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

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Taylor Ho
2026-05-05 11:37:13 -07:00
committed by GitHub
parent fb79c7de0c
commit dd95b7bb85
22 changed files with 4229 additions and 333 deletions
@@ -180,6 +180,158 @@ describe("acpNotificationHandler", () => {
});
});
it("attributes a completed live tool response to the matching request when a sibling is still executing", async () => {
// Regression: with two sibling tool requests, completing the first
// while the second is still unpaired must label the response with the
// first request's name. Previously the live path used the latest
// unpaired request, which could swap names across siblings.
registerPreparedSession("acp-session", "goose", "/Users/test");
setActiveMessageId("acp-session", "assistant-1");
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-a",
title: "read_file",
rawInput: { path: "/tmp/notes.md" },
},
} as never);
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-b",
title: "grep",
rawInput: { pattern: "TODO" },
},
} as never);
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-a",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: "file contents" },
},
],
},
} as never);
const [message] = useChatStore.getState().messagesBySession["acp-session"];
expect(message.content.map((block) => block.type)).toEqual([
"toolRequest",
"toolRequest",
"toolResponse",
]);
expect(message.content[0]).toMatchObject({
type: "toolRequest",
id: "tool-a",
name: "read_file",
status: "completed",
});
expect(message.content[1]).toMatchObject({
type: "toolRequest",
id: "tool-b",
name: "grep",
status: "executing",
});
expect(message.content[2]).toMatchObject({
type: "toolResponse",
id: "tool-a",
name: "read_file",
result: "file contents",
isError: false,
});
});
it("keeps a late live tool response from moving the streaming pointer back to its owner message", async () => {
registerPreparedSession("acp-session", "goose", "/Users/test");
setActiveMessageId("acp-session", "assistant-1");
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-a",
title: "read_file",
rawInput: { path: "/tmp/notes.md" },
},
} as never);
const beforeMessages =
useChatStore.getState().messagesBySession["acp-session"] ?? [];
useChatStore.setState((state) => ({
...state,
messagesBySession: {
...state.messagesBySession,
"acp-session": [
...beforeMessages,
{
id: "assistant-2",
role: "assistant",
created: Date.now(),
content: [],
metadata: {
userVisible: true,
agentVisible: true,
completionStatus: "inProgress",
},
},
],
},
}));
useChatStore.getState().setStreamingMessageId("acp-session", "assistant-2");
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-a",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: "file contents" },
},
],
},
} as never);
expect(
useChatStore.getState().getSessionRuntime("acp-session")
.streamingMessageId,
).toBe("assistant-2");
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "Continuing with the answer.",
},
},
} as never);
const messages = useChatStore.getState().messagesBySession["acp-session"];
const ownerMessage = messages.find((m) => m.id === "assistant-1");
const currentMessage = messages.find((m) => m.id === "assistant-2");
expect(ownerMessage?.content.map((block) => block.type)).toEqual([
"toolRequest",
"toolResponse",
]);
expect(currentMessage?.content).toEqual([
{ type: "text", text: "Continuing with the answer." },
]);
});
it("preserves structured tool output when ACP provides rawOutput", async () => {
registerPreparedSession(
"acp-session",
@@ -555,4 +707,183 @@ describe("acpNotificationHandler", () => {
isError: false,
});
});
it("threads tool chain summary onto the streaming tool request (live)", async () => {
registerPreparedSession("acp-session", "goose", "/tmp");
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tc-1",
title: "running ls",
},
} as never);
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tc-2",
title: "running pwd",
},
} as never);
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tc-1",
_meta: {
goose: {
toolChainSummary: {
summary: "inspected working directory",
count: 2,
},
},
},
},
} as never);
const messages = useChatStore.getState().messagesBySession["acp-session"];
expect(messages).toBeTruthy();
const toolReqs =
messages?.flatMap((m) =>
m.content.filter((c) => c.type === "toolRequest"),
) ?? [];
const first = toolReqs.find(
(c) => c.type === "toolRequest" && c.id === "tc-1",
);
const second = toolReqs.find(
(c) => c.type === "toolRequest" && c.id === "tc-2",
);
expect(first?.type === "toolRequest" && first.chainSummary).toEqual({
summary: "inspected working directory",
count: 2,
});
expect(
second?.type === "toolRequest" && second.chainSummary,
).toBeUndefined();
});
it("threads tool chain summary onto the first tool call even when the agent has moved to the next assistant message (live)", async () => {
registerPreparedSession("acp-session", "goose", "/tmp");
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tc-1",
title: "running ls",
},
} as never);
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tc-2",
title: "running pwd",
},
} as never);
// Simulate the agent moving on to the next assistant message: the
// streamingMessageId now points to a brand-new message that does not
// contain the original tool requests. This is what happens in practice
// by the time the chain summary task fires (after all tool responses
// have been emitted and the next agent turn has begun).
const beforeMessages =
useChatStore.getState().messagesBySession["acp-session"] ?? [];
const newAssistantId = "next-assistant-msg";
useChatStore.setState((state) => ({
...state,
messagesBySession: {
...state.messagesBySession,
"acp-session": [
...beforeMessages,
{
id: newAssistantId,
role: "assistant",
created: Date.now(),
content: [{ type: "text", text: "ok" }],
metadata: {
userVisible: true,
agentVisible: true,
completionStatus: "inProgress",
},
},
],
},
}));
useChatStore
.getState()
.setStreamingMessageId("acp-session", newAssistantId);
await handleSessionNotification({
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tc-1",
_meta: {
goose: {
toolChainSummary: {
summary: "inspected working directory",
count: 2,
},
},
},
},
} as never);
const messages = useChatStore.getState().messagesBySession["acp-session"];
const toolReqs =
messages?.flatMap((m) =>
m.content.filter((c) => c.type === "toolRequest"),
) ?? [];
const first = toolReqs.find(
(c) => c.type === "toolRequest" && c.id === "tc-1",
);
expect(first?.type === "toolRequest" && first.chainSummary).toEqual({
summary: "inspected working directory",
count: 2,
});
// The new assistant message must not have been mutated to absorb the
// chain summary (regression guard: it doesn't own the tool request).
const nextMsg = messages?.find((m) => m.id === newAssistantId);
expect(nextMsg?.content.some((c) => c.type === "toolRequest")).toBe(false);
});
it("attaches tool chain summary on initial tool_call during replay", async () => {
const replaySessionId = "replay-chain-summary-session";
useChatStore.setState({
loadingSessionIds: new Set<string>([replaySessionId]),
});
await handleSessionNotification({
sessionId: replaySessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: "tc-1",
title: "ran two things",
_meta: {
goose: {
toolChainSummary: {
summary: "applied dark mode polish",
count: 4,
},
},
},
},
} as never);
const buffer = getReplayBuffer(replaySessionId);
expect(buffer).toBeTruthy();
const tc = buffer
?.flatMap((m) => m.content)
.find((c) => c.type === "toolRequest" && c.id === "tc-1");
expect(tc?.type === "toolRequest" && tc.chainSummary).toEqual({
summary: "applied dark mode polish",
count: 4,
});
});
});
@@ -4,10 +4,7 @@ import type {
} from "@agentclientprotocol/sdk";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import {
getBufferedMessage,
findLatestUnpairedToolRequest,
} from "@/features/chat/hooks/replayBuffer";
import { getBufferedMessage } from "@/features/chat/hooks/replayBuffer";
import type {
ToolCallLocation,
ToolCallStatus,
@@ -31,7 +28,10 @@ import {
} from "./acpReplayAssistant";
import { getReplayCreated, getReplayMessageId } from "./acpReplayMetadata";
import { handleSessionInfoUpdate } from "./acpSessionInfoUpdate";
import { getToolCallIdentity } from "./acpToolCallIdentity";
import {
getToolCallIdentity,
getToolChainSummary,
} from "./acpToolCallIdentity";
import { perfLog } from "@/shared/lib/perfLog";
// Pre-set message ID for the next live stream per session.
@@ -214,6 +214,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
case "tool_call": {
const created = getReplayCreated(update);
const identity = getToolCallIdentity(update);
const chainSummary = getToolChainSummary(update);
const msg = ensureReplayAssistantMessage(
sessionId,
getReplayMessageId(update),
@@ -228,6 +229,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
status: "executing",
...toolCallUpdatePatch(update),
startedAt: created ?? Date.now(),
...(chainSummary ? { chainSummary } : {}),
});
break;
}
@@ -236,6 +238,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
const created = getReplayCreated(update);
const replayMessageId = getReplayMessageId(update);
const identity = getToolCallIdentity(update);
const chainSummary = getToolChainSummary(update);
const trackedMessageId = getTrackedReplayAssistantMessageId(sessionId);
const replayMsg = replayMessageId
? getBufferedMessage(sessionId, replayMessageId)
@@ -257,7 +260,8 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
if (
update.title ||
Object.keys(identity).length > 0 ||
Object.keys(patch).length > 0
Object.keys(patch).length > 0 ||
chainSummary
) {
const tc = msg.content.find(
(c) => c.type === "toolRequest" && c.id === update.toolCallId,
@@ -267,6 +271,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
...(update.title ? { name: update.title } : {}),
...identity,
...patch,
...(chainSummary ? { chainSummary } : {}),
});
}
}
@@ -343,6 +348,7 @@ function handleLive(sessionId: string, update: SessionUpdate): void {
case "tool_call": {
const messageId = ensureLiveAssistantMessage(sessionId);
const identity = getToolCallIdentity(update);
const chainSummary = getToolChainSummary(update);
const toolRequest: ToolRequestContent = {
type: "toolRequest",
@@ -353,6 +359,7 @@ function handleLive(sessionId: string, update: SessionUpdate): void {
status: "executing",
...toolCallUpdatePatch(update),
startedAt: Date.now(),
...(chainSummary ? { chainSummary } : {}),
};
store.setStreamingMessageId(sessionId, messageId);
store.appendToStreamingMessage(sessionId, toolRequest);
@@ -360,14 +367,24 @@ function handleLive(sessionId: string, update: SessionUpdate): void {
}
case "tool_call_update": {
const messageId = ensureLiveAssistantMessage(sessionId);
const identity = getToolCallIdentity(update);
const chainSummary = getToolChainSummary(update);
// Late-arriving updates (chain summaries, async titles) can target a
// tool call whose request lives in an older message than the currently
// streaming one. Patch the message that actually owns the tool call,
// falling back to ensureLiveAssistantMessage only if we can't find it.
const ownerMessageId = findLiveMessageIdWithToolCall(
sessionId,
update.toolCallId,
);
const messageId = ownerMessageId ?? ensureLiveAssistantMessage(sessionId);
const patch = toolCallUpdatePatch(update);
if (
update.title ||
Object.keys(identity).length > 0 ||
Object.keys(patch).length > 0
Object.keys(patch).length > 0 ||
chainSummary
) {
store.updateMessage(sessionId, messageId, (msg) => ({
...msg,
@@ -378,6 +395,7 @@ function handleLive(sessionId: string, update: SessionUpdate): void {
...(update.title ? { name: update.title } : {}),
...identity,
...patch,
...(chainSummary ? { chainSummary } : {}),
}
: c,
),
@@ -386,12 +404,18 @@ function handleLive(sessionId: string, update: SessionUpdate): void {
if (update.status === "completed" || update.status === "failed") {
const toolCallStatus = toolCallStatusFromUpdate(update.status);
const streamingMessage = store.messagesBySession[sessionId]?.find(
const ownerMessage = store.messagesBySession[sessionId]?.find(
(m) => m.id === messageId,
);
const toolRequest = streamingMessage
? findLatestUnpairedToolRequest(streamingMessage.content)
: null;
// Look up the request that this update belongs to by exact id —
// sibling tools can complete out of order, so the latest unpaired
// request isn't necessarily the one we're updating. Mirrors the
// replay branch above.
const toolRequest =
ownerMessage?.content.find(
(block): block is ToolRequestContent =>
block.type === "toolRequest" && block.id === update.toolCallId,
) ?? null;
store.updateMessage(sessionId, messageId, (msg) => ({
...msg,
@@ -411,13 +435,15 @@ function handleLive(sessionId: string, update: SessionUpdate): void {
const toolResponse: ToolResponseContent = {
type: "toolResponse",
id: update.toolCallId,
name: toolRequest?.name ?? "",
name: toolRequest?.name ?? update.title ?? "",
result: resultText,
structuredContent: extractToolStructuredContent(update),
isError: update.status === "failed",
};
store.setStreamingMessageId(sessionId, messageId);
store.appendToStreamingMessage(sessionId, toolResponse);
store.updateMessage(sessionId, messageId, (msg) => ({
...msg,
content: [...msg.content, toolResponse],
}));
if (update.status === "completed") {
attachMcpAppPayload(
sessionId,
@@ -509,6 +535,31 @@ function findStreamingMessageId(sessionId: string): string | null {
.streamingMessageId;
}
/**
* Locate the live message that owns a given tool call id by scanning
* `messagesBySession` from the most recent message backwards. Used by
* `tool_call_update` to keep late-arriving updates (chain summaries, async
* titles, status flips) anchored on the request's original message even when
* the streaming pointer has moved on to the next assistant turn.
*/
function findLiveMessageIdWithToolCall(
sessionId: string,
toolCallId: string,
): string | null {
const messages = useChatStore.getState().messagesBySession[sessionId];
if (!messages) return null;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (
messages[i].content.some(
(c) => c.type === "toolRequest" && c.id === toolCallId,
)
) {
return messages[i].id;
}
}
return null;
}
function ensureLiveAssistantMessage(
sessionId: string,
preferredMessageId?: string | null,
@@ -5,6 +5,11 @@ export interface ToolCallIdentity {
extensionName?: string;
}
export interface ToolChainSummary {
summary: string;
count: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -34,3 +39,30 @@ export function getToolCallIdentity(update: SessionUpdate): ToolCallIdentity {
: {}),
};
}
/**
* Extract a chain summary from `_meta.goose.toolChainSummary` of a tool-call
* SessionUpdate. Returns `undefined` when the meta is missing, malformed, or
* carries a non-positive count.
*
* The server attaches this to the FIRST tool call in a multi-tool chain once
* every step has completed; replays after reload re-emit it on the initial
* `ToolCall` notification so the chain header is correct on first paint.
*/
export function getToolChainSummary(
update: SessionUpdate,
): ToolChainSummary | undefined {
if (!isRecord(update._meta)) return undefined;
const goose = update._meta.goose;
if (!isRecord(goose)) return undefined;
const chain = goose.toolChainSummary;
if (!isRecord(chain)) return undefined;
const summary = chain.summary;
const count = chain.count;
if (typeof summary !== "string" || summary.length === 0) return undefined;
if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) {
return undefined;
}
return { summary, count: Math.trunc(count) };
}
@@ -193,13 +193,44 @@
"tools": {
"content": "Content",
"fileNotFound": "File not found: {{path}}",
"inputSummary": {
"command": "Command",
"line": "Line",
"path": "Path",
"query": "Query",
"resource": "Resource",
"tool": "Tool",
"workingDirectory": "Working directory"
},
"moreOutputs": "More outputs ({{count}})",
"openFile": "Open file",
"openFolder": "Open folder",
"openNamed": "Open {{name}}",
"openPath": "Open path",
"pathOutsideRoots": "Path is outside allowed roots",
"structuredContent": "Structured content",
"structuredOutput": "Structured output",
"structuredOutputLines": "{{count}} lines"
},
"tool_chain": {
"summary": {
"active": "working",
"reviewing_files": "reviewing files",
"running_commands": "running commands",
"checking_resources": "checking resources",
"updating_files": "updating files",
"steps_one": "{{count}} step",
"steps_other": "{{count}} steps"
},
"internalSteps": {
"show": "Show internal steps ({{count}})",
"hide": "Hide internal steps ({{count}})"
},
"title": {
"active": "working through {{count}} step",
"active_other": "working through {{count}} steps",
"labeled": "{{label}} ({{count}} step)",
"labeled_other": "{{label}} ({{count}} steps)"
}
}
}
@@ -193,13 +193,44 @@
"tools": {
"content": "Contenido",
"fileNotFound": "Archivo no encontrado: {{path}}",
"inputSummary": {
"command": "Comando",
"line": "Línea",
"path": "Ruta",
"query": "Búsqueda",
"resource": "Recurso",
"tool": "Herramienta",
"workingDirectory": "Directorio de trabajo"
},
"moreOutputs": "Más salidas ({{count}})",
"openFile": "Abrir archivo",
"openFolder": "Abrir carpeta",
"openNamed": "Abrir {{name}}",
"openPath": "Abrir ruta",
"pathOutsideRoots": "La ruta está fuera de las raíces permitidas de proyecto/artefactos.",
"structuredContent": "Contenido estructurado",
"structuredOutput": "Salida estructurada",
"structuredOutputLines": "{{count}} líneas"
},
"tool_chain": {
"summary": {
"active": "trabajando",
"reviewing_files": "revisando archivos",
"running_commands": "ejecutando comandos",
"checking_resources": "consultando recursos",
"updating_files": "actualizando archivos",
"steps_one": "{{count}} paso",
"steps_other": "{{count}} pasos"
},
"internalSteps": {
"show": "Mostrar pasos internos ({{count}})",
"hide": "Ocultar pasos internos ({{count}})"
},
"title": {
"active": "trabajando en {{count}} paso",
"active_other": "trabajando en {{count}} pasos",
"labeled": "{{label}} ({{count}} paso)",
"labeled_other": "{{label}} ({{count}} pasos)"
}
}
}
+13
View File
@@ -91,6 +91,13 @@ export type MessageCompletionStatus =
| "error"
| "stopped";
export interface ToolChainSummary {
/** Lowercase phrase covering the chain's tool calls (e.g. "applied dark mode polish"). */
summary: string;
/** Number of tool calls the summary covers. */
count: number;
}
export interface ToolRequestContent {
type: "toolRequest";
id: string;
@@ -104,6 +111,12 @@ export interface ToolRequestContent {
/** Epoch ms when the tool call started executing (set on event receipt). */
startedAt?: number;
annotations?: ContentAnnotations;
/**
* Server-generated summary of a multi-tool chain that starts at this tool
* call. Only set on the FIRST tool call of a chain (>= 2 tools); the rest of
* the chain has this field undefined.
*/
chainSummary?: ToolChainSummary;
}
export interface ToolResponseContent {
+446 -90
View File
@@ -1,3 +1,4 @@
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
@@ -13,26 +14,66 @@ import {
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import {
createContext,
isValidElement,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("group not-prose w-full min-w-0 max-w-full", className)}
{...props}
/>
);
interface ToolContextValue {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
const ToolContext = createContext<ToolContextValue | null>(null);
export const Tool = ({
className,
open,
defaultOpen = false,
onOpenChange,
...props
}: ToolProps) => {
const [isOpen, setIsOpen] = useControllableState({
defaultProp: defaultOpen,
onChange: onOpenChange,
prop: open,
});
const value = useMemo(() => ({ isOpen, setIsOpen }), [isOpen, setIsOpen]);
return (
<ToolContext.Provider value={value}>
<Collapsible
className={cn("group not-prose w-full min-w-0 max-w-full", className)}
open={isOpen}
onOpenChange={setIsOpen}
{...props}
/>
</ToolContext.Provider>
);
};
export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolHeaderProps = {
title?: string;
title?: ReactNode;
className?: string;
showIcon?: boolean;
showStatusBadge?: boolean;
/** When false, hides the trailing disclosure chevron in the header. */
showChevron?: boolean;
splitTrigger?: boolean;
layout?: "fill" | "fit";
elapsedSeconds?: number;
} & (
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
@@ -53,21 +94,55 @@ const statusLabels: Record<ToolPart["state"], string> = {
"output-error": "Error",
};
const statusIcons: Record<ToolPart["state"], ReactNode> = {
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"input-streaming": <CircleIcon className="size-4" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
const statusIconComponents: Record<ToolPart["state"], LucideIcon> = {
"approval-requested": ClockIcon,
"approval-responded": CheckCircleIcon,
"input-available": ClockIcon,
"input-streaming": CircleIcon,
"output-available": CheckCircleIcon,
"output-denied": XCircleIcon,
"output-error": XCircleIcon,
};
export const getStatusBadge = (status: ToolPart["state"]) => {
const statusIconClasses: Record<ToolPart["state"], string> = {
"approval-requested": "text-yellow-600",
"approval-responded": "text-blue-600",
"input-available": "animate-pulse",
"input-streaming": "",
"output-available": "text-green-600",
"output-denied": "text-orange-600",
"output-error": "text-red-600",
};
export const ToolStatusIcon = ({
status,
className,
}: {
status: ToolPart["state"];
className?: string;
}) => {
const Icon = statusIconComponents[status];
return (
<Icon
aria-hidden="true"
className={cn("size-4 shrink-0", statusIconClasses[status], className)}
/>
);
};
export const getStatusBadge = (
status: ToolPart["state"],
className?: string,
) => {
if (status === "output-available") return null;
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{statusIcons[status]}
<span
className={cn(
"shrink-0 flex items-center gap-1 text-xs text-muted-foreground",
className,
)}
>
<ToolStatusIcon status={status} />
{statusLabels[status]}
</span>
);
@@ -80,26 +155,81 @@ export const ToolHeader = ({
state,
toolName,
showIcon = true,
showStatusBadge = true,
showChevron = true,
splitTrigger = false,
layout = "fill",
elapsedSeconds,
...props
}: ToolHeaderProps) => {
const derivedName =
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
const isFitLayout = layout === "fit";
const toolContext = useContext(ToolContext);
const isOpen = toolContext?.isOpen ?? false;
return (
<CollapsibleTrigger
className={cn("inline-flex items-center gap-1.5 py-px", className)}
{...props}
>
{showIcon && <WrenchIcon className="size-4 text-muted-foreground" />}
<span className="font-medium text-sm">{title ?? derivedName}</span>
{getStatusBadge(state)}
const containerClasses = cn(
"items-center gap-1.5 py-px",
isFitLayout ? "inline-flex w-fit max-w-full self-start" : "flex w-full",
className,
);
const titleClasses = cn(
"min-w-0 truncate text-left text-sm font-medium",
isFitLayout ? "flex-none max-w-full" : "flex-1",
);
const trailing = (
<>
{showStatusBadge && getStatusBadge(state)}
{elapsedSeconds != null && (
<span className="tabular-nums text-xs text-muted-foreground">
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
{elapsedSeconds}s
</span>
)}
<ChevronDownIcon className="size-3.5 text-muted-foreground transition-transform group-data-[state=closed]:-rotate-90" />
{showChevron && (
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-[state=closed]:-rotate-90" />
)}
</>
);
if (splitTrigger) {
return (
<div className={containerClasses}>
<div
className={cn("min-w-0 cursor-pointer items-center gap-1.5", {
"flex flex-1": !isFitLayout,
"inline-flex max-w-full": isFitLayout,
})}
role="button"
tabIndex={0}
onClick={() => toolContext?.setIsOpen(!isOpen)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toolContext?.setIsOpen(!isOpen);
}
}}
>
{showIcon && (
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
)}
<span className={titleClasses}>{title ?? derivedName}</span>
</div>
<CollapsibleTrigger className="shrink-0 flex items-center gap-1.5">
{trailing}
</CollapsibleTrigger>
</div>
);
}
return (
<CollapsibleTrigger className={containerClasses} {...props}>
{showIcon && (
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
)}
<span className={titleClasses}>{title ?? derivedName}</span>
<span className="shrink-0 flex items-center gap-1.5">{trailing}</span>
</CollapsibleTrigger>
);
};
@@ -109,34 +239,213 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 min-w-0 max-w-full space-y-4 py-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 min-w-0 max-w-full space-y-2 py-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
export type ToolSectionProps = ComponentProps<"div"> & {
label: string;
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
export const ToolSection = ({
className,
label,
children,
...props
}: ToolSectionProps) => (
<div className={cn("space-y-2", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
{label}
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
{children}
</div>
);
export type ToolSurfaceProps = ComponentProps<"div"> & {
destructive?: boolean;
tone?: "muted" | "outline";
};
export const ToolSurface = ({
className,
destructive = false,
tone = "muted",
...props
}: ToolSurfaceProps) => (
<div
className={cn(
"overflow-hidden rounded-md text-xs [&_pre]:whitespace-pre-wrap [&_pre]:break-words",
destructive
? "bg-destructive/10 text-destructive"
: tone === "outline"
? "border border-border bg-background text-foreground"
: "bg-muted/50 text-foreground",
className,
)}
{...props}
/>
);
function EmbeddedOverflowViewport({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
const viewportRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [showTopFade, setShowTopFade] = useState(false);
const [showBottomFade, setShowBottomFade] = useState(false);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const updateFadeState = () => {
const { scrollTop, scrollHeight, clientHeight } = viewport;
const hasOverflow = scrollHeight - clientHeight > 1;
setShowTopFade(hasOverflow && scrollTop > 1);
setShowBottomFade(
hasOverflow && scrollTop + clientHeight < scrollHeight - 1,
);
};
updateFadeState();
viewport.addEventListener("scroll", updateFadeState, { passive: true });
window.addEventListener("resize", updateFadeState);
let resizeObserver: ResizeObserver | undefined;
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => updateFadeState());
resizeObserver.observe(viewport);
if (contentRef.current) {
resizeObserver.observe(contentRef.current);
}
}
return () => {
viewport.removeEventListener("scroll", updateFadeState);
window.removeEventListener("resize", updateFadeState);
resizeObserver?.disconnect();
};
}, []);
return (
<div className="relative">
<div ref={viewportRef} className={className}>
<div ref={contentRef}>{children}</div>
</div>
{showTopFade && (
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-gradient-to-b from-muted to-transparent" />
)}
{showBottomFade && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-6 bg-gradient-to-t from-muted to-transparent" />
)}
</div>
);
}
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
label?: string;
showLabel?: boolean;
summary?: ReactNode | ((options: { isOpen: boolean }) => ReactNode);
embedded?: boolean;
};
export const ToolInput = ({
className,
input,
label = "Parameters",
showLabel = true,
summary,
embedded = false,
...props
}: ToolInputProps) => {
const [isJsonOpen, setIsJsonOpen] = useState(false);
const hasStructuredInput =
input !== undefined &&
input !== null &&
(typeof input !== "object" ||
Array.isArray(input) ||
Object.keys(input as Record<string, unknown>).length > 0);
if (!summary && !hasStructuredInput) {
return null;
}
const summaryContent =
typeof summary === "function"
? summary({ isOpen: isJsonOpen })
: (summary ?? (
<span className="text-xs text-muted-foreground">{label}</span>
));
const inputBody = hasStructuredInput ? (
<Collapsible open={isJsonOpen} onOpenChange={setIsJsonOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-start gap-3 text-left"
>
<div className="min-w-0 flex-1">{summaryContent}</div>
<ChevronDownIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground transition-transform [button[data-state=closed]_&]:-rotate-90" />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1 mt-2 overflow-hidden outline-none data-[state=closed]:animate-out data-[state=open]:animate-in">
<pre className="overflow-auto whitespace-pre-wrap break-words font-mono text-[12px] leading-5 text-foreground/90">
{JSON.stringify(input, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
) : (
summaryContent
);
if (embedded) {
return (
<div className={cn("overflow-hidden px-3 py-2", className)} {...props}>
{inputBody}
</div>
);
}
if (!showLabel) {
return (
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
<ToolSurface tone="muted" className="px-3 py-2">
{inputBody}
</ToolSurface>
</div>
);
}
return (
<ToolSection
label={label}
className={cn("overflow-hidden", className)}
{...props}
>
<ToolSurface tone="muted" className="px-3 py-2">
{inputBody}
</ToolSurface>
</ToolSection>
);
};
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"];
errorText: ToolPart["errorText"];
label?: string;
contentClassName?: string;
plainText?: boolean;
showLabel?: boolean;
embedded?: boolean;
/** Max height (Tailwind class, e.g. "max-h-32") for the embedded scroll viewport. */
embeddedMaxHeightClass?: string;
};
export const ToolOutput = ({
@@ -145,72 +454,119 @@ export const ToolOutput = ({
errorText,
label,
contentClassName,
plainText = false,
showLabel = true,
embedded = false,
embeddedMaxHeightClass = "max-h-32",
...props
}: ToolOutputProps) => {
if (output === undefined && errorText === undefined) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
let isCodeBlockOutput = false;
const isReactOutput = isValidElement(output);
const renderedOutput = (() => {
if (typeof output === "object" && !isValidElement(output)) {
return (
<CodeBlock
code={JSON.stringify(output, null, 2)}
language="json"
className={
embedded
? "rounded-none border-0 bg-transparent shadow-none [&_pre]:m-0 [&_pre]:bg-transparent [&_pre]:p-0"
: undefined
}
/>
);
}
if (typeof output === "string") {
return (
<CodeBlock
code={output}
language="json"
className={
embedded
? "rounded-none border-0 bg-transparent shadow-none [&_pre]:m-0 [&_pre]:bg-transparent [&_pre]:p-0"
: undefined
}
/>
);
}
return <div>{output as ReactNode}</div>;
})();
if (output !== null && typeof output === "object" && !isReactOutput) {
isCodeBlockOutput = true;
Output = (
<CodeBlock
code={JSON.stringify(output, null, 2)}
className="border-0 bg-transparent shadow-none"
language="json"
transparentBackground
/>
if (embedded) {
const plainTextClasses =
"m-0 whitespace-pre-wrap break-words font-mono text-[12px] leading-5";
const plainOutput = errorText
? errorText
: typeof output === "string"
? output
: typeof output === "object" && !isValidElement(output)
? JSON.stringify(output, null, 2)
: null;
return (
<div
data-role="tool-output-embedded"
className={cn("overflow-hidden px-3 pb-2", className)}
{...props}
>
{plainOutput != null ? (
<EmbeddedOverflowViewport
className={cn("overflow-auto", embeddedMaxHeightClass)}
>
<pre
className={cn(
plainTextClasses,
errorText ? "text-destructive" : "text-muted-foreground",
)}
>
{plainOutput}
</pre>
</EmbeddedOverflowViewport>
) : (
<EmbeddedOverflowViewport
className={cn(
"overflow-auto text-muted-foreground",
embeddedMaxHeightClass,
)}
>
{renderedOutput}
</EmbeddedOverflowViewport>
)}
</div>
);
} else if (typeof output === "string" && plainText) {
Output = (
<pre className="m-0 whitespace-pre-wrap break-words p-4 font-mono text-foreground text-sm">
{output}
</pre>
);
} else if (output !== undefined && !plainText && !isReactOutput) {
isCodeBlockOutput = true;
Output = (
<CodeBlock
code={JSON.stringify(output) ?? String(output)}
className="border-0 bg-transparent shadow-none"
language="json"
transparentBackground
/>
);
} else if (output !== undefined && output !== null && !isReactOutput) {
Output = (
<pre className="m-0 whitespace-pre-wrap break-words p-4 font-mono text-foreground text-sm">
{String(output)}
</pre>
}
if (!showLabel) {
return (
<div className={cn("space-y-2", className)} {...props}>
{errorText ? (
<ToolSurface destructive className="px-3 py-2">
<div>{errorText}</div>
</ToolSurface>
) : (
<ToolSurface tone="muted" className="px-3 py-2">
{renderedOutput}
</ToolSurface>
)}
</div>
);
}
return (
<div
className={cn("w-full min-w-0 max-w-full space-y-2", className)}
<ToolSection
label={label ?? (errorText ? "Error" : "Result")}
className={className}
{...props}
>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{label ?? (errorText ? "Error" : "Result")}
</h4>
<div
className={cn(
"w-full rounded-md text-xs [&_table]:w-full [&_pre]:whitespace-pre-wrap [&_pre]:break-words",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground",
!isCodeBlockOutput && "overflow-x-auto",
contentClassName,
)}
<ToolSurface
destructive={Boolean(errorText)}
tone="muted"
className={cn("overflow-x-auto [&_table]:w-full", contentClassName)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
{renderedOutput}
</ToolSurface>
</ToolSection>
);
};