import { useState, useEffect, useRef, useMemo, useCallback, type KeyboardEvent } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { X, Send, Square, Code, Eye, Plus, Loader } from "lucide-react"; import type { UIMessage } from "ai"; import type { EmbedStore } from "../editor/embeds/embed-store"; import type { EmbedDataStore } from "../editor/embeds/embed-data-store"; import { buildDoc } from "../editor/embeds/build-doc"; import { useEmbedChat } from "../hooks/useEmbedChat"; import { useEmbedData } from "../hooks/useEmbedData"; import { useTheme } from "../hooks/useTheme"; import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts"; import { FilesSidebar } from "./FilesSidebar"; import { DataFileViewer } from "./DataFileViewer"; interface EmbedStudioProps { src: string; embedStore: EmbedStore | null; dataStore?: EmbedDataStore | null; modelRef: React.RefObject; userId: string; onClose: () => void; /** * Invoked when the agent chooses a descriptive filename for a brand * new chart. Parent should update the htmlEmbed node in the doc and * lift the new src into its own state so subsequent actions (close, * reopen, etc.) reference the renamed file. */ onRename?: (oldSrc: string, newSrc: string) => void; /** * Invoked when the user picks a different chart from the sidebar. * Parent is responsible for updating the studio's `src` and (if it * wants a fresh chat scope) rotating the React key on this * component so persisted messages reload for the new file. */ onSelectChart?: (name: string) => void; } const EMBED_TOOL_LABELS: Record = { createEmbed: ["Creating chart...", "Created chart"], patchEmbed: ["Updating chart...", "Updated chart"], readEmbed: ["Reading chart...", "Read chart"], listDataFiles: ["Listing data files...", "Listed data files"], readDataFile: ["Reading data file...", "Read data file"], }; function embedToolLabel(name: string, state: string): string { const pair = EMBED_TOOL_LABELS[name]; if (!pair) return name; return state === "result" ? pair[1] : pair[0]; } function toolSubtitle( name: string, input: unknown, ): string | null { if (!input || typeof input !== "object") return null; const args = input as Record; switch (name) { case "createEmbed": return typeof args.title === "string" ? String(args.title) : null; case "readDataFile": return typeof args.name === "string" ? String(args.name) : null; case "patchEmbed": { const search = typeof args.search === "string" ? args.search : ""; const firstLine = search.split("\n")[0]?.trim() ?? ""; return firstLine ? firstLine.slice(0, 60) : null; } default: return null; } } function MessageBubble({ message }: { message: UIMessage }) { const isUser = message.role === "user"; return (
{message.parts.map((part, i) => { if (part.type === "text") { return (
{isUser ? part.text : ( {part.text} )}
); } if (isToolPart(part)) { const tool = normalizeToolPart(part); if (!tool) return null; const subtitle = toolSubtitle(tool.toolName, tool.input); const isDone = tool.state === "result"; return (
{!isDone && } {embedToolLabel(tool.toolName, tool.state)} {subtitle && ( {subtitle} )}
); } return null; })}
); } function EmbedAgentStatus({ messages }: { messages: UIMessage[] }) { const last = messages[messages.length - 1]; if (!last) return null; if (last.role === "assistant") { const parts = last.parts ?? []; const runningTools = parts .map((p) => normalizeToolPart(p)) .filter((t): t is NonNullable => t !== null && t.state !== "result") .map((t) => t.toolName); if (runningTools.length > 0) { const pair = EMBED_TOOL_LABELS[runningTools[runningTools.length - 1]]; const text = pair ? pair[0] : runningTools[runningTools.length - 1]; return (
{text}
); } const hasText = parts.some((p: any) => p.type === "text" && p.text?.trim()); if (hasText) return null; } return (
Thinking...
); } export function EmbedStudio({ src, embedStore, dataStore = null, modelRef, userId, onClose, onRename, onSelectChart, }: EmbedStudioProps) { const { isDark, primaryColor } = useTheme(); const [html, setHtml] = useState(""); const [viewMode, setViewMode] = useState<"preview" | "code">("preview"); const [selectedDataFile, setSelectedDataFile] = useState(null); const messagesEndRef = useRef(null); const textareaRef = useRef(null); // Iframe double-buffer const frameA = useRef(null); const frameB = useRef(null); const [activeFrame, setActiveFrame] = useState<"a" | "b">("a"); const activeRef = useRef<"a" | "b">("a"); activeRef.current = activeFrame; const data = useEmbedData({ dataStore, userId }); const chat = useEmbedChat({ embedStore, dataStore, src, modelRef, userId, isDark, onRename }); const selectedFile = selectedDataFile ? data.getFile(selectedDataFile) : undefined; // Sync from embed store useEffect(() => { if (!embedStore || !src) return; setHtml(embedStore.get(src)); return embedStore.observeKey(src, setHtml); }, [embedStore, src]); // Build srcdoc const srcdoc = useMemo(() => { if (!html) return ""; return buildDoc(html, { isDark, primaryColor }); }, [html, isDark, primaryColor]); // Load srcdoc into inactive frame useEffect(() => { if (!srcdoc) return; const next = activeRef.current === "a" ? "b" : "a"; const frame = next === "a" ? frameA.current : frameB.current; if (frame) frame.srcdoc = srcdoc; }, [srcdoc]); const handleFrameLoad = useCallback((slot: "a" | "b") => { if (slot !== activeRef.current) { setActiveFrame(slot); } }, []); // Auto-scroll messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [chat.messages]); // Dev-only hook: let demo scripts inject a fake assistant reply into // the embed chat so the recording shows the agent "responding" // without waiting for a real LLM round-trip. useEffect(() => { if (!import.meta.env.DEV) return; const handler = (event: Event) => { const detail = (event as CustomEvent).detail as | { messages?: UIMessage[] } | undefined; if (Array.isArray(detail?.messages)) { chat.setMessages(detail.messages); } }; window.addEventListener("__demo-embed-chat", handler); return () => window.removeEventListener("__demo-embed-chat", handler); }, [chat]); const handleSend = useCallback(() => { const text = chat.input.trim(); if (!text || chat.isLoading) return; chat.sendMessage(text); chat.setInput(""); }, [chat]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend], ); const handleTextareaInput = useCallback(() => { const ta = textareaRef.current; if (ta) { ta.style.height = "auto"; ta.style.height = Math.min(ta.scrollHeight, 160) + "px"; } }, []); const frameStyle = (slot: "a" | "b"): React.CSSProperties => ({ position: "absolute", inset: 0, border: "none", width: "100%", height: "100%", transition: "opacity 120ms ease", opacity: activeFrame === slot ? 1 : 0, zIndex: activeFrame === slot ? 2 : 1, }); return (
{/* Header */}
📊 Embed Studio {src}
{/* Body: split panel */}
{/* Far-left: Files */} {/* Left: Chat */}
{chat.messages.length === 0 && (() => { const isBanner = src === "banner.html"; const hasChart = html.trim().length > 0; let title: string; let hint: string; if (isBanner) { title = hasChart ? "Ask for changes to the banner." : "Describe the banner you want."; hint = hasChart ? 'Example: "make it slower and more abstract"' : 'Example: "abstract flow field in the primary color, very subtle motion"'; } else if (hasChart) { title = "Ask for changes to the chart."; hint = 'Example: "switch to a sequential palette" or "highlight the biggest bar"'; } else { title = "Describe the chart you want to create."; hint = 'Example: "Create a bar chart showing model sizes with categorical colors"'; } return (

{title}

{hint}

); })()} {chat.messages.map((msg) => ( ))} {chat.isLoading && chat.messages.length > 0 && ( )}
{/* Input */}
{chat.error && (
{chat.error.message || "An error occurred"}
)}