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

@ -1,9 +1,12 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ResourcesScreen } from "./ResourcesScreen";
import type { ResourceListItemProps } from "../../groups/ResourceListItem/ResourceListItem";
import type { ResourceTemplateInputProps } from "../../groups/ResourceTemplateInput/ResourceTemplateInput";
import type { ResourcePreviewPanelProps } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
import type {
ResourceItem,
TemplateListItem,
SelectedResource,
SelectedTemplate,
} from "./ResourcesScreen";
const meta: Meta<typeof ResourcesScreen> = {
title: "Screens/ResourcesScreen",
@ -13,7 +16,11 @@ const meta: Meta<typeof ResourcesScreen> = {
searchText: "",
onSearchChange: fn(),
onRefreshList: fn(),
onSelectResource: fn(),
onSelectUri: fn(),
onSelectTemplate: fn(),
onReadResource: fn(),
onSubscribeResource: fn(),
onUnsubscribeResource: fn(),
listChanged: false,
subscriptions: [],
templates: [],
@ -23,45 +30,41 @@ const meta: Meta<typeof ResourcesScreen> = {
export default meta;
type Story = StoryObj<typeof ResourcesScreen>;
const sampleResources: ResourceListItemProps[] = [
const sampleResources: ResourceItem[] = [
{
name: "config.json",
uri: "file:///config.json",
annotations: { audience: "developer", priority: 0.8 },
selected: false,
onClick: fn(),
},
{
name: "README.md",
uri: "file:///README.md",
selected: false,
onClick: fn(),
},
{
name: "schema.sql",
uri: "file:///schema.sql",
annotations: { priority: 0.5 },
selected: false,
onClick: fn(),
},
];
const sampleTemplates: ResourceTemplateInputProps[] = [
const sampleTemplates: TemplateListItem[] = [
{
template: "file:///users/{userId}/profile",
variables: { userId: "" },
onVariableChange: fn(),
onSubmit: fn(),
name: "User Profile",
uriTemplate: "file:///users/{userId}/profile",
selected: false,
},
{
template: "db://tables/{tableName}/rows/{rowId}",
variables: { tableName: "", rowId: "" },
onVariableChange: fn(),
onSubmit: fn(),
name: "Table Row",
title: "Database Table Row",
uriTemplate: "db://tables/{tableName}/rows/{rowId}",
selected: false,
},
];
const selectedResourceProps: ResourcePreviewPanelProps = {
const selectedResourceData: SelectedResource = {
uri: "file:///config.json",
mimeType: "application/json",
annotations: { audience: "developer", priority: 0.8 },
@ -76,8 +79,12 @@ const selectedResourceProps: ResourcePreviewPanelProps = {
),
lastUpdated: "2026-03-17T10:30:00Z",
isSubscribed: true,
onSubscribe: fn(),
onUnsubscribe: fn(),
};
const selectedTemplateData: SelectedTemplate = {
name: "User Profile",
uriTemplate: "file:///users/{userId}/profile",
description: "Fetch a user profile by their unique identifier.",
};
export const WithResources: Story = {
@ -91,9 +98,13 @@ export const ResourceSelected: Story = {
resources: sampleResources.map((r) =>
r.uri === "file:///config.json" ? { ...r, selected: true } : r,
),
selectedResource: selectedResourceProps,
selectedResource: selectedResourceData,
subscriptions: [
{ name: "config.json", lastUpdated: "2026-03-17T10:30:00Z" },
{
name: "config.json",
uri: "file:///config.json",
lastUpdated: "2026-03-17T10:30:00Z",
},
],
},
};
@ -105,6 +116,42 @@ export const WithTemplates: Story = {
},
};
export const TemplateSelected: Story = {
args: {
resources: sampleResources,
templates: sampleTemplates.map((t) =>
t.uriTemplate === "file:///users/{userId}/profile"
? { ...t, selected: true }
: t,
),
selectedTemplate: selectedTemplateData,
},
};
export const TemplateWithResource: Story = {
args: {
resources: sampleResources,
templates: sampleTemplates.map((t) =>
t.uriTemplate === "file:///users/{userId}/profile"
? { ...t, selected: true }
: t,
),
selectedTemplate: selectedTemplateData,
selectedResource: {
uri: "file:///users/42/profile",
mimeType: "application/json",
annotations: { audience: "developer", priority: 0.8 },
content: JSON.stringify(
{ id: 42, name: "Alice", email: "alice@example.com" },
null,
2,
),
lastUpdated: "2026-03-17T11:15:00Z",
isSubscribed: false,
},
},
};
export const Empty: Story = {
args: {
resources: [],

View file

@ -11,26 +11,61 @@ import {
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ResourceListItem } from "../../groups/ResourceListItem/ResourceListItem";
import { ResourcePreviewPanel } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
import { ResourceTemplateInput } from "../../groups/ResourceTemplateInput/ResourceTemplateInput";
import type { ResourceListItemProps } from "../../groups/ResourceListItem/ResourceListItem";
import type { ResourcePreviewPanelProps } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
import type { ResourceTemplateInputProps } from "../../groups/ResourceTemplateInput/ResourceTemplateInput";
import { ResourceSubscribedItem } from "../../groups/ResourceSubscribedItem/ResourceSubscribedItem";
import { ResourceTemplatePanel } from "../../groups/ResourceTemplatePanel/ResourceTemplatePanel";
export interface SubscriptionInfo {
export interface ResourceItem {
name: string;
uri: string;
annotations?: { audience?: string; priority?: number };
selected: boolean;
}
export interface TemplateListItem {
name: string;
title?: string;
uriTemplate: string;
selected: boolean;
}
export interface SubscriptionItem {
name: string;
uri: string;
lastUpdated?: string;
}
export interface SelectedResource {
uri: string;
mimeType: string;
annotations?: { audience?: string; priority?: number };
content: string;
lastUpdated?: string;
isSubscribed: boolean;
}
export interface SelectedTemplate {
name: string;
title?: string;
uriTemplate: string;
description?: string;
annotations?: { audience?: string; priority?: number };
}
export interface ResourcesScreenProps {
resources: ResourceListItemProps[];
templates: ResourceTemplateInputProps[];
subscriptions: SubscriptionInfo[];
selectedResource?: ResourcePreviewPanelProps;
resources: ResourceItem[];
templates: TemplateListItem[];
subscriptions: SubscriptionItem[];
selectedResource?: SelectedResource;
selectedTemplate?: SelectedTemplate;
listChanged: boolean;
searchText: string;
onSearchChange: (text: string) => void;
onRefreshList: () => void;
onSelectResource: (uri: string) => void;
onSelectUri: (uri: string) => void;
onSelectTemplate: (uriTemplate: string) => void;
onReadResource: (uri: string) => void;
onSubscribeResource: (uri: string) => void;
onUnsubscribeResource: (uri: string) => void;
}
const PageContainer = Container.withProps({
@ -55,18 +90,12 @@ const EmptyState = Text.withProps({
py: "xl",
});
const SubscriptionMeta = Text.withProps({
span: true,
c: "dimmed",
size: "xs",
});
function formatSectionCount(label: string, count: number): string {
return `${label} (${count})`;
}
function formatLastUpdated(lastUpdated: string): string {
return ` — ${lastUpdated}`;
function templateDisplayName(item: TemplateListItem): string {
return item.title ?? item.name;
}
export function ResourcesScreen({
@ -74,10 +103,16 @@ export function ResourcesScreen({
templates,
subscriptions,
selectedResource,
selectedTemplate,
listChanged,
searchText,
onSearchChange,
onRefreshList,
onSelectUri,
onSelectTemplate,
onReadResource,
onSubscribeResource,
onUnsubscribeResource,
}: ResourcesScreenProps) {
const filteredResources = resources.filter((r) =>
r.name.toLowerCase().includes(searchText.toLowerCase()),
@ -113,7 +148,14 @@ export function ResourcesScreen({
<Accordion.Panel>
<Stack gap="xs">
{filteredResources.map((resource) => (
<ResourceListItem key={resource.uri} {...resource} />
<ResourceListItem
key={resource.uri}
name={resource.name}
uri={resource.uri}
annotations={resource.annotations}
selected={resource.selected}
onClick={() => onSelectUri(resource.uri)}
/>
))}
</Stack>
</Accordion.Panel>
@ -126,9 +168,12 @@ export function ResourcesScreen({
<Accordion.Panel>
<Stack gap="xs">
{templates.map((template) => (
<ResourceTemplateInput
key={template.template}
{...template}
<ResourceListItem
key={template.uriTemplate}
name={templateDisplayName(template)}
uri={template.uriTemplate}
selected={template.selected}
onClick={() => onSelectTemplate(template.uriTemplate)}
/>
))}
</Stack>
@ -142,14 +187,12 @@ export function ResourcesScreen({
<Accordion.Panel>
<Stack gap="xs">
{subscriptions.map((sub) => (
<Text key={sub.name} size="sm">
{sub.name}
{sub.lastUpdated && (
<SubscriptionMeta>
{formatLastUpdated(sub.lastUpdated)}
</SubscriptionMeta>
)}
</Text>
<ResourceSubscribedItem
key={sub.name}
name={sub.name}
lastUpdated={sub.lastUpdated}
onUnsubscribe={() => onUnsubscribeResource(sub.uri)}
/>
))}
</Stack>
</Accordion.Panel>
@ -160,13 +203,47 @@ export function ResourcesScreen({
</Grid.Col>
<Grid.Col span={8}>
<DetailCard>
{selectedResource ? (
<ResourcePreviewPanel {...selectedResource} />
<Stack gap="md">
{selectedTemplate ? (
<>
<DetailCard>
<ResourceTemplatePanel
{...selectedTemplate}
onReadResource={onReadResource}
/>
</DetailCard>
{selectedResource && (
<DetailCard>
<ResourcePreviewPanel
{...selectedResource}
onRefresh={() => onReadResource(selectedResource.uri)}
onSubscribe={() =>
onSubscribeResource(selectedResource.uri)
}
onUnsubscribe={() =>
onUnsubscribeResource(selectedResource.uri)
}
/>
</DetailCard>
)}
</>
) : selectedResource ? (
<DetailCard>
<ResourcePreviewPanel
{...selectedResource}
onRefresh={() => onReadResource(selectedResource.uri)}
onSubscribe={() => onSubscribeResource(selectedResource.uri)}
onUnsubscribe={() =>
onUnsubscribeResource(selectedResource.uri)
}
/>
</DetailCard>
) : (
<EmptyState>Select a resource to preview</EmptyState>
<DetailCard>
<EmptyState>Select a resource to preview</EmptyState>
</DetailCard>
)}
</DetailCard>
</Stack>
</Grid.Col>
</Grid>
</PageContainer>