Redesign Resources screen: template panel, subscriptions, hover states, and callback architecture

- Rename ResourceTemplateInput to ResourceTemplatePanel with panel layout
  (title header, URI with live variable preview, labeled inputs, Read Resource button)
- ResourceTemplatePanel manages its own variable state via useState;
  single onReadResource(uri) callback returns fully resolved URI
- Extract ResourceSubscribedItem group component with name, timestamp, and
  subtle Unsubscribe button
- Add hover state to listItem variant via theme classNames + App.css pseudo-selector
- Add Refresh button to ResourcePreviewPanel
- Refactor ResourcesScreen callbacks: onSelectUri, onSelectTemplate,
  onReadResource, onSubscribeResource, onUnsubscribeResource replace
  per-item onClick/onUnsubscribe props; screen wires callbacks to children
- Define data-only types (ResourceItem, TemplateListItem, SubscriptionItem,
  SelectedResource, SelectedTemplate) separating data from callbacks
- Extract named .withProps() subcomponents (FooterRow, AnnotationGroup,
  ActionGroup, Spacer) for AGENTS.md compliance
- Update all stories (ResourceTemplatePanel, ResourcesScreen, ConnectedView)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-03-29 20:04:25 -04:00
commit a0e763a315
2 changed files with 520 additions and 200 deletions

View file

@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ResourceTemplatePanel } from "./ResourceTemplatePanel";
const meta: Meta<typeof ResourceTemplatePanel> = {
title: "Groups/ResourceTemplatePanel",
component: ResourceTemplatePanel,
args: {
onReadResource: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ResourceTemplatePanel>;
export const SingleVariable: Story = {
args: {
name: "User Profile",
uriTemplate: "file:///users/{userId}/profile",
description: "Fetch a user profile by their unique identifier.",
},
};
export const MultipleVariables: Story = {
args: {
name: "Table Row",
title: "Database Table Row",
uriTemplate: "db://tables/{tableName}/rows/{rowId}",
description: "Access a specific row in a database table.",
},
};
export const WithAnnotations: Story = {
args: {
name: "Dynamic Text Resource",
uriTemplate: "resource://dynamic/{resourceId}",
description:
"Plaintext dynamic resource fabricated from the {resourceId} variable, which must be an integer.",
annotations: { audience: "developer", priority: 0.8 },
},
};
export const NoDescription: Story = {
args: {
name: "Simple Template",
uriTemplate: "file:///data/{filename}",
},
};

View file

@ -0,0 +1,146 @@
import { useState, useMemo } from "react";
import { Button, Group, Stack, Text, TextInput, Title } from "@mantine/core";
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
import { CopyButton } from "../../elements/CopyButton/CopyButton";
export interface ResourceTemplatePanelProps {
name: string;
title?: string;
uriTemplate: string;
description?: string;
annotations?: { audience?: string; priority?: number };
onReadResource: (uri: string) => void;
}
function parseVariableNames(uriTemplate: string): string[] {
const names: string[] = [];
const regex = /\{(\w+)\}/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(uriTemplate)) !== null) {
names.push(match[1]);
}
return names;
}
function resolveUri(
uriTemplate: string,
variables: Record<string, string>,
): string {
return uriTemplate.replace(/\{(\w+)\}/g, (_, key: string) => variables[key]);
}
function previewUri(
uriTemplate: string,
variables: Record<string, string>,
): string {
return uriTemplate.replace(/\{(\w+)\}/g, (match, key: string) =>
variables[key]?.length > 0 ? variables[key] : match,
);
}
function priorityLabel(priority: number): string {
if (priority >= 0.7) return "priority: high";
if (priority >= 0.4) return "priority: medium";
return "priority: low";
}
const HeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
});
const UriGroup = Group.withProps({
gap: "xs",
wrap: "nowrap",
});
const UriText = Text.withProps({
size: "sm",
c: "blue",
truncate: "end",
});
const DescriptionText = Text.withProps({
size: "sm",
c: "dimmed",
});
const FooterRow = Group.withProps({
justify: "space-between",
});
const AnnotationGroup = Group.withProps({
gap: "xs",
});
export function ResourceTemplatePanel({
name,
title,
uriTemplate,
description,
annotations,
onReadResource,
}: ResourceTemplatePanelProps) {
const variableNames = useMemo(
() => parseVariableNames(uriTemplate),
[uriTemplate],
);
const [variables, setVariables] = useState<Record<string, string>>(() =>
Object.fromEntries(variableNames.map((n) => [n, ""])),
);
function handleVariableChange(varName: string, value: string) {
setVariables((prev) => ({ ...prev, [varName]: value }));
}
const canSubmit = variableNames.every((n) => variables[n]?.length > 0);
function handleSubmit() {
onReadResource(resolveUri(uriTemplate, variables));
}
return (
<Stack gap="md">
<HeaderRow>
<Title order={4}>{title ?? name} Template</Title>
<UriGroup>
<UriText>{previewUri(uriTemplate, variables)}</UriText>
<CopyButton value={previewUri(uriTemplate, variables)} />
</UriGroup>
</HeaderRow>
{description && <DescriptionText>{description}</DescriptionText>}
<Stack gap="sm">
{variableNames.map((varName) => (
<TextInput
key={varName}
label={varName}
placeholder={`Enter ${varName}`}
value={variables[varName] ?? ""}
onChange={(e) =>
handleVariableChange(varName, e.currentTarget.value)
}
/>
))}
</Stack>
<FooterRow>
<AnnotationGroup>
{annotations?.audience && (
<AnnotationBadge label={annotations.audience} variant="audience" />
)}
{annotations?.priority !== undefined && (
<AnnotationBadge
label={priorityLabel(annotations.priority)}
variant="priority"
/>
)}
</AnnotationGroup>
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
Read Resource
</Button>
</FooterRow>
</Stack>
);
}