'use client'; import { memo, type ReactNode, useEffect, useRef, useState } from 'react'; import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; import { cn } from '@djangocfg/ui-core/lib'; import type { ChatToolCall } from '../types'; import { ToolPayloadValue, type ToolPayloadKind } from './ToolPayloadValue'; // Re-exported for back-compat: `ToolPayloadKind` is now OWNED by // ToolPayloadValue (breaking the old ToolCalls↔ToolPayloadValue import cycle). export type { ToolPayloadKind }; export interface ToolCallsProps { calls: ChatToolCall[]; /** Open every panel up-front. Default: false (panels are closed). */ defaultExpanded?: boolean; /** Auto-open while a tool is running, then auto-close on completion. * User toggles after that are remembered. Default: true. */ expandWhileStreaming?: boolean; /** Override how the tool input payload is rendered. Receives the raw value. */ renderInput?: (input: unknown, call: ChatToolCall) => ReactNode; /** Override how the tool output payload is rendered. */ renderOutput?: (output: unknown, call: ChatToolCall) => ReactNode; /** Override how the live `streamingText` is rendered. */ renderStreaming?: (text: string, call: ChatToolCall) => ReactNode; /** Single override for all three; specific renderers above take precedence. */ renderPayload?: (value: unknown, kind: ToolPayloadKind, call: ChatToolCall) => ReactNode; /** * Rendered once **after** all tool-call panels — always visible, outside * the collapsible panels. Use for rich UI derived from tool outputs (e.g. * vehicle cards, map pins, tax breakdowns). Receives the full calls array * so the renderer can aggregate across multiple tool calls. */ renderAfterCalls?: (calls: ChatToolCall[]) => ReactNode; /** * Custom renderer for each individual tool-call panel. When provided, * replaces the default collapsible ``. Return `null` to * suppress a specific call while still letting others render. */ renderToolCall?: (call: ChatToolCall) => ReactNode; /** * When `true`, the collapsible tool-call panels are not rendered at all. * `renderAfterCalls` still runs — use together to show only rich UI with * no raw accordion panels visible. */ hideToolCalls?: boolean; className?: string; } export function ToolCalls({ calls, defaultExpanded = false, expandWhileStreaming = true, renderInput, renderOutput, renderStreaming, renderPayload, renderAfterCalls, renderToolCall, hideToolCalls = false, className, }: ToolCallsProps) { if (!calls?.length) return null; return (
{!hideToolCalls && calls.map((call) => renderToolCall ?
{renderToolCall(call)}
: ( ), )} {renderAfterCalls ? renderAfterCalls(calls) : null}
); } interface ItemProps { call: ChatToolCall; defaultExpanded: boolean; expandWhileStreaming: boolean; renderInput?: ToolCallsProps['renderInput']; renderOutput?: ToolCallsProps['renderOutput']; renderStreaming?: ToolCallsProps['renderStreaming']; renderPayload?: ToolCallsProps['renderPayload']; } const ToolCallItem = memo(function ToolCallItem({ call, defaultExpanded, expandWhileStreaming, renderInput, renderOutput, renderStreaming, renderPayload, }: ItemProps) { const isRunning = call.status === 'running'; const initialOpen = defaultExpanded || (expandWhileStreaming && isRunning); const [open, setOpen] = useState(initialOpen); // Remember manual interaction so completion doesn't override it. const userToggledRef = useRef(false); const wasRunningRef = useRef(isRunning); // Auto-collapse on running → completed transition, unless user has interacted. useEffect(() => { if (wasRunningRef.current && !isRunning) { if (!userToggledRef.current && !defaultExpanded) { setOpen(false); } } wasRunningRef.current = isRunning; }, [isRunning, defaultExpanded]); const handleToggle = () => { userToggledRef.current = true; setOpen((v) => !v); }; const Icon = open ? ChevronDown : ChevronRight; // Status maps to a small dot color (success/error/cancelled/running). The // word itself is shown only for non-success states to keep the row quiet // — a green dot already reads as "ok". const statusDot = call.status === 'success' ? 'bg-success' : call.status === 'error' ? 'bg-destructive' : call.status === 'cancelled' ? 'bg-muted-foreground' : 'bg-warning'; const statusText = call.status === 'error' ? 'text-destructive' : call.status === 'cancelled' ? 'text-muted-foreground' : 'text-muted-foreground'; const renderValue = (value: unknown, kind: ToolPayloadKind): ReactNode => { if (kind === 'input' && renderInput) return renderInput(value, call); if (kind === 'output' && renderOutput) return renderOutput(value, call); if (kind === 'streaming' && renderStreaming) return renderStreaming(typeof value === 'string' ? value : String(value), call); if (renderPayload) return renderPayload(value, kind, call); return ; }; // A non-success terminal status still shows its word (errors must read); // success/running stay quiet (dot only). const showStatusWord = call.status === 'error' || call.status === 'cancelled'; return (
{open ? (
{call.input != null ? renderValue(call.input, 'input') : null} {call.streamingText != null ? renderValue(call.streamingText, 'streaming') : call.output !== undefined ? renderValue(call.output, 'output') : null}
) : null}
); }, (prev, next) => { // Re-render only when the call's observable surface actually changed. // Render-prop callbacks are accepted as referentially stable by callers — // they live in toolCallsProps which itself rarely changes. const a = prev.call; const b = next.call; return ( a.id === b.id && a.status === b.status && a.output === b.output && a.streamingText === b.streamingText && prev.defaultExpanded === next.defaultExpanded && prev.expandWhileStreaming === next.expandWhileStreaming && prev.renderInput === next.renderInput && prev.renderOutput === next.renderOutput && prev.renderStreaming === next.renderStreaming && prev.renderPayload === next.renderPayload ); }); // The default payload renderer delegates to the shared // (object → JsonTree, string/streaming →
) so a host that owns its own
// tool-call card via `renderToolCall` can render the exact same payload with
// no duplicated routing. See ToolPayloadValue.tsx.
function DefaultPayload({ value, kind }: { value: unknown; kind: ToolPayloadKind }) {
  return ;
}