import { memo, type ReactNode, useMemo, useRef, } from "react"; import { ArrowDown } from "lucide-react"; import { cn } from "../lib/utils"; import type { SessionMessage } from "../types/message"; import type { SessionPart, TextPart, ToolPart } from "../types/parts"; import type { AgentBranding } from "../types/branding"; import type { CustomToolRenderer } from "../types/tool-display"; import type { Run } from "../types/run"; import { useRunGroups } from "../hooks/use-run-groups"; import { useRunCollapseState } from "../hooks/use-run-collapse-state"; import { useAutoScroll } from "../hooks/use-auto-scroll"; import { MessageList } from "./message-list"; import { AgentTimeline, type AgentTimelineItem, } from "./agent-timeline"; import { InlineThinkingItem } from "../run/inline-thinking-item"; import { getToolDisplayMetadata } from "../utils/tool-display"; import { OpenUIArtifactRenderer, type OpenUIAction, type OpenUIComponentNode, } from "../openui/openui-artifact-renderer"; /** * Transcript-only container: message list + auto-scroll. Composers are * composed BELOW this by the app (the canonical one is `AgentComposer` in * `@tangle-network/sandbox-ui`) — this component never renders an input. */ export interface ChatContainerProps { messages: SessionMessage[]; partMap: Record; isStreaming: boolean; branding?: AgentBranding; className?: string; /** Custom renderer for tool details. Return ReactNode to override, null to use default. */ renderToolDetail?: CustomToolRenderer; /** Presentation mode for the session view. */ presentation?: "runs" | "timeline"; /** Callback when an OpenUI action button is pressed within inline OpenUI blocks. */ onOpenUIAction?: (action: OpenUIAction) => void; /** Enable rendering OpenUI schemas inline in the chat timeline. Defaults to true. */ enableOpenUI?: boolean; /** Optional actions rendered beside each grouped assistant run. */ renderRunActions?: (run: Run) => ReactNode; /** Optional actions rendered below each user message bubble. */ renderUserMessageActions?: (message: SessionMessage, parts: SessionPart[]) => ReactNode; /** Optional actions rendered beside individual tool items in the run-grouped * (`runs`) presentation. */ renderToolActions?: ( part: ToolPart, options: { run: Run; messageId: string; partIndex: number; }, ) => ReactNode; /** Optional actions rendered beside individual tool items in the `timeline` * presentation, which has no run/message grouping (hence part-only). */ renderTimelineToolActions?: (part: ToolPart) => ReactNode; /** In the `timeline` presentation, collapse to the first N spine rows behind * a "Show N more steps" toggle. Omit to always show every row. */ collapseTimelineAfter?: number; } 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; // Direct node or array of nodes if (isOpenUINode(output)) return [output]; if (Array.isArray(output) && output.length > 0 && output.every(isOpenUINode)) { return output as OpenUIComponentNode[]; } // Wrapped in { openui: ... } or { schema: ... } or { ui: ... } if (typeof output === "object" && !Array.isArray(output)) { const obj = output as Record; for (const key of ["openui", "schema", "ui"]) { if (obj[key]) { const inner = obj[key]; if (isOpenUINode(inner)) return [inner]; if (Array.isArray(inner) && inner.length > 0 && inner.every(isOpenUINode)) { return inner as OpenUIComponentNode[]; } } } } // Try to parse string as JSON containing OpenUI if (typeof output === "string") { try { const parsed = JSON.parse(output); return extractOpenUISchema(parsed); } catch { return null; } } return null; } function formatUnknown(value: unknown): string | undefined { if (value == null) return undefined; if (typeof value === "string") return value; try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function createdAtFromMessage(message: SessionMessage) { return message.time?.created ? new Date(message.time.created) : undefined; } function mapToolPartToTimelineType(part: ToolPart) { const name = part.tool.toLowerCase().replace(/^tool:/, ""); switch (name) { case "bash": case "shell": case "command": case "execute": return "bash" as const; case "write": case "write_file": case "create_file": return "write" as const; case "read": case "read_file": case "cat": return "read" as const; case "edit": case "patch": case "sed": return "edit" as const; case "glob": case "find": return "glob" as const; case "ls": return "list" as const; case "grep": case "search": case "rg": return "grep" as const; case "inspect": return "inspect" as const; default: return "unknown" as const; } } function buildTimelineItems( messages: SessionMessage[], partMap: Record, isStreaming: boolean, onOpenUIAction?: (action: OpenUIAction) => void, enableOpenUI = true, ): { items: AgentTimelineItem[]; showThinking: boolean } { const items: AgentTimelineItem[] = []; let latestUserIndex = -1; for (let index = messages.length - 1; index >= 0; index -= 1) { if (messages[index].role === "user") { latestUserIndex = index; break; } } const responseMessages = latestUserIndex >= 0 ? messages.slice(latestUserIndex + 1) : messages; const lastAssistantMessage = [...responseMessages] .reverse() .find((message) => message.role === "assistant"); const toToolCall = (part: ToolPart) => { const meta = getToolDisplayMetadata(part as ToolPart); const start = part.state.time?.start; const end = part.state.time?.end; return { id: part.id, type: mapToolPartToTimelineType(part), label: meta.title, status: part.state.status === "completed" ? "success" : part.state.status === "error" ? "error" : "running", // A human-readable summary (file path / command), not the raw input JSON — // the full input still renders in the expanded ExpandedToolDetail. detail: meta.commandSnippet ?? meta.targetPath ?? meta.description, output: formatUnknown(part.state.output), duration: start && end ? end - start : undefined, } as const; }; for (const message of messages) { const parts = partMap[message.id] ?? []; if (message.role === "user") { const content = parts .filter((part): part is TextPart => part.type === "text") .map((part) => part.text) .join("\n") .trim(); if (!content) continue; items.push({ id: message.id, kind: "message", role: "user", content, timestamp: createdAtFromMessage(message), }); continue; } const toolBuffer: ToolPart[] = []; const flushToolBuffer = (index: number) => { if (toolBuffer.length === 0) return; if (toolBuffer.length === 1) { items.push({ id: `${message.id}-tool-${toolBuffer[0].id}`, kind: "tool", call: toToolCall(toolBuffer[0]), part: toolBuffer[0], }); } else { items.push({ id: `${message.id}-tool-group-${index}`, kind: "tool_group", title: "Tool activity", parts: [...toolBuffer], calls: toolBuffer.map((part) => toToolCall(part)), }); } // Render OpenUI schemas from completed tool outputs inline if (enableOpenUI) { for (const part of toolBuffer) { if (part.state.status !== "completed" || !part.state.output) continue; const schema = extractOpenUISchema(part.state.output); if (!schema) continue; items.push({ id: `${message.id}-openui-${part.id}`, kind: "custom", content: (
), }); } } toolBuffer.length = 0; }; parts.forEach((part, index) => { const itemId = `${message.id}-${index}`; if (part.type === "tool") { toolBuffer.push(part); return; } flushToolBuffer(index); if (part.type === "text" && !part.synthetic && part.text.trim()) { // Check if the text itself contains an OpenUI JSON block if (enableOpenUI) { const jsonMatch = part.text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/); if (jsonMatch) { const schema = extractOpenUISchema(jsonMatch[1]); if (schema) { // Render the text before the JSON block (if any) const beforeJson = part.text.slice(0, part.text.indexOf("```")).trim(); if (beforeJson) { items.push({ id: `${itemId}-text`, kind: "message", role: "assistant", content: beforeJson, timestamp: createdAtFromMessage(message), }); } items.push({ id: `${itemId}-openui`, kind: "custom", content: (
), }); // Render text after the JSON block (if any) const afterJson = part.text.slice(part.text.lastIndexOf("```") + 3).trim(); if (afterJson) { items.push({ id: `${itemId}-after`, kind: "message", role: "assistant", content: afterJson, timestamp: createdAtFromMessage(message), }); } return; } } } items.push({ id: itemId, kind: "message", role: "assistant", content: part.text, timestamp: createdAtFromMessage(message), isStreaming: isStreaming && lastAssistantMessage?.id === message.id && index === parts.length - 1, }); return; } if (part.type === "reasoning") { items.push({ id: itemId, kind: "custom", content: , }); return; } }); flushToolBuffer(parts.length); } const showThinking = isStreaming && latestUserIndex >= 0 && (lastAssistantMessage == null || !items.some( (item) => item.kind === "message" && item.role === "assistant" && item.id.startsWith(lastAssistantMessage.id), )); return { items, showThinking }; } /** * Chat transcript container: message list + auto-scroll. * Orchestrates useRunGroups, useRunCollapseState, and useAutoScroll. */ export const ChatContainer = memo( ({ messages, partMap, isStreaming, branding, className, renderToolDetail, presentation = "runs", onOpenUIAction, enableOpenUI = true, renderRunActions, renderUserMessageActions, renderToolActions, renderTimelineToolActions, collapseTimelineAfter, }: ChatContainerProps) => { const scrollRef = useRef(null); // Group messages into runs const groups = useRunGroups({ messages, partMap, isStreaming }); // Extract runs for collapse state const runs = groups.filter((g) => g.type === "run").map((g) => g.run); const { isCollapsed, toggleCollapse } = useRunCollapseState(runs); // Auto-scroll const { isAtBottom, scrollToBottom } = useAutoScroll(scrollRef, [ messages, partMap, isStreaming, ]); const timeline = useMemo( () => buildTimelineItems(messages, partMap, isStreaming, onOpenUIAction, enableOpenUI), [messages, partMap, isStreaming, onOpenUIAction, enableOpenUI], ); return (
{/* Message area */}
{messages.length === 0 ? (
Start a conversation.
) : presentation === "timeline" ? (
) : (
)}
{/* Scroll-to-bottom button */} {!isAtBottom && (
)}
); }, ); ChatContainer.displayName = "ChatContainer";