Merge remote-tracking branch 'origin/v2/main' into v2/sdk-v2-migration-1624

# Conflicts:
#	clients/web/src/components/groups/ResourceLink/ResourceLink.tsx
#	clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx
#	clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
This commit is contained in:
cliffhall 2026-07-15 14:15:50 -04:00
commit 39362dd5ff
16 changed files with 591 additions and 197 deletions

View file

@ -122,14 +122,16 @@ describe("ContentViewer", () => {
type: "resource_link", type: "resource_link",
uri: "ui://app", uri: "ui://app",
name: "Cool App", name: "Cool App",
description: "An app",
mimeType: "text/html", mimeType: "text/html",
}; };
renderWithMantine(<ContentViewer block={block} />); renderWithMantine(<ContentViewer block={block} />);
expect(screen.getByText("Cool App")).toBeInTheDocument(); expect(screen.getByText("Cool App")).toBeInTheDocument();
expect(screen.getByText("An app")).toBeInTheDocument();
expect(screen.getByText("text/html")).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", () => { it("renders nothing for unknown block types", () => {

View file

@ -367,7 +367,6 @@ function BlockContent({
<ResourceLinkInfo <ResourceLinkInfo
uri={block.uri} uri={block.uri}
name={block.name} name={block.name}
description={block.description}
mimeType={block.mimeType} mimeType={block.mimeType}
/> />
); );

View file

@ -23,4 +23,30 @@ describe("ExpandToggle", () => {
await user.click(screen.getByRole("button", { name: "Expand" })); await user.click(screen.getByRole("button", { name: "Expand" }));
expect(onToggle).toHaveBeenCalledTimes(1); expect(onToggle).toHaveBeenCalledTimes(1);
}); });
it("uses ariaLabel as the accessible name when provided", () => {
renderWithMantine(
<ExpandToggle
expanded={false}
onToggle={vi.fn()}
ariaLabel="Expand resource demo://r/1"
/>,
);
expect(
screen.getByRole("button", { name: "Expand resource demo://r/1" }),
).toBeInTheDocument();
});
it("exposes aria-expanded=false when collapsed", () => {
renderWithMantine(<ExpandToggle expanded={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button")).toHaveAttribute(
"aria-expanded",
"false",
);
});
it("exposes aria-expanded=true when expanded", () => {
renderWithMantine(<ExpandToggle expanded={true} onToggle={vi.fn()} />);
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
});
}); });

View file

@ -5,16 +5,28 @@ export interface ExpandToggleProps {
/** Whether the owning entry is currently expanded. */ /** Whether the owning entry is currently expanded. */
expanded: boolean; expanded: boolean;
onToggle: () => void; 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 * 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 * Task cards). Uses the same expand/collapse-vertical icons as the list-level
* ListToggle: collapsed shows the expand icon, expanded shows the collapse * ListToggle: collapsed shows the expand icon, expanded shows the collapse
* icon. The aria-label stays "Expand"/"Collapse" so it reads the same as the * icon. The tooltip stays "Expand"/"Collapse" (the same verb as the text button
* text button it replaced. * 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 Icon = expanded ? RiCollapseVerticalLine : RiExpandVerticalLine;
const label = expanded ? "Collapse" : "Expand"; const label = expanded ? "Collapse" : "Expand";
return ( return (
@ -23,7 +35,8 @@ export function ExpandToggle({ expanded, onToggle }: ExpandToggleProps) {
variant="subtle" variant="subtle"
color="gray" color="gray"
size="md" size="md"
aria-label={label} aria-label={ariaLabel ?? label}
aria-expanded={expanded}
onClick={onToggle} onClick={onToggle}
> >
<Icon size={16} /> <Icon size={16} />

View file

@ -13,7 +13,6 @@ export const Full: Story = {
args: { args: {
uri: "file:///docs/readme.md", uri: "file:///docs/readme.md",
name: "Readme", name: "Readme",
description: "Project documentation",
mimeType: "text/markdown", mimeType: "text/markdown",
}, },
}; };

View file

@ -5,18 +5,12 @@ import { ResourceLinkInfo } from "./ResourceLinkInfo";
const URI = "file:///docs/readme.md"; const URI = "file:///docs/readme.md";
describe("ResourceLinkInfo", () => { describe("ResourceLinkInfo", () => {
it("renders uri, name, description, and mimeType", () => { it("renders uri, name, and mimeType", () => {
renderWithMantine( renderWithMantine(
<ResourceLinkInfo <ResourceLinkInfo uri={URI} name="Readme" mimeType="text/markdown" />,
uri={URI}
name="Readme"
description="The project readme"
mimeType="text/markdown"
/>,
); );
expect(screen.getByText(URI)).toBeInTheDocument(); expect(screen.getByText(URI)).toBeInTheDocument();
expect(screen.getByText("Readme")).toBeInTheDocument(); expect(screen.getByText("Readme")).toBeInTheDocument();
expect(screen.getByText("The project readme")).toBeInTheDocument();
expect(screen.getByText("text/markdown")).toBeInTheDocument(); expect(screen.getByText("text/markdown")).toBeInTheDocument();
}); });

View file

@ -1,18 +1,19 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Badge, Group, Stack, Text } from "@mantine/core"; import { Badge, Group, Stack, Text } from "@mantine/core";
import { CopyButton } from "../CopyButton/CopyButton";
export interface ResourceLinkInfoProps { export interface ResourceLinkInfoProps {
/** The linked resource's URI (always shown). */ /** The linked resource's URI (always shown, with a copy button). */
uri: string; uri: string;
/** Optional human-friendly name shown beneath the URI. */ /** Optional human-friendly name shown above the URI. */
name?: string; name?: string;
/** Optional description shown beneath the name. */
description?: string;
/** Optional MIME type shown as a badge. */ /** Optional MIME type shown as a badge. */
mimeType?: string; mimeType?: string;
/** /**
* Optional trailing element placed at the end of the URI row — e.g. an * Optional trailing element placed at the end of the URI row (opposite the
* expand/collapse indicator supplied by an interactive wrapper. * 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; action?: ReactNode;
} }
@ -21,70 +22,97 @@ const HeaderStack = Stack.withProps({
gap: 4, 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({ const UriRow = Group.withProps({
justify: "space-between", justify: "space-between",
wrap: "nowrap", wrap: "nowrap",
gap: "xs", 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({ const UriText = Text.withProps({
size: "sm", size: "sm",
c: "blue", fw: 500,
ff: "monospace",
variant: "monoBreak", variant: "monoBreak",
flex: 1, flex: 1,
miw: 0, miw: 0,
}); });
const MetaGroup = Group.withProps({
gap: "xs",
wrap: "nowrap",
});
const MimeBadge = Badge.withProps({ const MimeBadge = Badge.withProps({
size: "sm", // Match the point size of the ProtocolEntry method/status badges; the
variant: "light", // lowercase MIME text reads smaller than their uppercase labels at `sm`.
color: "blue", size: "md",
radius: "sm",
// MIME types are conventionally lowercase; keep them as-is rather than // MIME types are conventionally lowercase; keep them as-is rather than
// letting Badge's default uppercase transform mangle them. // letting Badge's default uppercase transform mangle them.
tt: "none", 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({ const NameText = Text.withProps({
size: "sm", size: "sm",
fw: 600, fw: 600,
}); flex: 1,
miw: 0,
const DescriptionText = Text.withProps({
size: "sm",
c: "dimmed",
}); });
/** /**
* Pure-display metadata for a `resource_link`: the URI (monospace, link-styled), * Pure-display metadata for a `resource_link`: an optional name and MIME-type
* optional name / description, and a MIME-type badge. The optional `action` * badge on the header row, then the URI on the line below with a copy button.
* slot lets an interactive wrapper (e.g. {@link ResourceLink}) place an * The URI is styled like ProtocolEntry/NetworkEntry URLs (`sm` / `fw: 500`,
* expand/collapse indicator in the URI row. * 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({ export function ResourceLinkInfo({
uri, uri,
name, name,
description,
mimeType, mimeType,
action, action,
}: ResourceLinkInfoProps) { }: ResourceLinkInfoProps) {
const hasHeader = Boolean(name || mimeType);
return ( return (
<HeaderStack> <HeaderStack>
<UriRow> {hasHeader && (
<UriText>{uri}</UriText> <HeaderRow justify={name ? "space-between" : "flex-end"}>
<MetaGroup> {name && <NameText>{name}</NameText>}
{mimeType && <MimeBadge>{mimeType}</MimeBadge>} {mimeType && <MimeBadge>{mimeType}</MimeBadge>}
{action} </HeaderRow>
</MetaGroup> )}
<UriRow>
<UriCluster>
<CopyButton value={uri} />
<UriText>{uri}</UriText>
</UriCluster>
{action}
</UriRow> </UriRow>
{name && <NameText>{name}</NameText>}
{description && <DescriptionText>{description}</DescriptionText>}
</HeaderStack> </HeaderStack>
); );
} }

View file

@ -41,13 +41,16 @@ export const Static: Story = {
args: { args: {
uri: URI, uri: URI,
name: "Readme", name: "Readme",
description: "Project documentation",
mimeType: "text/markdown", mimeType: "text/markdown",
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
expect(canvas.getByText(URI)).toBeInTheDocument(); 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: { args: {
uri: URI, uri: URI,
name: "Readme", name: "Readme",
description: "Click to read on demand",
mimeType: "text/markdown", mimeType: "text/markdown",
onReadResource: readMarkdown, onReadResource: readMarkdown,
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const button = canvas.getByRole("button", { await userEvent.click(
name: `Expand resource ${URI}`, canvas.getByRole("button", { name: `Expand resource ${URI}` }),
}); );
await userEvent.click(button);
await waitFor(() => await waitFor(() =>
expect(canvas.getByText("Resource:")).toBeInTheDocument(), expect(canvas.getByText(/Read on demand/)).toBeInTheDocument(),
); );
}, },
}; };
@ -77,7 +78,6 @@ export const LargeResult: Story = {
args: { args: {
uri: BLOB_URI, uri: BLOB_URI,
name: "Blob Resource", name: "Blob Resource",
description: "A large gzipped resource",
mimeType: "application/gzip", mimeType: "application/gzip",
onReadResource: readLargeBlob, onReadResource: readLargeBlob,
}, },
@ -86,8 +86,10 @@ export const LargeResult: Story = {
await userEvent.click( await userEvent.click(
canvas.getByRole("button", { name: `Expand resource ${BLOB_URI}` }), 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(() => await waitFor(() =>
expect(canvas.getByText("Resource:")).toBeInTheDocument(), expect(canvas.getByText(/"blob":/)).toBeInTheDocument(),
); );
}, },
}; };

View file

@ -15,24 +15,22 @@ const readResult = (text: string): ReadResourceResult => ({
}); });
describe("ResourceLink", () => { describe("ResourceLink", () => {
it("renders uri, name, description, and mimeType", () => { it("renders uri, name, and mimeType", () => {
renderWithMantine( renderWithMantine(
<ResourceLink <ResourceLink uri={URI} name="Readme" mimeType="text/markdown" />,
uri={URI}
name="Readme"
description="The project readme"
mimeType="text/markdown"
/>,
); );
expect(screen.getByText(URI)).toBeInTheDocument(); expect(screen.getByText(URI)).toBeInTheDocument();
expect(screen.getByText("Readme")).toBeInTheDocument(); expect(screen.getByText("Readme")).toBeInTheDocument();
expect(screen.getByText("The project readme")).toBeInTheDocument();
expect(screen.getByText("text/markdown")).toBeInTheDocument(); expect(screen.getByText("text/markdown")).toBeInTheDocument();
}); });
it("is not interactive without onReadResource", () => { it("renders no expand control without onReadResource", () => {
renderWithMantine(<ResourceLink uri={URI} />); renderWithMantine(<ResourceLink uri={URI} />);
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 () => { it("reads the resource on demand and renders the result inline", async () => {
@ -42,22 +40,19 @@ describe("ResourceLink", () => {
<ResourceLink uri={URI} onReadResource={onReadResource} />, <ResourceLink uri={URI} onReadResource={onReadResource} />,
); );
const button = screen.getByRole("button", { await user.click(
name: `Expand resource ${URI}`, screen.getByRole("button", { name: `Expand resource ${URI}` }),
}); );
expect(button).toHaveAttribute("aria-expanded", "false");
await user.click(button);
expect(onReadResource).toHaveBeenCalledWith(URI); expect(onReadResource).toHaveBeenCalledWith(URI);
// The full read result is rendered inline as formatted JSON.
await waitFor(() => await waitFor(() =>
expect(screen.getByText("Resource:")).toBeInTheDocument(), expect(screen.getByText(/"hello body"/)).toBeInTheDocument(),
); );
// The full read result is rendered as formatted JSON. // The toggle flips to the collapse control once expanded.
expect(screen.getByText(/"hello body"/)).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { name: `Collapse resource ${URI}` }), screen.getByRole("button", { name: `Collapse resource ${URI}` }),
).toHaveAttribute("aria-expanded", "true"); ).toBeInTheDocument();
}); });
it("collapses and re-expands without re-reading (result cached)", async () => { it("collapses and re-expands without re-reading (result cached)", async () => {
@ -74,13 +69,16 @@ describe("ResourceLink", () => {
expect(screen.getByText(/"cached body"/)).toBeInTheDocument(), 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( await user.click(
screen.getByRole("button", { name: `Collapse resource ${URI}` }), 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( await user.click(
screen.getByRole("button", { name: `Expand resource ${URI}` }), screen.getByRole("button", { name: `Expand resource ${URI}` }),
); );
@ -95,7 +93,9 @@ describe("ResourceLink", () => {
<ResourceLink uri={URI} onReadResource={onReadResource} />, <ResourceLink uri={URI} onReadResource={onReadResource} />,
); );
await user.click(screen.getByRole("button")); await user.click(
screen.getByRole("button", { name: `Expand resource ${URI}` }),
);
await waitFor(() => await waitFor(() =>
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(), expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
); );
@ -111,7 +111,9 @@ describe("ResourceLink", () => {
<ResourceLink uri={URI} onReadResource={onReadResource} />, <ResourceLink uri={URI} onReadResource={onReadResource} />,
); );
await user.click(screen.getByRole("button")); await user.click(
screen.getByRole("button", { name: `Expand resource ${URI}` }),
);
await waitFor(() => await waitFor(() =>
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(), expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
); );

View file

@ -1,25 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import { import { Alert, Card, Collapse, ScrollArea, Stack, Text } from "@mantine/core";
Alert,
Loader,
Paper,
ScrollArea,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import type { ReadResourceResult } from "@modelcontextprotocol/client"; import type { ReadResourceResult } from "@modelcontextprotocol/client";
import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
import { ResourceLinkInfo } from "../../elements/ResourceLinkInfo/ResourceLinkInfo"; import { ResourceLinkInfo } from "../../elements/ResourceLinkInfo/ResourceLinkInfo";
export interface ResourceLinkProps { export interface ResourceLinkProps {
/** The linked resource's URI (always shown). */ /** The linked resource's URI (always shown). */
uri: string; uri: string;
/** Optional human-friendly name shown beneath the URI. */ /** Optional human-friendly name shown above the URI. */
name?: string; name?: string;
/** Optional description shown beneath the name. */
description?: string;
/** Optional MIME type shown as a badge. */ /** Optional MIME type shown as a badge. */
mimeType?: string; mimeType?: string;
/** /**
@ -30,15 +20,15 @@ export interface ResourceLinkProps {
onReadResource?: (uri: string) => Promise<ReadResourceResult>; onReadResource?: (uri: string) => Promise<ReadResourceResult>;
} }
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, withBorder: true,
p: "sm", padding: "sm",
radius: "md", radius: "md",
}); variant: "inset",
const HeaderButton = UnstyledButton.withProps({
w: "100%",
ta: "left",
}); });
const ExpandedSection = Stack.withProps({ const ExpandedSection = Stack.withProps({
@ -58,12 +48,6 @@ const ResultScroll = ScrollArea.Autosize.withProps({
offsetScrollbars: true, offsetScrollbars: true,
}); });
const ResourceLabel = Text.withProps({
size: "sm",
fw: 600,
c: "green",
});
const LoadingText = Text.withProps({ const LoadingText = Text.withProps({
size: "sm", size: "sm",
c: "dimmed", c: "dimmed",
@ -79,7 +63,6 @@ const LoadingText = Text.withProps({
export function ResourceLink({ export function ResourceLink({
uri, uri,
name, name,
description,
mimeType, mimeType,
onReadResource, onReadResource,
}: ResourceLinkProps) { }: 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 ? ( const action = expandable ? (
loading ? ( <ExpandToggle
<Loader size="xs" /> expanded={expanded}
) : ( onToggle={() => void toggle()}
<Chevron size={16} aria-hidden /> ariaLabel={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
)
) : undefined;
const info = (
<ResourceLinkInfo
uri={uri}
name={name}
description={description}
mimeType={mimeType}
action={action}
/> />
); ) : undefined;
return ( return (
<LinkCard> <LinkCard>
{expandable ? ( <ResourceLinkInfo
<HeaderButton uri={uri}
onClick={() => void toggle()} name={name}
aria-expanded={expanded} mimeType={mimeType}
aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`} action={action}
> />
{info} {/* Same expand/collapse animation as ProtocolEntry: content stays mounted
</HeaderButton> (so the cached read result survives a collapse) and animates via
) : ( Mantine's Collapse. */}
info {expandable && (
)} <Collapse in={expanded}>
{expanded && ( <ExpandedSection>
<ExpandedSection> {loading ? (
{loading ? ( <LoadingText>Loading resource…</LoadingText>
<LoadingText>Loading resource…</LoadingText> ) : error !== null ? (
) : error !== null ? ( <Alert
<Alert color="red" variant="light" title="Failed to read resource"> color="red"
{error} variant="light"
</Alert> title="Failed to read resource"
) : result !== null ? ( >
<> {error}
<ResourceLabel>Resource:</ResourceLabel> </Alert>
) : result !== null ? (
<ResultScroll> <ResultScroll>
<ContentViewer <ContentViewer
block={{ block={{
@ -165,9 +142,9 @@ export function ResourceLink({
copyable copyable
/> />
</ResultScroll> </ResultScroll>
</> ) : null}
) : null} </ExpandedSection>
</ExpandedSection> </Collapse>
)} )}
</LinkCard> </LinkCard>
); );

View file

@ -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/client"; import type { CallToolResult } from "@modelcontextprotocol/client";
import { Card, Flex } from "@mantine/core";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { ToolResultPanel } from "./ToolResultPanel"; 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) => (
<Flex h={520} direction="column" align="stretch">
<Card withBorder padding="lg" variant="preview" flex={1}>
<Story />
</Card>
</Flex>
),
];
export const Empty: Story = { export const Empty: Story = {
args: { args: {
result: emptyResult, 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 = { export const ErrorResult: Story = {
args: { args: {
result: { result: {

View file

@ -7,6 +7,7 @@ import {
waitFor, waitFor,
} from "../../../test/renderWithMantine"; } from "../../../test/renderWithMantine";
import { ToolResultPanel } from "./ToolResultPanel"; import { ToolResultPanel } from "./ToolResultPanel";
import { resultHasResourceLinks } from "./toolResultUtils";
const okResult: CallToolResult = { const okResult: CallToolResult = {
content: [{ type: "text", text: "ok" }], content: [{ type: "text", text: "ok" }],
@ -41,7 +42,7 @@ describe("ToolResultPanel", () => {
expect(screen.getByText("No results yet")).toBeInTheDocument(); 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 user = userEvent.setup();
const onReadResource = vi.fn().mockResolvedValue({ const onReadResource = vi.fn().mockResolvedValue({
contents: [{ uri: "demo://r/1", text: "linked body" }], contents: [{ uri: "demo://r/1", text: "linked body" }],
@ -60,6 +61,10 @@ describe("ToolResultPanel", () => {
/>, />,
); );
expect(screen.getByText("ok")).toBeInTheDocument(); expect(screen.getByText("ok")).toBeInTheDocument();
// The link sits inside a grouped, labeled box.
expect(
screen.getByRole("heading", { name: "Resource Links" }),
).toBeInTheDocument();
await user.click( await user.click(
screen.getByRole("button", { name: "Expand resource demo://r/1" }), 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(<ToolResultPanel result={result} onClear={() => {}} />);
// 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(<ToolResultPanel result={result} onClear={() => {}} />);
// 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 () => { it("invokes onClear when the close button is clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onClear = vi.fn(); const onClear = vi.fn();
@ -76,4 +116,36 @@ describe("ToolResultPanel", () => {
await user.click(screen.getByRole("button", { name: "Close results" })); await user.click(screen.getByRole("button", { name: "Close results" }));
expect(onClear).toHaveBeenCalledTimes(1); 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);
});
});
}); });

View file

@ -2,6 +2,7 @@ import {
Alert, Alert,
CloseButton, CloseButton,
Group, Group,
Paper,
ScrollArea, ScrollArea,
Stack, Stack,
Text, Text,
@ -13,6 +14,7 @@ import type {
} from "@modelcontextprotocol/client"; } from "@modelcontextprotocol/client";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { ResourceLink } from "../ResourceLink/ResourceLink"; import { ResourceLink } from "../ResourceLink/ResourceLink";
import { resultHasResourceLinks } from "./toolResultUtils";
export interface ToolResultPanelProps { export interface ToolResultPanelProps {
result: CallToolResult; result: CallToolResult;
@ -29,13 +31,44 @@ export interface ToolResultPanelProps {
onReadResource?: (uri: string) => Promise<ReadResourceResult>; onReadResource?: (uri: string) => Promise<ReadResourceResult>;
} }
// Outer column: the header pins (`flex: 0 0 auto`) and the scroll region type ContentBlock = CallToolResult["content"][number];
// absorbs overflow when the enclosing card hits its `mah`. `mih: 0` lets the type ResourceLinkBlock = Extract<ContentBlock, { type: "resource_link" }>;
// flex children shrink below their content's intrinsic height.
// 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({ const PanelStack = Stack.withProps({
gap: "md", gap: "md",
miw: 0, miw: 0,
mih: 0, mih: 0,
flex: 1,
}); });
const HeaderRow = Group.withProps({ const HeaderRow = Group.withProps({
@ -50,10 +83,10 @@ const HeaderLeft = Group.withProps({
wrap: "nowrap", wrap: "nowrap",
}); });
// `0 1 auto` + `mih: 0` lets the scroll region shrink (never grow) so a short // Body scroll region for non-resource-link results: fills the card and scrolls
// result doesn't reserve height while a long one scrolls within the card cap. // within it when the content is taller than the available space.
const ResultScroll = ScrollArea.withProps({ const ResultScroll = ScrollArea.withProps({
flex: "0 1 auto", flex: 1,
miw: 0, miw: 0,
mih: 0, mih: 0,
type: "auto", type: "auto",
@ -65,11 +98,138 @@ const ResultStack = Stack.withProps({
gap: "md", 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<ReadResourceResult>;
}) {
return (
<ResourceLinksBox>
<ResourceLinksInner>
<ResourceLinksHeader>Resource Links</ResourceLinksHeader>
<ResourceLinksScroll>
<ResourceLinksStack>
{links.map(({ block, index }) => (
<ResourceLink
key={index}
uri={block.uri}
name={block.name}
mimeType={block.mimeType}
onReadResource={onReadResource}
/>
))}
</ResourceLinksStack>
</ResourceLinksScroll>
</ResourceLinksInner>
</ResourceLinksBox>
);
}
export function ToolResultPanel({ export function ToolResultPanel({
result, result,
onClear, onClear,
onReadResource, onReadResource,
}: ToolResultPanelProps) { }: 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 (
<ResourceLinksGroup
key={`links-${segment.links[0].index}`}
links={segment.links}
onReadResource={onReadResource}
/>
);
}
const viewer = (
<ContentViewer
key={segment.index}
block={segment.block}
copyable={segment.block.type === "text"}
/>
);
// 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 ? (
<NonLinkCap key={segment.index}>{viewer}</NonLinkCap>
) : (
viewer
);
});
return ( return (
<PanelStack> <PanelStack>
<HeaderRow> <HeaderRow>
@ -83,39 +243,26 @@ export function ToolResultPanel({
</Title> </Title>
</HeaderLeft> </HeaderLeft>
</HeaderRow> </HeaderRow>
<ResultScroll> {result.isError ? (
<ResultStack> <ResultScroll>
{result.isError ? ( <Alert color="red" variant="light" title="Tool Error">
<Alert color="red" variant="light" title="Tool Error"> {result.content
{result.content .filter((b) => b.type === "text")
.filter((b) => b.type === "text") .map((b) => b.text)
.map((b) => b.text) .join("\n")}
.join("\n")} </Alert>
</Alert> </ResultScroll>
) : result.content.length === 0 ? ( ) : result.content.length === 0 ? (
<Text c="dimmed">No results yet</Text> <ResultScroll>
) : ( <Text c="dimmed">No results yet</Text>
result.content.map((block, index) => </ResultScroll>
block.type === "resource_link" ? ( ) : hasLinks ? (
<ResourceLink <FillStack>{segmentNodes}</FillStack>
key={index} ) : (
uri={block.uri} <ResultScroll>
name={block.name} <ResultStack>{segmentNodes}</ResultStack>
description={block.description} </ResultScroll>
mimeType={block.mimeType} )}
onReadResource={onReadResource}
/>
) : (
<ContentViewer
key={index}
block={block}
copyable={block.type === "text"}
/>
),
)
)}
</ResultStack>
</ResultScroll>
</PanelStack> </PanelStack>
); );
} }

View file

@ -0,0 +1,14 @@
import type { CallToolResult } from "@modelcontextprotocol/client";
/**
* 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")
);
}

View file

@ -163,7 +163,7 @@ describe("ToolsScreen", () => {
expect(screen.queryByText("Results")).toBeNull(); 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( renderWithMantine(
<ToolsScreen <ToolsScreen
{...baseProps} {...baseProps}
@ -174,6 +174,34 @@ describe("ToolsScreen", () => {
/>, />,
); );
expect(screen.getByText("Results")).toBeInTheDocument(); 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(
<ToolsScreen
{...baseProps}
callState={{
status: "ok",
result: {
content: [
{ type: "resource_link", uri: "demo://r/1", name: "Linked" },
],
},
}}
/>,
);
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 () => { it("invokes onCallTool with form values on Execute", async () => {

View file

@ -10,6 +10,7 @@ import {
type ToolProgress, type ToolProgress,
} from "../../groups/ToolDetailPanel/ToolDetailPanel"; } from "../../groups/ToolDetailPanel/ToolDetailPanel";
import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel"; import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel";
import { resultHasResourceLinks } from "../../groups/ToolResultPanel/toolResultUtils";
import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils"; import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils";
export interface ToolCallState { export interface ToolCallState {
@ -184,7 +185,14 @@ export function ToolsScreen({
// `result` (App.tsx), so the executing form (progress + cancel) shows // `result` (App.tsx), so the executing form (progress + cancel) shows
// until the result lands. // until the result lands.
<ContentPane mah={SCROLL_MAX_HEIGHT}> <ContentPane mah={SCROLL_MAX_HEIGHT}>
<ContentCard> {/* 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. */}
<ContentCard
flex={resultHasResourceLinks(callState.result) ? 1 : undefined}
>
<ToolResultPanel <ToolResultPanel
result={callState.result} result={callState.result}
onClear={() => onClearResult?.()} onClear={() => onClearResult?.()}