playground.tsx 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098
  1. "use client";
  2. import { useEffect, useState, useCallback, useRef, useMemo } from "react";
  3. import { flushSync } from "react-dom";
  4. import { useUIStream, type TokenUsage } from "@json-render/react";
  5. import type { Spec } from "@json-render/core";
  6. import { collectUsedComponents, serializeProps } from "@json-render/codegen";
  7. import { toast } from "sonner";
  8. import {
  9. ResizablePanelGroup,
  10. ResizablePanel,
  11. ResizableHandle,
  12. } from "@/components/ui/resizable";
  13. import { CodeBlock } from "./code-block";
  14. import { CopyButton } from "./copy-button";
  15. import { Toaster } from "./ui/sonner";
  16. import { Header } from "./header";
  17. import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
  18. import { PlaygroundRenderer } from "@/lib/render/renderer";
  19. import { playgroundCatalog } from "@/lib/render/catalog";
  20. import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
  21. type Tab = "json" | "nested" | "stream" | "catalog";
  22. type RenderView = "preview" | "code";
  23. type MobileView =
  24. | "json"
  25. | "nested"
  26. | "stream"
  27. | "catalog"
  28. | "preview"
  29. | "generated-code";
  30. interface Version {
  31. id: string;
  32. prompt: string;
  33. tree: Spec | null;
  34. status: "generating" | "complete" | "error";
  35. usage: TokenUsage | null;
  36. rawLines: string[];
  37. }
  38. /**
  39. * Convert a flat Spec into a nested tree structure that is easier for humans
  40. * to read. Children keys are resolved recursively into inline objects.
  41. */
  42. function specToNested(spec: Spec): Record<string, unknown> {
  43. function resolve(key: string): Record<string, unknown> {
  44. const el = spec.elements[key];
  45. if (!el) return { _key: key, _missing: true };
  46. const node: Record<string, unknown> = { type: el.type };
  47. if (el.props && Object.keys(el.props).length > 0) {
  48. node.props = el.props;
  49. }
  50. if (el.visible !== undefined) {
  51. node.visible = el.visible;
  52. }
  53. if (el.on && Object.keys(el.on).length > 0) {
  54. node.on = el.on;
  55. }
  56. if (el.repeat) {
  57. node.repeat = el.repeat;
  58. }
  59. if (el.children && el.children.length > 0) {
  60. node.children = el.children.map(resolve);
  61. }
  62. return node;
  63. }
  64. const result: Record<string, unknown> = {};
  65. if (spec.state && Object.keys(spec.state).length > 0) {
  66. result.state = spec.state;
  67. }
  68. result.elements = resolve(spec.root);
  69. return result;
  70. }
  71. const EXAMPLE_PROMPTS = [
  72. "Create a login form",
  73. "Build a pricing page",
  74. "Design a user profile card",
  75. "Make a contact form",
  76. ];
  77. export function Playground() {
  78. const [versions, setVersions] = useState<Version[]>([]);
  79. const [selectedVersionId, setSelectedVersionId] = useState<string | null>(
  80. null,
  81. );
  82. const [inputValue, setInputValue] = useState("");
  83. const [activeTab, setActiveTab] = useState<Tab>("json");
  84. const [catalogSection, setCatalogSection] = useState<
  85. "components" | "actions"
  86. >("components");
  87. const [renderView, setRenderView] = useState<RenderView>("preview");
  88. const [mobileView, setMobileView] = useState<MobileView>("preview");
  89. const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
  90. const inputRef = useRef<HTMLTextAreaElement>(null);
  91. const mobileInputRef = useRef<HTMLTextAreaElement>(null);
  92. const versionsEndRef = useRef<HTMLDivElement>(null);
  93. // Track the currently generating version ID
  94. const generatingVersionIdRef = useRef<string | null>(null);
  95. // Track the current tree for use as previousSpec in next generation
  96. const currentTreeRef = useRef<Spec | null>(null);
  97. const {
  98. spec: apiSpec,
  99. isStreaming,
  100. usage: streamUsage,
  101. rawLines: streamRawLines,
  102. send,
  103. clear,
  104. } = useUIStream({
  105. api: "/api/generate",
  106. onError: (err: Error) => {
  107. console.error("Generation error:", err);
  108. toast.error(err.message || "Generation failed. Please try again.");
  109. // Mark the version as errored
  110. if (generatingVersionIdRef.current) {
  111. const erroredVersionId = generatingVersionIdRef.current;
  112. setVersions((prev) =>
  113. prev.map((v) =>
  114. v.id === erroredVersionId ? { ...v, status: "error" as const } : v,
  115. ),
  116. );
  117. generatingVersionIdRef.current = null;
  118. }
  119. },
  120. } as Parameters<typeof useUIStream>[0]);
  121. // Get the selected version
  122. const selectedVersion = versions.find((v) => v.id === selectedVersionId);
  123. // Determine which tree to display:
  124. // - If streaming and selected version is the generating one, show apiSpec
  125. // - Otherwise show the selected version's tree
  126. const isSelectedVersionGenerating =
  127. selectedVersionId === generatingVersionIdRef.current && isStreaming;
  128. const hasValidApiTree =
  129. apiSpec && apiSpec.root && Object.keys(apiSpec.elements).length > 0;
  130. const currentTree =
  131. isSelectedVersionGenerating && hasValidApiTree
  132. ? apiSpec
  133. : (selectedVersion?.tree ??
  134. (isSelectedVersionGenerating ? apiSpec : null));
  135. // Raw JSONL lines: live from stream during generation, or stored per version
  136. const currentRawLines = isSelectedVersionGenerating
  137. ? streamRawLines
  138. : (selectedVersion?.rawLines ?? []);
  139. // Keep the ref updated with the current tree for use in handleSubmit
  140. if (
  141. currentTree &&
  142. currentTree.root &&
  143. Object.keys(currentTree.elements).length > 0
  144. ) {
  145. currentTreeRef.current = currentTree;
  146. }
  147. // Scroll to bottom when versions change
  148. useEffect(() => {
  149. versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
  150. }, [versions]);
  151. // Update version when streaming completes
  152. useEffect(() => {
  153. if (
  154. !isStreaming &&
  155. apiSpec &&
  156. apiSpec.root &&
  157. generatingVersionIdRef.current
  158. ) {
  159. const completedVersionId = generatingVersionIdRef.current;
  160. setVersions((prev) =>
  161. prev.map((v) =>
  162. v.id === completedVersionId
  163. ? {
  164. ...v,
  165. tree: apiSpec,
  166. status: "complete" as const,
  167. usage: streamUsage,
  168. rawLines: streamRawLines,
  169. }
  170. : v,
  171. ),
  172. );
  173. generatingVersionIdRef.current = null;
  174. }
  175. }, [isStreaming, apiSpec, streamUsage, streamRawLines]);
  176. const handleSubmit = useCallback(async () => {
  177. if (!inputValue.trim() || isStreaming) return;
  178. const newVersionId = Date.now().toString();
  179. const newVersion: Version = {
  180. id: newVersionId,
  181. prompt: inputValue.trim(),
  182. tree: null,
  183. status: "generating",
  184. usage: null,
  185. rawLines: [],
  186. };
  187. generatingVersionIdRef.current = newVersionId;
  188. setVersions((prev) => [...prev, newVersion]);
  189. setSelectedVersionId(newVersionId);
  190. setInputValue("");
  191. // Pass the current tree as context so the API can iterate on it
  192. await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
  193. }, [inputValue, isStreaming, send]);
  194. const handleKeyDown = useCallback(
  195. (e: React.KeyboardEvent) => {
  196. if (e.key === "Enter" && !e.shiftKey) {
  197. e.preventDefault();
  198. handleSubmit();
  199. }
  200. },
  201. [handleSubmit],
  202. );
  203. const jsonCode = currentTree
  204. ? JSON.stringify(currentTree, null, 2)
  205. : "// waiting...";
  206. const nestedCode = useMemo(() => {
  207. if (!currentTree || !currentTree.root) return "// waiting...";
  208. return JSON.stringify(specToNested(currentTree), null, 2);
  209. }, [currentTree]);
  210. const generatedCode = useMemo(() => {
  211. if (!currentTree || !currentTree.root) {
  212. return "// Generate a UI to see the code";
  213. }
  214. const tree = currentTree;
  215. const components = collectUsedComponents(tree);
  216. function generateJSX(key: string, indent: number): string {
  217. const element = tree.elements[key];
  218. if (!element) return "";
  219. const spaces = " ".repeat(indent);
  220. const componentName = element.type;
  221. const propsObj: Record<string, unknown> = {};
  222. for (const [k, v] of Object.entries(element.props)) {
  223. if (v !== null && v !== undefined) {
  224. propsObj[k] = v;
  225. }
  226. }
  227. const propsStr = serializeProps(propsObj);
  228. const hasChildren = element.children && element.children.length > 0;
  229. if (!hasChildren) {
  230. return propsStr
  231. ? `${spaces}<${componentName} ${propsStr} />`
  232. : `${spaces}<${componentName} />`;
  233. }
  234. const lines: string[] = [];
  235. lines.push(
  236. propsStr
  237. ? `${spaces}<${componentName} ${propsStr}>`
  238. : `${spaces}<${componentName}>`,
  239. );
  240. for (const childKey of element.children!) {
  241. lines.push(generateJSX(childKey, indent + 1));
  242. }
  243. lines.push(`${spaces}</${componentName}>`);
  244. return lines.join("\n");
  245. }
  246. const jsx = generateJSX(tree.root, 2);
  247. const imports = Array.from(components).sort().join(", ");
  248. return `"use client";
  249. import { ${imports} } from "@/components/ui";
  250. export default function Page() {
  251. return (
  252. <div className="min-h-screen p-8 flex items-center justify-center">
  253. ${jsx}
  254. </div>
  255. );
  256. }`;
  257. }, [currentTree]);
  258. // Chat pane content
  259. const chatPane = (
  260. <div className="h-full flex flex-col border-t border-border">
  261. <div className="border-b border-border px-3 h-9 flex items-center">
  262. <span className="text-xs font-mono text-muted-foreground">
  263. versions
  264. </span>
  265. </div>
  266. <div
  267. className={`flex-1 p-2 min-h-0 ${versions.length > 0 ? "overflow-y-auto space-y-1" : "flex"}`}
  268. >
  269. {versions.length === 0 ? (
  270. <div className="flex-1 flex flex-col items-center justify-center text-center px-4">
  271. <p className="text-sm text-muted-foreground mb-4">
  272. Describe what you want to build, then iterate on it.
  273. </p>
  274. <div className="flex flex-wrap gap-2 justify-center">
  275. {EXAMPLE_PROMPTS.map((prompt) => (
  276. <button
  277. key={prompt}
  278. onMouseDown={(e) => {
  279. e.preventDefault();
  280. flushSync(() => setInputValue(prompt));
  281. // chatPane is rendered in both desktop and mobile layouts,
  282. // so inputRef may point to the hidden instance. Find the
  283. // textarea in the same layout container as the clicked button.
  284. const container = (e.currentTarget as HTMLElement).closest(
  285. ".h-full.flex.flex-col",
  286. );
  287. const el =
  288. container?.querySelector<HTMLTextAreaElement>(
  289. "textarea",
  290. ) ?? inputRef.current;
  291. if (el) {
  292. el.focus();
  293. el.setSelectionRange(prompt.length, prompt.length);
  294. }
  295. }}
  296. className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
  297. >
  298. {prompt}
  299. </button>
  300. ))}
  301. </div>
  302. </div>
  303. ) : (
  304. versions.map((version, index) => (
  305. <button
  306. key={version.id}
  307. onClick={() => setSelectedVersionId(version.id)}
  308. className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
  309. selectedVersionId === version.id
  310. ? "bg-muted text-foreground"
  311. : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
  312. }`}
  313. >
  314. <div className="flex items-center gap-2">
  315. <span className="text-xs font-mono text-muted-foreground/70 shrink-0">
  316. v{index + 1}
  317. </span>
  318. <span className="truncate flex-1">{version.prompt}</span>
  319. {version.status === "generating" && (
  320. <span className="text-xs text-muted-foreground shrink-0 animate-pulse">
  321. ...
  322. </span>
  323. )}
  324. {version.status === "error" && (
  325. <span className="text-xs text-red-500 shrink-0">failed</span>
  326. )}
  327. </div>
  328. {version.usage && (
  329. <div className="flex items-center gap-2 mt-1 ml-6">
  330. <span className="text-[10px] font-mono text-muted-foreground/60">
  331. {version.usage.totalTokens.toLocaleString()} tokens
  332. </span>
  333. </div>
  334. )}
  335. </button>
  336. ))
  337. )}
  338. <div ref={versionsEndRef} />
  339. </div>
  340. <div
  341. className="border-t border-border p-3 cursor-text"
  342. onMouseDown={(e) => {
  343. // Focus textarea unless clicking a button or the textarea itself
  344. const target = e.target as HTMLElement;
  345. if (!target.closest("button") && target.tagName !== "TEXTAREA") {
  346. e.preventDefault();
  347. inputRef.current?.focus();
  348. }
  349. }}
  350. >
  351. <textarea
  352. ref={inputRef}
  353. value={inputValue}
  354. onChange={(e) => setInputValue(e.target.value)}
  355. onKeyDown={handleKeyDown}
  356. placeholder="Describe changes..."
  357. className="w-full bg-background text-base sm:text-sm resize-none outline-none placeholder:text-muted-foreground/50"
  358. rows={2}
  359. autoFocus
  360. />
  361. <div className="flex justify-between items-center mt-2">
  362. {versions.length > 0 ? (
  363. <button
  364. onClick={() => {
  365. setVersions([]);
  366. setSelectedVersionId(null);
  367. clear();
  368. }}
  369. className="text-xs text-muted-foreground hover:text-foreground transition-colors"
  370. >
  371. Clear
  372. </button>
  373. ) : (
  374. <div />
  375. )}
  376. {isStreaming ? (
  377. <button
  378. onClick={() => clear()}
  379. className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
  380. aria-label="Stop"
  381. >
  382. <svg
  383. width="14"
  384. height="14"
  385. viewBox="0 0 24 24"
  386. fill="currentColor"
  387. stroke="none"
  388. >
  389. <rect x="6" y="6" width="12" height="12" />
  390. </svg>
  391. </button>
  392. ) : (
  393. <button
  394. onClick={handleSubmit}
  395. disabled={!inputValue.trim()}
  396. 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"
  397. aria-label="Send"
  398. >
  399. <svg
  400. width="14"
  401. height="14"
  402. viewBox="0 0 24 24"
  403. fill="none"
  404. stroke="currentColor"
  405. strokeWidth="2"
  406. strokeLinecap="round"
  407. strokeLinejoin="round"
  408. >
  409. <path d="M5 12h14" />
  410. <path d="m12 5 7 7-7 7" />
  411. </svg>
  412. </button>
  413. )}
  414. </div>
  415. </div>
  416. </div>
  417. );
  418. // Catalog data for the catalog tab
  419. const catalogData = useMemo(
  420. () => buildCatalogDisplayData(playgroundCatalog.data),
  421. [],
  422. );
  423. // Code pane content
  424. const copyText =
  425. activeTab === "stream"
  426. ? currentRawLines.join("\n")
  427. : activeTab === "json"
  428. ? jsonCode
  429. : activeTab === "nested"
  430. ? nestedCode
  431. : "";
  432. const codePane = (
  433. <div className="h-full flex flex-col border-t border-border">
  434. <div className="border-b border-border px-3 h-9 flex items-center gap-3">
  435. {(["json", "nested", "stream", "catalog"] as const).map((tab) => (
  436. <button
  437. key={tab}
  438. onClick={() => setActiveTab(tab)}
  439. className={`text-xs font-mono transition-colors ${
  440. activeTab === tab
  441. ? "text-foreground"
  442. : "text-muted-foreground hover:text-foreground"
  443. }`}
  444. >
  445. {tab}
  446. </button>
  447. ))}
  448. <div className="flex-1" />
  449. {activeTab !== "catalog" && (
  450. <CopyButton text={copyText} className="text-muted-foreground" />
  451. )}
  452. </div>
  453. <div className="flex-1 overflow-auto">
  454. {activeTab === "catalog" ? (
  455. <div className="h-full flex flex-col text-sm">
  456. <div className="flex items-center gap-3 px-3 h-9 border-b border-border">
  457. {(
  458. [
  459. {
  460. key: "components",
  461. label: `components (${catalogData.components.length})`,
  462. },
  463. {
  464. key: "actions",
  465. label: `actions (${catalogData.actions.length})`,
  466. },
  467. ] as const
  468. ).map(({ key, label }) => (
  469. <button
  470. key={key}
  471. onClick={() => setCatalogSection(key)}
  472. className={`text-xs font-mono transition-colors ${
  473. catalogSection === key
  474. ? "text-foreground"
  475. : "text-muted-foreground hover:text-foreground"
  476. }`}
  477. >
  478. {label}
  479. </button>
  480. ))}
  481. </div>
  482. <div className="flex-1 overflow-auto p-3">
  483. {catalogSection === "components" ? (
  484. <div className="space-y-3">
  485. {catalogData.components.map((comp) => (
  486. <div
  487. key={comp.name}
  488. className="pb-3 border-b border-border last:border-b-0"
  489. >
  490. <div className="flex items-baseline gap-2 mb-1">
  491. <span className="font-mono font-medium text-foreground">
  492. {comp.name}
  493. </span>
  494. {comp.slots.length > 0 && (
  495. <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
  496. slots: {comp.slots.join(", ")}
  497. </span>
  498. )}
  499. </div>
  500. {comp.description && (
  501. <p className="text-xs text-muted-foreground mb-2">
  502. {comp.description}
  503. </p>
  504. )}
  505. {comp.props.length > 0 && (
  506. <div className="flex flex-wrap gap-1 mb-1">
  507. {comp.props.map((p) => (
  508. <span
  509. key={p.name}
  510. className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
  511. >
  512. {p.name}
  513. <span className="text-green-700/50 dark:text-green-400/50">
  514. : {p.type}
  515. </span>
  516. </span>
  517. ))}
  518. </div>
  519. )}
  520. {comp.events.length > 0 && (
  521. <div className="flex flex-wrap gap-1 mt-1.5">
  522. {comp.events.map((e) => (
  523. <span
  524. key={e}
  525. className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
  526. >
  527. on.{e}
  528. </span>
  529. ))}
  530. </div>
  531. )}
  532. </div>
  533. ))}
  534. </div>
  535. ) : (
  536. <div className="space-y-3">
  537. {catalogData.actions.map((action) => (
  538. <div
  539. key={action.name}
  540. className="pb-3 border-b border-border last:border-b-0"
  541. >
  542. <span className="font-mono font-medium text-foreground">
  543. {action.name}
  544. </span>
  545. {action.description && (
  546. <p className="text-xs text-muted-foreground mt-1 mb-2">
  547. {action.description}
  548. </p>
  549. )}
  550. {action.params.length > 0 && (
  551. <div className="flex flex-wrap gap-1">
  552. {action.params.map((p) => (
  553. <span
  554. key={p.name}
  555. className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
  556. >
  557. {p.name}
  558. <span className="text-green-700/50 dark:text-green-400/50">
  559. : {p.type}
  560. </span>
  561. </span>
  562. ))}
  563. </div>
  564. )}
  565. </div>
  566. ))}
  567. </div>
  568. )}
  569. </div>
  570. </div>
  571. ) : activeTab === "stream" ? (
  572. currentRawLines.length > 0 ? (
  573. <CodeBlock
  574. code={currentRawLines.join("\n")}
  575. lang="json"
  576. fillHeight
  577. hideCopyButton
  578. />
  579. ) : (
  580. <div className="text-muted-foreground/50 p-3 text-sm font-mono">
  581. {isStreaming ? "streaming..." : "// waiting for generation"}
  582. </div>
  583. )
  584. ) : activeTab === "nested" ? (
  585. <CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
  586. ) : (
  587. <CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
  588. )}
  589. </div>
  590. </div>
  591. );
  592. // Preview pane content
  593. const previewPane = (
  594. <div className="h-full flex flex-col border-t border-border">
  595. <div className="border-b border-border px-3 h-9 flex items-center gap-3">
  596. {(
  597. [
  598. { key: "preview", label: "preview" },
  599. { key: "code", label: "code" },
  600. ] as const
  601. ).map(({ key, label }) => (
  602. <button
  603. key={key}
  604. onClick={() => setRenderView(key)}
  605. className={`text-xs font-mono transition-colors ${
  606. renderView === key
  607. ? "text-foreground"
  608. : "text-muted-foreground hover:text-foreground"
  609. }`}
  610. >
  611. {label}
  612. </button>
  613. ))}
  614. <div className="flex-1" />
  615. {renderView === "code" && (
  616. <CopyButton text={generatedCode} className="text-muted-foreground" />
  617. )}
  618. </div>
  619. <div className="flex-1 overflow-auto">
  620. {renderView === "preview" ? (
  621. currentTree && currentTree.root ? (
  622. <div className="w-full min-h-full flex items-center justify-center p-6">
  623. <PlaygroundRenderer
  624. spec={currentTree}
  625. data={currentTree.state}
  626. loading={isStreaming}
  627. />
  628. </div>
  629. ) : (
  630. <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
  631. {isStreaming
  632. ? "generating..."
  633. : "// enter a prompt to generate UI"}
  634. </div>
  635. )
  636. ) : (
  637. <CodeBlock
  638. code={generatedCode}
  639. lang="tsx"
  640. fillHeight
  641. hideCopyButton
  642. />
  643. )}
  644. </div>
  645. </div>
  646. );
  647. return (
  648. <div className="h-full flex flex-col">
  649. <Header />
  650. {/* Desktop: 3-pane resizable layout */}
  651. <div className="hidden lg:flex flex-1 min-h-0">
  652. <ResizablePanelGroup className="flex-1">
  653. <ResizablePanel defaultSize={25} minSize={15}>
  654. {chatPane}
  655. </ResizablePanel>
  656. <ResizableHandle />
  657. <ResizablePanel defaultSize={35} minSize={20}>
  658. {codePane}
  659. </ResizablePanel>
  660. <ResizableHandle />
  661. <ResizablePanel defaultSize={40} minSize={20}>
  662. {previewPane}
  663. </ResizablePanel>
  664. </ResizablePanelGroup>
  665. </div>
  666. {/* Mobile: toolbar + content + prompt input */}
  667. <div className="flex lg:hidden flex-col flex-1 min-h-0">
  668. {/* Top toolbar */}
  669. <div className="border-b border-border px-3 h-9 flex items-center gap-3 shrink-0 overflow-x-auto">
  670. {/* Version badge */}
  671. <button
  672. onClick={() => setVersionsSheetOpen(true)}
  673. className="text-xs font-mono font-medium px-1.5 py-0.5 rounded bg-muted text-foreground shrink-0"
  674. >
  675. v
  676. {versions.length > 0
  677. ? versions.findIndex((v) => v.id === selectedVersionId) + 1 ||
  678. versions.length
  679. : 0}
  680. </button>
  681. {/* Code tabs */}
  682. {(["json", "nested", "stream", "catalog"] as const).map((tab) => (
  683. <button
  684. key={tab}
  685. onClick={() => setMobileView(tab)}
  686. className={`text-xs font-mono transition-colors shrink-0 ${
  687. mobileView === tab
  688. ? "text-foreground"
  689. : "text-muted-foreground hover:text-foreground"
  690. }`}
  691. >
  692. {tab}
  693. </button>
  694. ))}
  695. <div className="flex-1" />
  696. {/* Preview / code toggle */}
  697. {[
  698. { key: "preview" as const, label: "preview" },
  699. { key: "generated-code" as const, label: "code" },
  700. ].map(({ key, label }) => (
  701. <button
  702. key={key}
  703. onClick={() => setMobileView(key)}
  704. className={`text-xs font-mono transition-colors shrink-0 ${
  705. mobileView === key
  706. ? "text-foreground"
  707. : "text-muted-foreground hover:text-foreground"
  708. }`}
  709. >
  710. {label}
  711. </button>
  712. ))}
  713. </div>
  714. {/* Main content area */}
  715. <div className="flex-1 min-h-0 overflow-auto">
  716. {mobileView === "catalog" ? (
  717. <div className="h-full flex flex-col text-sm">
  718. <div className="flex items-center gap-3 px-3 h-9 border-b border-border">
  719. {(
  720. [
  721. {
  722. key: "components",
  723. label: `components (${catalogData.components.length})`,
  724. },
  725. {
  726. key: "actions",
  727. label: `actions (${catalogData.actions.length})`,
  728. },
  729. ] as const
  730. ).map(({ key, label }) => (
  731. <button
  732. key={key}
  733. onClick={() => setCatalogSection(key)}
  734. className={`text-xs font-mono transition-colors ${
  735. catalogSection === key
  736. ? "text-foreground"
  737. : "text-muted-foreground hover:text-foreground"
  738. }`}
  739. >
  740. {label}
  741. </button>
  742. ))}
  743. </div>
  744. <div className="flex-1 overflow-auto p-3">
  745. {catalogSection === "components" ? (
  746. <div className="space-y-3">
  747. {catalogData.components.map((comp) => (
  748. <div
  749. key={comp.name}
  750. className="pb-3 border-b border-border last:border-b-0"
  751. >
  752. <div className="flex items-baseline gap-2 mb-1">
  753. <span className="font-mono font-medium text-foreground">
  754. {comp.name}
  755. </span>
  756. {comp.slots.length > 0 && (
  757. <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
  758. slots: {comp.slots.join(", ")}
  759. </span>
  760. )}
  761. </div>
  762. {comp.description && (
  763. <p className="text-xs text-muted-foreground mb-2">
  764. {comp.description}
  765. </p>
  766. )}
  767. {comp.props.length > 0 && (
  768. <div className="flex flex-wrap gap-1 mb-1">
  769. {comp.props.map((p) => (
  770. <span
  771. key={p.name}
  772. className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
  773. >
  774. {p.name}
  775. <span className="text-green-700/50 dark:text-green-400/50">
  776. : {p.type}
  777. </span>
  778. </span>
  779. ))}
  780. </div>
  781. )}
  782. {comp.events.length > 0 && (
  783. <div className="flex flex-wrap gap-1 mt-1.5">
  784. {comp.events.map((e) => (
  785. <span
  786. key={e}
  787. className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
  788. >
  789. on.{e}
  790. </span>
  791. ))}
  792. </div>
  793. )}
  794. </div>
  795. ))}
  796. </div>
  797. ) : (
  798. <div className="space-y-3">
  799. {catalogData.actions.map((action) => (
  800. <div
  801. key={action.name}
  802. className="pb-3 border-b border-border last:border-b-0"
  803. >
  804. <span className="font-mono font-medium text-foreground">
  805. {action.name}
  806. </span>
  807. {action.description && (
  808. <p className="text-xs text-muted-foreground mt-1 mb-2">
  809. {action.description}
  810. </p>
  811. )}
  812. {action.params.length > 0 && (
  813. <div className="flex flex-wrap gap-1">
  814. {action.params.map((p) => (
  815. <span
  816. key={p.name}
  817. className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
  818. >
  819. {p.name}
  820. <span className="text-green-700/50 dark:text-green-400/50">
  821. : {p.type}
  822. </span>
  823. </span>
  824. ))}
  825. </div>
  826. )}
  827. </div>
  828. ))}
  829. </div>
  830. )}
  831. </div>
  832. </div>
  833. ) : mobileView === "stream" ? (
  834. currentRawLines.length > 0 ? (
  835. <CodeBlock
  836. code={currentRawLines.join("\n")}
  837. lang="json"
  838. fillHeight
  839. hideCopyButton
  840. />
  841. ) : (
  842. <div className="text-muted-foreground/50 p-3 text-sm font-mono">
  843. {isStreaming ? "streaming..." : "// waiting for generation"}
  844. </div>
  845. )
  846. ) : mobileView === "nested" ? (
  847. <CodeBlock
  848. code={nestedCode}
  849. lang="json"
  850. fillHeight
  851. hideCopyButton
  852. />
  853. ) : mobileView === "json" ? (
  854. <CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
  855. ) : mobileView === "preview" ? (
  856. currentTree && currentTree.root ? (
  857. <div className="w-full min-h-full flex items-center justify-center p-6">
  858. <PlaygroundRenderer
  859. spec={currentTree}
  860. data={currentTree.state}
  861. loading={isStreaming}
  862. />
  863. </div>
  864. ) : (
  865. <div className="h-full flex flex-col items-center justify-center text-center px-4">
  866. {isStreaming ? (
  867. <p className="text-sm text-muted-foreground/50">
  868. generating...
  869. </p>
  870. ) : (
  871. <>
  872. <p className="text-sm text-muted-foreground mb-4">
  873. Describe what you want to build, then iterate on it.
  874. </p>
  875. <div className="flex flex-wrap gap-2 justify-center">
  876. {EXAMPLE_PROMPTS.map((prompt) => (
  877. <button
  878. key={prompt}
  879. onMouseDown={(e) => {
  880. e.preventDefault();
  881. flushSync(() => setInputValue(prompt));
  882. mobileInputRef.current?.focus();
  883. mobileInputRef.current?.setSelectionRange(
  884. prompt.length,
  885. prompt.length,
  886. );
  887. }}
  888. className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
  889. >
  890. {prompt}
  891. </button>
  892. ))}
  893. </div>
  894. </>
  895. )}
  896. </div>
  897. )
  898. ) : (
  899. /* generated-code */
  900. <CodeBlock
  901. code={generatedCode}
  902. lang="tsx"
  903. fillHeight
  904. hideCopyButton
  905. />
  906. )}
  907. </div>
  908. {/* Prompt input pinned to bottom */}
  909. <div
  910. className="border-t border-border p-3 shrink-0 cursor-text"
  911. onMouseDown={(e) => {
  912. const target = e.target as HTMLElement;
  913. if (!target.closest("button") && target.tagName !== "TEXTAREA") {
  914. e.preventDefault();
  915. mobileInputRef.current?.focus();
  916. }
  917. }}
  918. >
  919. <textarea
  920. ref={mobileInputRef}
  921. value={inputValue}
  922. onChange={(e) => setInputValue(e.target.value)}
  923. onKeyDown={handleKeyDown}
  924. placeholder="Describe changes..."
  925. className="w-full bg-background text-base resize-none outline-none placeholder:text-muted-foreground/50"
  926. rows={2}
  927. />
  928. <div className="flex justify-between items-center mt-2">
  929. {versions.length > 0 ? (
  930. <button
  931. onClick={() => {
  932. setVersions([]);
  933. setSelectedVersionId(null);
  934. clear();
  935. }}
  936. className="text-xs text-muted-foreground hover:text-foreground transition-colors"
  937. >
  938. Clear
  939. </button>
  940. ) : (
  941. <div />
  942. )}
  943. {isStreaming ? (
  944. <button
  945. onClick={() => clear()}
  946. className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
  947. aria-label="Stop"
  948. >
  949. <svg
  950. width="14"
  951. height="14"
  952. viewBox="0 0 24 24"
  953. fill="currentColor"
  954. stroke="none"
  955. >
  956. <rect x="6" y="6" width="12" height="12" />
  957. </svg>
  958. </button>
  959. ) : (
  960. <button
  961. onClick={handleSubmit}
  962. disabled={!inputValue.trim()}
  963. 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"
  964. aria-label="Send"
  965. >
  966. <svg
  967. width="14"
  968. height="14"
  969. viewBox="0 0 24 24"
  970. fill="none"
  971. stroke="currentColor"
  972. strokeWidth="2"
  973. strokeLinecap="round"
  974. strokeLinejoin="round"
  975. >
  976. <path d="M5 12h14" />
  977. <path d="m12 5 7 7-7 7" />
  978. </svg>
  979. </button>
  980. )}
  981. </div>
  982. </div>
  983. {/* Versions sheet */}
  984. <Sheet open={versionsSheetOpen} onOpenChange={setVersionsSheetOpen}>
  985. <SheetContent>
  986. <SheetTitle className="text-sm font-mono mb-4">Versions</SheetTitle>
  987. <div className="flex-1 overflow-y-auto space-y-1">
  988. {versions.map((version, index) => (
  989. <button
  990. key={version.id}
  991. onClick={() => {
  992. setSelectedVersionId(version.id);
  993. setVersionsSheetOpen(false);
  994. }}
  995. className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
  996. selectedVersionId === version.id
  997. ? "bg-muted text-foreground"
  998. : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
  999. }`}
  1000. >
  1001. <div className="flex items-center gap-2">
  1002. <span className="text-xs font-mono text-muted-foreground/70 shrink-0">
  1003. v{index + 1}
  1004. </span>
  1005. <span className="truncate flex-1">{version.prompt}</span>
  1006. {version.status === "generating" && (
  1007. <span className="text-xs text-muted-foreground shrink-0 animate-pulse">
  1008. ...
  1009. </span>
  1010. )}
  1011. {version.status === "error" && (
  1012. <span className="text-xs text-red-500 shrink-0">
  1013. failed
  1014. </span>
  1015. )}
  1016. </div>
  1017. {version.usage && (
  1018. <div className="flex items-center gap-2 mt-1 ml-6">
  1019. <span className="text-[10px] font-mono text-muted-foreground/60">
  1020. {version.usage.totalTokens.toLocaleString()} tokens
  1021. </span>
  1022. </div>
  1023. )}
  1024. </button>
  1025. ))}
  1026. {versions.length === 0 && (
  1027. <p className="text-sm text-muted-foreground px-3">
  1028. No versions yet. Enter a prompt to get started.
  1029. </p>
  1030. )}
  1031. </div>
  1032. </SheetContent>
  1033. </Sheet>
  1034. </div>
  1035. <Toaster position="bottom-right" />
  1036. </div>
  1037. );
  1038. }