* 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
6 changed files with 728 additions and 17 deletions
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ResourceLinkInfo } from "./ResourceLinkInfo";
|
||||
|
||||
const meta: Meta<typeof ResourceLinkInfo> = {
|
||||
title: "Elements/ResourceLinkInfo",
|
||||
component: ResourceLinkInfo,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ResourceLinkInfo>;
|
||||
|
||||
export const Full: Story = {
|
||||
args: {
|
||||
uri: "file:///docs/readme.md",
|
||||
name: "Readme",
|
||||
description: "Project documentation",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
};
|
||||
|
||||
export const UriOnly: Story = {
|
||||
args: {
|
||||
uri: "demo://resource/dynamic/text/2",
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { ResourceLinkInfo } from "./ResourceLinkInfo";
|
||||
|
||||
const URI = "file:///docs/readme.md";
|
||||
|
||||
describe("ResourceLinkInfo", () => {
|
||||
it("renders uri, name, description, and mimeType", () => {
|
||||
renderWithMantine(
|
||||
<ResourceLinkInfo
|
||||
uri={URI}
|
||||
name="Readme"
|
||||
description="The project readme"
|
||||
mimeType="text/markdown"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(URI)).toBeInTheDocument();
|
||||
expect(screen.getByText("Readme")).toBeInTheDocument();
|
||||
expect(screen.getByText("The project readme")).toBeInTheDocument();
|
||||
expect(screen.getByText("text/markdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only the uri when optional fields are absent", () => {
|
||||
renderWithMantine(<ResourceLinkInfo uri={URI} />);
|
||||
expect(screen.getByText(URI)).toBeInTheDocument();
|
||||
expect(screen.queryByText("text/markdown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the action slot when provided", () => {
|
||||
renderWithMantine(
|
||||
<ResourceLinkInfo uri={URI} action={<span>chevron</span>} />,
|
||||
);
|
||||
expect(screen.getByText("chevron")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Badge, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
export interface ResourceLinkInfoProps {
|
||||
/** 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;
|
||||
/**
|
||||
* Optional trailing element placed at the end of the URI row — e.g. an
|
||||
* expand/collapse indicator supplied by an interactive wrapper.
|
||||
*/
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
const HeaderStack = Stack.withProps({
|
||||
gap: 4,
|
||||
});
|
||||
|
||||
const UriRow = Group.withProps({
|
||||
justify: "space-between",
|
||||
wrap: "nowrap",
|
||||
gap: "xs",
|
||||
align: "flex-start",
|
||||
});
|
||||
|
||||
const UriText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "blue",
|
||||
ff: "monospace",
|
||||
variant: "monoBreak",
|
||||
flex: 1,
|
||||
miw: 0,
|
||||
});
|
||||
|
||||
const MetaGroup = Group.withProps({
|
||||
gap: "xs",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const MimeBadge = Badge.withProps({
|
||||
size: "sm",
|
||||
variant: "light",
|
||||
color: "blue",
|
||||
// MIME types are conventionally lowercase; keep them as-is rather than
|
||||
// letting Badge's default uppercase transform mangle them.
|
||||
tt: "none",
|
||||
});
|
||||
|
||||
const NameText = Text.withProps({
|
||||
size: "sm",
|
||||
fw: 600,
|
||||
});
|
||||
|
||||
const DescriptionText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
});
|
||||
|
||||
/**
|
||||
* Pure-display metadata for a `resource_link`: the URI (monospace, link-styled),
|
||||
* optional name / description, and a MIME-type badge. The optional `action`
|
||||
* slot lets an interactive wrapper (e.g. {@link ResourceLink}) place an
|
||||
* expand/collapse indicator in the URI row.
|
||||
*/
|
||||
export function ResourceLinkInfo({
|
||||
uri,
|
||||
name,
|
||||
description,
|
||||
mimeType,
|
||||
action,
|
||||
}: ResourceLinkInfoProps) {
|
||||
return (
|
||||
<HeaderStack>
|
||||
<UriRow>
|
||||
<UriText>{uri}</UriText>
|
||||
<MetaGroup>
|
||||
{mimeType && <MimeBadge>{mimeType}</MimeBadge>}
|
||||
{action}
|
||||
</MetaGroup>
|
||||
</UriRow>
|
||||
{name && <NameText>{name}</NameText>}
|
||||
{description && <DescriptionText>{description}</DescriptionText>}
|
||||
</HeaderStack>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue