playground.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. "use client";
  2. import { useEffect, useState, useCallback, useRef, useMemo } from "react";
  3. import { Renderer, useUIStream, JSONUIProvider } from "@json-render/react";
  4. import type { UITree } from "@json-render/core";
  5. import { collectUsedComponents, serializeProps } from "@json-render/codegen";
  6. import { toast } from "sonner";
  7. import {
  8. ResizablePanelGroup,
  9. ResizablePanel,
  10. ResizableHandle,
  11. } from "@/components/ui/resizable";
  12. import { CodeBlock } from "./code-block";
  13. import { CopyButton } from "./copy-button";
  14. import { Toaster } from "./ui/sonner";
  15. import { Header } from "./header";
  16. import {
  17. demoRegistry,
  18. fallbackComponent,
  19. useInteractiveState,
  20. } from "./demo/index";
  21. type Tab = "json" | "stream";
  22. type RenderView = "preview" | "code";
  23. type MobilePane = "chat" | "code" | "preview";
  24. interface Version {
  25. id: string;
  26. prompt: string;
  27. tree: UITree | null;
  28. status: "generating" | "complete" | "error";
  29. }
  30. const EXAMPLE_PROMPTS = [
  31. "Create a login form",
  32. "Build a pricing page",
  33. "Design a user profile card",
  34. "Make a contact form",
  35. ];
  36. export function Playground() {
  37. const [versions, setVersions] = useState<Version[]>([]);
  38. const [selectedVersionId, setSelectedVersionId] = useState<string | null>(
  39. null,
  40. );
  41. const [inputValue, setInputValue] = useState("");
  42. const [streamLines, setStreamLines] = useState<string[]>([]);
  43. const [activeTab, setActiveTab] = useState<Tab>("json");
  44. const [renderView, setRenderView] = useState<RenderView>("preview");
  45. const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
  46. const inputRef = useRef<HTMLTextAreaElement>(null);
  47. const versionsEndRef = useRef<HTMLDivElement>(null);
  48. // Track the currently generating version ID
  49. const generatingVersionIdRef = useRef<string | null>(null);
  50. // Track the current tree for use as previousTree in next generation
  51. const currentTreeRef = useRef<UITree | null>(null);
  52. const {
  53. tree: apiTree,
  54. isStreaming,
  55. send,
  56. clear,
  57. } = useUIStream({
  58. api: "/api/generate",
  59. onError: (err: Error) => {
  60. console.error("Generation error:", err);
  61. toast.error(err.message || "Generation failed. Please try again.");
  62. // Mark the version as errored
  63. if (generatingVersionIdRef.current) {
  64. const erroredVersionId = generatingVersionIdRef.current;
  65. setVersions((prev) =>
  66. prev.map((v) =>
  67. v.id === erroredVersionId ? { ...v, status: "error" as const } : v,
  68. ),
  69. );
  70. generatingVersionIdRef.current = null;
  71. }
  72. },
  73. } as Parameters<typeof useUIStream>[0]);
  74. useInteractiveState();
  75. // Get the selected version
  76. const selectedVersion = versions.find((v) => v.id === selectedVersionId);
  77. // Determine which tree to display:
  78. // - If streaming and selected version is the generating one, show apiTree
  79. // - Otherwise show the selected version's tree
  80. const isSelectedVersionGenerating =
  81. selectedVersionId === generatingVersionIdRef.current && isStreaming;
  82. const hasValidApiTree =
  83. apiTree && apiTree.root && Object.keys(apiTree.elements).length > 0;
  84. const currentTree =
  85. isSelectedVersionGenerating && hasValidApiTree
  86. ? apiTree
  87. : (selectedVersion?.tree ??
  88. (isSelectedVersionGenerating ? apiTree : null));
  89. // Keep the ref updated with the current tree for use in handleSubmit
  90. if (
  91. currentTree &&
  92. currentTree.root &&
  93. Object.keys(currentTree.elements).length > 0
  94. ) {
  95. currentTreeRef.current = currentTree;
  96. }
  97. // Scroll to bottom when versions change
  98. useEffect(() => {
  99. versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
  100. }, [versions]);
  101. useEffect(() => {
  102. if (apiTree) {
  103. const streamLine = JSON.stringify({ tree: apiTree });
  104. if (
  105. !streamLines.includes(streamLine) &&
  106. Object.keys(apiTree.elements).length > 0
  107. ) {
  108. setStreamLines((prev) => {
  109. const lastLine = prev[prev.length - 1];
  110. if (lastLine !== streamLine) {
  111. return [...prev, streamLine];
  112. }
  113. return prev;
  114. });
  115. }
  116. }
  117. }, [apiTree, streamLines]);
  118. // Update version when streaming completes
  119. useEffect(() => {
  120. if (
  121. !isStreaming &&
  122. apiTree &&
  123. apiTree.root &&
  124. generatingVersionIdRef.current
  125. ) {
  126. const completedVersionId = generatingVersionIdRef.current;
  127. setVersions((prev) =>
  128. prev.map((v) =>
  129. v.id === completedVersionId
  130. ? { ...v, tree: apiTree, status: "complete" as const }
  131. : v,
  132. ),
  133. );
  134. generatingVersionIdRef.current = null;
  135. }
  136. }, [isStreaming, apiTree]);
  137. const handleSubmit = useCallback(async () => {
  138. if (!inputValue.trim() || isStreaming) return;
  139. const newVersionId = Date.now().toString();
  140. const newVersion: Version = {
  141. id: newVersionId,
  142. prompt: inputValue.trim(),
  143. tree: null,
  144. status: "generating",
  145. };
  146. generatingVersionIdRef.current = newVersionId;
  147. setVersions((prev) => [...prev, newVersion]);
  148. setSelectedVersionId(newVersionId);
  149. setInputValue("");
  150. setStreamLines([]); // Reset stream lines for new generation
  151. // Pass the current tree as context so the API can iterate on it
  152. await send(inputValue.trim(), { previousTree: currentTreeRef.current });
  153. }, [inputValue, isStreaming, send]);
  154. const handleKeyDown = useCallback(
  155. (e: React.KeyboardEvent) => {
  156. if (e.key === "Enter" && !e.shiftKey) {
  157. e.preventDefault();
  158. handleSubmit();
  159. }
  160. },
  161. [handleSubmit],
  162. );
  163. useEffect(() => {
  164. (
  165. window as unknown as { __demoAction?: (text: string) => void }
  166. ).__demoAction = (text: string) => {
  167. toast(text);
  168. };
  169. return () => {
  170. delete (window as unknown as { __demoAction?: (text: string) => void })
  171. .__demoAction;
  172. };
  173. }, []);
  174. const jsonCode = currentTree
  175. ? JSON.stringify(currentTree, null, 2)
  176. : "// waiting...";
  177. const generatedCode = useMemo(() => {
  178. if (!currentTree || !currentTree.root) {
  179. return "// Generate a UI to see the code";
  180. }
  181. const tree = currentTree;
  182. const components = collectUsedComponents(tree);
  183. function generateJSX(key: string, indent: number): string {
  184. const element = tree.elements[key];
  185. if (!element) return "";
  186. const spaces = " ".repeat(indent);
  187. const componentName = element.type;
  188. const propsObj: Record<string, unknown> = {};
  189. for (const [k, v] of Object.entries(element.props)) {
  190. if (v !== null && v !== undefined) {
  191. propsObj[k] = v;
  192. }
  193. }
  194. const propsStr = serializeProps(propsObj);
  195. const hasChildren = element.children && element.children.length > 0;
  196. if (!hasChildren) {
  197. return propsStr
  198. ? `${spaces}<${componentName} ${propsStr} />`
  199. : `${spaces}<${componentName} />`;
  200. }
  201. const lines: string[] = [];
  202. lines.push(
  203. propsStr
  204. ? `${spaces}<${componentName} ${propsStr}>`
  205. : `${spaces}<${componentName}>`,
  206. );
  207. for (const childKey of element.children!) {
  208. lines.push(generateJSX(childKey, indent + 1));
  209. }
  210. lines.push(`${spaces}</${componentName}>`);
  211. return lines.join("\n");
  212. }
  213. const jsx = generateJSX(tree.root, 2);
  214. const imports = Array.from(components).sort().join(", ");
  215. return `"use client";
  216. import { ${imports} } from "@/components/ui";
  217. export default function Page() {
  218. return (
  219. <div className="min-h-screen p-8 flex items-center justify-center">
  220. ${jsx}
  221. </div>
  222. );
  223. }`;
  224. }, [currentTree]);
  225. // Chat pane content
  226. const chatPane = (
  227. <div className="h-full flex flex-col border-t border-border">
  228. <div className="border-b border-border px-3 h-9 flex items-center">
  229. <span className="text-xs font-mono text-muted-foreground">
  230. versions
  231. </span>
  232. </div>
  233. <div
  234. className={`flex-1 p-2 min-h-0 ${versions.length > 0 ? "overflow-y-auto space-y-1" : "flex"}`}
  235. >
  236. {versions.length === 0 ? (
  237. <div className="flex-1 flex flex-col items-center justify-center text-center px-4">
  238. <p className="text-sm text-muted-foreground mb-4">
  239. Describe what you want to build, then iterate on it.
  240. </p>
  241. <div className="flex flex-wrap gap-2 justify-center">
  242. {EXAMPLE_PROMPTS.map((prompt) => (
  243. <button
  244. key={prompt}
  245. onClick={() => {
  246. setInputValue(prompt);
  247. setTimeout(() => {
  248. if (inputRef.current) {
  249. inputRef.current.focus();
  250. inputRef.current.setSelectionRange(
  251. prompt.length,
  252. prompt.length,
  253. );
  254. }
  255. }, 0);
  256. }}
  257. className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
  258. >
  259. {prompt}
  260. </button>
  261. ))}
  262. </div>
  263. </div>
  264. ) : (
  265. versions.map((version, index) => (
  266. <button
  267. key={version.id}
  268. onClick={() => setSelectedVersionId(version.id)}
  269. className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
  270. selectedVersionId === version.id
  271. ? "bg-muted text-foreground"
  272. : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
  273. }`}
  274. >
  275. <div className="flex items-center gap-2">
  276. <span className="text-xs font-mono text-muted-foreground/70 shrink-0">
  277. v{index + 1}
  278. </span>
  279. <span className="truncate flex-1">{version.prompt}</span>
  280. {version.status === "generating" && (
  281. <span className="text-xs text-muted-foreground shrink-0 animate-pulse">
  282. ...
  283. </span>
  284. )}
  285. {version.status === "error" && (
  286. <span className="text-xs text-red-500 shrink-0">failed</span>
  287. )}
  288. </div>
  289. </button>
  290. ))
  291. )}
  292. <div ref={versionsEndRef} />
  293. </div>
  294. <div
  295. className="border-t border-border p-3 cursor-text"
  296. onMouseDown={(e) => {
  297. // Focus textarea unless clicking a button or the textarea itself
  298. const target = e.target as HTMLElement;
  299. if (!target.closest("button") && target.tagName !== "TEXTAREA") {
  300. e.preventDefault();
  301. inputRef.current?.focus();
  302. }
  303. }}
  304. >
  305. <textarea
  306. ref={inputRef}
  307. value={inputValue}
  308. onChange={(e) => setInputValue(e.target.value)}
  309. onKeyDown={handleKeyDown}
  310. placeholder="Describe changes..."
  311. className="w-full bg-background text-base sm:text-sm resize-none outline-none placeholder:text-muted-foreground/50"
  312. rows={2}
  313. />
  314. <div className="flex justify-between items-center mt-2">
  315. {versions.length > 0 ? (
  316. <button
  317. onClick={() => {
  318. setVersions([]);
  319. setSelectedVersionId(null);
  320. setStreamLines([]);
  321. clear();
  322. }}
  323. className="text-xs text-muted-foreground hover:text-foreground transition-colors"
  324. >
  325. Clear
  326. </button>
  327. ) : (
  328. <div />
  329. )}
  330. {isStreaming ? (
  331. <button
  332. onClick={() => clear()}
  333. className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
  334. aria-label="Stop"
  335. >
  336. <svg
  337. width="14"
  338. height="14"
  339. viewBox="0 0 24 24"
  340. fill="currentColor"
  341. stroke="none"
  342. >
  343. <rect x="6" y="6" width="12" height="12" />
  344. </svg>
  345. </button>
  346. ) : (
  347. <button
  348. onClick={handleSubmit}
  349. disabled={!inputValue.trim()}
  350. 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"
  351. aria-label="Send"
  352. >
  353. <svg
  354. width="14"
  355. height="14"
  356. viewBox="0 0 24 24"
  357. fill="none"
  358. stroke="currentColor"
  359. strokeWidth="2"
  360. strokeLinecap="round"
  361. strokeLinejoin="round"
  362. >
  363. <path d="M5 12h14" />
  364. <path d="m12 5 7 7-7 7" />
  365. </svg>
  366. </button>
  367. )}
  368. </div>
  369. </div>
  370. </div>
  371. );
  372. // Code pane content
  373. const codePane = (
  374. <div className="h-full flex flex-col border-t border-border">
  375. <div className="border-b border-border px-3 h-9 flex items-center gap-3">
  376. {(["json", "stream"] as const).map((tab) => (
  377. <button
  378. key={tab}
  379. onClick={() => setActiveTab(tab)}
  380. className={`text-xs font-mono transition-colors ${
  381. activeTab === tab
  382. ? "text-foreground"
  383. : "text-muted-foreground hover:text-foreground"
  384. }`}
  385. >
  386. {tab}
  387. </button>
  388. ))}
  389. <div className="flex-1" />
  390. <CopyButton
  391. text={activeTab === "stream" ? streamLines.join("\n") : jsonCode}
  392. className="text-muted-foreground"
  393. />
  394. </div>
  395. <div className="flex-1 overflow-auto">
  396. {activeTab === "stream" ? (
  397. streamLines.length > 0 ? (
  398. <CodeBlock
  399. code={streamLines.join("\n")}
  400. lang="json"
  401. fillHeight
  402. hideCopyButton
  403. />
  404. ) : (
  405. <div className="text-muted-foreground/50 p-3 text-sm font-mono">
  406. {isStreaming ? "streaming..." : "// waiting for generation"}
  407. </div>
  408. )
  409. ) : (
  410. <CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
  411. )}
  412. </div>
  413. </div>
  414. );
  415. // Preview pane content
  416. const previewPane = (
  417. <div className="h-full flex flex-col border-t border-border">
  418. <div className="border-b border-border px-3 h-9 flex items-center gap-3">
  419. {(
  420. [
  421. { key: "preview", label: "preview" },
  422. { key: "code", label: "code" },
  423. ] as const
  424. ).map(({ key, label }) => (
  425. <button
  426. key={key}
  427. onClick={() => setRenderView(key)}
  428. className={`text-xs font-mono transition-colors ${
  429. renderView === key
  430. ? "text-foreground"
  431. : "text-muted-foreground hover:text-foreground"
  432. }`}
  433. >
  434. {label}
  435. </button>
  436. ))}
  437. <div className="flex-1" />
  438. {renderView === "code" && (
  439. <CopyButton text={generatedCode} className="text-muted-foreground" />
  440. )}
  441. </div>
  442. <div className="flex-1 overflow-auto">
  443. {renderView === "preview" ? (
  444. currentTree && currentTree.root ? (
  445. <div className="w-full min-h-full flex items-center justify-center p-6">
  446. <JSONUIProvider
  447. registry={
  448. demoRegistry as Parameters<
  449. typeof JSONUIProvider
  450. >[0]["registry"]
  451. }
  452. >
  453. <Renderer
  454. tree={currentTree!}
  455. registry={
  456. demoRegistry as Parameters<typeof Renderer>[0]["registry"]
  457. }
  458. loading={isStreaming}
  459. fallback={
  460. fallbackComponent as Parameters<
  461. typeof Renderer
  462. >[0]["fallback"]
  463. }
  464. />
  465. </JSONUIProvider>
  466. </div>
  467. ) : (
  468. <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
  469. {isStreaming
  470. ? "generating..."
  471. : "// enter a prompt to generate UI"}
  472. </div>
  473. )
  474. ) : (
  475. <CodeBlock
  476. code={generatedCode}
  477. lang="tsx"
  478. fillHeight
  479. hideCopyButton
  480. />
  481. )}
  482. </div>
  483. </div>
  484. );
  485. return (
  486. <div className="h-full flex flex-col">
  487. <Header />
  488. {/* Desktop: 3-pane resizable layout */}
  489. <div className="hidden lg:flex flex-1 min-h-0">
  490. <ResizablePanelGroup className="flex-1">
  491. <ResizablePanel defaultSize={25} minSize={15}>
  492. {chatPane}
  493. </ResizablePanel>
  494. <ResizableHandle />
  495. <ResizablePanel defaultSize={35} minSize={20}>
  496. {codePane}
  497. </ResizablePanel>
  498. <ResizableHandle />
  499. <ResizablePanel defaultSize={40} minSize={20}>
  500. {previewPane}
  501. </ResizablePanel>
  502. </ResizablePanelGroup>
  503. </div>
  504. {/* Mobile: Single pane with bottom tabs */}
  505. <div className="flex lg:hidden flex-col flex-1 min-h-0">
  506. {/* Panes - all in DOM, visibility controlled */}
  507. <div className="flex-1 min-h-0 relative">
  508. <div
  509. className={`absolute inset-0 ${mobilePane === "chat" ? "" : "invisible"}`}
  510. >
  511. {chatPane}
  512. </div>
  513. <div
  514. className={`absolute inset-0 ${mobilePane === "code" ? "" : "invisible"}`}
  515. >
  516. {codePane}
  517. </div>
  518. <div
  519. className={`absolute inset-0 ${mobilePane === "preview" ? "" : "invisible"}`}
  520. >
  521. {previewPane}
  522. </div>
  523. </div>
  524. {/* Bottom tab bar */}
  525. <div className="border-t border-border flex shrink-0">
  526. {(
  527. [
  528. {
  529. key: "chat",
  530. label: "Chat",
  531. 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",
  532. },
  533. {
  534. key: "code",
  535. label: "Code",
  536. icon: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
  537. },
  538. {
  539. key: "preview",
  540. label: "Preview",
  541. 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",
  542. },
  543. ] as const
  544. ).map(({ key, label, icon }) => (
  545. <button
  546. key={key}
  547. onClick={() => setMobilePane(key)}
  548. className={`flex-1 py-3 flex flex-col items-center gap-1 transition-colors ${
  549. mobilePane === key ? "text-foreground" : "text-muted-foreground"
  550. }`}
  551. >
  552. <svg
  553. className="w-5 h-5"
  554. fill="none"
  555. stroke="currentColor"
  556. viewBox="0 0 24 24"
  557. strokeWidth={1.5}
  558. >
  559. <path strokeLinecap="round" strokeLinejoin="round" d={icon} />
  560. </svg>
  561. <span className="text-xs">{label}</span>
  562. </button>
  563. ))}
  564. </div>
  565. </div>
  566. <Toaster position="bottom-right" />
  567. </div>
  568. );
  569. }