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:
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user