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:
Cliff Hall 2026-07-15 14:07:27 -04:00 • committed by GitHub
commit 8f70b60725
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 591 additions and 197 deletions

View file

@ -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(),
);
},
};

View file

@ -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(),
);

View file

@ -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>
);