* 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>
268 lines
8 KiB
TypeScript
268 lines
8 KiB
TypeScript
import {
|
|
Alert,
|
|
CloseButton,
|
|
Group,
|
|
Paper,
|
|
ScrollArea,
|
|
Stack,
|
|
Text,
|
|
Title,
|
|
} from "@mantine/core";
|
|
import type {
|
|
CallToolResult,
|
|
ReadResourceResult,
|
|
} 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;
|
|
/**
|
|
* Dismiss the result and return to the input form (#1661). Mirrors the
|
|
* Prompts screen: the result replaces the form while present, and the
|
|
* top-left X flips back to the form so the tool can be re-run.
|
|
*/
|
|
onClear: () => void;
|
|
/**
|
|
* Read-on-demand handler so `resource_link` blocks in the result can fetch
|
|
* and inline their contents.
|
|
*/
|
|
onReadResource?: (uri: string) => Promise<ReadResourceResult>;
|
|
}
|
|
|
|
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({
|
|
justify: "space-between",
|
|
flex: "0 0 auto",
|
|
});
|
|
|
|
// Close button + title, mirroring PromptMessagesDisplay so the two result
|
|
// panels dismiss the same way.
|
|
const HeaderLeft = Group.withProps({
|
|
gap: "xs",
|
|
wrap: "nowrap",
|
|
});
|
|
|
|
// 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: 1,
|
|
miw: 0,
|
|
mih: 0,
|
|
type: "auto",
|
|
scrollbars: "y",
|
|
offsetScrollbars: true,
|
|
});
|
|
|
|
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>
|
|
<HeaderLeft>
|
|
<CloseButton aria-label="Close results" onClick={onClear} />
|
|
{/* h3 (not h4), size h4: request modals open over the Tools screen
|
|
with an `h2` `Modal.Title`, so an `h4` here would skip a level
|
|
(axe `heading-order`); `size="h4"` preserves the visual size. */}
|
|
<Title order={3} size="h4">
|
|
Results
|
|
</Title>
|
|
</HeaderLeft>
|
|
</HeaderRow>
|
|
{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>
|
|
);
|
|
}
|