* 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:
parent
a10ced988c
commit
8d4363d0ea
1 changed files with 728 additions and 17 deletions
174
clients/web/src/components/groups/ResourceLink/ResourceLink.tsx
Normal file
174
clients/web/src/components/groups/ResourceLink/ResourceLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue