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
8 changed files with 520 additions and 200 deletions

View file

@ -6,6 +6,7 @@ const meta: Meta<typeof ResourcePreviewPanel> = {
title: "Groups/ResourcePreviewPanel",
component: ResourcePreviewPanel,
args: {
onRefresh: fn(),
onSubscribe: fn(),
onUnsubscribe: fn(),
},

View file

@ -1,4 +1,4 @@
import { Flex, Group, Stack, Text, Title } from "@mantine/core";
import { Button, Flex, Group, Stack, Text, Title } from "@mantine/core";
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { CopyButton } from "../../elements/CopyButton/CopyButton";
@ -11,6 +11,7 @@ export interface ResourcePreviewPanelProps {
content: string;
lastUpdated?: string;
isSubscribed: boolean;
onRefresh: () => void;
onSubscribe: () => void;
onUnsubscribe: () => void;
}
@ -62,6 +63,20 @@ const MimeText = Text.withProps({
c: "dimmed",
});
const FooterRow = Group.withProps({
justify: "space-between",
});
const AnnotationGroup = Group.withProps({
gap: "xs",
});
const ActionGroup = Group.withProps({
gap: "xs",
});
const Spacer = Flex.withProps({});
export function ResourcePreviewPanel({
uri,
mimeType,
@ -69,6 +84,7 @@ export function ResourcePreviewPanel({
content,
lastUpdated,
isSubscribed,
onRefresh,
onSubscribe,
onUnsubscribe,
}: ResourcePreviewPanelProps) {
@ -91,12 +107,12 @@ export function ResourcePreviewPanel({
{lastUpdated ? (
<TimestampText>{formatLastUpdated(lastUpdated)}</TimestampText>
) : (
<Flex />
<Spacer />
)}
<MimeText>{mimeType}</MimeText>
</MetaRow>
<Group justify="space-between">
<Group gap="xs">
<FooterRow>
<AnnotationGroup>
{annotations?.audience && (
<AnnotationBadge label={annotations.audience} variant="audience" />
)}
@ -106,12 +122,17 @@ export function ResourcePreviewPanel({
variant="priority"
/>
)}
</Group>
<SubscribeButton
subscribed={isSubscribed}
onToggle={isSubscribed ? onUnsubscribe : onSubscribe}
/>
</Group>
</AnnotationGroup>
<ActionGroup>
<Button variant="subtle" size="sm" onClick={onRefresh}>
Refresh
</Button>
<SubscribeButton
subscribed={isSubscribed}
onToggle={isSubscribed ? onUnsubscribe : onSubscribe}
/>
</ActionGroup>
</FooterRow>
</Stack>
);
}

View file

@ -0,0 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ResourceSubscribedItem } from "./ResourceSubscribedItem";
const meta: Meta<typeof ResourceSubscribedItem> = {
title: "Groups/ResourceSubscribedItem",
component: ResourceSubscribedItem,
args: {
onUnsubscribe: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ResourceSubscribedItem>;
export const WithTimestamp: Story = {
args: {
name: "config.json",
lastUpdated: "2026-03-17T10:30:00Z",
},
};
export const WithoutTimestamp: Story = {
args: {
name: "schema.sql",
},
};

View file

@ -0,0 +1,43 @@
import { Button, Group, Stack, Text } from "@mantine/core";
export interface ResourceSubscribedItemProps {
name: string;
lastUpdated?: string;
onUnsubscribe: () => void;
}
const NameText = Text.withProps({
size: "sm",
fw: 500,
});
const TimestampText = Text.withProps({
size: "xs",
c: "dimmed",
});
const SubtleButton = Button.withProps({
variant: "subtle",
size: "xs",
});
const ItemRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
});
export function ResourceSubscribedItem({
name,
lastUpdated,
onUnsubscribe,
}: ResourceSubscribedItemProps) {
return (
<ItemRow>
<Stack gap={2}>
<NameText>{name}</NameText>
{lastUpdated && <TimestampText>{lastUpdated}</TimestampText>}
</Stack>
<SubtleButton onClick={onUnsubscribe}>Unsubscribe</SubtleButton>
</ItemRow>
);
}

View file

@ -1,39 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ResourceTemplateInput } from "./ResourceTemplateInput";
const meta: Meta<typeof ResourceTemplateInput> = {
title: "Groups/ResourceTemplateInput",
component: ResourceTemplateInput,
args: {
onVariableChange: fn(),
onSubmit: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ResourceTemplateInput>;
export const SingleVariable: Story = {
args: {
template: "user/{id}",
variables: {},
},
};
export const MultipleVariables: Story = {
args: {
template: "org/{orgId}/user/{userId}",
variables: {},
},
};
export const FilledIn: Story = {
args: {
template: "org/{orgId}/user/{userId}",
variables: {
orgId: "acme-corp",
userId: "user-42",
},
},
};

View file

@ -1,74 +0,0 @@
import { Button, Group, Text, TextInput } from "@mantine/core";
export interface ResourceTemplateInputProps {
template: string;
variables: Record<string, string>;
onVariableChange: (name: string, value: string) => void;
onSubmit: () => void;
}
interface TemplatePart {
type: "static" | "variable";
value: string;
}
function parseTemplate(template: string): TemplatePart[] {
const parts: TemplatePart[] = [];
const regex = /\{(\w+)\}/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(template)) !== null) {
if (match.index > lastIndex) {
parts.push({
type: "static",
value: template.slice(lastIndex, match.index),
});
}
parts.push({ type: "variable", value: match[1] });
lastIndex = regex.lastIndex;
}
if (lastIndex < template.length) {
parts.push({ type: "static", value: template.slice(lastIndex) });
}
return parts;
}
const StaticPart = Text.withProps({
size: "sm",
pb: 6,
});
export function ResourceTemplateInput({
template,
variables,
onVariableChange,
onSubmit,
}: ResourceTemplateInputProps) {
const parts = parseTemplate(template);
return (
<Group gap="xs" align="flex-end">
{parts.map((part, index) =>
part.type === "static" ? (
<StaticPart key={index}>{part.value}</StaticPart>
) : (
<TextInput
key={part.value}
size="sm"
placeholder={part.value}
value={variables[part.value] ?? ""}
onChange={(e) =>
onVariableChange(part.value, e.currentTarget.value)
}
/>
),
)}
<Button size="sm" onClick={onSubmit}>
Go
</Button>
</Group>
);
}

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>
);
}