playground.tsx 42 KB

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