tfrere HF Staff commited on
Commit
8fc8501
·
1 Parent(s): 19b3f9c

feat(editor): embed studio with data files and agent-aware editing

Browse files

Editor
- New Embed Studio panel for AI-assisted chart creation: upload and
manage data files (3MB limit), browse them in a dedicated viewer,
and iterate on the generated HTML via a chat wired to a dedicated
embed agent (embed-chat + embed-system-prompt + embed-tools +
tiptap-catalog) that can create, rename and edit embeds.
- Tiptap extensions for agent-driven editing:
- agent-focus: Yjs-awareness ranges so every peer sees where
another user's AI is working ("<Name> agent" label).
- agent-highlight: local PM decoration to mark the paragraph the
agent is currently rewriting.
- agent-rewrite: typewriter-animated range rewrite command used
by the rephrase flow.
- Rephrase flow now goes through agent-rewrite (typewriter) instead
of a plain replace; bold demonstration moved to the floating bubble
toolbar above a selection.
- SlashMenu reorganised with grouping and dev-only items dropped.
- New components: DataFileViewer, FilesSidebar, UploadZone.
- Refreshed TopBar, ChatPanel, SettingsDrawer, Editor.
- Hooks: useEmbedChat + useEmbedData (data file store) + refreshed
useAgentChat.
- data-files helper + ai-tool-parts update.

Made-with: Cursor

backend/src/agent/embed-chat.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
  EMBED_SYSTEM_PROMPT,
3
  BANNER_SYSTEM_PROMPT,
4
  buildEmbedContext,
 
5
  } from "./embed-system-prompt.js";
6
  import { embedTools } from "./embed-tools.js";
7
  import { streamChatResponse } from "./stream-handler.js";
@@ -11,10 +12,22 @@ export async function handleEmbedChat(req: Request, res: Response) {
11
  const { context } = req.body;
12
  const contextBlock = buildEmbedContext(context?.embedHtml);
13
  const isBanner = Boolean(context?.isBanner);
 
 
14
 
15
  const parts = [EMBED_SYSTEM_PROMPT];
16
  if (isBanner) parts.push(BANNER_SYSTEM_PROMPT);
 
 
 
 
 
 
 
 
 
17
  if (contextBlock) parts.push(`## Current chart\n\n${contextBlock}`);
 
18
  const systemPrompt = parts.join("\n\n");
19
 
20
  return streamChatResponse(req, res, {
 
2
  EMBED_SYSTEM_PROMPT,
3
  BANNER_SYSTEM_PROMPT,
4
  buildEmbedContext,
5
+ buildDataFilesContext,
6
  } from "./embed-system-prompt.js";
7
  import { embedTools } from "./embed-tools.js";
8
  import { streamChatResponse } from "./stream-handler.js";
 
12
  const { context } = req.body;
13
  const contextBlock = buildEmbedContext(context?.embedHtml);
14
  const isBanner = Boolean(context?.isBanner);
15
+ const isScratch = Boolean(context?.isScratch);
16
+ const dataFilesBlock = buildDataFilesContext(context?.dataFiles);
17
 
18
  const parts = [EMBED_SYSTEM_PROMPT];
19
  if (isBanner) parts.push(BANNER_SYSTEM_PROMPT);
20
+ if (isScratch) {
21
+ parts.push(
22
+ "## New chart\n\nThis chart is BRAND NEW - it has an auto-generated " +
23
+ "placeholder filename. Your VERY FIRST `createEmbed` call MUST include " +
24
+ "a descriptive `filename` slug (kebab-case, no extension, 2-4 words) " +
25
+ "that reflects what the chart represents. The client will rename the " +
26
+ "underlying file and keep the document in sync.",
27
+ );
28
+ }
29
  if (contextBlock) parts.push(`## Current chart\n\n${contextBlock}`);
30
+ if (dataFilesBlock) parts.push(`## Attached data\n\n${dataFilesBlock}`);
31
  const systemPrompt = parts.join("\n\n");
32
 
33
  return streamChatResponse(req, res, {
backend/src/agent/embed-system-prompt.ts CHANGED
@@ -11,10 +11,24 @@ You help users create, edit, and improve interactive data visualizations.
11
  ## Tools
12
 
13
  - **createEmbed**: Create or fully replace the chart HTML. Use for new charts or extensive rewrites.
 
 
 
 
 
14
  - **patchEmbed**: Surgically edit a specific part of the chart. Preferred for small changes.
15
  The search string must be an exact verbatim copy from the current HTML.
16
  Call readEmbed first if you're unsure of the exact content.
17
  - **readEmbed**: Read the full current HTML. Call before patching.
 
 
 
 
 
 
 
 
 
18
 
19
  ## Chart conventions
20
 
@@ -106,28 +120,63 @@ Every chart is a self-contained HTML fragment with:
106
  - Select closest previous sibling with root class, fallback to last unmounted
107
 
108
  ### Data
109
- - For inline data, embed it directly in the script
110
- - For CSV loading, implement \`fetchFirstAvailable(['/data/file.csv', './assets/data/file.csv'])\`
111
- - Handle errors gracefully with a red \`<pre>\` message
 
 
 
112
 
113
  ## Guidelines
114
 
115
  1. **Act immediately.** When the user asks for a chart, create it right away with createEmbed.
116
  2. **Use patchEmbed for edits.** Read the current chart first, then apply surgical patches.
117
  3. **Follow conventions strictly.** Every chart must have: root div, scoped style, IIFE script, ColorPalettes, mount guard, responsive layout.
118
- 4. **Be creative with design.** Make charts visually appealing with clean typography, proper spacing, and smooth transitions.
119
- 5. **Keep it concise.** Don't explain what you're doing unless asked.
 
 
 
 
120
 
121
  ## Context
122
 
123
  The current chart HTML (if any) is provided between <embed> tags.
124
- Use this to understand the existing chart structure before making edits.`;
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  export function buildEmbedContext(embedHtml?: string): string {
127
  if (!embedHtml) return "";
128
  return `<embed>\n${embedHtml}\n</embed>`;
129
  }
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  /**
132
  * Extra instructions layered on top of the base system prompt when the
133
  * chart being edited is the article banner (top-of-article hero).
 
11
  ## Tools
12
 
13
  - **createEmbed**: Create or fully replace the chart HTML. Use for new charts or extensive rewrites.
14
+ On the FIRST call for a new chart, you MUST pass a descriptive \`filename\`
15
+ slug (kebab-case, no extension, e.g. "attention-heatmap", "model-accuracy",
16
+ "sales-by-region") that reflects what the chart represents. The client
17
+ will rename the underlying file and keep the doc in sync. Omit
18
+ \`filename\` on subsequent calls that only refresh the same chart.
19
  - **patchEmbed**: Surgically edit a specific part of the chart. Preferred for small changes.
20
  The search string must be an exact verbatim copy from the current HTML.
21
  Call readEmbed first if you're unsure of the exact content.
22
  - **readEmbed**: Read the full current HTML. Call before patching.
23
+ - **listDataFiles**: List user-attached data files (CSV, TSV, JSON, NDJSON, TXT).
24
+ Call this as soon as the user references "the data", "my dataset", a filename,
25
+ or otherwise implies real content should drive the chart.
26
+ - **readDataFile**: Read the raw content of a single attached data file.
27
+ Use to inspect structure, sample values, and pull the data you need. Because
28
+ the chart iframe is sandboxed and cannot fetch external files, you must
29
+ **inline** the data you use inside the chart script (ideally as a parsed
30
+ JSON array). For very large files, aggregate / downsample the data before
31
+ inlining it rather than embedding the raw content verbatim.
32
 
33
  ## Chart conventions
34
 
 
120
  - Select closest previous sibling with root class, fallback to last unmounted
121
 
122
  ### Data
123
+ - Prefer user-attached data files: call \`listDataFiles\` first, then
124
+ \`readDataFile\` on the one that matches the user's intent, and **inline**
125
+ the parsed data directly in the script (the iframe is sandboxed and cannot
126
+ fetch external files).
127
+ - For demo/synthetic data, embed it inline as a JS array/object literal.
128
+ - Handle parse/render errors gracefully with a red \`<pre>\` message.
129
 
130
  ## Guidelines
131
 
132
  1. **Act immediately.** When the user asks for a chart, create it right away with createEmbed.
133
  2. **Use patchEmbed for edits.** Read the current chart first, then apply surgical patches.
134
  3. **Follow conventions strictly.** Every chart must have: root div, scoped style, IIFE script, ColorPalettes, mount guard, responsive layout.
135
+ 4. **Use attached data when relevant.** If the user mentions their data, a
136
+ filename, or a dataset, inspect the <data-files> manifest, call
137
+ readDataFile on the best match, and inline a parsed subset into the chart.
138
+ Never fabricate column names - only use what the file actually contains.
139
+ 5. **Be creative with design.** Make charts visually appealing with clean typography, proper spacing, and smooth transitions.
140
+ 6. **Keep it concise.** Don't explain what you're doing unless asked.
141
 
142
  ## Context
143
 
144
  The current chart HTML (if any) is provided between <embed> tags.
145
+ Use this to understand the existing chart structure before making edits.
146
+
147
+ When data files are attached, a manifest of each file (name, type, size,
148
+ shape) is provided between <data-files> tags. Treat this as the source of
149
+ truth: never invent file names or columns. To inspect raw values, call
150
+ readDataFile.`;
151
+
152
+ export interface DataFileMeta {
153
+ name: string;
154
+ ext: string;
155
+ size: number;
156
+ rowCount?: number;
157
+ columns?: string[];
158
+ }
159
 
160
  export function buildEmbedContext(embedHtml?: string): string {
161
  if (!embedHtml) return "";
162
  return `<embed>\n${embedHtml}\n</embed>`;
163
  }
164
 
165
+ function formatDataFileLine(file: DataFileMeta): string {
166
+ const sizeKb = (file.size / 1024).toFixed(1);
167
+ const rows = file.rowCount !== undefined ? ` rows=${file.rowCount}` : "";
168
+ const cols = file.columns && file.columns.length > 0
169
+ ? ` columns=[${file.columns.slice(0, 20).join(", ")}${file.columns.length > 20 ? ", ..." : ""}]`
170
+ : "";
171
+ return `- ${file.name} (${file.ext}, ${sizeKb} KB)${rows}${cols}`;
172
+ }
173
+
174
+ export function buildDataFilesContext(files?: DataFileMeta[]): string {
175
+ if (!files || files.length === 0) return "";
176
+ const lines = files.map(formatDataFileLine).join("\n");
177
+ return `<data-files>\n${lines}\n</data-files>`;
178
+ }
179
+
180
  /**
181
  * Extra instructions layered on top of the base system prompt when the
182
  * chart being edited is the article banner (top-of-article hero).
backend/src/agent/embed-tools.ts CHANGED
@@ -28,6 +28,18 @@ export const embedTools = {
28
  .string()
29
  .optional()
30
  .describe("Data source attribution"),
 
 
 
 
 
 
 
 
 
 
 
 
31
  }),
32
  }),
33
 
@@ -57,4 +69,28 @@ export const embedTools = {
57
  "to verify the exact content, or to understand the current structure before editing.",
58
  inputSchema: z.object({}),
59
  }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  };
 
28
  .string()
29
  .optional()
30
  .describe("Data source attribution"),
31
+ filename: z
32
+ .string()
33
+ .optional()
34
+ .describe(
35
+ "Descriptive file name slug for this chart, WITHOUT extension " +
36
+ "(e.g. 'attention-heatmap', 'model-accuracy', 'sales-by-region'). " +
37
+ "Use kebab-case, ASCII only, concise (2-4 words, <40 chars). " +
38
+ "REQUIRED on the first createEmbed call for a new chart - the " +
39
+ "client will slugify it, ensure uniqueness, and rename the file " +
40
+ "automatically. Omit on subsequent calls that only update the " +
41
+ "existing chart (the current name will be kept).",
42
+ ),
43
  }),
44
  }),
45
 
 
69
  "to verify the exact content, or to understand the current structure before editing.",
70
  inputSchema: z.object({}),
71
  }),
72
+
73
+ listDataFiles: tool({
74
+ description:
75
+ "List all data files (CSV, TSV, JSON, NDJSON, TXT) the user has " +
76
+ "attached to this document. Returns one line per file with name, " +
77
+ "extension, size, row count, and column names when available. " +
78
+ "Call this when the user mentions data, a dataset, or wants to " +
79
+ "visualize content from a specific file.",
80
+ inputSchema: z.object({}),
81
+ }),
82
+
83
+ readDataFile: tool({
84
+ description:
85
+ "Read the full raw content of a data file previously listed by " +
86
+ "listDataFiles. Use this to inspect the exact structure, sample " +
87
+ "values, or to embed small datasets directly into the chart. Large " +
88
+ "files may be truncated - prefer loading via fetch from " +
89
+ "/data/<name> for charts that rely on the full dataset.",
90
+ inputSchema: z.object({
91
+ name: z
92
+ .string()
93
+ .describe("Exact file name as returned by listDataFiles (e.g. 'sales.csv')"),
94
+ }),
95
+ }),
96
  };
backend/src/agent/system-prompt.ts CHANGED
@@ -1,27 +1,95 @@
1
- export const SYSTEM_PROMPT = `You are a writing assistant embedded in a collaborative article editor.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  You help users write, edit, and improve their articles.
3
 
4
- ## Tools
5
 
6
- ### Document editing
7
- - **applyDiff**: Your PRIMARY editing tool. Surgically edit the document with context-aware diffs.
8
  Provide surrounding context (contextBefore / contextAfter) to locate the exact position, even
9
  when the same text appears multiple times. Call it multiple times for multiple edits.
10
  - **replaceSelection**: Replace selected text. Use ONLY when the user has text selected.
11
  - **insertAtCursor**: Insert new text at cursor. Use for additions only.
12
 
 
 
 
 
 
 
 
13
  ### Article metadata (frontmatter)
14
  - **updateFrontmatter**: Update article metadata fields (title, subtitle, template, DOI, etc.).
15
- Only include the fields you want to change. Use when the user asks to modify article settings.
16
  - **addAuthor**: Add an author with name, optional URL, and affiliation indices.
17
- Can also create a new affiliation in the same call.
18
  - **removeAuthor**: Remove an author by 0-based index.
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  ## Editing strategy - applyDiff
21
 
22
  You MUST work with surgical, minimal diffs using applyDiff:
23
  1. **contextBefore**: Copy a few words that appear just before the text you want to change.
24
- This helps locate the right spot when the same phrase exists in multiple places.
25
  2. **contentToDelete**: The exact verbatim text to remove. Even a single extra space will
26
  cause the edit to fail. Keep it as short as possible while being unique.
27
  3. **contentToInsert**: The replacement text. Can be empty to simply delete.
@@ -44,6 +112,30 @@ When the user asks about article metadata:
44
  - To remove an author: use **removeAuthor** with the 0-based index from the current authors list.
45
  - The current frontmatter is provided in <frontmatter> tags when available.
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  ## Guidelines
48
 
49
  1. **Be concise.** When the user asks for an edit, use tools immediately instead of
 
1
+ import { classifyBySelectionEffect, groupByCategory } from "./tiptap-catalog.js";
2
+
3
+ /**
4
+ * System prompt for the article writing assistant.
5
+ *
6
+ * The "Available editor commands" and "Selection State Machine" sections
7
+ * are generated dynamically from the Tiptap command catalog so that
8
+ * adding a new command automatically updates the prompt.
9
+ *
10
+ * The state-machine framing is borrowed from tiptap-apcore: telling the
11
+ * LLM which commands REQUIRE / PRESERVE / DESTROY / have NO effect on the
12
+ * selection eliminates most "selected text was lost, next command did
13
+ * nothing" bugs.
14
+ */
15
+
16
+ function buildCommandListSection(): string {
17
+ const byCategory = groupByCategory();
18
+ const lines: string[] = [];
19
+ for (const [category, names] of Object.entries(byCategory)) {
20
+ if (names.length === 0) continue;
21
+ lines.push(`- **${category}**: ${names.join(", ")}`);
22
+ }
23
+ return lines.join("\n");
24
+ }
25
+
26
+ function buildSelectionStateMachineSection(): string {
27
+ const groups = classifyBySelectionEffect();
28
+ const fmt = (names: string[]) => (names.length > 0 ? names.join(", ") : "(none)");
29
+ return `REQUIRE SELECTION (no effect without an active text range - call selectText first):
30
+ ${fmt(groups.require)}
31
+
32
+ PRESERVE SELECTION (selection survives - safe to chain more format commands after):
33
+ ${fmt(groups.preserve)}
34
+
35
+ DESTROY SELECTION (cursor collapses after - you MUST re-select before applying format commands):
36
+ ${fmt(groups.destroy)}
37
+
38
+ NO SELECTION NEEDED (operates on the block at the cursor):
39
+ ${fmt(groups.none)}`;
40
+ }
41
+
42
+ export const SYSTEM_PROMPT = `You are a writing assistant embedded in a collaborative research article editor.
43
  You help users write, edit, and improve their articles.
44
 
45
+ ## Tools overview
46
 
47
+ ### Prose editing (primary)
48
+ - **applyDiff**: Your PRIMARY editing tool. Surgically edit prose with context-aware diffs.
49
  Provide surrounding context (contextBefore / contextAfter) to locate the exact position, even
50
  when the same text appears multiple times. Call it multiple times for multiple edits.
51
  - **replaceSelection**: Replace selected text. Use ONLY when the user has text selected.
52
  - **insertAtCursor**: Insert new text at cursor. Use for additions only.
53
 
54
+ ### Structured editor commands (format, lists, headings, links)
55
+ ${buildCommandListSection()}
56
+
57
+ These commands translate directly to Tiptap \`editor.chain()\` calls on the client.
58
+ Use them when the user asks to change formatting, block types, or create links - NOT for
59
+ rewriting prose (use applyDiff for that).
60
+
61
  ### Article metadata (frontmatter)
62
  - **updateFrontmatter**: Update article metadata fields (title, subtitle, template, DOI, etc.).
 
63
  - **addAuthor**: Add an author with name, optional URL, and affiliation indices.
 
64
  - **removeAuthor**: Remove an author by 0-based index.
65
 
66
+ ## Selection State Machine - THE CORE RULE
67
+
68
+ Every editor command interacts with an invisible "selection state". You MUST track it mentally.
69
+
70
+ ${buildSelectionStateMachineSection()}
71
+
72
+ **Key rule**: After any selection-destroying command (insertAtCursor, applyDiff, replaceSelection),
73
+ you MUST call \`selectText\` again before issuing a REQUIRE-SELECTION command like toggleBold.
74
+
75
+ **Multi-occurrence rule**: When the user says "make all X bold", you MUST loop:
76
+ call \`selectText(X, occurrence=1)\` then \`toggleBold\`, then again with \`occurrence=1\` (not 2 -
77
+ after toggling the first one the indices shift). Stop when \`found\` is false.
78
+
79
+ ## Common task patterns
80
+
81
+ 1. Format existing text: \`selectText(phrase)\` then \`toggleBold\`.
82
+ 2. Replace AND format in one step: prefer \`applyDiff\` with markdown-like output, or use \`replaceSelection\`
83
+ with inline markdown.
84
+ 3. Multi-format same text: \`selectText\` then \`toggleBold\` then \`toggleItalic\` (format commands preserve selection).
85
+ 4. Change block type: place cursor in target block (via selectText on any text in it) then \`toggleHeading({ level: 2 })\`.
86
+ 5. Add a link: \`selectText(anchor)\` then \`setLink({ href: 'https://...' })\`.
87
+ 6. Convert paragraph to list: \`selectText(first-line)\` then \`toggleBulletList\`.
88
+
89
  ## Editing strategy - applyDiff
90
 
91
  You MUST work with surgical, minimal diffs using applyDiff:
92
  1. **contextBefore**: Copy a few words that appear just before the text you want to change.
 
93
  2. **contentToDelete**: The exact verbatim text to remove. Even a single extra space will
94
  cause the edit to fail. Keep it as short as possible while being unique.
95
  3. **contentToInsert**: The replacement text. Can be empty to simply delete.
 
112
  - To remove an author: use **removeAuthor** with the 0-based index from the current authors list.
113
  - The current frontmatter is provided in <frontmatter> tags when available.
114
 
115
+ ## Stop condition - CRITICAL
116
+
117
+ Once you have executed the commands needed to fulfil the user's request,
118
+ you MUST end the turn with a short plain-text confirmation (one sentence)
119
+ and STOP calling tools.
120
+
121
+ - If the last tool returned success, emit text like "Done." and stop.
122
+ - If the last tool failed (e.g. \`selectText: text not found\`), emit a
123
+ short explanation of what went wrong and stop - do NOT retry indefinitely.
124
+ - NEVER call the same tool with the same arguments twice in a row.
125
+ - Hard limit: at most 8 tool calls per user request. If you need more,
126
+ stop and ask the user to split their request.
127
+
128
+ The UI shows a "Thinking..." spinner while tool rounds are in flight, so
129
+ staying in a tool-call loop without emitting text looks broken to the user.
130
+
131
+ ## Anti-patterns - NEVER DO THESE
132
+
133
+ - NEVER call a mark-level format command (toggleBold, setLink, etc.) without a preceding selectText.
134
+ - NEVER assume \`selectText\` succeeded - check the \`found\` field in its result.
135
+ - NEVER call \`toggleBold\` right after \`insertAtCursor\` or \`applyDiff\` - the selection is gone.
136
+ - NEVER guess Tiptap positions - use \`selectText\` with text content instead.
137
+ - NEVER end a turn with only tool calls - always conclude with a one-line text reply.
138
+
139
  ## Guidelines
140
 
141
  1. **Be concise.** When the user asks for an edit, use tools immediately instead of
backend/src/agent/tiptap-catalog.ts ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { tool, type Tool } from "ai";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * Catalog of Tiptap editor commands exposed to the AI agent.
6
+ *
7
+ * Each entry pairs:
8
+ * - an AI SDK tool definition (description + zod input schema)
9
+ * - safety metadata used to build the system prompt dynamically
10
+ * (category, selectionEffect, destructive)
11
+ *
12
+ * The frontend's agent executor dispatches on `name` and calls
13
+ * `editor.chain()[command](args).run()`, so the catalog is the single
14
+ * source of truth for what the agent can do.
15
+ *
16
+ * Inspired by `tiptap-apcore`'s Registry + AnnotationCatalog pattern,
17
+ * but intentionally pared down to the ~15 commands that make sense for
18
+ * a research article editor.
19
+ */
20
+
21
+ type Category = "query" | "selection" | "format" | "content";
22
+
23
+ /**
24
+ * How a command interacts with the current text selection.
25
+ *
26
+ * - `require`: must have a non-collapsed selection (mark toggles)
27
+ * - `preserve`: selection survives execution (most mark toggles, alignment)
28
+ * - `destroy`: cursor collapses after execution (insertContent)
29
+ * - `none`: operates on the block at cursor, no selection needed
30
+ */
31
+ type SelectionEffect = "require" | "preserve" | "destroy" | "none";
32
+
33
+ // `tool()` returns a strongly-typed Tool<InputSchema, Output>; storing them
34
+ // in a homogeneous array requires the most permissive Tool generic.
35
+ type AnyTool = Tool<any, any>;
36
+
37
+ export interface TiptapCommandEntry {
38
+ name: string;
39
+ category: Category;
40
+ selectionEffect: SelectionEffect;
41
+ destructive: boolean;
42
+ tool: AnyTool;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Command entries
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export const TIPTAP_COMMANDS: TiptapCommandEntry[] = [
50
+ // -- Selection ---------------------------------------------------------
51
+ {
52
+ name: "selectText",
53
+ category: "selection",
54
+ selectionEffect: "preserve",
55
+ destructive: false,
56
+ tool: tool({
57
+ description:
58
+ "Find a text substring in the document and select it. Use this BEFORE applying " +
59
+ "mark-level formatting commands (toggleBold, setLink, etc.). Returns { found, from, to } " +
60
+ "so you can verify the match. When the same text appears multiple times, use `occurrence` " +
61
+ "(1-based) or provide `contextBefore` / `contextAfter` to disambiguate.",
62
+ inputSchema: z.object({
63
+ text: z.string().describe("The exact text to select (substring match)"),
64
+ occurrence: z
65
+ .number()
66
+ .int()
67
+ .min(1)
68
+ .optional()
69
+ .describe(
70
+ "Which occurrence to select (1-based). Defaults to 1. Ignored if contextBefore/After are provided.",
71
+ ),
72
+ contextBefore: z
73
+ .string()
74
+ .optional()
75
+ .describe("A few words appearing just before the target text, used to disambiguate."),
76
+ contextAfter: z
77
+ .string()
78
+ .optional()
79
+ .describe("A few words appearing just after the target text, used to disambiguate."),
80
+ }),
81
+ }),
82
+ },
83
+
84
+ // -- Format: marks (require selection) --------------------------------
85
+ {
86
+ name: "toggleBold",
87
+ category: "format",
88
+ selectionEffect: "require",
89
+ destructive: false,
90
+ tool: tool({
91
+ description:
92
+ "Toggle bold on the current selection. MUST be called after selectText - has no effect without an active text range.",
93
+ inputSchema: z.object({}),
94
+ }),
95
+ },
96
+ {
97
+ name: "toggleItalic",
98
+ category: "format",
99
+ selectionEffect: "require",
100
+ destructive: false,
101
+ tool: tool({
102
+ description:
103
+ "Toggle italic on the current selection. MUST be called after selectText - has no effect without an active text range.",
104
+ inputSchema: z.object({}),
105
+ }),
106
+ },
107
+ {
108
+ name: "toggleStrike",
109
+ category: "format",
110
+ selectionEffect: "require",
111
+ destructive: false,
112
+ tool: tool({
113
+ description:
114
+ "Toggle strikethrough on the current selection. MUST be called after selectText.",
115
+ inputSchema: z.object({}),
116
+ }),
117
+ },
118
+ {
119
+ name: "toggleCode",
120
+ category: "format",
121
+ selectionEffect: "require",
122
+ destructive: false,
123
+ tool: tool({
124
+ description:
125
+ "Toggle inline code formatting on the current selection. MUST be called after selectText.",
126
+ inputSchema: z.object({}),
127
+ }),
128
+ },
129
+ {
130
+ name: "setLink",
131
+ category: "format",
132
+ selectionEffect: "require",
133
+ destructive: false,
134
+ tool: tool({
135
+ description:
136
+ "Turn the current selection into a hyperlink. MUST be called after selectText. " +
137
+ "Only http(s)/mailto/tel/relative URLs are accepted.",
138
+ inputSchema: z.object({
139
+ href: z.string().describe("Target URL (http/https/mailto/tel or relative)"),
140
+ }),
141
+ }),
142
+ },
143
+ {
144
+ name: "unsetLink",
145
+ category: "format",
146
+ selectionEffect: "require",
147
+ destructive: false,
148
+ tool: tool({
149
+ description: "Remove hyperlink formatting from the current selection.",
150
+ inputSchema: z.object({}),
151
+ }),
152
+ },
153
+
154
+ // -- Format: node-level (no selection needed) -------------------------
155
+ {
156
+ name: "toggleHeading",
157
+ category: "format",
158
+ selectionEffect: "none",
159
+ destructive: false,
160
+ tool: tool({
161
+ description:
162
+ "Convert the block at the cursor to a heading (or back to a paragraph if already that level). " +
163
+ "Operates on the containing block - no text selection needed.",
164
+ inputSchema: z.object({
165
+ level: z.number().int().min(1).max(3).describe("Heading level (1, 2 or 3)"),
166
+ }),
167
+ }),
168
+ },
169
+ {
170
+ name: "setParagraph",
171
+ category: "format",
172
+ selectionEffect: "none",
173
+ destructive: false,
174
+ tool: tool({
175
+ description: "Convert the block at the cursor to a plain paragraph.",
176
+ inputSchema: z.object({}),
177
+ }),
178
+ },
179
+ {
180
+ name: "toggleBulletList",
181
+ category: "format",
182
+ selectionEffect: "none",
183
+ destructive: false,
184
+ tool: tool({
185
+ description: "Toggle the block at the cursor between a bullet list item and a paragraph.",
186
+ inputSchema: z.object({}),
187
+ }),
188
+ },
189
+ {
190
+ name: "toggleOrderedList",
191
+ category: "format",
192
+ selectionEffect: "none",
193
+ destructive: false,
194
+ tool: tool({
195
+ description: "Toggle the block at the cursor between an ordered (numbered) list item and a paragraph.",
196
+ inputSchema: z.object({}),
197
+ }),
198
+ },
199
+ {
200
+ name: "toggleBlockquote",
201
+ category: "format",
202
+ selectionEffect: "none",
203
+ destructive: false,
204
+ tool: tool({
205
+ description: "Toggle blockquote on the block at the cursor.",
206
+ inputSchema: z.object({}),
207
+ }),
208
+ },
209
+ {
210
+ name: "toggleCodeBlock",
211
+ category: "format",
212
+ selectionEffect: "none",
213
+ destructive: false,
214
+ tool: tool({
215
+ description:
216
+ "Toggle code block on the block at the cursor. Use for multi-line code snippets.",
217
+ inputSchema: z.object({
218
+ language: z
219
+ .string()
220
+ .optional()
221
+ .describe("Language hint for syntax highlighting (e.g. 'python', 'typescript')"),
222
+ }),
223
+ }),
224
+ },
225
+ ];
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Helpers
229
+ // ---------------------------------------------------------------------------
230
+
231
+ /** AI SDK tool definitions keyed by command name. Pass directly to streamText. */
232
+ export function buildTiptapTools(): Record<string, AnyTool> {
233
+ const out: Record<string, AnyTool> = {};
234
+ for (const entry of TIPTAP_COMMANDS) {
235
+ out[entry.name] = entry.tool;
236
+ }
237
+ return out;
238
+ }
239
+
240
+ /** Commands grouped by how they interact with the selection. */
241
+ export function classifyBySelectionEffect(): Record<SelectionEffect, string[]> {
242
+ const groups: Record<SelectionEffect, string[]> = {
243
+ require: [],
244
+ preserve: [],
245
+ destroy: [],
246
+ none: [],
247
+ };
248
+ for (const entry of TIPTAP_COMMANDS) {
249
+ groups[entry.selectionEffect].push(entry.name);
250
+ }
251
+ return groups;
252
+ }
253
+
254
+ /** Commands grouped by category, used to build a concise command list in the prompt. */
255
+ export function groupByCategory(): Record<Category, string[]> {
256
+ const groups: Record<Category, string[]> = {
257
+ query: [],
258
+ selection: [],
259
+ format: [],
260
+ content: [],
261
+ };
262
+ for (const entry of TIPTAP_COMMANDS) {
263
+ groups[entry.category].push(entry.name);
264
+ }
265
+ return groups;
266
+ }
backend/src/agent/tools.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { tool } from "ai";
2
  import { z } from "zod";
 
3
 
4
  /**
5
  * Tools exposed to the LLM. These return instructions that the frontend
@@ -9,9 +10,15 @@ import { z } from "zod";
9
  *
10
  * All edits from a single agent turn are grouped into one Yjs undo step,
11
  * so the user can revert an entire agent action with a single Cmd+Z.
 
 
 
 
 
 
12
  */
13
 
14
- export const editorTools = {
15
  replaceSelection: tool({
16
  description:
17
  "Replace the currently selected text in the editor with new content. " +
@@ -34,7 +41,7 @@ export const editorTools = {
34
 
35
  applyDiff: tool({
36
  description:
37
- "The PRIMARY tool for editing. Locates a piece of text in the document using " +
38
  "context and replaces it. Provide surrounding context to disambiguate when the " +
39
  "same text appears multiple times. For multiple edits, call this tool multiple " +
40
  "times in a single response. Each call should be a minimal, surgical edit.",
@@ -64,7 +71,9 @@ export const editorTools = {
64
  ),
65
  }),
66
  }),
 
67
 
 
68
  updateFrontmatter: tool({
69
  description:
70
  "Update article metadata (frontmatter). Use when the user asks to change the title, " +
@@ -116,3 +125,9 @@ export const editorTools = {
116
  }),
117
  }),
118
  };
 
 
 
 
 
 
 
1
  import { tool } from "ai";
2
  import { z } from "zod";
3
+ import { buildTiptapTools } from "./tiptap-catalog.js";
4
 
5
  /**
6
  * Tools exposed to the LLM. These return instructions that the frontend
 
10
  *
11
  * All edits from a single agent turn are grouped into one Yjs undo step,
12
  * so the user can revert an entire agent action with a single Cmd+Z.
13
+ *
14
+ * The editor commands come from a static catalog (`tiptap-catalog.ts`)
15
+ * that also drives the system prompt's "Selection State Machine" section.
16
+ * The domain-specific tools (applyDiff, replaceSelection, insertAtCursor,
17
+ * frontmatter) are defined inline here since they don't map 1:1 to a
18
+ * native Tiptap command.
19
  */
20
 
21
+ const customEditorTools = {
22
  replaceSelection: tool({
23
  description:
24
  "Replace the currently selected text in the editor with new content. " +
 
41
 
42
  applyDiff: tool({
43
  description:
44
+ "The PRIMARY tool for editing prose. Locates a piece of text in the document using " +
45
  "context and replaces it. Provide surrounding context to disambiguate when the " +
46
  "same text appears multiple times. For multiple edits, call this tool multiple " +
47
  "times in a single response. Each call should be a minimal, surgical edit.",
 
71
  ),
72
  }),
73
  }),
74
+ };
75
 
76
+ const frontmatterTools = {
77
  updateFrontmatter: tool({
78
  description:
79
  "Update article metadata (frontmatter). Use when the user asks to change the title, " +
 
125
  }),
126
  }),
127
  };
128
+
129
+ export const editorTools = {
130
+ ...buildTiptapTools(),
131
+ ...customEditorTools,
132
+ ...frontmatterTools,
133
+ };
frontend/src/components/ChatPanel.tsx CHANGED
@@ -2,19 +2,7 @@ import { useState, useRef, useEffect, type KeyboardEvent } from "react";
2
  import ReactMarkdown from "react-markdown";
3
  import remarkGfm from "remark-gfm";
4
  import { Tooltip } from "./Tooltip";
5
- import {
6
- Send,
7
- Square,
8
- Plus,
9
- X,
10
- Sparkles,
11
- AlignLeft,
12
- Expand,
13
- SpellCheck,
14
- Languages,
15
- Shrink,
16
- Loader,
17
- } from "lucide-react";
18
  import type { UIMessage } from "ai";
19
  import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts";
20
 
@@ -30,27 +18,16 @@ interface ChatPanelProps {
30
  isLoading: boolean;
31
  error: Error | undefined;
32
  input: string;
33
- hasSelection: boolean;
34
  models: ModelOption[];
35
  selectedModel: string;
36
  onModelChange: (modelId: string) => void;
37
  onSend: (content: string) => void;
38
- onQuickAction: (action: string) => void;
39
  onSetInput: (value: string) => void;
40
  onStop: () => void;
41
  onNewChat: () => void;
42
  onClose?: () => void;
43
  }
44
 
45
- const QUICK_ACTIONS = [
46
- { id: "rewrite", label: "Rewrite", Icon: Sparkles },
47
- { id: "expand", label: "Expand", Icon: Expand },
48
- { id: "summarize", label: "Summarize", Icon: Shrink },
49
- { id: "fix-grammar", label: "Fix grammar", Icon: SpellCheck },
50
- { id: "translate", label: "Translate", Icon: Languages },
51
- { id: "simplify", label: "Simplify", Icon: AlignLeft },
52
- ];
53
-
54
  function TokenGauge({ tokens, ratio }: { tokens: number; ratio: number }) {
55
  const r = 8;
56
  const circ = 2 * Math.PI * r;
@@ -84,12 +61,10 @@ export function ChatPanel({
84
  isLoading,
85
  error,
86
  input,
87
- hasSelection,
88
  models,
89
  selectedModel,
90
  onModelChange,
91
  onSend,
92
- onQuickAction,
93
  onSetInput,
94
  onStop,
95
  onNewChat,
@@ -188,23 +163,6 @@ export function ChatPanel({
188
  )}
189
  </div>
190
 
191
- {/* Quick actions */}
192
- {hasSelection && (
193
- <div className="chat-panel__actions">
194
- {QUICK_ACTIONS.map((action) => (
195
- <button
196
- key={action.id}
197
- className="chip chip--outlined chip--clickable"
198
- onClick={() => onQuickAction(action.id)}
199
- style={{ height: 24, fontSize: "0.65rem", gap: 3 }}
200
- >
201
- <action.Icon size={12} />
202
- {action.label}
203
- </button>
204
- ))}
205
- </div>
206
- )}
207
-
208
  {/* Input */}
209
  <div className="chat-panel__input">
210
  <div className="chat-panel__input-row">
 
2
  import ReactMarkdown from "react-markdown";
3
  import remarkGfm from "remark-gfm";
4
  import { Tooltip } from "./Tooltip";
5
+ import { Send, Square, Plus, X, Sparkles, Loader } from "lucide-react";
 
 
 
 
 
 
 
 
 
 
 
 
6
  import type { UIMessage } from "ai";
7
  import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts";
8
 
 
18
  isLoading: boolean;
19
  error: Error | undefined;
20
  input: string;
 
21
  models: ModelOption[];
22
  selectedModel: string;
23
  onModelChange: (modelId: string) => void;
24
  onSend: (content: string) => void;
 
25
  onSetInput: (value: string) => void;
26
  onStop: () => void;
27
  onNewChat: () => void;
28
  onClose?: () => void;
29
  }
30
 
 
 
 
 
 
 
 
 
 
31
  function TokenGauge({ tokens, ratio }: { tokens: number; ratio: number }) {
32
  const r = 8;
33
  const circ = 2 * Math.PI * r;
 
61
  isLoading,
62
  error,
63
  input,
 
64
  models,
65
  selectedModel,
66
  onModelChange,
67
  onSend,
 
68
  onSetInput,
69
  onStop,
70
  onNewChat,
 
163
  )}
164
  </div>
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  {/* Input */}
167
  <div className="chat-panel__input">
168
  <div className="chat-panel__input-row">
frontend/src/components/DataFileViewer.tsx ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo, useState } from "react";
2
+ import { X, Table as TableIcon, FileText } from "lucide-react";
3
+ import type { EmbedDataFile } from "../editor/embeds/embed-data-store";
4
+ import { formatBytes } from "../utils/data-files";
5
+
6
+ interface DataFileViewerProps {
7
+ file: EmbedDataFile;
8
+ onClose: () => void;
9
+ }
10
+
11
+ const MAX_PREVIEW_ROWS = 100;
12
+
13
+ function splitCsvLine(line: string, delim: string): string[] {
14
+ const out: string[] = [];
15
+ let cur = "";
16
+ let inQuotes = false;
17
+ for (let i = 0; i < line.length; i++) {
18
+ const c = line[i];
19
+ if (inQuotes) {
20
+ if (c === '"') {
21
+ if (line[i + 1] === '"') {
22
+ cur += '"';
23
+ i++;
24
+ } else {
25
+ inQuotes = false;
26
+ }
27
+ } else {
28
+ cur += c;
29
+ }
30
+ } else if (c === '"') {
31
+ inQuotes = true;
32
+ } else if (c === delim) {
33
+ out.push(cur);
34
+ cur = "";
35
+ } else {
36
+ cur += c;
37
+ }
38
+ }
39
+ out.push(cur);
40
+ return out;
41
+ }
42
+
43
+ function parseTable(content: string, delim: string, maxRows: number): {
44
+ columns: string[];
45
+ rows: string[][];
46
+ total: number;
47
+ } {
48
+ const lines = content.split(/\r\n|\n|\r/).filter((l) => l.length > 0);
49
+ if (lines.length === 0) return { columns: [], rows: [], total: 0 };
50
+ const columns = splitCsvLine(lines[0], delim);
51
+ const rows = lines
52
+ .slice(1, 1 + maxRows)
53
+ .map((l) => splitCsvLine(l, delim));
54
+ return { columns, rows, total: Math.max(0, lines.length - 1) };
55
+ }
56
+
57
+ function prettyJson(content: string): string {
58
+ try {
59
+ return JSON.stringify(JSON.parse(content), null, 2);
60
+ } catch {
61
+ return content;
62
+ }
63
+ }
64
+
65
+ export function DataFileViewer({ file, onClose }: DataFileViewerProps) {
66
+ const { ext } = file.meta;
67
+ const isTabular = ext === "csv" || ext === "tsv";
68
+ const [mode, setMode] = useState<"table" | "raw">(isTabular ? "table" : "raw");
69
+
70
+ const parsed = useMemo(() => {
71
+ if (!isTabular) return null;
72
+ return parseTable(file.content, ext === "tsv" ? "\t" : ",", MAX_PREVIEW_ROWS);
73
+ }, [file.content, ext, isTabular]);
74
+
75
+ const raw = useMemo(() => {
76
+ if (ext === "json") return prettyJson(file.content);
77
+ return file.content;
78
+ }, [file.content, ext]);
79
+
80
+ return (
81
+ <div className="es-data-viewer">
82
+ <div className="es-data-viewer__header">
83
+ <div className="es-data-viewer__title">
84
+ <strong>{file.meta.name}</strong>
85
+ <span className="es-data-viewer__meta">
86
+ {ext.toUpperCase()} - {formatBytes(file.meta.size)}
87
+ {file.meta.rowCount !== undefined ? ` - ${file.meta.rowCount} rows` : ""}
88
+ </span>
89
+ </div>
90
+ <div className="es-data-viewer__actions">
91
+ {isTabular && (
92
+ <div className="es-data-viewer__tabs">
93
+ <button
94
+ type="button"
95
+ className={`es-preview__tab ${mode === "table" ? "es-preview__tab--active" : ""}`}
96
+ onClick={() => setMode("table")}
97
+ >
98
+ <TableIcon size={12} />
99
+ Table
100
+ </button>
101
+ <button
102
+ type="button"
103
+ className={`es-preview__tab ${mode === "raw" ? "es-preview__tab--active" : ""}`}
104
+ onClick={() => setMode("raw")}
105
+ >
106
+ <FileText size={12} />
107
+ Raw
108
+ </button>
109
+ </div>
110
+ )}
111
+ <button
112
+ type="button"
113
+ className="embed-btn"
114
+ onClick={onClose}
115
+ aria-label="Close file viewer"
116
+ >
117
+ <X size={14} />
118
+ </button>
119
+ </div>
120
+ </div>
121
+
122
+ <div className="es-data-viewer__body">
123
+ {isTabular && mode === "table" && parsed ? (
124
+ <div className="es-data-table-wrap">
125
+ <table className="es-data-table">
126
+ <thead>
127
+ <tr>
128
+ {parsed.columns.map((col, i) => (
129
+ <th key={i}>{col}</th>
130
+ ))}
131
+ </tr>
132
+ </thead>
133
+ <tbody>
134
+ {parsed.rows.map((row, ri) => (
135
+ <tr key={ri}>
136
+ {parsed.columns.map((_, ci) => (
137
+ <td key={ci}>{row[ci] ?? ""}</td>
138
+ ))}
139
+ </tr>
140
+ ))}
141
+ </tbody>
142
+ </table>
143
+ {parsed.total > parsed.rows.length && (
144
+ <div className="es-data-table__footer">
145
+ Showing first {parsed.rows.length} of {parsed.total} rows
146
+ </div>
147
+ )}
148
+ </div>
149
+ ) : (
150
+ <pre className="es-data-viewer__raw">
151
+ <code>{raw}</code>
152
+ </pre>
153
+ )}
154
+ </div>
155
+ </div>
156
+ );
157
+ }
frontend/src/components/EmbedStudio.tsx CHANGED
@@ -4,23 +4,37 @@ import remarkGfm from "remark-gfm";
4
  import { X, Send, Square, Code, Eye, Plus, Loader } from "lucide-react";
5
  import type { UIMessage } from "ai";
6
  import type { EmbedStore } from "../editor/embeds/embed-store";
 
7
  import { buildDoc } from "../editor/embeds/build-doc";
8
  import { useEmbedChat } from "../hooks/useEmbedChat";
 
9
  import { useTheme } from "../hooks/useTheme";
10
  import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts";
 
 
11
 
12
  interface EmbedStudioProps {
13
  src: string;
14
  embedStore: EmbedStore | null;
 
15
  modelRef: React.RefObject<string>;
16
  userId: string;
17
  onClose: () => void;
 
 
 
 
 
 
 
18
  }
19
 
20
  const EMBED_TOOL_LABELS: Record<string, [string, string]> = {
21
  createEmbed: ["Creating chart...", "Created chart"],
22
  patchEmbed: ["Updating chart...", "Updated chart"],
23
  readEmbed: ["Reading chart...", "Read chart"],
 
 
24
  };
25
 
26
  function embedToolLabel(name: string, state: string): string {
@@ -29,6 +43,27 @@ function embedToolLabel(name: string, state: string): string {
29
  return state === "result" ? pair[1] : pair[0];
30
  }
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  function MessageBubble({ message }: { message: UIMessage }) {
33
  const isUser = message.role === "user";
34
  return (
@@ -46,11 +81,20 @@ function MessageBubble({ message }: { message: UIMessage }) {
46
  if (isToolPart(part)) {
47
  const tool = normalizeToolPart(part);
48
  if (!tool) return null;
 
 
49
  return (
50
- <div key={i} className="es-message__tool">
 
 
 
 
51
  <span className="es-message__tool-name">
52
  {embedToolLabel(tool.toolName, tool.state)}
53
  </span>
 
 
 
54
  </div>
55
  );
56
  }
@@ -94,10 +138,19 @@ function EmbedAgentStatus({ messages }: { messages: UIMessage[] }) {
94
  );
95
  }
96
 
97
- export function EmbedStudio({ src, embedStore, modelRef, userId, onClose }: EmbedStudioProps) {
 
 
 
 
 
 
 
 
98
  const { isDark, primaryColor } = useTheme();
99
  const [html, setHtml] = useState("");
100
  const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
 
101
  const messagesEndRef = useRef<HTMLDivElement>(null);
102
  const textareaRef = useRef<HTMLTextAreaElement>(null);
103
 
@@ -108,7 +161,10 @@ export function EmbedStudio({ src, embedStore, modelRef, userId, onClose }: Embe
108
  const activeRef = useRef<"a" | "b">("a");
109
  activeRef.current = activeFrame;
110
 
111
- const chat = useEmbedChat({ embedStore, src, modelRef, userId });
 
 
 
112
 
113
  // Sync from embed store
114
  useEffect(() => {
@@ -225,6 +281,17 @@ export function EmbedStudio({ src, embedStore, modelRef, userId, onClose }: Embe
225
 
226
  {/* Body: split panel */}
227
  <div className="es-body">
 
 
 
 
 
 
 
 
 
 
 
228
  {/* Left: Chat */}
229
  <div className="es-chat">
230
  <div className="es-chat__messages">
@@ -285,9 +352,18 @@ export function EmbedStudio({ src, embedStore, modelRef, userId, onClose }: Embe
285
  </div>
286
  </div>
287
 
288
- {/* Right: Preview */}
289
  <div className="es-preview">
290
- <div className="es-preview__toolbar">
 
 
 
 
 
 
 
 
 
291
  <button
292
  className={`es-preview__tab ${viewMode === "preview" ? "es-preview__tab--active" : ""}`}
293
  onClick={() => setViewMode("preview")}
@@ -309,7 +385,9 @@ export function EmbedStudio({ src, embedStore, modelRef, userId, onClose }: Embe
309
  </div>
310
 
311
  {/* Both panels always mounted - toggle with hidden class to preserve iframe state */}
312
- <div className={`es-preview__frame-container ${viewMode !== "preview" ? "es-hidden" : ""}`}>
 
 
313
  {html ? (
314
  <>
315
  <iframe
@@ -334,7 +412,9 @@ export function EmbedStudio({ src, embedStore, modelRef, userId, onClose }: Embe
334
  </div>
335
  )}
336
  </div>
337
- <div className={`es-preview__code ${viewMode !== "code" ? "es-hidden" : ""}`}>
 
 
338
  <pre className="es-preview__pre">
339
  <code>{html || "// No chart HTML yet"}</code>
340
  </pre>
 
4
  import { X, Send, Square, Code, Eye, Plus, Loader } from "lucide-react";
5
  import type { UIMessage } from "ai";
6
  import type { EmbedStore } from "../editor/embeds/embed-store";
7
+ import type { EmbedDataStore } from "../editor/embeds/embed-data-store";
8
  import { buildDoc } from "../editor/embeds/build-doc";
9
  import { useEmbedChat } from "../hooks/useEmbedChat";
10
+ import { useEmbedData } from "../hooks/useEmbedData";
11
  import { useTheme } from "../hooks/useTheme";
12
  import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts";
13
+ import { FilesSidebar } from "./FilesSidebar";
14
+ import { DataFileViewer } from "./DataFileViewer";
15
 
16
  interface EmbedStudioProps {
17
  src: string;
18
  embedStore: EmbedStore | null;
19
+ dataStore?: EmbedDataStore | null;
20
  modelRef: React.RefObject<string>;
21
  userId: string;
22
  onClose: () => void;
23
+ /**
24
+ * Invoked when the agent chooses a descriptive filename for a brand
25
+ * new chart. Parent should update the htmlEmbed node in the doc and
26
+ * lift the new src into its own state so subsequent actions (close,
27
+ * reopen, etc.) reference the renamed file.
28
+ */
29
+ onRename?: (oldSrc: string, newSrc: string) => void;
30
  }
31
 
32
  const EMBED_TOOL_LABELS: Record<string, [string, string]> = {
33
  createEmbed: ["Creating chart...", "Created chart"],
34
  patchEmbed: ["Updating chart...", "Updated chart"],
35
  readEmbed: ["Reading chart...", "Read chart"],
36
+ listDataFiles: ["Listing data files...", "Listed data files"],
37
+ readDataFile: ["Reading data file...", "Read data file"],
38
  };
39
 
40
  function embedToolLabel(name: string, state: string): string {
 
43
  return state === "result" ? pair[1] : pair[0];
44
  }
45
 
46
+ function toolSubtitle(
47
+ name: string,
48
+ input: unknown,
49
+ ): string | null {
50
+ if (!input || typeof input !== "object") return null;
51
+ const args = input as Record<string, unknown>;
52
+ switch (name) {
53
+ case "createEmbed":
54
+ return typeof args.title === "string" ? String(args.title) : null;
55
+ case "readDataFile":
56
+ return typeof args.name === "string" ? String(args.name) : null;
57
+ case "patchEmbed": {
58
+ const search = typeof args.search === "string" ? args.search : "";
59
+ const firstLine = search.split("\n")[0]?.trim() ?? "";
60
+ return firstLine ? firstLine.slice(0, 60) : null;
61
+ }
62
+ default:
63
+ return null;
64
+ }
65
+ }
66
+
67
  function MessageBubble({ message }: { message: UIMessage }) {
68
  const isUser = message.role === "user";
69
  return (
 
81
  if (isToolPart(part)) {
82
  const tool = normalizeToolPart(part);
83
  if (!tool) return null;
84
+ const subtitle = toolSubtitle(tool.toolName, tool.input);
85
+ const isDone = tool.state === "result";
86
  return (
87
+ <div
88
+ key={i}
89
+ className={`es-message__tool ${isDone ? "es-message__tool--done" : "es-message__tool--running"}`}
90
+ >
91
+ {!isDone && <Loader size={11} className="spin" />}
92
  <span className="es-message__tool-name">
93
  {embedToolLabel(tool.toolName, tool.state)}
94
  </span>
95
+ {subtitle && (
96
+ <span className="es-message__tool-subtitle">{subtitle}</span>
97
+ )}
98
  </div>
99
  );
100
  }
 
138
  );
139
  }
140
 
141
+ export function EmbedStudio({
142
+ src,
143
+ embedStore,
144
+ dataStore = null,
145
+ modelRef,
146
+ userId,
147
+ onClose,
148
+ onRename,
149
+ }: EmbedStudioProps) {
150
  const { isDark, primaryColor } = useTheme();
151
  const [html, setHtml] = useState("");
152
  const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
153
+ const [selectedDataFile, setSelectedDataFile] = useState<string | null>(null);
154
  const messagesEndRef = useRef<HTMLDivElement>(null);
155
  const textareaRef = useRef<HTMLTextAreaElement>(null);
156
 
 
161
  const activeRef = useRef<"a" | "b">("a");
162
  activeRef.current = activeFrame;
163
 
164
+ const data = useEmbedData({ dataStore, userId });
165
+ const chat = useEmbedChat({ embedStore, dataStore, src, modelRef, userId, onRename });
166
+
167
+ const selectedFile = selectedDataFile ? data.getFile(selectedDataFile) : undefined;
168
 
169
  // Sync from embed store
170
  useEffect(() => {
 
281
 
282
  {/* Body: split panel */}
283
  <div className="es-body">
284
+ {/* Far-left: Files */}
285
+ <FilesSidebar
286
+ embedStore={embedStore}
287
+ currentSrc={src}
288
+ dataFiles={data.files}
289
+ selectedDataFile={selectedDataFile}
290
+ onSelectDataFile={setSelectedDataFile}
291
+ onUploadFiles={data.uploadFiles}
292
+ onRemoveDataFile={data.removeFile}
293
+ />
294
+
295
  {/* Left: Chat */}
296
  <div className="es-chat">
297
  <div className="es-chat__messages">
 
352
  </div>
353
  </div>
354
 
355
+ {/* Right: Preview (chart) or data file viewer */}
356
  <div className="es-preview">
357
+ {selectedFile ? (
358
+ <DataFileViewer
359
+ file={selectedFile}
360
+ onClose={() => setSelectedDataFile(null)}
361
+ />
362
+ ) : null}
363
+
364
+ <div
365
+ className={`es-preview__toolbar ${selectedFile ? "es-hidden" : ""}`}
366
+ >
367
  <button
368
  className={`es-preview__tab ${viewMode === "preview" ? "es-preview__tab--active" : ""}`}
369
  onClick={() => setViewMode("preview")}
 
385
  </div>
386
 
387
  {/* Both panels always mounted - toggle with hidden class to preserve iframe state */}
388
+ <div
389
+ className={`es-preview__frame-container ${viewMode !== "preview" || selectedFile ? "es-hidden" : ""}`}
390
+ >
391
  {html ? (
392
  <>
393
  <iframe
 
412
  </div>
413
  )}
414
  </div>
415
+ <div
416
+ className={`es-preview__code ${viewMode !== "code" || selectedFile ? "es-hidden" : ""}`}
417
+ >
418
  <pre className="es-preview__pre">
419
  <code>{html || "// No chart HTML yet"}</code>
420
  </pre>
frontend/src/components/FilesSidebar.tsx ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { FileText, Trash2, BarChart3, Database, AlertCircle } from "lucide-react";
3
+ import type { EmbedStore } from "../editor/embeds/embed-store";
4
+ import type { EmbedDataFileMeta } from "../editor/embeds/embed-data-store";
5
+ import { UploadZone } from "./UploadZone";
6
+ import { formatBytes } from "../utils/data-files";
7
+ import type { UploadResult } from "../hooks/useEmbedData";
8
+
9
+ interface FilesSidebarProps {
10
+ embedStore: EmbedStore | null;
11
+ currentSrc: string;
12
+ dataFiles: EmbedDataFileMeta[];
13
+ selectedDataFile: string | null;
14
+ onSelectDataFile: (name: string | null) => void;
15
+ onUploadFiles: (files: FileList | File[]) => Promise<UploadResult[]>;
16
+ onRemoveDataFile: (name: string) => void;
17
+ }
18
+
19
+ export function FilesSidebar({
20
+ embedStore,
21
+ currentSrc,
22
+ dataFiles,
23
+ selectedDataFile,
24
+ onSelectDataFile,
25
+ onUploadFiles,
26
+ onRemoveDataFile,
27
+ }: FilesSidebarProps) {
28
+ const [charts, setCharts] = useState<string[]>(() =>
29
+ embedStore ? embedStore.keys() : [],
30
+ );
31
+ const [uploadError, setUploadError] = useState<string | null>(null);
32
+
33
+ useEffect(() => {
34
+ if (!embedStore) return;
35
+ setCharts(embedStore.keys());
36
+ return embedStore.observe(() => setCharts(embedStore.keys()));
37
+ }, [embedStore]);
38
+
39
+ const handleUpload = async (files: FileList | File[]) => {
40
+ setUploadError(null);
41
+ const results = await onUploadFiles(files);
42
+ const firstError = results.find((r) => !r.ok);
43
+ if (firstError?.error) setUploadError(firstError.error);
44
+ const firstOk = results.find((r) => r.ok && r.name);
45
+ if (firstOk?.name) onSelectDataFile(firstOk.name);
46
+ };
47
+
48
+ return (
49
+ <div className="es-files">
50
+ <section className="es-files__section">
51
+ <header className="es-files__section-title">
52
+ <BarChart3 size={13} />
53
+ <span>Charts</span>
54
+ <span className="es-files__count">{charts.length}</span>
55
+ </header>
56
+ <ul className="es-files__list">
57
+ {charts.length === 0 && (
58
+ <li className="es-files__empty">No charts yet</li>
59
+ )}
60
+ {charts.map((name) => (
61
+ <li
62
+ key={name}
63
+ className={`es-files__item ${name === currentSrc ? "es-files__item--current" : ""}`}
64
+ title={name === currentSrc ? "Current chart" : name}
65
+ >
66
+ <FileText size={12} />
67
+ <span className="es-files__name">{name}</span>
68
+ {name === currentSrc && (
69
+ <span className="es-files__badge">editing</span>
70
+ )}
71
+ </li>
72
+ ))}
73
+ </ul>
74
+ </section>
75
+
76
+ <section className="es-files__section">
77
+ <header className="es-files__section-title">
78
+ <Database size={13} />
79
+ <span>Data</span>
80
+ <span className="es-files__count">{dataFiles.length}</span>
81
+ </header>
82
+
83
+ <ul className="es-files__list">
84
+ {dataFiles.length === 0 && (
85
+ <li className="es-files__empty">No data uploaded yet</li>
86
+ )}
87
+ {dataFiles.map((file) => {
88
+ const isSelected = file.name === selectedDataFile;
89
+ return (
90
+ <li
91
+ key={file.name}
92
+ className={`es-files__item es-files__item--data ${isSelected ? "es-files__item--current" : ""}`}
93
+ >
94
+ <button
95
+ type="button"
96
+ className="es-files__item-main"
97
+ onClick={() => onSelectDataFile(isSelected ? null : file.name)}
98
+ title={file.name}
99
+ >
100
+ <FileText size={12} />
101
+ <span className="es-files__name">{file.name}</span>
102
+ <span className="es-files__size">{formatBytes(file.size)}</span>
103
+ </button>
104
+ <button
105
+ type="button"
106
+ className="es-files__item-remove"
107
+ onClick={(e) => {
108
+ e.stopPropagation();
109
+ if (isSelected) onSelectDataFile(null);
110
+ onRemoveDataFile(file.name);
111
+ }}
112
+ aria-label={`Remove ${file.name}`}
113
+ title="Remove file"
114
+ >
115
+ <Trash2 size={12} />
116
+ </button>
117
+ </li>
118
+ );
119
+ })}
120
+ </ul>
121
+
122
+ <UploadZone onFiles={handleUpload} compact={dataFiles.length > 0} />
123
+ {uploadError && (
124
+ <div className="es-files__error">
125
+ <AlertCircle size={12} />
126
+ <span>{uploadError}</span>
127
+ </div>
128
+ )}
129
+ </section>
130
+ </div>
131
+ );
132
+ }
frontend/src/components/TopBar.tsx CHANGED
@@ -1,6 +1,20 @@
 
1
  import type { Editor as TiptapEditor } from "@tiptap/core";
2
  import type { HocuspocusProvider } from "@hocuspocus/provider";
3
- import { Undo2, Redo2, Settings, Upload, Eye, Sun, Moon, Menu, LogIn } from "lucide-react";
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import { Tooltip } from "./Tooltip";
5
  import { SyncIndicator } from "./SyncIndicator";
6
  import type { CollabUser } from "../utils/user";
@@ -48,6 +62,43 @@ export function TopBar({
48
  ? `${publishingUserName} is publishing...`
49
  : "Publish in progress..."
50
  : "Publish article";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  return (
52
  <div className="top-bar">
53
  <button
@@ -103,6 +154,53 @@ export function TopBar({
103
  {theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
104
  </button>
105
  </Tooltip>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  <span className="divider-v" />
107
  <Tooltip title={publishTooltip}>
108
  <button
 
1
+ import { useEffect, useRef, useState } from "react";
2
  import type { Editor as TiptapEditor } from "@tiptap/core";
3
  import type { HocuspocusProvider } from "@hocuspocus/provider";
4
+ import {
5
+ Undo2,
6
+ Redo2,
7
+ Settings,
8
+ Upload,
9
+ Eye,
10
+ Sun,
11
+ Moon,
12
+ Menu,
13
+ LogIn,
14
+ MoreHorizontal,
15
+ FileText,
16
+ Trash2,
17
+ } from "lucide-react";
18
  import { Tooltip } from "./Tooltip";
19
  import { SyncIndicator } from "./SyncIndicator";
20
  import type { CollabUser } from "../utils/user";
 
62
  ? `${publishingUserName} is publishing...`
63
  : "Publish in progress..."
64
  : "Publish article";
65
+
66
+ // Dev / utility menu state (Load demo, Reset article). Kept out of
67
+ // the slash menu so it doesn't clutter the authoring surface.
68
+ const [devMenuOpen, setDevMenuOpen] = useState(false);
69
+ const devMenuRef = useRef<HTMLDivElement | null>(null);
70
+ useEffect(() => {
71
+ if (!devMenuOpen) return;
72
+ const onDocClick = (e: MouseEvent) => {
73
+ if (!devMenuRef.current) return;
74
+ if (!devMenuRef.current.contains(e.target as Node)) setDevMenuOpen(false);
75
+ };
76
+ const onKey = (e: KeyboardEvent) => {
77
+ if (e.key === "Escape") setDevMenuOpen(false);
78
+ };
79
+ document.addEventListener("mousedown", onDocClick);
80
+ document.addEventListener("keydown", onKey);
81
+ return () => {
82
+ document.removeEventListener("mousedown", onDocClick);
83
+ document.removeEventListener("keydown", onKey);
84
+ };
85
+ }, [devMenuOpen]);
86
+
87
+ const handleLoadDemo = () => {
88
+ setDevMenuOpen(false);
89
+ window.dispatchEvent(new CustomEvent("load-demo-content"));
90
+ };
91
+ const handleReset = () => {
92
+ setDevMenuOpen(false);
93
+ if (
94
+ window.confirm(
95
+ "Reset the article? This wipes the document, banner, frontmatter and citations.",
96
+ )
97
+ ) {
98
+ window.dispatchEvent(new CustomEvent("reset-article"));
99
+ }
100
+ };
101
+
102
  return (
103
  <div className="top-bar">
104
  <button
 
154
  {theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
155
  </button>
156
  </Tooltip>
157
+
158
+ <div className="top-bar__menu" ref={devMenuRef}>
159
+ <Tooltip title="More actions">
160
+ <button
161
+ className="icon-btn"
162
+ onClick={() => setDevMenuOpen((v) => !v)}
163
+ aria-label="More actions"
164
+ aria-haspopup="menu"
165
+ aria-expanded={devMenuOpen || undefined}
166
+ >
167
+ <MoreHorizontal size={18} />
168
+ </button>
169
+ </Tooltip>
170
+ {devMenuOpen && (
171
+ <div className="top-bar__menu-pop" role="menu">
172
+ <button
173
+ className="top-bar__menu-item"
174
+ role="menuitem"
175
+ onClick={handleLoadDemo}
176
+ >
177
+ <FileText size={14} />
178
+ <span className="top-bar__menu-item-content">
179
+ <span className="top-bar__menu-item-title">
180
+ Load demo content
181
+ </span>
182
+ <span className="top-bar__menu-item-desc">
183
+ Replace document with a full demo article
184
+ </span>
185
+ </span>
186
+ </button>
187
+ <button
188
+ className="top-bar__menu-item top-bar__menu-item--danger"
189
+ role="menuitem"
190
+ onClick={handleReset}
191
+ >
192
+ <Trash2 size={14} />
193
+ <span className="top-bar__menu-item-content">
194
+ <span className="top-bar__menu-item-title">Reset article</span>
195
+ <span className="top-bar__menu-item-desc">
196
+ Wipe document, banner, frontmatter, citations
197
+ </span>
198
+ </span>
199
+ </button>
200
+ </div>
201
+ )}
202
+ </div>
203
+
204
  <span className="divider-v" />
205
  <Tooltip title={publishTooltip}>
206
  <button
frontend/src/components/UploadZone.tsx ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useState, type DragEvent } from "react";
2
+ import { Upload } from "lucide-react";
3
+ import { ACCEPTED_DATA_EXTS, MAX_DATA_FILE_SIZE } from "../utils/data-files";
4
+
5
+ interface UploadZoneProps {
6
+ onFiles: (files: FileList | File[]) => void | Promise<void>;
7
+ disabled?: boolean;
8
+ compact?: boolean;
9
+ }
10
+
11
+ const ACCEPT_ATTR = ACCEPTED_DATA_EXTS.map((e) => `.${e}`).join(",");
12
+ const MAX_MB = (MAX_DATA_FILE_SIZE / (1024 * 1024)).toFixed(0);
13
+
14
+ export function UploadZone({ onFiles, disabled, compact }: UploadZoneProps) {
15
+ const inputRef = useRef<HTMLInputElement>(null);
16
+ const [isDragging, setIsDragging] = useState(false);
17
+
18
+ const handleDrop = (e: DragEvent<HTMLDivElement>) => {
19
+ e.preventDefault();
20
+ setIsDragging(false);
21
+ if (disabled) return;
22
+ if (e.dataTransfer.files?.length) void onFiles(e.dataTransfer.files);
23
+ };
24
+
25
+ const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
26
+ e.preventDefault();
27
+ if (!disabled) setIsDragging(true);
28
+ };
29
+
30
+ const handleDragLeave = () => setIsDragging(false);
31
+
32
+ return (
33
+ <div
34
+ className={`es-upload-zone ${isDragging ? "es-upload-zone--active" : ""} ${compact ? "es-upload-zone--compact" : ""}`}
35
+ onDrop={handleDrop}
36
+ onDragOver={handleDragOver}
37
+ onDragLeave={handleDragLeave}
38
+ onClick={() => !disabled && inputRef.current?.click()}
39
+ role="button"
40
+ tabIndex={disabled ? -1 : 0}
41
+ aria-label="Upload data file"
42
+ >
43
+ <input
44
+ ref={inputRef}
45
+ type="file"
46
+ accept={ACCEPT_ATTR}
47
+ multiple
48
+ onChange={(e) => {
49
+ if (e.target.files?.length) void onFiles(e.target.files);
50
+ e.target.value = "";
51
+ }}
52
+ style={{ display: "none" }}
53
+ />
54
+ <Upload size={compact ? 14 : 18} />
55
+ {compact ? (
56
+ <span>Add data file</span>
57
+ ) : (
58
+ <div className="es-upload-zone__text">
59
+ <strong>Drop a data file</strong>
60
+ <span>CSV, TSV, JSON, NDJSON, TXT - up to {MAX_MB} MB</span>
61
+ </div>
62
+ )}
63
+ </div>
64
+ );
65
+ }
frontend/src/editor/Editor.tsx CHANGED
@@ -29,10 +29,14 @@ import { Glossary } from "./extensions/glossary";
29
  import { Footnote } from "./extensions/footnote";
30
  import { Stack, StackColumn } from "./extensions/stack";
31
  import { ScrollGuard } from "./extensions/scroll-guard";
 
 
 
32
  import { CitationPanel } from "./CitationPanel";
33
  import { createCommentStore, CommentStore } from "./comments";
34
  import { createFrontmatterStore, FrontmatterStore } from "./frontmatter/frontmatter-store";
35
  import { createEmbedStore, EmbedStore } from "./embeds/embed-store";
 
36
  import { COMPONENTS, createComponentExtension } from "./components";
37
  import { uploadImage } from "./upload";
38
  import { loadDemoContent } from "./load-demo";
@@ -44,6 +48,7 @@ interface EditorProps {
44
  onCommentStoreReady: (store: CommentStore) => void;
45
  onFrontmatterStoreReady: (store: FrontmatterStore) => void;
46
  onEmbedStoreReady?: (store: EmbedStore) => void;
 
47
  onSettingsMapReady?: (map: Y.Map<any>) => void;
48
  onEditorReady: (editor: TiptapEditor | null) => void;
49
  onUndoManagerReady?: (manager: UndoManager) => void;
@@ -58,6 +63,7 @@ export function Editor({
58
  onCommentStoreReady,
59
  onFrontmatterStoreReady,
60
  onEmbedStoreReady,
 
61
  onProviderReady,
62
  onSettingsMapReady,
63
  onEditorReady,
@@ -95,6 +101,7 @@ export function Editor({
95
  const commentStore = useMemo(() => createCommentStore(ydoc), [ydoc]);
96
  const frontmatterStore = useMemo(() => createFrontmatterStore(ydoc), [ydoc]);
97
  const embedStore = useMemo(() => createEmbedStore(ydoc), [ydoc]);
 
98
  const [showCitationPanel, setShowCitationPanel] = useState(false);
99
 
100
  useEffect(() => {
@@ -109,6 +116,10 @@ export function Editor({
109
  onEmbedStoreReady?.(embedStore);
110
  }, [embedStore, onEmbedStoreReady]);
111
 
 
 
 
 
112
  useEffect(() => {
113
  onSettingsMapReady?.(settingsMap);
114
  }, [settingsMap, onSettingsMapReady]);
@@ -214,6 +225,12 @@ export function Editor({
214
  }),
215
  Comment,
216
  ScrollGuard,
 
 
 
 
 
 
217
  ],
218
  },
219
  [ydoc, provider],
@@ -268,7 +285,7 @@ export function Editor({
268
  return () => window.removeEventListener("reset-article", handler);
269
  }, [editor, frontmatterStore, citationsMap, settingsMap, embedStore, ydoc]);
270
 
271
- // Dev-only hook used by the demo recording script (backend/demo/trio.ts).
272
  // Lets the script swap the banner HTML deterministically, simulating a
273
  // successful Embed Studio agent run without depending on the LLM backend.
274
  useEffect(() => {
@@ -290,6 +307,7 @@ export function Editor({
290
  useEffect(() => {
291
  if (!editor) return;
292
  (editor.commands as any).updateUser(user);
 
293
  }, [editor, user]);
294
 
295
  useEffect(() => {
 
29
  import { Footnote } from "./extensions/footnote";
30
  import { Stack, StackColumn } from "./extensions/stack";
31
  import { ScrollGuard } from "./extensions/scroll-guard";
32
+ import { AgentHighlight } from "./extensions/agent-highlight";
33
+ import { AgentFocus } from "./extensions/agent-focus";
34
+ import { AgentRewrite } from "./extensions/agent-rewrite";
35
  import { CitationPanel } from "./CitationPanel";
36
  import { createCommentStore, CommentStore } from "./comments";
37
  import { createFrontmatterStore, FrontmatterStore } from "./frontmatter/frontmatter-store";
38
  import { createEmbedStore, EmbedStore } from "./embeds/embed-store";
39
+ import { createEmbedDataStore, EmbedDataStore } from "./embeds/embed-data-store";
40
  import { COMPONENTS, createComponentExtension } from "./components";
41
  import { uploadImage } from "./upload";
42
  import { loadDemoContent } from "./load-demo";
 
48
  onCommentStoreReady: (store: CommentStore) => void;
49
  onFrontmatterStoreReady: (store: FrontmatterStore) => void;
50
  onEmbedStoreReady?: (store: EmbedStore) => void;
51
+ onEmbedDataStoreReady?: (store: EmbedDataStore) => void;
52
  onSettingsMapReady?: (map: Y.Map<any>) => void;
53
  onEditorReady: (editor: TiptapEditor | null) => void;
54
  onUndoManagerReady?: (manager: UndoManager) => void;
 
63
  onCommentStoreReady,
64
  onFrontmatterStoreReady,
65
  onEmbedStoreReady,
66
+ onEmbedDataStoreReady,
67
  onProviderReady,
68
  onSettingsMapReady,
69
  onEditorReady,
 
101
  const commentStore = useMemo(() => createCommentStore(ydoc), [ydoc]);
102
  const frontmatterStore = useMemo(() => createFrontmatterStore(ydoc), [ydoc]);
103
  const embedStore = useMemo(() => createEmbedStore(ydoc), [ydoc]);
104
+ const embedDataStore = useMemo(() => createEmbedDataStore(ydoc), [ydoc]);
105
  const [showCitationPanel, setShowCitationPanel] = useState(false);
106
 
107
  useEffect(() => {
 
116
  onEmbedStoreReady?.(embedStore);
117
  }, [embedStore, onEmbedStoreReady]);
118
 
119
+ useEffect(() => {
120
+ onEmbedDataStoreReady?.(embedDataStore);
121
+ }, [embedDataStore, onEmbedDataStoreReady]);
122
+
123
  useEffect(() => {
124
  onSettingsMapReady?.(settingsMap);
125
  }, [settingsMap, onSettingsMapReady]);
 
225
  }),
226
  Comment,
227
  ScrollGuard,
228
+ AgentHighlight,
229
+ AgentFocus.configure({
230
+ provider,
231
+ user,
232
+ }),
233
+ AgentRewrite,
234
  ],
235
  },
236
  [ydoc, provider],
 
285
  return () => window.removeEventListener("reset-article", handler);
286
  }, [editor, frontmatterStore, citationsMap, settingsMap, embedStore, ydoc]);
287
 
288
+ // Dev-only hook used by the demo recording script (backend/demo/showcase.ts).
289
  // Lets the script swap the banner HTML deterministically, simulating a
290
  // successful Embed Studio agent run without depending on the LLM backend.
291
  useEffect(() => {
 
307
  useEffect(() => {
308
  if (!editor) return;
309
  (editor.commands as any).updateUser(user);
310
+ editor.commands.updateAgentFocusUser(user);
311
  }, [editor, user]);
312
 
313
  useEffect(() => {
frontend/src/editor/SlashMenu.tsx CHANGED
@@ -5,16 +5,26 @@ import {
5
  forwardRef,
6
  useEffect,
7
  useImperativeHandle,
 
8
  useState,
9
  useCallback,
10
  } from "react";
11
  import type { Editor } from "@tiptap/core";
12
  import { getComponentSlashItems } from "./components";
13
 
 
 
 
 
 
 
 
 
14
  interface SlashItem {
15
  title: string;
16
  description: string;
17
  icon: string;
 
18
  command: (editor: Editor) => void;
19
  }
20
 
@@ -23,6 +33,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
23
  title: "Heading 1",
24
  description: "Large section heading",
25
  icon: "H1",
 
26
  command: (editor) =>
27
  editor.chain().focus().toggleHeading({ level: 1 }).run(),
28
  },
@@ -30,6 +41,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
30
  title: "Heading 2",
31
  description: "Medium section heading",
32
  icon: "H2",
 
33
  command: (editor) =>
34
  editor.chain().focus().toggleHeading({ level: 2 }).run(),
35
  },
@@ -37,6 +49,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
37
  title: "Heading 3",
38
  description: "Small section heading",
39
  icon: "H3",
 
40
  command: (editor) =>
41
  editor.chain().focus().toggleHeading({ level: 3 }).run(),
42
  },
@@ -44,36 +57,42 @@ const BUILT_IN_ITEMS: SlashItem[] = [
44
  title: "Bullet list",
45
  description: "Unordered list",
46
  icon: "•",
 
47
  command: (editor) => editor.chain().focus().toggleBulletList().run(),
48
  },
49
  {
50
  title: "Numbered list",
51
  description: "Ordered list",
52
  icon: "1.",
 
53
  command: (editor) => editor.chain().focus().toggleOrderedList().run(),
54
  },
55
  {
56
  title: "Quote",
57
  description: "Blockquote",
58
  icon: "❝",
 
59
  command: (editor) => editor.chain().focus().toggleBlockquote().run(),
60
  },
61
  {
62
  title: "Code block",
63
  description: "Code with syntax highlighting",
64
  icon: "<>",
 
65
  command: (editor) => editor.chain().focus().toggleCodeBlock().run(),
66
  },
67
  {
68
  title: "Divider",
69
  description: "Horizontal rule",
70
  icon: "—",
 
71
  command: (editor) => editor.chain().focus().setHorizontalRule().run(),
72
  },
73
  {
74
  title: "Image",
75
  description: "Upload or embed an image",
76
  icon: "🖼",
 
77
  command: (editor) => {
78
  editor.chain().focus().insertImageUpload().run();
79
  },
@@ -82,6 +101,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
82
  title: "Citation",
83
  description: "Cite a paper (DOI or BibTeX)",
84
  icon: "📎",
 
85
  command: () => {
86
  window.dispatchEvent(new CustomEvent("open-citation-panel"));
87
  },
@@ -90,6 +110,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
90
  title: "Glossary",
91
  description: "Highlighted term with tooltip definition",
92
  icon: "📖",
 
93
  command: (editor) => {
94
  editor.chain().focus().insertGlossary("term", "Definition here…").run();
95
  },
@@ -98,6 +119,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
98
  title: "Footnote",
99
  description: "Numbered footnote reference",
100
  icon: "¹",
 
101
  command: (editor) => {
102
  editor.chain().focus().insertFootnote("Footnote content…").run();
103
  },
@@ -106,6 +128,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
106
  title: "Stack",
107
  description: "Multi-column layout (2-4 columns)",
108
  icon: "▥",
 
109
  command: (editor) => {
110
  editor.chain().focus().insertStack(2).run();
111
  },
@@ -114,11 +137,11 @@ const BUILT_IN_ITEMS: SlashItem[] = [
114
  title: "New Chart",
115
  description: "Create a D3 chart with AI (Embed Studio)",
116
  icon: "📊",
 
117
  command: (editor) => {
118
  const id = `d3-chart-${Date.now().toString(36)}`;
119
  const src = `${id}.html`;
120
- (editor.chain().focus() as any).inserthtmlEmbed().run();
121
- // Find the just-inserted htmlEmbed node and set its src
122
  setTimeout(() => {
123
  const { doc } = editor.state;
124
  let targetPos = -1;
@@ -143,25 +166,94 @@ const BUILT_IN_ITEMS: SlashItem[] = [
143
  }, 50);
144
  },
145
  },
146
- {
147
- title: "Load demo content",
148
- description: "Replace document with a full demo article",
149
- icon: "📄",
150
- command: () => {
151
- window.dispatchEvent(new CustomEvent("load-demo-content"));
152
- },
153
- },
154
- {
155
- title: "Reset article",
156
- description: "Wipe document, banner, frontmatter and citations",
157
- icon: "🗑",
158
- command: () => {
159
- window.dispatchEvent(new CustomEvent("reset-article"));
160
- },
161
- },
162
  ];
163
 
164
- const ITEMS: SlashItem[] = [...BUILT_IN_ITEMS, ...getComponentSlashItems()];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
  interface SlashMenuListProps {
167
  items: SlashItem[];
@@ -176,6 +268,12 @@ const SlashMenuList = forwardRef<SlashMenuListRef, SlashMenuListProps>(
176
  ({ items, command }, ref) => {
177
  const [selectedIndex, setSelectedIndex] = useState(0);
178
 
 
 
 
 
 
 
179
  useEffect(() => {
180
  setSelectedIndex(0);
181
  }, [items]);
@@ -210,18 +308,31 @@ const SlashMenuList = forwardRef<SlashMenuListRef, SlashMenuListProps>(
210
 
211
  return (
212
  <div className="slash-menu">
213
- {items.map((item, index) => (
214
- <button
215
- key={item.title}
216
- className={`slash-menu-item ${index === selectedIndex ? "is-selected" : ""}`}
217
- onClick={() => selectItem(index)}
218
- >
219
- <span className="slash-menu-item-icon">{item.icon}</span>
220
- <span className="slash-menu-item-content">
221
- <span className="slash-menu-item-title">{item.title}</span>
222
- <span className="slash-menu-item-desc">{item.description}</span>
223
- </span>
224
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  ))}
226
  </div>
227
  );
@@ -233,8 +344,14 @@ SlashMenuList.displayName = "SlashMenuList";
233
  export function slashMenuSuggestion() {
234
  return {
235
  items: ({ query }: { query: string }) => {
236
- return ITEMS.filter((item) =>
237
- item.title.toLowerCase().includes(query.toLowerCase()),
 
 
 
 
 
 
238
  );
239
  },
240
 
 
5
  forwardRef,
6
  useEffect,
7
  useImperativeHandle,
8
+ useMemo,
9
  useState,
10
  useCallback,
11
  } from "react";
12
  import type { Editor } from "@tiptap/core";
13
  import { getComponentSlashItems } from "./components";
14
 
15
+ type SlashSection =
16
+ | "Insert"
17
+ | "Text"
18
+ | "Academic"
19
+ | "Media"
20
+ | "Layout"
21
+ | "Advanced";
22
+
23
  interface SlashItem {
24
  title: string;
25
  description: string;
26
  icon: string;
27
+ section: SlashSection;
28
  command: (editor: Editor) => void;
29
  }
30
 
 
33
  title: "Heading 1",
34
  description: "Large section heading",
35
  icon: "H1",
36
+ section: "Text",
37
  command: (editor) =>
38
  editor.chain().focus().toggleHeading({ level: 1 }).run(),
39
  },
 
41
  title: "Heading 2",
42
  description: "Medium section heading",
43
  icon: "H2",
44
+ section: "Text",
45
  command: (editor) =>
46
  editor.chain().focus().toggleHeading({ level: 2 }).run(),
47
  },
 
49
  title: "Heading 3",
50
  description: "Small section heading",
51
  icon: "H3",
52
+ section: "Text",
53
  command: (editor) =>
54
  editor.chain().focus().toggleHeading({ level: 3 }).run(),
55
  },
 
57
  title: "Bullet list",
58
  description: "Unordered list",
59
  icon: "•",
60
+ section: "Text",
61
  command: (editor) => editor.chain().focus().toggleBulletList().run(),
62
  },
63
  {
64
  title: "Numbered list",
65
  description: "Ordered list",
66
  icon: "1.",
67
+ section: "Text",
68
  command: (editor) => editor.chain().focus().toggleOrderedList().run(),
69
  },
70
  {
71
  title: "Quote",
72
  description: "Blockquote",
73
  icon: "❝",
74
+ section: "Text",
75
  command: (editor) => editor.chain().focus().toggleBlockquote().run(),
76
  },
77
  {
78
  title: "Code block",
79
  description: "Code with syntax highlighting",
80
  icon: "<>",
81
+ section: "Text",
82
  command: (editor) => editor.chain().focus().toggleCodeBlock().run(),
83
  },
84
  {
85
  title: "Divider",
86
  description: "Horizontal rule",
87
  icon: "—",
88
+ section: "Text",
89
  command: (editor) => editor.chain().focus().setHorizontalRule().run(),
90
  },
91
  {
92
  title: "Image",
93
  description: "Upload or embed an image",
94
  icon: "🖼",
95
+ section: "Insert",
96
  command: (editor) => {
97
  editor.chain().focus().insertImageUpload().run();
98
  },
 
101
  title: "Citation",
102
  description: "Cite a paper (DOI or BibTeX)",
103
  icon: "📎",
104
+ section: "Academic",
105
  command: () => {
106
  window.dispatchEvent(new CustomEvent("open-citation-panel"));
107
  },
 
110
  title: "Glossary",
111
  description: "Highlighted term with tooltip definition",
112
  icon: "📖",
113
+ section: "Academic",
114
  command: (editor) => {
115
  editor.chain().focus().insertGlossary("term", "Definition here…").run();
116
  },
 
119
  title: "Footnote",
120
  description: "Numbered footnote reference",
121
  icon: "¹",
122
+ section: "Academic",
123
  command: (editor) => {
124
  editor.chain().focus().insertFootnote("Footnote content…").run();
125
  },
 
128
  title: "Stack",
129
  description: "Multi-column layout (2-4 columns)",
130
  icon: "▥",
131
+ section: "Layout",
132
  command: (editor) => {
133
  editor.chain().focus().insertStack(2).run();
134
  },
 
137
  title: "New Chart",
138
  description: "Create a D3 chart with AI (Embed Studio)",
139
  icon: "📊",
140
+ section: "Insert",
141
  command: (editor) => {
142
  const id = `d3-chart-${Date.now().toString(36)}`;
143
  const src = `${id}.html`;
144
+ (editor.chain().focus() as any).insertHtmlEmbed().run();
 
145
  setTimeout(() => {
146
  const { doc } = editor.state;
147
  let targetPos = -1;
 
166
  }, 50);
167
  },
168
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  ];
170
 
171
+ /**
172
+ * Component-driven slash items (Accordion, Note, HtmlEmbed, ...) come
173
+ * from the shared component registry. We map each tag to its slash
174
+ * section here so the menu stays categorized when new components are
175
+ * added.
176
+ */
177
+ const COMPONENT_SECTION_MAP: Record<string, SlashSection> = {
178
+ Accordion: "Layout",
179
+ Note: "Academic",
180
+ Quote: "Text",
181
+ Wide: "Layout",
182
+ FullWidth: "Layout",
183
+ Sidenote: "Academic",
184
+ Reference: "Media",
185
+ HtmlEmbed: "Media",
186
+ HfUser: "Media",
187
+ RawHtml: "Advanced",
188
+ Mermaid: "Media",
189
+ };
190
+
191
+ function inferSection(title: string): SlashSection {
192
+ // Component slash items don't carry the original tag; match by label.
193
+ const byLabel: Record<string, SlashSection> = {
194
+ Accordion: "Layout",
195
+ "Note / Callout": "Academic",
196
+ "Quote block": "Text",
197
+ Wide: "Layout",
198
+ "Full width": "Layout",
199
+ Sidenote: "Academic",
200
+ "Reference / Figure": "Media",
201
+ "HTML Embed": "Media",
202
+ "HF User card": "Media",
203
+ "Raw HTML": "Advanced",
204
+ "Mermaid diagram": "Media",
205
+ };
206
+ return byLabel[title] ?? "Layout";
207
+ }
208
+
209
+ function withInferredSection(
210
+ items: Array<Omit<SlashItem, "section"> & { section?: SlashSection }>,
211
+ ): SlashItem[] {
212
+ return items.map((item) => ({
213
+ ...item,
214
+ section: item.section ?? inferSection(item.title),
215
+ }));
216
+ }
217
+
218
+ const ITEMS: SlashItem[] = [
219
+ ...BUILT_IN_ITEMS,
220
+ ...withInferredSection(getComponentSlashItems()),
221
+ ];
222
+
223
+ // Display order of section groups in the menu. "Insert" is a tiny
224
+ // featured section at the very top holding the two flagship heavy
225
+ // inserts (New Chart, Image) - this editor's signature features.
226
+ // Text blocks follow because they're 80% of real usage.
227
+ const SECTION_ORDER: SlashSection[] = [
228
+ "Insert",
229
+ "Text",
230
+ "Media",
231
+ "Academic",
232
+ "Layout",
233
+ "Advanced",
234
+ ];
235
+
236
+ // Used to re-export so we can also feed the section map to anyone who
237
+ // wants it (e.g. the TopBar "more" menu could list categories).
238
+ export { SECTION_ORDER, COMPONENT_SECTION_MAP };
239
+
240
+ interface SlashGroup {
241
+ section: SlashSection;
242
+ items: SlashItem[];
243
+ }
244
+
245
+ function groupItems(items: SlashItem[]): SlashGroup[] {
246
+ const map = new Map<SlashSection, SlashItem[]>();
247
+ for (const item of items) {
248
+ const list = map.get(item.section) ?? [];
249
+ list.push(item);
250
+ map.set(item.section, list);
251
+ }
252
+ return SECTION_ORDER.filter((s) => map.has(s)).map((section) => ({
253
+ section,
254
+ items: map.get(section)!,
255
+ }));
256
+ }
257
 
258
  interface SlashMenuListProps {
259
  items: SlashItem[];
 
268
  ({ items, command }, ref) => {
269
  const [selectedIndex, setSelectedIndex] = useState(0);
270
 
271
+ // We render items in section groups. Keyboard navigation still
272
+ // operates on the flat `items` array (not on section headers),
273
+ // so ArrowDown moves between actual items regardless of their
274
+ // section.
275
+ const groups = useMemo(() => groupItems(items), [items]);
276
+
277
  useEffect(() => {
278
  setSelectedIndex(0);
279
  }, [items]);
 
308
 
309
  return (
310
  <div className="slash-menu">
311
+ {groups.map((group) => (
312
+ <div key={group.section} className="slash-menu-group">
313
+ <div className="slash-menu-section" aria-hidden="true">
314
+ {group.section}
315
+ </div>
316
+ {group.items.map((item) => {
317
+ // Map back to the flat index so keyboard state and
318
+ // click highlight stay in sync.
319
+ const flatIndex = items.indexOf(item);
320
+ return (
321
+ <button
322
+ key={`${group.section}:${item.title}`}
323
+ className={`slash-menu-item ${flatIndex === selectedIndex ? "is-selected" : ""}`}
324
+ onClick={() => selectItem(flatIndex)}
325
+ onMouseEnter={() => setSelectedIndex(flatIndex)}
326
+ >
327
+ <span className="slash-menu-item-icon">{item.icon}</span>
328
+ <span className="slash-menu-item-content">
329
+ <span className="slash-menu-item-title">{item.title}</span>
330
+ <span className="slash-menu-item-desc">{item.description}</span>
331
+ </span>
332
+ </button>
333
+ );
334
+ })}
335
+ </div>
336
  ))}
337
  </div>
338
  );
 
344
  export function slashMenuSuggestion() {
345
  return {
346
  items: ({ query }: { query: string }) => {
347
+ const q = query.toLowerCase();
348
+ if (!q) return ITEMS;
349
+ // Match on title OR description so typing "image" surfaces
350
+ // both "Image" and "HTML Embed", etc.
351
+ return ITEMS.filter(
352
+ (item) =>
353
+ item.title.toLowerCase().includes(q) ||
354
+ item.description.toLowerCase().includes(q),
355
  );
356
  },
357
 
frontend/src/editor/agent-executor.ts ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Editor } from "@tiptap/core";
2
+
3
+ /**
4
+ * Frontend executor for Tiptap commands issued by the AI agent.
5
+ *
6
+ * The backend exposes a catalog of commands (see `backend/src/agent/tiptap-catalog.ts`).
7
+ * When the LLM calls one, `useAgentChat` receives the tool call and
8
+ * dispatches it here. Most commands map directly to
9
+ * `editor.chain().focus()[commandName](args).run()`; a few need
10
+ * specific handling (selectText, setLink).
11
+ *
12
+ * Returns a short human-readable status string that is sent back to the
13
+ * LLM as the tool result so it can plan its next step.
14
+ *
15
+ * Inspired by `tiptap-apcore`'s TiptapExecutor but intentionally tiny -
16
+ * we only cover the commands declared in the catalog.
17
+ */
18
+
19
+ const SAFE_URL_PROTOCOLS = /^(https?|mailto|tel):/i;
20
+ const SAFE_URL_RELATIVE = /^[/#?]/;
21
+
22
+ function isSafeUrl(url: string): boolean {
23
+ const trimmed = url.trim();
24
+ return SAFE_URL_PROTOCOLS.test(trimmed) || SAFE_URL_RELATIVE.test(trimmed);
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // selectText: semantic selection by text content
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Extract all text from the ProseMirror document as a flat string,
33
+ * keeping a mapping from string offsets to document positions.
34
+ *
35
+ * Duplicated from the logic inside `useAgentChat.findTextPosition` so
36
+ * this module has no React dependency.
37
+ */
38
+ function getDocTextWithPositions(editor: Editor): { text: string; map: number[] } {
39
+ const chunks: string[] = [];
40
+ const map: number[] = [];
41
+
42
+ editor.state.doc.descendants((node, pos) => {
43
+ if (node.isText && node.text) {
44
+ for (let i = 0; i < node.text.length; i++) {
45
+ map.push(pos + i);
46
+ chunks.push(node.text[i]);
47
+ }
48
+ } else if (node.isBlock && chunks.length > 0) {
49
+ map.push(pos);
50
+ chunks.push("\n");
51
+ }
52
+ });
53
+
54
+ return { text: chunks.join(""), map };
55
+ }
56
+
57
+ interface SelectTextResult {
58
+ found: boolean;
59
+ from?: number;
60
+ to?: number;
61
+ }
62
+
63
+ function handleSelectText(
64
+ editor: Editor,
65
+ args: {
66
+ text: string;
67
+ occurrence?: number;
68
+ contextBefore?: string;
69
+ contextAfter?: string;
70
+ },
71
+ ): SelectTextResult {
72
+ const { text, occurrence = 1, contextBefore, contextAfter } = args;
73
+ if (!text) return { found: false };
74
+
75
+ const { text: docText, map } = getDocTextWithPositions(editor);
76
+
77
+ // Collect all candidate indices
78
+ const candidates: number[] = [];
79
+ let startIdx = 0;
80
+ while (true) {
81
+ const idx = docText.indexOf(text, startIdx);
82
+ if (idx === -1) break;
83
+ candidates.push(idx);
84
+ startIdx = idx + 1;
85
+ }
86
+ if (candidates.length === 0) return { found: false };
87
+
88
+ let chosenIdx: number;
89
+ if (contextBefore || contextAfter) {
90
+ let bestIdx = candidates[0];
91
+ let bestScore = -1;
92
+ for (const idx of candidates) {
93
+ let score = 0;
94
+ if (contextBefore) {
95
+ const before = docText.slice(Math.max(0, idx - contextBefore.length - 10), idx);
96
+ if (before.includes(contextBefore)) score += 2;
97
+ else if (before.toLowerCase().includes(contextBefore.toLowerCase())) score += 1;
98
+ }
99
+ if (contextAfter) {
100
+ const after = docText.slice(idx + text.length, idx + text.length + contextAfter.length + 10);
101
+ if (after.includes(contextAfter)) score += 2;
102
+ else if (after.toLowerCase().includes(contextAfter.toLowerCase())) score += 1;
103
+ }
104
+ if (score > bestScore) {
105
+ bestScore = score;
106
+ bestIdx = idx;
107
+ }
108
+ }
109
+ chosenIdx = bestIdx;
110
+ } else {
111
+ const occIdx = Math.max(0, Math.min(occurrence - 1, candidates.length - 1));
112
+ chosenIdx = candidates[occIdx];
113
+ }
114
+
115
+ const from = map[chosenIdx];
116
+ const to = map[chosenIdx + text.length - 1] + 1;
117
+ editor.chain().focus().setTextSelection({ from, to }).run();
118
+ return { found: true, from, to };
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Catalog command dispatch
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * Tiptap commands that take no arguments. Called as
127
+ * `editor.chain().focus()[name]().run()`.
128
+ */
129
+ const EMPTY_ARG_COMMANDS = new Set([
130
+ "toggleBold",
131
+ "toggleItalic",
132
+ "toggleStrike",
133
+ "toggleCode",
134
+ "unsetLink",
135
+ "setParagraph",
136
+ "toggleBulletList",
137
+ "toggleOrderedList",
138
+ "toggleBlockquote",
139
+ ]);
140
+
141
+ /**
142
+ * Map a tool call to an `editor.chain()` invocation.
143
+ *
144
+ * Returns null when the tool name is not in the Tiptap catalog (so the
145
+ * caller can fall back to its own handling for applyDiff, frontmatter, etc.).
146
+ */
147
+ export function executeTiptapCommand(
148
+ editor: Editor,
149
+ toolName: string,
150
+ args: Record<string, unknown>,
151
+ ): string | null {
152
+ // selectText is not a native Tiptap command.
153
+ if (toolName === "selectText") {
154
+ const result = handleSelectText(editor, args as Parameters<typeof handleSelectText>[1]);
155
+ if (!result.found) {
156
+ return `selectText: text not found in the document`;
157
+ }
158
+ return `selectText: selected range ${result.from}-${result.to}`;
159
+ }
160
+
161
+ // Empty-arg commands - just call them directly.
162
+ if (EMPTY_ARG_COMMANDS.has(toolName)) {
163
+ const chain = editor.chain().focus();
164
+ const fn = (chain as unknown as Record<string, () => typeof chain>)[toolName];
165
+ if (typeof fn !== "function") {
166
+ return `Unknown editor command: ${toolName}`;
167
+ }
168
+ const success = fn.call(chain).run();
169
+ return success ? `${toolName} applied` : `${toolName} failed (selection state?)`;
170
+ }
171
+
172
+ // Commands with specific argument shapes.
173
+ switch (toolName) {
174
+ case "toggleHeading": {
175
+ const level = (args as { level?: number }).level;
176
+ if (!level || level < 1 || level > 3) return "toggleHeading: invalid level";
177
+ const ok = editor
178
+ .chain()
179
+ .focus()
180
+ .toggleHeading({ level: level as 1 | 2 | 3 })
181
+ .run();
182
+ return ok ? `toggleHeading (level ${level}) applied` : "toggleHeading failed";
183
+ }
184
+ case "toggleCodeBlock": {
185
+ const language = (args as { language?: string }).language;
186
+ const chain = editor.chain().focus();
187
+ const ok = language
188
+ ? chain.toggleCodeBlock({ language }).run()
189
+ : chain.toggleCodeBlock().run();
190
+ return ok ? "toggleCodeBlock applied" : "toggleCodeBlock failed";
191
+ }
192
+ case "setLink": {
193
+ const href = (args as { href?: string }).href;
194
+ if (!href || !isSafeUrl(href)) return `setLink: unsafe or missing URL`;
195
+ const ok = editor.chain().focus().setLink({ href }).run();
196
+ return ok ? `setLink applied (${href})` : "setLink failed (is text selected?)";
197
+ }
198
+ default:
199
+ return null;
200
+ }
201
+ }
202
+
203
+ /** Tool names handled by `executeTiptapCommand`, useful for dispatch. */
204
+ export const TIPTAP_TOOL_NAMES = new Set<string>([
205
+ "selectText",
206
+ "toggleBold",
207
+ "toggleItalic",
208
+ "toggleStrike",
209
+ "toggleCode",
210
+ "setLink",
211
+ "unsetLink",
212
+ "toggleHeading",
213
+ "setParagraph",
214
+ "toggleBulletList",
215
+ "toggleOrderedList",
216
+ "toggleBlockquote",
217
+ "toggleCodeBlock",
218
+ ]);
frontend/src/editor/embeds/embed-data-store.ts ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as Y from "yjs";
2
+
3
+ /**
4
+ * Metadata describing a data file uploaded into the embed data store.
5
+ *
6
+ * Kept intentionally lean (strings + small numbers) so it can live
7
+ * alongside the raw content inside a single Y.Map entry without
8
+ * bloating the document size.
9
+ */
10
+ export interface EmbedDataFileMeta {
11
+ name: string;
12
+ /** Extension without leading dot, lowercased: "csv" | "tsv" | "json" | "txt" */
13
+ ext: string;
14
+ /** Size of the stored text content in bytes (UTF-8). */
15
+ size: number;
16
+ /** Uploader user id (if available). */
17
+ uploader?: string;
18
+ /** Epoch ms when the file was added. */
19
+ addedAt: number;
20
+ /** Light metadata computed client-side after parsing. */
21
+ rowCount?: number;
22
+ columns?: string[];
23
+ }
24
+
25
+ export interface EmbedDataFile {
26
+ meta: EmbedDataFileMeta;
27
+ /** Raw text content (UTF-8). */
28
+ content: string;
29
+ }
30
+
31
+ /**
32
+ * Collaborative store for tabular/textual data files attached to a
33
+ * document. Each key is a filename (e.g. "sales.csv") and the value is
34
+ * an `EmbedDataFile` object (meta + raw text content).
35
+ *
36
+ * These files are accessible to the Embed Studio AI via tool calls so
37
+ * charts can reference user-provided datasets. The store is backed by
38
+ * `Y.Map("embed-data")` for realtime collaboration.
39
+ */
40
+ export function createEmbedDataStore(ydoc: Y.Doc) {
41
+ const ymap = ydoc.getMap<EmbedDataFile>("embed-data");
42
+
43
+ function get(name: string): EmbedDataFile | undefined {
44
+ return ymap.get(name);
45
+ }
46
+
47
+ function getContent(name: string): string {
48
+ return ymap.get(name)?.content ?? "";
49
+ }
50
+
51
+ function getMeta(name: string): EmbedDataFileMeta | undefined {
52
+ return ymap.get(name)?.meta;
53
+ }
54
+
55
+ function has(name: string): boolean {
56
+ return ymap.has(name);
57
+ }
58
+
59
+ function set(file: EmbedDataFile) {
60
+ ymap.set(file.meta.name, file);
61
+ }
62
+
63
+ function remove(name: string) {
64
+ ymap.delete(name);
65
+ }
66
+
67
+ function rename(oldName: string, newName: string) {
68
+ const file = ymap.get(oldName);
69
+ if (!file) return;
70
+ ydoc.transact(() => {
71
+ ymap.set(newName, { ...file, meta: { ...file.meta, name: newName } });
72
+ ymap.delete(oldName);
73
+ });
74
+ }
75
+
76
+ function keys(): string[] {
77
+ return Array.from(ymap.keys());
78
+ }
79
+
80
+ function list(): EmbedDataFileMeta[] {
81
+ const items: EmbedDataFileMeta[] = [];
82
+ ymap.forEach((file) => {
83
+ items.push(file.meta);
84
+ });
85
+ return items.sort((a, b) => a.name.localeCompare(b.name));
86
+ }
87
+
88
+ function observe(callback: () => void) {
89
+ ymap.observe(callback);
90
+ return () => ymap.unobserve(callback);
91
+ }
92
+
93
+ return {
94
+ get,
95
+ getContent,
96
+ getMeta,
97
+ has,
98
+ set,
99
+ remove,
100
+ rename,
101
+ keys,
102
+ list,
103
+ observe,
104
+ };
105
+ }
106
+
107
+ export type EmbedDataStore = ReturnType<typeof createEmbedDataStore>;
frontend/src/editor/extensions/agent-focus.ts ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Broadcasts "this user's AI agent is currently focused on this range" to
3
+ * every collaborator via Yjs awareness, and renders it as a ProseMirror
4
+ * decoration tinted with the owner's color.
5
+ *
6
+ * Design notes
7
+ * ------------
8
+ * - We combine two Tiptap patterns that already exist separately in the
9
+ * ecosystem:
10
+ * 1. the paid `@tiptap-pro/ai-toolkit` `setActiveSelection` feature,
11
+ * which freezes a range locally while the agent is working on it;
12
+ * 2. `@tiptap/extension-collaboration-caret`, which shares cursor
13
+ * positions across peers via Yjs awareness using relative
14
+ * positions.
15
+ *
16
+ * - Ranges are stored in awareness as **Y.js relative positions**
17
+ * (JSON form for safe transport) so they remain meaningful on every
18
+ * peer's doc even as Bob or Carol are typing concurrently. We resolve
19
+ * them back to absolute positions at decoration time.
20
+ *
21
+ * - Every peer (including self) gets both the inline tint AND the
22
+ * floating "<Name> agent" label. When the chat panel is open the
23
+ * editor often loses DOM focus to the chat textarea, which makes
24
+ * the native `::selection` fade or disappear: the PM decoration is
25
+ * then the ONLY thing keeping the range visible on the owner's
26
+ * screen. To avoid stacking two tints when the editor still has
27
+ * focus, `_ui.css` hides the native `::selection` inside
28
+ * `.ProseMirror` while `.editor-app--chat-open` is set.
29
+ *
30
+ * Usage (from App.tsx):
31
+ * editor.commands.setAgentFocus({ from, to }); // on chat open
32
+ * editor.commands.clearAgentFocus(); // on chat close
33
+ */
34
+ import { Extension } from "@tiptap/core";
35
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
36
+ import { Decoration, DecorationSet } from "@tiptap/pm/view";
37
+ import {
38
+ absolutePositionToRelativePosition,
39
+ relativePositionToAbsolutePosition,
40
+ ySyncPluginKey,
41
+ } from "@tiptap/y-tiptap";
42
+ import * as Y from "yjs";
43
+
44
+ export interface AgentFocusUser {
45
+ name: string;
46
+ color: string;
47
+ avatarUrl?: string;
48
+ }
49
+
50
+ export interface AgentFocusOptions {
51
+ // Any Yjs provider with a `.awareness` API (Hocuspocus, y-websocket, ...)
52
+ provider: any;
53
+ user: AgentFocusUser;
54
+ }
55
+
56
+ interface AwarenessFocus {
57
+ anchor: unknown;
58
+ head: unknown;
59
+ user: AgentFocusUser;
60
+ }
61
+
62
+ export const agentFocusPluginKey = new PluginKey("agent-focus");
63
+
64
+ declare module "@tiptap/core" {
65
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
66
+ interface Commands<ReturnType> {
67
+ agentFocus: {
68
+ /** Mark `[from, to]` as "my agent is working on this". */
69
+ setAgentFocus: (args: { from: number; to: number }) => ReturnType;
70
+ /** Remove our own agent-focus highlight. */
71
+ clearAgentFocus: () => ReturnType;
72
+ /** Update the awareness user (name / color / avatar). */
73
+ updateAgentFocusUser: (user: AgentFocusUser) => ReturnType;
74
+ };
75
+ }
76
+ }
77
+
78
+ export const AgentFocus = Extension.create<AgentFocusOptions>({
79
+ name: "agentFocus",
80
+
81
+ addOptions() {
82
+ return {
83
+ provider: null,
84
+ user: { name: "Anonymous", color: "#5c7cfa" },
85
+ };
86
+ },
87
+
88
+ onCreate() {
89
+ if (!this.options.provider) {
90
+ throw new Error('The "provider" option is required for AgentFocus');
91
+ }
92
+ },
93
+
94
+ addCommands() {
95
+ // None of these commands mutate the ProseMirror document - they only
96
+ // touch Yjs awareness. That's what guarantees they stay out of the
97
+ // undo stack: `Y.UndoManager` tracks CRDT ops, awareness is
98
+ // ephemeral metadata. Tiptap will still dispatch an empty transaction
99
+ // after each command returns `true`, but an empty tr has
100
+ // `tr.docChanged === false`, so y-sync ignores it and undo is clean.
101
+ return {
102
+ setAgentFocus:
103
+ ({ from, to }) =>
104
+ ({ state }) => {
105
+ const ystate = ySyncPluginKey.getState(state);
106
+ if (!ystate?.binding) return false;
107
+
108
+ const size = state.doc.content.size;
109
+ const a = Math.max(0, Math.min(from, size));
110
+ const b = Math.max(0, Math.min(to, size));
111
+ if (a === b) return false;
112
+ const [lo, hi] = a < b ? [a, b] : [b, a];
113
+
114
+ const anchor = absolutePositionToRelativePosition(
115
+ lo,
116
+ ystate.type,
117
+ ystate.binding.mapping,
118
+ );
119
+ const head = absolutePositionToRelativePosition(
120
+ hi,
121
+ ystate.type,
122
+ ystate.binding.mapping,
123
+ );
124
+
125
+ this.options.provider.awareness.setLocalStateField("agentFocus", {
126
+ anchor: Y.relativePositionToJSON(anchor),
127
+ head: Y.relativePositionToJSON(head),
128
+ user: this.options.user,
129
+ });
130
+ return true;
131
+ },
132
+
133
+ clearAgentFocus: () => () => {
134
+ this.options.provider.awareness.setLocalStateField("agentFocus", null);
135
+ return true;
136
+ },
137
+
138
+ updateAgentFocusUser: (user) => () => {
139
+ this.options.user = user;
140
+ // Re-emit current focus with the updated user payload, if any.
141
+ const current = this.options.provider.awareness.getLocalState()
142
+ ?.agentFocus as AwarenessFocus | null | undefined;
143
+ if (current?.anchor && current?.head) {
144
+ this.options.provider.awareness.setLocalStateField("agentFocus", {
145
+ ...current,
146
+ user,
147
+ });
148
+ }
149
+ return true;
150
+ },
151
+ };
152
+ },
153
+
154
+ addProseMirrorPlugins() {
155
+ const awareness = this.options.provider.awareness;
156
+
157
+ return [
158
+ new Plugin({
159
+ key: agentFocusPluginKey,
160
+ state: {
161
+ init: () => ({ stamp: 0 }),
162
+ apply(tr, prev) {
163
+ if (tr.getMeta(agentFocusPluginKey)) {
164
+ return { stamp: prev.stamp + 1 };
165
+ }
166
+ return prev;
167
+ },
168
+ },
169
+ props: {
170
+ decorations(state) {
171
+ const ystate = ySyncPluginKey.getState(state);
172
+ if (!ystate?.binding) return null;
173
+
174
+ const decorations: Decoration[] = [];
175
+ const localClientId = awareness.clientID;
176
+
177
+ awareness.getStates().forEach((s: any, clientId: number) => {
178
+ const focus = s?.agentFocus as
179
+ | AwarenessFocus
180
+ | null
181
+ | undefined;
182
+ if (!focus || !focus.anchor || !focus.head) return;
183
+ const isSelf = clientId === localClientId;
184
+
185
+ let from: number | null = null;
186
+ let to: number | null = null;
187
+ try {
188
+ from = relativePositionToAbsolutePosition(
189
+ ystate.doc,
190
+ ystate.type,
191
+ Y.createRelativePositionFromJSON(focus.anchor),
192
+ ystate.binding.mapping,
193
+ );
194
+ to = relativePositionToAbsolutePosition(
195
+ ystate.doc,
196
+ ystate.type,
197
+ Y.createRelativePositionFromJSON(focus.head),
198
+ ystate.binding.mapping,
199
+ );
200
+ } catch {
201
+ return;
202
+ }
203
+
204
+ if (from == null || to == null || from === to) return;
205
+ const [lo, hi] = from < to ? [from, to] : [to, from];
206
+
207
+ const color = focus.user?.color || "#5c7cfa";
208
+ const name = focus.user?.name || "";
209
+ // Match Tiptap's `defaultSelectionBuilder` exactly: a flat
210
+ // `{color}70` tint (hex + alpha), no underline, no shadow -
211
+ // so an agent focus is visually the same as a collaborator
212
+ // selection, only the label name ("… agent") tells them apart.
213
+ const style = `background-color: ${color}70`;
214
+
215
+ decorations.push(
216
+ Decoration.inline(
217
+ lo,
218
+ hi,
219
+ {
220
+ class: `agent-focus${isSelf ? " agent-focus--self" : ""}`,
221
+ style,
222
+ "data-agent-focus-user": name,
223
+ "data-agent-focus-color": color,
224
+ } as any,
225
+ // inclusiveStart:false / inclusiveEnd:false -> typing
226
+ // at either edge should not extend the highlight, the
227
+ // Y.js relative position already pins the true
228
+ // boundary.
229
+ { inclusiveStart: false, inclusiveEnd: false } as any,
230
+ ),
231
+ );
232
+
233
+ // Floating label anchored at the END of the range, i.e.
234
+ // on the last line of the selection at the cursor
235
+ // position. This mirrors `.collaboration-cursor__label`,
236
+ // which also renders on the right of the last line (at
237
+ // the selection head). Using `hi` instead of `lo` keeps
238
+ // the badge visually coupled to "where the agent's cursor
239
+ // would sit if it were typing right now".
240
+ decorations.push(
241
+ Decoration.widget(
242
+ hi,
243
+ () => {
244
+ const anchor = document.createElement("span");
245
+ anchor.className = "agent-focus__anchor";
246
+
247
+ const label = document.createElement("span");
248
+ label.className = "agent-focus__label";
249
+ label.setAttribute(
250
+ "style",
251
+ `background-color: ${color}`,
252
+ );
253
+
254
+ const avatarUrl = focus.user?.avatarUrl;
255
+ if (avatarUrl) {
256
+ const avatar = document.createElement("img");
257
+ avatar.src = avatarUrl;
258
+ avatar.className = "agent-focus__avatar";
259
+ avatar.alt = "";
260
+ label.appendChild(avatar);
261
+ }
262
+
263
+ const text = document.createElement("span");
264
+ text.className = "agent-focus__text";
265
+ text.textContent = `${name || "Someone"} agent`;
266
+ label.appendChild(text);
267
+
268
+ anchor.appendChild(label);
269
+ return anchor;
270
+ },
271
+ {
272
+ // `side: 1` -> the widget stays on the RIGHT side of
273
+ // the `hi` position, so if the user types inside the
274
+ // range the label doesn't get pushed into the middle
275
+ // of new content. `key` lets PM reuse the DOM on
276
+ // every awareness tick instead of re-creating it.
277
+ side: 1,
278
+ key: `agent-focus-label-${clientId}-${name}`,
279
+ } as any,
280
+ ),
281
+ );
282
+ });
283
+
284
+ if (decorations.length === 0) return null;
285
+ return DecorationSet.create(state.doc, decorations);
286
+ },
287
+ },
288
+ view(view) {
289
+ // `change` fires only when an awareness state is actually added,
290
+ // updated, or removed - NOT on heartbeat ticks. Listening to
291
+ // `update` instead (as some examples do) would trigger a
292
+ // re-render every ~30s for nothing.
293
+ //
294
+ // The dispatched transaction is meta-only: no steps, no doc
295
+ // change, no selection change. y-sync ignores it because
296
+ // `tr.docChanged` is false, and the Yjs UndoManager only
297
+ // tracks CRDT operations - so this never pollutes the undo
298
+ // stack. Agent-focus is 100% ephemeral decoration state.
299
+ const handler = () => {
300
+ if (view.isDestroyed) return;
301
+ view.dispatch(
302
+ view.state.tr.setMeta(agentFocusPluginKey, { refresh: true }),
303
+ );
304
+ };
305
+ awareness.on("change", handler);
306
+ return {
307
+ destroy() {
308
+ awareness.off("change", handler);
309
+ },
310
+ };
311
+ },
312
+ }),
313
+ ];
314
+ },
315
+ });
frontend/src/editor/extensions/agent-highlight.ts ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Demo-only Tiptap extension that lets the Playwright showcase script mark a
3
+ * paragraph as "the one an agent is currently working on" and have the
4
+ * highlight survive any amount of concurrent Yjs syncing, remote typing,
5
+ * or local selection changes.
6
+ *
7
+ * Why a ProseMirror decoration and not a DOM class?
8
+ * -------------------------------------------------
9
+ * In a Tiptap + Yjs editor, the DOM for a paragraph can be torn down and
10
+ * re-created at any time: every remote update from Bob or Carol triggers
11
+ * a sync transaction, and PM may decide to rebuild the `<p>` (especially
12
+ * under the collaboration plugin). Any `className` we slap on the `<p>`
13
+ * is lost on the next render, which is why previous versions of the
14
+ * demo relied on a 150 ms polling timer to re-apply the class. That
15
+ * worked, but it flickers and leaves a visible gap between "selection
16
+ * happens" and "class comes back".
17
+ *
18
+ * ProseMirror decorations solve this at the right layer: they live in
19
+ * editor state, are re-rendered on every view update, and are remapped
20
+ * through doc changes automatically. Here we store a `{ from, to, phase }`
21
+ * range in plugin state, decorate the paragraph containing `from` with
22
+ * the appropriate `demo-agent-{pending|rewriting}` class, and let PM do
23
+ * the rest.
24
+ *
25
+ * Usage (from the demo script or App.tsx):
26
+ * editor.commands.setAgentHighlight({ from, to, phase: "pending" });
27
+ * editor.commands.setAgentHighlight({ phase: "rewriting" });
28
+ * editor.commands.clearAgentHighlight();
29
+ */
30
+ import { Extension } from "@tiptap/core";
31
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
32
+ import { Decoration, DecorationSet } from "@tiptap/pm/view";
33
+
34
+ export type AgentHighlightPhase = "pending" | "rewriting";
35
+
36
+ interface AgentHighlightState {
37
+ from: number | null;
38
+ to: number | null;
39
+ phase: AgentHighlightPhase | null;
40
+ }
41
+
42
+ interface AgentHighlightMeta {
43
+ from?: number | null;
44
+ to?: number | null;
45
+ phase?: AgentHighlightPhase | null;
46
+ clear?: boolean;
47
+ }
48
+
49
+ export const agentHighlightPluginKey =
50
+ new PluginKey<AgentHighlightState>("agent-highlight");
51
+
52
+ declare module "@tiptap/core" {
53
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
54
+ interface Commands<ReturnType> {
55
+ agentHighlight: {
56
+ /**
57
+ * Set (or update) the highlighted range and phase. Omitting
58
+ * `from`/`to` keeps the existing range; omitting `phase` keeps
59
+ * the existing phase. Both can be updated independently so the
60
+ * App.tsx rewrite handler can flip `pending -> rewriting` without
61
+ * recomputing the range.
62
+ */
63
+ setAgentHighlight: (args: {
64
+ from?: number;
65
+ to?: number;
66
+ phase?: AgentHighlightPhase;
67
+ }) => ReturnType;
68
+ /** Remove any active highlight. */
69
+ clearAgentHighlight: () => ReturnType;
70
+ };
71
+ }
72
+ }
73
+
74
+ export const AgentHighlight = Extension.create({
75
+ name: "agentHighlight",
76
+
77
+ addProseMirrorPlugins() {
78
+ return [
79
+ new Plugin<AgentHighlightState>({
80
+ key: agentHighlightPluginKey,
81
+ state: {
82
+ init: (): AgentHighlightState => ({
83
+ from: null,
84
+ to: null,
85
+ phase: null,
86
+ }),
87
+ apply(tr, prev): AgentHighlightState {
88
+ const meta = tr.getMeta(agentHighlightPluginKey) as
89
+ | AgentHighlightMeta
90
+ | undefined;
91
+ if (meta) {
92
+ if (meta.clear) {
93
+ return { from: null, to: null, phase: null };
94
+ }
95
+ return {
96
+ from: meta.from !== undefined ? meta.from : prev.from,
97
+ to: meta.to !== undefined ? meta.to : prev.to,
98
+ phase: meta.phase !== undefined ? meta.phase : prev.phase,
99
+ };
100
+ }
101
+
102
+ if (prev.from !== null && prev.to !== null) {
103
+ const from = tr.mapping.map(prev.from, 1);
104
+ const to = tr.mapping.map(prev.to, -1);
105
+ if (to > from) {
106
+ if (from === prev.from && to === prev.to) return prev;
107
+ return { ...prev, from, to };
108
+ }
109
+ return { from: null, to: null, phase: null };
110
+ }
111
+
112
+ return prev;
113
+ },
114
+ },
115
+ props: {
116
+ decorations(state) {
117
+ const s = agentHighlightPluginKey.getState(state);
118
+ if (!s || s.from === null || s.to === null || !s.phase) {
119
+ return null;
120
+ }
121
+
122
+ let $pos;
123
+ try {
124
+ $pos = state.doc.resolve(s.from);
125
+ } catch {
126
+ return null;
127
+ }
128
+
129
+ const depth = $pos.depth;
130
+ if (depth <= 0) return null;
131
+
132
+ const nodeStart = $pos.before(depth);
133
+ const nodeEnd = $pos.after(depth);
134
+ const cls = `demo-agent-${s.phase}`;
135
+
136
+ return DecorationSet.create(state.doc, [
137
+ Decoration.node(nodeStart, nodeEnd, { class: cls }),
138
+ ]);
139
+ },
140
+ },
141
+ }),
142
+ ];
143
+ },
144
+
145
+ addCommands() {
146
+ return {
147
+ setAgentHighlight:
148
+ (args) =>
149
+ ({ tr, dispatch }) => {
150
+ if (dispatch) {
151
+ tr.setMeta(agentHighlightPluginKey, args);
152
+ }
153
+ return true;
154
+ },
155
+ clearAgentHighlight:
156
+ () =>
157
+ ({ tr, dispatch }) => {
158
+ if (dispatch) {
159
+ tr.setMeta(agentHighlightPluginKey, { clear: true });
160
+ }
161
+ return true;
162
+ },
163
+ };
164
+ },
165
+ });
frontend/src/editor/extensions/agent-rewrite.ts ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * AgentRewrite
3
+ * ============
4
+ *
5
+ * One canonical Tiptap command to perform an agent-driven replacement
6
+ * of a range in the document - optionally with a typewriter animation
7
+ * so the user sees the agent "typing" the new content in place of the
8
+ * old one.
9
+ *
10
+ * Called by:
11
+ * - the real agent's tool calls (applyDiff / rewrite / rephrase),
12
+ * - the Playwright showcase demo script via the `__demo-chat` event.
13
+ *
14
+ * Why this lives in an extension (and not in App.tsx or the demo)
15
+ * ---------------------------------------------------------------
16
+ * The previous design had an ad-hoc `setTimeout` loop in App.tsx that
17
+ * re-inserted characters at `startPos + i` - a fixed numeric origin.
18
+ * Under Yjs collaboration any concurrent remote edit that shifted
19
+ * `startPos` (e.g. another user inserting a character before it) would
20
+ * desync the animation and land subsequent chunks inside the wrong
21
+ * paragraph, producing a visible "write-over-itself" artifact.
22
+ *
23
+ * Here we track the insertion `cursor` in PM plugin state and remap it
24
+ * through `tr.mapping` on every transaction - local OR remote. This
25
+ * guarantees the typewriter resumes at the correct absolute position
26
+ * no matter what Bob or Carol are doing elsewhere in the doc.
27
+ *
28
+ * Cancellation
29
+ * ------------
30
+ * Each call to `agentRewriteRange` bumps a cancel token stored in
31
+ * plugin state. Any still-scheduled animation step that observes a
32
+ * different token aborts. That way, if the agent fires two
33
+ * overlapping rewrites, the first one stops immediately instead of
34
+ * fighting the second for control of the doc.
35
+ *
36
+ * Undo-friendliness
37
+ * -----------------
38
+ * Every animation step is a small atomic `insertText` transaction,
39
+ * so y-undo sees a tidy sequence of ReplaceSteps instead of one huge
40
+ * debug-hostile mega-op. Undo works as expected (one step per chunk).
41
+ */
42
+ import { Extension } from "@tiptap/core";
43
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
44
+
45
+ export type AgentRewriteAnimation = "none" | "typewriter";
46
+
47
+ export interface AgentRewriteArgs {
48
+ /** Absolute PM position of the start of the range to replace. */
49
+ from: number;
50
+ /** Absolute PM position of the end of the range to replace. */
51
+ to: number;
52
+ /** New text to insert in place of the deleted range. */
53
+ text: string;
54
+ /**
55
+ * "none" -> replace the range atomically in one transaction.
56
+ * "typewriter" -> stream the replacement chunk-by-chunk with small
57
+ * delays so the UI shows the agent "typing".
58
+ * Default: "none" (safer for programmatic / non-demo callers).
59
+ */
60
+ animation?: AgentRewriteAnimation;
61
+ /**
62
+ * Characters per animation tick. A random int in [min,max] is chosen
63
+ * each step, which makes the typing feel organic. Default [1, 2].
64
+ */
65
+ chunkSize?: [number, number];
66
+ /**
67
+ * Milliseconds between animation ticks. Default [22, 48].
68
+ */
69
+ chunkMs?: [number, number];
70
+ }
71
+
72
+ interface AgentRewriteState {
73
+ /**
74
+ * Current insertion position for the in-flight typewriter animation.
75
+ * Mapped through every transaction so local + remote edits never
76
+ * shift the cursor out of sync with the actual doc content.
77
+ */
78
+ cursor: number | null;
79
+ /**
80
+ * Monotonically-increasing token. Any call to `agentRewriteRange`
81
+ * bumps this, which retroactively cancels any animation step still
82
+ * scheduled from a previous call.
83
+ */
84
+ cancelToken: number;
85
+ }
86
+
87
+ interface AgentRewriteMeta {
88
+ cancel?: boolean;
89
+ setCursor?: number | null;
90
+ }
91
+
92
+ const agentRewriteKey = new PluginKey<AgentRewriteState>("agent-rewrite");
93
+
94
+ declare module "@tiptap/core" {
95
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
96
+ interface Commands<ReturnType> {
97
+ agentRewrite: {
98
+ /**
99
+ * Replace `[from, to]` with `text`. When `animation === "typewriter"`
100
+ * the insertion is streamed chunk-by-chunk; otherwise it's one
101
+ * atomic transaction.
102
+ */
103
+ agentRewriteRange: (args: AgentRewriteArgs) => ReturnType;
104
+ /** Abort any in-flight typewriter animation immediately. */
105
+ cancelAgentRewrite: () => ReturnType;
106
+ };
107
+ }
108
+ }
109
+
110
+ export const AgentRewrite = Extension.create({
111
+ name: "agentRewrite",
112
+
113
+ addProseMirrorPlugins() {
114
+ return [
115
+ new Plugin<AgentRewriteState>({
116
+ key: agentRewriteKey,
117
+ state: {
118
+ init: (): AgentRewriteState => ({ cursor: null, cancelToken: 0 }),
119
+ apply(tr, prev): AgentRewriteState {
120
+ let next = prev;
121
+ // Remap the tracked cursor through any doc change (local or
122
+ // remote). This is the whole point of using a plugin state
123
+ // instead of a closure-captured number: Yjs pushes tr's for
124
+ // remote edits and we ride their mapping.
125
+ if (tr.docChanged && next.cursor !== null) {
126
+ next = { ...next, cursor: tr.mapping.map(next.cursor) };
127
+ }
128
+ const meta = tr.getMeta(agentRewriteKey) as
129
+ | AgentRewriteMeta
130
+ | undefined;
131
+ if (meta) {
132
+ if (meta.cancel) {
133
+ // `cancel` wipes the cursor AND advances the token so
134
+ // in-flight steps observe a mismatch and bail out.
135
+ next = {
136
+ cursor: null,
137
+ cancelToken: next.cancelToken + 1,
138
+ };
139
+ }
140
+ if (meta.setCursor !== undefined) {
141
+ // `setCursor` is the primitive used by the command to
142
+ // re-arm the plugin with a fresh starting position
143
+ // (AFTER any cancel has taken effect in the same meta).
144
+ next = { ...next, cursor: meta.setCursor };
145
+ }
146
+ }
147
+ return next;
148
+ },
149
+ },
150
+ }),
151
+ ];
152
+ },
153
+
154
+ addCommands() {
155
+ return {
156
+ cancelAgentRewrite:
157
+ () =>
158
+ ({ tr, dispatch }) => {
159
+ if (dispatch) {
160
+ tr.setMeta(agentRewriteKey, { cancel: true });
161
+ }
162
+ return true;
163
+ },
164
+
165
+ agentRewriteRange:
166
+ (args) =>
167
+ ({ editor, state, view, tr, dispatch }) => {
168
+ const docSize = state.doc.content.size;
169
+ const a = Math.max(0, Math.min(args.from, docSize));
170
+ const b = Math.max(0, Math.min(args.to, docSize));
171
+ if (b < a) return false;
172
+ if (!dispatch) return true;
173
+
174
+ // Step 1: single atomic transaction that (a) cancels any
175
+ // in-flight rewrite, (b) deletes the target range, and (c)
176
+ // plants the insertion cursor at `a`. Done in one tr so the
177
+ // deletion and cursor seed land together - avoids a visible
178
+ // gap between delete and first insert.
179
+ tr.setMeta(agentRewriteKey, {
180
+ cancel: true,
181
+ setCursor: a,
182
+ } as AgentRewriteMeta);
183
+ if (b > a) tr.delete(a, b);
184
+ dispatch(tr);
185
+
186
+ const text = args.text ?? "";
187
+ if (!text) {
188
+ // Nothing to insert, just a deletion. Done.
189
+ view.dispatch(
190
+ view.state.tr.setMeta(agentRewriteKey, {
191
+ setCursor: null,
192
+ } as AgentRewriteMeta),
193
+ );
194
+ return true;
195
+ }
196
+
197
+ const animation = args.animation ?? "none";
198
+ if (animation === "none") {
199
+ // Synchronous, one-shot insert. Single tr -> one undo step.
200
+ const cursor =
201
+ agentRewriteKey.getState(editor.state)?.cursor ?? a;
202
+ view.dispatch(
203
+ view.state.tr
204
+ .insertText(text, cursor)
205
+ .setMeta(agentRewriteKey, {
206
+ setCursor: null,
207
+ } as AgentRewriteMeta),
208
+ );
209
+ return true;
210
+ }
211
+
212
+ // Typewriter: async stream of small insertText trs. Each step
213
+ // reads the CURRENT cursor from plugin state (mapped through
214
+ // every intervening tr, including remote Yjs updates), so the
215
+ // animation is immune to concurrent edits.
216
+ const [chunkMin, chunkMax] = args.chunkSize ?? [1, 2];
217
+ const [delayMin, delayMax] = args.chunkMs ?? [22, 48];
218
+ const myToken =
219
+ agentRewriteKey.getState(editor.state)?.cancelToken ?? 0;
220
+ let i = 0;
221
+
222
+ const step = () => {
223
+ if (view.isDestroyed) return;
224
+ const ps = agentRewriteKey.getState(view.state);
225
+ if (!ps) return;
226
+ // Another rewrite started (or cancel was called) -> stop.
227
+ if (ps.cancelToken !== myToken) return;
228
+ if (ps.cursor === null) return;
229
+
230
+ if (i >= text.length) {
231
+ // Done: clear the cursor marker so nothing lingers in
232
+ // plugin state. The caller is responsible for any visual
233
+ // cleanup (e.g. `clearAgentFocus`).
234
+ view.dispatch(
235
+ view.state.tr.setMeta(agentRewriteKey, {
236
+ setCursor: null,
237
+ } as AgentRewriteMeta),
238
+ );
239
+ return;
240
+ }
241
+
242
+ const remaining = text.length - i;
243
+ const maxTake = Math.max(1, Math.min(chunkMax, remaining));
244
+ const minTake = Math.max(1, Math.min(chunkMin, maxTake));
245
+ const take =
246
+ minTake +
247
+ Math.floor(Math.random() * (maxTake - minTake + 1));
248
+ const chunk = text.slice(i, i + take);
249
+
250
+ // `insertText(chunk, cursor)` advances the cursor via the
251
+ // plugin's `apply` mapping (see state.apply above).
252
+ view.dispatch(view.state.tr.insertText(chunk, ps.cursor));
253
+ i += chunk.length;
254
+
255
+ const delay =
256
+ delayMin + Math.random() * Math.max(0, delayMax - delayMin);
257
+ setTimeout(step, delay);
258
+ };
259
+ // Kick off on a macrotask so the initial delete tr has fully
260
+ // applied (and the cursor seed is live in plugin state)
261
+ // before the first step reads it.
262
+ setTimeout(step, 0);
263
+ return true;
264
+ },
265
+ };
266
+ },
267
+ });
frontend/src/editor/frontmatter/SettingsDrawer.tsx CHANGED
@@ -77,15 +77,6 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
77
  />
78
  </FieldGroup>
79
 
80
- <FieldGroup label="Banner embed">
81
- <input
82
- className="form-input"
83
- placeholder="banner.html"
84
- value={data.banner}
85
- onChange={(e) => update("banner", e.target.value)}
86
- />
87
- </FieldGroup>
88
-
89
  <FieldGroup label="Citation style">
90
  <select
91
  className="form-select"
 
77
  />
78
  </FieldGroup>
79
 
 
 
 
 
 
 
 
 
 
80
  <FieldGroup label="Citation style">
81
  <select
82
  className="form-select"
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -8,6 +8,7 @@ import type { Editor } from "@tiptap/core";
8
  import type { UndoManager } from "yjs";
9
  import type { UIMessage } from "ai";
10
  import type { FrontmatterStore } from "../editor/frontmatter/frontmatter-store";
 
11
 
12
  interface UseAgentChatOptions {
13
  editor: Editor | null;
@@ -149,6 +150,18 @@ export function useAgentChat({ editor, undoManager, frontmatterStore, modelRef,
149
 
150
  const executeToolCall = useCallback(
151
  (toolCall: { toolName: string; args: unknown; toolCallId: string }) => {
 
 
 
 
 
 
 
 
 
 
 
 
152
  switch (toolCall.toolName) {
153
  // --- Frontmatter tools (no editor needed) ---
154
  case "updateFrontmatter": {
@@ -258,10 +271,29 @@ export function useAgentChat({ editor, undoManager, frontmatterStore, modelRef,
258
  [editor, frontmatterStore, startAgentBatch, findTextPosition],
259
  );
260
 
 
 
 
 
 
 
261
  const { addToolOutput, ...chat } = useChat({
262
  transport,
263
  messages: initialMessages,
264
- sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
 
 
 
 
 
 
 
 
 
 
 
 
 
265
 
266
  async onToolCall({ toolCall }) {
267
  if (toolCall.dynamic) return;
@@ -310,32 +342,6 @@ export function useAgentChat({ editor, undoManager, frontmatterStore, modelRef,
310
  [chat, getEditorContext, endAgentBatch, modelRef],
311
  );
312
 
313
- const sendQuickAction = useCallback(
314
- (action: string) => {
315
- endAgentBatch();
316
- const context = getEditorContext();
317
- const prompts: Record<string, string> = {
318
- rewrite:
319
- "Rewrite the selected text to be clearer and more concise.",
320
- expand:
321
- "Expand the selected text with more detail and examples.",
322
- summarize:
323
- "Summarize the selected text into a shorter version.",
324
- "fix-grammar":
325
- "Fix any grammar and spelling errors in the selected text.",
326
- translate:
327
- "Translate the selected text to English (or French if already in English).",
328
- simplify:
329
- "Simplify the selected text so it's easier to understand.",
330
- };
331
-
332
- const prompt = prompts[action] || action;
333
- const model = modelRef.current;
334
- chat.sendMessage({ text: prompt }, { body: { context, model } });
335
- },
336
- [chat, getEditorContext, endAgentBatch, modelRef],
337
- );
338
-
339
  const isLoading =
340
  chat.status === "streaming" || chat.status === "submitted";
341
 
@@ -353,7 +359,6 @@ export function useAgentChat({ editor, undoManager, frontmatterStore, modelRef,
353
  isLoading,
354
  error: chat.error,
355
  sendMessage,
356
- sendQuickAction,
357
  clearMessages,
358
  setMessages,
359
  input,
 
8
  import type { UndoManager } from "yjs";
9
  import type { UIMessage } from "ai";
10
  import type { FrontmatterStore } from "../editor/frontmatter/frontmatter-store";
11
+ import { executeTiptapCommand, TIPTAP_TOOL_NAMES } from "../editor/agent-executor";
12
 
13
  interface UseAgentChatOptions {
14
  editor: Editor | null;
 
150
 
151
  const executeToolCall = useCallback(
152
  (toolCall: { toolName: string; args: unknown; toolCallId: string }) => {
153
+ // --- Tiptap catalog commands (select, toggleBold, toggleHeading, ...) ---
154
+ if (TIPTAP_TOOL_NAMES.has(toolCall.toolName)) {
155
+ if (!editor) return "Editor not available";
156
+ startAgentBatch();
157
+ const result = executeTiptapCommand(
158
+ editor,
159
+ toolCall.toolName,
160
+ (toolCall.args as Record<string, unknown>) ?? {},
161
+ );
162
+ return result ?? `Unknown editor command: ${toolCall.toolName}`;
163
+ }
164
+
165
  switch (toolCall.toolName) {
166
  // --- Frontmatter tools (no editor needed) ---
167
  case "updateFrontmatter": {
 
271
  [editor, frontmatterStore, startAgentBatch, findTextPosition],
272
  );
273
 
274
+ /**
275
+ * Hard cap on consecutive auto-sent tool-call rounds to prevent
276
+ * runaway loops (e.g. a model that keeps calling tools instead of
277
+ * emitting a final text response).
278
+ */
279
+ const MAX_TOOL_ROUNDS = 8;
280
  const { addToolOutput, ...chat } = useChat({
281
  transport,
282
  messages: initialMessages,
283
+ sendAutomaticallyWhen: ({ messages }) => {
284
+ let toolRounds = 0;
285
+ for (let i = messages.length - 1; i >= 0; i--) {
286
+ const m = messages[i];
287
+ if (m.role !== "assistant") break;
288
+ const hasToolPart = (m.parts ?? []).some((p) =>
289
+ typeof p.type === "string" && p.type.startsWith("tool-"),
290
+ );
291
+ if (!hasToolPart) break;
292
+ toolRounds += 1;
293
+ }
294
+ if (toolRounds >= MAX_TOOL_ROUNDS) return false;
295
+ return lastAssistantMessageIsCompleteWithToolCalls({ messages });
296
+ },
297
 
298
  async onToolCall({ toolCall }) {
299
  if (toolCall.dynamic) return;
 
342
  [chat, getEditorContext, endAgentBatch, modelRef],
343
  );
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  const isLoading =
346
  chat.status === "streaming" || chat.status === "submitted";
347
 
 
359
  isLoading,
360
  error: chat.error,
361
  sendMessage,
 
362
  clearMessages,
363
  setMessages,
364
  input,
frontend/src/hooks/useEmbedChat.ts CHANGED
@@ -6,17 +6,66 @@ import {
6
  import { useCallback, useEffect, useRef, useState } from "react";
7
  import type { UIMessage } from "ai";
8
  import type { EmbedStore } from "../editor/embeds/embed-store";
 
9
  import { loadMessages, saveMessages } from "../utils/chat-persistence";
10
 
11
  interface UseEmbedChatOptions {
12
  embedStore: EmbedStore | null;
 
13
  /** Current embed src key (filename) */
14
  src: string;
15
  modelRef: React.RefObject<string>;
16
  userId: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  }
18
 
19
  const MAX_EMBED_SIZE = 512_000;
 
 
 
 
 
 
 
20
 
21
  const transport = new DefaultChatTransport({ api: "/api/embed-chat" });
22
 
@@ -24,12 +73,25 @@ function scopeKey(src: string): string {
24
  return `embed:${src}`;
25
  }
26
 
27
- export function useEmbedChat({ embedStore, src, modelRef, userId }: UseEmbedChatOptions) {
28
  const [input, setInput] = useState("");
 
 
 
 
 
29
  const srcRef = useRef(src);
30
- srcRef.current = src;
 
 
 
 
 
 
31
  const userIdRef = useRef(userId);
32
  userIdRef.current = userId;
 
 
33
 
34
  // Guard: only allow sendAutomaticallyWhen AFTER the user has sent at
35
  // least one message in this session. Prevents auto-send on mount when
@@ -38,13 +100,31 @@ export function useEmbedChat({ embedStore, src, modelRef, userId }: UseEmbedChat
38
 
39
  const getEmbedContext = useCallback(() => {
40
  if (!embedStore || !srcRef.current) return {};
41
- const embedHtml = embedStore.get(srcRef.current);
42
- const isBanner = srcRef.current === "banner.html";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  return {
44
  embedHtml: embedHtml || undefined,
45
  isBanner,
 
 
46
  };
47
- }, [embedStore]);
48
 
49
  const executeToolCall = useCallback(
50
  (toolCall: { toolName: string; args: unknown; toolCallId: string }) => {
@@ -54,19 +134,58 @@ export function useEmbedChat({ embedStore, src, modelRef, userId }: UseEmbedChat
54
 
55
  switch (toolCall.toolName) {
56
  case "createEmbed": {
57
- const { html, title } = toolCall.args as {
58
  html: string;
59
  title?: string;
60
  source?: string;
 
61
  };
62
  if (!html?.trim()) return "ERROR: html cannot be empty";
63
  const stripped = html.trim();
64
  if (stripped.length > MAX_EMBED_SIZE) {
65
  return `ERROR: chart too large (${stripped.length} bytes, max ${MAX_EMBED_SIZE}). Simplify the code.`;
66
  }
67
- embedStore.set(currentSrc, stripped);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  const lines = stripped.split("\n").length;
69
- return `chart created (${lines} lines, ${stripped.length} chars)${title ? ` - "${title}"` : ""}`;
 
70
  }
71
 
72
  case "patchEmbed": {
@@ -101,11 +220,40 @@ export function useEmbedChat({ embedStore, src, modelRef, userId }: UseEmbedChat
101
  return current;
102
  }
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  default:
105
  return `Unknown tool: ${toolCall.toolName}`;
106
  }
107
  },
108
- [embedStore],
109
  );
110
 
111
  const { addToolOutput, setMessages: chatSetMessages, ...chat } = useChat({
 
6
  import { useCallback, useEffect, useRef, useState } from "react";
7
  import type { UIMessage } from "ai";
8
  import type { EmbedStore } from "../editor/embeds/embed-store";
9
+ import type { EmbedDataStore } from "../editor/embeds/embed-data-store";
10
  import { loadMessages, saveMessages } from "../utils/chat-persistence";
11
 
12
  interface UseEmbedChatOptions {
13
  embedStore: EmbedStore | null;
14
+ dataStore?: EmbedDataStore | null;
15
  /** Current embed src key (filename) */
16
  src: string;
17
  modelRef: React.RefObject<string>;
18
  userId: string;
19
+ /**
20
+ * Called when the agent picks a descriptive filename via createEmbed.
21
+ * Parent is expected to update the htmlEmbed node's src attribute in
22
+ * the doc and lift the new src into its own state. The hook already
23
+ * moves the content in embedStore and migrates persisted messages.
24
+ */
25
+ onRename?: (oldSrc: string, newSrc: string) => void;
26
+ }
27
+
28
+ /**
29
+ * Slugify an arbitrary filename hint into a safe kebab-case slug.
30
+ * Keeps ASCII letters/digits/dashes, collapses whitespace, lowercases.
31
+ */
32
+ function slugify(raw: string): string {
33
+ const base = raw
34
+ .toLowerCase()
35
+ .replace(/\.html?$/i, "")
36
+ .normalize("NFKD")
37
+ .replace(/[\u0300-\u036f]/g, "")
38
+ .replace(/[^a-z0-9]+/g, "-")
39
+ .replace(/^-+|-+$/g, "")
40
+ .slice(0, 60);
41
+ return base || "chart";
42
+ }
43
+
44
+ /**
45
+ * Given a slug, find a unique .html filename in `embedStore`. If the
46
+ * desired name is free, return it; otherwise append `-2`, `-3`, ...
47
+ */
48
+ function ensureUniqueFilename(
49
+ slug: string,
50
+ embedStore: EmbedStore,
51
+ currentSrc: string,
52
+ ): string {
53
+ const base = `${slug}.html`;
54
+ if (base === currentSrc) return base;
55
+ if (!embedStore.has(base)) return base;
56
+ let i = 2;
57
+ while (embedStore.has(`${slug}-${i}.html`)) i++;
58
+ return `${slug}-${i}.html`;
59
  }
60
 
61
  const MAX_EMBED_SIZE = 512_000;
62
+ /**
63
+ * Hard cap on the number of characters we inline when the agent reads
64
+ * a data file. Above this, the tool returns a truncated preview with
65
+ * a warning so the model doesn't blow past its context window on a
66
+ * single tool call.
67
+ */
68
+ const MAX_DATA_INLINE = 120_000;
69
 
70
  const transport = new DefaultChatTransport({ api: "/api/embed-chat" });
71
 
 
73
  return `embed:${src}`;
74
  }
75
 
76
+ export function useEmbedChat({ embedStore, dataStore, src, modelRef, userId, onRename }: UseEmbedChatOptions) {
77
  const [input, setInput] = useState("");
78
+ // srcRef is the source of truth for the active embed filename. It
79
+ // starts from the `src` prop but may change mid-session when the
80
+ // agent renames the chart via createEmbed({ filename }). We do NOT
81
+ // overwrite it from the prop on every render to avoid clobbering a
82
+ // freshly-chosen name before the parent has caught up.
83
  const srcRef = useRef(src);
84
+ // Sync srcRef whenever the incoming `src` prop changes from outside.
85
+ // After an internal rename, we already set srcRef to the new name and
86
+ // asked the parent to lift it; when the parent's state arrives here
87
+ // the values match and this is a no-op.
88
+ if (src !== srcRef.current) {
89
+ srcRef.current = src;
90
+ }
91
  const userIdRef = useRef(userId);
92
  userIdRef.current = userId;
93
+ const onRenameRef = useRef(onRename);
94
+ onRenameRef.current = onRename;
95
 
96
  // Guard: only allow sendAutomaticallyWhen AFTER the user has sent at
97
  // least one message in this session. Prevents auto-send on mount when
 
100
 
101
  const getEmbedContext = useCallback(() => {
102
  if (!embedStore || !srcRef.current) return {};
103
+ const currentSrc = srcRef.current;
104
+ const embedHtml = embedStore.get(currentSrc);
105
+ const isBanner = currentSrc === "banner.html";
106
+ // A "scratch" file is a newly-created placeholder whose name is a
107
+ // hash-based auto-generated slug (see SlashMenu). When the chart is
108
+ // still empty under such a name, we explicitly ask the agent to
109
+ // pick a descriptive filename on the first createEmbed call.
110
+ const isScratch =
111
+ !isBanner && !embedHtml && /^d3-chart-[a-z0-9]+\.html$/i.test(currentSrc);
112
+ const dataFiles = dataStore
113
+ ? dataStore.list().map((m) => ({
114
+ name: m.name,
115
+ ext: m.ext,
116
+ size: m.size,
117
+ rowCount: m.rowCount,
118
+ columns: m.columns,
119
+ }))
120
+ : [];
121
  return {
122
  embedHtml: embedHtml || undefined,
123
  isBanner,
124
+ isScratch,
125
+ dataFiles,
126
  };
127
+ }, [embedStore, dataStore]);
128
 
129
  const executeToolCall = useCallback(
130
  (toolCall: { toolName: string; args: unknown; toolCallId: string }) => {
 
134
 
135
  switch (toolCall.toolName) {
136
  case "createEmbed": {
137
+ const { html, title, filename } = toolCall.args as {
138
  html: string;
139
  title?: string;
140
  source?: string;
141
+ filename?: string;
142
  };
143
  if (!html?.trim()) return "ERROR: html cannot be empty";
144
  const stripped = html.trim();
145
  if (stripped.length > MAX_EMBED_SIZE) {
146
  return `ERROR: chart too large (${stripped.length} bytes, max ${MAX_EMBED_SIZE}). Simplify the code.`;
147
  }
148
+
149
+ // Resolve the target src: if the agent proposed a filename
150
+ // slug, slugify + ensure uniqueness. Never rename the banner
151
+ // (its name is load-bearing for the article layout).
152
+ let targetSrc = currentSrc;
153
+ const isBanner = currentSrc === "banner.html";
154
+ if (filename && !isBanner) {
155
+ const slug = slugify(filename);
156
+ const candidate = ensureUniqueFilename(slug, embedStore, currentSrc);
157
+ if (candidate !== currentSrc) {
158
+ targetSrc = candidate;
159
+ }
160
+ }
161
+
162
+ if (targetSrc !== currentSrc) {
163
+ // Write html under the new key and remove the old one atomically.
164
+ embedStore.set(targetSrc, stripped);
165
+ embedStore.remove(currentSrc);
166
+ // Migrate persisted chat messages so the localStorage scope
167
+ // tracks the new file name.
168
+ try {
169
+ const uid = userIdRef.current;
170
+ const existing = loadMessages(uid, scopeKey(currentSrc));
171
+ if (existing && existing.length > 0) {
172
+ saveMessages(uid, scopeKey(targetSrc), existing);
173
+ }
174
+ saveMessages(uid, scopeKey(currentSrc), []);
175
+ } catch {
176
+ // Non-fatal: persistence is best-effort.
177
+ }
178
+ srcRef.current = targetSrc;
179
+ // Let the parent update the htmlEmbed node's src attribute
180
+ // in the ProseMirror doc and lift the new src into state.
181
+ onRenameRef.current?.(currentSrc, targetSrc);
182
+ } else {
183
+ embedStore.set(currentSrc, stripped);
184
+ }
185
+
186
  const lines = stripped.split("\n").length;
187
+ const renamed = targetSrc !== currentSrc ? ` [renamed to ${targetSrc}]` : "";
188
+ return `chart created (${lines} lines, ${stripped.length} chars)${title ? ` - "${title}"` : ""}${renamed}`;
189
  }
190
 
191
  case "patchEmbed": {
 
220
  return current;
221
  }
222
 
223
+ case "listDataFiles": {
224
+ if (!dataStore) return "(no data files - data store unavailable)";
225
+ const files = dataStore.list();
226
+ if (files.length === 0) return "(no data files attached)";
227
+ return files
228
+ .map((f) => {
229
+ const cols = f.columns && f.columns.length > 0
230
+ ? ` columns=[${f.columns.join(", ")}]`
231
+ : "";
232
+ const rows = f.rowCount !== undefined ? ` rows=${f.rowCount}` : "";
233
+ return `- ${f.name} (${f.ext}, ${f.size} bytes)${rows}${cols}`;
234
+ })
235
+ .join("\n");
236
+ }
237
+
238
+ case "readDataFile": {
239
+ if (!dataStore) return "ERROR: data store unavailable";
240
+ const { name } = toolCall.args as { name: string };
241
+ if (!name) return "ERROR: missing file name";
242
+ const file = dataStore.get(name);
243
+ if (!file) return `ERROR: data file "${name}" not found`;
244
+ const content = file.content;
245
+ if (content.length <= MAX_DATA_INLINE) return content;
246
+ return (
247
+ content.slice(0, MAX_DATA_INLINE) +
248
+ `\n\n[TRUNCATED - full file is ${content.length} chars, showing first ${MAX_DATA_INLINE}]`
249
+ );
250
+ }
251
+
252
  default:
253
  return `Unknown tool: ${toolCall.toolName}`;
254
  }
255
  },
256
+ [embedStore, dataStore],
257
  );
258
 
259
  const { addToolOutput, setMessages: chatSetMessages, ...chat } = useChat({
frontend/src/hooks/useEmbedData.ts ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import type {
3
+ EmbedDataFile,
4
+ EmbedDataFileMeta,
5
+ EmbedDataStore,
6
+ } from "../editor/embeds/embed-data-store";
7
+ import {
8
+ MAX_DATA_FILE_SIZE,
9
+ extFromName,
10
+ inferDataShape,
11
+ isAcceptedExt,
12
+ } from "../utils/data-files";
13
+
14
+ interface UseEmbedDataOptions {
15
+ dataStore: EmbedDataStore | null;
16
+ userId: string;
17
+ }
18
+
19
+ export interface UploadResult {
20
+ ok: boolean;
21
+ name?: string;
22
+ error?: string;
23
+ }
24
+
25
+ export function useEmbedData({ dataStore, userId }: UseEmbedDataOptions) {
26
+ const [files, setFiles] = useState<EmbedDataFileMeta[]>(() =>
27
+ dataStore ? dataStore.list() : [],
28
+ );
29
+
30
+ useEffect(() => {
31
+ if (!dataStore) {
32
+ setFiles([]);
33
+ return;
34
+ }
35
+ setFiles(dataStore.list());
36
+ return dataStore.observe(() => {
37
+ setFiles(dataStore.list());
38
+ });
39
+ }, [dataStore]);
40
+
41
+ const uploadFile = useCallback(
42
+ async (file: File): Promise<UploadResult> => {
43
+ if (!dataStore) return { ok: false, error: "Data store not ready" };
44
+ if (file.size > MAX_DATA_FILE_SIZE) {
45
+ return {
46
+ ok: false,
47
+ error: `File too large (max ${(MAX_DATA_FILE_SIZE / (1024 * 1024)).toFixed(0)} MB)`,
48
+ };
49
+ }
50
+ const ext = extFromName(file.name);
51
+ if (!isAcceptedExt(ext)) {
52
+ return {
53
+ ok: false,
54
+ error: `Unsupported file type ".${ext}". Use CSV, TSV, JSON, NDJSON or TXT.`,
55
+ };
56
+ }
57
+
58
+ const content = await file.text();
59
+ const shape = inferDataShape(ext, content);
60
+ const record: EmbedDataFile = {
61
+ meta: {
62
+ name: file.name,
63
+ ext,
64
+ size: new Blob([content]).size,
65
+ uploader: userId,
66
+ addedAt: Date.now(),
67
+ rowCount: shape.rowCount,
68
+ columns: shape.columns,
69
+ },
70
+ content,
71
+ };
72
+ dataStore.set(record);
73
+ return { ok: true, name: file.name };
74
+ },
75
+ [dataStore, userId],
76
+ );
77
+
78
+ const uploadFiles = useCallback(
79
+ async (fileList: FileList | File[]): Promise<UploadResult[]> => {
80
+ const arr = Array.from(fileList);
81
+ const results: UploadResult[] = [];
82
+ for (const f of arr) {
83
+ results.push(await uploadFile(f));
84
+ }
85
+ return results;
86
+ },
87
+ [uploadFile],
88
+ );
89
+
90
+ const removeFile = useCallback(
91
+ (name: string) => {
92
+ dataStore?.remove(name);
93
+ },
94
+ [dataStore],
95
+ );
96
+
97
+ const getFile = useCallback(
98
+ (name: string) => dataStore?.get(name),
99
+ [dataStore],
100
+ );
101
+
102
+ return {
103
+ files,
104
+ uploadFile,
105
+ uploadFiles,
106
+ removeFile,
107
+ getFile,
108
+ };
109
+ }
frontend/src/styles/components/_embed-studio.css CHANGED
@@ -174,10 +174,31 @@
174
  border-radius: 6px;
175
  color: var(--muted-color);
176
  margin-top: 4px;
 
177
  }
178
 
179
  .es-message__tool-name {
180
  font-weight: 600;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  }
182
 
183
  .es-message__tool-status--running {
@@ -303,6 +324,20 @@
303
  flex: 1;
304
  position: relative;
305
  overflow: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  }
307
 
308
  .es-hidden {
@@ -338,3 +373,356 @@
338
  word-break: break-all;
339
  color: var(--text-color);
340
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  border-radius: 6px;
175
  color: var(--muted-color);
176
  margin-top: 4px;
177
+ max-width: 100%;
178
  }
179
 
180
  .es-message__tool-name {
181
  font-weight: 600;
182
+ white-space: nowrap;
183
+ }
184
+
185
+ .es-message__tool-subtitle {
186
+ font-family: var(--font-mono);
187
+ font-size: 10.5px;
188
+ opacity: 0.75;
189
+ overflow: hidden;
190
+ text-overflow: ellipsis;
191
+ white-space: nowrap;
192
+ max-width: 180px;
193
+ }
194
+
195
+ .es-message__tool--running {
196
+ color: var(--primary-color);
197
+ background: color-mix(in srgb, var(--primary-color) 12%, var(--code-bg));
198
+ }
199
+
200
+ .es-message__tool--done {
201
+ color: var(--success-color, var(--muted-color));
202
  }
203
 
204
  .es-message__tool-status--running {
 
324
  flex: 1;
325
  position: relative;
326
  overflow: hidden;
327
+ /* The iframe body is set to `background: transparent` by build-doc,
328
+ so whatever sits behind the iframe is what actually shows. Pin the
329
+ container to --page-bg so the preview tracks the theme instead of
330
+ falling through to the browser's default white. */
331
+ background: var(--page-bg);
332
+ }
333
+
334
+ .es-preview__frame-container iframe {
335
+ /* Some browsers paint a default white canvas on `srcdoc` iframes
336
+ before the document's own <style> applies. Forcing the element
337
+ itself to transparent lets --page-bg show through, which keeps
338
+ dark mode clean while the chart is (re)loading. */
339
+ background: transparent;
340
+ color-scheme: light dark;
341
  }
342
 
343
  .es-hidden {
 
373
  word-break: break-all;
374
  color: var(--text-color);
375
  }
376
+
377
+ /* --- Files sidebar (far left) --- */
378
+
379
+ .es-files {
380
+ width: 240px;
381
+ min-width: 220px;
382
+ max-width: 260px;
383
+ display: flex;
384
+ flex-direction: column;
385
+ border-right: 1px solid var(--border-color);
386
+ background: var(--surface-bg);
387
+ overflow-y: auto;
388
+ }
389
+
390
+ .es-files__section {
391
+ display: flex;
392
+ flex-direction: column;
393
+ padding: 10px 12px 14px;
394
+ border-bottom: 1px solid var(--border-color);
395
+ }
396
+
397
+ .es-files__section:last-child {
398
+ border-bottom: none;
399
+ }
400
+
401
+ .es-files__section-title {
402
+ display: flex;
403
+ align-items: center;
404
+ gap: 6px;
405
+ text-transform: uppercase;
406
+ font-size: 10.5px;
407
+ font-weight: 700;
408
+ letter-spacing: 0.06em;
409
+ color: var(--muted-color);
410
+ margin-bottom: 8px;
411
+ }
412
+
413
+ .es-files__count {
414
+ margin-left: auto;
415
+ font-size: 10.5px;
416
+ font-weight: 600;
417
+ color: var(--muted-color);
418
+ background: var(--code-bg);
419
+ padding: 1px 6px;
420
+ border-radius: 10px;
421
+ }
422
+
423
+ .es-files__list {
424
+ list-style: none;
425
+ margin: 0 0 8px;
426
+ padding: 0;
427
+ display: flex;
428
+ flex-direction: column;
429
+ gap: 2px;
430
+ }
431
+
432
+ .es-files__empty {
433
+ font-size: 11.5px;
434
+ color: var(--muted-color);
435
+ padding: 4px 0 6px;
436
+ font-style: italic;
437
+ }
438
+
439
+ .es-files__item {
440
+ display: flex;
441
+ align-items: center;
442
+ gap: 6px;
443
+ padding: 5px 8px;
444
+ border-radius: 6px;
445
+ font-size: 12px;
446
+ color: var(--text-color);
447
+ min-width: 0;
448
+ }
449
+
450
+ .es-files__item--current {
451
+ background: color-mix(in srgb, var(--primary-color) 14%, transparent);
452
+ color: var(--text-color);
453
+ }
454
+
455
+ .es-files__item--data {
456
+ padding: 0;
457
+ overflow: hidden;
458
+ }
459
+
460
+ .es-files__item-main {
461
+ flex: 1;
462
+ display: flex;
463
+ align-items: center;
464
+ gap: 6px;
465
+ background: transparent;
466
+ border: none;
467
+ padding: 5px 8px;
468
+ font-size: 12px;
469
+ color: inherit;
470
+ cursor: pointer;
471
+ text-align: left;
472
+ min-width: 0;
473
+ border-radius: 6px;
474
+ }
475
+
476
+ .es-files__item-main:hover {
477
+ background: var(--code-bg);
478
+ }
479
+
480
+ .es-files__item--current .es-files__item-main:hover {
481
+ background: color-mix(in srgb, var(--primary-color) 20%, transparent);
482
+ }
483
+
484
+ .es-files__name {
485
+ flex: 1;
486
+ overflow: hidden;
487
+ text-overflow: ellipsis;
488
+ white-space: nowrap;
489
+ min-width: 0;
490
+ }
491
+
492
+ .es-files__size {
493
+ font-size: 10.5px;
494
+ color: var(--muted-color);
495
+ font-variant-numeric: tabular-nums;
496
+ }
497
+
498
+ .es-files__badge {
499
+ font-size: 9.5px;
500
+ text-transform: uppercase;
501
+ letter-spacing: 0.06em;
502
+ font-weight: 700;
503
+ color: var(--primary-color);
504
+ background: var(--page-bg);
505
+ padding: 1px 5px;
506
+ border-radius: 10px;
507
+ }
508
+
509
+ .es-files__item-remove {
510
+ background: transparent;
511
+ border: none;
512
+ color: var(--muted-color);
513
+ cursor: pointer;
514
+ padding: 4px 8px;
515
+ display: flex;
516
+ align-items: center;
517
+ opacity: 0;
518
+ transition: opacity 120ms ease, color 120ms ease;
519
+ }
520
+
521
+ .es-files__item--data:hover .es-files__item-remove {
522
+ opacity: 1;
523
+ }
524
+
525
+ .es-files__item-remove:hover {
526
+ color: var(--danger-color, #e44);
527
+ }
528
+
529
+ .es-files__error {
530
+ display: flex;
531
+ align-items: center;
532
+ gap: 6px;
533
+ margin-top: 6px;
534
+ padding: 6px 8px;
535
+ font-size: 11.5px;
536
+ color: var(--danger-color, #e44);
537
+ background: color-mix(in srgb, var(--danger-color, #e44) 10%, transparent);
538
+ border-radius: 6px;
539
+ }
540
+
541
+ /* --- Upload zone --- */
542
+
543
+ .es-upload-zone {
544
+ display: flex;
545
+ align-items: center;
546
+ justify-content: center;
547
+ gap: 8px;
548
+ padding: 14px 10px;
549
+ margin-top: 4px;
550
+ border: 1.5px dashed var(--border-color);
551
+ border-radius: 8px;
552
+ color: var(--muted-color);
553
+ font-size: 12px;
554
+ cursor: pointer;
555
+ transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
556
+ text-align: center;
557
+ flex-direction: column;
558
+ }
559
+
560
+ .es-upload-zone:hover,
561
+ .es-upload-zone:focus-visible {
562
+ border-color: var(--primary-color);
563
+ color: var(--text-color);
564
+ background: color-mix(in srgb, var(--primary-color) 6%, transparent);
565
+ outline: none;
566
+ }
567
+
568
+ .es-upload-zone--active {
569
+ border-color: var(--primary-color);
570
+ background: color-mix(in srgb, var(--primary-color) 12%, transparent);
571
+ color: var(--text-color);
572
+ }
573
+
574
+ .es-upload-zone--compact {
575
+ flex-direction: row;
576
+ padding: 8px 10px;
577
+ font-size: 11.5px;
578
+ }
579
+
580
+ .es-upload-zone__text {
581
+ display: flex;
582
+ flex-direction: column;
583
+ gap: 2px;
584
+ align-items: center;
585
+ }
586
+
587
+ .es-upload-zone__text strong {
588
+ color: var(--text-color);
589
+ font-size: 12.5px;
590
+ font-weight: 600;
591
+ }
592
+
593
+ .es-upload-zone__text span {
594
+ font-size: 10.5px;
595
+ color: var(--muted-color);
596
+ }
597
+
598
+ /* --- Data file viewer --- */
599
+
600
+ .es-data-viewer {
601
+ position: absolute;
602
+ inset: 0;
603
+ display: flex;
604
+ flex-direction: column;
605
+ background: var(--page-bg);
606
+ z-index: 3;
607
+ }
608
+
609
+ .es-preview {
610
+ position: relative;
611
+ }
612
+
613
+ .es-data-viewer__header {
614
+ display: flex;
615
+ align-items: center;
616
+ gap: 12px;
617
+ padding: 8px 12px;
618
+ border-bottom: 1px solid var(--border-color);
619
+ background: var(--surface-bg);
620
+ flex-shrink: 0;
621
+ }
622
+
623
+ .es-data-viewer__title {
624
+ display: flex;
625
+ flex-direction: column;
626
+ gap: 2px;
627
+ min-width: 0;
628
+ }
629
+
630
+ .es-data-viewer__title strong {
631
+ font-size: 13px;
632
+ font-weight: 600;
633
+ overflow: hidden;
634
+ text-overflow: ellipsis;
635
+ white-space: nowrap;
636
+ }
637
+
638
+ .es-data-viewer__meta {
639
+ font-size: 11px;
640
+ color: var(--muted-color);
641
+ font-variant-numeric: tabular-nums;
642
+ }
643
+
644
+ .es-data-viewer__actions {
645
+ margin-left: auto;
646
+ display: flex;
647
+ align-items: center;
648
+ gap: 8px;
649
+ }
650
+
651
+ .es-data-viewer__tabs {
652
+ display: flex;
653
+ gap: 2px;
654
+ }
655
+
656
+ .es-data-viewer__body {
657
+ flex: 1;
658
+ overflow: auto;
659
+ padding: 12px;
660
+ min-height: 0;
661
+ }
662
+
663
+ .es-data-viewer__raw {
664
+ margin: 0;
665
+ padding: 12px 14px;
666
+ background: var(--code-bg);
667
+ border-radius: 6px;
668
+ font-family: var(--font-mono);
669
+ font-size: 12px;
670
+ line-height: 1.6;
671
+ color: var(--text-color);
672
+ overflow-x: auto;
673
+ white-space: pre;
674
+ }
675
+
676
+ .es-data-table-wrap {
677
+ border: 1px solid var(--border-color);
678
+ border-radius: 6px;
679
+ overflow: auto;
680
+ }
681
+
682
+ .es-data-table {
683
+ width: 100%;
684
+ border-collapse: collapse;
685
+ font-family: var(--font-mono);
686
+ font-size: 11.5px;
687
+ }
688
+
689
+ .es-data-table th,
690
+ .es-data-table td {
691
+ padding: 6px 10px;
692
+ border-right: 1px solid var(--border-color);
693
+ border-bottom: 1px solid var(--border-color);
694
+ text-align: left;
695
+ vertical-align: top;
696
+ max-width: 280px;
697
+ overflow: hidden;
698
+ text-overflow: ellipsis;
699
+ white-space: nowrap;
700
+ }
701
+
702
+ .es-data-table th:last-child,
703
+ .es-data-table td:last-child {
704
+ border-right: none;
705
+ }
706
+
707
+ .es-data-table thead th {
708
+ position: sticky;
709
+ top: 0;
710
+ background: var(--surface-bg);
711
+ font-weight: 600;
712
+ color: var(--text-color);
713
+ z-index: 1;
714
+ }
715
+
716
+ .es-data-table tbody tr:nth-child(even) {
717
+ background: color-mix(in srgb, var(--border-color) 20%, transparent);
718
+ }
719
+
720
+ .es-data-table__footer {
721
+ padding: 8px 12px;
722
+ font-size: 11px;
723
+ color: var(--muted-color);
724
+ border-top: 1px solid var(--border-color);
725
+ background: var(--surface-bg);
726
+ font-style: italic;
727
+ text-align: center;
728
+ }
frontend/src/styles/editor/_block-tools.css CHANGED
@@ -44,16 +44,35 @@
44
  border: 1px solid var(--border-color);
45
  border-radius: 10px;
46
  padding: 0.35rem;
47
- min-width: 220px;
 
 
 
48
  box-shadow: var(--shadow-lg);
49
- overflow: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
51
 
52
  .slash-menu-item {
53
  display: flex;
54
  align-items: center;
55
  gap: 0.75rem;
56
- padding: 0.5rem 0.65rem;
57
  border-radius: 6px;
58
  cursor: pointer;
59
  transition: background 0.1s;
 
44
  border: 1px solid var(--border-color);
45
  border-radius: 10px;
46
  padding: 0.35rem;
47
+ min-width: 280px;
48
+ max-width: 340px;
49
+ max-height: 360px;
50
+ overflow-y: auto;
51
  box-shadow: var(--shadow-lg);
52
+ }
53
+
54
+ .slash-menu-group + .slash-menu-group {
55
+ margin-top: 0.25rem;
56
+ padding-top: 0.35rem;
57
+ border-top: 1px solid var(--border-color);
58
+ }
59
+
60
+ .slash-menu-section {
61
+ font-size: 0.65rem;
62
+ font-weight: 600;
63
+ letter-spacing: 0.06em;
64
+ text-transform: uppercase;
65
+ color: var(--muted-color);
66
+ padding: 0.35rem 0.65rem 0.25rem;
67
+ user-select: none;
68
+ pointer-events: none;
69
  }
70
 
71
  .slash-menu-item {
72
  display: flex;
73
  align-items: center;
74
  gap: 0.75rem;
75
+ padding: 0.45rem 0.6rem;
76
  border-radius: 6px;
77
  cursor: pointer;
78
  transition: background 0.1s;
frontend/src/utils/ai-tool-parts.ts CHANGED
@@ -13,6 +13,8 @@ export interface NormalizedToolPart {
13
  toolName: string;
14
  /** "result" when the call has produced output, otherwise the in-flight state. */
15
  state: string;
 
 
16
  result?: unknown;
17
  }
18
 
@@ -34,12 +36,13 @@ export function normalizeToolPart(part: AnyPart): NormalizedToolPart | null {
34
 
35
  if (type === "tool-invocation") {
36
  const inv = anyPart.toolInvocation as
37
- | { toolName?: string; state?: string; result?: unknown }
38
  | undefined;
39
  if (!inv?.toolName) return null;
40
  return {
41
  toolName: inv.toolName,
42
  state: inv.state ?? "call",
 
43
  result: inv.result,
44
  };
45
  }
@@ -52,6 +55,7 @@ export function normalizeToolPart(part: AnyPart): NormalizedToolPart | null {
52
  return {
53
  toolName,
54
  state: normalized,
 
55
  result: anyPart.output,
56
  };
57
  }
 
13
  toolName: string;
14
  /** "result" when the call has produced output, otherwise the in-flight state. */
15
  state: string;
16
+ /** Arguments passed to the tool (v5: part.toolInvocation.args, v6: part.input). */
17
+ input?: unknown;
18
  result?: unknown;
19
  }
20
 
 
36
 
37
  if (type === "tool-invocation") {
38
  const inv = anyPart.toolInvocation as
39
+ | { toolName?: string; state?: string; args?: unknown; result?: unknown }
40
  | undefined;
41
  if (!inv?.toolName) return null;
42
  return {
43
  toolName: inv.toolName,
44
  state: inv.state ?? "call",
45
+ input: inv.args,
46
  result: inv.result,
47
  };
48
  }
 
55
  return {
56
  toolName,
57
  state: normalized,
58
+ input: anyPart.input,
59
  result: anyPart.output,
60
  };
61
  }
frontend/src/utils/data-files.ts ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { EmbedDataFileMeta } from "../editor/embeds/embed-data-store";
2
+
3
+ /**
4
+ * Light-weight client-side parsing of uploaded data files. Extracts
5
+ * column names and row counts so the sidebar and agent system prompt
6
+ * can preview dataset shape without shipping the full content every
7
+ * time.
8
+ *
9
+ * Parsing is deliberately forgiving: it never throws on malformed
10
+ * input, and returns `undefined` metadata fields when shape cannot be
11
+ * inferred.
12
+ */
13
+
14
+ export const ACCEPTED_DATA_EXTS = ["csv", "tsv", "json", "txt", "ndjson"] as const;
15
+ export type AcceptedDataExt = (typeof ACCEPTED_DATA_EXTS)[number];
16
+
17
+ export const MAX_DATA_FILE_SIZE = 3 * 1024 * 1024;
18
+
19
+ export function extFromName(name: string): string {
20
+ const match = name.toLowerCase().match(/\.([a-z0-9]+)$/);
21
+ return match ? match[1] : "";
22
+ }
23
+
24
+ export function isAcceptedExt(ext: string): ext is AcceptedDataExt {
25
+ return (ACCEPTED_DATA_EXTS as readonly string[]).includes(ext);
26
+ }
27
+
28
+ function splitCsvLine(line: string, delim: string): string[] {
29
+ const out: string[] = [];
30
+ let cur = "";
31
+ let inQuotes = false;
32
+ for (let i = 0; i < line.length; i++) {
33
+ const c = line[i];
34
+ if (inQuotes) {
35
+ if (c === '"') {
36
+ if (line[i + 1] === '"') {
37
+ cur += '"';
38
+ i++;
39
+ } else {
40
+ inQuotes = false;
41
+ }
42
+ } else {
43
+ cur += c;
44
+ }
45
+ } else if (c === '"') {
46
+ inQuotes = true;
47
+ } else if (c === delim) {
48
+ out.push(cur);
49
+ cur = "";
50
+ } else {
51
+ cur += c;
52
+ }
53
+ }
54
+ out.push(cur);
55
+ return out;
56
+ }
57
+
58
+ interface ParsedShape {
59
+ rowCount?: number;
60
+ columns?: string[];
61
+ }
62
+
63
+ function parseDelimited(content: string, delim: string): ParsedShape {
64
+ const lines = content
65
+ .split(/\r\n|\n|\r/)
66
+ .filter((l) => l.length > 0);
67
+ if (lines.length === 0) return {};
68
+ const header = splitCsvLine(lines[0], delim).map((c) => c.trim());
69
+ return {
70
+ columns: header,
71
+ rowCount: Math.max(0, lines.length - 1),
72
+ };
73
+ }
74
+
75
+ function parseJson(content: string): ParsedShape {
76
+ try {
77
+ const parsed = JSON.parse(content);
78
+ if (Array.isArray(parsed)) {
79
+ const first = parsed.find((r) => r && typeof r === "object");
80
+ return {
81
+ rowCount: parsed.length,
82
+ columns: first ? Object.keys(first as Record<string, unknown>) : undefined,
83
+ };
84
+ }
85
+ if (parsed && typeof parsed === "object") {
86
+ return { columns: Object.keys(parsed as Record<string, unknown>) };
87
+ }
88
+ } catch {
89
+ // swallow - return empty shape
90
+ }
91
+ return {};
92
+ }
93
+
94
+ function parseNdjson(content: string): ParsedShape {
95
+ const lines = content.split(/\r\n|\n|\r/).filter((l) => l.trim().length > 0);
96
+ if (lines.length === 0) return {};
97
+ let columns: string[] | undefined;
98
+ try {
99
+ const first = JSON.parse(lines[0]);
100
+ if (first && typeof first === "object" && !Array.isArray(first)) {
101
+ columns = Object.keys(first as Record<string, unknown>);
102
+ }
103
+ } catch {
104
+ // ignore
105
+ }
106
+ return { rowCount: lines.length, columns };
107
+ }
108
+
109
+ export function inferDataShape(ext: string, content: string): ParsedShape {
110
+ switch (ext) {
111
+ case "csv":
112
+ return parseDelimited(content, ",");
113
+ case "tsv":
114
+ return parseDelimited(content, "\t");
115
+ case "json":
116
+ return parseJson(content);
117
+ case "ndjson":
118
+ return parseNdjson(content);
119
+ default:
120
+ return {};
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Build a lightweight manifest line per file suitable for inclusion in
126
+ * the agent system prompt. Keeps it to one line per file so many
127
+ * datasets can coexist without exploding prompt size.
128
+ */
129
+ export function formatManifestLine(meta: EmbedDataFileMeta): string {
130
+ const size = formatBytes(meta.size);
131
+ const shape =
132
+ meta.rowCount !== undefined
133
+ ? ` - ${meta.rowCount} rows`
134
+ : "";
135
+ const cols = meta.columns && meta.columns.length > 0
136
+ ? ` - columns: ${meta.columns.slice(0, 12).join(", ")}${meta.columns.length > 12 ? ", ..." : ""}`
137
+ : "";
138
+ return `- ${meta.name} (${meta.ext.toUpperCase()}, ${size}${shape})${cols}`;
139
+ }
140
+
141
+ export function formatBytes(bytes: number): string {
142
+ if (bytes < 1024) return `${bytes} B`;
143
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
144
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
145
+ }