// Owns: tool-payload formatting helpers, ToolCallDisplay, ToolCallFallback, // and ReconnectStreamMessage used by AssistantChat. import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { IconLoader2, IconCircleX, IconCheck, IconChevronRight, IconCopy, IconCode, IconBrandSlack, IconTerminal2, IconDatabase, IconSearch, IconFileCode, IconShieldCheck, IconX, } from "@tabler/icons-react"; import React, { useState, useEffect, useCallback, useLayoutEffect, useRef, } from "react"; import type { A2AAgentActivitySnapshot, A2AAgentActivityToolCall, } from "../../a2a/activity.js"; import type { ActionChatUIConfig } from "../../action-ui.js"; import type { AgentMcpAppPayload } from "../../mcp-client/app-result.js"; import { AgentTaskCard } from "../AgentTaskCard.js"; import { writeClipboardText } from "../clipboard.js"; import { Popover, PopoverContent, PopoverTrigger, } from "../components/ui/popover.js"; import { ConnectBuilderCard } from "../ConnectBuilderCard.js"; import { useT } from "../i18n.js"; import { McpAppRenderer } from "../mcp-apps/McpAppRenderer.js"; import type { AgentCallProgress, ContentPart } from "../sse-event-processor.js"; import { BashCell, EditCell, WriteCell, FilesChangedSummary, } from "../tool-cells/index.js"; import { humanizeToolName, isCallAgentToolCallShadowed, } from "../tool-display.js"; import { cn } from "../utils.js"; import { ActionChatUiSurface } from "./action-chat-ui-surface.js"; import { SmoothMarkdownText, HighlightedCodeBlock, useSmoothStreamingText, } from "./markdown-renderer.js"; import { resolveToolRenderer } from "./tool-render-registry.js"; import { isBuiltinDataWidgetActionRenderer, resolveBuiltinActionChatRenderer, resolveBuiltinFallbackToolRenderer, } from "./widgets/builtin-tool-renderers.js"; // Exported so AssistantChatInner can provide a context value. export const ChatRunningContext = React.createContext(false); export const ChatRunDurationContext = React.createContext(null); export const ASSISTANT_VISIBLE_TOOL_CALL_LIMIT = 3; /** * Human-in-the-loop approval bridge. `AssistantChatInner` provides a value that * re-issues the turn approving a specific paused tool call (opt-in * `needsApproval` actions). When null, the Approve button is not rendered. * Deny defaults to local-only (the action stays un-run) unless `onDeny` is * provided, and "Always allow" only renders when `onAlwaysAllow` is provided * — both are additive so existing action-approval consumers are unaffected. */ export type ApprovalContextValue = { /** Re-issue the turn so the server runs the approved call. */ onApprove: (approvalKey: string) => void; /** * Optional host hook invoked in addition to the local "denied" state, e.g. * so a Code session can also resolve its own pending approval as denied. */ onDeny?: (approvalKey: string) => void; /** * Optional host hook that persists this exact call so future occurrences * skip the approval gate. When absent, no "Always allow" button renders. */ onAlwaysAllow?: (approvalKey: string) => void; }; export const ApprovalContext = React.createContext( null, ); export const TOOL_LONG_RUNNING_HINT_DELAY_MS = 45_000; export function ToolActivityPresentation({ toolName, isRunning, isActiveTail, suppressLongRunningHint = false, children, }: { toolName: string; isRunning: boolean; isActiveTail: boolean; suppressLongRunningHint?: boolean; children: React.ReactNode; }) { const [showLongRunningHint, setShowLongRunningHint] = useState(false); // A batched update can first reveal a tool with its result already attached. // Presentation follows the active chat tail rather than execution state so // that newly revealed completed tools still get their entrance motion. const [animateEntry] = useState(isActiveTail); useEffect(() => { if (!isRunning || suppressLongRunningHint) { setShowLongRunningHint(false); return; } setShowLongRunningHint(false); const timeout = window.setTimeout(() => { setShowLongRunningHint(true); }, TOOL_LONG_RUNNING_HINT_DELAY_MS); return () => window.clearTimeout(timeout); }, [isRunning, suppressLongRunningHint, toolName]); return (
{children} {isRunning && showLongRunningHint && (
Still working. Large updates can take a minute or two.
)}
); } // ─── Tool-payload formatting ────────────────────────────────────────────────── type ToolDetailSection = "input" | "result"; export type ToolDetailPayload = { section: ToolDetailSection; title: string; text: string; copyText: string; lang: string; }; function stringifyToolValue(value: unknown, pretty = false): string { if (typeof value === "string") return value; try { return JSON.stringify(value, null, pretty ? 2 : 0); } catch { return String(value ?? ""); } } function looksLikeSql(text: string): boolean { return /^\s*(select|with|insert|update|delete|merge|create|alter|drop|explain|declare|begin)\b/i.test( text, ); } function parseJsonText(text: string): unknown | null { const trimmed = text.trim(); if (!trimmed || !/^[{[]/.test(trimmed)) return null; try { return JSON.parse(trimmed); } catch { return null; } } function inferToolTextLanguage( text: string, key?: string, toolName?: string, ): string { const keyName = (key ?? "").toLowerCase(); const tool = (toolName ?? "").toLowerCase(); if ( keyName === "code" && (tool.includes("run-code") || tool.includes("run_code")) ) { return "javascript"; } if ( keyName === "sql" || keyName.endsWith("sql") || keyName === "query" || tool.includes("bigquery") || tool.includes("db-query") || looksLikeSql(text) ) { return "sql"; } return parseJsonText(text) ? "json" : "text"; } function formatToolTextValue( value: unknown, key?: string, toolName?: string, ): { text: string; lang: string } { if (typeof value === "string") { const parsed = parseJsonText(value); if (parsed) { return { text: JSON.stringify(parsed, null, 2), lang: "json" }; } return { text: value, lang: inferToolTextLanguage(value, key, toolName), }; } return { text: stringifyToolValue(value, true), lang: "json" }; } export function toolInputPayload( toolName: string, args: Record, ): ToolDetailPayload | null { const entries = Object.entries(args); if (entries.length === 0) return null; if (entries.length === 1) { const [key, value] = entries[0]!; const formatted = formatToolTextValue(value, key, toolName); const normalizedKey = key.toLowerCase(); const keyLabel = normalizedKey === "sql" || normalizedKey.endsWith("sql") ? "SQL" : key; return { section: "input", title: `Input - ${keyLabel}`, text: formatted.text, copyText: typeof value === "string" ? value : stringifyToolValue(value, true), lang: formatted.lang, }; } return { section: "input", title: "Input", text: JSON.stringify(args, null, 2), copyText: JSON.stringify(args, null, 2), lang: "json", }; } export function toolResultPayload( result: string | undefined, ): ToolDetailPayload | null { if (result === undefined) return null; const formatted = formatToolTextValue(result); return { section: "result", title: "Result", text: formatted.text, copyText: result, lang: formatted.lang, }; } // ─── Tool icon helpers ──────────────────────────────────────────────────────── type ToolIconComponent = React.ComponentType<{ className?: string; size?: number | string; }>; function resolveToolIcon(toolName: string): ToolIconComponent { const name = toolName.toLowerCase(); if (name.includes("slack")) return IconBrandSlack; if ( name.includes("bash") || name.includes("shell") || name.includes("terminal") || name.includes("run-code") || name.includes("exec") ) { return IconTerminal2; } if ( name.includes("sql") || name.includes("bigquery") || name.includes("db-query") || name.includes("query") ) { return IconDatabase; } if ( name.includes("search") || name.includes("find") || name.includes("grep") ) { return IconSearch; } if ( name.includes("file") || name.includes("read") || name.includes("write") || name.includes("edit") ) { return IconFileCode; } return IconCode; } // ─── Simple code viewer (Codex-style gray box) ──────────────────────────────── function SimpleCodeViewer({ text, lang, className, maxHeightClass = "max-h-56", }: { text: string; lang: string; className?: string; maxHeightClass?: string; }) { return (
{lang !== "text" && (
{lang}
)}
); } function ToolOutputPopover({ open, onOpenChange, title, payload, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: string; payload: ToolDetailPayload; children: React.ReactNode; }) { const [copied, setCopied] = useState(false); const copyResetRef = useRef | null>(null); useEffect(() => { return () => { if (copyResetRef.current) clearTimeout(copyResetRef.current); }; }, []); const copyValue = useCallback(async () => { try { if (await writeClipboardText(payload.copyText)) { setCopied(true); if (copyResetRef.current) clearTimeout(copyResetRef.current); copyResetRef.current = setTimeout(() => setCopied(false), 1200); } } catch { // Clipboard failures should not interrupt chat rendering. } }, [payload.copyText]); return ( {children}
{title}
); } // ─── Collapsible height animation ───────────────────────────────────────────── export function AnimatedCollapse({ open, children, }: { open: boolean; children: React.ReactNode; }) { const [mounted, setMounted] = useState(open); useLayoutEffect(() => { if (open) setMounted(true); }, [open]); const onTransitionEnd = useCallback( (event: React.TransitionEvent) => { if (event.propertyName !== "grid-template-rows" || open) return; setMounted(false); }, [open], ); if (!mounted) return null; return (
{children}
); } // ─── Human-in-the-loop approval affordance ──────────────────────────────────── /** * Inline Approve/Deny prompt rendered when a `needsApproval` action paused the * turn. Approve re-issues the turn with the call's `approvalKey`; Deny dismisses * the prompt locally (the action stays un-run). */ function ApprovalAffordance({ toolName, approval, }: { toolName: string; approval: { approvalKey: string; dismissed?: boolean }; }) { const ctx = React.useContext(ApprovalContext); const [approved, setApproved] = useState(false); const [denied, setDenied] = useState(false); // Once approved, the turn is re-issued; collapse to a quiet note so the user // can't double-fire the approval. if (approved) { return (
Approved. Re-running {toolName}...
); } // Deny defaults to local-only (the action simply stays un-run). When the // host also provided `onDeny` (e.g. a Code session resolving its own // pending approval), it fires alongside the local state. if (denied) { return (
Denied. {toolName} did not run.
); } return (
Approve to run {toolName}? {ctx && ( )} {ctx?.onAlwaysAllow && ( )}
); } // ─── ToolCallDisplay ────────────────────────────────────────────────────────── export function ToolCallDisplay({ toolName, argsText, args, result, mcpApp, chatUI, isRunning, structuredMeta, approval, repeatCount, isLatestRunning = isRunning, isActiveTail, }: { toolName: string; argsText?: string; args: Record; result?: string; mcpApp?: AgentMcpAppPayload; chatUI?: ActionChatUIConfig; isRunning: boolean; structuredMeta?: Record; approval?: { approvalKey: string; dismissed?: boolean }; repeatCount?: number; /** The latest tool shown while the overall chat turn is still active. */ isActiveTail?: boolean; /** @deprecated Use isActiveTail. */ isLatestRunning?: boolean; }) { const showActiveTail = isActiveTail ?? isLatestRunning; // Delegate to bespoke cells when structured metadata is present. // These must be separate components so hook order in ToolCallDisplayGeneric // is always stable (no conditional hook calls). const toolKind = structuredMeta?.toolKind as string | undefined; const wrapToolDisplay = (children: React.ReactNode) => ( {children} ); if (toolKind === "bash") { return wrapToolDisplay( [0]["meta"] } output={result} isRunning={isRunning} />, ); } if (toolKind === "edit") { return wrapToolDisplay( [0]["meta"] } isRunning={isRunning} />, ); } if (toolKind === "write") { return wrapToolDisplay( [0]["meta"] } isRunning={isRunning} />, ); } return wrapToolDisplay( , ); } function ToolCallDisplayGeneric({ toolName, argsText, args, result, mcpApp, chatUI, isRunning, isActiveTail, structuredMeta, approval, repeatCount, }: { toolName: string; argsText?: string; args: Record; result?: string; mcpApp?: AgentMcpAppPayload; chatUI?: ActionChatUIConfig; isRunning: boolean; isActiveTail: boolean; structuredMeta?: Record; approval?: { approvalKey: string; dismissed?: boolean }; repeatCount?: number; }) { const isRawCallAgent = toolName === "call-agent"; const isAgentCall = toolName.startsWith("agent:") || isRawCallAgent; const [expanded, setExpanded] = useState(isAgentCall); const [outputOpen, setOutputOpen] = useState(false); const agentName = toolName.startsWith("agent:") ? toolName.slice(6) : typeof args.agent === "string" ? args.agent : null; const isAgentError = isAgentCall && result === "Error calling agent"; const agentStreamText = isRawCallAgent ? (result ?? "") : isAgentCall ? (argsText ?? "") : ""; const agentActivity = structuredMeta?.agentActivity as | A2AAgentActivitySnapshot | undefined; const agentProgress = structuredMeta?.agentProgress as | AgentCallProgress | undefined; const hasStreamText = agentStreamText.length > 0; const hasArgs = !isAgentCall && Object.keys(args).length > 0; // Render connect-builder as ConnectBuilderCard once the result is available if (toolName === "connect-builder" && result) { try { const parsed = JSON.parse(result); if (parsed?.kind === "connect-builder-card") { return ( ); } } catch { // fall through to default pill rendering } } // Render agent-teams spawn as AgentTaskCard once the result is available if ( toolName === "agent-teams" && (args as Record)?.action === "spawn" && result ) { try { const parsed = JSON.parse(result); if (parsed.taskId && parsed.threadId) { return ( )?.task || "Sub-agent task" } onOpen={(tid) => { window.dispatchEvent( new CustomEvent("agent-task-open", { detail: { threadId: tid, description: parsed.description || (args as Record)?.task || "", name: parsed.name || "", }, }), ); }} /> ); } } catch { // Fall through to default pill rendering } } const parsedResult = result ? parseJsonText(result) : null; const nativeToolContext = { toolName, args, resultText: result, resultJson: parsedResult, isRunning, isActiveTail, chatUI, }; const skipRegistryRenderer = !isAgentCall && isBuiltinDataWidgetActionRenderer(nativeToolContext); const NativeToolRenderer = isAgentCall ? null : (resolveBuiltinActionChatRenderer(nativeToolContext) ?? (skipRegistryRenderer ? null : resolveToolRenderer(nativeToolContext)) ?? resolveBuiltinFallbackToolRenderer(nativeToolContext)); if (NativeToolRenderer) { return ( ); } const inputPayload = hasArgs ? toolInputPayload(toolName, args) : null; const resultPayload = toolResultPayload(result); const displayName = isAgentCall ? isRunning ? `Asking ${agentName}...` : isAgentError ? `Error asking ${agentName}` : `Asked ${agentName}` : humanizeToolName(toolName); const canExpand = isAgentCall ? hasStreamText : hasArgs || result !== undefined; const isExpanded = isAgentCall ? hasStreamText && expanded : expanded; const ToolIcon = resolveToolIcon(toolName); const outputTitle = `Raw ${toolName} tool call output`; if (isAgentCall) { return ( ); } return (
{mcpApp && }
{inputPayload && ( )} {resultPayload && ( )}
{approval && ( )}
); } function AgentCallCell({ agentName, activity, progress, responseText, isRunning, isError, durationMs, }: { agentName: string; activity?: A2AAgentActivitySnapshot; progress?: AgentCallProgress; responseText: string; isRunning: boolean; isError: boolean; durationMs?: number; }) { const t = useT(); const [open, setOpen] = useState(true); const toolCount = activity?.toolCalls?.length ?? 0; // Response segments are ordered against the tool calls that preceded them, so // they render in the timeline where the remote agent actually said them. // Once the authoritative result text arrives, its segment moves to the // bottom block instead of being rendered twice. const segments = activity?.response ?? []; const inlineSegments = responseText && !isRunning ? segments.slice(0, toolCount) : segments; const finalText = responseText || (inlineSegments.length ? "" : activity?.responseText); const work = activity?.reasoning?.length || toolCount || inlineSegments.length; const workItemCount = Math.max( activity?.reasoning?.length ?? 0, toolCount, inlineSegments.length, ); const label = isRunning ? t("agentPanel.delegatedAgent.asking", { name: agentName }) : isError ? t("agentPanel.delegatedAgent.error", { name: agentName }) : t("agentPanel.delegatedAgent.asked", { name: agentName }); const workContent = work ? (
{Array.from({ length: workItemCount }, (_, index) => { const reasoningText = activity?.reasoning?.[index]; const segment = inlineSegments[index]; const tool = activity?.toolCalls?.[index]; return ( {reasoningText && ( )} {segment && (
)} {tool && ( )}
); })}
) : null; const progressState = progress?.state.replaceAll(/[-_]+/g, " "); const progressText = isRunning && !activity && progress && progressState ? [ progressState.charAt(0).toUpperCase() + progressState.slice(1), t("agentPanel.delegatedAgent.elapsed", { duration: formatWorkedDuration(progress.elapsedSeconds * 1000), }), progress.detail, ] .filter(Boolean) .join(" · ") : null; return (
{workContent && (isRunning ? ( workContent ) : ( {workContent} ))} {progressText && (

{progressText}

)} {finalText && (
)}
); } function AgentActivityToolCallRow({ tool, isActiveTail, }: { tool: A2AAgentActivityToolCall; isActiveTail: boolean; }) { const isRunning = tool.status === "running"; const ToolIcon = resolveToolIcon(tool.name); return (
{isRunning ? ( ) : ( )} {humanizeToolName(tool.name)}
); } // ─── ToolCallFallback ────────────────────────────────────────────────────────── export function ToolCallFallback({ toolName, args, argsText, result, ...rest }: ToolCallMessagePartProps & { mcpApp?: AgentMcpAppPayload; chatUI?: ActionChatUIConfig; structuredMeta?: Record; activity?: boolean; approval?: { approvalKey: string; dismissed?: boolean }; repeatCount?: number; isLatestRunning?: boolean; isActiveTail?: boolean; }) { const chatRunning = React.useContext(ChatRunningContext); const isRunning = result === undefined && (chatRunning || rest.activity === true); return ( } argsText={argsText} result={ typeof result === "string" ? result : result !== undefined ? JSON.stringify(result) : undefined } mcpApp={rest.mcpApp} chatUI={rest.chatUI} structuredMeta={rest.structuredMeta} isRunning={isRunning} isActiveTail={rest.isActiveTail} isLatestRunning={rest.isLatestRunning} approval={rest.approval} repeatCount={rest.repeatCount} /> ); } // ─── ReconnectStreamMessage ──────────────────────────────────────────────────── // Renders the agent's in-progress response during reconnection (outside // assistant-ui's runtime). Uses the same visual styling as normal messages. export function ReconnectStreamMessage({ content, }: { content: ContentPart[]; }) { const chatRunning = React.useContext(ChatRunningContext); const toolSummary = getReconnectToolSummaryInfo(content); const latestReasoningPartIndex = content.reduce( (latestIndex, part, index) => part.type === "reasoning" ? index : latestIndex, -1, ); const streamingTextPartIndex = content.at(-1)?.type === "text" ? content.length - 1 : -1; const streamingReasoningPartIndex = content.at(-1)?.type === "reasoning" ? content.length - 1 : -1; const latestActiveToolIndex = content.reduce( (latestIndex, part, index) => part.type === "tool-call" && !isCallAgentToolCallShadowed(content, index) && (chatRunning || part.activity === true) ? index : latestIndex, -1, ); const renderPart = (part: ContentPart, i: number) => { if (isCallAgentToolCallShadowed(content, i)) return null; if (part.type === "text") { const partStreaming = chatRunning && i === streamingTextPartIndex; return ( ); } if (part.type === "reasoning") { return ( ); } return ( ); }; const renderedParts: React.ReactNode[] = []; let summaryStartIndex = -1; let summaryToolCount = 0; const flushSummary = (endIndex: number) => { if (summaryStartIndex < 0) return; renderedParts.push( {content .slice(summaryStartIndex, endIndex) .map((part, offset) => renderPart(part, summaryStartIndex + offset))} , ); summaryStartIndex = -1; summaryToolCount = 0; }; for (let i = 0; i < content.length; i++) { const part = content[i]!; if (isCallAgentToolCallShadowed(content, i)) continue; const isOlderToolWork = toolSummary.startIndex >= 0 && i < toolSummary.startIndex && isReconnectToolSummaryPart(content, i, toolSummary.startIndex); if (isOlderToolWork) { summaryStartIndex = summaryStartIndex < 0 ? i : summaryStartIndex; if (part.type === "tool-call") summaryToolCount++; continue; } flushSummary(i); renderedParts.push(renderPart(part, i)); } flushSummary(content.length); return (
{renderedParts}
); } function getReconnectToolSummaryInfo(content: readonly ContentPart[]) { const toolCallIndices = content.reduce((indices, part, index) => { if ( part.type === "tool-call" && !isCallAgentToolCallShadowed(content, index) && isReconnectSummarizablePart(part) ) { indices.push(index); } return indices; }, []); if (toolCallIndices.length <= ASSISTANT_VISIBLE_TOOL_CALL_LIMIT) { return { startIndex: -1 }; } return { startIndex: toolCallIndices[ toolCallIndices.length - ASSISTANT_VISIBLE_TOOL_CALL_LIMIT ]!, }; } function isReconnectSummarizablePart(part: ContentPart): boolean { return ( part.type === "reasoning" || (part.type === "tool-call" && part.toolName !== "connect-builder" && part.chatUI === undefined && part.mcpApp === undefined) ); } function isReconnectToolSummaryPart( content: readonly ContentPart[], index: number, startIndex: number, ): boolean { if (startIndex < 0 || index >= startIndex) return false; if ( isCallAgentToolCallShadowed(content, index) || !isReconnectSummarizablePart(content[index]!) ) { return false; } let segmentStart = index; while ( segmentStart > 0 && !isCallAgentToolCallShadowed(content, segmentStart - 1) && isReconnectSummarizablePart(content[segmentStart - 1]!) ) { segmentStart--; } let segmentEnd = index + 1; while ( segmentEnd < startIndex && !isCallAgentToolCallShadowed(content, segmentEnd) && isReconnectSummarizablePart(content[segmentEnd]!) ) { segmentEnd++; } return content .slice(segmentStart, segmentEnd) .some((candidate) => candidate.type === "tool-call"); } // ─── Reasoning / Thinking cell ──────────────────────────────────────────────── /** * Completed reasoning and tool calls share one outer "Worked for…" * disclosure. Reasoning cells inside it render their prose directly so * opening that summary never reveals a redundant second disclosure. */ const WorkSummaryContentContext = React.createContext(false); export function ReasoningCell({ text, isStreaming = false, resetKey, defaultOpen, autoCollapse = false, collapseWhenReplaced = false, durationMs, }: { text: string; isStreaming?: boolean; /** Stable identity used to restart the reveal when a new reasoning part mounts. */ resetKey?: string; defaultOpen?: boolean; /** Animate closed when a live reasoning segment finishes during a run. */ autoCollapse?: boolean; /** Animate closed when a newer reasoning segment replaces this one. */ collapseWhenReplaced?: boolean; /** * Elapsed thinking time in ms, once known. Only meaningful once streaming * has finished — callers that track live timing (see ReasoningMessagePart) * pass this so the label can read "Thought for Xs" instead of "Thought". * Historical messages with no live timing simply omit it. */ durationMs?: number | null; }) { const embeddedInWorkSummary = React.useContext(WorkSummaryContentContext); const [open, setOpen] = useState(defaultOpen ?? true); const wasStreamingRef = useRef(isStreaming); const wasReplacedRef = useRef(collapseWhenReplaced); const trimmed = text.trim(); const visibleText = useSmoothStreamingText( trimmed, isStreaming, resetKey ?? "reasoning", ); useEffect(() => { if (autoCollapse && wasStreamingRef.current && !isStreaming) { setOpen(false); } wasStreamingRef.current = isStreaming; }, [autoCollapse, isStreaming]); useEffect(() => { if (collapseWhenReplaced && !wasReplacedRef.current) { setOpen(false); } wasReplacedRef.current = collapseWhenReplaced; }, [collapseWhenReplaced]); if (!trimmed && !isStreaming) return null; if (embeddedInWorkSummary) { return (
{visibleText || (isStreaming ? "…" : "")}
); } const label = isStreaming ? "Thinking" : durationMs != null ? `Thought for ${formatWorkedDuration(durationMs)}` : "Thought"; // Only clamp to a scroll-free "tail" view while actively streaming and // expanded — once the run finishes the full text is shown, unclamped. const showTail = isStreaming && open; return (
{visibleText || (isStreaming ? "…" : "")}
); } // ─── Worked-for duration helpers ────────────────────────────────────────────── export function formatWorkedDuration(ms: number): string { const totalSeconds = Math.max(0, Math.round(ms / 1000)); if (totalSeconds < 60) { return totalSeconds <= 1 ? "1s" : `${totalSeconds}s`; } const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (minutes < 60) { if (seconds === 0) return `${minutes}m`; return `${minutes}m ${seconds}s`; } const hours = Math.floor(minutes / 60); const remMinutes = minutes % 60; if (remMinutes === 0) return `${hours}h`; return `${hours}h ${remMinutes}m`; } export function WorkedForSummary({ durationMs, defaultOpen = false, autoCollapse = false, children, }: { durationMs?: number | null; /** Keep completed work visible when the turn contains interactive UI. */ defaultOpen?: boolean; /** When true, close the summary after a run has completed. */ autoCollapse?: boolean; children: React.ReactNode; }) { // Ordinary completed work starts closed so a remount never flashes details // while auto-collapse settles. Interactive UI opts into an open summary. const [open, setOpen] = useState(defaultOpen); useEffect(() => { if (defaultOpen) { setOpen(true); } else if (autoCollapse) { setOpen(false); } }, [autoCollapse, defaultOpen]); const label = durationMs != null && durationMs >= 1000 ? `Worked for ${formatWorkedDuration(durationMs)}` : "Worked"; return (
{children}
); } export function RanToolsSummary({ toolCount, children, }: { toolCount: number; children: React.ReactNode; }) { const [open, setOpen] = useState(false); const label = `Ran ${toolCount} ${toolCount === 1 ? "tool" : "tools"}`; return (
{children}
); } // ─── Re-export for AssistantMessage ─────────────────────────────────────────── // AssistantMessage in AssistantChat.tsx uses FilesChangedSummary directly, so // re-export it so AssistantChat.tsx can import from one place. export { FilesChangedSummary };