"use client"; import { useCallback, useRef, useState } from "react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, type UIMessage } from "ai"; import { SPEC_DATA_PART, SPEC_DATA_PART_TYPE, type SpecDataPart, } from "@json-render/core"; import { useJsonRenderMessage } from "@json-render/react"; import { ArrowUp, Bug, ChevronRight, FilePen, FilePlus, FileText, FolderGit2, FolderSearch, FolderTree, Gauge, Globe, Hammer, ListChecks, Loader2, Search, SquareChevronRight, Wrench, type LucideIcon, } from "lucide-react"; import { Streamdown } from "streamdown"; import { code } from "@streamdown/code"; import { ReportRenderer } from "@/lib/render/renderer"; import { AGENT_IDS, AGENTS, type AgentId, DEFAULT_AGENT_ID, } from "@/lib/agents"; type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart }; type AppMessage = UIMessage; const transport = new DefaultChatTransport({ api: "/api/agent" }); const SUGGESTIONS: Array<{ label: string; description: string; icon: LucideIcon; prompt: string; }> = [ { label: "Build & test a library", description: "Scaffold a TS package with vitest and run the suite.", icon: Hammer, prompt: "Scaffold a tiny TypeScript semver-parsing library with vitest tests, run the tests, and report the results.", }, { label: "Fix failing code", description: "Plant a subtle bug, then debug it end to end.", icon: Bug, prompt: "Create a small JS module with a subtle off-by-one bug and a failing test, then diagnose and fix it like a real debugging session.", }, { label: "Benchmark something", description: "Measure two approaches and chart the numbers.", icon: Gauge, prompt: "Write and run a quick benchmark comparing JSON.parse vs a streaming JSON parser on a 5MB file, and report the numbers as a bar chart.", }, { label: "Explore a repo", description: "Clone a project and map out what each part does.", icon: FolderGit2, prompt: "Clone github.com/vercel-labs/json-render, explore the package structure, and report what each package does.", }, ]; /** Per-tool icon + readable [running, done] labels (labels used for a11y). */ const TOOL_META: Record< string, { icon: LucideIcon; labels: [string, string] } > = { bash: { icon: SquareChevronRight, labels: ["Running command", "Ran command"], }, read: { icon: FileText, labels: ["Reading file", "Read file"] }, write: { icon: FilePlus, labels: ["Writing file", "Wrote file"] }, edit: { icon: FilePen, labels: ["Editing file", "Edited file"] }, grep: { icon: Search, labels: ["Searching code", "Searched code"] }, glob: { icon: FolderSearch, labels: ["Listing files", "Listed files"] }, ls: { icon: FolderTree, labels: ["Listing directory", "Listed directory"] }, webSearch: { icon: Globe, labels: ["Searching the web", "Searched the web"] }, WebFetch: { icon: Globe, labels: ["Fetching page", "Fetched page"] }, TodoWrite: { icon: ListChecks, labels: ["Updating plan", "Updated plan"] }, }; /** Pull a one-line human hint out of a tool input (command, path, query). */ function toolInputHint(input: unknown): string | null { if (input == null || typeof input !== "object") return null; const record = input as Record; const hint = record.command ?? record.file_path ?? record.pattern ?? record.query; return typeof hint === "string" ? hint : null; } function ToolCallDisplay({ toolName, state, input, output, }: { toolName: string; state: string; input: unknown; output: unknown; }) { const [expanded, setExpanded] = useState(false); const isLoading = state !== "output-available" && state !== "output-error" && state !== "output-denied"; const meta = TOOL_META[toolName]; const Icon = meta?.icon ?? Wrench; const label = meta ? meta.labels[isLoading ? 0 : 1] : toolName; const hint = toolInputHint(input); return (
{expanded && !isLoading && output != null && (
          {typeof output === "string"
            ? output
            : JSON.stringify(output, null, 2)}
        
)}
); } /** * Monochrome agent marks. Claude (svgl) and OpenAI (svgl) are single-path * brand glyphs forced to `currentColor`; Pi uses its namesake π since it has * no published logo. All inherit the surrounding text color. */ type MarkProps = { className?: string }; function ClaudeMark({ className }: MarkProps) { return ( ); } function OpenAIMark({ className }: MarkProps) { return ( ); } function PiMark({ className }: MarkProps) { // pi.dev/logo-auto.svg — blocky "Pi" mark. The viewBox is padded a little // past the mark's bounds so it reads slightly smaller than the other marks. return ( ); } const AGENT_MARKS: Record React.ReactNode> = { "claude-code": ClaudeMark, codex: OpenAIMark, pi: PiMark, }; /** Segmented control for picking the coding agent before a chat starts. */ function AgentSelector({ value, onChange, }: { value: AgentId; onChange: (id: AgentId) => void; }) { return (
{AGENT_IDS.map((id) => { const Mark = AGENT_MARKS[id]; return ( ); })}
); } /** Shimmering status line shown while we wait for the agent to produce output. */ function PendingLine({ label }: { label: string }) { return (
{label}
); } function MessageBubble({ message, isLast, isStreaming, pendingLabel, }: { message: AppMessage; isLast: boolean; isStreaming: boolean; pendingLabel: string; }) { const { spec, text, hasSpec } = useJsonRenderMessage(message.parts); if (message.role === "user") { return (
{text}
); } // Ordered segments: adjacent text merged, adjacent tool calls grouped, // the spec rendered inline where the agent emitted it. const segments: Array< | { kind: "text"; text: string } | { kind: "tools"; tools: Array<{ toolCallId: string; toolName: string; state: string; input: unknown; output: unknown; }>; } | { kind: "spec" } > = []; let specInserted = false; for (const part of message.parts) { if (part.type === "text") { if (!part.text.trim()) continue; const last = segments[segments.length - 1]; if (last?.kind === "text") last.text += part.text; else segments.push({ kind: "text", text: part.text }); } else if (part.type.startsWith("tool-")) { const tp = part as { type: string; toolCallId: string; state: string; input?: unknown; output?: unknown; }; const tool = { toolCallId: tp.toolCallId, toolName: tp.type.replace(/^tool-/, ""), state: tp.state, input: tp.input, output: tp.output, }; const last = segments[segments.length - 1]; if (last?.kind === "tools") last.tools.push(tool); else segments.push({ kind: "tools", tools: [tool] }); } else if (part.type === SPEC_DATA_PART_TYPE && !specInserted) { segments.push({ kind: "spec" }); specInserted = true; } } const showLoader = isLast && isStreaming && segments.length === 0 && !hasSpec; return (
{segments.map((seg, i) => { if (seg.kind === "text") { return (
{seg.text}
); } if (seg.kind === "spec") { if (!hasSpec) return null; return (
); } return (
{seg.tools.map((t) => ( ))}
); })} {showLoader && } {hasSpec && !specInserted && (
)}
); } export default function HarnessChatPage() { const [input, setInput] = useState(""); const [agentId, setAgentId] = useState(DEFAULT_AGENT_ID); const [chatId, setChatId] = useState(() => crypto.randomUUID()); const [isResetting, setIsResetting] = useState(false); const inputRef = useRef(null); const { messages, sendMessage, setMessages, status, error, id, stop } = useChat({ transport, id: chatId }); const isStreaming = status === "streaming" || status === "submitted"; const isBusy = isStreaming || isResetting; const handleSubmit = useCallback( async (text?: string) => { const message = text || input; if (!message.trim() || isBusy) return; setInput(""); // The server locks the agent to the chat on the first message; sending // it every turn is harmless and keeps follow-ups consistent. await sendMessage({ text: message.trim() }, { body: { agent: agentId } }); }, [input, isBusy, sendMessage, agentId], ); const handleClear = useCallback(async () => { if (isResetting) return; setIsResetting(true); stop(); // Drop the server-side harness session (and its sandbox) for this chat. try { await fetch(`/api/agent?id=${encodeURIComponent(id)}`, { method: "DELETE", }); } finally { setMessages([]); setChatId(crypto.randomUUID()); setInput(""); setIsResetting(false); inputRef.current?.focus(); } }, [id, isResetting, setMessages, stop]); const isEmpty = messages.length === 0; return (
{/* The header only appears once a chat has started; the first screen is headerless so the brand title carries it. */} {!isEmpty && (
{/* Left: active agent */} {(() => { const Mark = AGENT_MARKS[agentId]; return ( {AGENTS[agentId].label} ); })()} {/* Center: brand, absolutely centered so side widths can't shift it */}

AI SDK HarnessAgent + json-render

{/* Right: reset */}
)}
{isEmpty ? (

AI SDK HarnessAgent + json-render

A coding agent works in a live sandbox, then reports back as rendered UI — steps, diffs, terminal output, tests, and charts — instead of a wall of markdown.

{SUGGESTIONS.map((s) => { const Icon = s.icon; return ( ); })}
) : (
{messages.map((message, index) => ( ))} {isStreaming && messages[messages.length - 1]?.role === "user" && ( )} {error && (
{error.message}
)}
)}
{isEmpty && (
Agent
)}