playground.tsx 43 KB

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