mirror of
https://github.com/coder/coder.git
synced 2026-09-22 21:22:17 +08:00
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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user