playground.tsx 19 KB

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