playground.tsx 46 KB

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