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

View file

@ -0,0 +1,174 @@
import { useState } from "react";
import {
Alert,
Loader,
Paper,
ScrollArea,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { ResourceLinkInfo } from "../../elements/ResourceLinkInfo/ResourceLinkInfo";
export interface ResourceLinkProps {
/** The linked resource's URI (always shown). */
uri: string;
/** Optional human-friendly name shown beneath the URI. */
name?: string;
/** Optional description shown beneath the name. */
description?: string;
/** Optional MIME type shown as a badge. */
mimeType?: string;
/**
* Read-on-demand handler. When provided, the card becomes expandable: the
* first expand calls this with the link's `uri` and renders the returned
* read result inline. Omit to render a static, non-expandable card.
*/
onReadResource?: (uri: string) => Promise<ReadResourceResult>;
}
const LinkCard = Paper.withProps({
withBorder: true,
p: "sm",
radius: "md",
});
const HeaderButton = UnstyledButton.withProps({
w: "100%",
ta: "left",
});
const ExpandedSection = Stack.withProps({
gap: "xs",
mt: "xs",
});
// Caps the inline read result so a large resource scrolls within the card
// instead of pushing the page down — mirrors V1's bounded resource view.
// `Autosize` sizes to the content up to `mah`, then scrolls; a plain
// ScrollArea would need a definite height to scroll, which this card (sized to
// its content) does not provide.
const ResultScroll = ScrollArea.Autosize.withProps({
mah: 400,
type: "auto",
scrollbars: "y",
offsetScrollbars: true,
});
const ResourceLabel = Text.withProps({
size: "sm",
fw: 600,
c: "green",
});
const LoadingText = Text.withProps({
size: "sm",
c: "dimmed",
});
/**
* Expandable card for a `resource_link` content block. Renders the link's
* metadata via {@link ResourceLinkInfo} and — when `onReadResource` is supplied
* — an expand affordance that reads the linked resource on demand and renders
* the full read result inline as formatted JSON (via {@link ContentViewer}).
* The fetched result is cached so collapsing and re-expanding does not re-read.
*/
export function ResourceLink({
uri,
name,
description,
mimeType,
onReadResource,
}: ResourceLinkProps) {
const [expanded, setExpanded] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ReadResourceResult | null>(null);
const [error, setError] = useState<string | null>(null);
const expandable = Boolean(onReadResource);
async function toggle() {
if (!onReadResource) return;
if (expanded) {
setExpanded(false);
return;
}
setExpanded(true);
// Only a successful result is cached; re-expanding after an error retries
// the read so a transient failure isn't permanent.
if (result !== null) return;
// Don't fire a second read if one is already in flight (rapid toggle).
if (loading) return;
setError(null);
setLoading(true);
try {
setResult(await onReadResource(uri));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
const Chevron = expanded ? RiArrowDownSLine : RiArrowRightSLine;
const action = expandable ? (
loading ? (
<Loader size="xs" />
) : (
<Chevron size={16} aria-hidden />
)
) : undefined;
const info = (
<ResourceLinkInfo
uri={uri}
name={name}
description={description}
mimeType={mimeType}
action={action}
/>
);
return (
<LinkCard>
{expandable ? (
<HeaderButton
onClick={() => void toggle()}
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
>
{info}
</HeaderButton>
) : (
info
)}
{expanded && (
<ExpandedSection>
{loading ? (
<LoadingText>Loading resource…</LoadingText>
) : error !== null ? (
<Alert color="red" variant="light" title="Failed to read resource">
{error}
</Alert>
) : result !== null ? (
<>
<ResourceLabel>Resource:</ResourceLabel>
<ResultScroll>
<ContentViewer
block={{
type: "text",
text: JSON.stringify(result, null, 2),
}}
copyable
/>
</ResultScroll>
</>
) : null}
</ExpandedSection>
)}
</LinkCard>
);
}