From 07dbee69dff7c4ebecae741c84daeb04e720c926 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 25 Mar 2026 08:47:57 -0400 Subject: [PATCH] feat: collapse MCP tool results by default (#23568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/ai-elements/tool.stories.tsx | 183 +++++++++++++++++- site/src/components/ai-elements/tool/Tool.tsx | 69 +++++-- .../components/ai-elements/tool/ToolIcon.tsx | 60 ++++-- 3 files changed, 282 insertions(+), 30 deletions(-) diff --git a/site/src/components/ai-elements/tool.stories.tsx b/site/src/components/ai-elements/tool.stories.tsx index d92288198b..35971f20f1 100644 --- a/site/src/components/ai-elements/tool.stories.tsx +++ b/site/src/components/ai-elements/tool.stories.tsx @@ -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 () + // 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 // --------------------------------------------------------------------------- diff --git a/site/src/components/ai-elements/tool/Tool.tsx b/site/src/components/ai-elements/tool/Tool.tsx index f9f5cda72f..d6f286ffd1 100644 --- a/site/src/components/ai-elements/tool/Tool.tsx +++ b/site/src/components/ai-elements/tool/Tool.tsx @@ -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 = ({ ? 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 ( - <> -
- - -
+ + + *]:text-content-destructive")}> + + + {isError && ( + + + + + + {errorMessage || "Tool call failed"} + + + )} + {isRunning && ( + + )} + + } + > {writeFileDiff ? ( @@ -509,7 +542,7 @@ const GenericToolRenderer: FC = ({ ) : fileContent ? ( @@ -524,7 +557,7 @@ const GenericToolRenderer: FC = ({ ) : ( resultOutput && ( @@ -539,7 +572,7 @@ const GenericToolRenderer: FC = ({ ) )} - + ); }; diff --git a/site/src/components/ai-elements/tool/ToolIcon.tsx b/site/src/components/ai-elements/tool/ToolIcon.tsx index eed1a7371b..80280f0f2b 100644 --- a/site/src/components/ai-elements/tool/ToolIcon.tsx +++ b/site/src/components/ai-elements/tool/ToolIcon.tsx @@ -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 ( -
+ // Always render the same DOM shape so React never unmounts + // the 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 = ( +
setImgError(true)} />
); + + if (serverName) { + return ( + + {img} + {serverName} + + ); + } + + return img; } switch (name) {