|
@@ -1,7 +1,8 @@
|
|
|
"use client";
|
|
"use client";
|
|
|
|
|
|
|
|
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
|
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
|
|
-import { useUIStream } from "@json-render/react";
|
|
|
|
|
|
|
+import { flushSync } from "react-dom";
|
|
|
|
|
+import { useUIStream, type TokenUsage } from "@json-render/react";
|
|
|
import type { Spec } from "@json-render/core";
|
|
import type { Spec } from "@json-render/core";
|
|
|
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
|
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
|
|
import { toast } from "sonner";
|
|
import { toast } from "sonner";
|
|
@@ -14,17 +15,72 @@ import { CodeBlock } from "./code-block";
|
|
|
import { CopyButton } from "./copy-button";
|
|
import { CopyButton } from "./copy-button";
|
|
|
import { Toaster } from "./ui/sonner";
|
|
import { Toaster } from "./ui/sonner";
|
|
|
import { Header } from "./header";
|
|
import { Header } from "./header";
|
|
|
-import { PlaygroundRenderer } from "@/lib/renderer";
|
|
|
|
|
|
|
+import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
|
|
|
|
|
+import { PlaygroundRenderer } from "@/lib/render/renderer";
|
|
|
|
|
+import { playgroundCatalog } from "@/lib/render/catalog";
|
|
|
|
|
|
|
|
-type Tab = "json" | "stream";
|
|
|
|
|
|
|
+type Tab = "json" | "nested" | "stream" | "catalog";
|
|
|
type RenderView = "preview" | "code";
|
|
type RenderView = "preview" | "code";
|
|
|
-type MobilePane = "chat" | "code" | "preview";
|
|
|
|
|
|
|
+type MobileView =
|
|
|
|
|
+ | "json"
|
|
|
|
|
+ | "nested"
|
|
|
|
|
+ | "stream"
|
|
|
|
|
+ | "catalog"
|
|
|
|
|
+ | "preview"
|
|
|
|
|
+ | "generated-code";
|
|
|
|
|
|
|
|
interface Version {
|
|
interface Version {
|
|
|
id: string;
|
|
id: string;
|
|
|
prompt: string;
|
|
prompt: string;
|
|
|
tree: Spec | null;
|
|
tree: Spec | null;
|
|
|
status: "generating" | "complete" | "error";
|
|
status: "generating" | "complete" | "error";
|
|
|
|
|
+ usage: TokenUsage | null;
|
|
|
|
|
+ rawLines: string[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Convert a flat Spec into a nested tree structure that is easier for humans
|
|
|
|
|
+ * to read. Children keys are resolved recursively into inline objects.
|
|
|
|
|
+ */
|
|
|
|
|
+function specToNested(spec: Spec): Record<string, unknown> {
|
|
|
|
|
+ function resolve(key: string): Record<string, unknown> {
|
|
|
|
|
+ const el = spec.elements[key];
|
|
|
|
|
+ if (!el) return { _key: key, _missing: true };
|
|
|
|
|
+
|
|
|
|
|
+ const node: Record<string, unknown> = { type: el.type };
|
|
|
|
|
+
|
|
|
|
|
+ if (el.props && Object.keys(el.props).length > 0) {
|
|
|
|
|
+ node.props = el.props;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (el.visible !== undefined) {
|
|
|
|
|
+ node.visible = el.visible;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (el.on && Object.keys(el.on).length > 0) {
|
|
|
|
|
+ node.on = el.on;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (el.repeat) {
|
|
|
|
|
+ node.repeat = el.repeat;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (el.children && el.children.length > 0) {
|
|
|
|
|
+ node.children = el.children.map(resolve);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return node;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const result: Record<string, unknown> = {};
|
|
|
|
|
+
|
|
|
|
|
+ if (spec.state && Object.keys(spec.state).length > 0) {
|
|
|
|
|
+ result.state = spec.state;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ result.elements = resolve(spec.root);
|
|
|
|
|
+
|
|
|
|
|
+ return result;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const EXAMPLE_PROMPTS = [
|
|
const EXAMPLE_PROMPTS = [
|
|
@@ -40,11 +96,15 @@ export function Playground() {
|
|
|
null,
|
|
null,
|
|
|
);
|
|
);
|
|
|
const [inputValue, setInputValue] = useState("");
|
|
const [inputValue, setInputValue] = useState("");
|
|
|
- const [streamLines, setStreamLines] = useState<string[]>([]);
|
|
|
|
|
const [activeTab, setActiveTab] = useState<Tab>("json");
|
|
const [activeTab, setActiveTab] = useState<Tab>("json");
|
|
|
|
|
+ const [catalogSection, setCatalogSection] = useState<
|
|
|
|
|
+ "components" | "actions"
|
|
|
|
|
+ >("components");
|
|
|
const [renderView, setRenderView] = useState<RenderView>("preview");
|
|
const [renderView, setRenderView] = useState<RenderView>("preview");
|
|
|
- const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
|
|
|
|
|
|
|
+ const [mobileView, setMobileView] = useState<MobileView>("preview");
|
|
|
|
|
+ const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
|
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
|
+ const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
|
|
const versionsEndRef = useRef<HTMLDivElement>(null);
|
|
const versionsEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
// Track the currently generating version ID
|
|
// Track the currently generating version ID
|
|
@@ -56,6 +116,8 @@ export function Playground() {
|
|
|
const {
|
|
const {
|
|
|
spec: apiSpec,
|
|
spec: apiSpec,
|
|
|
isStreaming,
|
|
isStreaming,
|
|
|
|
|
+ usage: streamUsage,
|
|
|
|
|
+ rawLines: streamRawLines,
|
|
|
send,
|
|
send,
|
|
|
clear,
|
|
clear,
|
|
|
} = useUIStream({
|
|
} = useUIStream({
|
|
@@ -93,6 +155,11 @@ export function Playground() {
|
|
|
: (selectedVersion?.tree ??
|
|
: (selectedVersion?.tree ??
|
|
|
(isSelectedVersionGenerating ? apiSpec : null));
|
|
(isSelectedVersionGenerating ? apiSpec : null));
|
|
|
|
|
|
|
|
|
|
+ // Raw JSONL lines: live from stream during generation, or stored per version
|
|
|
|
|
+ const currentRawLines = isSelectedVersionGenerating
|
|
|
|
|
+ ? streamRawLines
|
|
|
|
|
+ : (selectedVersion?.rawLines ?? []);
|
|
|
|
|
+
|
|
|
// Keep the ref updated with the current tree for use in handleSubmit
|
|
// Keep the ref updated with the current tree for use in handleSubmit
|
|
|
if (
|
|
if (
|
|
|
currentTree &&
|
|
currentTree &&
|
|
@@ -107,24 +174,6 @@ export function Playground() {
|
|
|
versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
|
}, [versions]);
|
|
}, [versions]);
|
|
|
|
|
|
|
|
- useEffect(() => {
|
|
|
|
|
- if (apiSpec) {
|
|
|
|
|
- const streamLine = JSON.stringify({ tree: apiSpec });
|
|
|
|
|
- if (
|
|
|
|
|
- !streamLines.includes(streamLine) &&
|
|
|
|
|
- Object.keys(apiSpec.elements).length > 0
|
|
|
|
|
- ) {
|
|
|
|
|
- setStreamLines((prev) => {
|
|
|
|
|
- const lastLine = prev[prev.length - 1];
|
|
|
|
|
- if (lastLine !== streamLine) {
|
|
|
|
|
- return [...prev, streamLine];
|
|
|
|
|
- }
|
|
|
|
|
- return prev;
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }, [apiSpec, streamLines]);
|
|
|
|
|
-
|
|
|
|
|
// Update version when streaming completes
|
|
// Update version when streaming completes
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (
|
|
if (
|
|
@@ -137,13 +186,19 @@ export function Playground() {
|
|
|
setVersions((prev) =>
|
|
setVersions((prev) =>
|
|
|
prev.map((v) =>
|
|
prev.map((v) =>
|
|
|
v.id === completedVersionId
|
|
v.id === completedVersionId
|
|
|
- ? { ...v, tree: apiSpec, status: "complete" as const }
|
|
|
|
|
|
|
+ ? {
|
|
|
|
|
+ ...v,
|
|
|
|
|
+ tree: apiSpec,
|
|
|
|
|
+ status: "complete" as const,
|
|
|
|
|
+ usage: streamUsage,
|
|
|
|
|
+ rawLines: streamRawLines,
|
|
|
|
|
+ }
|
|
|
: v,
|
|
: v,
|
|
|
),
|
|
),
|
|
|
);
|
|
);
|
|
|
generatingVersionIdRef.current = null;
|
|
generatingVersionIdRef.current = null;
|
|
|
}
|
|
}
|
|
|
- }, [isStreaming, apiSpec]);
|
|
|
|
|
|
|
+ }, [isStreaming, apiSpec, streamUsage, streamRawLines]);
|
|
|
|
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
const handleSubmit = useCallback(async () => {
|
|
|
if (!inputValue.trim() || isStreaming) return;
|
|
if (!inputValue.trim() || isStreaming) return;
|
|
@@ -154,13 +209,14 @@ export function Playground() {
|
|
|
prompt: inputValue.trim(),
|
|
prompt: inputValue.trim(),
|
|
|
tree: null,
|
|
tree: null,
|
|
|
status: "generating",
|
|
status: "generating",
|
|
|
|
|
+ usage: null,
|
|
|
|
|
+ rawLines: [],
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
generatingVersionIdRef.current = newVersionId;
|
|
generatingVersionIdRef.current = newVersionId;
|
|
|
setVersions((prev) => [...prev, newVersion]);
|
|
setVersions((prev) => [...prev, newVersion]);
|
|
|
setSelectedVersionId(newVersionId);
|
|
setSelectedVersionId(newVersionId);
|
|
|
setInputValue("");
|
|
setInputValue("");
|
|
|
- setStreamLines([]); // Reset stream lines for new generation
|
|
|
|
|
|
|
|
|
|
// Pass the current tree as context so the API can iterate on it
|
|
// Pass the current tree as context so the API can iterate on it
|
|
|
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
|
|
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
|
|
@@ -180,6 +236,11 @@ export function Playground() {
|
|
|
? JSON.stringify(currentTree, null, 2)
|
|
? JSON.stringify(currentTree, null, 2)
|
|
|
: "// waiting...";
|
|
: "// waiting...";
|
|
|
|
|
|
|
|
|
|
+ const nestedCode = useMemo(() => {
|
|
|
|
|
+ if (!currentTree || !currentTree.root) return "// waiting...";
|
|
|
|
|
+ return JSON.stringify(specToNested(currentTree), null, 2);
|
|
|
|
|
+ }, [currentTree]);
|
|
|
|
|
+
|
|
|
const generatedCode = useMemo(() => {
|
|
const generatedCode = useMemo(() => {
|
|
|
if (!currentTree || !currentTree.root) {
|
|
if (!currentTree || !currentTree.root) {
|
|
|
return "// Generate a UI to see the code";
|
|
return "// Generate a UI to see the code";
|
|
@@ -262,17 +323,23 @@ ${jsx}
|
|
|
{EXAMPLE_PROMPTS.map((prompt) => (
|
|
{EXAMPLE_PROMPTS.map((prompt) => (
|
|
|
<button
|
|
<button
|
|
|
key={prompt}
|
|
key={prompt}
|
|
|
- onClick={() => {
|
|
|
|
|
- setInputValue(prompt);
|
|
|
|
|
- setTimeout(() => {
|
|
|
|
|
- if (inputRef.current) {
|
|
|
|
|
- inputRef.current.focus();
|
|
|
|
|
- inputRef.current.setSelectionRange(
|
|
|
|
|
- prompt.length,
|
|
|
|
|
- prompt.length,
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
- }, 0);
|
|
|
|
|
|
|
+ onMouseDown={(e) => {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ flushSync(() => setInputValue(prompt));
|
|
|
|
|
+ // chatPane is rendered in both desktop and mobile layouts,
|
|
|
|
|
+ // so inputRef may point to the hidden instance. Find the
|
|
|
|
|
+ // textarea in the same layout container as the clicked button.
|
|
|
|
|
+ const container = (e.currentTarget as HTMLElement).closest(
|
|
|
|
|
+ ".h-full.flex.flex-col",
|
|
|
|
|
+ );
|
|
|
|
|
+ const el =
|
|
|
|
|
+ container?.querySelector<HTMLTextAreaElement>(
|
|
|
|
|
+ "textarea",
|
|
|
|
|
+ ) ?? inputRef.current;
|
|
|
|
|
+ if (el) {
|
|
|
|
|
+ el.focus();
|
|
|
|
|
+ el.setSelectionRange(prompt.length, prompt.length);
|
|
|
|
|
+ }
|
|
|
}}
|
|
}}
|
|
|
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
|
|
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
|
|
|
>
|
|
>
|
|
@@ -306,6 +373,13 @@ ${jsx}
|
|
|
<span className="text-xs text-red-500 shrink-0">failed</span>
|
|
<span className="text-xs text-red-500 shrink-0">failed</span>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ {version.usage && (
|
|
|
|
|
+ <div className="flex items-center gap-2 mt-1 ml-6">
|
|
|
|
|
+ <span className="text-[10px] font-mono text-muted-foreground/60">
|
|
|
|
|
+ {version.usage.totalTokens.toLocaleString()} tokens
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
</button>
|
|
</button>
|
|
|
))
|
|
))
|
|
|
)}
|
|
)}
|
|
@@ -330,6 +404,7 @@ ${jsx}
|
|
|
placeholder="Describe changes..."
|
|
placeholder="Describe changes..."
|
|
|
className="w-full bg-background text-base sm:text-sm resize-none outline-none placeholder:text-muted-foreground/50"
|
|
className="w-full bg-background text-base sm:text-sm resize-none outline-none placeholder:text-muted-foreground/50"
|
|
|
rows={2}
|
|
rows={2}
|
|
|
|
|
+ autoFocus
|
|
|
/>
|
|
/>
|
|
|
<div className="flex justify-between items-center mt-2">
|
|
<div className="flex justify-between items-center mt-2">
|
|
|
{versions.length > 0 ? (
|
|
{versions.length > 0 ? (
|
|
@@ -337,7 +412,6 @@ ${jsx}
|
|
|
onClick={() => {
|
|
onClick={() => {
|
|
|
setVersions([]);
|
|
setVersions([]);
|
|
|
setSelectedVersionId(null);
|
|
setSelectedVersionId(null);
|
|
|
- setStreamLines([]);
|
|
|
|
|
clear();
|
|
clear();
|
|
|
}}
|
|
}}
|
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
@@ -390,11 +464,104 @@ ${jsx}
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ // Catalog data for the catalog tab
|
|
|
|
|
+ const catalogData = useMemo(() => {
|
|
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
+ const raw = playgroundCatalog.data as any;
|
|
|
|
|
+
|
|
|
|
|
+ function extractFields(zodObj: unknown): { name: string; type: string }[] {
|
|
|
|
|
+ if (!zodObj) return [];
|
|
|
|
|
+ try {
|
|
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
+ const obj = zodObj as any;
|
|
|
|
|
+ // Zod v4: shape is a plain object; Zod v3: shape is via _def.shape()
|
|
|
|
|
+ const shape =
|
|
|
|
|
+ typeof obj.shape === "object"
|
|
|
|
|
+ ? obj.shape
|
|
|
|
|
+ : typeof obj._def?.shape === "function"
|
|
|
|
|
+ ? obj._def.shape()
|
|
|
|
|
+ : typeof obj._def?.shape === "object"
|
|
|
|
|
+ ? obj._def.shape
|
|
|
|
|
+ : null;
|
|
|
|
|
+ if (!shape) return [];
|
|
|
|
|
+
|
|
|
|
|
+ return Object.entries(shape).map(([name, schema]) => {
|
|
|
|
|
+ let type = "unknown";
|
|
|
|
|
+ try {
|
|
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
+ const s = schema as any;
|
|
|
|
|
+ const typeName: string =
|
|
|
|
|
+ s?._zod?.def?.type ?? s?._def?.typeName ?? "";
|
|
|
|
|
+ if (typeName.includes("string")) type = "string";
|
|
|
|
|
+ else if (typeName.includes("number")) type = "number";
|
|
|
|
|
+ else if (typeName.includes("boolean")) type = "boolean";
|
|
|
|
|
+ else if (typeName.includes("array")) type = "array";
|
|
|
|
|
+ else if (typeName.includes("enum")) {
|
|
|
|
|
+ const values = s?._zod?.def?.values ?? s?._def?.values;
|
|
|
|
|
+ type = Array.isArray(values) ? values.join(" | ") : "enum";
|
|
|
|
|
+ } else if (typeName.includes("union")) type = "union";
|
|
|
|
|
+ else if (typeName.includes("nullable")) {
|
|
|
|
|
+ const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
|
|
|
|
|
+ const innerName: string =
|
|
|
|
|
+ inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
|
|
|
|
|
+ if (innerName.includes("string")) type = "string?";
|
|
|
|
|
+ else if (innerName.includes("number")) type = "number?";
|
|
|
|
|
+ else if (innerName.includes("boolean")) type = "boolean?";
|
|
|
|
|
+ else if (innerName.includes("array")) type = "array?";
|
|
|
|
|
+ else if (innerName.includes("enum")) {
|
|
|
|
|
+ const values = inner?._zod?.def?.values ?? inner?._def?.values;
|
|
|
|
|
+ type = Array.isArray(values)
|
|
|
|
|
+ ? `(${values.join(" | ")})?`
|
|
|
|
|
+ : "enum?";
|
|
|
|
|
+ } else type = "optional";
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // ignore
|
|
|
|
|
+ }
|
|
|
|
|
+ return { name, type };
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const components = Object.entries(raw.components ?? {})
|
|
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
+ .map(([name, def]: [string, any]) => ({
|
|
|
|
|
+ name,
|
|
|
|
|
+ description: (def.description as string) ?? "",
|
|
|
|
|
+ props: extractFields(def.props),
|
|
|
|
|
+ slots: (def.slots as string[]) ?? [],
|
|
|
|
|
+ events: (def.events as string[]) ?? [],
|
|
|
|
|
+ }))
|
|
|
|
|
+ .sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
+
|
|
|
|
|
+ const actions = Object.entries(raw.actions ?? {})
|
|
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
+ .map(([name, def]: [string, any]) => ({
|
|
|
|
|
+ name,
|
|
|
|
|
+ description: (def.description as string) ?? "",
|
|
|
|
|
+ params: extractFields(def.params),
|
|
|
|
|
+ }))
|
|
|
|
|
+ .sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
+
|
|
|
|
|
+ return { components, actions };
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
// Code pane content
|
|
// Code pane content
|
|
|
|
|
+ const copyText =
|
|
|
|
|
+ activeTab === "stream"
|
|
|
|
|
+ ? currentRawLines.join("\n")
|
|
|
|
|
+ : activeTab === "json"
|
|
|
|
|
+ ? jsonCode
|
|
|
|
|
+ : activeTab === "nested"
|
|
|
|
|
+ ? nestedCode
|
|
|
|
|
+ : "";
|
|
|
|
|
+
|
|
|
const codePane = (
|
|
const codePane = (
|
|
|
<div className="h-full flex flex-col border-t border-border">
|
|
<div className="h-full flex flex-col border-t border-border">
|
|
|
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
|
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
|
|
- {(["json", "stream"] as const).map((tab) => (
|
|
|
|
|
|
|
+ {(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
|
|
<button
|
|
<button
|
|
|
key={tab}
|
|
key={tab}
|
|
|
onClick={() => setActiveTab(tab)}
|
|
onClick={() => setActiveTab(tab)}
|
|
@@ -408,16 +575,132 @@ ${jsx}
|
|
|
</button>
|
|
</button>
|
|
|
))}
|
|
))}
|
|
|
<div className="flex-1" />
|
|
<div className="flex-1" />
|
|
|
- <CopyButton
|
|
|
|
|
- text={activeTab === "stream" ? streamLines.join("\n") : jsonCode}
|
|
|
|
|
- className="text-muted-foreground"
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ {activeTab !== "catalog" && (
|
|
|
|
|
+ <CopyButton text={copyText} className="text-muted-foreground" />
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
<div className="flex-1 overflow-auto">
|
|
<div className="flex-1 overflow-auto">
|
|
|
- {activeTab === "stream" ? (
|
|
|
|
|
- streamLines.length > 0 ? (
|
|
|
|
|
|
|
+ {activeTab === "catalog" ? (
|
|
|
|
|
+ <div className="h-full flex flex-col text-sm">
|
|
|
|
|
+ <div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
|
|
|
|
+ {(
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "components",
|
|
|
|
|
+ label: `components (${catalogData.components.length})`,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "actions",
|
|
|
|
|
+ label: `actions (${catalogData.actions.length})`,
|
|
|
|
|
+ },
|
|
|
|
|
+ ] as const
|
|
|
|
|
+ ).map(({ key, label }) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={key}
|
|
|
|
|
+ onClick={() => setCatalogSection(key)}
|
|
|
|
|
+ className={`text-xs font-mono transition-colors ${
|
|
|
|
|
+ catalogSection === key
|
|
|
|
|
+ ? "text-foreground"
|
|
|
|
|
+ : "text-muted-foreground hover:text-foreground"
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {label}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex-1 overflow-auto p-3">
|
|
|
|
|
+ {catalogSection === "components" ? (
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ {catalogData.components.map((comp) => (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={comp.name}
|
|
|
|
|
+ className="pb-3 border-b border-border last:border-b-0"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="flex items-baseline gap-2 mb-1">
|
|
|
|
|
+ <span className="font-mono font-medium text-foreground">
|
|
|
|
|
+ {comp.name}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {comp.slots.length > 0 && (
|
|
|
|
|
+ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
|
|
|
|
+ slots: {comp.slots.join(", ")}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {comp.description && (
|
|
|
|
|
+ <p className="text-xs text-muted-foreground mb-2">
|
|
|
|
|
+ {comp.description}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {comp.props.length > 0 && (
|
|
|
|
|
+ <div className="flex flex-wrap gap-1 mb-1">
|
|
|
|
|
+ {comp.props.map((p) => (
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={p.name}
|
|
|
|
|
+ className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
|
|
|
|
|
+ >
|
|
|
|
|
+ {p.name}
|
|
|
|
|
+ <span className="text-green-700/50 dark:text-green-400/50">
|
|
|
|
|
+ : {p.type}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {comp.events.length > 0 && (
|
|
|
|
|
+ <div className="flex flex-wrap gap-1 mt-1.5">
|
|
|
|
|
+ {comp.events.map((e) => (
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={e}
|
|
|
|
|
+ className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
|
|
|
|
+ >
|
|
|
|
|
+ on.{e}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ {catalogData.actions.map((action) => (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={action.name}
|
|
|
|
|
+ className="pb-3 border-b border-border last:border-b-0"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className="font-mono font-medium text-foreground">
|
|
|
|
|
+ {action.name}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {action.description && (
|
|
|
|
|
+ <p className="text-xs text-muted-foreground mt-1 mb-2">
|
|
|
|
|
+ {action.description}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {action.params.length > 0 && (
|
|
|
|
|
+ <div className="flex flex-wrap gap-1">
|
|
|
|
|
+ {action.params.map((p) => (
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={p.name}
|
|
|
|
|
+ className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
|
|
|
|
|
+ >
|
|
|
|
|
+ {p.name}
|
|
|
|
|
+ <span className="text-green-700/50 dark:text-green-400/50">
|
|
|
|
|
+ : {p.type}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : activeTab === "stream" ? (
|
|
|
|
|
+ currentRawLines.length > 0 ? (
|
|
|
<CodeBlock
|
|
<CodeBlock
|
|
|
- code={streamLines.join("\n")}
|
|
|
|
|
|
|
+ code={currentRawLines.join("\n")}
|
|
|
lang="json"
|
|
lang="json"
|
|
|
fillHeight
|
|
fillHeight
|
|
|
hideCopyButton
|
|
hideCopyButton
|
|
@@ -427,6 +710,8 @@ ${jsx}
|
|
|
{isStreaming ? "streaming..." : "// waiting for generation"}
|
|
{isStreaming ? "streaming..." : "// waiting for generation"}
|
|
|
</div>
|
|
</div>
|
|
|
)
|
|
)
|
|
|
|
|
+ ) : activeTab === "nested" ? (
|
|
|
|
|
+ <CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
|
|
|
) : (
|
|
) : (
|
|
|
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
|
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
|
|
)}
|
|
)}
|
|
@@ -465,7 +750,11 @@ ${jsx}
|
|
|
{renderView === "preview" ? (
|
|
{renderView === "preview" ? (
|
|
|
currentTree && currentTree.root ? (
|
|
currentTree && currentTree.root ? (
|
|
|
<div className="w-full min-h-full flex items-center justify-center p-6">
|
|
<div className="w-full min-h-full flex items-center justify-center p-6">
|
|
|
- <PlaygroundRenderer spec={currentTree} loading={isStreaming} />
|
|
|
|
|
|
|
+ <PlaygroundRenderer
|
|
|
|
|
+ spec={currentTree}
|
|
|
|
|
+ data={currentTree.state}
|
|
|
|
|
+ loading={isStreaming}
|
|
|
|
|
+ />
|
|
|
</div>
|
|
</div>
|
|
|
) : (
|
|
) : (
|
|
|
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
|
|
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
|
|
@@ -507,68 +796,377 @@ ${jsx}
|
|
|
</ResizablePanelGroup>
|
|
</ResizablePanelGroup>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Mobile: Single pane with bottom tabs */}
|
|
|
|
|
|
|
+ {/* Mobile: toolbar + content + prompt input */}
|
|
|
<div className="flex lg:hidden flex-col flex-1 min-h-0">
|
|
<div className="flex lg:hidden flex-col flex-1 min-h-0">
|
|
|
- {/* Panes - all in DOM, visibility controlled */}
|
|
|
|
|
- <div className="flex-1 min-h-0 relative">
|
|
|
|
|
- <div
|
|
|
|
|
- className={`absolute inset-0 ${mobilePane === "chat" ? "" : "invisible"}`}
|
|
|
|
|
- >
|
|
|
|
|
- {chatPane}
|
|
|
|
|
- </div>
|
|
|
|
|
- <div
|
|
|
|
|
- className={`absolute inset-0 ${mobilePane === "code" ? "" : "invisible"}`}
|
|
|
|
|
- >
|
|
|
|
|
- {codePane}
|
|
|
|
|
- </div>
|
|
|
|
|
- <div
|
|
|
|
|
- className={`absolute inset-0 ${mobilePane === "preview" ? "" : "invisible"}`}
|
|
|
|
|
|
|
+ {/* Top toolbar */}
|
|
|
|
|
+ <div className="border-b border-border px-3 h-9 flex items-center gap-3 shrink-0 overflow-x-auto">
|
|
|
|
|
+ {/* Version badge */}
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setVersionsSheetOpen(true)}
|
|
|
|
|
+ className="text-xs font-mono font-medium px-1.5 py-0.5 rounded bg-muted text-foreground shrink-0"
|
|
|
>
|
|
>
|
|
|
- {previewPane}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- {/* Bottom tab bar */}
|
|
|
|
|
- <div className="border-t border-border flex shrink-0">
|
|
|
|
|
- {(
|
|
|
|
|
- [
|
|
|
|
|
- {
|
|
|
|
|
- key: "chat",
|
|
|
|
|
- label: "Chat",
|
|
|
|
|
- icon: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- key: "code",
|
|
|
|
|
- label: "Code",
|
|
|
|
|
- icon: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- key: "preview",
|
|
|
|
|
- label: "Preview",
|
|
|
|
|
- icon: "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
|
|
|
|
|
- },
|
|
|
|
|
- ] as const
|
|
|
|
|
- ).map(({ key, label, icon }) => (
|
|
|
|
|
|
|
+ v
|
|
|
|
|
+ {versions.length > 0
|
|
|
|
|
+ ? versions.findIndex((v) => v.id === selectedVersionId) + 1 ||
|
|
|
|
|
+ versions.length
|
|
|
|
|
+ : 0}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {/* Code tabs */}
|
|
|
|
|
+ {(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={tab}
|
|
|
|
|
+ onClick={() => setMobileView(tab)}
|
|
|
|
|
+ className={`text-xs font-mono transition-colors shrink-0 ${
|
|
|
|
|
+ mobileView === tab
|
|
|
|
|
+ ? "text-foreground"
|
|
|
|
|
+ : "text-muted-foreground hover:text-foreground"
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {tab}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ <div className="flex-1" />
|
|
|
|
|
+ {/* Preview / code toggle */}
|
|
|
|
|
+ {[
|
|
|
|
|
+ { key: "preview" as const, label: "preview" },
|
|
|
|
|
+ { key: "generated-code" as const, label: "code" },
|
|
|
|
|
+ ].map(({ key, label }) => (
|
|
|
<button
|
|
<button
|
|
|
key={key}
|
|
key={key}
|
|
|
- onClick={() => setMobilePane(key)}
|
|
|
|
|
- className={`flex-1 py-3 flex flex-col items-center gap-1 transition-colors ${
|
|
|
|
|
- mobilePane === key ? "text-foreground" : "text-muted-foreground"
|
|
|
|
|
|
|
+ onClick={() => setMobileView(key)}
|
|
|
|
|
+ className={`text-xs font-mono transition-colors shrink-0 ${
|
|
|
|
|
+ mobileView === key
|
|
|
|
|
+ ? "text-foreground"
|
|
|
|
|
+ : "text-muted-foreground hover:text-foreground"
|
|
|
}`}
|
|
}`}
|
|
|
>
|
|
>
|
|
|
- <svg
|
|
|
|
|
- className="w-5 h-5"
|
|
|
|
|
- fill="none"
|
|
|
|
|
- stroke="currentColor"
|
|
|
|
|
- viewBox="0 0 24 24"
|
|
|
|
|
- strokeWidth={1.5}
|
|
|
|
|
- >
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d={icon} />
|
|
|
|
|
- </svg>
|
|
|
|
|
- <span className="text-xs">{label}</span>
|
|
|
|
|
|
|
+ {label}
|
|
|
</button>
|
|
</button>
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Main content area */}
|
|
|
|
|
+ <div className="flex-1 min-h-0 overflow-auto">
|
|
|
|
|
+ {mobileView === "catalog" ? (
|
|
|
|
|
+ <div className="h-full flex flex-col text-sm">
|
|
|
|
|
+ <div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
|
|
|
|
+ {(
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "components",
|
|
|
|
|
+ label: `components (${catalogData.components.length})`,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "actions",
|
|
|
|
|
+ label: `actions (${catalogData.actions.length})`,
|
|
|
|
|
+ },
|
|
|
|
|
+ ] as const
|
|
|
|
|
+ ).map(({ key, label }) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={key}
|
|
|
|
|
+ onClick={() => setCatalogSection(key)}
|
|
|
|
|
+ className={`text-xs font-mono transition-colors ${
|
|
|
|
|
+ catalogSection === key
|
|
|
|
|
+ ? "text-foreground"
|
|
|
|
|
+ : "text-muted-foreground hover:text-foreground"
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {label}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex-1 overflow-auto p-3">
|
|
|
|
|
+ {catalogSection === "components" ? (
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ {catalogData.components.map((comp) => (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={comp.name}
|
|
|
|
|
+ className="pb-3 border-b border-border last:border-b-0"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="flex items-baseline gap-2 mb-1">
|
|
|
|
|
+ <span className="font-mono font-medium text-foreground">
|
|
|
|
|
+ {comp.name}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {comp.slots.length > 0 && (
|
|
|
|
|
+ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
|
|
|
|
+ slots: {comp.slots.join(", ")}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {comp.description && (
|
|
|
|
|
+ <p className="text-xs text-muted-foreground mb-2">
|
|
|
|
|
+ {comp.description}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {comp.props.length > 0 && (
|
|
|
|
|
+ <div className="flex flex-wrap gap-1 mb-1">
|
|
|
|
|
+ {comp.props.map((p) => (
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={p.name}
|
|
|
|
|
+ className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
|
|
|
|
|
+ >
|
|
|
|
|
+ {p.name}
|
|
|
|
|
+ <span className="text-green-700/50 dark:text-green-400/50">
|
|
|
|
|
+ : {p.type}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {comp.events.length > 0 && (
|
|
|
|
|
+ <div className="flex flex-wrap gap-1 mt-1.5">
|
|
|
|
|
+ {comp.events.map((e) => (
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={e}
|
|
|
|
|
+ className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
|
|
|
|
+ >
|
|
|
|
|
+ on.{e}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ {catalogData.actions.map((action) => (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={action.name}
|
|
|
|
|
+ className="pb-3 border-b border-border last:border-b-0"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className="font-mono font-medium text-foreground">
|
|
|
|
|
+ {action.name}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {action.description && (
|
|
|
|
|
+ <p className="text-xs text-muted-foreground mt-1 mb-2">
|
|
|
|
|
+ {action.description}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {action.params.length > 0 && (
|
|
|
|
|
+ <div className="flex flex-wrap gap-1">
|
|
|
|
|
+ {action.params.map((p) => (
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={p.name}
|
|
|
|
|
+ className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
|
|
|
|
|
+ >
|
|
|
|
|
+ {p.name}
|
|
|
|
|
+ <span className="text-green-700/50 dark:text-green-400/50">
|
|
|
|
|
+ : {p.type}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : mobileView === "stream" ? (
|
|
|
|
|
+ currentRawLines.length > 0 ? (
|
|
|
|
|
+ <CodeBlock
|
|
|
|
|
+ code={currentRawLines.join("\n")}
|
|
|
|
|
+ lang="json"
|
|
|
|
|
+ fillHeight
|
|
|
|
|
+ hideCopyButton
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="text-muted-foreground/50 p-3 text-sm font-mono">
|
|
|
|
|
+ {isStreaming ? "streaming..." : "// waiting for generation"}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ ) : mobileView === "nested" ? (
|
|
|
|
|
+ <CodeBlock
|
|
|
|
|
+ code={nestedCode}
|
|
|
|
|
+ lang="json"
|
|
|
|
|
+ fillHeight
|
|
|
|
|
+ hideCopyButton
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : mobileView === "json" ? (
|
|
|
|
|
+ <CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
|
|
|
|
+ ) : mobileView === "preview" ? (
|
|
|
|
|
+ currentTree && currentTree.root ? (
|
|
|
|
|
+ <div className="w-full min-h-full flex items-center justify-center p-6">
|
|
|
|
|
+ <PlaygroundRenderer
|
|
|
|
|
+ spec={currentTree}
|
|
|
|
|
+ data={currentTree.state}
|
|
|
|
|
+ loading={isStreaming}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="h-full flex flex-col items-center justify-center text-center px-4">
|
|
|
|
|
+ {isStreaming ? (
|
|
|
|
|
+ <p className="text-sm text-muted-foreground/50">
|
|
|
|
|
+ generating...
|
|
|
|
|
+ </p>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <p className="text-sm text-muted-foreground mb-4">
|
|
|
|
|
+ Describe what you want to build, then iterate on it.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <div className="flex flex-wrap gap-2 justify-center">
|
|
|
|
|
+ {EXAMPLE_PROMPTS.map((prompt) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={prompt}
|
|
|
|
|
+ onMouseDown={(e) => {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ flushSync(() => setInputValue(prompt));
|
|
|
|
|
+ mobileInputRef.current?.focus();
|
|
|
|
|
+ mobileInputRef.current?.setSelectionRange(
|
|
|
|
|
+ prompt.length,
|
|
|
|
|
+ prompt.length,
|
|
|
|
|
+ );
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
|
|
|
|
|
+ >
|
|
|
|
|
+ {prompt}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ /* generated-code */
|
|
|
|
|
+ <CodeBlock
|
|
|
|
|
+ code={generatedCode}
|
|
|
|
|
+ lang="tsx"
|
|
|
|
|
+ fillHeight
|
|
|
|
|
+ hideCopyButton
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Prompt input pinned to bottom */}
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="border-t border-border p-3 shrink-0 cursor-text"
|
|
|
|
|
+ onMouseDown={(e) => {
|
|
|
|
|
+ const target = e.target as HTMLElement;
|
|
|
|
|
+ if (!target.closest("button") && target.tagName !== "TEXTAREA") {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ mobileInputRef.current?.focus();
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ ref={mobileInputRef}
|
|
|
|
|
+ value={inputValue}
|
|
|
|
|
+ onChange={(e) => setInputValue(e.target.value)}
|
|
|
|
|
+ onKeyDown={handleKeyDown}
|
|
|
|
|
+ placeholder="Describe changes..."
|
|
|
|
|
+ className="w-full bg-background text-base resize-none outline-none placeholder:text-muted-foreground/50"
|
|
|
|
|
+ rows={2}
|
|
|
|
|
+ />
|
|
|
|
|
+ <div className="flex justify-between items-center mt-2">
|
|
|
|
|
+ {versions.length > 0 ? (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setVersions([]);
|
|
|
|
|
+ setSelectedVersionId(null);
|
|
|
|
|
+ clear();
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
|
|
|
+ >
|
|
|
|
|
+ Clear
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div />
|
|
|
|
|
+ )}
|
|
|
|
|
+ {isStreaming ? (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => clear()}
|
|
|
|
|
+ className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
|
|
|
|
|
+ aria-label="Stop"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg
|
|
|
|
|
+ width="14"
|
|
|
|
|
+ height="14"
|
|
|
|
|
+ viewBox="0 0 24 24"
|
|
|
|
|
+ fill="currentColor"
|
|
|
|
|
+ stroke="none"
|
|
|
|
|
+ >
|
|
|
|
|
+ <rect x="6" y="6" width="12" height="12" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={handleSubmit}
|
|
|
|
|
+ disabled={!inputValue.trim()}
|
|
|
|
|
+ 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"
|
|
|
|
|
+ aria-label="Send"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg
|
|
|
|
|
+ width="14"
|
|
|
|
|
+ height="14"
|
|
|
|
|
+ viewBox="0 0 24 24"
|
|
|
|
|
+ fill="none"
|
|
|
|
|
+ stroke="currentColor"
|
|
|
|
|
+ strokeWidth="2"
|
|
|
|
|
+ strokeLinecap="round"
|
|
|
|
|
+ strokeLinejoin="round"
|
|
|
|
|
+ >
|
|
|
|
|
+ <path d="M5 12h14" />
|
|
|
|
|
+ <path d="m12 5 7 7-7 7" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Versions sheet */}
|
|
|
|
|
+ <Sheet open={versionsSheetOpen} onOpenChange={setVersionsSheetOpen}>
|
|
|
|
|
+ <SheetContent>
|
|
|
|
|
+ <SheetTitle className="text-sm font-mono mb-4">Versions</SheetTitle>
|
|
|
|
|
+ <div className="flex-1 overflow-y-auto space-y-1">
|
|
|
|
|
+ {versions.map((version, index) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={version.id}
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setSelectedVersionId(version.id);
|
|
|
|
|
+ setVersionsSheetOpen(false);
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
|
|
|
|
|
+ selectedVersionId === version.id
|
|
|
|
|
+ ? "bg-muted text-foreground"
|
|
|
|
|
+ : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <span className="text-xs font-mono text-muted-foreground/70 shrink-0">
|
|
|
|
|
+ v{index + 1}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="truncate flex-1">{version.prompt}</span>
|
|
|
|
|
+ {version.status === "generating" && (
|
|
|
|
|
+ <span className="text-xs text-muted-foreground shrink-0 animate-pulse">
|
|
|
|
|
+ ...
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {version.status === "error" && (
|
|
|
|
|
+ <span className="text-xs text-red-500 shrink-0">
|
|
|
|
|
+ failed
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {version.usage && (
|
|
|
|
|
+ <div className="flex items-center gap-2 mt-1 ml-6">
|
|
|
|
|
+ <span className="text-[10px] font-mono text-muted-foreground/60">
|
|
|
|
|
+ {version.usage.totalTokens.toLocaleString()} tokens
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ {versions.length === 0 && (
|
|
|
|
|
+ <p className="text-sm text-muted-foreground px-3">
|
|
|
|
|
+ No versions yet. Enter a prompt to get started.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </SheetContent>
|
|
|
|
|
+ </Sheet>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<Toaster position="bottom-right" />
|
|
<Toaster position="bottom-right" />
|