feat(chat): group consecutive tool calls into one summarized chain card (#8995)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Taylor Ho
2026-05-05 11:37:13 -07:00
committed by GitHub
parent fb79c7de0c
commit dd95b7bb85
22 changed files with 4229 additions and 333 deletions
@@ -10,12 +10,7 @@
* When the session finishes loading (loadingSessionIds removes the id), the
* buffer is flushed as a single store.setMessages() call — O(1) re-render.
*/
import type {
Message,
MessageContent,
ToolRequestContent,
ToolResponseContent,
} from "@/shared/types/messages";
import type { Message } from "@/shared/types/messages";
const replayBuffers = new Map<string, Message[]>();
@@ -51,25 +46,3 @@ export function getAndDeleteReplayBuffer(
export function clearReplayBuffer(sessionId: string): void {
replayBuffers.delete(sessionId);
}
export function findLatestUnpairedToolRequest(
content: MessageContent[],
): ToolRequestContent | null {
for (let index = content.length - 1; index >= 0; index -= 1) {
const block = content[index];
if (block?.type !== "toolRequest") {
continue;
}
const alreadyHasResponse = content.some(
(candidate): candidate is ToolResponseContent =>
candidate.type === "toolResponse" && candidate.id === block.id,
);
if (!alreadyHasResponse) {
return block;
}
}
return null;
}
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import {
getToolInputSummaryRows,
isHoistableText,
isStringifiedCopyOfStructured,
} from "../toolCallPresentation";
describe("getToolInputSummaryRows", () => {
it("returns command + working directory rows for shell-style args", () => {
const rows = getToolInputSummaryRows({
name: "developer__shell",
arguments: { command: "npm test", cwd: "/repo" },
});
expect(rows).toEqual([
{
kind: "command",
value: "npm test",
monospace: true,
renderAs: "bash",
},
{ kind: "workingDirectory", value: "/repo", monospace: true },
]);
});
it("returns a resource row for url args", () => {
const rows = getToolInputSummaryRows({
name: "fetch",
arguments: { url: "https://example.com" },
});
expect(rows).toEqual([
{ kind: "resource", value: "https://example.com", monospace: true },
]);
});
it("returns query + path rows for search-style args", () => {
const rows = getToolInputSummaryRows({
name: "developer__grep",
arguments: { query: "TODO", path: "/repo/src" },
});
expect(rows).toEqual([
{ kind: "query", value: "TODO", monospace: true },
{ kind: "path", value: "/repo/src", monospace: true },
]);
});
it("collapses long file paths to basenames and preserves the full path in title", () => {
const rows = getToolInputSummaryRows({
name: "developer__edit",
arguments: { path: "/Users/tho/repo/src/lib/index.ts" },
});
expect(rows).toEqual([
{
kind: "path",
value: "index.ts",
monospace: true,
title: "/Users/tho/repo/src/lib/index.ts",
},
]);
});
it("includes line row when present alongside a path", () => {
const rows = getToolInputSummaryRows({
name: "developer__read",
arguments: { path: "/repo/foo.ts", line: 42 },
});
expect(rows).toEqual([
{
kind: "path",
value: "foo.ts",
monospace: true,
title: "/repo/foo.ts",
},
{ kind: "line", value: "42" },
]);
});
it("falls back to a tool row with the tool name when no familiar arg keys are present", () => {
const rows = getToolInputSummaryRows({
name: "custom-extension",
arguments: { foo: 1 },
});
expect(rows).toEqual([{ kind: "tool", value: "custom-extension" }]);
});
it("returns an empty list when args and name are both empty", () => {
expect(getToolInputSummaryRows({ name: "", arguments: {} })).toEqual([]);
});
it("ignores empty string values when scanning args", () => {
const rows = getToolInputSummaryRows({
name: "developer__shell",
arguments: { command: " ", cwd: "/repo" },
});
expect(rows).toEqual([
{
kind: "path",
value: "repo",
monospace: true,
title: "/repo",
},
]);
});
});
describe("isHoistableText", () => {
it("accepts a short single-line string", () => {
expect(isHoistableText("Found 3 matches")).toBe(true);
});
it("rejects multi-line strings", () => {
expect(isHoistableText("line one\nline two")).toBe(false);
expect(isHoistableText("line one\rline two")).toBe(false);
});
it("rejects empty / whitespace-only strings", () => {
expect(isHoistableText("")).toBe(false);
expect(isHoistableText(" ")).toBe(false);
expect(isHoistableText(undefined)).toBe(false);
});
it("rejects strings longer than the max length budget", () => {
expect(isHoistableText("x".repeat(80))).toBe(true);
expect(isHoistableText("x".repeat(81))).toBe(false);
});
it("trims before measuring length and line count", () => {
expect(isHoistableText(" Found 3 matches ")).toBe(true);
expect(isHoistableText(` ${"x".repeat(81)} `)).toBe(false);
});
});
describe("isStringifiedCopyOfStructured", () => {
it("returns true when text is a compact JSON stringification of structured", () => {
const structured = { kind: "summary", count: 3 };
expect(
isStringifiedCopyOfStructured(JSON.stringify(structured), structured),
).toBe(true);
});
it("returns true when text is a pretty-printed JSON stringification", () => {
const structured = { kind: "summary", count: 3 };
expect(
isStringifiedCopyOfStructured(
JSON.stringify(structured, null, 2),
structured,
),
).toBe(true);
});
it("returns false when text is not valid JSON", () => {
expect(isStringifiedCopyOfStructured("Found 3 matches", { count: 3 })).toBe(
false,
);
});
it("returns false when parsed text differs structurally from structured", () => {
expect(
isStringifiedCopyOfStructured(JSON.stringify({ count: 3 }), { count: 4 }),
).toBe(false);
});
it("returns false when either side is missing", () => {
expect(isStringifiedCopyOfStructured(undefined, { count: 3 })).toBe(false);
expect(isStringifiedCopyOfStructured("{}", undefined)).toBe(false);
});
it("treats null structured as a valid comparison target", () => {
expect(isStringifiedCopyOfStructured("null", null)).toBe(true);
});
});
@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import {
getChainAggregateStatus,
getToolItemName,
getToolItemStatus,
shouldRenderAsGroupedChain,
type ToolChainItem,
} from "../toolChainGrouping";
import type {
ToolRequestContent,
ToolResponseContent,
} from "@/shared/types/messages";
function makeRequest(
overrides: Partial<ToolRequestContent> = {},
): ToolRequestContent {
return {
type: "toolRequest",
id: "req-1",
name: "tool name",
arguments: {},
status: "completed",
...overrides,
};
}
function makeResponse(
overrides: Partial<ToolResponseContent> = {},
): ToolResponseContent {
return {
type: "toolResponse",
id: "req-1",
name: "tool name",
result: "ok",
isError: false,
...overrides,
};
}
function pair(
key: string,
request?: Partial<ToolRequestContent>,
response?: Partial<ToolResponseContent>,
): ToolChainItem {
return {
key,
request: request ? makeRequest(request) : undefined,
response: response ? makeResponse(response) : undefined,
};
}
describe("getToolItemName", () => {
it("uses request name when present", () => {
expect(getToolItemName(pair("a", { name: "edit foo" }))).toBe("edit foo");
});
it("falls back to response name", () => {
expect(getToolItemName(pair("a", undefined, { name: "ran sh" }))).toBe(
"ran sh",
);
});
it("falls back to a generic label when neither has a name", () => {
expect(getToolItemName(pair("a", { name: "" }, { name: "" }))).toBe(
"Tool result",
);
});
});
describe("getToolItemStatus", () => {
it("treats response presence as completed", () => {
expect(getToolItemStatus(pair("a", { name: "x" }, {}))).toBe("completed");
});
it("treats response.isError as error", () => {
expect(getToolItemStatus(pair("a", { name: "x" }, { isError: true }))).toBe(
"error",
);
});
it("uses request status when no response yet", () => {
expect(getToolItemStatus(pair("a", { status: "executing" }))).toBe(
"executing",
);
});
});
describe("getChainAggregateStatus", () => {
it("prefers error over pending and executing", () => {
expect(
getChainAggregateStatus([
pair("a", { status: "executing" }),
pair("b", { name: "x" }, { isError: true }),
pair("c", { status: "pending" }),
]),
).toBe("error");
});
it("prefers stopped over executing/pending when there is no error", () => {
expect(
getChainAggregateStatus([
pair("a", { status: "executing" }),
pair("b", { status: "stopped" }),
]),
).toBe("stopped");
});
it("returns executing when any request is executing and none failed", () => {
expect(
getChainAggregateStatus([
pair("a", { name: "x" }, {}),
pair("b", { status: "executing" }),
]),
).toBe("executing");
});
it("returns pending when only pending is present", () => {
expect(
getChainAggregateStatus([
pair("a", { status: "pending" }),
pair("b", { status: "pending" }),
]),
).toBe("pending");
});
it("returns completed when every step finished cleanly", () => {
expect(
getChainAggregateStatus([
pair("a", { name: "x" }, {}),
pair("b", { name: "y" }, {}),
]),
).toBe("completed");
});
});
describe("shouldRenderAsGroupedChain", () => {
it("is false for single-item sections", () => {
expect(shouldRenderAsGroupedChain([pair("a", { name: "x" })])).toBe(false);
});
it("is true once there are 2+ items", () => {
expect(
shouldRenderAsGroupedChain([
pair("a", { name: "x" }),
pair("b", { name: "y" }),
]),
).toBe(true);
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { summarizeToolChainSteps } from "../toolChainSummary";
import type { ToolChainItem } from "../toolChainGrouping";
let nextId = 0;
function step(name: string): ToolChainItem {
const id = `step-${++nextId}`;
return {
key: id,
request: {
type: "toolRequest",
id,
name,
arguments: {},
status: "completed",
},
};
}
describe("summarizeToolChainSteps", () => {
it("classifies file reads as reviewing_files", () => {
const summary = summarizeToolChainSteps([
step("Read · src/index.ts"),
step("List · src/"),
]);
expect(summary.kind).toBe("reviewing_files");
expect(summary.titleKey).toBe("tool_chain.summary.reviewing_files");
expect(summary.count).toBe(2);
});
it("classifies shell/bash steps as running_commands", () => {
const summary = summarizeToolChainSteps([
step("Shell · ls -la"),
step("Shell · cargo test"),
step("Read · Cargo.toml"),
]);
expect(summary.kind).toBe("running_commands");
expect(summary.titleKey).toBe("tool_chain.summary.running_commands");
});
it("classifies write/edit-heavy chains as updating_files", () => {
const summary = summarizeToolChainSteps([
step("Edit · src/lib.rs"),
step("Write · src/new.rs"),
step("Read · src/lib.rs"),
]);
expect(summary.kind).toBe("updating_files");
expect(summary.titleKey).toBe("tool_chain.summary.updating_files");
});
it("classifies fetch/url steps as checking_resources", () => {
const summary = summarizeToolChainSteps([
step("Fetch · https://example.com"),
step("Fetch · https://example.com/api"),
]);
expect(summary.kind).toBe("checking_resources");
expect(summary.titleKey).toBe("tool_chain.summary.checking_resources");
});
it("uses the detail to detect URLs even when the prefix is generic", () => {
const summary = summarizeToolChainSteps([
step("Tool · https://example.com"),
step("Tool · https://example.com/api"),
]);
expect(summary.kind).toBe("checking_resources");
});
it("falls back to reviewing_files when nothing dominates", () => {
const summary = summarizeToolChainSteps([
step("Read · src/a.ts"),
step("Read · src/b.ts"),
step("Read · src/c.ts"),
]);
expect(summary.kind).toBe("reviewing_files");
});
it("returns a sane default for empty chains", () => {
const summary = summarizeToolChainSteps([]);
expect(summary.count).toBe(0);
expect(summary.kind).toBe("reviewing_files");
});
it("breaks ties between updating and reviewing in favor of updating only when strictly greater", () => {
const summary = summarizeToolChainSteps([
step("Edit · src/a.ts"),
step("Read · src/b.ts"),
]);
expect(summary.kind).toBe("reviewing_files");
});
it("treats command tokens correctly when prefix contains 'execute'", () => {
const summary = summarizeToolChainSteps([
step("Execute · npm test"),
step("Execute · npm build"),
]);
expect(summary.kind).toBe("running_commands");
});
});
@@ -0,0 +1,201 @@
const COMMAND_KEYS = ["command", "cmd", "script"];
const SEARCH_KEYS = ["query", "pattern", "search", "needle", "text"];
const PATH_KEYS = [
"path",
"file",
"filePath",
"filepath",
"targetPath",
"directory",
"dir",
"cwd",
"folder",
];
const URL_KEYS = ["url", "uri", "href"];
/**
* Stable, locale-independent identifier for a row's label. The renderer
* resolves this to a translated string at draw time via
* `chat.tools.inputSummary.<kind>`. Keeping the identifier on the row
* (rather than baking the English label in here) lets downstream code
* branch on row identity (e.g. headers that pull the path row) without
* snapping to a particular locale.
*/
export type ToolInputSummaryRowKind =
| "command"
| "workingDirectory"
| "query"
| "path"
| "resource"
| "line"
| "tool";
export interface ToolInputSummaryRow {
kind: ToolInputSummaryRowKind;
value: string;
monospace?: boolean;
/** Full path/value for hover tooltip when `value` was shortened. */
title?: string;
/** Hint for syntax-highlighting downstream renderers. */
renderAs?: "text" | "bash";
}
interface ToolCallPresentationInput {
name: string;
arguments: Record<string, unknown>;
}
function getStringArgument(
args: Record<string, unknown>,
keys: string[],
): string | undefined {
for (const key of keys) {
const value = args[key];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return undefined;
}
function getNumericArgument(
args: Record<string, unknown>,
keys: string[],
): number | undefined {
for (const key of keys) {
const value = args[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
}
return undefined;
}
function basenameOf(path: string): string {
const normalized = path.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
return parts[parts.length - 1] ?? path;
}
/**
* Translate raw tool arguments into a small set of labeled rows for the
* expanded tool card. Falls back to an empty list when no familiar shape is
* found, leaving the JSON dump as the canonical representation.
*
* Slim port of `toolCallPresentation.ts` from PR #8773 — that version also
* leaned on `kind` / `locations` on the wire. The current main does not carry
* those fields, so this version is args-only.
*/
export function getToolInputSummaryRows({
name,
arguments: args,
}: ToolCallPresentationInput): ToolInputSummaryRow[] {
const command = getStringArgument(args, COMMAND_KEYS);
if (command) {
const cwd = getStringArgument(args, ["cwd"]);
return [
{
kind: "command",
value: command,
monospace: true,
renderAs: "bash",
},
...(cwd
? [
{
kind: "workingDirectory" as const,
value: cwd,
monospace: true,
},
]
: []),
];
}
const query = getStringArgument(args, SEARCH_KEYS);
if (query) {
const path = getStringArgument(args, PATH_KEYS);
return [
{ kind: "query", value: query, monospace: true },
...(path
? [{ kind: "path" as const, value: path, monospace: true }]
: []),
];
}
const url = getStringArgument(args, URL_KEYS);
if (url) {
return [{ kind: "resource", value: url, monospace: true }];
}
const path = getStringArgument(args, PATH_KEYS);
if (path) {
const line = getNumericArgument(args, ["line", "startLine"]);
const displayPath = basenameOf(path);
return [
{
kind: "path",
value: displayPath,
monospace: true,
title: path,
},
...(line ? [{ kind: "line" as const, value: String(line) }] : []),
];
}
if (name.trim().length > 0) {
return [{ kind: "tool", value: name }];
}
return [];
}
/**
* Maximum length (in characters, after trim) for a text result to be eligible
* for hoisting into the tool header subtitle. Longer values stay in the body.
*/
const HOISTABLE_TEXT_MAX_LENGTH = 80;
/**
* Returns true when a tool's text `result` is short enough and simple enough
* to render as a header subtitle alongside the tool name. Multi-line and
* empty/whitespace-only strings are never hoistable.
*/
export function isHoistableText(text: string | undefined): text is string {
if (typeof text !== "string") return false;
const trimmed = text.trim();
if (trimmed.length === 0) return false;
if (trimmed.includes("\n") || trimmed.includes("\r")) return false;
return trimmed.length <= HOISTABLE_TEXT_MAX_LENGTH;
}
/**
* Returns true when a text `result` is just a stringified copy of the
* `structured` content — e.g. a server that emits `result = JSON.stringify(x)`
* alongside `structuredContent = x`. Whitespace- and indent-insensitive: the
* comparison normalizes both sides via JSON parse + compact stringify.
*
* Used by the de-dupe matrix in ToolCallAdapter to suppress the redundant
* text result when the structured form will already be rendered.
*/
export function isStringifiedCopyOfStructured(
text: string | undefined,
structured: unknown,
): boolean {
if (typeof text !== "string" || structured === undefined) return false;
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return false;
}
try {
return JSON.stringify(parsed) === JSON.stringify(structured);
} catch {
return false;
}
}
@@ -0,0 +1,52 @@
import type {
ToolCallStatus,
ToolRequestContent,
ToolResponseContent,
} from "@/shared/types/messages";
/**
* A pairing of one tool request and (optionally) its matching response,
* preserving the order they appeared in the assistant message.
*
* Chains are derived from this order — see `groupAdjacentToolItems` and
* `MessageBubble.groupContentSections` — so the server emits no chain metadata.
*/
export interface ToolChainItem {
key: string;
request?: ToolRequestContent;
response?: ToolResponseContent;
}
export function getToolItemName(item: ToolChainItem): string {
return item.request?.name || item.response?.name || "Tool result";
}
export function getToolItemStatus(item: ToolChainItem): ToolCallStatus {
if (item.response) {
return item.response.isError ? "error" : "completed";
}
return item.request?.status ?? "completed";
}
/**
* Aggregate status across a chain. Failure-leaning so collapsed parents don't
* mask a failed step behind a still-pending sibling.
*/
export function getChainAggregateStatus(
items: ToolChainItem[],
): ToolCallStatus {
if (items.some((i) => getToolItemStatus(i) === "error")) return "error";
if (items.some((i) => getToolItemStatus(i) === "stopped")) return "stopped";
if (items.some((i) => getToolItemStatus(i) === "executing"))
return "executing";
if (items.some((i) => getToolItemStatus(i) === "pending")) return "pending";
return "completed";
}
/**
* Whether the section should render as a grouped parent card. Single-item
* sections render inline (no parent wrapper), matching prior UX.
*/
export function shouldRenderAsGroupedChain(items: ToolChainItem[]): boolean {
return items.length >= 2;
}
@@ -0,0 +1,153 @@
import { getToolItemName, type ToolChainItem } from "./toolChainGrouping";
/**
* Buckets used to classify a single step inside a tool chain. Ported from the
* Rust `classify_tool_chain_step` / `summarize_tool_chain` that previously
* lived in `crates/goose/src/acp/server.rs` so the wire stays free of any
* `_goose/tool-chain-*` metadata.
*/
export type ToolChainStepKind =
| "reviewing_files"
| "running_commands"
| "checking_resources"
| "updating_files";
const STEP_TITLE_KEYS: Record<ToolChainStepKind, string> = {
reviewing_files: "tool_chain.summary.reviewing_files",
running_commands: "tool_chain.summary.running_commands",
checking_resources: "tool_chain.summary.checking_resources",
updating_files: "tool_chain.summary.updating_files",
};
const ACTIVE_TITLE_KEY = "tool_chain.summary.active";
const HTTP_PREFIXES = ["http://", "https://"];
const RESOURCE_TOKENS = ["fetch", "http", "url", "uri", "download"];
const UPDATE_TOKENS = [
"edit",
"write",
"create",
"update",
"replace",
"rename",
"move",
"delete",
];
const COMMAND_TOKENS = ["shell", "command", "bash", "terminal", "execute"];
function classifyStepLabel(label: string): ToolChainStepKind {
const lower = label.toLowerCase();
const sepIndex = lower.indexOf(" · ");
const prefix = sepIndex === -1 ? lower : lower.slice(0, sepIndex);
const detail = sepIndex === -1 ? "" : lower.slice(sepIndex + 3);
if (
HTTP_PREFIXES.some((p) => detail.startsWith(p)) ||
RESOURCE_TOKENS.some((t) => prefix.includes(t))
) {
return "checking_resources";
}
if (UPDATE_TOKENS.some((t) => prefix.includes(t))) {
return "updating_files";
}
if (COMMAND_TOKENS.some((t) => prefix.includes(t))) {
return "running_commands";
}
return "reviewing_files";
}
interface BucketCounts {
reviewing_files: number;
running_commands: number;
checking_resources: number;
updating_files: number;
}
function countBuckets(items: ToolChainItem[]): BucketCounts {
const counts: BucketCounts = {
reviewing_files: 0,
running_commands: 0,
checking_resources: 0,
updating_files: 0,
};
for (const item of items) {
counts[classifyStepLabel(getToolItemName(item))] += 1;
}
return counts;
}
function pickDominantBucket(counts: BucketCounts): ToolChainStepKind {
const {
reviewing_files,
running_commands,
checking_resources,
updating_files,
} = counts;
if (
updating_files > reviewing_files &&
updating_files >= running_commands &&
updating_files >= checking_resources
) {
return "updating_files";
}
if (
checking_resources > reviewing_files &&
checking_resources >= running_commands
) {
return "checking_resources";
}
if (running_commands > reviewing_files) {
return "running_commands";
}
return "reviewing_files";
}
export interface ToolChainSummary {
/** i18n key for the chain title (e.g. "running commands"). */
titleKey: string;
/** i18n key for the count suffix; consumers add count via `t(suffixKey, { count })`. */
countKey: string;
/** Number of steps the parent card represents. */
count: number;
/** Bucket the chain falls into; useful for icons/styling. */
kind: ToolChainStepKind;
}
/**
* Derive a localized chain summary from the per-step tool names.
*
* Note: when the chain is "active" (any non-completed step) callers may prefer
* the live `tool_chain.summary.active` label over the kind-based title. This
* function returns the deterministic kind regardless; the caller decides how
* to render based on aggregate status.
*/
export function summarizeToolChainSteps(
items: ToolChainItem[],
): ToolChainSummary {
if (items.length === 0) {
return {
titleKey: ACTIVE_TITLE_KEY,
countKey: "tool_chain.summary.steps",
count: 0,
kind: "reviewing_files",
};
}
const counts = countBuckets(items);
const kind = pickDominantBucket(counts);
return {
titleKey: STEP_TITLE_KEYS[kind],
countKey: "tool_chain.summary.steps",
count: items.length,
kind,
};
}
export const TOOL_CHAIN_ACTIVE_TITLE_KEY = ACTIVE_TITLE_KEY;
@@ -1,16 +1,24 @@
import { useState, useEffect } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen, ChevronRight } from "lucide-react";
import { ChevronRight, FolderOpen } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { CodeBlock } from "@/shared/ui/ai-elements/code-block";
import {
Tool,
ToolHeader,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
ToolSurface,
} from "@/shared/ui/ai-elements/tool";
import { toolStatusMap } from "../lib/toolStatusMap";
import {
getToolInputSummaryRows,
isHoistableText,
isStringifiedCopyOfStructured,
type ToolInputSummaryRow,
} from "@/features/chat/lib/toolCallPresentation";
import type { ToolCallLocation, ToolCallStatus } from "@/shared/types/messages";
import { useArtifactPolicyContext } from "@/features/chat/hooks/ArtifactPolicyContext";
@@ -26,6 +34,12 @@ interface ToolCallAdapterProps {
startedAt?: number;
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** When false, the chevron-side status badge is hidden (used inside chains). */
showStatusBadge?: boolean;
/** When false, hides the trailing disclosure chevron in the header. */
showChevron?: boolean;
/** When true, the card sizes to its content rather than filling its parent. */
fitWidth?: boolean;
}
function useElapsedTime(status: ToolCallStatus, startedAt?: number) {
@@ -34,7 +48,6 @@ function useElapsedTime(status: ToolCallStatus, startedAt?: number) {
useEffect(() => {
if (status === "executing") {
const origin = startedAt ?? Date.now();
// Compute initial elapsed immediately so the first render is accurate.
setElapsed(Math.floor((Date.now() - origin) / 1000));
const interval = setInterval(() => {
setElapsed(Math.floor((Date.now() - origin) / 1000));
@@ -177,6 +190,75 @@ function ArtifactActions({ locations }: { locations?: ToolCallLocation[] }) {
);
}
const COMMAND_PREVIEW_CODEBLOCK_CLASSES =
"rounded-none border-0 bg-transparent shadow-none [&>div]:overflow-hidden [&_pre]:m-0 [&_pre]:bg-transparent [&_pre]:p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:text-[12px] [&_pre]:leading-5 [&_code]:font-mono [&_code]:text-[12px] [&_code]:leading-5";
function InputSummary({
rows,
isOpen,
}: {
rows: ToolInputSummaryRow[];
isOpen: boolean;
}) {
const { t } = useTranslation("chat");
if (rows.length === 0) return null;
return (
<dl className="space-y-1.5">
{rows.map((row) => {
const label = t(`tools.inputSummary.${row.kind}`);
const key = `${row.kind}:${row.value}`;
if (row.renderAs === "bash") {
return (
<div key={key} className="space-y-0.5">
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">
{label}
</dt>
<dd>
<CodeBlock
code={row.value}
language="bash"
data-tool-command-preview={!isOpen ? "" : undefined}
className={cn(
COMMAND_PREVIEW_CODEBLOCK_CLASSES,
!isOpen && "[&_pre]:line-clamp-3 [&_pre]:overflow-hidden",
)}
/>
</dd>
</div>
);
}
return (
<div key={key} className="flex items-baseline gap-2">
<dt className="shrink-0 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{label}
</dt>
<dd
className={cn(
"min-w-0 truncate text-[12px] text-foreground",
row.monospace && "font-mono",
)}
title={row.title ?? row.value}
>
{row.value}
</dd>
</div>
);
})}
</dl>
);
}
function splitHeaderTitleByPath(name: string, fileLabel: string) {
const index = name.toLowerCase().lastIndexOf(fileLabel.toLowerCase());
if (index === -1) return null;
return {
prefix: name.slice(0, index),
fileLabel: name.slice(index, index + fileLabel.length),
suffix: name.slice(index + fileLabel.length),
};
}
export function ToolCallAdapter({
name,
arguments: args,
@@ -188,19 +270,102 @@ export function ToolCallAdapter({
startedAt,
open,
onOpenChange,
showStatusBadge = true,
showChevron = true,
fitWidth = false,
}: ToolCallAdapterProps) {
const { t } = useTranslation("chat");
const elapsed = useElapsedTime(status, startedAt);
const state = toolStatusMap[status];
const summaryRows = useMemo(
() => getToolInputSummaryRows({ name, arguments: args }),
[args, name],
);
const elapsedSeconds =
status === "executing" && elapsed >= 3 ? elapsed : undefined;
const outputViewportClassName = cn(
"max-h-[28rem] overflow-auto",
"[scrollbar-color:hsl(var(--muted-foreground))_transparent] [scrollbar-width:thin]",
"[&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted-foreground/50",
const { resolveMarkdownHref, openResolvedPath } = useArtifactPolicyContext();
const pathRow = summaryRows.find((row) => row.kind === "path");
const headerFileLabel = pathRow?.value;
const headerFilePath = pathRow?.title ?? pathRow?.value;
const headerTitleParts =
headerFileLabel && headerFilePath
? splitHeaderTitleByPath(name, headerFileLabel)
: null;
const headerFileCandidate = useMemo(
() => (headerFilePath ? resolveMarkdownHref(headerFilePath) : null),
[headerFilePath, resolveMarkdownHref],
);
const canOpenHeaderFile = Boolean(headerTitleParts && headerFileCandidate);
const hasStructuredArgs = Object.keys(args).length > 0;
const hasOutput = Boolean(result);
const hasStructuredContent = !isError && structuredContent !== undefined;
// De-dupe + title-hoisting matrix: when both a text result and structured
// content are present, decide whether the text is a redundant stringified
// copy of the structured payload (hide), short enough to hoist into the
// header subtitle (lift), or worth rendering in the body alongside the
// structured block (keep).
const textIsStringifiedCopy =
hasOutput &&
hasStructuredContent &&
isStringifiedCopyOfStructured(result, structuredContent);
const canHoistResultIntoHeader =
hasOutput &&
hasStructuredContent &&
!textIsStringifiedCopy &&
!headerTitleParts &&
isHoistableText(result);
const showResultBody =
hasOutput && !textIsStringifiedCopy && !canHoistResultIntoHeader;
const headerTitle: ReactNode = headerTitleParts ? (
<>
<span data-tool-title-prefix>{headerTitleParts.prefix}</span>
{canOpenHeaderFile ? (
<button
type="button"
data-clickable-file
onClick={(event) => {
event.stopPropagation();
if (!headerFileCandidate) return;
void openResolvedPath(headerFileCandidate.resolvedPath).catch(
() => {},
);
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
title={headerFileCandidate?.resolvedPath ?? headerFilePath}
aria-label={t("tools.openNamed", {
name: headerTitleParts.fileLabel,
})}
className="inline truncate text-foreground underline-offset-2 hover:underline"
>
{headerTitleParts.fileLabel}
</button>
) : (
<span>{headerTitleParts.fileLabel}</span>
)}
<span>{headerTitleParts.suffix}</span>
</>
) : canHoistResultIntoHeader ? (
<>
<span>{name}</span>
<span aria-hidden="true" className="text-muted-foreground">
{" · "}
</span>
<span data-tool-title-hoisted className="truncate text-foreground">
{(result ?? "").trim()}
</span>
</>
) : (
name
);
const showCombinedSurface = summaryRows.length > 0 || hasStructuredArgs;
return (
<div className="w-full min-w-0 max-w-full">
@@ -208,27 +373,63 @@ export function ToolCallAdapter({
<ToolHeader
type="dynamic-tool"
toolName={name}
title={name}
title={headerTitle}
state={state}
showIcon={false}
showStatusBadge={showStatusBadge}
showChevron={showChevron}
splitTrigger={canOpenHeaderFile}
layout={fitWidth ? "fit" : "fill"}
elapsedSeconds={elapsedSeconds}
/>
<ToolContent>
{Object.keys(args).length > 0 && <ToolInput input={args} />}
<ToolOutput
output={isError ? undefined : result}
errorText={isError ? result : undefined}
label={isError ? undefined : t("tools.content")}
contentClassName={outputViewportClassName}
plainText
/>
{!isError && structuredContent !== undefined && (
<ToolOutput
output={structuredContent}
errorText={undefined}
label={t("tools.structuredContent")}
contentClassName={outputViewportClassName}
/>
{showCombinedSurface ? (
<ToolSurface tone="muted" className="bg-muted">
<ToolInput
input={args}
showLabel={false}
embedded
summary={({ isOpen }) => (
<InputSummary rows={summaryRows} isOpen={isOpen} />
)}
/>
{showResultBody && (
<ToolOutput
output={isError ? undefined : result}
errorText={isError ? result : undefined}
showLabel={false}
embedded
embeddedMaxHeightClass="max-h-32"
/>
)}
{hasStructuredContent && (
<ToolOutput
output={structuredContent}
errorText={undefined}
showLabel={false}
embedded
embeddedMaxHeightClass="max-h-32"
/>
)}
</ToolSurface>
) : (
<>
{showResultBody && (
<ToolOutput
output={isError ? undefined : result}
errorText={isError ? result : undefined}
contentClassName="max-h-[28rem] overflow-y-auto"
/>
)}
{hasStructuredContent && (
<ToolOutput
output={structuredContent}
errorText={undefined}
label={t("tools.structuredContent")}
contentClassName="max-h-[28rem] overflow-y-auto"
/>
)}
</>
)}
</ToolContent>
</Tool>
+295 -56
View File
@@ -1,16 +1,87 @@
import { useState } from "react";
import { ChevronRight } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, CircleIcon, ClockIcon } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { ToolCallAdapter } from "./ToolCallAdapter";
import type {
ToolRequestContent,
ToolResponseContent,
} from "@/shared/types/messages";
import {
getChainAggregateStatus,
getToolItemName,
getToolItemStatus,
shouldRenderAsGroupedChain,
type ToolChainItem,
} from "@/features/chat/lib/toolChainGrouping";
import { summarizeToolChainSteps } from "@/features/chat/lib/toolChainSummary";
import type { ToolCallStatus } from "@/shared/types/messages";
export interface ToolChainItem {
key: string;
request?: ToolRequestContent;
response?: ToolResponseContent;
export type { ToolChainItem };
const STEP_BULLET_ICON: Record<
Exclude<ToolCallStatus, "error" | "stopped">,
LucideIcon
> = {
pending: CircleIcon,
executing: ClockIcon,
completed: Check,
};
const STEP_BULLET_CLASS: Record<
Exclude<ToolCallStatus, "error" | "stopped">,
string
> = {
pending: "text-muted-foreground/70",
executing: "text-muted-foreground animate-pulse",
completed: "text-muted-foreground",
};
function ChainStepBullet({ status }: { status: ToolCallStatus }) {
if (status === "error") {
return (
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-red-600" />
);
}
if (status === "stopped") {
return (
<span
aria-hidden
className="size-1.5 shrink-0 rounded-full bg-orange-600"
/>
);
}
const Icon = STEP_BULLET_ICON[status];
return (
<Icon className={cn("size-3.5 shrink-0", STEP_BULLET_CLASS[status])} />
);
}
function ChainStepRail({
status,
isLast = false,
lineTailVisible = true,
}: {
status: ToolCallStatus;
/** Last row in the expanded chain: hides the spine stub below the bullet until `lineTailVisible`. */
isLast?: boolean;
lineTailVisible?: boolean;
}) {
return (
<div
aria-hidden="true"
className="relative flex w-4 shrink-0 justify-center self-stretch"
>
{isLast && (
<div
className={cn(
"pointer-events-none absolute top-5 bottom-0 left-1/2 z-11 w-2.5 -translate-x-1/2 bg-background transition-opacity duration-150",
lineTailVisible ? "opacity-0" : "opacity-100",
)}
/>
)}
<div className="relative z-10 mt-1 flex h-4 w-4 items-center justify-center rounded-full bg-background ring-2 ring-background">
<ChainStepBullet status={status} />
</div>
</div>
);
}
const INTERNAL_TOOL_PREFIXES = new Set([
@@ -39,17 +110,6 @@ const INTERNAL_TOOL_PREFIXES = new Set([
"zsh",
]);
function getToolItemName(item: ToolChainItem): string {
return item.request?.name || item.response?.name || "Tool result";
}
function getToolItemStatus(item: ToolChainItem) {
if (item.response) {
return item.response.isError ? "error" : "completed";
}
return item.request?.status ?? "completed";
}
function isLowSignalToolStep(item: ToolChainItem): boolean {
if (getToolItemStatus(item) !== "completed") {
return false;
@@ -105,9 +165,28 @@ function partitionToolSteps(toolItems: ToolChainItem[]) {
}
export function ToolChainCards({ toolItems }: { toolItems: ToolChainItem[] }) {
const { t } = useTranslation("chat");
const [showInternalSteps, setShowInternalSteps] = useState(false);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
const { primaryItems, hiddenItems } = partitionToolSteps(toolItems);
const grouped = shouldRenderAsGroupedChain(toolItems);
const aggregateStatus = getChainAggregateStatus(toolItems);
const summary = summarizeToolChainSteps(primaryItems);
const isActiveChain =
aggregateStatus === "executing" || aggregateStatus === "pending";
// Chains that mount as already-complete (history replay) start collapsed;
// live chains mount mid-execution, stay open while running, and auto-collapse
// once they finish so the chat keeps moving forward.
const [chainExpanded, setChainExpanded] = useState(() => isActiveChain);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
const wasActiveChainRef = useRef(isActiveChain);
useEffect(() => {
if (wasActiveChainRef.current && !isActiveChain) {
setChainExpanded(false);
setExpandedKeys(new Set());
setShowInternalSteps(false);
}
wasActiveChainRef.current = isActiveChain;
}, [isActiveChain]);
const handleOpenChange = (key: string, open: boolean) => {
setExpandedKeys((prev) => {
@@ -121,53 +200,213 @@ export function ToolChainCards({ toolItems }: { toolItems: ToolChainItem[] }) {
});
};
const renderToolItem = (item: ToolChainItem) => {
const renderToolItem = (
item: ToolChainItem,
options: { withRail: boolean; isLastInChain?: boolean },
) => {
const name = getToolItemName(item);
const status = getToolItemStatus(item);
const { request, response } = item;
const isOpen = expandedKeys.has(item.key);
if (!options.withRail) {
return (
<div
key={item.key}
data-role="tool-single"
className="flex max-w-full items-start gap-2.5"
>
<button
type="button"
onClick={() => handleOpenChange(item.key, !isOpen)}
aria-expanded={isOpen}
aria-label={name}
className="flex w-4 shrink-0 justify-center pt-1"
>
<span className="flex size-4 items-center justify-center">
<ChevronRight
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-90",
)}
/>
</span>
</button>
<div className="min-w-0 flex-1">
<ToolCallAdapter
name={name}
arguments={request?.arguments ?? {}}
status={status}
locations={request?.locations}
result={response?.result}
structuredContent={response?.structuredContent}
isError={response?.isError}
startedAt={request?.startedAt}
open={isOpen}
onOpenChange={(open) => handleOpenChange(item.key, open)}
showChevron={false}
/>
</div>
</div>
);
}
return (
<ToolCallAdapter
<div
key={item.key}
name={name}
arguments={request?.arguments ?? {}}
status={status}
locations={request?.locations}
result={response?.result}
structuredContent={response?.structuredContent}
isError={response?.isError}
startedAt={request?.startedAt}
open={expandedKeys.has(item.key)}
onOpenChange={(open) => handleOpenChange(item.key, open)}
/>
data-role="tool-chain-step"
className="relative z-1 flex max-w-full items-stretch gap-2.5"
>
<ChainStepRail
status={status}
isLast={options.isLastInChain}
lineTailVisible={isOpen}
/>
<div className="min-w-0 flex-1 pb-1">
<ToolCallAdapter
name={name}
arguments={request?.arguments ?? {}}
status={status}
locations={request?.locations}
result={response?.result}
structuredContent={response?.structuredContent}
isError={response?.isError}
startedAt={request?.startedAt}
open={isOpen}
onOpenChange={(open) => handleOpenChange(item.key, open)}
showStatusBadge={false}
fitWidth
/>
</div>
</div>
);
};
if (!grouped) {
return (
<div className="my-3 flex w-full min-w-0 max-w-full flex-col gap-3">
{primaryItems.map((item) => renderToolItem(item, { withRail: false }))}
</div>
);
}
// Prefer the server-generated LLM chain summary (anchored on the first tool
// request of the chain) over the deterministic bucket phrase. The summary is
// attached after every step in the chain has completed, so it's only
// available for finished chains; while the chain is still active, fall back
// to the deterministic phrase.
const firstChainSummary = toolItems.find((item) => item.request?.chainSummary)
?.request?.chainSummary;
const labelText =
!isActiveChain && firstChainSummary
? firstChainSummary.summary
: isActiveChain
? t("tool_chain.summary.active")
: t(summary.titleKey);
const headerText = isActiveChain
? t("tool_chain.title.active", { count: toolItems.length })
: t("tool_chain.title.labeled", {
label: labelText,
count: toolItems.length,
});
const hasHiddenDisclosure = hiddenItems.length > 0;
return (
<div className="my-1 flex w-full min-w-0 max-w-full flex-col items-stretch gap-3">
{primaryItems.map((item) => renderToolItem(item))}
<section
className="my-3 flex w-full min-w-0 max-w-full flex-col gap-0"
data-role="tool-chain-card"
data-status={aggregateStatus}
>
<button
type="button"
onClick={() => {
if (chainExpanded) {
setExpandedKeys(new Set());
setShowInternalSteps(false);
}
setChainExpanded((prev) => !prev);
}}
aria-expanded={chainExpanded}
className="flex w-full max-w-full items-center gap-2.5 pb-1 text-left text-sm font-medium text-foreground"
>
<span
aria-hidden="true"
className="flex size-4 shrink-0 items-center justify-center"
>
<ChevronRight
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
chainExpanded && "rotate-90",
)}
/>
</span>
<span className="min-w-0 flex-1 truncate">{headerText}</span>
</button>
{hiddenItems.length > 0 && (
<div className="ml-1 flex flex-col items-start gap-1.5">
<button
type="button"
onClick={() => setShowInternalSteps((prev) => !prev)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-muted-foreground"
>
<ChevronRight
className={cn(
"h-3 w-3 transition-transform",
showInternalSteps && "rotate-90",
)}
/>
{showInternalSteps
? `Hide internal steps (${hiddenItems.length})`
: `Show internal steps (${hiddenItems.length})`}
</button>
{chainExpanded && (
<div className="relative flex flex-col gap-1 pt-1.5">
<div
aria-hidden="true"
className="pointer-events-none absolute top-0 bottom-0 left-2 z-0 w-px -translate-x-1/2 bg-border"
/>
{primaryItems.map((item, index) => {
const isLastPrimary = index === primaryItems.length - 1;
const isLastInChain =
isLastPrimary &&
!hasHiddenDisclosure &&
!(showInternalSteps && hiddenItems.length > 0);
return renderToolItem(item, {
withRail: true,
isLastInChain,
});
})}
{showInternalSteps && hiddenItems.map((item) => renderToolItem(item))}
{hasHiddenDisclosure && (
<div
data-role="tool-chain-internal-disclosure"
className="relative z-1 flex max-w-full items-stretch gap-2.5"
>
<ChainStepRail
status="completed"
isLast={!showInternalSteps}
lineTailVisible={showInternalSteps}
/>
<div className="min-w-0 flex-1 pb-1">
<button
type="button"
onClick={() => setShowInternalSteps((prev) => !prev)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronRight
aria-hidden="true"
className={cn(
"h-3 w-3 transition-transform",
showInternalSteps && "rotate-90",
)}
/>
{showInternalSteps
? t("tool_chain.internalSteps.hide", {
count: hiddenItems.length,
})
: t("tool_chain.internalSteps.show", {
count: hiddenItems.length,
})}
</button>
</div>
</div>
)}
{showInternalSteps &&
hiddenItems.map((item, index) =>
renderToolItem(item, {
withRail: true,
isLastInChain: index === hiddenItems.length - 1,
}),
)}
</div>
)}
</div>
</section>
);
}
@@ -501,14 +501,19 @@ describe("MessageBubble", () => {
},
]);
render(<MessageBubble message={msg} />);
const { container } = render(<MessageBubble message={msg} />);
// Completed-on-mount chains render collapsed; expand the parent card first.
const chainHeader = container.querySelector<HTMLButtonElement>(
'[data-role="tool-chain-card"] > button[aria-expanded]',
);
if (!chainHeader) throw new Error("expected tool-chain-card header");
await user.click(chainHeader);
expect(screen.getByText("Create PDF about whales")).toBeInTheDocument();
expect(screen.getByText("Write whales.pdf")).toBeInTheDocument();
expect(
screen.queryByText("python3 create_whales.py"),
).not.toBeInTheDocument();
expect(screen.queryByText("ls -lh whales.pdf")).not.toBeInTheDocument();
expect(screen.queryByText("python3 create_whales.py")).toBeNull();
expect(screen.queryByText("ls -lh whales.pdf")).toBeNull();
expect(screen.getByText("Show internal steps (2)")).toBeInTheDocument();
await user.click(screen.getByText("Show internal steps (2)"));
@@ -1,19 +1,32 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ArtifactLinkCandidate } from "@/features/chat/hooks/ArtifactPolicyContext";
import type { ToolCallLocation } from "@/shared/types/messages";
import { ToolCallAdapter } from "../ToolCallAdapter";
const mockResolveMarkdownHref =
vi.fn<(href: string) => ArtifactLinkCandidate | null>();
const mockPathExists = vi.fn<(path: string) => Promise<boolean>>();
const mockOpenResolvedPath = vi.fn<(path: string) => Promise<void>>();
vi.mock("@/features/chat/hooks/ArtifactPolicyContext", () => ({
useArtifactPolicyContext: () => ({
resolveMarkdownHref: () => null,
pathExists: async () => false,
resolveMarkdownHref: mockResolveMarkdownHref,
pathExists: mockPathExists,
openResolvedPath: mockOpenResolvedPath,
getAllSessionArtifacts: () => [],
}),
}));
beforeEach(() => {
mockResolveMarkdownHref.mockReturnValue(null);
});
afterEach(() => {
vi.clearAllMocks();
});
function renderAdapter(
overrides: Partial<Parameters<typeof ToolCallAdapter>[0]> = {},
) {
@@ -28,55 +41,13 @@ function renderAdapter(
);
}
describe("ToolCallAdapter — output", () => {
beforeEach(() => {
mockOpenResolvedPath.mockReset();
});
it("shows content and structured content together in the parent tool accordion", () => {
renderAdapter({
open: true,
result: JSON.stringify({
restaurants: [{ name: "Content Coffee" }],
}),
structuredContent: {
restaurants: [{ name: "Structured Coffee" }],
},
});
expect(screen.getByText("Content")).toBeInTheDocument();
expect(screen.getByText("Structured content")).toBeInTheDocument();
expect(screen.getByText(/Content Coffee/)).toBeInTheDocument();
expect(screen.getByText(/Structured Coffee/)).toBeInTheDocument();
expect(
screen.getByText(/"restaurants":\[\{"name":"Content Coffee"\}\]/),
).toBeInTheDocument();
expect(
screen.queryByText(/structured output .*lines/i),
).not.toBeInTheDocument();
});
it("shows falsy primitive structured content", () => {
renderAdapter({
open: true,
result: "Completed",
structuredContent: false,
});
expect(screen.getByText("Structured content")).toBeInTheDocument();
expect(screen.getByText("false")).toBeInTheDocument();
});
});
describe("ToolCallAdapter — ArtifactActions", () => {
beforeEach(() => {
mockOpenResolvedPath.mockReset();
});
it('renders "Open file" button when a location is provided', () => {
const locations: ToolCallLocation[] = [
{ path: "/Users/test/project/output.md" },
];
it('renders "Open file" button for reported ACP locations', () => {
renderAdapter({
locations: [{ path: "/Users/test/project/output.md" }],
});
renderAdapter({ locations });
expect(screen.getByRole("button", { name: /open file/i })).toBeEnabled();
expect(
@@ -84,7 +55,7 @@ describe("ToolCallAdapter — ArtifactActions", () => {
).toBeInTheDocument();
});
it("does NOT render artifact actions when no locations are reported", () => {
it("does NOT render artifact actions when no locations are provided", () => {
renderAdapter();
expect(
@@ -92,17 +63,18 @@ describe("ToolCallAdapter — ArtifactActions", () => {
).not.toBeInTheDocument();
});
it('shows "More outputs" toggle for secondary locations', async () => {
it('shows "More outputs" toggle when there are multiple locations', async () => {
const user = userEvent.setup();
renderAdapter({
locations: [
{ path: "/Users/test/project/output.md" },
{ path: "/Users/test/project/notes.md" },
],
});
const locations: ToolCallLocation[] = [
{ path: "/Users/test/project/output.md" },
{ path: "/Users/test/project/notes.md" },
];
renderAdapter({ locations });
const toggle = screen.getByText(/more outputs/i);
expect(toggle).toBeInTheDocument();
expect(
screen.queryByText("/Users/test/project/notes.md"),
).not.toBeInTheDocument();
@@ -114,33 +86,117 @@ describe("ToolCallAdapter — ArtifactActions", () => {
).toBeInTheDocument();
});
it("opens reported location when primary button is clicked", async () => {
it("invokes openResolvedPath when an artifact button is clicked", async () => {
const user = userEvent.setup();
mockOpenResolvedPath.mockResolvedValue(undefined);
const locations: ToolCallLocation[] = [
{ path: "/Users/test/project/output.md" },
];
renderAdapter({ locations });
renderAdapter({
locations: [{ path: "/Users/test/project/output.md" }],
});
await user.click(screen.getByRole("button", { name: /open file/i }));
expect(mockOpenResolvedPath).toHaveBeenCalledWith(
"/Users/test/project/output.md",
);
});
});
it("shows opener errors", async () => {
const user = userEvent.setup();
mockOpenResolvedPath.mockRejectedValue(
new Error("File not found: /Users/test/project/output.md"),
);
renderAdapter({
locations: [{ path: "/Users/test/project/output.md" }],
});
await user.click(screen.getByRole("button", { name: /open file/i }));
describe("ToolCallAdapter — expanded body", () => {
it("renders the tool name and status badge in the header", () => {
renderAdapter();
expect(
await screen.findByText("File not found: /Users/test/project/output.md"),
screen.getByRole("button", { name: /write_file/i }),
).toBeInTheDocument();
});
it("shows the text result when expanded", () => {
renderAdapter({ open: true, structuredContent: undefined });
expect(screen.getByText(/created \/project\/output\.md/i)).toBeVisible();
});
it("renders structured content when present without a text result", () => {
renderAdapter({
open: true,
result: undefined,
structuredContent: { kind: "summary", count: 3 },
});
expect(screen.getByText(/"kind"/)).toBeInTheDocument();
expect(screen.getByText(/"summary"/)).toBeInTheDocument();
});
it("renders the error result when isError is true", () => {
renderAdapter({ open: true, isError: true, result: "Boom" });
expect(screen.getByText("Boom")).toBeInTheDocument();
});
});
describe("ToolCallAdapter — text + structured de-dupe matrix", () => {
it("hides redundant text when result is a stringified copy of structured", () => {
const structured = { kind: "summary", count: 3 };
renderAdapter({
open: true,
arguments: {},
result: JSON.stringify(structured),
structuredContent: structured,
});
// The structured payload renders exactly once, not twice.
const summaryMatches = screen.getAllByText(/"summary"/);
expect(summaryMatches).toHaveLength(1);
});
it("hoists short single-line text into the header when both differ", () => {
renderAdapter({
open: true,
arguments: {},
result: "Found 3 matches",
structuredContent: { matches: 3 },
});
// The hoisted text renders inside the header subtitle slot.
const hoisted = document.querySelector("[data-tool-title-hoisted]");
expect(hoisted).not.toBeNull();
expect(hoisted?.textContent).toContain("Found 3 matches");
// Body shows the structured payload but does NOT duplicate the hoisted
// text — i.e. "Found 3 matches" appears exactly once across the card.
const allMatches = screen.getAllByText(/Found 3 matches/);
expect(allMatches).toHaveLength(1);
expect(screen.getByText(/"matches"/)).toBeInTheDocument();
});
it("renders both text and structured in the body when text is multi-line", () => {
renderAdapter({
open: true,
arguments: {},
result: "line one\nline two\nline three",
structuredContent: { matches: 3 },
});
// No header hoisting for multi-line text.
expect(document.querySelector("[data-tool-title-hoisted]")).toBeNull();
// Both blocks render in the body.
expect(screen.getByText(/line one/)).toBeInTheDocument();
expect(screen.getByText(/"matches"/)).toBeInTheDocument();
});
it("does not hoist text when path-based header hoisting takes precedence", () => {
// Tool name contains the basename → path-based hoisting activates.
renderAdapter({
open: true,
name: "Write file output.md",
arguments: { path: "/project/output.md" },
result: "Wrote file",
structuredContent: { bytes: 42 },
});
// Path-based hoisting wins; result text stays in the body.
expect(document.querySelector("[data-tool-title-hoisted]")).toBeNull();
expect(screen.getByText(/wrote file/i)).toBeInTheDocument();
expect(screen.getByText(/"bytes"/)).toBeInTheDocument();
});
});
@@ -0,0 +1,337 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ToolChainCards } from "../ToolChainCards";
import type { ToolChainItem } from "@/features/chat/lib/toolChainGrouping";
vi.mock("@/features/chat/hooks/ArtifactPolicyContext", () => ({
useArtifactPolicyContext: () => ({
resolveToolCardDisplay: () => ({
role: "none",
primaryCandidate: null,
secondaryCandidates: [],
}),
resolveMarkdownHref: () => null,
pathExists: vi.fn().mockResolvedValue(false),
openResolvedPath: vi.fn().mockResolvedValue(undefined),
}),
}));
let nextId = 0;
function pair(
name: string,
options: {
isError?: boolean;
status?: ToolChainItem["request"] extends infer R
? R extends { status: infer S }
? S
: never
: never;
completed?: boolean;
} = {},
): ToolChainItem {
const id = `tool-${++nextId}`;
const completed = options.completed !== false;
return {
key: id,
request: {
type: "toolRequest",
id,
name,
arguments: {},
status: options.status ?? "completed",
},
response: completed
? {
type: "toolResponse",
id,
name,
result: "ok",
isError: options.isError ?? false,
}
: undefined,
};
}
describe("ToolChainCards", () => {
it("renders without a parent header for a single tool item", () => {
render(<ToolChainCards toolItems={[pair("Read · src/a.ts")]} />);
expect(
screen.queryByRole("button", { name: /reviewing files|step/i }),
).not.toBeInTheDocument();
});
it("renders a deterministic chain header for multi-tool chains", () => {
render(
<ToolChainCards
toolItems={[pair("Shell · npm test"), pair("Shell · npm run build")]}
/>,
);
expect(
screen.getByRole("button", { name: /running commands.*2 step/i }),
).toBeInTheDocument();
});
it("uses the active label while any step is still in progress", () => {
render(
<ToolChainCards
toolItems={[
pair("Shell · npm test", { completed: true }),
pair("Shell · npm build", {
status: "executing",
completed: false,
}),
]}
/>,
);
expect(
screen.getByRole("button", { name: /working through 2 steps/i }),
).toBeInTheDocument();
});
it("collapses and re-expands an active chain when the header is clicked", async () => {
const user = userEvent.setup();
render(
<ToolChainCards
toolItems={[
pair("Edit · src/a.ts"),
pair("Edit · src/b.ts", {
status: "executing",
completed: false,
}),
]}
/>,
);
const header = screen.getByRole("button", {
name: /working through 2 steps/i,
});
expect(header).toHaveAttribute("aria-expanded", "true");
await user.click(header);
expect(header).toHaveAttribute("aria-expanded", "false");
});
it("starts collapsed when the chain mounts already complete (replay)", async () => {
const user = userEvent.setup();
render(
<ToolChainCards
toolItems={[pair("Edit · src/a.ts"), pair("Edit · src/b.ts")]}
/>,
);
const header = screen.getByRole("button", {
name: /updating files.*2 steps/i,
});
expect(header).toHaveAttribute("aria-expanded", "false");
await user.click(header);
expect(header).toHaveAttribute("aria-expanded", "true");
});
it("auto-collapses a live chain once every step has completed", () => {
const a = pair("Edit · src/a.ts");
const bRequest = pair("Edit · src/b.ts", {
status: "executing",
completed: false,
});
const { rerender } = render(<ToolChainCards toolItems={[a, bRequest]} />);
const activeHeader = screen.getByRole("button", {
name: /working through 2 steps/i,
});
expect(activeHeader).toHaveAttribute("aria-expanded", "true");
// Same chain identity, but the second step now has a response — i.e. the
// chain has just completed in realtime.
const bComplete: typeof bRequest = {
...bRequest,
request: bRequest.request
? { ...bRequest.request, status: "completed" }
: bRequest.request,
response: {
type: "toolResponse",
id: bRequest.request?.id ?? "tool-x",
name: "Edit · src/b.ts",
result: "ok",
isError: false,
},
};
rerender(<ToolChainCards toolItems={[a, bComplete]} />);
const completedHeader = screen.getByRole("button", {
name: /updating files.*2 steps/i,
});
expect(completedHeader).toHaveAttribute("aria-expanded", "false");
});
it("surfaces error status as a data attribute on the chain wrapper", () => {
const { container } = render(
<ToolChainCards
toolItems={[
pair("Shell · npm test"),
pair("Shell · npm build", { isError: true }),
]}
/>,
);
const wrapper = container.querySelector('[data-role="tool-chain-card"]');
expect(wrapper?.getAttribute("data-status")).toBe("error");
});
it("renders a step rail row for each child inside a chain", () => {
const { container } = render(
<ToolChainCards
toolItems={[
pair("Edit · src/a.ts"),
pair("Edit · src/b.ts"),
pair("Edit · src/c.ts", {
status: "executing",
completed: false,
}),
]}
/>,
);
const rows = container.querySelectorAll('[data-role="tool-chain-step"]');
expect(rows).toHaveLength(3);
});
it("does not wrap a single tool call in a rail row", () => {
const { container } = render(
<ToolChainCards toolItems={[pair("Read · src/a.ts")]} />,
);
const rows = container.querySelectorAll('[data-role="tool-chain-step"]');
expect(rows).toHaveLength(0);
});
it("renders a left caret button on a single tool call that toggles its open state", async () => {
const user = userEvent.setup();
const { container } = render(
<ToolChainCards toolItems={[pair("Read · src/a.ts")]} />,
);
const wrapper = container.querySelector('[data-role="tool-single"]');
expect(wrapper).not.toBeNull();
const caret = wrapper?.querySelector(
":scope > button",
) as HTMLButtonElement;
expect(caret).toBeTruthy();
expect(caret).toHaveAttribute("aria-expanded", "false");
await user.click(caret);
expect(caret).toHaveAttribute("aria-expanded", "true");
});
it("hides the trailing right-side chevron on a single tool call", () => {
const { container } = render(
<ToolChainCards toolItems={[pair("Read · src/a.ts")]} />,
);
// The shared ToolHeader's trailing chevron is a CollapsibleTrigger
// styled with the group-data-[state=closed]:-rotate-90 class. With
// showChevron={false} the icon should not render at all inside the
// single-tool wrapper.
const wrapper = container.querySelector('[data-role="tool-single"]');
expect(wrapper).not.toBeNull();
const trailingChevron = wrapper?.querySelector(
".group-data-\\[state\\=closed\\]\\:-rotate-90",
);
expect(trailingChevron).toBeNull();
});
it("counts the internal-steps disclosure as part of the rail", async () => {
const user = userEvent.setup();
const { container } = render(
<ToolChainCards
toolItems={[
pair("Edit · src/a.ts"),
pair("Edit · src/b.ts"),
pair("ls"),
pair("cat"),
]}
/>,
);
// The chain mounts as already-complete (default test pair → completed),
// so the rail starts collapsed during replay; expand it first.
await user.click(
screen.getByRole("button", { name: /updating files.*4 steps/i }),
);
const disclosure = container.querySelector(
'[data-role="tool-chain-internal-disclosure"]',
);
expect(disclosure).not.toBeNull();
const beforeRows = container.querySelectorAll(
'[data-role="tool-chain-step"]',
);
expect(beforeRows.length).toBeGreaterThanOrEqual(1);
const showButton = screen.getByRole("button", {
name: /show internal steps \(2\)/i,
});
await user.click(showButton);
const afterRows = container.querySelectorAll(
'[data-role="tool-chain-step"]',
);
expect(afterRows.length).toBe(beforeRows.length + 2);
});
it("removes the heavy parent card chrome around the chain wrapper", () => {
const { container } = render(
<ToolChainCards
toolItems={[pair("Edit · src/a.ts"), pair("Edit · src/b.ts")]}
/>,
);
const wrapper = container.querySelector('[data-role="tool-chain-card"]');
expect(wrapper).not.toBeNull();
const className = wrapper?.getAttribute("class") ?? "";
expect(className).not.toMatch(/border-/);
expect(className).not.toMatch(/bg-muted/);
});
it("prefers the LLM chain summary over the deterministic phrase when present", () => {
const a = pair("Edit · src/a.ts");
const b = pair("Edit · src/b.ts");
if (a.request) {
a.request.chainSummary = {
summary: "applied dark mode polish",
count: 2,
};
}
render(<ToolChainCards toolItems={[a, b]} />);
expect(
screen.getByRole("button", {
name: /applied dark mode polish.*2 steps/i,
}),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /updating files/i }),
).not.toBeInTheDocument();
});
it("falls back to the deterministic phrase when no chain summary is present", () => {
render(
<ToolChainCards
toolItems={[pair("Edit · src/a.ts"), pair("Edit · src/b.ts")]}
/>,
);
expect(
screen.getByRole("button", { name: /updating files.*2 steps/i }),
).toBeInTheDocument();
});
it("does not surface the chain summary while the chain is still active", () => {
const a = pair("Edit · src/a.ts");
const b = pair("Edit · src/b.ts", {
status: "executing",
completed: false,
});
if (a.request) {
a.request.chainSummary = {
summary: "applied dark mode polish",
count: 2,
};
}
render(<ToolChainCards toolItems={[a, b]} />);
expect(
screen.getByRole("button", { name: /working through 2 steps/i }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /applied dark mode polish/i }),
).not.toBeInTheDocument();
});
});
@@ -180,6 +180,158 @@ describe("acpNotificationHandler", () => {
});
});
it("attributes a completed live tool response to the matching request when a sibling is still executing", async () => {
// Regression: with two sibling tool requests, completing the first
// while the second is still unpaired must label the response with the
// first request's name. Previously the live path used the latest
// unpaired request, which could swap names across siblings.
registerPreparedSession("acp-session", "goose", "/Users/test");
setActiveMessageId("acp-session", "assistant-1");
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-a",
title: "read_file",
rawInput: { path: "/tmp/notes.md" },
},
} as never);
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-b",
title: "grep",
rawInput: { pattern: "TODO" },
},
} as never);
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-a",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: "file contents" },
},
],
},
} as never);
const [message] = useChatStore.getState().messagesBySession["acp-session"];
expect(message.content.map((block) => block.type)).toEqual([
"toolRequest",
"toolRequest",
"toolResponse",
]);
expect(message.content[0]).toMatchObject({
type: "toolRequest",
id: "tool-a",
name: "read_file",
status: "completed",
});
expect(message.content[1]).toMatchObject({
type: "toolRequest",
id: "tool-b",
name: "grep",
status: "executing",
});
expect(message.content[2]).toMatchObject({
type: "toolResponse",
id: "tool-a",
name: "read_file",
result: "file contents",
isError: false,
});
});
it("keeps a late live tool response from moving the streaming pointer back to its owner message", async () => {
registerPreparedSession("acp-session", "goose", "/Users/test");
setActiveMessageId("acp-session", "assistant-1");
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-a",
title: "read_file",
rawInput: { path: "/tmp/notes.md" },
},
} as never);
const beforeMessages =
useChatStore.getState().messagesBySession["acp-session"] ?? [];
useChatStore.setState((state) => ({
...state,
messagesBySession: {
...state.messagesBySession,
"acp-session": [
...beforeMessages,
{
id: "assistant-2",
role: "assistant",
created: Date.now(),
content: [],
metadata: {
userVisible: true,
agentVisible: true,
completionStatus: "inProgress",
},
},
],
},
}));
useChatStore.getState().setStreamingMessageId("acp-session", "assistant-2");
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-a",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: "file contents" },
},
],
},
} as never);
expect(
useChatStore.getState().getSessionRuntime("acp-session")
.streamingMessageId,
).toBe("assistant-2");
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "Continuing with the answer.",
},
},
} as never);
const messages = useChatStore.getState().messagesBySession["acp-session"];
const ownerMessage = messages.find((m) => m.id === "assistant-1");
const currentMessage = messages.find((m) => m.id === "assistant-2");
expect(ownerMessage?.content.map((block) => block.type)).toEqual([
"toolRequest",
"toolResponse",
]);
expect(currentMessage?.content).toEqual([
{ type: "text", text: "Continuing with the answer." },
]);
});
it("preserves structured tool output when ACP provides rawOutput", async () => {
registerPreparedSession(
"acp-session",
@@ -555,4 +707,183 @@ describe("acpNotificationHandler", () => {
isError: false,
});
});
it("threads tool chain summary onto the streaming tool request (live)", async () => {
registerPreparedSession("acp-session", "goose", "/tmp");
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tc-1",
title: "running ls",
},
} as never);
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tc-2",
title: "running pwd",
},
} as never);
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tc-1",
_meta: {
goose: {
toolChainSummary: {
summary: "inspected working directory",
count: 2,
},
},
},
},
} as never);
const messages = useChatStore.getState().messagesBySession["acp-session"];
expect(messages).toBeTruthy();
const toolReqs =
messages?.flatMap((m) =>
m.content.filter((c) => c.type === "toolRequest"),
) ?? [];
const first = toolReqs.find(
(c) => c.type === "toolRequest" && c.id === "tc-1",
);
const second = toolReqs.find(
(c) => c.type === "toolRequest" && c.id === "tc-2",
);
expect(first?.type === "toolRequest" && first.chainSummary).toEqual({
summary: "inspected working directory",
count: 2,
});
expect(
second?.type === "toolRequest" && second.chainSummary,
).toBeUndefined();
});
it("threads tool chain summary onto the first tool call even when the agent has moved to the next assistant message (live)", async () => {
registerPreparedSession("acp-session", "goose", "/tmp");
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tc-1",
title: "running ls",
},
} as never);
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tc-2",
title: "running pwd",
},
} as never);
// Simulate the agent moving on to the next assistant message: the
// streamingMessageId now points to a brand-new message that does not
// contain the original tool requests. This is what happens in practice
// by the time the chain summary task fires (after all tool responses
// have been emitted and the next agent turn has begun).
const beforeMessages =
useChatStore.getState().messagesBySession["acp-session"] ?? [];
const newAssistantId = "next-assistant-msg";
useChatStore.setState((state) => ({
...state,
messagesBySession: {
...state.messagesBySession,
"acp-session": [
...beforeMessages,
{
id: newAssistantId,
role: "assistant",
created: Date.now(),
content: [{ type: "text", text: "ok" }],
metadata: {
userVisible: true,
agentVisible: true,
completionStatus: "inProgress",
},
},
],
},
}));
useChatStore
.getState()
.setStreamingMessageId("acp-session", newAssistantId);
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tc-1",
_meta: {
goose: {
toolChainSummary: {
summary: "inspected working directory",
count: 2,
},
},
},
},
} as never);
const messages = useChatStore.getState().messagesBySession["acp-session"];
const toolReqs =
messages?.flatMap((m) =>
m.content.filter((c) => c.type === "toolRequest"),
) ?? [];
const first = toolReqs.find(
(c) => c.type === "toolRequest" && c.id === "tc-1",
);
expect(first?.type === "toolRequest" && first.chainSummary).toEqual({
summary: "inspected working directory",
count: 2,
});
// The new assistant message must not have been mutated to absorb the
// chain summary (regression guard: it doesn't own the tool request).
const nextMsg = messages?.find((m) => m.id === newAssistantId);
expect(nextMsg?.content.some((c) => c.type === "toolRequest")).toBe(false);
});
it("attaches tool chain summary on initial tool_call during replay", async () => {
const replaySessionId = "replay-chain-summary-session";
useChatStore.setState({
loadingSessionIds: new Set<string>([replaySessionId]),
});
await handleSessionNotification({
sessionId: replaySessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: "tc-1",
title: "ran two things",
_meta: {
goose: {
toolChainSummary: {
summary: "applied dark mode polish",
count: 4,
},
},
},
},
} as never);
const buffer = getReplayBuffer(replaySessionId);
expect(buffer).toBeTruthy();
const tc = buffer
?.flatMap((m) => m.content)
.find((c) => c.type === "toolRequest" && c.id === "tc-1");
expect(tc?.type === "toolRequest" && tc.chainSummary).toEqual({
summary: "applied dark mode polish",
count: 4,
});
});
});
@@ -4,10 +4,7 @@ import type {
} from "@agentclientprotocol/sdk";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import {
getBufferedMessage,
findLatestUnpairedToolRequest,
} from "@/features/chat/hooks/replayBuffer";
import { getBufferedMessage } from "@/features/chat/hooks/replayBuffer";
import type {
ToolCallLocation,
ToolCallStatus,
@@ -31,7 +28,10 @@ import {
} from "./acpReplayAssistant";
import { getReplayCreated, getReplayMessageId } from "./acpReplayMetadata";
import { handleSessionInfoUpdate } from "./acpSessionInfoUpdate";
import { getToolCallIdentity } from "./acpToolCallIdentity";
import {
getToolCallIdentity,
getToolChainSummary,
} from "./acpToolCallIdentity";
import { perfLog } from "@/shared/lib/perfLog";
// Pre-set message ID for the next live stream per session.
@@ -214,6 +214,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
case "tool_call": {
const created = getReplayCreated(update);
const identity = getToolCallIdentity(update);
const chainSummary = getToolChainSummary(update);
const msg = ensureReplayAssistantMessage(
sessionId,
getReplayMessageId(update),
@@ -228,6 +229,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
status: "executing",
...toolCallUpdatePatch(update),
startedAt: created ?? Date.now(),
...(chainSummary ? { chainSummary } : {}),
});
break;
}
@@ -236,6 +238,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
const created = getReplayCreated(update);
const replayMessageId = getReplayMessageId(update);
const identity = getToolCallIdentity(update);
const chainSummary = getToolChainSummary(update);
const trackedMessageId = getTrackedReplayAssistantMessageId(sessionId);
const replayMsg = replayMessageId
? getBufferedMessage(sessionId, replayMessageId)
@@ -257,7 +260,8 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
if (
update.title ||
Object.keys(identity).length > 0 ||
Object.keys(patch).length > 0
Object.keys(patch).length > 0 ||
chainSummary
) {
const tc = msg.content.find(
(c) => c.type === "toolRequest" && c.id === update.toolCallId,
@@ -267,6 +271,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
...(update.title ? { name: update.title } : {}),
...identity,
...patch,
...(chainSummary ? { chainSummary } : {}),
});
}
}
@@ -343,6 +348,7 @@ function handleLive(sessionId: string, update: SessionUpdate): void {
case "tool_call": {
const messageId = ensureLiveAssistantMessage(sessionId);
const identity = getToolCallIdentity(update);
const chainSummary = getToolChainSummary(update);
const toolRequest: ToolRequestContent = {
type: "toolRequest",
@@ -353,6 +359,7 @@ function handleLive(sessionId: string, update: SessionUpdate): void {
status: "executing",
...toolCallUpdatePatch(update),
startedAt: Date.now(),
...(chainSummary ? { chainSummary } : {}),
};
store.setStreamingMessageId(sessionId, messageId);
store.appendToStreamingMessage(sessionId, toolRequest);
@@ -360,14 +367,24 @@ function handleLive(sessionId: string, update: SessionUpdate): void {
}
case "tool_call_update": {
const messageId = ensureLiveAssistantMessage(sessionId);
const identity = getToolCallIdentity(update);
const chainSummary = getToolChainSummary(update);
// Late-arriving updates (chain summaries, async titles) can target a
// tool call whose request lives in an older message than the currently
// streaming one. Patch the message that actually owns the tool call,
// falling back to ensureLiveAssistantMessage only if we can't find it.
const ownerMessageId = findLiveMessageIdWithToolCall(
sessionId,
update.toolCallId,
);
const messageId = ownerMessageId ?? ensureLiveAssistantMessage(sessionId);
const patch = toolCallUpdatePatch(update);
if (
update.title ||
Object.keys(identity).length > 0 ||
Object.keys(patch).length > 0
Object.keys(patch).length > 0 ||
chainSummary
) {
store.updateMessage(sessionId, messageId, (msg) => ({
...msg,
@@ -378,6 +395,7 @@ function handleLive(sessionId: string, update: SessionUpdate): void {
...(update.title ? { name: update.title } : {}),
...identity,
...patch,
...(chainSummary ? { chainSummary } : {}),
}
: c,
),
@@ -386,12 +404,18 @@ function handleLive(sessionId: string, update: SessionUpdate): void {
if (update.status === "completed" || update.status === "failed") {
const toolCallStatus = toolCallStatusFromUpdate(update.status);
const streamingMessage = store.messagesBySession[sessionId]?.find(
const ownerMessage = store.messagesBySession[sessionId]?.find(
(m) => m.id === messageId,
);
const toolRequest = streamingMessage
? findLatestUnpairedToolRequest(streamingMessage.content)
: null;
// Look up the request that this update belongs to by exact id —
// sibling tools can complete out of order, so the latest unpaired
// request isn't necessarily the one we're updating. Mirrors the
// replay branch above.
const toolRequest =
ownerMessage?.content.find(
(block): block is ToolRequestContent =>
block.type === "toolRequest" && block.id === update.toolCallId,
) ?? null;
store.updateMessage(sessionId, messageId, (msg) => ({
...msg,
@@ -411,13 +435,15 @@ function handleLive(sessionId: string, update: SessionUpdate): void {
const toolResponse: ToolResponseContent = {
type: "toolResponse",
id: update.toolCallId,
name: toolRequest?.name ?? "",
name: toolRequest?.name ?? update.title ?? "",
result: resultText,
structuredContent: extractToolStructuredContent(update),
isError: update.status === "failed",
};
store.setStreamingMessageId(sessionId, messageId);
store.appendToStreamingMessage(sessionId, toolResponse);
store.updateMessage(sessionId, messageId, (msg) => ({
...msg,
content: [...msg.content, toolResponse],
}));
if (update.status === "completed") {
attachMcpAppPayload(
sessionId,
@@ -509,6 +535,31 @@ function findStreamingMessageId(sessionId: string): string | null {
.streamingMessageId;
}
/**
* Locate the live message that owns a given tool call id by scanning
* `messagesBySession` from the most recent message backwards. Used by
* `tool_call_update` to keep late-arriving updates (chain summaries, async
* titles, status flips) anchored on the request's original message even when
* the streaming pointer has moved on to the next assistant turn.
*/
function findLiveMessageIdWithToolCall(
sessionId: string,
toolCallId: string,
): string | null {
const messages = useChatStore.getState().messagesBySession[sessionId];
if (!messages) return null;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (
messages[i].content.some(
(c) => c.type === "toolRequest" && c.id === toolCallId,
)
) {
return messages[i].id;
}
}
return null;
}
function ensureLiveAssistantMessage(
sessionId: string,
preferredMessageId?: string | null,
@@ -5,6 +5,11 @@ export interface ToolCallIdentity {
extensionName?: string;
}
export interface ToolChainSummary {
summary: string;
count: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -34,3 +39,30 @@ export function getToolCallIdentity(update: SessionUpdate): ToolCallIdentity {
: {}),
};
}
/**
* Extract a chain summary from `_meta.goose.toolChainSummary` of a tool-call
* SessionUpdate. Returns `undefined` when the meta is missing, malformed, or
* carries a non-positive count.
*
* The server attaches this to the FIRST tool call in a multi-tool chain once
* every step has completed; replays after reload re-emit it on the initial
* `ToolCall` notification so the chain header is correct on first paint.
*/
export function getToolChainSummary(
update: SessionUpdate,
): ToolChainSummary | undefined {
if (!isRecord(update._meta)) return undefined;
const goose = update._meta.goose;
if (!isRecord(goose)) return undefined;
const chain = goose.toolChainSummary;
if (!isRecord(chain)) return undefined;
const summary = chain.summary;
const count = chain.count;
if (typeof summary !== "string" || summary.length === 0) return undefined;
if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) {
return undefined;
}
return { summary, count: Math.trunc(count) };
}
@@ -193,13 +193,44 @@
"tools": {
"content": "Content",
"fileNotFound": "File not found: {{path}}",
"inputSummary": {
"command": "Command",
"line": "Line",
"path": "Path",
"query": "Query",
"resource": "Resource",
"tool": "Tool",
"workingDirectory": "Working directory"
},
"moreOutputs": "More outputs ({{count}})",
"openFile": "Open file",
"openFolder": "Open folder",
"openNamed": "Open {{name}}",
"openPath": "Open path",
"pathOutsideRoots": "Path is outside allowed roots",
"structuredContent": "Structured content",
"structuredOutput": "Structured output",
"structuredOutputLines": "{{count}} lines"
},
"tool_chain": {
"summary": {
"active": "working",
"reviewing_files": "reviewing files",
"running_commands": "running commands",
"checking_resources": "checking resources",
"updating_files": "updating files",
"steps_one": "{{count}} step",
"steps_other": "{{count}} steps"
},
"internalSteps": {
"show": "Show internal steps ({{count}})",
"hide": "Hide internal steps ({{count}})"
},
"title": {
"active": "working through {{count}} step",
"active_other": "working through {{count}} steps",
"labeled": "{{label}} ({{count}} step)",
"labeled_other": "{{label}} ({{count}} steps)"
}
}
}
@@ -193,13 +193,44 @@
"tools": {
"content": "Contenido",
"fileNotFound": "Archivo no encontrado: {{path}}",
"inputSummary": {
"command": "Comando",
"line": "Línea",
"path": "Ruta",
"query": "Búsqueda",
"resource": "Recurso",
"tool": "Herramienta",
"workingDirectory": "Directorio de trabajo"
},
"moreOutputs": "Más salidas ({{count}})",
"openFile": "Abrir archivo",
"openFolder": "Abrir carpeta",
"openNamed": "Abrir {{name}}",
"openPath": "Abrir ruta",
"pathOutsideRoots": "La ruta está fuera de las raíces permitidas de proyecto/artefactos.",
"structuredContent": "Contenido estructurado",
"structuredOutput": "Salida estructurada",
"structuredOutputLines": "{{count}} líneas"
},
"tool_chain": {
"summary": {
"active": "trabajando",
"reviewing_files": "revisando archivos",
"running_commands": "ejecutando comandos",
"checking_resources": "consultando recursos",
"updating_files": "actualizando archivos",
"steps_one": "{{count}} paso",
"steps_other": "{{count}} pasos"
},
"internalSteps": {
"show": "Mostrar pasos internos ({{count}})",
"hide": "Ocultar pasos internos ({{count}})"
},
"title": {
"active": "trabajando en {{count}} paso",
"active_other": "trabajando en {{count}} pasos",
"labeled": "{{label}} ({{count}} paso)",
"labeled_other": "{{label}} ({{count}} pasos)"
}
}
}
+13
View File
@@ -91,6 +91,13 @@ export type MessageCompletionStatus =
| "error"
| "stopped";
export interface ToolChainSummary {
/** Lowercase phrase covering the chain's tool calls (e.g. "applied dark mode polish"). */
summary: string;
/** Number of tool calls the summary covers. */
count: number;
}
export interface ToolRequestContent {
type: "toolRequest";
id: string;
@@ -104,6 +111,12 @@ export interface ToolRequestContent {
/** Epoch ms when the tool call started executing (set on event receipt). */
startedAt?: number;
annotations?: ContentAnnotations;
/**
* Server-generated summary of a multi-tool chain that starts at this tool
* call. Only set on the FIRST tool call of a chain (>= 2 tools); the rest of
* the chain has this field undefined.
*/
chainSummary?: ToolChainSummary;
}
export interface ToolResponseContent {
+446 -90
View File
@@ -1,3 +1,4 @@
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
@@ -13,26 +14,66 @@ import {
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import {
createContext,
isValidElement,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("group not-prose w-full min-w-0 max-w-full", className)}
{...props}
/>
);
interface ToolContextValue {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
const ToolContext = createContext<ToolContextValue | null>(null);
export const Tool = ({
className,
open,
defaultOpen = false,
onOpenChange,
...props
}: ToolProps) => {
const [isOpen, setIsOpen] = useControllableState({
defaultProp: defaultOpen,
onChange: onOpenChange,
prop: open,
});
const value = useMemo(() => ({ isOpen, setIsOpen }), [isOpen, setIsOpen]);
return (
<ToolContext.Provider value={value}>
<Collapsible
className={cn("group not-prose w-full min-w-0 max-w-full", className)}
open={isOpen}
onOpenChange={setIsOpen}
{...props}
/>
</ToolContext.Provider>
);
};
export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolHeaderProps = {
title?: string;
title?: ReactNode;
className?: string;
showIcon?: boolean;
showStatusBadge?: boolean;
/** When false, hides the trailing disclosure chevron in the header. */
showChevron?: boolean;
splitTrigger?: boolean;
layout?: "fill" | "fit";
elapsedSeconds?: number;
} & (
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
@@ -53,21 +94,55 @@ const statusLabels: Record<ToolPart["state"], string> = {
"output-error": "Error",
};
const statusIcons: Record<ToolPart["state"], ReactNode> = {
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"input-streaming": <CircleIcon className="size-4" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
const statusIconComponents: Record<ToolPart["state"], LucideIcon> = {
"approval-requested": ClockIcon,
"approval-responded": CheckCircleIcon,
"input-available": ClockIcon,
"input-streaming": CircleIcon,
"output-available": CheckCircleIcon,
"output-denied": XCircleIcon,
"output-error": XCircleIcon,
};
export const getStatusBadge = (status: ToolPart["state"]) => {
const statusIconClasses: Record<ToolPart["state"], string> = {
"approval-requested": "text-yellow-600",
"approval-responded": "text-blue-600",
"input-available": "animate-pulse",
"input-streaming": "",
"output-available": "text-green-600",
"output-denied": "text-orange-600",
"output-error": "text-red-600",
};
export const ToolStatusIcon = ({
status,
className,
}: {
status: ToolPart["state"];
className?: string;
}) => {
const Icon = statusIconComponents[status];
return (
<Icon
aria-hidden="true"
className={cn("size-4 shrink-0", statusIconClasses[status], className)}
/>
);
};
export const getStatusBadge = (
status: ToolPart["state"],
className?: string,
) => {
if (status === "output-available") return null;
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{statusIcons[status]}
<span
className={cn(
"shrink-0 flex items-center gap-1 text-xs text-muted-foreground",
className,
)}
>
<ToolStatusIcon status={status} />
{statusLabels[status]}
</span>
);
@@ -80,26 +155,81 @@ export const ToolHeader = ({
state,
toolName,
showIcon = true,
showStatusBadge = true,
showChevron = true,
splitTrigger = false,
layout = "fill",
elapsedSeconds,
...props
}: ToolHeaderProps) => {
const derivedName =
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
const isFitLayout = layout === "fit";
const toolContext = useContext(ToolContext);
const isOpen = toolContext?.isOpen ?? false;
return (
<CollapsibleTrigger
className={cn("inline-flex items-center gap-1.5 py-px", className)}
{...props}
>
{showIcon && <WrenchIcon className="size-4 text-muted-foreground" />}
<span className="font-medium text-sm">{title ?? derivedName}</span>
{getStatusBadge(state)}
const containerClasses = cn(
"items-center gap-1.5 py-px",
isFitLayout ? "inline-flex w-fit max-w-full self-start" : "flex w-full",
className,
);
const titleClasses = cn(
"min-w-0 truncate text-left text-sm font-medium",
isFitLayout ? "flex-none max-w-full" : "flex-1",
);
const trailing = (
<>
{showStatusBadge && getStatusBadge(state)}
{elapsedSeconds != null && (
<span className="tabular-nums text-xs text-muted-foreground">
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
{elapsedSeconds}s
</span>
)}
<ChevronDownIcon className="size-3.5 text-muted-foreground transition-transform group-data-[state=closed]:-rotate-90" />
{showChevron && (
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-[state=closed]:-rotate-90" />
)}
</>
);
if (splitTrigger) {
return (
<div className={containerClasses}>
<div
className={cn("min-w-0 cursor-pointer items-center gap-1.5", {
"flex flex-1": !isFitLayout,
"inline-flex max-w-full": isFitLayout,
})}
role="button"
tabIndex={0}
onClick={() => toolContext?.setIsOpen(!isOpen)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toolContext?.setIsOpen(!isOpen);
}
}}
>
{showIcon && (
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
)}
<span className={titleClasses}>{title ?? derivedName}</span>
</div>
<CollapsibleTrigger className="shrink-0 flex items-center gap-1.5">
{trailing}
</CollapsibleTrigger>
</div>
);
}
return (
<CollapsibleTrigger className={containerClasses} {...props}>
{showIcon && (
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
)}
<span className={titleClasses}>{title ?? derivedName}</span>
<span className="shrink-0 flex items-center gap-1.5">{trailing}</span>
</CollapsibleTrigger>
);
};
@@ -109,34 +239,213 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 min-w-0 max-w-full space-y-4 py-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 min-w-0 max-w-full space-y-2 py-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
export type ToolSectionProps = ComponentProps<"div"> & {
label: string;
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
export const ToolSection = ({
className,
label,
children,
...props
}: ToolSectionProps) => (
<div className={cn("space-y-2", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
{label}
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
{children}
</div>
);
export type ToolSurfaceProps = ComponentProps<"div"> & {
destructive?: boolean;
tone?: "muted" | "outline";
};
export const ToolSurface = ({
className,
destructive = false,
tone = "muted",
...props
}: ToolSurfaceProps) => (
<div
className={cn(
"overflow-hidden rounded-md text-xs [&_pre]:whitespace-pre-wrap [&_pre]:break-words",
destructive
? "bg-destructive/10 text-destructive"
: tone === "outline"
? "border border-border bg-background text-foreground"
: "bg-muted/50 text-foreground",
className,
)}
{...props}
/>
);
function EmbeddedOverflowViewport({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
const viewportRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [showTopFade, setShowTopFade] = useState(false);
const [showBottomFade, setShowBottomFade] = useState(false);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const updateFadeState = () => {
const { scrollTop, scrollHeight, clientHeight } = viewport;
const hasOverflow = scrollHeight - clientHeight > 1;
setShowTopFade(hasOverflow && scrollTop > 1);
setShowBottomFade(
hasOverflow && scrollTop + clientHeight < scrollHeight - 1,
);
};
updateFadeState();
viewport.addEventListener("scroll", updateFadeState, { passive: true });
window.addEventListener("resize", updateFadeState);
let resizeObserver: ResizeObserver | undefined;
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => updateFadeState());
resizeObserver.observe(viewport);
if (contentRef.current) {
resizeObserver.observe(contentRef.current);
}
}
return () => {
viewport.removeEventListener("scroll", updateFadeState);
window.removeEventListener("resize", updateFadeState);
resizeObserver?.disconnect();
};
}, []);
return (
<div className="relative">
<div ref={viewportRef} className={className}>
<div ref={contentRef}>{children}</div>
</div>
{showTopFade && (
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-gradient-to-b from-muted to-transparent" />
)}
{showBottomFade && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-6 bg-gradient-to-t from-muted to-transparent" />
)}
</div>
);
}
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
label?: string;
showLabel?: boolean;
summary?: ReactNode | ((options: { isOpen: boolean }) => ReactNode);
embedded?: boolean;
};
export const ToolInput = ({
className,
input,
label = "Parameters",
showLabel = true,
summary,
embedded = false,
...props
}: ToolInputProps) => {
const [isJsonOpen, setIsJsonOpen] = useState(false);
const hasStructuredInput =
input !== undefined &&
input !== null &&
(typeof input !== "object" ||
Array.isArray(input) ||
Object.keys(input as Record<string, unknown>).length > 0);
if (!summary && !hasStructuredInput) {
return null;
}
const summaryContent =
typeof summary === "function"
? summary({ isOpen: isJsonOpen })
: (summary ?? (
<span className="text-xs text-muted-foreground">{label}</span>
));
const inputBody = hasStructuredInput ? (
<Collapsible open={isJsonOpen} onOpenChange={setIsJsonOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-start gap-3 text-left"
>
<div className="min-w-0 flex-1">{summaryContent}</div>
<ChevronDownIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground transition-transform [button[data-state=closed]_&]:-rotate-90" />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1 mt-2 overflow-hidden outline-none data-[state=closed]:animate-out data-[state=open]:animate-in">
<pre className="overflow-auto whitespace-pre-wrap break-words font-mono text-[12px] leading-5 text-foreground/90">
{JSON.stringify(input, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
) : (
summaryContent
);
if (embedded) {
return (
<div className={cn("overflow-hidden px-3 py-2", className)} {...props}>
{inputBody}
</div>
);
}
if (!showLabel) {
return (
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
<ToolSurface tone="muted" className="px-3 py-2">
{inputBody}
</ToolSurface>
</div>
);
}
return (
<ToolSection
label={label}
className={cn("overflow-hidden", className)}
{...props}
>
<ToolSurface tone="muted" className="px-3 py-2">
{inputBody}
</ToolSurface>
</ToolSection>
);
};
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"];
errorText: ToolPart["errorText"];
label?: string;
contentClassName?: string;
plainText?: boolean;
showLabel?: boolean;
embedded?: boolean;
/** Max height (Tailwind class, e.g. "max-h-32") for the embedded scroll viewport. */
embeddedMaxHeightClass?: string;
};
export const ToolOutput = ({
@@ -145,72 +454,119 @@ export const ToolOutput = ({
errorText,
label,
contentClassName,
plainText = false,
showLabel = true,
embedded = false,
embeddedMaxHeightClass = "max-h-32",
...props
}: ToolOutputProps) => {
if (output === undefined && errorText === undefined) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
let isCodeBlockOutput = false;
const isReactOutput = isValidElement(output);
const renderedOutput = (() => {
if (typeof output === "object" && !isValidElement(output)) {
return (
<CodeBlock
code={JSON.stringify(output, null, 2)}
language="json"
className={
embedded
? "rounded-none border-0 bg-transparent shadow-none [&_pre]:m-0 [&_pre]:bg-transparent [&_pre]:p-0"
: undefined
}
/>
);
}
if (typeof output === "string") {
return (
<CodeBlock
code={output}
language="json"
className={
embedded
? "rounded-none border-0 bg-transparent shadow-none [&_pre]:m-0 [&_pre]:bg-transparent [&_pre]:p-0"
: undefined
}
/>
);
}
return <div>{output as ReactNode}</div>;
})();
if (output !== null && typeof output === "object" && !isReactOutput) {
isCodeBlockOutput = true;
Output = (
<CodeBlock
code={JSON.stringify(output, null, 2)}
className="border-0 bg-transparent shadow-none"
language="json"
transparentBackground
/>
if (embedded) {
const plainTextClasses =
"m-0 whitespace-pre-wrap break-words font-mono text-[12px] leading-5";
const plainOutput = errorText
? errorText
: typeof output === "string"
? output
: typeof output === "object" && !isValidElement(output)
? JSON.stringify(output, null, 2)
: null;
return (
<div
data-role="tool-output-embedded"
className={cn("overflow-hidden px-3 pb-2", className)}
{...props}
>
{plainOutput != null ? (
<EmbeddedOverflowViewport
className={cn("overflow-auto", embeddedMaxHeightClass)}
>
<pre
className={cn(
plainTextClasses,
errorText ? "text-destructive" : "text-muted-foreground",
)}
>
{plainOutput}
</pre>
</EmbeddedOverflowViewport>
) : (
<EmbeddedOverflowViewport
className={cn(
"overflow-auto text-muted-foreground",
embeddedMaxHeightClass,
)}
>
{renderedOutput}
</EmbeddedOverflowViewport>
)}
</div>
);
} else if (typeof output === "string" && plainText) {
Output = (
<pre className="m-0 whitespace-pre-wrap break-words p-4 font-mono text-foreground text-sm">
{output}
</pre>
);
} else if (output !== undefined && !plainText && !isReactOutput) {
isCodeBlockOutput = true;
Output = (
<CodeBlock
code={JSON.stringify(output) ?? String(output)}
className="border-0 bg-transparent shadow-none"
language="json"
transparentBackground
/>
);
} else if (output !== undefined && output !== null && !isReactOutput) {
Output = (
<pre className="m-0 whitespace-pre-wrap break-words p-4 font-mono text-foreground text-sm">
{String(output)}
</pre>
}
if (!showLabel) {
return (
<div className={cn("space-y-2", className)} {...props}>
{errorText ? (
<ToolSurface destructive className="px-3 py-2">
<div>{errorText}</div>
</ToolSurface>
) : (
<ToolSurface tone="muted" className="px-3 py-2">
{renderedOutput}
</ToolSurface>
)}
</div>
);
}
return (
<div
className={cn("w-full min-w-0 max-w-full space-y-2", className)}
<ToolSection
label={label ?? (errorText ? "Error" : "Result")}
className={className}
{...props}
>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{label ?? (errorText ? "Error" : "Result")}
</h4>
<div
className={cn(
"w-full rounded-md text-xs [&_table]:w-full [&_pre]:whitespace-pre-wrap [&_pre]:break-words",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground",
!isCodeBlockOutput && "overflow-x-auto",
contentClassName,
)}
<ToolSurface
destructive={Boolean(errorText)}
tone="muted"
className={cn("overflow-x-auto [&_table]:w-full", contentClassName)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
{renderedOutput}
</ToolSurface>
</ToolSection>
);
};