import { memo, useMemo, type ComponentType, type ReactNode } from "react"; import * as Collapsible from "@radix-ui/react-collapsible"; import { Bot, Loader2, ChevronDown, ChevronRight, Terminal, FileEdit, FileSearch, Search, PencilLine, Globe, ClipboardList, Settings, Sparkles, type LucideProps, } from "lucide-react"; import { cn } from "../lib/utils"; import { formatDuration } from "../utils/format"; import type { Run, ToolCategory } from "../types/run"; import type { SessionPart, ToolPart, ReasoningPart } from "../types/parts"; import type { AgentBranding } from "../types/branding"; import type { CustomToolRenderer } from "../types/tool-display"; import { InlineToolItem } from "./inline-tool-item"; import { InlineThinkingItem } from "./inline-thinking-item"; import { Markdown } from "../markdown/markdown"; /** * One row on the run's timeline spine: a connector line + accent dot in a * narrow gutter, content to the right. Mirrors AgentTimeline's row so a run * reads as separated, distinct steps — not one filled box. */ function SpineRow({ accentClassName, isLast, children, }: { accentClassName: string; isLast: boolean; children: ReactNode; }) { return (
{!isLast && ( )}
{children}
); } import { OpenUIArtifactRenderer, type OpenUIAction, type OpenUIComponentNode, } from "../openui/openui-artifact-renderer"; // --------------------------------------------------------------------------- // Default branding // --------------------------------------------------------------------------- const DEFAULT_BRANDING: AgentBranding = { label: "Agent", accentClass: "text-primary", bgClass: "bg-[var(--accent-surface-soft)]", containerBgClass: "bg-muted", borderClass: "border-border", iconClass: "", textClass: "text-primary", }; const ASSISTANT_SHELL = "min-w-0 flex-1 space-y-3 rounded-[26px] border border-[var(--border-subtle)] bg-[color:color-mix(in_srgb,var(--bg-card)_94%,transparent)] px-5 py-4 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"; function AssistantShell({ branding, isStreaming, children, }: { branding: AgentBranding; isStreaming: boolean; children: ReactNode; }) { return (
{branding.label} {isStreaming ? ( Thinking ) : null}
{children}
); } // --------------------------------------------------------------------------- // Category icon mapping // --------------------------------------------------------------------------- const CATEGORY_ICON_MAP: Record> = { command: Terminal, write: FileEdit, read: FileSearch, search: Search, edit: PencilLine, task: Bot, web: Globe, todo: ClipboardList, other: Settings, }; const CATEGORY_ORDER: ToolCategory[] = [ "command", "write", "edit", "read", "search", "web", "task", "todo", "other", ]; // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- export interface RunGroupProps { run: Run; partMap: Record; collapsed: boolean; onToggle: () => void; branding?: AgentBranding; renderToolDetail?: CustomToolRenderer; headerActions?: ReactNode; renderToolActions?: ( part: ToolPart, options: { run: Run; messageId: string; partIndex: number; }, ) => ReactNode; } const OPENUI_NODE_TYPES = new Set([ "heading", "text", "badge", "stat", "key_value", "code", "markdown", "table", "actions", "separator", "stack", "grid", "card", ]); function isOpenUINode(value: unknown): value is OpenUIComponentNode { return ( typeof value === "object" && value !== null && "type" in value && typeof (value as Record).type === "string" && OPENUI_NODE_TYPES.has((value as Record).type as string) ); } function extractOpenUISchema(output: unknown): OpenUIComponentNode[] | null { if (output == null) return null; if (isOpenUINode(output)) return [output]; if (Array.isArray(output) && output.length > 0 && output.every(isOpenUINode)) { return output as OpenUIComponentNode[]; } if (typeof output === "object" && !Array.isArray(output)) { const obj = output as Record; for (const key of ["openui", "schema", "ui"]) { if (obj[key] == null) continue; const inner = obj[key]; if (typeof inner === "string") { try { const parsed = JSON.parse(inner); return extractOpenUISchema(parsed); } catch { continue; } } const nested = extractOpenUISchema(inner); if (nested) return nested; } } if (typeof output === "string") { try { return extractOpenUISchema(JSON.parse(output)); } catch { return null; } } return null; } function getOpenUISummary(output: unknown) { if (!output || typeof output !== "object" || Array.isArray(output)) { return null; } const summary = (output as Record).summary; return typeof summary === "string" && summary.trim() ? summary.trim() : null; } function isOpenUITool(part: ToolPart) { const normalized = part.tool.toLowerCase().replace(/^tool:/, ""); return normalized.includes("openui"); } // --------------------------------------------------------------------------- // Stat badges // --------------------------------------------------------------------------- function CategoryBadges({ categories }: { categories: Set }) { const sorted = useMemo( () => CATEGORY_ORDER.filter((category) => categories.has(category)), [categories], ); if (sorted.length === 0) return null; return (
{sorted.map((cat) => { const Icon = CATEGORY_ICON_MAP[cat] ?? Settings; return ( ); })}
); } function renderSummary(run: Run) { const parts: string[] = []; if (run.stats.toolCount > 0) { parts.push(`${run.stats.toolCount} tool${run.stats.toolCount === 1 ? "" : "s"}`); } if (run.stats.textPartCount > 0) { parts.push(`${run.stats.textPartCount} response${run.stats.textPartCount === 1 ? "" : "s"}`); } if (run.stats.thinkingDurationMs > 0) { parts.push(`${formatDuration(run.stats.thinkingDurationMs)} thinking`); } return parts.join(", "); } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- /** * Collapsible container for a consecutive group of assistant messages (a "run"). * Shows a summary header with stats and renders tool/thinking/text parts. */ export const RunGroup = memo( ({ run, partMap, collapsed, onToggle, branding = DEFAULT_BRANDING, renderToolDetail, headerActions, renderToolActions, }: RunGroupProps) => { // Flatten all parts from all messages in this run const allParts = useMemo(() => { const parts: Array<{ part: SessionPart; msgId: string; index: number; }> = []; for (const msg of run.messages) { const msgParts = partMap[msg.id] ?? []; msgParts.forEach((part, index) => { parts.push({ part, msgId: msg.id, index }); }); } return parts; }, [run.messages, partMap]); const { stats, isStreaming } = run; const hasRenderableParts = allParts.some(({ part }) => { if (part.type === "tool" || part.type === "reasoning") { return true; } return part.type === "text" && !part.synthetic && part.text.trim().length > 0; }); if (!hasRenderableParts) { if (!isStreaming) { return null; } return (
); } const showTraceChrome = allParts.some(({ part }) => { if (part.type === "reasoning") { return true; } if (part.type === "tool") { return !isOpenUITool(part as ToolPart); } return false; }); if (!showTraceChrome) { return ( {allParts.map(({ part, msgId, index }) => { const key = `${msgId}-${index}`; if (part.type === "tool" && isOpenUITool(part as ToolPart)) { const toolPart = part as ToolPart; const schema = extractOpenUISchema(toolPart.state.output); const summary = getOpenUISummary(toolPart.state.output); if (toolPart.state.status === "completed" && schema) { return (
{summary ? (
{summary}
) : null}
); } if (toolPart.state.status === "running") { return (
Building view…
); } } if (part.type === "text" && !part.synthetic && part.text.trim()) { return (
{part.text}
); } return null; })}
); } // Renderable rows: skip empty/synthetic text so spine dots map to real steps. const rows = allParts.filter(({ part }) => { if (part.type === "tool" || part.type === "reasoning") return true; return part.type === "text" && !part.synthetic && part.text.trim().length > 0; }); const dotAccent = (part: SessionPart): string => { if (part.type === "reasoning") return "bg-[var(--brand-glow)]"; if (part.type === "text") return "bg-primary"; return "bg-[var(--border-hover)]"; }; return ( onToggle()}>
{/* Header — a quiet row, not a filled box */}
{headerActions ? (
{headerActions}
) : null}
{/* Collapsed preview */} {collapsed && run.summaryText ? (
{run.summaryText}
) : null} {/* Expanded — separated steps on a timeline spine, no wrapping box */}
{rows.map(({ part, msgId, index }, rowIndex) => { const key = `${msgId}-${index}`; const isLast = rowIndex === rows.length - 1; let node: ReactNode = null; if (part.type === "tool") { if (isOpenUITool(part as ToolPart)) { const toolPart = part as ToolPart; const schema = extractOpenUISchema(toolPart.state.output); const summary = getOpenUISummary(toolPart.state.output); if (toolPart.state.status === "completed" && schema) { node = (
{summary ? (
{summary}
) : null}
); } else if (toolPart.state.status === "running") { node = (
Building view…
); } } if (node === null) { node = ( ); } } else if (part.type === "reasoning") { node = ( ); } else if (part.type === "text" && !part.synthetic && part.text.trim()) { node = (
{part.text}
); } if (!node) return null; return ( {node} ); })}
); }, ); RunGroup.displayName = "RunGroup";