import type { UIMessage } from "ai"; import { useEffect, useRef } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { deriveAgentStatus, type AgentStatus } from "../utils/agent-status"; function InlineThinking({ status }: { status: AgentStatus }) { return (
); } interface ChatPanelProps { messages: UIMessage[]; isLoading: boolean; input: string; setInput: (v: string) => void; onSend: (text: string) => void; onStop: () => void; error: Error | null; mode: "agent" | "plan"; onModeChange: (mode: "agent" | "plan") => void; autoFix?: { attempts: number; maxAttempts: number; active: boolean; stoppedReason?: "max-attempts" | "no-progress" | null; }; } const AUTOFIX_PREFIX = "[auto-fix attempt"; const AUTOFIX_ATTEMPT_RE = /^\[auto-fix attempt (\d+)\/(\d+)\]/; function getAutoFixMeta( m: UIMessage, ): { attempt: number; max: number } | null { if (m.role !== "user") return null; const first = (m.parts ?? []).find( (p) => (p as { type: string }).type === "text", ) as { text?: string } | undefined; const text = first?.text ?? ""; if (!text.startsWith(AUTOFIX_PREFIX)) return null; const match = AUTOFIX_ATTEMPT_RE.exec(text); if (!match) return { attempt: 0, max: 0 }; return { attempt: Number(match[1]), max: Number(match[2]) }; } const EXAMPLE_PROMPTS = [ "Build the default template: HF login, buttons to move the head, and the WebRTC video stream.", "Add a joystick on-screen that drives the head pose continuously while I drag it.", "Make the antennas react to the robot mic volume using the Web Audio API.", "Add a small 'dance' button that plays a scripted sequence of head poses for ~5 seconds.", ]; function renderMessagePart( part: UIMessage["parts"][number], idx: number, ): React.ReactNode { const type = part.type; if (type === "text") { const text = (part as { text: string }).text; if (!text.trim()) return null; return (
{text}
); } if (typeof type === "string" && type.startsWith("tool-")) { const toolPart = part as { type: string; state?: string; input?: Record; output?: unknown; errorText?: string; }; const toolName = type.slice("tool-".length); const state = toolPart.state ?? "unknown"; const isDone = state === "output-available" || state === "result"; const isError = state === "output-error" || state === "error"; const isPending = !isDone && !isError; const statusClass = isDone ? "status-done" : isError ? "status-error" : "status-pending"; const isStreaming = state === "input-streaming"; const detail = extractToolDetail(toolName, toolPart.input); return (
{isPending ? ( ) : ( )} {toolLabel(toolName)} {detail && ( · {detail} {isStreaming ? "…" : ""} )}
{toolPart.errorText && (
{toolPart.errorText}
)}
); } return null; } function extractToolDetail( toolName: string, input: Record | undefined, ): string { if (!input) return ""; if (toolName === "write_file") { const parts: string[] = []; if (typeof input.path === "string" && input.path) parts.push(input.path); if (typeof input.content === "string") { parts.push(`${input.content.length.toLocaleString()} chars`); } return parts.join(" · "); } if (toolName === "edit_file") { const parts: string[] = []; if (typeof input.path === "string" && input.path) parts.push(input.path); if (typeof input.new_string === "string") { parts.push(`${input.new_string.length.toLocaleString()} chars`); } return parts.join(" · "); } if ( (toolName === "read_file" || toolName === "delete_file") && typeof input.path === "string" ) { return input.path; } if (toolName === "read_skill_doc" && typeof input.topic === "string") { return input.topic; } return ""; } function toolLabel(name: string): string { const map: Record = { write_file: "Writing file", edit_file: "Editing file", delete_file: "Deleting file", read_file: "Reading file", list_files: "Listing files", read_skill_doc: "Loading skill chapter", read_console_logs: "Reading console logs", show_preview: "Opening preview", }; return map[name] ?? name; } export function ChatPanel({ messages, isLoading, input, setInput, onSend, onStop, error, mode, onModeChange, autoFix, }: ChatPanelProps) { const scrollRef = useRef(null); const textareaRef = useRef(null); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); }, [messages]); const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); const text = input.trim(); // Having text always wins, even while the agent is still streaming: // `onSend` interrupts the in-flight turn and queues ours. This is // the v0/Claude/ChatGPT pattern - users expect their new message // to supersede whatever the model was doing. No explicit stop // required. if (text) { onSend(text); return; } // Empty input + running agent -> the submit button acts as Stop. if (isLoading) { onStop(); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }; const showEmpty = messages.length === 0; const agentStatus = deriveAgentStatus(messages, isLoading, autoFix ?? null); const lastMessage = messages[messages.length - 1]; const planAwaitingApproval = mode === "plan" && !isLoading && !!lastMessage && lastMessage.role === "assistant"; const handleApprovePlan = () => { onModeChange("agent"); onSend("Approved - switch to Agent mode and execute the plan above."); }; return (