feat(web): expandable resource_link with read-on-demand (#1449) (#1456)

* feat(web): expandable resource_link with read-on-demand (#1449)

Replace the placeholder `resource_link` rendering in ContentViewer with a
dedicated, Mantine-native `ResourceLink` element that mirrors V1's behavior:

- Shows uri (monospace, link-styled), optional name/description, and a
  mimeType badge.
- Expand/collapse affordance (chevron + loading spinner) that is keyboard
  accessible (native button: role, aria-expanded, aria-label, Enter/Space).
- On first expand, reads the linked resource on demand via an injected
  callback and renders the full read result inline as formatted JSON under a
  green "Resource:" label, capped to a max height with scroll for large
  results. The fetched result is cached across collapse/expand.

The read handler is threaded as a "dumb component + callback as prop" through
ToolResultPanel → ToolsScreen → InspectorView, with App supplying a handler
that returns the `ReadResourceResult` from `inspectorClient.readResource`.

Also extract the shared `resourceContentsToBlock` helper into
`utils/resourceContents.ts` (previously private to ResourcePreviewPanel).

Tests: unit coverage for ResourceLink (collapsed/expanded/error/cache) and
the ContentViewer wiring, plus Storybook stories with play functions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): scroll large resource_link read results within the card

A plain ScrollArea with only `mah` does not scroll when its parent (the
content-sized card) has no definite height, so a large read result overflowed
the card instead of scrolling. Switch to ScrollArea.Autosize, which grows with
content up to the max height and then scrolls. Adds a LargeResult story
covering the bounded-scroll behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): make ResourceLink a group; extract ResourceLinkInfo element

ResourceLink is stateful (expand/loading/cached result) and fetches data via
a callback — group-like behavior, and the only `elements/` component using
useState. Move it to `groups/` and split out the pure-display metadata as a
new `elements/ResourceLinkInfo` element (uri/name/description/mime + an
optional action slot).

This keeps the layer dependencies clean (no element→group import, which
moving ResourceLink alone would have introduced):
- ContentViewer (element) renders ResourceLinkInfo (element) statically for
  `resource_link` blocks and no longer takes an onReadResource prop.
- ToolResultPanel (group) renders the interactive ResourceLink (group) for
  `resource_link` blocks, ContentViewer for the rest.
- ResourceLink (group) composes ResourceLinkInfo + ContentViewer.

Also reverts the now-single-consumer resourceContents.ts extraction, restoring
ResourcePreviewPanel's local toContentBlock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): make ResourceLink read retryable and guard double-fetch

Address PR review: a failed read is no longer cached as terminal — only
a successful result is cached, so collapsing and re-expanding retries the
read after a transient failure. Add a loading guard so toggling while a
read is in flight does not fire a redundant second read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(web): prettier formatting for ResourceLink test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: remove accidentally-committed build artifacts; gitignore clients/*/build

A stray 'git add -A' swept 8 compiled build files into d25c9382. Remove
them from tracking and add clients/*/build to .gitignore so client build
output is never committed again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-13 17:15:25 -04:00 • committed by GitHub
commit 8d4363d0ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 728 additions and 17 deletions

View file

@ -74,3 +74,15 @@ export const JsonWithCopy: Story = {
copyable: true,
},
};
export const ResourceLinkBlock: Story = {
args: {
block: {
type: "resource_link",
uri: "file:///docs/readme.md",
name: "Readme",
description: "Project documentation",
mimeType: "text/markdown",
},
},
};

View file

@ -99,6 +99,21 @@ describe("ContentViewer", () => {
expect(screen.getByText("ui://app")).toBeInTheDocument();
});
it("renders a resource_link statically (no expand control)", () => {
const block: ContentBlock = {
type: "resource_link",
uri: "ui://app",
name: "Cool App",
description: "An app",
mimeType: "text/html",
};
renderWithMantine(<ContentViewer block={block} />);
expect(screen.getByText("Cool App")).toBeInTheDocument();
expect(screen.getByText("An app")).toBeInTheDocument();
expect(screen.getByText("text/html")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("renders nothing for unknown block types", () => {
renderWithMantine(<ContentViewer block={{ type: "unknown" } as never} />);
expect(screen.queryByRole("img")).not.toBeInTheDocument();

View file

@ -1,8 +1,9 @@
import { Code, Flex, Image, Stack, Text } from "@mantine/core";
import { Code, Flex, Image, Stack } from "@mantine/core";
import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CopyButton } from "../CopyButton/CopyButton";
import { ResourceLinkInfo } from "../ResourceLinkInfo/ResourceLinkInfo";
export interface ContentViewerProps {
block: ContentBlock;
@ -131,12 +132,16 @@ export function ContentViewer({
</Stack>
);
case "resource_link":
// Static metadata only. The interactive, read-on-demand presentation
// lives in the `groups/ResourceLink` group, rendered by content-block
// hosts (e.g. ToolResultPanel) that can supply a read handler.
return (
<Stack gap="xs">
<Text size="sm" c="blue">
{block.name ?? block.uri}
</Text>
</Stack>
<ResourceLinkInfo
uri={block.uri}
name={block.name}
description={block.description}
mimeType={block.mimeType}
/>
);
default:
return null;