Browse Source

add playground (#40)

* add playground

* update title
Chris Tate 5 tháng trước cách đây
mục cha
commit
e323d263d0

+ 0 - 0
apps/web/app/docs/actions/page.tsx → apps/web/app/(main)/docs/actions/page.tsx


+ 0 - 0
apps/web/app/docs/ai-sdk/page.tsx → apps/web/app/(main)/docs/ai-sdk/page.tsx


+ 0 - 0
apps/web/app/docs/api/codegen/page.tsx → apps/web/app/(main)/docs/api/codegen/page.tsx


+ 0 - 0
apps/web/app/docs/api/core/page.tsx → apps/web/app/(main)/docs/api/core/page.tsx


+ 0 - 0
apps/web/app/docs/api/react/page.tsx → apps/web/app/(main)/docs/api/react/page.tsx


+ 0 - 0
apps/web/app/docs/catalog/page.tsx → apps/web/app/(main)/docs/catalog/page.tsx


+ 0 - 0
apps/web/app/docs/code-export/page.tsx → apps/web/app/(main)/docs/code-export/page.tsx


+ 0 - 0
apps/web/app/docs/components/page.tsx → apps/web/app/(main)/docs/components/page.tsx


+ 0 - 0
apps/web/app/docs/data-binding/page.tsx → apps/web/app/(main)/docs/data-binding/page.tsx


+ 0 - 0
apps/web/app/docs/installation/page.tsx → apps/web/app/(main)/docs/installation/page.tsx


+ 0 - 0
apps/web/app/docs/layout.tsx → apps/web/app/(main)/docs/layout.tsx


+ 0 - 0
apps/web/app/docs/page.tsx → apps/web/app/(main)/docs/page.tsx


+ 0 - 0
apps/web/app/docs/quick-start/page.tsx → apps/web/app/(main)/docs/quick-start/page.tsx


+ 0 - 0
apps/web/app/docs/streaming/page.tsx → apps/web/app/(main)/docs/streaming/page.tsx


+ 0 - 0
apps/web/app/docs/validation/page.tsx → apps/web/app/(main)/docs/validation/page.tsx


+ 0 - 0
apps/web/app/docs/visibility/page.tsx → apps/web/app/(main)/docs/visibility/page.tsx


+ 16 - 0
apps/web/app/(main)/layout.tsx

@@ -0,0 +1,16 @@
+import { Header } from "@/components/header";
+import { Footer } from "@/components/footer";
+
+export default function MainLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <div className="min-h-screen flex flex-col">
+      <Header />
+      <main className="flex-1">{children}</main>
+      <Footer />
+    </div>
+  );
+}

+ 3 - 3
apps/web/app/page.tsx → apps/web/app/(main)/page.tsx

@@ -10,11 +10,11 @@ export default function Home() {
       {/* Hero */}
       <section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
         <h1 className="text-5xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
-          Predictable. Guardrailed. Fast.
+          AI → json-render → UI
         </h1>
         <p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
-          Let users generate dashboards, widgets, apps, and data visualizations
-          from prompts — safely constrained to components you define.
+          Define a component catalog. Users prompt. AI outputs JSON constrained
+          to your catalog. Your components render it.
         </p>
 
         <Demo />

+ 25 - 3
apps/web/app/api/generate/route.ts

@@ -77,18 +77,40 @@ EXAMPLE (Blog with responsive grid):
 
 Generate JSONL:`;
 
-const MAX_PROMPT_LENGTH = 140;
+const MAX_PROMPT_LENGTH = 500;
 const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
 
 export async function POST(req: Request) {
-  const { prompt } = await req.json();
+  const { prompt, context } = await req.json();
+  const previousTree = context?.previousTree;
 
   const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);
 
+  // Build the user prompt, including previous tree for iteration
+  let userPrompt = sanitizedPrompt;
+  if (
+    previousTree &&
+    previousTree.root &&
+    Object.keys(previousTree.elements || {}).length > 0
+  ) {
+    userPrompt = `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):
+${JSON.stringify(previousTree, null, 2)}
+
+USER REQUEST: ${sanitizedPrompt}
+
+IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
+- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
+- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
+- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
+- To add children: update the parent element with new children array
+
+DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
+  }
+
   const result = streamText({
     model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
     system: SYSTEM_PROMPT,
-    prompt: sanitizedPrompt,
+    prompt: userPrompt,
     temperature: 0.7,
   });
 

+ 1 - 9
apps/web/app/layout.tsx

@@ -1,8 +1,6 @@
 import type { Metadata } from "next";
 import localFont from "next/font/local";
 import "./globals.css";
-import { Header } from "@/components/header";
-import { Footer } from "@/components/footer";
 import { ThemeProvider } from "@/components/theme-provider";
 import { Analytics } from "@vercel/analytics/next";
 import { SpeedInsights } from "@vercel/speed-insights/next";
@@ -76,13 +74,7 @@ export default function RootLayout({
   return (
     <html lang="en" suppressHydrationWarning>
       <body className={`${geistSans.variable} ${geistMono.variable}`}>
-        <ThemeProvider>
-          <div className="min-h-screen flex flex-col">
-            <Header />
-            <main className="flex-1">{children}</main>
-            <Footer />
-          </div>
-        </ThemeProvider>
+        <ThemeProvider>{children}</ThemeProvider>
         <Analytics />
         <SpeedInsights />
       </body>

+ 9 - 0
apps/web/app/playground/layout.tsx

@@ -0,0 +1,9 @@
+export default function PlaygroundLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <div className="h-screen flex flex-col overflow-hidden">{children}</div>
+  );
+}

+ 2 - 65
apps/web/app/playground/page.tsx

@@ -1,72 +1,9 @@
-import { Button } from "@/components/ui/button";
+import { Playground } from "@/components/playground";
 
 export const metadata = {
   title: "Playground | json-render",
 };
 
 export default function PlaygroundPage() {
-  return (
-    <div className="max-w-4xl mx-auto px-6 py-16">
-      <h1 className="text-3xl font-bold mb-4">Playground</h1>
-      <p className="text-muted-foreground mb-12">
-        Try json-render with a live example.
-      </p>
-
-      <div className="space-y-12">
-        <section>
-          <h2 className="text-xl font-semibold mb-4">Run locally</h2>
-          <p className="text-sm text-muted-foreground mb-4">
-            Clone the repository and run the example dashboard.
-          </p>
-          <pre className="text-sm mb-4">
-            <code>{`git clone https://github.com/vercel-labs/json-render
-cd json-render
-pnpm install
-pnpm dev`}</code>
-          </pre>
-          <p className="text-sm text-muted-foreground">
-            Open <code>http://localhost:3001</code> for the example dashboard.
-          </p>
-        </section>
-
-        <section>
-          <h2 className="text-xl font-semibold mb-4">Example prompts</h2>
-          <p className="text-sm text-muted-foreground mb-4">
-            Try these prompts in the example dashboard:
-          </p>
-          <div className="space-y-2">
-            {[
-              "Create a revenue dashboard with monthly metrics",
-              "Build a user management panel with a table",
-              "Design a settings form with text inputs",
-              "Make a notification center with alerts",
-            ].map((prompt) => (
-              <div
-                key={prompt}
-                className="p-3 border border-border rounded text-sm font-mono"
-              >
-                {prompt}
-              </div>
-            ))}
-          </div>
-        </section>
-
-        <section>
-          <h2 className="text-xl font-semibold mb-4">Interactive playground</h2>
-          <p className="text-sm text-muted-foreground mb-6">
-            A browser-based playground is coming soon.
-          </p>
-          <Button variant="outline" asChild>
-            <a
-              href="https://github.com/vercel-labs/json-render"
-              target="_blank"
-              rel="noopener noreferrer"
-            >
-              Star on GitHub
-            </a>
-          </Button>
-        </section>
-      </div>
-    </div>
-  );
+  return <Playground />;
 }

+ 66 - 18
apps/web/components/demo.tsx

@@ -159,9 +159,28 @@ type Phase = "typing" | "streaming" | "complete";
 type Tab = "stream" | "json";
 type RenderView = "dynamic" | "static";
 
-export function Demo() {
-  const [mode, setMode] = useState<Mode>("simulation");
-  const [phase, setPhase] = useState<Phase>("typing");
+interface DemoProps {
+  fullscreen?: boolean;
+  skipSimulation?: boolean;
+}
+
+const EXAMPLE_PROMPTS = [
+  "Create a login form with email and password",
+  "Build a feedback form with rating stars",
+  "Design a contact card with avatar",
+  "Make a settings panel with toggles",
+];
+
+export function Demo({
+  fullscreen = false,
+  skipSimulation = false,
+}: DemoProps) {
+  const [mode, setMode] = useState<Mode>(
+    skipSimulation ? "interactive" : "simulation",
+  );
+  const [phase, setPhase] = useState<Phase>(
+    skipSimulation ? "complete" : "typing",
+  );
   const [typedPrompt, setTypedPrompt] = useState("");
   const [userPrompt, setUserPrompt] = useState("");
   const [stageIndex, setStageIndex] = useState(-1);
@@ -909,10 +928,19 @@ Open [http://localhost:3000](http://localhost:3000) to view.
   const isStreamingSimulation = mode === "simulation" && phase === "streaming";
   const showLoadingDots = isStreamingSimulation || isStreaming;
 
+  const handleExampleClick = useCallback((prompt: string) => {
+    setMode("interactive");
+    setPhase("complete");
+    setUserPrompt(prompt);
+    setTimeout(() => inputRef.current?.focus(), 0);
+  }, []);
+
   return (
-    <div className="w-full max-w-4xl mx-auto text-left">
+    <div
+      className={`w-full text-left ${fullscreen ? "h-full flex flex-col" : "max-w-5xl mx-auto"}`}
+    >
       {/* Prompt input */}
-      <div className="mb-6">
+      <div className={fullscreen ? "mb-4" : "mb-6"}>
         <div
           className="border border-border rounded p-3 bg-background font-mono text-sm min-h-[44px] flex items-center justify-between cursor-text"
           onClick={() => {
@@ -1000,16 +1028,32 @@ Open [http://localhost:3000](http://localhost:3000) to view.
             </button>
           )}
         </div>
-        <div className="mt-2 text-xs text-muted-foreground text-center">
-          Try: &quot;Create a login form&quot; or &quot;Build a feedback form
-          with rating&quot;
-        </div>
+        {fullscreen ? (
+          <div className="mt-3 flex flex-wrap gap-2 justify-center">
+            {EXAMPLE_PROMPTS.map((prompt) => (
+              <button
+                key={prompt}
+                onClick={() => handleExampleClick(prompt)}
+                className="text-xs px-3 py-1.5 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
+              >
+                {prompt}
+              </button>
+            ))}
+          </div>
+        ) : (
+          <div className="mt-2 text-xs text-muted-foreground text-center">
+            Try: &quot;Create a login form&quot; or &quot;Build a feedback form
+            with rating&quot;
+          </div>
+        )}
       </div>
 
-      <div className="grid lg:grid-cols-2 gap-4">
+      <div
+        className={`grid lg:grid-cols-2 gap-4 ${fullscreen ? "flex-1 min-h-0" : ""}`}
+      >
         {/* Tabbed code/stream/json panel */}
-        <div className="min-w-0">
-          <div className="flex items-center gap-4 mb-2 h-6">
+        <div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
+          <div className="flex items-center gap-4 mb-2 h-6 shrink-0">
             {(["json", "stream"] as const).map((tab) => (
               <button
                 key={tab}
@@ -1024,7 +1068,9 @@ Open [http://localhost:3000](http://localhost:3000) to view.
               </button>
             ))}
           </div>
-          <div className="border border-border rounded bg-background font-mono text-xs h-96 text-left grid relative group">
+          <div
+            className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
+          >
             <div className="absolute top-2 right-2 z-10">
               <CopyButton
                 text={
@@ -1034,7 +1080,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
               />
             </div>
             <div
-              className={`overflow-auto h-full ${activeTab === "stream" ? "" : "hidden"}`}
+              className={`overflow-auto ${activeTab === "stream" ? "" : "hidden"}`}
             >
               {streamLines.length > 0 ? (
                 <>
@@ -1059,7 +1105,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
               )}
             </div>
             <div
-              className={`overflow-auto h-full ${activeTab === "json" ? "" : "hidden"}`}
+              className={`overflow-auto ${activeTab === "json" ? "" : "hidden"}`}
             >
               <CodeBlock
                 code={jsonCode}
@@ -1072,8 +1118,8 @@ Open [http://localhost:3000](http://localhost:3000) to view.
         </div>
 
         {/* Rendered output using json-render */}
-        <div className="min-w-0">
-          <div className="flex items-center justify-between mb-2 h-6">
+        <div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
+          <div className="flex items-center justify-between mb-2 h-6 shrink-0">
             <div className="flex items-center gap-4">
               {(
                 [
@@ -1126,7 +1172,9 @@ Open [http://localhost:3000](http://localhost:3000) to view.
               </button>
             </div>
           </div>
-          <div className="border border-border rounded bg-background h-96 grid relative group">
+          <div
+            className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
+          >
             {renderView === "static" && (
               <div className="absolute top-2 right-2 z-10">
                 <CopyButton

+ 6 - 0
apps/web/components/header.tsx

@@ -47,6 +47,12 @@ export function Header() {
           </Link>
         </div>
         <nav className="flex items-center gap-4">
+          <Link
+            href="/playground"
+            className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+          >
+            Playground
+          </Link>
           <Link
             href="/docs"
             className="text-sm text-muted-foreground hover:text-foreground transition-colors"

+ 598 - 0
apps/web/components/playground.tsx

@@ -0,0 +1,598 @@
+"use client";
+
+import { useEffect, useState, useCallback, useRef, useMemo } from "react";
+import { Renderer, useUIStream, JSONUIProvider } from "@json-render/react";
+import type { UITree } from "@json-render/core";
+import { collectUsedComponents, serializeProps } from "@json-render/codegen";
+import { toast } from "sonner";
+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 {
+  demoRegistry,
+  fallbackComponent,
+  useInteractiveState,
+} from "./demo/index";
+
+type Tab = "json" | "stream";
+type RenderView = "preview" | "code";
+type MobilePane = "chat" | "code" | "preview";
+
+interface Version {
+  id: string;
+  prompt: string;
+  tree: UITree | null;
+  status: "generating" | "complete";
+}
+
+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<Version[]>([]);
+  const [selectedVersionId, setSelectedVersionId] = useState<string | null>(
+    null,
+  );
+  const [inputValue, setInputValue] = useState("");
+  const [streamLines, setStreamLines] = useState<string[]>([]);
+  const [activeTab, setActiveTab] = useState<Tab>("json");
+  const [renderView, setRenderView] = useState<RenderView>("preview");
+  const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
+  const inputRef = useRef<HTMLTextAreaElement>(null);
+  const versionsEndRef = useRef<HTMLDivElement>(null);
+
+  // Track the currently generating version ID
+  const generatingVersionIdRef = useRef<string | null>(null);
+
+  // Track the current tree for use as previousTree in next generation
+  const currentTreeRef = useRef<UITree | null>(null);
+
+  const {
+    tree: apiTree,
+    isStreaming,
+    send,
+    clear,
+  } = useUIStream({
+    api: "/api/generate",
+    onError: (err: Error) => console.error("Generation error:", err),
+  } as Parameters<typeof useUIStream>[0]);
+
+  useInteractiveState();
+
+  // 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 apiTree
+  // - Otherwise show the selected version's tree
+  const isSelectedVersionGenerating =
+    selectedVersionId === generatingVersionIdRef.current && isStreaming;
+  const hasValidApiTree =
+    apiTree && apiTree.root && Object.keys(apiTree.elements).length > 0;
+
+  const currentTree =
+    isSelectedVersionGenerating && hasValidApiTree
+      ? apiTree
+      : (selectedVersion?.tree ??
+        (isSelectedVersionGenerating ? apiTree : null));
+
+  // 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]);
+
+  useEffect(() => {
+    if (apiTree) {
+      const streamLine = JSON.stringify({ tree: apiTree });
+      if (
+        !streamLines.includes(streamLine) &&
+        Object.keys(apiTree.elements).length > 0
+      ) {
+        setStreamLines((prev) => {
+          const lastLine = prev[prev.length - 1];
+          if (lastLine !== streamLine) {
+            return [...prev, streamLine];
+          }
+          return prev;
+        });
+      }
+    }
+  }, [apiTree, streamLines]);
+
+  // Update version when streaming completes
+  useEffect(() => {
+    if (
+      !isStreaming &&
+      apiTree &&
+      apiTree.root &&
+      generatingVersionIdRef.current
+    ) {
+      const completedVersionId = generatingVersionIdRef.current;
+      setVersions((prev) =>
+        prev.map((v) =>
+          v.id === completedVersionId
+            ? { ...v, tree: apiTree, status: "complete" as const }
+            : v,
+        ),
+      );
+      generatingVersionIdRef.current = null;
+    }
+  }, [isStreaming, apiTree]);
+
+  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",
+    };
+
+    generatingVersionIdRef.current = newVersionId;
+    setVersions((prev) => [...prev, newVersion]);
+    setSelectedVersionId(newVersionId);
+    setInputValue("");
+    setStreamLines([]); // Reset stream lines for new generation
+
+    // Pass the current tree as context so the API can iterate on it
+    await send(inputValue.trim(), { previousTree: currentTreeRef.current });
+  }, [inputValue, isStreaming, send]);
+
+  const handleKeyDown = useCallback(
+    (e: React.KeyboardEvent) => {
+      if (e.key === "Enter" && !e.shiftKey) {
+        e.preventDefault();
+        handleSubmit();
+      }
+    },
+    [handleSubmit],
+  );
+
+  useEffect(() => {
+    (
+      window as unknown as { __demoAction?: (text: string) => void }
+    ).__demoAction = (text: string) => {
+      toast(text);
+    };
+    return () => {
+      delete (window as unknown as { __demoAction?: (text: string) => void })
+        .__demoAction;
+    };
+  }, []);
+
+  const jsonCode = currentTree
+    ? JSON.stringify(currentTree, null, 2)
+    : "// waiting...";
+
+  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<string, unknown> = {};
+      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}</${componentName}>`);
+      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 (
+    <div className="min-h-screen p-8 flex items-center justify-center">
+${jsx}
+    </div>
+  );
+}`;
+  }, [currentTree]);
+
+  // Chat pane content
+  const chatPane = (
+    <div className="h-full flex flex-col border-t border-border">
+      <div className="border-b border-border px-3 h-9 flex items-center">
+        <span className="text-xs font-mono text-muted-foreground">
+          versions
+        </span>
+      </div>
+      <div
+        className={`flex-1 p-2 min-h-0 ${versions.length > 0 ? "overflow-y-auto space-y-1" : "flex"}`}
+      >
+        {versions.length === 0 ? (
+          <div className="flex-1 flex flex-col items-center justify-center text-center px-4">
+            <p className="text-sm text-muted-foreground mb-4">
+              Describe what you want to build, then iterate on it.
+            </p>
+            <div className="flex flex-wrap gap-2 justify-center">
+              {EXAMPLE_PROMPTS.map((prompt) => (
+                <button
+                  key={prompt}
+                  onClick={() => {
+                    setInputValue(prompt);
+                    setTimeout(() => {
+                      if (inputRef.current) {
+                        inputRef.current.focus();
+                        inputRef.current.setSelectionRange(
+                          prompt.length,
+                          prompt.length,
+                        );
+                      }
+                    }, 0);
+                  }}
+                  className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
+                >
+                  {prompt}
+                </button>
+              ))}
+            </div>
+          </div>
+        ) : (
+          versions.map((version, index) => (
+            <button
+              key={version.id}
+              onClick={() => setSelectedVersionId(version.id)}
+              className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
+                selectedVersionId === version.id
+                  ? "bg-muted text-foreground"
+                  : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
+              }`}
+            >
+              <div className="flex items-center gap-2">
+                <span className="text-xs font-mono text-muted-foreground/70 shrink-0">
+                  v{index + 1}
+                </span>
+                <span className="truncate flex-1">{version.prompt}</span>
+                {version.status === "generating" && (
+                  <span className="text-xs text-muted-foreground shrink-0 animate-pulse">
+                    ...
+                  </span>
+                )}
+              </div>
+            </button>
+          ))
+        )}
+        <div ref={versionsEndRef} />
+      </div>
+      <div
+        className="border-t border-border p-3 cursor-text"
+        onMouseDown={(e) => {
+          // 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();
+          }
+        }}
+      >
+        <textarea
+          ref={inputRef}
+          value={inputValue}
+          onChange={(e) => setInputValue(e.target.value)}
+          onKeyDown={handleKeyDown}
+          placeholder="Describe changes..."
+          className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
+          rows={2}
+        />
+        <div className="flex justify-between items-center mt-2">
+          {versions.length > 0 ? (
+            <button
+              onClick={() => {
+                setVersions([]);
+                setSelectedVersionId(null);
+                setStreamLines([]);
+                clear();
+              }}
+              className="text-xs text-muted-foreground hover:text-foreground transition-colors"
+            >
+              Clear
+            </button>
+          ) : (
+            <div />
+          )}
+          {isStreaming ? (
+            <button
+              onClick={() => clear()}
+              className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
+              aria-label="Stop"
+            >
+              <svg
+                width="14"
+                height="14"
+                viewBox="0 0 24 24"
+                fill="currentColor"
+                stroke="none"
+              >
+                <rect x="6" y="6" width="12" height="12" />
+              </svg>
+            </button>
+          ) : (
+            <button
+              onClick={handleSubmit}
+              disabled={!inputValue.trim()}
+              className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
+              aria-label="Send"
+            >
+              <svg
+                width="14"
+                height="14"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                strokeWidth="2"
+                strokeLinecap="round"
+                strokeLinejoin="round"
+              >
+                <path d="M5 12h14" />
+                <path d="m12 5 7 7-7 7" />
+              </svg>
+            </button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+
+  // Code pane content
+  const codePane = (
+    <div className="h-full flex flex-col border-t border-border">
+      <div className="border-b border-border px-3 h-9 flex items-center gap-3">
+        {(["json", "stream"] as const).map((tab) => (
+          <button
+            key={tab}
+            onClick={() => setActiveTab(tab)}
+            className={`text-xs font-mono transition-colors ${
+              activeTab === tab
+                ? "text-foreground"
+                : "text-muted-foreground hover:text-foreground"
+            }`}
+          >
+            {tab}
+          </button>
+        ))}
+        <div className="flex-1" />
+        <CopyButton
+          text={activeTab === "stream" ? streamLines.join("\n") : jsonCode}
+          className="text-muted-foreground"
+        />
+      </div>
+      <div className="flex-1 overflow-auto">
+        {activeTab === "stream" ? (
+          streamLines.length > 0 ? (
+            <CodeBlock
+              code={streamLines.join("\n")}
+              lang="json"
+              fillHeight
+              hideCopyButton
+            />
+          ) : (
+            <div className="text-muted-foreground/50 p-3 text-sm font-mono">
+              {isStreaming ? "streaming..." : "// waiting for generation"}
+            </div>
+          )
+        ) : (
+          <CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
+        )}
+      </div>
+    </div>
+  );
+
+  // Preview pane content
+  const previewPane = (
+    <div className="h-full flex flex-col border-t border-border">
+      <div className="border-b border-border px-3 h-9 flex items-center gap-3">
+        {(
+          [
+            { key: "preview", label: "preview" },
+            { key: "code", label: "code" },
+          ] as const
+        ).map(({ key, label }) => (
+          <button
+            key={key}
+            onClick={() => setRenderView(key)}
+            className={`text-xs font-mono transition-colors ${
+              renderView === key
+                ? "text-foreground"
+                : "text-muted-foreground hover:text-foreground"
+            }`}
+          >
+            {label}
+          </button>
+        ))}
+        <div className="flex-1" />
+        {renderView === "code" && (
+          <CopyButton text={generatedCode} className="text-muted-foreground" />
+        )}
+      </div>
+      <div className="flex-1 overflow-auto">
+        {renderView === "preview" ? (
+          currentTree && currentTree.root ? (
+            <div className="w-full min-h-full flex items-center justify-center p-6">
+              <JSONUIProvider
+                registry={
+                  demoRegistry as Parameters<
+                    typeof JSONUIProvider
+                  >[0]["registry"]
+                }
+              >
+                <Renderer
+                  tree={currentTree!}
+                  registry={
+                    demoRegistry as Parameters<typeof Renderer>[0]["registry"]
+                  }
+                  loading={isStreaming}
+                  fallback={
+                    fallbackComponent as Parameters<
+                      typeof Renderer
+                    >[0]["fallback"]
+                  }
+                />
+              </JSONUIProvider>
+            </div>
+          ) : (
+            <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
+              {isStreaming
+                ? "generating..."
+                : "// enter a prompt to generate UI"}
+            </div>
+          )
+        ) : (
+          <CodeBlock
+            code={generatedCode}
+            lang="tsx"
+            fillHeight
+            hideCopyButton
+          />
+        )}
+      </div>
+    </div>
+  );
+
+  return (
+    <div className="h-full flex flex-col">
+      <Header />
+
+      {/* Desktop: 3-pane resizable layout */}
+      <div className="hidden lg:flex flex-1 min-h-0">
+        <ResizablePanelGroup className="flex-1">
+          <ResizablePanel defaultSize={25} minSize={15}>
+            {chatPane}
+          </ResizablePanel>
+          <ResizableHandle />
+          <ResizablePanel defaultSize={35} minSize={20}>
+            {codePane}
+          </ResizablePanel>
+          <ResizableHandle />
+          <ResizablePanel defaultSize={40} minSize={20}>
+            {previewPane}
+          </ResizablePanel>
+        </ResizablePanelGroup>
+      </div>
+
+      {/* Mobile: Single pane with bottom tabs */}
+      <div className="flex lg:hidden flex-col flex-1 min-h-0">
+        {/* Panes - all in DOM, visibility controlled */}
+        <div className="flex-1 min-h-0 relative">
+          <div
+            className={`absolute inset-0 ${mobilePane === "chat" ? "" : "invisible"}`}
+          >
+            {chatPane}
+          </div>
+          <div
+            className={`absolute inset-0 ${mobilePane === "code" ? "" : "invisible"}`}
+          >
+            {codePane}
+          </div>
+          <div
+            className={`absolute inset-0 ${mobilePane === "preview" ? "" : "invisible"}`}
+          >
+            {previewPane}
+          </div>
+        </div>
+
+        {/* Bottom tab bar */}
+        <div className="border-t border-border flex shrink-0">
+          {(
+            [
+              {
+                key: "chat",
+                label: "Chat",
+                icon: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
+              },
+              {
+                key: "code",
+                label: "Code",
+                icon: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
+              },
+              {
+                key: "preview",
+                label: "Preview",
+                icon: "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
+              },
+            ] as const
+          ).map(({ key, label, icon }) => (
+            <button
+              key={key}
+              onClick={() => setMobilePane(key)}
+              className={`flex-1 py-3 flex flex-col items-center gap-1 transition-colors ${
+                mobilePane === key ? "text-foreground" : "text-muted-foreground"
+              }`}
+            >
+              <svg
+                className="w-5 h-5"
+                fill="none"
+                stroke="currentColor"
+                viewBox="0 0 24 24"
+                strokeWidth={1.5}
+              >
+                <path strokeLinecap="round" strokeLinejoin="round" d={icon} />
+              </svg>
+              <span className="text-xs">{label}</span>
+            </button>
+          ))}
+        </div>
+      </div>
+
+      <Toaster position="bottom-right" />
+    </div>
+  );
+}

+ 54 - 0
apps/web/components/ui/resizable.tsx

@@ -0,0 +1,54 @@
+"use client";
+
+import * as React from "react";
+import { GripVerticalIcon } from "lucide-react";
+import { Group, Panel, Separator } from "react-resizable-panels";
+
+import { cn } from "@/lib/utils";
+
+function ResizablePanelGroup({
+  className,
+  ...props
+}: React.ComponentProps<typeof Group>) {
+  return (
+    <Group
+      data-slot="resizable-panel-group"
+      className={cn(
+        "flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function ResizablePanel({ ...props }: React.ComponentProps<typeof Panel>) {
+  return <Panel data-slot="resizable-panel" {...props} />;
+}
+
+function ResizableHandle({
+  withHandle,
+  className,
+  ...props
+}: React.ComponentProps<typeof Separator> & {
+  withHandle?: boolean;
+}) {
+  return (
+    <Separator
+      data-slot="resizable-handle"
+      className={cn(
+        "bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
+        className,
+      )}
+      {...props}
+    >
+      {withHandle && (
+        <div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
+          <GripVerticalIcon className="size-2.5" />
+        </div>
+      )}
+    </Separator>
+  );
+}
+
+export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

+ 1 - 0
apps/web/package.json

@@ -29,6 +29,7 @@
     "next-themes": "^0.4.6",
     "react": "19.2.3",
     "react-dom": "19.2.3",
+    "react-resizable-panels": "^4.4.1",
     "shiki": "^3.21.0",
     "sonner": "^2.0.7",
     "tailwind-merge": "^3.4.0",

+ 6 - 2
packages/react/src/hooks.ts

@@ -132,8 +132,12 @@ export function useUIStream({
       setIsStreaming(true);
       setError(null);
 
-      // Start with an empty tree
-      let currentTree: UITree = { root: "", elements: {} };
+      // Start with previous tree if provided, otherwise empty tree
+      const previousTree = context?.previousTree as UITree | undefined;
+      let currentTree: UITree =
+        previousTree && previousTree.root
+          ? { ...previousTree, elements: { ...previousTree.elements } }
+          : { root: "", elements: {} };
       setTree(currentTree);
 
       try {

+ 14 - 0
pnpm-lock.yaml

@@ -89,6 +89,9 @@ importers:
       react-dom:
         specifier: 19.2.3
         version: 19.2.3(react@19.2.3)
+      react-resizable-panels:
+        specifier: ^4.4.1
+        version: 4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       shiki:
         specifier: ^3.21.0
         version: 3.21.0
@@ -2700,6 +2703,12 @@ packages:
       '@types/react':
         optional: true
 
+  react-resizable-panels@4.4.1:
+    resolution: {integrity: sha512-dpM9oI6rGlAq7VYDeafSRA1JmkJv8aNuKySR+tZLQQLfaeqTnQLSM52EcoI/QdowzsjVUCk6jViKS0xHWITVRQ==}
+    peerDependencies:
+      react: ^18.0.0 || ^19.0.0
+      react-dom: ^18.0.0 || ^19.0.0
+
   react-style-singleton@2.2.3:
     resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
     engines: {node: '>=10'}
@@ -5621,6 +5630,11 @@ snapshots:
     optionalDependencies:
       '@types/react': 19.2.3
 
+  react-resizable-panels@4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+    dependencies:
+      react: 19.2.3
+      react-dom: 19.2.3(react@19.2.3)
+
   react-style-singleton@2.2.3(@types/react@19.2.3)(react@19.2.3):
     dependencies:
       get-nonce: 1.0.1