Spaces:
Runtime error
Runtime error
| 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 ( | |
| <div className="chat-thinking" role="status" aria-live="polite"> | |
| <span className="chat-thinking-dots" aria-hidden="true"> | |
| <span /> | |
| <span /> | |
| <span /> | |
| </span> | |
| <span className="chat-thinking-label">{status.label}</span> | |
| {status.detail && ( | |
| <span className="chat-thinking-detail mono">· {status.detail}</span> | |
| )} | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div key={idx} className="bubble-agent-text markdown"> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown> | |
| </div> | |
| ); | |
| } | |
| if (typeof type === "string" && type.startsWith("tool-")) { | |
| const toolPart = part as { | |
| type: string; | |
| state?: string; | |
| input?: Record<string, unknown>; | |
| 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 ( | |
| <div key={idx} className={`bubble-tool ${statusClass}`}> | |
| <div className="bubble-tool-header"> | |
| {isPending ? ( | |
| <span className="tool-spinner" aria-label="in progress" /> | |
| ) : ( | |
| <span className="dot" /> | |
| )} | |
| <span>{toolLabel(toolName)}</span> | |
| {detail && ( | |
| <span className="mono bubble-tool-detail-inline"> | |
| · {detail} | |
| {isStreaming ? "…" : ""} | |
| </span> | |
| )} | |
| </div> | |
| {toolPart.errorText && ( | |
| <div className="bubble-tool-detail">{toolPart.errorText}</div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| return null; | |
| } | |
| function extractToolDetail( | |
| toolName: string, | |
| input: Record<string, unknown> | 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<string, string> = { | |
| 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<HTMLDivElement>(null); | |
| const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => { | |
| 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 ( | |
| <aside className="chat-panel"> | |
| <div className="chat-messages" ref={scrollRef}> | |
| {showEmpty && ( | |
| <div className="chat-empty"> | |
| <p> | |
| Describe the Reachy Mini app you want to build. The agent | |
| produces <strong>tiny static demos</strong> - 3 files | |
| (<code>index.html</code>, <code>main.js</code>,{" "} | |
| <code>README.md</code>), no build step, no server, HF login | |
| only. Pick a starting point below or write your own prompt. | |
| </p> | |
| <div className="chat-empty-examples"> | |
| {EXAMPLE_PROMPTS.map((p) => ( | |
| <button | |
| key={p} | |
| type="button" | |
| className="chat-empty-example" | |
| disabled={isLoading} | |
| onClick={() => { | |
| if (isLoading) return; | |
| setInput(""); | |
| onSend(p); | |
| }} | |
| > | |
| {p} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {(() => { | |
| const seen = new Set<string>(); | |
| return messages.map((m, idx) => { | |
| if (seen.has(m.id)) return null; | |
| seen.add(m.id); | |
| const autoFixMeta = getAutoFixMeta(m); | |
| if (autoFixMeta) { | |
| return ( | |
| <div key={m.id} className="autofix-divider" role="note"> | |
| <span className="autofix-divider-line" aria-hidden="true" /> | |
| <span className="autofix-divider-label"> | |
| Auto-fix | |
| {autoFixMeta.max > 0 && ( | |
| <> | |
| {" "} | |
| <span className="mono"> | |
| attempt {autoFixMeta.attempt}/{autoFixMeta.max} | |
| </span> | |
| </> | |
| )} | |
| <span className="autofix-divider-hint"> | |
| · preview errors sent back to the agent | |
| </span> | |
| </span> | |
| <span className="autofix-divider-line" aria-hidden="true" /> | |
| </div> | |
| ); | |
| } | |
| const isLast = idx === messages.length - 1; | |
| const showInlineThinking = | |
| isLoading && agentStatus && isLast && m.role === "assistant"; | |
| return m.role === "user" ? ( | |
| <div key={m.id} className="bubble-user"> | |
| {m.parts | |
| .map((p) => ((p as { type: string }).type === "text" ? (p as { text: string }).text : "")) | |
| .join("")} | |
| </div> | |
| ) : ( | |
| <div key={m.id} className="bubble-agent"> | |
| {m.parts.map((p, i) => renderMessagePart(p, i))} | |
| {showInlineThinking && ( | |
| <InlineThinking status={agentStatus} /> | |
| )} | |
| </div> | |
| ); | |
| }); | |
| })()} | |
| {isLoading && | |
| agentStatus && | |
| (messages.length === 0 || | |
| messages[messages.length - 1].role !== "assistant") && ( | |
| <div className="bubble-agent bubble-agent-placeholder"> | |
| <InlineThinking status={agentStatus} /> | |
| </div> | |
| )} | |
| {autoFix && !autoFix.active && autoFix.stoppedReason && ( | |
| <div className="autofix-note"> | |
| {autoFix.stoppedReason === "max-attempts" | |
| ? `Stopped after ${autoFix.maxAttempts} auto-fix attempts. Retry or rephrase.` | |
| : "Auto-fix stopped: no progress between attempts. Retry or rephrase."} | |
| </div> | |
| )} | |
| {error && ( | |
| <div className="signin-error" style={{ alignSelf: "stretch" }}> | |
| {error.message} | |
| </div> | |
| )} | |
| </div> | |
| {planAwaitingApproval && ( | |
| <div className="plan-approval" role="status"> | |
| <div className="plan-approval-text"> | |
| <strong>Plan mode.</strong> Review the plan above. When you're ready, | |
| switch to <em>Agent mode</em> to execute it. | |
| </div> | |
| <div className="plan-approval-actions"> | |
| <button | |
| type="button" | |
| className="btn btn-primary plan-approval-cta" | |
| onClick={handleApprovePlan} | |
| disabled={isLoading} | |
| > | |
| Switch to Agent & execute | |
| </button> | |
| <button | |
| type="button" | |
| className="btn btn-ghost plan-approval-secondary" | |
| onClick={() => onModeChange("agent")} | |
| disabled={isLoading} | |
| title="Switch to Agent mode without sending a message" | |
| > | |
| Switch only | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| <form className="chat-composer" onSubmit={handleSubmit}> | |
| <div className="chat-composer-field"> | |
| <textarea | |
| ref={textareaRef} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={ | |
| mode === "plan" | |
| ? "Describe what to design - the agent won't edit files…" | |
| : "Describe what to build or change…" | |
| } | |
| rows={1} | |
| /> | |
| {(() => { | |
| const hasText = input.trim().length > 0; | |
| const showStop = isLoading && !hasText; | |
| return ( | |
| <button | |
| type="submit" | |
| className="chat-send" | |
| disabled={!isLoading && !hasText} | |
| title={ | |
| showStop | |
| ? "Stop" | |
| : isLoading | |
| ? "Interrupt and send" | |
| : "Send" | |
| } | |
| > | |
| {showStop ? "■" : "↑"} | |
| </button> | |
| ); | |
| })()} | |
| </div> | |
| <div className="chat-composer-toolbar"> | |
| <div className="mode-toggle" role="tablist" aria-label="Agent mode"> | |
| <button | |
| type="button" | |
| role="tab" | |
| aria-selected={mode === "agent"} | |
| className={`mode-tab ${mode === "agent" ? "active" : ""}`} | |
| onClick={() => onModeChange("agent")} | |
| disabled={isLoading} | |
| title="Agent mode: the agent writes and edits files" | |
| > | |
| Agent | |
| </button> | |
| <button | |
| type="button" | |
| role="tab" | |
| aria-selected={mode === "plan"} | |
| className={`mode-tab ${mode === "plan" ? "active" : ""}`} | |
| onClick={() => onModeChange("plan")} | |
| disabled={isLoading} | |
| title="Plan mode: read-only, the agent drafts a plan in Markdown" | |
| > | |
| Plan | |
| </button> | |
| </div> | |
| <div className="chat-status" aria-live="polite"> | |
| <span>Enter to send · Shift+Enter for newline</span> | |
| </div> | |
| </div> | |
| </form> | |
| </aside> | |
| ); | |
| } | |