* 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>
172 lines
5.7 KiB
TypeScript
172 lines
5.7 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
|
import {
|
|
renderWithMantine,
|
|
screen,
|
|
waitFor,
|
|
} from "../../../test/renderWithMantine";
|
|
import { ResourceLink } from "./ResourceLink";
|
|
|
|
const URI = "file:///docs/readme.md";
|
|
|
|
const readResult = (text: string): ReadResourceResult => ({
|
|
contents: [{ uri: URI, mimeType: "text/plain", text }],
|
|
});
|
|
|
|
describe("ResourceLink", () => {
|
|
it("renders uri, name, description, and mimeType", () => {
|
|
renderWithMantine(
|
|
<ResourceLink
|
|
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("is not interactive without onReadResource", () => {
|
|
renderWithMantine(<ResourceLink uri={URI} />);
|
|
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("reads the resource on demand and renders the result inline", async () => {
|
|
const user = userEvent.setup();
|
|
const onReadResource = vi.fn().mockResolvedValue(readResult("hello body"));
|
|
renderWithMantine(
|
|
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
|
);
|
|
|
|
const button = screen.getByRole("button", {
|
|
name: `Expand resource ${URI}`,
|
|
});
|
|
expect(button).toHaveAttribute("aria-expanded", "false");
|
|
|
|
await user.click(button);
|
|
|
|
expect(onReadResource).toHaveBeenCalledWith(URI);
|
|
await waitFor(() =>
|
|
expect(screen.getByText("Resource:")).toBeInTheDocument(),
|
|
);
|
|
// The full read result is rendered as formatted JSON.
|
|
expect(screen.getByText(/"hello body"/)).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
|
|
).toHaveAttribute("aria-expanded", "true");
|
|
});
|
|
|
|
it("collapses and re-expands without re-reading (result cached)", async () => {
|
|
const user = userEvent.setup();
|
|
const onReadResource = vi.fn().mockResolvedValue(readResult("cached body"));
|
|
renderWithMantine(
|
|
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
|
);
|
|
|
|
await user.click(
|
|
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
|
);
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/"cached body"/)).toBeInTheDocument(),
|
|
);
|
|
|
|
// Collapse — content is hidden.
|
|
await user.click(
|
|
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
|
|
);
|
|
expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
|
|
|
|
// Re-expand — content returns without a second read.
|
|
await user.click(
|
|
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
|
);
|
|
expect(screen.getByText(/"cached body"/)).toBeInTheDocument();
|
|
expect(onReadResource).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("shows an alert when the read fails", async () => {
|
|
const user = userEvent.setup();
|
|
const onReadResource = vi.fn().mockRejectedValue(new Error("nope"));
|
|
renderWithMantine(
|
|
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
|
);
|
|
|
|
await user.click(screen.getByRole("button"));
|
|
await waitFor(() =>
|
|
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
|
|
);
|
|
expect(screen.getByText("nope")).toBeInTheDocument();
|
|
});
|
|
|
|
it("retries the read on re-expand after a failure", async () => {
|
|
const user = userEvent.setup();
|
|
const onReadResource = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("transient"))
|
|
.mockResolvedValueOnce(readResult("recovered body"));
|
|
renderWithMantine(
|
|
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
|
);
|
|
|
|
// First expand fails.
|
|
await user.click(
|
|
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
|
);
|
|
await waitFor(() =>
|
|
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
|
|
);
|
|
|
|
// Collapse, then re-expand — the read is retried (error is not cached).
|
|
await user.click(
|
|
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
|
|
);
|
|
await user.click(
|
|
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/"recovered body"/)).toBeInTheDocument(),
|
|
);
|
|
expect(
|
|
screen.queryByText("Failed to read resource"),
|
|
).not.toBeInTheDocument();
|
|
expect(onReadResource).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not fire a second read when toggled while a read is in flight", async () => {
|
|
const user = userEvent.setup();
|
|
let resolveRead: (value: ReadResourceResult) => void = () => {};
|
|
const onReadResource = vi.fn().mockImplementation(
|
|
() =>
|
|
new Promise<ReadResourceResult>((resolve) => {
|
|
resolveRead = resolve;
|
|
}),
|
|
);
|
|
renderWithMantine(
|
|
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
|
);
|
|
|
|
// Expand — read is in flight (loading), then collapse and re-expand.
|
|
await user.click(
|
|
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
|
);
|
|
await user.click(
|
|
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
|
|
);
|
|
await user.click(
|
|
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
|
);
|
|
|
|
// Still only the original in-flight read — no redundant fetch.
|
|
expect(onReadResource).toHaveBeenCalledTimes(1);
|
|
|
|
resolveRead(readResult("in flight body"));
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/"in flight body"/)).toBeInTheDocument(),
|
|
);
|
|
});
|
|
});
|