Files
fusion/packages/dashboard/app/components/ChatToolCalls.tsx
Fusion 67226f47c2 feat(FN-2902): merge fusion/fn-2902
- feat(FN-2902): complete Step 6 — address review feedback
- test(FN-2902): stabilize bundle-output assertions for pi-claude-cli
- test(FN-2902): complete Step 4 — cover grouped tool-call rendering
- feat(FN-2902): complete Step 3 — add grouped tool-call styles
- feat(FN-2902): complete Step 2 — group multiple tool calls in QuickChatFAB
- feat(FN-2902): complete Step 1 — group multiple tool calls in ChatView
- chore(release): v0.8.3
- Update changeset
- fix(tui): swap 3/4 panel hotkeys so they match the help text
- fix(tui): always show auth token and report accurate macOS memory usage
- chore(release): v0.8.2
- feat(FN-2895): merge fusion/fn-2895
- fix(cli): validate agentId in fn_task_create/update and unblock bundle tests

Fusion-Task-Id: FN-2902
2026-04-28 22:14:07 -07:00

161 lines
5.2 KiB
TypeScript

import type { ReactNode } from "react";
import { Wrench } from "lucide-react";
export interface ChatToolCallInfo {
toolName: string;
args?: Record<string, unknown>;
result?: unknown;
status: "running" | "completed";
isError?: boolean;
}
interface ChatToolCallsProps {
toolCalls?: ChatToolCallInfo[];
compact?: boolean;
}
function truncateValue(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength)}`;
}
function formatToolArgsSummary(args?: Record<string, unknown>): string | null {
if (!args) {
return null;
}
const entries = Object.entries(args);
if (entries.length === 0) return null;
return entries
.map(([key, value]) => {
let stringValue = "";
if (typeof value === "string") {
stringValue = value;
} else {
try {
stringValue = JSON.stringify(value);
} catch {
stringValue = String(value);
}
}
return `${key}=${truncateValue(stringValue, 50)}`;
})
.join(", ");
}
function formatToolResultSummary(result: unknown): string | null {
if (result === undefined) {
return null;
}
if (typeof result === "string") {
return truncateValue(result, 200);
}
try {
return truncateValue(JSON.stringify(result), 200);
} catch {
return truncateValue(String(result), 200);
}
}
function renderToolCallItem(toolCall: ChatToolCallInfo, index: number): ReactNode {
const isRunning = toolCall.status === "running";
const isError = toolCall.status === "completed" && toolCall.isError;
const argsSummary = formatToolArgsSummary(toolCall.args);
const resultSummary = formatToolResultSummary(toolCall.result);
const summaryPreview = isRunning
? argsSummary
: resultSummary
? `result: ${resultSummary}`
: argsSummary
? `args: ${argsSummary}`
: null;
const statusLabel = isRunning ? "running" : isError ? "error" : "completed";
return (
<details
key={`${toolCall.toolName}-${index}`}
className={`chat-tool-call${isRunning ? " chat-tool-call--running" : ""}${isError ? " chat-tool-call--error" : ""}`}
open={isRunning}
>
<summary>
<span className="chat-tool-call-status-dot" aria-hidden="true" />
<span className="chat-tool-call-name">{toolCall.toolName}</span>
{summaryPreview && (
<span className="chat-tool-call-preview" title={summaryPreview}>
{summaryPreview}
</span>
)}
<span className="chat-tool-call-status-text">{statusLabel}</span>
</summary>
<div className="chat-tool-call-content">
{argsSummary && (
<div className="chat-tool-call-row">
<span className="chat-tool-call-label">args</span>
<span className="chat-tool-call-value">{argsSummary}</span>
</div>
)}
{resultSummary && (
<div className={`chat-tool-call-row${isError ? " chat-tool-call-row--error" : ""}`}>
<span className="chat-tool-call-label">result</span>
<span className="chat-tool-call-value">{resultSummary}</span>
</div>
)}
</div>
</details>
);
}
export function ChatToolCalls({ toolCalls, compact = false }: ChatToolCallsProps): ReactNode {
if (!toolCalls || toolCalls.length === 0) {
return null;
}
const className = `chat-tool-calls${compact ? " chat-tool-calls--compact" : ""}`;
if (toolCalls.length === 1) {
return (
<div className={className} data-testid="chat-tool-calls">
<div className="chat-tool-calls-header">
<Wrench size={12} aria-hidden="true" />
<span>Tool calls</span>
</div>
{renderToolCallItem(toolCalls[0], 0)}
</div>
);
}
const completedCount = toolCalls.filter((toolCall) => toolCall.status === "completed" && !toolCall.isError).length;
const runningCount = toolCalls.filter((toolCall) => toolCall.status === "running").length;
const errorCount = toolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length;
const hasRunning = runningCount > 0;
return (
<div className={className} data-testid="chat-tool-calls">
<details className="chat-tool-calls-group" open={hasRunning}>
<summary className="chat-tool-calls-group-summary">
<div className="chat-tool-calls-header">
<Wrench size={12} aria-hidden="true" />
<span>{toolCalls.length} tool calls</span>
</div>
<span className="chat-tool-calls-group-status">
{completedCount > 0 && <span className="chat-tool-calls-group-count">{completedCount} completed</span>}
{runningCount > 0 && (
<span className="chat-tool-calls-group-count chat-tool-calls-group-count--running">{runningCount} running</span>
)}
{errorCount > 0 && (
<span className="chat-tool-calls-group-count chat-tool-calls-group-count--error">{errorCount} error</span>
)}
</span>
</summary>
<div className="chat-tool-calls-group-items">{toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))}</div>
</details>
</div>
);
}