import { AlertCircle, Check, ChevronDown, ChevronRight, Zap, } from "lucide-react"; import { useEffect, useState } from "react"; /** * Expandable tool call row — the web equivalent of Ink's ToolTrail node. * * Renders one `tool.start` + `tool.complete` pair (plus any `tool.progress` * in between) as a single collapsible item in the transcript: * * ▸ ● read_file(path=/foo) 2.3s * * Click the header to reveal a preformatted body with context (args), the * streaming preview (while running), and the final summary or error. Error * rows auto-expand so failures aren't silently collapsed. */ export interface ToolEntry { kind: "tool"; id: string; tool_id: string; name: string; context?: string; preview?: string; summary?: string; error?: string; inline_diff?: string; status: "running" | "done" | "error"; startedAt: number; completedAt?: number; } const STATUS_TONE: Record = { running: "border-primary/40 bg-primary/[0.04]", done: "border-border bg-muted/20", error: "border-destructive/50 bg-destructive/[0.04]", }; const BULLET_TONE: Record = { running: "text-primary", done: "text-primary/80", error: "text-destructive", }; const TICK_MS = 500; export function ToolCall({ tool }: { tool: ToolEntry }) { // `open` is derived: errors default-expanded, everything else collapsed. // `null` means "follow the default"; any explicit bool is the user's override. // This lets a running tool flip to expanded automatically when it errors, // without mirroring state in an effect. const [userOverride, setUserOverride] = useState(null); const open = userOverride ?? tool.status === "error"; // Tick `now` while the tool is running so the elapsed label updates live. const [now, setNow] = useState(() => Date.now()); useEffect(() => { if (tool.status !== "running") return; const id = window.setInterval(() => setNow(() => Date.now()), TICK_MS); return () => window.clearInterval(id); }, [tool.status]); // Historical tools (hydrated from session.resume) signal missing timestamps // with `startedAt === 0`; we hide the elapsed badge for those rather than // rendering a misleading "0ms". const hasTimestamps = tool.startedAt > 0; const elapsed = hasTimestamps ? fmtElapsed((tool.completedAt ?? now) - tool.startedAt) : null; const hasBody = !!( tool.context || tool.preview || tool.summary || tool.error || tool.inline_diff ); const Chevron = open ? ChevronDown : ChevronRight; return (
{open && hasBody && (
{tool.context &&
{tool.context}
} {tool.preview && tool.status === "running" && (
{tool.preview}
)} {tool.inline_diff && (
                {colorizeDiff(tool.inline_diff)}
              
)} {tool.summary && (
{tool.summary}
)} {tool.error && (
{tool.error}
)}
)}
); } function Section({ label, children, tone, }: { label: string; children: React.ReactNode; tone?: "error"; }) { return (
{label}
{children}
); } function fmtElapsed(ms: number): string { const sec = Math.max(0, ms) / 1000; if (sec < 1) return `${Math.round(ms)}ms`; if (sec < 10) return `${sec.toFixed(1)}s`; if (sec < 60) return `${Math.round(sec)}s`; const m = Math.floor(sec / 60); const s = Math.round(sec % 60); return s ? `${m}m ${s}s` : `${m}m`; } /** Colorize unified-diff lines for the inline diff section. */ function colorizeDiff(diff: string): React.ReactNode { return diff.split("\n").map((line, i) => (
{line || "\u00A0"}
)); } function diffLineClass(line: string): string { if (line.startsWith("+") && !line.startsWith("+++")) return "text-emerald-500 dark:text-emerald-400"; if (line.startsWith("-") && !line.startsWith("---")) return "text-destructive"; if (line.startsWith("@@")) return "text-primary"; return "text-muted-foreground/80"; }