"use client"; import { useEffect, useState, useCallback, useRef, useMemo } from "react"; import { flushSync } from "react-dom"; import type { Spec } from "@json-render/core"; import { collectUsedComponents, serializeProps } from "@json-render/codegen"; import { toast } from "sonner"; import { stringify as yamlStringify } from "yaml"; import type { EditMode } from "@json-render/core"; import { usePlaygroundStream, type StreamFormat, type TokenUsage, } from "@/lib/use-playground-stream"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle, } from "@/components/ui/resizable"; import { CodeBlock } from "./code-block"; import { CopyButton } from "./copy-button"; import { Toaster } from "./ui/sonner"; import { Header } from "./header"; import { Sheet, SheetContent, SheetTitle } from "./ui/sheet"; import { JsonEditor } from "@visual-json/react"; import type { JsonValue } from "@visual-json/react"; import { PlaygroundRenderer } from "@/lib/render/renderer"; import { playgroundCatalog } from "@/lib/render/catalog"; import { buildCatalogDisplayData } from "@/lib/render/catalog-display"; type Tab = "spec" | "nested" | "stream" | "catalog" | "visual"; type RenderView = "preview" | "code"; type MobileView = | "spec" | "nested" | "stream" | "catalog" | "visual" | "preview" | "generated-code"; interface Version { id: string; prompt: string; tree: Spec | null; status: "generating" | "complete" | "error"; usage: TokenUsage | null; rawLines: string[]; format: StreamFormat; } function formatTokens(n: number): string { if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`; return String(n); } function PlaygroundControls({ format, setFormat, editModes, setEditModes, showClear, onClear, }: { format: StreamFormat; setFormat: (f: StreamFormat) => void; editModes: EditMode[]; setEditModes: React.Dispatch>; showClear: boolean; onClear: () => void; }) { return (
{(["jsonl", "yaml"] as const).map((f) => ( ))}
{(["patch", "merge", "diff"] as const).map((m) => ( ))}
{showClear && ( )}
); } /** * Convert a flat Spec into a nested tree structure that is easier for humans * to read. Children keys are resolved recursively into inline objects. */ function specToNested(spec: Spec): Record { function resolve(key: string): Record { const el = spec.elements[key]; if (!el) return { _key: key, _missing: true }; const node: Record = { type: el.type }; if (el.props && Object.keys(el.props).length > 0) { node.props = el.props; } if (el.visible !== undefined) { node.visible = el.visible; } if (el.on && Object.keys(el.on).length > 0) { node.on = el.on; } if (el.repeat) { node.repeat = el.repeat; } if (el.children && el.children.length > 0) { node.children = el.children.map(resolve); } return node; } const result: Record = {}; if (spec.state && Object.keys(spec.state).length > 0) { result.state = spec.state; } result.elements = resolve(spec.root); return result; } const EXAMPLE_PROMPTS = [ "Create a login form", "Build a pricing page", "Design a user profile card", "Make a contact form", ]; export function Playground() { const [versions, setVersions] = useState([]); const [selectedVersionId, setSelectedVersionId] = useState( null, ); const [inputValue, setInputValue] = useState(""); const [activeTab, setActiveTab] = useState("spec"); const [catalogSection, setCatalogSection] = useState< "components" | "actions" >("components"); const [renderView, setRenderView] = useState("preview"); const [mobileView, setMobileView] = useState("preview"); const [versionsSheetOpen, setVersionsSheetOpen] = useState(false); const [format, setFormat] = useState("jsonl"); const [editModes, setEditModes] = useState(["patch"]); const inputRef = useRef(null); const mobileInputRef = useRef(null); const versionsEndRef = useRef(null); // Track the currently generating version ID const generatingVersionIdRef = useRef(null); // Track the current tree for use as previousSpec in next generation const currentTreeRef = useRef(null); const { spec: apiSpec, isStreaming, usage: streamUsage, rawLines: streamRawLines, send, clear, } = usePlaygroundStream({ api: "/api/generate", format, editModes, onError: (err: Error) => { console.error("Generation error:", err); toast.error(err.message || "Generation failed. Please try again."); if (generatingVersionIdRef.current) { const erroredVersionId = generatingVersionIdRef.current; setVersions((prev) => prev.map((v) => v.id === erroredVersionId ? { ...v, status: "error" as const } : v, ), ); generatingVersionIdRef.current = null; } }, }); // Get the selected version const selectedVersion = versions.find((v) => v.id === selectedVersionId); // Determine which tree to display: // - If streaming and selected version is the generating one, show apiSpec // - Otherwise show the selected version's tree const isSelectedVersionGenerating = selectedVersionId === generatingVersionIdRef.current && isStreaming; const hasValidApiTree = apiSpec && apiSpec.root && Object.keys(apiSpec.elements).length > 0; const currentTree = isSelectedVersionGenerating && hasValidApiTree ? apiSpec : (selectedVersion?.tree ?? (isSelectedVersionGenerating ? apiSpec : null)); // Raw JSONL lines: live from stream during generation, or stored per version const currentRawLines = isSelectedVersionGenerating ? streamRawLines : (selectedVersion?.rawLines ?? []); // Keep the ref updated with the current tree for use in handleSubmit if ( currentTree && currentTree.root && Object.keys(currentTree.elements).length > 0 ) { currentTreeRef.current = currentTree; } // Scroll to bottom when versions change useEffect(() => { versionsEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [versions]); // Update version when streaming completes useEffect(() => { if ( !isStreaming && apiSpec && apiSpec.root && generatingVersionIdRef.current ) { const completedVersionId = generatingVersionIdRef.current; setVersions((prev) => prev.map((v) => v.id === completedVersionId ? { ...v, tree: apiSpec, status: "complete" as const, usage: streamUsage, rawLines: streamRawLines, } : v, ), ); generatingVersionIdRef.current = null; } }, [isStreaming, apiSpec, streamUsage, streamRawLines]); const handleSubmit = useCallback(async () => { if (!inputValue.trim() || isStreaming) return; const newVersionId = Date.now().toString(); const newVersion: Version = { id: newVersionId, prompt: inputValue.trim(), tree: null, status: "generating", usage: null, rawLines: [], format, }; generatingVersionIdRef.current = newVersionId; setVersions((prev) => [...prev, newVersion]); setSelectedVersionId(newVersionId); setInputValue(""); // Pass the current tree as context so the API can iterate on it await send(inputValue.trim(), { previousSpec: currentTreeRef.current }); }, [inputValue, isStreaming, send, format]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }, [handleSubmit], ); const handleVisualChange = useCallback( (value: JsonValue) => { if (!selectedVersionId || isStreaming) return; setVersions((prev) => prev.map((v) => v.id === selectedVersionId ? { ...v, tree: value as unknown as Spec } : v, ), ); }, [selectedVersionId, isStreaming], ); const specCode = useMemo(() => { if (!currentTree) return format === "yaml" ? "# waiting..." : "// waiting..."; if (format === "yaml") { return yamlStringify(currentTree, { indent: 2 }).trimEnd(); } return JSON.stringify(currentTree, null, 2); }, [currentTree, format]); const specLang = format === "yaml" ? "yaml" : "json"; const nestedCode = useMemo(() => { if (!currentTree || !currentTree.root) return "// waiting..."; return JSON.stringify(specToNested(currentTree), null, 2); }, [currentTree]); const generatedCode = useMemo(() => { if (!currentTree || !currentTree.root) { return "// Generate a UI to see the code"; } const tree = currentTree; const components = collectUsedComponents(tree); function generateJSX(key: string, indent: number): string { const element = tree.elements[key]; if (!element) return ""; const spaces = " ".repeat(indent); const componentName = element.type; const propsObj: Record = {}; for (const [k, v] of Object.entries(element.props ?? {})) { if (v !== null && v !== undefined) { propsObj[k] = v; } } const propsStr = serializeProps(propsObj); const hasChildren = element.children && element.children.length > 0; if (!hasChildren) { return propsStr ? `${spaces}<${componentName} ${propsStr} />` : `${spaces}<${componentName} />`; } const lines: string[] = []; lines.push( propsStr ? `${spaces}<${componentName} ${propsStr}>` : `${spaces}<${componentName}>`, ); for (const childKey of element.children!) { lines.push(generateJSX(childKey, indent + 1)); } lines.push(`${spaces}`); return lines.join("\n"); } const jsx = generateJSX(tree.root, 2); const imports = Array.from(components).sort().join(", "); return `"use client"; import { ${imports} } from "@/components/ui"; export default function Page() { return (
${jsx}
); }`; }, [currentTree]); // Determine syntax lang for raw stream based on selected version's format const streamLang = isSelectedVersionGenerating ? format === "yaml" ? "yaml" : "json" : selectedVersion?.format === "yaml" ? "yaml" : "json"; // Chat pane content const chatPane = (
versions
0 ? "overflow-y-auto space-y-1" : "flex"}`} > {versions.length === 0 ? (

Describe what you want to build, then iterate on it.

{EXAMPLE_PROMPTS.map((prompt) => ( ))}
) : ( versions.map((version, index) => ( )) )}
{ // Focus textarea unless clicking a button or the textarea itself const target = e.target as HTMLElement; if (!target.closest("button") && target.tagName !== "TEXTAREA") { e.preventDefault(); inputRef.current?.focus(); } }} >