mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +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 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 { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||||
import { Tool } from "./tool";
|
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
|
// WriteFile stories
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useTheme } from "@emotion/react";
|
import { useTheme } from "@emotion/react";
|
||||||
import { FileDiff, File as FileViewer } from "@pierre/diffs/react";
|
import { FileDiff, File as FileViewer } from "@pierre/diffs/react";
|
||||||
import type * as TypesGen from "api/typesGenerated";
|
import type * as TypesGen from "api/typesGenerated";
|
||||||
|
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
|
||||||
import { type ComponentPropsWithRef, type FC, memo } from "react";
|
import { type ComponentPropsWithRef, type FC, memo } from "react";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "#/components/Tooltip/Tooltip";
|
||||||
import { ChatSummarizedTool } from "./ChatSummarizedTool";
|
import { ChatSummarizedTool } from "./ChatSummarizedTool";
|
||||||
import { ComputerTool } from "./ComputerTool";
|
import { ComputerTool } from "./ComputerTool";
|
||||||
import { CreateWorkspaceTool } from "./CreateWorkspaceTool";
|
import { CreateWorkspaceTool } from "./CreateWorkspaceTool";
|
||||||
@@ -19,6 +25,7 @@ import { ProposePlanTool } from "./ProposePlanTool";
|
|||||||
import { ReadFileTool } from "./ReadFileTool";
|
import { ReadFileTool } from "./ReadFileTool";
|
||||||
import { ReadTemplateTool } from "./ReadTemplateTool";
|
import { ReadTemplateTool } from "./ReadTemplateTool";
|
||||||
import { SubagentTool } from "./SubagentTool";
|
import { SubagentTool } from "./SubagentTool";
|
||||||
|
import { ToolCollapsible } from "./ToolCollapsible";
|
||||||
import { ToolIcon } from "./ToolIcon";
|
import { ToolIcon } from "./ToolIcon";
|
||||||
import { ToolLabel } from "./ToolLabel";
|
import { ToolLabel } from "./ToolLabel";
|
||||||
import {
|
import {
|
||||||
@@ -480,24 +487,50 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
|||||||
? mcpServers?.find((s) => s.id === mcpServerConfigId)
|
? mcpServers?.find((s) => s.id === mcpServerConfigId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const hasContent = Boolean(writeFileDiff || fileContent || resultOutput);
|
||||||
|
const isRunning = status === "running";
|
||||||
|
const rec = asRecord(result);
|
||||||
|
const errorMessage = rec ? asString(rec.error || rec.message) : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ToolCollapsible
|
||||||
<div className="flex items-center gap-2">
|
hasContent={hasContent}
|
||||||
<ToolIcon
|
header={
|
||||||
name={name}
|
<>
|
||||||
isError={status === "error" || isError}
|
<ToolIcon
|
||||||
iconUrl={mcpServer?.icon_url}
|
name={name}
|
||||||
/>
|
isError={status === "error" || isError}
|
||||||
<ToolLabel
|
iconUrl={mcpServer?.icon_url}
|
||||||
name={name}
|
isRunning={isRunning}
|
||||||
args={args}
|
serverName={mcpServer?.display_name}
|
||||||
result={result}
|
/>
|
||||||
mcpSlug={mcpServer?.slug}
|
<span className={cn(isError && "[&>*]:text-content-destructive")}>
|
||||||
/>
|
<ToolLabel
|
||||||
</div>
|
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 ? (
|
{writeFileDiff ? (
|
||||||
<ScrollArea
|
<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"
|
viewportClassName="max-h-64"
|
||||||
scrollBarClassName="w-1.5"
|
scrollBarClassName="w-1.5"
|
||||||
>
|
>
|
||||||
@@ -509,7 +542,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
) : fileContent ? (
|
) : fileContent ? (
|
||||||
<ScrollArea
|
<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"
|
viewportClassName="max-h-64"
|
||||||
scrollBarClassName="w-1.5"
|
scrollBarClassName="w-1.5"
|
||||||
>
|
>
|
||||||
@@ -524,7 +557,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
resultOutput && (
|
resultOutput && (
|
||||||
<ScrollArea
|
<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"
|
viewportClassName="max-h-64"
|
||||||
scrollBarClassName="w-1.5"
|
scrollBarClassName="w-1.5"
|
||||||
>
|
>
|
||||||
@@ -539,7 +572,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</ToolCollapsible>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,34 +12,72 @@ import type React from "react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "#/components/Tooltip/Tooltip";
|
||||||
|
|
||||||
export const ToolIcon: React.FC<{
|
export const ToolIcon: React.FC<{
|
||||||
name: string;
|
name: string;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
}> = ({ name, isError, iconUrl }) => {
|
isRunning?: boolean;
|
||||||
|
serverName?: string;
|
||||||
|
}> = ({ name, isError, iconUrl, isRunning, serverName }) => {
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const color = isError ? "text-content-destructive" : "text-content-secondary";
|
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.
|
// 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) {
|
if (iconUrl && !imgError) {
|
||||||
return (
|
// Always render the same DOM shape so React never unmounts
|
||||||
<div
|
// the <img> when isError changes (avoids a reload flicker).
|
||||||
className={cn(
|
//
|
||||||
"flex h-4 w-4 shrink-0 items-center justify-center",
|
// The wrapper clips a translated copy of the image so the
|
||||||
"rounded-full bg-surface-secondary",
|
// drop-shadow trick can work on error (see image classes).
|
||||||
isError && "ring-1 ring-content-destructive",
|
// 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
|
<ExternalImage
|
||||||
src={iconUrl}
|
src={iconUrl}
|
||||||
alt={`${name} icon`}
|
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)}
|
onError={() => setImgError(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (serverName) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{img}</TooltipTrigger>
|
||||||
|
<TooltipContent>{serverName}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return img;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (name) {
|
switch (name) {
|
||||||
|
|||||||
Reference in New Issue
Block a user