tfrere's picture
tfrere HF Staff
feat(embed-studio): clickable chart list to switch files
79008fb
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<string>;
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<string, [string, string]> = {
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<string, unknown>;
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 (
<div className={`es-message ${isUser ? "es-message--user" : "es-message--ai"}`}>
{message.parts.map((part, i) => {
if (part.type === "text") {
return (
<div key={i} className="es-message__text">
{isUser ? part.text : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{part.text}</ReactMarkdown>
)}
</div>
);
}
if (isToolPart(part)) {
const tool = normalizeToolPart(part);
if (!tool) return null;
const subtitle = toolSubtitle(tool.toolName, tool.input);
const isDone = tool.state === "result";
return (
<div
key={i}
className={`es-message__tool ${isDone ? "es-message__tool--done" : "es-message__tool--running"}`}
>
{!isDone && <Loader size={11} className="spin" />}
<span className="es-message__tool-name">
{embedToolLabel(tool.toolName, tool.state)}
</span>
{subtitle && (
<span className="es-message__tool-subtitle">{subtitle}</span>
)}
</div>
);
}
return null;
})}
</div>
);
}
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<typeof t> => 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 (
<div className="chat-panel__thinking">
<Loader size={13} className="spin" />
<span className="shimmer-text">{text}</span>
</div>
);
}
const hasText = parts.some((p: any) => p.type === "text" && p.text?.trim());
if (hasText) return null;
}
return (
<div className="chat-panel__thinking">
<Loader size={13} className="spin" />
<span className="shimmer-text">Thinking...</span>
</div>
);
}
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<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Iframe double-buffer
const frameA = useRef<HTMLIFrameElement>(null);
const frameB = useRef<HTMLIFrameElement>(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<HTMLTextAreaElement>) => {
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 (
<div className="embed-studio">
{/* Header */}
<div className="es-header">
<div className="es-header__left">
<span className="es-header__icon">📊</span>
<span className="es-header__title">Embed Studio</span>
<code className="es-header__src">{src}</code>
</div>
<div className="es-header__actions">
<button
className="embed-btn"
onClick={() => chat.clearMessages()}
aria-label="New conversation"
title="New conversation"
>
<Plus size={16} />
</button>
<button
className="embed-btn"
onClick={onClose}
aria-label="Close Embed Studio"
>
<X size={16} />
</button>
</div>
</div>
{/* Body: split panel */}
<div className="es-body">
{/* Far-left: Files */}
<FilesSidebar
embedStore={embedStore}
currentSrc={src}
dataFiles={data.files}
selectedDataFile={selectedDataFile}
onSelectChart={onSelectChart}
onSelectDataFile={setSelectedDataFile}
onUploadFiles={data.uploadFiles}
onRemoveDataFile={data.removeFile}
/>
{/* Left: Chat */}
<div className="es-chat">
<div className="es-chat__messages">
{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 (
<div className="es-chat__empty">
<p>{title}</p>
<p className="es-chat__hint">{hint}</p>
</div>
);
})()}
{chat.messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{chat.isLoading && chat.messages.length > 0 && (
<EmbedAgentStatus messages={chat.messages} />
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="es-chat__input-area">
{chat.error && (
<div className="es-chat__error">
{chat.error.message || "An error occurred"}
</div>
)}
<div className="es-chat__input-row">
<textarea
ref={textareaRef}
value={chat.input}
onChange={(e) => chat.setInput(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleTextareaInput}
placeholder={
html.trim().length > 0
? src === "banner.html"
? "Tweak the banner..."
: "Tweak the chart..."
: src === "banner.html"
? "Describe your banner..."
: "Describe your chart..."
}
rows={1}
className="es-chat__textarea"
/>
{chat.isLoading ? (
<button
className="es-chat__send-btn"
onClick={chat.stop}
aria-label="Stop"
>
<Square size={16} />
</button>
) : (
<button
className="es-chat__send-btn"
onClick={handleSend}
disabled={!chat.input.trim()}
aria-label="Send"
>
<Send size={16} />
</button>
)}
</div>
</div>
</div>
{/* Right: Preview (chart) or data file viewer */}
<div className="es-preview">
{selectedFile ? (
<DataFileViewer
file={selectedFile}
onClose={() => setSelectedDataFile(null)}
/>
) : null}
<div
className={`es-preview__toolbar ${selectedFile ? "es-hidden" : ""}`}
>
<button
className={`es-preview__tab ${viewMode === "preview" ? "es-preview__tab--active" : ""}`}
onClick={() => setViewMode("preview")}
>
<Eye size={14} />
Preview
</button>
<button
className={`es-preview__tab ${viewMode === "code" ? "es-preview__tab--active" : ""}`}
onClick={() => setViewMode("code")}
>
<Code size={14} />
Code
</button>
<div style={{ flex: 1 }} />
<button className="embed-btn embed-btn-primary" onClick={onClose}>
Save & Close
</button>
</div>
{/* Both panels always mounted - toggle with hidden class to preserve iframe state */}
<div
className={`es-preview__frame-container ${viewMode !== "preview" || selectedFile ? "es-hidden" : ""}`}
>
{html ? (
<>
<iframe
ref={frameA}
title="Chart preview A"
sandbox="allow-scripts allow-same-origin"
onLoad={() => handleFrameLoad("a")}
style={frameStyle("a")}
/>
<iframe
ref={frameB}
title="Chart preview B"
sandbox="allow-scripts allow-same-origin"
onLoad={() => handleFrameLoad("b")}
style={frameStyle("b")}
/>
</>
) : (
<div className="es-preview__empty">
<span style={{ fontSize: 32, opacity: 0.4 }}>📊</span>
<p>Chart preview will appear here</p>
</div>
)}
</div>
<div
className={`es-preview__code ${viewMode !== "code" || selectedFile ? "es-hidden" : ""}`}
>
<pre className="es-preview__pre">
<code>{html || "// No chart HTML yet"}</code>
</pre>
</div>
</div>
</div>
</div>
);
}