feat: collapse MCP tool results by default (#23568)

Wraps the `GenericToolRenderer` (used for MCP and unrecognized tools) in
`ToolCollapsible` so the result content is hidden behind a
click-to-expand chevron, matching the pattern used by `read_file`,
`write_file`, and other built-in tool renderers.

### Changes

- Move `ToolIcon` + `ToolLabel` into the `ToolCollapsible` `header` prop
- Compute `hasContent` from `writeFileDiff` / `fileContent` /
`resultOutput` — when there's no content, the header renders as a plain
div with no chevron
- Remove `ml-6` from `ScrollArea` classNames (the `ToolCollapsible`
button handles its own layout)
- `defaultExpanded` is `false` by default in `ToolCollapsible`, so
results start collapsed

### Before

MCP tool results were always fully visible inline.

### After

MCP tool results are collapsed by default with a chevron toggle,
consistent with `read_file`, `edit_files`, `list_templates`, etc.
This commit is contained in:
Kyle Carberry
2026-03-25 12:47:57 +00:00
committed by GitHub
parent ae9174daff
commit 07dbee69df
3 changed files with 282 additions and 30 deletions
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, spyOn, userEvent, within } from "storybook/test";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { Tool } from "./tool";
@@ -469,6 +469,187 @@ export const TaskNameGenericRendering: Story = {
},
};
// ---------------------------------------------------------------------------
// MCP tool stories (generic renderer with MCP server context)
// ---------------------------------------------------------------------------
const sampleMCPServers = [
{
id: "mcp-server-1",
slug: "linear",
display_name: "Linear",
description: "Project management",
icon_url: "https://linear.app/favicon.ico",
transport: "streamable_http",
url: "https://mcp.linear.app",
auth_type: "oauth2",
has_oauth2_secret: false,
has_api_key: false,
has_custom_headers: false,
tool_allow_list: [],
tool_deny_list: [],
availability: "default_on",
enabled: true,
auth_connected: true,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
},
] satisfies readonly import("api/typesGenerated").MCPServerConfig[];
export const MCPToolRunning: Story = {
args: {
name: "linear__list_issues",
status: "running",
args: { project: "backend" },
mcpServerConfigId: "mcp-server-1",
mcpServers: sampleMCPServers,
},
play: async ({ canvasElement }) => {
// Spinner should be visible while running.
expect(canvasElement.querySelector(".animate-spin")).not.toBeNull();
// Icon should be monochrome (brightness-0 filter).
const icon = canvasElement.querySelector(".brightness-0");
expect(icon).not.toBeNull();
},
};
export const MCPToolCompleted: Story = {
args: {
name: "linear__list_issues",
status: "completed",
args: { project: "backend" },
result: {
issues: [
{ id: "LIN-123", title: "Fix auth flow" },
{ id: "LIN-456", title: "Update dashboard" },
],
},
mcpServerConfigId: "mcp-server-1",
mcpServers: sampleMCPServers,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// No spinner when completed.
expect(canvasElement.querySelector(".animate-spin")).toBeNull();
// Icon should still be monochrome when completed.
expect(canvasElement.querySelector(".brightness-0")).not.toBeNull();
// Result should be collapsed by default.
const toggle = canvas.getByRole("button");
expect(toggle).toBeInTheDocument();
// Expand to see result content.
await userEvent.click(toggle);
// @pierre/diffs renders inside a Shadow DOM (<diffs-container>)
// so textContent on the host element can't see the content.
// Query into the shadow root to verify the JSON rendered.
await waitFor(() => {
const shadow = canvasElement.querySelector("diffs-container")?.shadowRoot;
expect(shadow?.textContent).toContain("Fix auth flow");
});
},
};
export const MCPToolError: Story = {
args: {
name: "linear__list_issues",
status: "error",
isError: true,
args: { project: "backend" },
result: { error: "Authentication token expired" },
mcpServerConfigId: "mcp-server-1",
mcpServers: sampleMCPServers,
},
play: async ({ canvasElement }) => {
// Error alert icon should be present.
expect(canvasElement.querySelector(".lucide-circle-alert")).not.toBeNull();
// Label text should use the destructive color.
const label = canvasElement.querySelector(
".\\[\\&\\>\\*\\]\\:text-content-destructive",
);
expect(label).not.toBeNull();
},
};
export const MCPToolNoResult: Story = {
args: {
name: "linear__create_issue",
status: "completed",
args: { title: "New issue" },
mcpServerConfigId: "mcp-server-1",
mcpServers: sampleMCPServers,
},
play: async ({ canvasElement }) => {
// No toggle button when there is no result content.
expect(canvasElement.querySelector("button")).toBeNull();
},
};
export const MCPToolSlackIcon: Story = {
args: {
name: "slack__post_message",
status: "completed",
result: { ok: true, channel: "#general" },
mcpServerConfigId: "mcp-server-1",
mcpServers: [
{
...sampleMCPServers[0],
slug: "slack",
display_name: "Slack",
icon_url:
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d5/Slack_icon_2019.svg/500px-Slack_icon_2019.svg.png",
},
],
},
};
export const MCPToolGitHubIcon: Story = {
args: {
name: "github__list_prs",
status: "completed",
result: { prs: [{ id: 1, title: "Fix bug" }] },
mcpServerConfigId: "mcp-server-1",
mcpServers: [
{
...sampleMCPServers[0],
slug: "github",
display_name: "GitHub",
icon_url:
"https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg",
},
],
},
};
export const MCPToolFigmaIcon: Story = {
args: {
name: "figma__get_file",
status: "completed",
result: { file: "design.fig" },
mcpServerConfigId: "mcp-server-1",
mcpServers: [
{
...sampleMCPServers[0],
slug: "figma",
display_name: "Figma",
icon_url:
"https://upload.wikimedia.org/wikipedia/commons/3/33/Figma-logo.svg",
},
],
},
};
export const MCPToolNoServer: Story = {
args: {
name: "some_custom_tool",
status: "completed",
result: { output: "Tool finished successfully" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Falls through to generic wrench icon + raw tool name.
expect(canvas.getByText("some_custom_tool")).toBeInTheDocument();
},
};
// ---------------------------------------------------------------------------
// WriteFile stories
// ---------------------------------------------------------------------------
+51 -18
View File
@@ -1,9 +1,15 @@
import { useTheme } from "@emotion/react";
import { FileDiff, File as FileViewer } from "@pierre/diffs/react";
import type * as TypesGen from "api/typesGenerated";
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
import { type ComponentPropsWithRef, type FC, memo } from "react";
import { cn } from "utils/cn";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { ChatSummarizedTool } from "./ChatSummarizedTool";
import { ComputerTool } from "./ComputerTool";
import { CreateWorkspaceTool } from "./CreateWorkspaceTool";
@@ -19,6 +25,7 @@ import { ProposePlanTool } from "./ProposePlanTool";
import { ReadFileTool } from "./ReadFileTool";
import { ReadTemplateTool } from "./ReadTemplateTool";
import { SubagentTool } from "./SubagentTool";
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolLabel } from "./ToolLabel";
import {
@@ -480,24 +487,50 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
? mcpServers?.find((s) => s.id === mcpServerConfigId)
: undefined;
const hasContent = Boolean(writeFileDiff || fileContent || resultOutput);
const isRunning = status === "running";
const rec = asRecord(result);
const errorMessage = rec ? asString(rec.error || rec.message) : "";
return (
<>
<div className="flex items-center gap-2">
<ToolIcon
name={name}
isError={status === "error" || isError}
iconUrl={mcpServer?.icon_url}
/>
<ToolLabel
name={name}
args={args}
result={result}
mcpSlug={mcpServer?.slug}
/>
</div>
<ToolCollapsible
hasContent={hasContent}
header={
<>
<ToolIcon
name={name}
isError={status === "error" || isError}
iconUrl={mcpServer?.icon_url}
isRunning={isRunning}
serverName={mcpServer?.display_name}
/>
<span className={cn(isError && "[&>*]:text-content-destructive")}>
<ToolLabel
name={name}
args={args}
result={result}
mcpSlug={mcpServer?.slug}
/>
</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Tool call failed"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
</>
}
>
{writeFileDiff ? (
<ScrollArea
className="mt-1.5 ml-6 rounded-md border border-solid border-border-default text-2xs"
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
@@ -509,7 +542,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
</ScrollArea>
) : fileContent ? (
<ScrollArea
className="mt-1.5 ml-6 rounded-md border border-solid border-border-default text-2xs"
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
@@ -524,7 +557,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
) : (
resultOutput && (
<ScrollArea
className="mt-1.5 ml-6 rounded-md border border-solid border-border-default text-2xs"
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
@@ -539,7 +572,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
</ScrollArea>
)
)}
</>
</ToolCollapsible>
);
};
@@ -12,34 +12,72 @@ import type React from "react";
import { useState } from "react";
import { cn } from "utils/cn";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
export const ToolIcon: React.FC<{
name: string;
isError: boolean;
iconUrl?: string;
}> = ({ name, isError, iconUrl }) => {
isRunning?: boolean;
serverName?: string;
}> = ({ name, isError, iconUrl, isRunning, serverName }) => {
const [imgError, setImgError] = useState(false);
const color = isError ? "text-content-destructive" : "text-content-secondary";
const base = cn("h-4 w-4 shrink-0", color);
const base = cn("h-4 w-4 shrink-0", color, isRunning && "grayscale");
// If an MCP icon URL is provided and hasn't failed, render it.
// Strip colour so external icons match the monochrome lucide
// style. brightness-0 forces every pixel to black, then in dark
// mode we invert to white and tune opacity to approximate
// content-secondary (light ≈ 34% lightness, dark ≈ 65%).
if (iconUrl && !imgError) {
return (
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center",
"rounded-full bg-surface-secondary",
isError && "ring-1 ring-content-destructive",
)}
>
// Always render the same DOM shape so React never unmounts
// the <img> when isError changes (avoids a reload flicker).
//
// The wrapper clips a translated copy of the image so the
// drop-shadow trick can work on error (see image classes).
// In the normal state the image is not translated and the
// wrapper's overflow-hidden is a harmless no-op.
const img = (
<div className="h-4 w-4 shrink-0 overflow-hidden">
<ExternalImage
src={iconUrl}
alt={`${name} icon`}
className="h-3 w-3"
className={cn(
"block h-4 w-4",
isError
? // Drop-shadow recolor: brightness-0 makes every
// pixel black, drop-shadow casts an exact-color
// copy 16px to the right following the alpha
// channel, -translate-x-4 shifts the colored copy
// into the original position, and the wrapper's
// overflow-hidden clips the black original.
"-translate-x-4 [filter:brightness(0)_drop-shadow(16px_0_0_hsl(var(--content-destructive)))]"
: // Monochrome: brightness-0 strips colour to black,
// dark:invert flips to white for dark backgrounds,
// opacity tuned per-theme to match content-secondary
// (light ~35% lightness, dark ~65%).
"brightness-0 opacity-[0.35] dark:invert dark:opacity-[0.65]",
)}
onError={() => setImgError(true)}
/>
</div>
);
if (serverName) {
return (
<Tooltip>
<TooltipTrigger asChild>{img}</TooltipTrigger>
<TooltipContent>{serverName}</TooltipContent>
</Tooltip>
);
}
return img;
}
switch (name) {