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:
@@ -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