File size: 16,373 Bytes
76fc93a
 
 
 
 
 
8fc8501
76fc93a
 
8fc8501
76fc93a
 
8fc8501
 
76fc93a
 
 
 
8fc8501
76fc93a
 
 
8fc8501
 
 
 
 
 
 
79008fb
 
 
 
 
 
 
76fc93a
 
 
 
 
 
8fc8501
 
76fc93a
 
 
 
 
 
 
 
8fc8501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76fc93a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fc8501
 
76fc93a
8fc8501
 
 
 
 
76fc93a
 
 
8fc8501
 
 
76fc93a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fc8501
 
 
 
 
 
 
 
79008fb
8fc8501
76fc93a
09a820f
 
 
 
76fc93a
 
8fc8501
76fc93a
 
 
 
 
 
 
 
 
 
8fc8501
27abbe5
8fc8501
 
76fc93a
 
 
 
 
 
 
 
09a820f
 
76fc93a
 
09a820f
 
76fc93a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fc8501
 
 
 
 
 
79008fb
8fc8501
 
 
 
 
76fc93a
 
 
56a91d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76fc93a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56a91d6
 
 
 
 
 
 
 
 
76fc93a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fc8501
76fc93a
8fc8501
 
 
 
 
 
 
 
 
 
76fc93a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fc8501
09a820f
8fc8501
76fc93a
09a820f
 
 
76fc93a
 
 
be7d6fa
76fc93a
 
 
 
 
 
be7d6fa
76fc93a
 
 
09a820f
76fc93a
 
 
 
 
 
 
8fc8501
 
 
76fc93a
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
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();
  // The banner is a special embed: it renders edge-to-edge in a 5:2 box (the
  // hero) rather than as a height-reporting chart. The studio must preview it
  // the same way (fullBleed srcdoc + constrained 5:2 stage) so it's WYSIWYG.
  const isBanner = src === "banner.html";
  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. Banner previews use fullBleed so the content fills the
  // iframe edge-to-edge, matching FrontmatterHero and the published output.
  const srcdoc = useMemo(() => {
    if (!html) return "";
    return buildDoc(html, { isDark, primaryColor, fullBleed: isBanner });
  }, [html, isDark, primaryColor, isBanner]);

  // 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 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 ${isBanner ? "es-preview__frame-container--banner" : ""} ${viewMode !== "preview" || selectedFile ? "es-hidden" : ""}`}
          >
            {html ? (
              <div
                className={`es-preview__stage ${isBanner ? "es-preview__stage--banner" : ""}`}
              >
                <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>
            ) : (
              <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>
  );
}