"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, type UIMessage } from "ai"; import { SPEC_DATA_PART, type SpecDataPart, type Spec, } from "@json-render/core"; import { JSONUIProvider, Renderer, buildSpecFromParts, useJsonRenderMessage, useStateStore, } from "@json-render/react"; import { JsonRenderDevtools } from "@json-render/devtools-react"; import { registry } from "@/lib/registry"; import { catalog } from "@/lib/catalog"; // Shared computed helpers the AI can reference with $computed. Keeping these // minimal avoids surprising the agent: +/-/toggle cover the most common cases. const computedFunctions = { inc: (args: Record) => ((args.of as number | undefined) ?? 0) + ((args.by as number | undefined) ?? 1), dec: (args: Record) => ((args.of as number | undefined) ?? 0) - ((args.by as number | undefined) ?? 1), toggle: (args: Record) => !(args.of as boolean | undefined), add: (args: Record) => ((args.a as number | undefined) ?? 0) + ((args.b as number | undefined) ?? 0), }; // --------------------------------------------------------------------------- // Types & transport // --------------------------------------------------------------------------- type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart }; type AppMessage = UIMessage; const transport = new DefaultChatTransport({ api: "/api/chat" }); const SUGGESTIONS = [ { label: "Counter", prompt: "Build an interactive counter with +/- and reset buttons", blurb: "Dispatches setState actions visible in the Actions panel.", }, { label: "Todo list", prompt: "Make a todo list where I can type a task, add it, mark it done, and remove it", blurb: "Shows pushState / removeState and two-way bound inputs.", }, { label: "Dashboard", prompt: "Show me a fitness tracker with three metrics (steps, calories, sleep), progress bars, and a tip callout", blurb: "Metrics + progress — good State and Stream panel content.", }, { label: "Quiz", prompt: "Quiz me on three world geography questions with checkboxes and a submit button that reveals my score", blurb: "Bindings + conditional visibility in the Spec panel.", }, ]; // --------------------------------------------------------------------------- // MessageSpecRenderer — one per assistant message, all wired // into the same top-level state store so the devtools sees everything. // --------------------------------------------------------------------------- function MessageSpecRenderer({ spec }: { spec: Spec }): ReactNode { const { update, getSnapshot } = useStateStore(); const seeded = useRef>(new Set()); // Seed the shared store with this message's initial state the first time // each top-level key appears. The AI is prompted to namespace every path // with the message id, so keys from different messages never collide. // We only seed keys that aren't already in the store, so live user edits // aren't clobbered by late-arriving patches. useEffect(() => { const branch = spec.state as Record | undefined; if (!branch) return; const current = getSnapshot() as Record; const updates: Record = {}; for (const [key, value] of Object.entries(branch)) { if (seeded.current.has(key)) continue; seeded.current.add(key); if (current[key] === undefined) { updates[`/${key}`] = value; } } if (Object.keys(updates).length > 0) { update(updates); } }, [spec.state, update, getSnapshot]); return (
); } // --------------------------------------------------------------------------- // Bubbles // --------------------------------------------------------------------------- function UserBubble({ text }: { text: string }) { return (
{text}
); } function AssistantBubble({ message, isLast, isStreaming, }: { message: AppMessage; isLast: boolean; isStreaming: boolean; }) { const { spec, text, hasSpec } = useJsonRenderMessage(message.parts); const showThinking = isLast && isStreaming && !text && !hasSpec; return (
{showThinking &&
Thinking…
} {text &&
{text}
} {hasSpec && spec && }
); } // --------------------------------------------------------------------------- // Empty state // --------------------------------------------------------------------------- function EmptyState({ onPick }: { onPick: (prompt: string) => void }) { return (
json-render devtools

Chat with AI, inspect every renderer.

Each assistant reply streams a fresh Spec rendered inline below the message. One floating devtools panel captures every streamed patch, state change, and dispatched action — across every message on this page.

Tip
The panel is already open. Switch between Spec, State, Actions, Stream, Catalog and Pick to see what each renderer exposes.
Shortcut
Toggle the panel with J or click the {"{}"} badge.
{SUGGESTIONS.map((s) => ( ))}
); } // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- export default function Page() { const [input, setInput] = useState(""); const listRef = useRef(null); const taRef = useRef(null); const { messages, sendMessage, setMessages, status, error } = useChat({ transport }); const isStreaming = status === "streaming" || status === "submitted"; // The devtools Spec panel inspects one spec at a time; we show the most // recent assistant message's spec as a sensible default. const currentSpec = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]; if (m.role !== "assistant") continue; const spec = buildSpecFromParts(m.parts); if (spec) return spec; } return null; }, [messages]); const handleSubmit = useCallback( (preset?: string) => { const text = (preset ?? input).trim(); if (!text || isStreaming) return; setInput(""); void sendMessage({ text }); taRef.current?.focus(); }, [input, isStreaming, sendMessage], ); // Auto-scroll to bottom on new content. useEffect(() => { const el = listRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [messages, isStreaming]); return (
json-render devtools
AI chat · shared state · one panel
{messages.length > 0 && ( )} Docs ↗
{messages.length === 0 ? ( handleSubmit(p)} /> ) : (
{messages.map((m, i) => { const isLast = i === messages.length - 1; if (m.role === "user") { const t = m.parts .filter((p) => p.type === "text") .map((p) => (p as { text: string }).text) .join(""); return ; } return ( ); })} {error &&
{error.message}
}
)}