web: Group tool-result resource_links in a scrollable "Resource Links" box with Protocol colors (#1686)
* web: Group tool-result resource_links in a scrollable "Resource Links" box with Protocol colors Closes #1685 - ToolResultPanel: coalesce consecutive `resource_link` blocks into a single bordered, internally-scrollable "Resource Links" box (mirroring the "Messages" box in the Protocol monitoring sidebar). Non-link blocks and non-adjacent runs are preserved in order via `segmentContent`. - ResourceLink: render each link card on the recessed `inset` surface (`Card variant="inset"`), matching ProtocolEntry's colors in light and dark modes; nested read-result Code blocks read as raised on the grey card via the inset cascade variable. - Drop redundant metadata: the resource `description` line (ResourceLinkInfo, and thus ContentViewer/ResourceLink) and the green "Resource:" label above the expanded read result. - Update tests + stories for the removed props/label and the new grouping box; add multi-link and split-run coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwZGbocZ64yN9bgb2mj1qE * web: keep the resource_link description; only drop the "Resource:" label Reverts the description removal from the previous commit per review — the `description` line is retained on ResourceLinkInfo (and its ContentViewer / ResourceLink callers). Only the redundant green "Resource:" label above the expanded read result stays removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwZGbocZ64yN9bgb2mj1qE * web: drop the resource_link description from the link cards The `description` is generated by the server and can be misleading — e.g. a blob resource served with a `text/plain` mimeType gets described as a "plaintext resource" even though the payload is a blob. Rather than surface that nonsense text, drop the description line from ResourceLinkInfo (and its ContentViewer / ResourceLink callers) entirely. Reverts the description-retention from the prior commit; the grouped "Resource Links" box and the "Resource:" label removal are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwZGbocZ64yN9bgb2mj1qE * web: refine resource-link cards — URI-below-name + copy, ProtocolEntry toggle, full-height box Follow-up refinements to the Resource Links display: - ResourceLinkInfo: move the URI below the name and add a CopyButton beside it so the URI can be copied. The name + MIME badge (and the optional expand control) sit on the header row; the URI + copy sit on the row below. - ResourceLink: replace the bespoke chevron with the same tooltip'd ExpandToggle used by ProtocolEntry, and animate the read-result panel with Mantine's Collapse (same as ProtocolEntry). The toggle is a sibling of the URI copy button (no card-wide wrapping button) so the two real buttons never nest (axe `nested-interactive`). - ToolResultPanel: when a result contains a Resource Links box, fill the card's height so the box grows into the available space and scrolls internally (pinned "Resource Links" heading), instead of capping at a fixed content height. Plain text/image results keep the scroll-within-card body. - ToolsScreen: give the result card `flex: 1` so it fills the pane, providing the definite height the Resource Links box grows into. - Tests/stories updated for the new layout, the Expand/Collapse toggle labels, the mounted-through-collapse read result, and the full-height box (with a height-bounded decorator on the ResourceLinks story). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwZGbocZ64yN9bgb2mj1qE * web: align resource-link card with ProtocolEntry — toggle row, badge, URI style Bring ResourceLinkInfo closer to the ProtocolEntry message card: - Move the expand/collapse toggle to the second (URI) row, opposite the copy button — mirroring ProtocolEntry, whose toggle sits on the row below the badges. The MIME badge stays on the header row's right, beside the name. - MIME badge: bump to `size="md"` so the lowercase MIME text matches the point size of ProtocolEntry's uppercase method/status badges, and make it a solid dark-blue fill with white text in dark mode (keeping the blue-light tint in light mode) instead of a washed-out translucent chip. - Render the URI like ProtocolEntry/NetworkEntry render URLs — `sm` / `fw: 500` in the default sans-serif face and text color — rather than a blue monospace "link". `monoBreak` is kept only for long-URI wrapping. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwZGbocZ64yN9bgb2mj1qE * web: scope result-card fill to resource-link results (review follow-up) Code-review finding: ToolsScreen gave the result card `flex={1}` unconditionally, so plain text/image/error results also filled the full pane height — leaving a tall empty card and a height jump versus the input-form state. Fill is only needed so a "Resource Links" box can grow and scroll. Gate the fill on a new `resultHasResourceLinks(result)` helper (extracted to `toolResultUtils.ts` so the component module keeps exporting only components, per react-refresh/only-export-components). Non-link results keep their content-sized card; link results still fill and scroll internally. Tests: unit tests for the helper (link / text-only / empty / error) and a ToolsScreen test that a resource-link result renders the Resource Links box (covering the `flex={1}` branch). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwZGbocZ64yN9bgb2mj1qE * web: prettier-format the resultHasResourceLinks error-case test fixture The error-case fixture in ToolResultPanel.test.tsx wasn't re-formatted after adding the required `name` field, failing CI's `format:check`. No logic change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwZGbocZ64yN9bgb2mj1qE * web: cap a non-link block above the Resource Links box at 50% height Addresses the mixed-content case from the review: a long text block preceding the links would take its full natural height and push the Resource Links box almost entirely off-screen (only the first card peeking at the bottom). In the links-containing result, wrap each non-link block in a ScrollArea.Autosize capped at `mah: 50%` (flex "0 1 auto"), so it scrolls within at most half the card and the links box keeps the rest. A short block still takes only its content height; a result with no links renders non-link blocks uncapped in the outer scroll body as before. Adds a `ResourceLinksWithLongText` story (full-height decorator) exercising the cap. Verified visually: the text scrolls within ~50% and the links box fills the remainder. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwZGbocZ64yN9bgb2mj1qE * web: address review — expand-toggle a11y, stale JSDoc, stronger fill test Review follow-ups on PR #1686: - ExpandToggle: add an optional `ariaLabel` (defaults to the tooltip verb, so ProtocolEntry/NetworkEntry/Task are unchanged) and expose `aria-expanded`. ResourceLink passes a per-resource label ("Expand/Collapse resource <uri>") so multiple link toggles are distinguishable to assistive tech and announce their state — restoring the a11y the card-wide button previously provided, while keeping the visible tooltip a plain "Expand"/"Collapse". - ResourceLinkInfo: fix the stale JSDoc that still described the URI as "monospace, link-styled" — it now renders sans-serif sm/fw:500 default color. - ToolsScreen test: assert the result card actually gets `flex: 1` for a links result and NOT for a plain text result, so the test verifies the fill branch it names rather than only that the heading renders. Tests: ExpandToggle gains ariaLabel + aria-expanded cases; ResourceLink / ToolResultPanel / stories updated for the per-resource toggle name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwZGbocZ64yN9bgb2mj1qE * web: restore cli/tui/launcher package-locks accidentally committed These lock files were touched by a local install in the worktree (dropping the `version` field and adding optional @emnapi platform deps); they are unrelated to this PR. Restore them to their v2/main state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwZGbocZ64yN9bgb2mj1qE --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8d33e76f97
commit
8f70b60725
16 changed files with 591 additions and 197 deletions
|
|
@ -122,14 +122,16 @@ describe("ContentViewer", () => {
|
|||
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();
|
||||
// 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", () => {
|
||||
|
|
|
|||
|
|
@ -367,7 +367,6 @@ function BlockContent({
|
|||
<ResourceLinkInfo
|
||||
uri={block.uri}
|
||||
name={block.name}
|
||||
description={block.description}
|
||||
mimeType={block.mimeType}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<Icon size={16} />
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export const Full: Story = {
|
|||
args: {
|
||||
uri: "file:///docs/readme.md",
|
||||
name: "Readme",
|
||||
description: "Project documentation",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ResourceLinkInfo
|
||||
uri={URI}
|
||||
name="Readme"
|
||||
description="The project readme"
|
||||
mimeType="text/markdown"
|
||||
/>,
|
||||
<ResourceLinkInfo uri={URI} name="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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<HeaderStack>
|
||||
<UriRow>
|
||||
<UriText>{uri}</UriText>
|
||||
<MetaGroup>
|
||||
{hasHeader && (
|
||||
<HeaderRow justify={name ? "space-between" : "flex-end"}>
|
||||
{name && <NameText>{name}</NameText>}
|
||||
{mimeType && <MimeBadge>{mimeType}</MimeBadge>}
|
||||
{action}
|
||||
</MetaGroup>
|
||||
</HeaderRow>
|
||||
)}
|
||||
<UriRow>
|
||||
<UriCluster>
|
||||
<CopyButton value={uri} />
|
||||
<UriText>{uri}</UriText>
|
||||
</UriCluster>
|
||||
{action}
|
||||
</UriRow>
|
||||
{name && <NameText>{name}</NameText>}
|
||||
{description && <DescriptionText>{description}</DescriptionText>}
|
||||
</HeaderStack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ResourceLink
|
||||
uri={URI}
|
||||
name="Readme"
|
||||
description="The project readme"
|
||||
mimeType="text/markdown"
|
||||
/>,
|
||||
<ResourceLink uri={URI} name="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", () => {
|
||||
it("renders no expand control without onReadResource", () => {
|
||||
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 () => {
|
||||
|
|
@ -42,22 +40,19 @@ describe("ResourceLink", () => {
|
|||
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
||||
);
|
||||
|
||||
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", () => {
|
|||
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
||||
);
|
||||
|
||||
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", () => {
|
|||
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
||||
);
|
||||
|
||||
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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
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 ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<Chevron size={16} aria-hidden />
|
||||
)
|
||||
) : undefined;
|
||||
|
||||
const info = (
|
||||
<ResourceLinkInfo
|
||||
uri={uri}
|
||||
name={name}
|
||||
description={description}
|
||||
mimeType={mimeType}
|
||||
action={action}
|
||||
<ExpandToggle
|
||||
expanded={expanded}
|
||||
onToggle={() => void toggle()}
|
||||
ariaLabel={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
|
||||
/>
|
||||
);
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<LinkCard>
|
||||
{expandable ? (
|
||||
<HeaderButton
|
||||
onClick={() => void toggle()}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
|
||||
>
|
||||
{info}
|
||||
</HeaderButton>
|
||||
) : (
|
||||
info
|
||||
)}
|
||||
{expanded && (
|
||||
<ExpandedSection>
|
||||
{loading ? (
|
||||
<LoadingText>Loading resource…</LoadingText>
|
||||
) : error !== null ? (
|
||||
<Alert color="red" variant="light" title="Failed to read resource">
|
||||
{error}
|
||||
</Alert>
|
||||
) : result !== null ? (
|
||||
<>
|
||||
<ResourceLabel>Resource:</ResourceLabel>
|
||||
<ResourceLinkInfo
|
||||
uri={uri}
|
||||
name={name}
|
||||
mimeType={mimeType}
|
||||
action={action}
|
||||
/>
|
||||
{/* Same expand/collapse animation as ProtocolEntry: content stays mounted
|
||||
(so the cached read result survives a collapse) and animates via
|
||||
Mantine's Collapse. */}
|
||||
{expandable && (
|
||||
<Collapse in={expanded}>
|
||||
<ExpandedSection>
|
||||
{loading ? (
|
||||
<LoadingText>Loading resource…</LoadingText>
|
||||
) : error !== null ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
title="Failed to read resource"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
) : result !== null ? (
|
||||
<ResultScroll>
|
||||
<ContentViewer
|
||||
block={{
|
||||
|
|
@ -165,9 +142,9 @@ export function ResourceLink({
|
|||
copyable
|
||||
/>
|
||||
</ResultScroll>
|
||||
</>
|
||||
) : null}
|
||||
</ExpandedSection>
|
||||
) : null}
|
||||
</ExpandedSection>
|
||||
</Collapse>
|
||||
)}
|
||||
</LinkCard>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<Flex h={520} direction="column" align="stretch">
|
||||
<Card withBorder padding="lg" variant="preview" flex={1}>
|
||||
<Story />
|
||||
</Card>
|
||||
</Flex>
|
||||
),
|
||||
];
|
||||
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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(<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 () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ReadResourceResult>;
|
||||
}
|
||||
|
||||
// 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<ContentBlock, { type: "resource_link" }>;
|
||||
|
||||
// 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<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({
|
||||
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 (
|
||||
<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 (
|
||||
<PanelStack>
|
||||
<HeaderRow>
|
||||
|
|
@ -83,39 +243,26 @@ export function ToolResultPanel({
|
|||
</Title>
|
||||
</HeaderLeft>
|
||||
</HeaderRow>
|
||||
<ResultScroll>
|
||||
<ResultStack>
|
||||
{result.isError ? (
|
||||
<Alert color="red" variant="light" title="Tool Error">
|
||||
{result.content
|
||||
.filter((b) => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("\n")}
|
||||
</Alert>
|
||||
) : result.content.length === 0 ? (
|
||||
<Text c="dimmed">No results yet</Text>
|
||||
) : (
|
||||
result.content.map((block, index) =>
|
||||
block.type === "resource_link" ? (
|
||||
<ResourceLink
|
||||
key={index}
|
||||
uri={block.uri}
|
||||
name={block.name}
|
||||
description={block.description}
|
||||
mimeType={block.mimeType}
|
||||
onReadResource={onReadResource}
|
||||
/>
|
||||
) : (
|
||||
<ContentViewer
|
||||
key={index}
|
||||
block={block}
|
||||
copyable={block.type === "text"}
|
||||
/>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</ResultStack>
|
||||
</ResultScroll>
|
||||
{result.isError ? (
|
||||
<ResultScroll>
|
||||
<Alert color="red" variant="light" title="Tool Error">
|
||||
{result.content
|
||||
.filter((b) => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("\n")}
|
||||
</Alert>
|
||||
</ResultScroll>
|
||||
) : result.content.length === 0 ? (
|
||||
<ResultScroll>
|
||||
<Text c="dimmed">No results yet</Text>
|
||||
</ResultScroll>
|
||||
) : hasLinks ? (
|
||||
<FillStack>{segmentNodes}</FillStack>
|
||||
) : (
|
||||
<ResultScroll>
|
||||
<ResultStack>{segmentNodes}</ResultStack>
|
||||
</ResultScroll>
|
||||
)}
|
||||
</PanelStack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
<ToolsScreen
|
||||
{...baseProps}
|
||||
|
|
@ -174,6 +174,34 @@ describe("ToolsScreen", () => {
|
|||
/>,
|
||||
);
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
<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
|
||||
result={callState.result}
|
||||
onClear={() => onClearResult?.()}
|
||||
|
|
|
|||
Loading…
Reference in a new issue