diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx index 1b04403fb..8c9ee3331 100644 --- a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx +++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx @@ -122,14 +122,16 @@ describe("ContentViewer", () => { type: "resource_link", uri: "ui://app", name: "Cool App", - description: "An app", mimeType: "text/html", }; renderWithMantine(); expect(screen.getByText("Cool App")).toBeInTheDocument(); - expect(screen.getByText("An app")).toBeInTheDocument(); expect(screen.getByText("text/html")).toBeInTheDocument(); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); + // The URI copy button is present, but there's no expand control. + expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /^Expand resource/ }), + ).not.toBeInTheDocument(); }); it("renders nothing for unknown block types", () => { diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx index 64087d9a1..fe46a4894 100644 --- a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx +++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx @@ -367,7 +367,6 @@ function BlockContent({ ); diff --git a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.test.tsx b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.test.tsx index 01e6c2bc4..c52614d62 100644 --- a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.test.tsx +++ b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.test.tsx @@ -23,4 +23,30 @@ describe("ExpandToggle", () => { await user.click(screen.getByRole("button", { name: "Expand" })); expect(onToggle).toHaveBeenCalledTimes(1); }); + + it("uses ariaLabel as the accessible name when provided", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Expand resource demo://r/1" }), + ).toBeInTheDocument(); + }); + + it("exposes aria-expanded=false when collapsed", () => { + renderWithMantine(); + expect(screen.getByRole("button")).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + + it("exposes aria-expanded=true when expanded", () => { + renderWithMantine(); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true"); + }); }); diff --git a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx index d8965012e..0d578281d 100644 --- a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx +++ b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx @@ -5,16 +5,28 @@ export interface ExpandToggleProps { /** Whether the owning entry is currently expanded. */ expanded: boolean; onToggle: () => void; + /** + * Overrides the accessible name (aria-label). Defaults to the tooltip text + * ("Expand"/"Collapse"). Pass a per-entry name (e.g. including the resource + * URI) when several toggles sit in one list, so assistive tech can tell them + * apart; the visible tooltip stays the plain verb. + */ + ariaLabel?: string; } /** * Icon toggle for a per-entry expand/collapse control (Protocol, Network, and * Task cards). Uses the same expand/collapse-vertical icons as the list-level * ListToggle: collapsed shows the expand icon, expanded shows the collapse - * icon. The aria-label stays "Expand"/"Collapse" so it reads the same as the - * text button it replaced. + * icon. The tooltip stays "Expand"/"Collapse" (the same verb as the text button + * it replaced); `aria-expanded` exposes the disclosure state and `ariaLabel` + * can distinguish sibling toggles. */ -export function ExpandToggle({ expanded, onToggle }: ExpandToggleProps) { +export function ExpandToggle({ + expanded, + onToggle, + ariaLabel, +}: ExpandToggleProps) { const Icon = expanded ? RiCollapseVerticalLine : RiExpandVerticalLine; const label = expanded ? "Collapse" : "Expand"; return ( @@ -23,7 +35,8 @@ export function ExpandToggle({ expanded, onToggle }: ExpandToggleProps) { variant="subtle" color="gray" size="md" - aria-label={label} + aria-label={ariaLabel ?? label} + aria-expanded={expanded} onClick={onToggle} > diff --git a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.stories.tsx b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.stories.tsx index cdbecd362..2b255c26f 100644 --- a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.stories.tsx +++ b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.stories.tsx @@ -13,7 +13,6 @@ export const Full: Story = { args: { uri: "file:///docs/readme.md", name: "Readme", - description: "Project documentation", mimeType: "text/markdown", }, }; diff --git a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.test.tsx b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.test.tsx index 1a3dedc6b..526b3727c 100644 --- a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.test.tsx +++ b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.test.tsx @@ -5,18 +5,12 @@ import { ResourceLinkInfo } from "./ResourceLinkInfo"; const URI = "file:///docs/readme.md"; describe("ResourceLinkInfo", () => { - it("renders uri, name, description, and mimeType", () => { + it("renders uri, name, and mimeType", () => { renderWithMantine( - , + , ); expect(screen.getByText(URI)).toBeInTheDocument(); expect(screen.getByText("Readme")).toBeInTheDocument(); - expect(screen.getByText("The project readme")).toBeInTheDocument(); expect(screen.getByText("text/markdown")).toBeInTheDocument(); }); diff --git a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx index 71e2b5e17..a9cc1e4b6 100644 --- a/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx +++ b/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx @@ -1,18 +1,19 @@ import type { ReactNode } from "react"; import { Badge, Group, Stack, Text } from "@mantine/core"; +import { CopyButton } from "../CopyButton/CopyButton"; export interface ResourceLinkInfoProps { - /** The linked resource's URI (always shown). */ + /** The linked resource's URI (always shown, with a copy button). */ uri: string; - /** Optional human-friendly name shown beneath the URI. */ + /** Optional human-friendly name shown above 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. + * Optional trailing element placed at the end of the URI row (opposite the + * copy button) — e.g. an expand/collapse control supplied by an interactive + * wrapper. Mirrors ProtocolEntry, whose toggle sits on the row below the + * badges. */ action?: ReactNode; } @@ -21,70 +22,97 @@ const HeaderStack = Stack.withProps({ gap: 4, }); +// Name (left) + MIME badge (right). `justify` is set per-instance: spread when +// a name is present, otherwise the badge hugs the right. +const HeaderRow = Group.withProps({ + wrap: "nowrap", + gap: "xs", + align: "center", +}); + +// Copy control + URI (left) and the optional expand/collapse control (right), +// on the line below the header — mirroring ProtocolEntry's controls row. const UriRow = Group.withProps({ justify: "space-between", wrap: "nowrap", gap: "xs", - align: "flex-start", + align: "center", }); +// Copy button + URI cluster; flexes so the URI fills and the action stays right. +const UriCluster = Group.withProps({ + wrap: "nowrap", + gap: "xs", + align: "center", + flex: 1, + miw: 0, +}); + +// Match how ProtocolEntry/NetworkEntry render a URL: `sm` / `fw: 500`, in the +// default sans-serif face and text color (not a blue monospace "link"). The +// `monoBreak` variant only adds `word-break: break-all` so a long URI wraps +// within the card instead of overflowing. const UriText = Text.withProps({ size: "sm", - c: "blue", - ff: "monospace", + fw: 500, variant: "monoBreak", flex: 1, miw: 0, }); -const MetaGroup = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - const MimeBadge = Badge.withProps({ - size: "sm", - variant: "light", - color: "blue", + // Match the point size of the ProtocolEntry method/status badges; the + // lowercase MIME text reads smaller than their uppercase labels at `sm`. + size: "md", + radius: "sm", // MIME types are conventionally lowercase; keep them as-is rather than // letting Badge's default uppercase transform mangle them. tt: "none", + autoContrast: false, + // Light mode: the tinted blue-light chip (unchanged). Dark mode: a solid + // dark-blue fill with white text — matching the solid ProtocolEntry badges + // rather than a washed-out translucent tint. + bg: "light-dark(var(--mantine-color-blue-light), var(--mantine-color-blue-9))", + c: "light-dark(var(--mantine-color-blue-light-color), var(--mantine-color-white))", }); const NameText = Text.withProps({ size: "sm", fw: 600, -}); - -const DescriptionText = Text.withProps({ - size: "sm", - c: "dimmed", + flex: 1, + miw: 0, }); /** - * 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. + * Pure-display metadata for a `resource_link`: an optional name and MIME-type + * badge on the header row, then the URI on the line below with a copy button. + * The URI is styled like ProtocolEntry/NetworkEntry URLs (`sm` / `fw: 500`, + * default sans-serif face and color), not a blue monospace link. The optional + * `action` slot lets an interactive wrapper (e.g. {@link ResourceLink}) place + * an expand/collapse control at the end of the URI row. */ export function ResourceLinkInfo({ uri, name, - description, mimeType, action, }: ResourceLinkInfoProps) { + const hasHeader = Boolean(name || mimeType); return ( - - {uri} - + {hasHeader && ( + + {name && {name}} {mimeType && {mimeType}} - {action} - + + )} + + + + {uri} + + {action} - {name && {name}} - {description && {description}} ); } diff --git a/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx b/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx index 5c4dc89b5..a1dbac366 100644 --- a/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx +++ b/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx @@ -41,13 +41,16 @@ export const Static: Story = { args: { uri: URI, name: "Readme", - description: "Project documentation", mimeType: "text/markdown", }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect(canvas.getByText(URI)).toBeInTheDocument(); - expect(canvas.queryByRole("button")).not.toBeInTheDocument(); + // Copy button is present; there's no expand control in the static card. + expect(canvas.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + expect( + canvas.queryByRole("button", { name: /^Expand resource/ }), + ).not.toBeInTheDocument(); }, }; @@ -55,18 +58,16 @@ export const Expandable: Story = { args: { uri: URI, name: "Readme", - description: "Click to read on demand", mimeType: "text/markdown", onReadResource: readMarkdown, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const button = canvas.getByRole("button", { - name: `Expand resource ${URI}`, - }); - await userEvent.click(button); + await userEvent.click( + canvas.getByRole("button", { name: `Expand resource ${URI}` }), + ); await waitFor(() => - expect(canvas.getByText("Resource:")).toBeInTheDocument(), + expect(canvas.getByText(/Read on demand/)).toBeInTheDocument(), ); }, }; @@ -77,7 +78,6 @@ export const LargeResult: Story = { args: { uri: BLOB_URI, name: "Blob Resource", - description: "A large gzipped resource", mimeType: "application/gzip", onReadResource: readLargeBlob, }, @@ -86,8 +86,10 @@ export const LargeResult: Story = { await userEvent.click( canvas.getByRole("button", { name: `Expand resource ${BLOB_URI}` }), ); + // The `"blob"` key appears only in the expanded read result's JSON (not in + // the metadata badge), so it confirms the inline result rendered. await waitFor(() => - expect(canvas.getByText("Resource:")).toBeInTheDocument(), + expect(canvas.getByText(/"blob":/)).toBeInTheDocument(), ); }, }; diff --git a/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx b/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx index f78e84c84..50a4f13b8 100644 --- a/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx +++ b/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx @@ -15,24 +15,22 @@ const readResult = (text: string): ReadResourceResult => ({ }); describe("ResourceLink", () => { - it("renders uri, name, description, and mimeType", () => { + it("renders uri, name, and mimeType", () => { renderWithMantine( - , + , ); 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", () => { + it("renders no expand control without onReadResource", () => { renderWithMantine(); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); + // The URI copy button is always present, but there's no expand affordance. + expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: `Expand resource ${URI}` }), + ).not.toBeInTheDocument(); }); it("reads the resource on demand and renders the result inline", async () => { @@ -42,22 +40,19 @@ describe("ResourceLink", () => { , ); - const button = screen.getByRole("button", { - name: `Expand resource ${URI}`, - }); - expect(button).toHaveAttribute("aria-expanded", "false"); - - await user.click(button); + await user.click( + screen.getByRole("button", { name: `Expand resource ${URI}` }), + ); expect(onReadResource).toHaveBeenCalledWith(URI); + // The full read result is rendered inline as formatted JSON. await waitFor(() => - expect(screen.getByText("Resource:")).toBeInTheDocument(), + expect(screen.getByText(/"hello body"/)).toBeInTheDocument(), ); - // The full read result is rendered as formatted JSON. - expect(screen.getByText(/"hello body"/)).toBeInTheDocument(); + // The toggle flips to the collapse control once expanded. expect( screen.getByRole("button", { name: `Collapse resource ${URI}` }), - ).toHaveAttribute("aria-expanded", "true"); + ).toBeInTheDocument(); }); it("collapses and re-expands without re-reading (result cached)", async () => { @@ -74,13 +69,16 @@ describe("ResourceLink", () => { expect(screen.getByText(/"cached body"/)).toBeInTheDocument(), ); - // Collapse — content is hidden. + // Collapse — the toggle flips back to the expand control. (The read result + // stays mounted inside the animated Collapse, so it isn't re-read.) await user.click( screen.getByRole("button", { name: `Collapse resource ${URI}` }), ); - expect(screen.queryByText("Resource:")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: `Expand resource ${URI}` }), + ).toBeInTheDocument(); - // Re-expand — content returns without a second read. + // Re-expand — content is still present without a second read. await user.click( screen.getByRole("button", { name: `Expand resource ${URI}` }), ); @@ -95,7 +93,9 @@ describe("ResourceLink", () => { , ); - await user.click(screen.getByRole("button")); + await user.click( + screen.getByRole("button", { name: `Expand resource ${URI}` }), + ); await waitFor(() => expect(screen.getByText("Failed to read resource")).toBeInTheDocument(), ); @@ -111,7 +111,9 @@ describe("ResourceLink", () => { , ); - await user.click(screen.getByRole("button")); + await user.click( + screen.getByRole("button", { name: `Expand resource ${URI}` }), + ); await waitFor(() => expect(screen.getByText("Failed to read resource")).toBeInTheDocument(), ); diff --git a/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx b/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx index 2e474783a..1430c46e9 100644 --- a/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx +++ b/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx @@ -1,25 +1,15 @@ import { useState } from "react"; -import { - Alert, - Loader, - Paper, - ScrollArea, - Stack, - Text, - UnstyledButton, -} from "@mantine/core"; +import { Alert, Card, Collapse, ScrollArea, Stack, Text } 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 { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; 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. */ + /** Optional human-friendly name shown above the URI. */ name?: string; - /** Optional description shown beneath the name. */ - description?: string; /** Optional MIME type shown as a badge. */ mimeType?: string; /** @@ -30,15 +20,15 @@ export interface ResourceLinkProps { onReadResource?: (uri: string) => Promise; } -const LinkCard = Paper.withProps({ +// Recessed "inset" surface so each link card reads the same as a Protocol +// message card (ProtocolEntry), matching its colors in both light and dark +// modes; the inset variant also raises nested Code blocks (the expanded read +// result) onto a lighter surface via its cascade variable. +const LinkCard = Card.withProps({ withBorder: true, - p: "sm", + padding: "sm", radius: "md", -}); - -const HeaderButton = UnstyledButton.withProps({ - w: "100%", - ta: "left", + variant: "inset", }); const ExpandedSection = Stack.withProps({ @@ -58,12 +48,6 @@ const ResultScroll = ScrollArea.Autosize.withProps({ offsetScrollbars: true, }); -const ResourceLabel = Text.withProps({ - size: "sm", - fw: 600, - c: "green", -}); - const LoadingText = Text.withProps({ size: "sm", c: "dimmed", @@ -79,7 +63,6 @@ const LoadingText = Text.withProps({ export function ResourceLink({ uri, name, - description, mimeType, onReadResource, }: ResourceLinkProps) { @@ -113,49 +96,43 @@ export function ResourceLink({ } } - const Chevron = expanded ? RiArrowDownSLine : RiArrowRightSLine; + // Same tooltip'd expand/collapse control as ProtocolEntry (ExpandToggle), + // placed in the header row's meta slot as a sibling of the URI's copy button. + // A per-resource `ariaLabel` keeps the toggles distinguishable to assistive + // tech when several links are listed (the visible tooltip stays "Expand"). const action = expandable ? ( - loading ? ( - - ) : ( - - ) - ) : undefined; - - const info = ( - void toggle()} + ariaLabel={`${expanded ? "Collapse" : "Expand"} resource ${uri}`} /> - ); + ) : undefined; return ( - {expandable ? ( - void toggle()} - aria-expanded={expanded} - aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`} - > - {info} - - ) : ( - info - )} - {expanded && ( - - {loading ? ( - Loading resource… - ) : error !== null ? ( - - {error} - - ) : result !== null ? ( - <> - Resource: + + {/* Same expand/collapse animation as ProtocolEntry: content stays mounted + (so the cached read result survives a collapse) and animates via + Mantine's Collapse. */} + {expandable && ( + + + {loading ? ( + Loading resource… + ) : error !== null ? ( + + {error} + + ) : result !== null ? ( - - ) : null} - + ) : null} + + )} ); diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx index 059b39949..49eb35b4d 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx @@ -1,5 +1,6 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { Card, Flex } from "@mantine/core"; import { fn } from "storybook/test"; import { ToolResultPanel } from "./ToolResultPanel"; @@ -60,6 +61,61 @@ const mixedResult: CallToolResult = { ], }; +const resourceLinksResult: CallToolResult = { + content: [ + { + type: "text", + text: "Here are 3 resource links to resources available in this server:", + }, + { + type: "resource_link", + uri: "demo://resource/dynamic/blob/1", + name: "Blob Resource 1", + mimeType: "text/plain", + }, + { + type: "resource_link", + uri: "demo://resource/dynamic/text/2", + name: "Text Resource 2", + mimeType: "text/plain", + }, + { + type: "resource_link", + uri: "demo://resource/dynamic/blob/3", + name: "Blob Resource 3", + mimeType: "text/plain", + }, + ], +}; + +// A long text block preceding the links, so the 50%-height cap on the text is +// exercised: the text scrolls within (at most) half the card and the Resource +// Links box keeps the rest. +const resourceLinksWithLongTextResult: CallToolResult = { + content: [ + { + type: "text", + text: "Here are 3 resource links to resources available in this server. " + .repeat(60) + .trim(), + }, + ...resourceLinksResult.content.filter((b) => b.type === "resource_link"), + ], +}; + +// Mirrors the Tools screen's full-height result card (ContentPane height → +// ContentCard `flex: 1`) so the box fills the available space and scrolls +// within, as it does in the app. +const fillHeightDecorators: Decorator[] = [ + (Story) => ( + + + + + + ), +]; + export const Empty: Story = { args: { result: emptyResult, @@ -90,6 +146,33 @@ export const MixedContent: Story = { }, }; +// A run of `resource_link` blocks is grouped into one scrollable "Resource +// Links" box, with each link card in the recessed inset surface that matches +// the Protocol message cards. The decorator mirrors the Tools screen's +// full-height result card (ContentPane height → ContentCard `flex: 1`) so the +// box fills the available space and scrolls within, as it does in the app. +export const ResourceLinks: Story = { + args: { + result: resourceLinksResult, + onReadResource: async (uri: string) => ({ + contents: [{ uri, mimeType: "text/plain", text: `Contents of ${uri}` }], + }), + }, + decorators: fillHeightDecorators, +}; + +// A long text block above the links is capped at half the card height and +// scrolls within, so it can't push the Resource Links box out of view. +export const ResourceLinksWithLongText: Story = { + args: { + result: resourceLinksWithLongTextResult, + onReadResource: async (uri: string) => ({ + contents: [{ uri, mimeType: "text/plain", text: `Contents of ${uri}` }], + }), + }, + decorators: fillHeightDecorators, +}; + export const ErrorResult: Story = { args: { result: { diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx index 0874cb1e3..9f608af19 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx @@ -7,6 +7,7 @@ import { waitFor, } from "../../../test/renderWithMantine"; import { ToolResultPanel } from "./ToolResultPanel"; +import { resultHasResourceLinks } from "./toolResultUtils"; const okResult: CallToolResult = { content: [{ type: "text", text: "ok" }], @@ -41,7 +42,7 @@ describe("ToolResultPanel", () => { expect(screen.getByText("No results yet")).toBeInTheDocument(); }); - it("renders a resource_link block as an expandable ResourceLink", async () => { + it("groups resource_link blocks in a scrollable Resource Links box", async () => { const user = userEvent.setup(); const onReadResource = vi.fn().mockResolvedValue({ contents: [{ uri: "demo://r/1", text: "linked body" }], @@ -60,6 +61,10 @@ describe("ToolResultPanel", () => { />, ); expect(screen.getByText("ok")).toBeInTheDocument(); + // The link sits inside a grouped, labeled box. + expect( + screen.getByRole("heading", { name: "Resource Links" }), + ).toBeInTheDocument(); await user.click( screen.getByRole("button", { name: "Expand resource demo://r/1" }), ); @@ -69,6 +74,41 @@ describe("ToolResultPanel", () => { ); }); + it("collapses consecutive resource_link blocks into a single box", () => { + const result: CallToolResult = { + content: [ + { type: "text", text: "intro" }, + { type: "resource_link", uri: "demo://r/1", name: "One" }, + { type: "resource_link", uri: "demo://r/2", name: "Two" }, + { type: "resource_link", uri: "demo://r/3", name: "Three" }, + ], + }; + renderWithMantine( {}} />); + // One shared "Resource Links" heading for the whole run of links. + expect( + screen.getAllByRole("heading", { name: "Resource Links" }), + ).toHaveLength(1); + expect(screen.getByText("One")).toBeInTheDocument(); + expect(screen.getByText("Two")).toBeInTheDocument(); + expect(screen.getByText("Three")).toBeInTheDocument(); + }); + + it("renders a separate Resource Links box per non-adjacent run", () => { + const result: CallToolResult = { + content: [ + { type: "resource_link", uri: "demo://r/1", name: "One" }, + { type: "text", text: "divider" }, + { type: "resource_link", uri: "demo://r/2", name: "Two" }, + ], + }; + renderWithMantine( {}} />); + // The text block between the two links splits them into two boxes. + expect( + screen.getAllByRole("heading", { name: "Resource Links" }), + ).toHaveLength(2); + expect(screen.getByText("divider")).toBeInTheDocument(); + }); + it("invokes onClear when the close button is clicked", async () => { const user = userEvent.setup(); const onClear = vi.fn(); @@ -76,4 +116,36 @@ describe("ToolResultPanel", () => { await user.click(screen.getByRole("button", { name: "Close results" })); expect(onClear).toHaveBeenCalledTimes(1); }); + + describe("resultHasResourceLinks", () => { + it("is true only for a non-error result containing a resource_link", () => { + expect( + resultHasResourceLinks({ + content: [ + { type: "text", text: "ok" }, + { type: "resource_link", uri: "demo://r/1", name: "Linked" }, + ], + }), + ).toBe(true); + }); + + it("is false for a text-only result", () => { + expect(resultHasResourceLinks(okResult)).toBe(false); + }); + + it("is false for an empty result", () => { + expect(resultHasResourceLinks(emptyResult)).toBe(false); + }); + + it("is false when the result is an error, even with a resource_link", () => { + expect( + resultHasResourceLinks({ + isError: true, + content: [ + { type: "resource_link", uri: "demo://r/1", name: "Linked" }, + ], + }), + ).toBe(false); + }); + }); }); diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx index 885b39fd9..75e8552a1 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx @@ -2,6 +2,7 @@ import { Alert, CloseButton, Group, + Paper, ScrollArea, Stack, Text, @@ -13,6 +14,7 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { ResourceLink } from "../ResourceLink/ResourceLink"; +import { resultHasResourceLinks } from "./toolResultUtils"; export interface ToolResultPanelProps { result: CallToolResult; @@ -29,13 +31,44 @@ export interface ToolResultPanelProps { onReadResource?: (uri: string) => Promise; } -// Outer column: the header pins (`flex: 0 0 auto`) and the scroll region -// absorbs overflow when the enclosing card hits its `mah`. `mih: 0` lets the -// flex children shrink below their content's intrinsic height. +type ContentBlock = CallToolResult["content"][number]; +type ResourceLinkBlock = Extract; + +// A result's content is rendered as a run of segments in original order: each +// non-link block on its own, and every maximal run of consecutive +// `resource_link` blocks collapsed into one grouped "Resource Links" box. +type ResultSegment = + | { kind: "links"; links: { block: ResourceLinkBlock; index: number }[] } + | { kind: "block"; block: ContentBlock; index: number }; + +// Walk the content array once, coalescing adjacent `resource_link` blocks into a +// single `links` segment so a run of links renders inside one scrollable box +// while preserving the overall block order. +function segmentContent(content: ContentBlock[]): ResultSegment[] { + const segments: ResultSegment[] = []; + content.forEach((block, index) => { + if (block.type === "resource_link") { + const last = segments[segments.length - 1]; + if (last && last.kind === "links") { + last.links.push({ block, index }); + } else { + segments.push({ kind: "links", links: [{ block, index }] }); + } + } else { + segments.push({ kind: "block", block, index }); + } + }); + return segments; +} + +// Outer column fills the (full-height) result card: the header pins +// (`flex: 0 0 auto`) and the body below fills the rest. `mih: 0` lets the flex +// children shrink below their content's intrinsic height. const PanelStack = Stack.withProps({ gap: "md", miw: 0, mih: 0, + flex: 1, }); const HeaderRow = Group.withProps({ @@ -50,10 +83,10 @@ const HeaderLeft = Group.withProps({ wrap: "nowrap", }); -// `0 1 auto` + `mih: 0` lets the scroll region shrink (never grow) so a short -// result doesn't reserve height while a long one scrolls within the card cap. +// Body scroll region for non-resource-link results: fills the card and scrolls +// within it when the content is taller than the available space. const ResultScroll = ScrollArea.withProps({ - flex: "0 1 auto", + flex: 1, miw: 0, mih: 0, type: "auto", @@ -65,11 +98,138 @@ const ResultStack = Stack.withProps({ gap: "md", }); +// A non-link block that shares the card with a "Resource Links" box: capped at +// half the available height and scrollable within, so a long text block can't +// crowd the links box out of view (it keeps the remaining space). `Autosize` +// sizes to content up to the cap, so a short block still takes only what it +// needs. Without links, non-link blocks flow in the main scroll body instead. +const NonLinkCap = ScrollArea.Autosize.withProps({ + mah: "50%", + flex: "0 1 auto", + mih: 0, + type: "auto", + scrollbars: "y", + offsetScrollbars: true, +}); + +// Body column for results that contain a "Resource Links" box: it fills the +// card so the box (which is `flex: 1` within it) can grow to the available +// height and scroll internally, rather than capping at its content height. +const FillStack = Stack.withProps({ + gap: "md", + flex: 1, + mih: 0, +}); + +// Grouped container for a run of `resource_link` blocks — a bordered box with a +// pinned "Resource Links" heading and its own scroll region, mirroring the +// "Messages" box in the Protocol monitoring sidebar (ProtocolListPanel). The +// `panel` variant makes it a flex column (overflow hidden, min-height 0) and +// `flex: 1` lets it grow to fill the result card's available height. +const ResourceLinksBox = Paper.withProps({ + withBorder: true, + radius: "md", + p: "md", + variant: "panel", + flex: 1, + mih: 0, +}); + +const ResourceLinksInner = Stack.withProps({ + gap: "sm", + flex: 1, + mih: 0, +}); + +const ResourceLinksHeader = Title.withProps({ + // The panel "Results" title is h3 (size h4); this sub-box heading is h4 so the + // heading order doesn't skip a level (axe `heading-order`). + order: 4, + size: "h5", +}); + +// Fills the box below the pinned heading and scrolls the link list within it. +const ResourceLinksScroll = ScrollArea.withProps({ + flex: 1, + mih: 0, + type: "auto", + scrollbars: "y", + offsetScrollbars: true, +}); + +const ResourceLinksStack = Stack.withProps({ + gap: "sm", +}); + +function ResourceLinksGroup({ + links, + onReadResource, +}: { + links: { block: ResourceLinkBlock; index: number }[]; + onReadResource?: (uri: string) => Promise; +}) { + return ( + + + Resource Links + + + {links.map(({ block, index }) => ( + + ))} + + + + + ); +} + export function ToolResultPanel({ result, onClear, onReadResource, }: ToolResultPanelProps) { + const segments = + result.isError || result.content.length === 0 + ? [] + : segmentContent(result.content); + // Results with a Resource Links box fill the card so the box grows to the + // available height (and scrolls inside). Plain text/image results keep the + // scroll-within-card body so a short result doesn't reserve empty height. + const hasLinks = resultHasResourceLinks(result); + + const segmentNodes = segments.map((segment) => { + if (segment.kind === "links") { + return ( + + ); + } + const viewer = ( + + ); + // Alongside a Resource Links box, cap the block at half the height (and let + // it scroll); on its own it flows in the outer scroll body uncapped. + return hasLinks ? ( + {viewer} + ) : ( + viewer + ); + }); + return ( @@ -83,39 +243,26 @@ export function ToolResultPanel({ - - - {result.isError ? ( - - {result.content - .filter((b) => b.type === "text") - .map((b) => b.text) - .join("\n")} - - ) : result.content.length === 0 ? ( - No results yet - ) : ( - result.content.map((block, index) => - block.type === "resource_link" ? ( - - ) : ( - - ), - ) - )} - - + {result.isError ? ( + + + {result.content + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n")} + + + ) : result.content.length === 0 ? ( + + No results yet + + ) : hasLinks ? ( + {segmentNodes} + ) : ( + + {segmentNodes} + + )} ); } diff --git a/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts b/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts new file mode 100644 index 000000000..3098c4da1 --- /dev/null +++ b/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts @@ -0,0 +1,14 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Whether a result renders a "Resource Links" box — i.e. a non-error result + * with at least one `resource_link` block. Hosts use this to decide whether the + * result surface should fill the available height (so the box can grow and + * scroll internally); plain text/image results keep their content-sized card. + */ +export function resultHasResourceLinks(result: CallToolResult): boolean { + return ( + !result.isError && + result.content.some((block) => block.type === "resource_link") + ); +} diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx index ecd8a622a..15692b6aa 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx @@ -163,7 +163,7 @@ describe("ToolsScreen", () => { expect(screen.queryByText("Results")).toBeNull(); }); - it("renders the result panel when callState has a result", () => { + it("renders a content-sized result card for a plain text result", () => { renderWithMantine( { />, ); expect(screen.getByText("Results")).toBeInTheDocument(); + // No links → the result card is NOT flex-filled (sizes to content). + const card = screen.getByText("Results").closest(".mantine-Card-root"); + expect(card).not.toHaveStyle({ flex: "1" }); + }); + + it("fills the result card with a Resource Links box when the result has links", () => { + // A resource_link result takes the full-height (`flex={1}`) card branch so + // the Resource Links box can grow and scroll within. + renderWithMantine( + , + ); + expect( + screen.getByRole("heading", { name: "Resource Links" }), + ).toBeInTheDocument(); + // Links → the result card fills the pane (`flex: 1`), distinguishing this + // branch from the content-sized text-result card above. + const card = screen.getByText("Results").closest(".mantine-Card-root"); + expect(card).toHaveStyle({ flex: "1" }); }); it("invokes onCallTool with form values on Execute", async () => { diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index aa312e18a..d538f556c 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -10,6 +10,7 @@ import { type ToolProgress, } from "../../groups/ToolDetailPanel/ToolDetailPanel"; import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel"; +import { resultHasResourceLinks } from "../../groups/ToolResultPanel/toolResultUtils"; import { collectSchemaDefaults } from "../../../utils/jsonUtils"; export interface ToolCallState { @@ -181,7 +182,14 @@ export function ToolsScreen({ // `result` (App.tsx), so the executing form (progress + cancel) shows // until the result lands. - + {/* Fill the pane's full height only when the result renders a + "Resource Links" box, so that box can expand into the available + space and scroll within. Plain text/image/error results keep the + content-sized card (matching the input-form state) instead of + reserving a tall empty card. */} + onClearResult?.()}