playground.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. "use client";
  2. import { useEffect, useState, useCallback, useRef, useMemo } from "react";
  3. import { useUIStream } from "@json-render/react";
  4. import type { Spec } 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 { PlaygroundRenderer } from "@/lib/renderer";
  17. type Tab = "json" | "stream";
  18. type RenderView = "preview" | "code";
  19. type MobilePane = "chat" | "code" | "preview";
  20. interface Version {
  21. id: string;
  22. prompt: string;
  23. tree: Spec | null;
  24. status: "generating" | "complete" | "error";
  25. }
  26. const EXAMPLE_PROMPTS = [
  27. "Create a login form",
  28. "Build a pricing page",
  29. "Design a user profile card",
  30. "Make a contact form",
  31. ];
  32. export function Playground() {
  33. const [versions, setVersions] = useState<Version[]>([]);
  34. const [selectedVersionId, setSelectedVersionId] = useState<string | null>(
  35. null,
  36. );
  37. const [inputValue, setInputValue] = useState("");
  38. const [streamLines, setStreamLines] = useState<string[]>([]);
  39. const [activeTab, setActiveTab] = useState<Tab>("json");
  40. const [renderView, setRenderView] = useState<RenderView>("preview");
  41. const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
  42. const inputRef = useRef<HTMLTextAreaElement>(null);
  43. const versionsEndRef = useRef<HTMLDivElement>(null);
  44. // Track the currently generating version ID
  45. const generatingVersionIdRef = useRef<string | null>(null);
  46. // Track the current tree for use as previousSpec in next generation
  47. const currentTreeRef = useRef<Spec | null>(null);
  48. const {
  49. spec: apiSpec,
  50. isStreaming,
  51. send,
  52. clear,
  53. } = useUIStream({
  54. api: "/api/generate",
  55. onError: (err: Error) => {
  56. console.error("Generation error:", err);
  57. toast.error(err.message || "Generation failed. Please try again.");
  58. // Mark the version as errored
  59. if (generatingVersionIdRef.current) {
  60. const erroredVersionId = generatingVersionIdRef.current;
  61. setVersions((prev) =>
  62. prev.map((v) =>
  63. v.id === erroredVersionId ? { ...v, status: "error" as const } : v,
  64. ),
  65. );
  66. generatingVersionIdRef.current = null;
  67. }
  68. },
  69. } as Parameters<typeof useUIStream>[0]);
  70. // Get the selected version
  71. const selectedVersion = versions.find((v) => v.id === selectedVersionId);
  72. // Determine which tree to display:
  73. // - If streaming and selected version is the generating one, show apiSpec
  74. // - Otherwise show the selected version's tree
  75. const isSelectedVersionGenerating =
  76. selectedVersionId === generatingVersionIdRef.current && isStreaming;
  77. const hasValidApiTree =
  78. apiSpec && apiSpec.root && Object.keys(apiSpec.elements).length > 0;
  79. const currentTree =
  80. isSelectedVersionGenerating && hasValidApiTree
  81. ? apiSpec
  82. : (selectedVersion?.tree ??
  83. (isSelectedVersionGenerating ? apiSpec : null));
  84. // Keep the ref updated with the current tree for use in handleSubmit
  85. if (
  86. currentTree &&
  87. currentTree.root &&
  88. Object.keys(currentTree.elements).length > 0
  89. ) {
  90. currentTreeRef.current = currentTree;
  91. }
  92. // Scroll to bottom when versions change
  93. useEffect(() => {
  94. versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
  95. }, [versions]);
  96. useEffect(() => {
  97. if (apiSpec) {
  98. const streamLine = JSON.stringify({ tree: apiSpec });
  99. if (
  100. !streamLines.includes(streamLine) &&
  101. Object.keys(apiSpec.elements).length > 0
  102. ) {
  103. setStreamLines((prev) => {
  104. const lastLine = prev[prev.length - 1];
  105. if (lastLine !== streamLine) {
  106. return [...prev, streamLine];
  107. }
  108. return prev;
  109. });
  110. }
  111. }
  112. }, [apiSpec, streamLines]);
  113. // Update version when streaming completes
  114. useEffect(() => {
  115. if (
  116. !isStreaming &&
  117. apiSpec &&
  118. apiSpec.root &&
  119. generatingVersionIdRef.current
  120. ) {
  121. const completedVersionId = generatingVersionIdRef.current;
  122. setVersions((prev) =>
  123. prev.map((v) =>
  124. v.id === completedVersionId
  125. ? { ...v, tree: apiSpec, status: "complete" as const }
  126. : v,
  127. ),
  128. );
  129. generatingVersionIdRef.current = null;
  130. }
  131. }, [isStreaming, apiSpec]);
  132. const handleSubmit = useCallback(async () => {
  133. if (!inputValue.trim() || isStreaming) return;
  134. const newVersionId = Date.now().toString();
  135. const newVersion: Version = {
  136. id: newVersionId,
  137. prompt: inputValue.trim(),
  138. tree: null,
  139. status: "generating",
  140. };
  141. generatingVersionIdRef.current = newVersionId;
  142. setVersions((prev) => [...prev, newVersion]);
  143. setSelectedVersionId(newVersionId);
  144. setInputValue("");
  145. setStreamLines([]); // Reset stream lines for new generation
  146. // Pass the current tree as context so the API can iterate on it
  147. await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
  148. }, [inputValue, isStreaming, send]);
  149. const handleKeyDown = useCallback(
  150. (e: React.KeyboardEvent) => {
  151. if (e.key === "Enter" && !e.shiftKey) {
  152. e.preventDefault();
  153. handleSubmit();
  154. }
  155. },
  156. [handleSubmit],
  157. );
  158. const jsonCode = currentTree
  159. ? JSON.stringify(currentTree, null, 2)
  160. : "// waiting...";
  161. const generatedCode = useMemo(() => {
  162. if (!currentTree || !currentTree.root) {
  163. return "// Generate a UI to see the code";
  164. }
  165. const tree = currentTree;
  166. const components = collectUsedComponents(tree);
  167. function generateJSX(key: string, indent: number): string {
  168. const element = tree.elements[key];
  169. if (!element) return "";
  170. const spaces = " ".repeat(indent);
  171. const componentName = element.type;
  172. const propsObj: Record<string, unknown> = {};
  173. for (const [k, v] of Object.entries(element.props)) {
  174. if (v !== null && v !== undefined) {
  175. propsObj[k] = v;
  176. }
  177. }
  178. const propsStr = serializeProps(propsObj);
  179. const hasChildren = element.children && element.children.length > 0;
  180. if (!hasChildren) {
  181. return propsStr
  182. ? `${spaces}<${componentName} ${propsStr} />`
  183. : `${spaces}<${componentName} />`;
  184. }
  185. const lines: string[] = [];
  186. lines.push(
  187. propsStr
  188. ? `${spaces}<${componentName} ${propsStr}>`
  189. : `${spaces}<${componentName}>`,
  190. );
  191. for (const childKey of element.children!) {
  192. lines.push(generateJSX(childKey, indent + 1));
  193. }
  194. lines.push(`${spaces}</${componentName}>`);
  195. return lines.join("\n");
  196. }
  197. const jsx = generateJSX(tree.root, 2);
  198. const imports = Array.from(components).sort().join(", ");
  199. return `"use client";
  200. import { ${imports} } from "@/components/ui";
  201. export default function Page() {
  202. return (
  203. <div className="min-h-screen p-8 flex items-center justify-center">
  204. ${jsx}
  205. </div>
  206. );
  207. }`;
  208. }, [currentTree]);
  209. // Chat pane content
  210. const chatPane = (
  211. <div className="h-full flex flex-col border-t border-border">
  212. <div className="border-b border-border px-3 h-9 flex items-center">
  213. <span className="text-xs font-mono text-muted-foreground">
  214. versions
  215. </span>
  216. </div>
  217. <div
  218. className={`flex-1 p-2 min-h-0 ${versions.length > 0 ? "overflow-y-auto space-y-1" : "flex"}`}
  219. >
  220. {versions.length === 0 ? (
  221. <div className="flex-1 flex flex-col items-center justify-center text-center px-4">
  222. <p className="text-sm text-muted-foreground mb-4">
  223. Describe what you want to build, then iterate on it.
  224. </p>
  225. <div className="flex flex-wrap gap-2 justify-center">
  226. {EXAMPLE_PROMPTS.map((prompt) => (
  227. <button
  228. key={prompt}
  229. onClick={() => {
  230. setInputValue(prompt);
  231. setTimeout(() => {
  232. if (inputRef.current) {
  233. inputRef.current.focus();
  234. inputRef.current.setSelectionRange(
  235. prompt.length,
  236. prompt.length,
  237. );
  238. }
  239. }, 0);
  240. }}
  241. className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
  242. >
  243. {prompt}
  244. </button>
  245. ))}
  246. </div>
  247. </div>
  248. ) : (
  249. versions.map((version, index) => (
  250. <button
  251. key={version.id}
  252. onClick={() => setSelectedVersionId(version.id)}
  253. className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
  254. selectedVersionId === version.id
  255. ? "bg-muted text-foreground"
  256. : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
  257. }`}
  258. >
  259. <div className="flex items-center gap-2">
  260. <span className="text-xs font-mono text-muted-foreground/70 shrink-0">
  261. v{index + 1}
  262. </span>
  263. <span className="truncate flex-1">{version.prompt}</span>
  264. {version.status === "generating" && (
  265. <span className="text-xs text-muted-foreground shrink-0 animate-pulse">
  266. ...
  267. </span>
  268. )}
  269. {version.status === "error" && (
  270. <span className="text-xs text-red-500 shrink-0">failed</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. <PlaygroundRenderer spec={currentTree} loading={isStreaming} />
  431. </div>
  432. ) : (
  433. <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
  434. {isStreaming
  435. ? "generating..."
  436. : "// enter a prompt to generate UI"}
  437. </div>
  438. )
  439. ) : (
  440. <CodeBlock
  441. code={generatedCode}
  442. lang="tsx"
  443. fillHeight
  444. hideCopyButton
  445. />
  446. )}
  447. </div>
  448. </div>
  449. );
  450. return (
  451. <div className="h-full flex flex-col">
  452. <Header />
  453. {/* Desktop: 3-pane resizable layout */}
  454. <div className="hidden lg:flex flex-1 min-h-0">
  455. <ResizablePanelGroup className="flex-1">
  456. <ResizablePanel defaultSize={25} minSize={15}>
  457. {chatPane}
  458. </ResizablePanel>
  459. <ResizableHandle />
  460. <ResizablePanel defaultSize={35} minSize={20}>
  461. {codePane}
  462. </ResizablePanel>
  463. <ResizableHandle />
  464. <ResizablePanel defaultSize={40} minSize={20}>
  465. {previewPane}
  466. </ResizablePanel>
  467. </ResizablePanelGroup>
  468. </div>
  469. {/* Mobile: Single pane with bottom tabs */}
  470. <div className="flex lg:hidden flex-col flex-1 min-h-0">
  471. {/* Panes - all in DOM, visibility controlled */}
  472. <div className="flex-1 min-h-0 relative">
  473. <div
  474. className={`absolute inset-0 ${mobilePane === "chat" ? "" : "invisible"}`}
  475. >
  476. {chatPane}
  477. </div>
  478. <div
  479. className={`absolute inset-0 ${mobilePane === "code" ? "" : "invisible"}`}
  480. >
  481. {codePane}
  482. </div>
  483. <div
  484. className={`absolute inset-0 ${mobilePane === "preview" ? "" : "invisible"}`}
  485. >
  486. {previewPane}
  487. </div>
  488. </div>
  489. {/* Bottom tab bar */}
  490. <div className="border-t border-border flex shrink-0">
  491. {(
  492. [
  493. {
  494. key: "chat",
  495. label: "Chat",
  496. 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",
  497. },
  498. {
  499. key: "code",
  500. label: "Code",
  501. icon: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
  502. },
  503. {
  504. key: "preview",
  505. label: "Preview",
  506. 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",
  507. },
  508. ] as const
  509. ).map(({ key, label, icon }) => (
  510. <button
  511. key={key}
  512. onClick={() => setMobilePane(key)}
  513. className={`flex-1 py-3 flex flex-col items-center gap-1 transition-colors ${
  514. mobilePane === key ? "text-foreground" : "text-muted-foreground"
  515. }`}
  516. >
  517. <svg
  518. className="w-5 h-5"
  519. fill="none"
  520. stroke="currentColor"
  521. viewBox="0 0 24 24"
  522. strokeWidth={1.5}
  523. >
  524. <path strokeLinecap="round" strokeLinejoin="round" d={icon} />
  525. </svg>
  526. <span className="text-xs">{label}</span>
  527. </button>
  528. ))}
  529. </div>
  530. </div>
  531. <Toaster position="bottom-right" />
  532. </div>
  533. );
  534. }