@webmcp-auto-ui/ui 2.5.36 → 2.5.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,9 +8,9 @@
8
8
  import {
9
9
  createState, injectStyles, mountRunControls, mountHistoryPanel,
10
10
  setupDnD, deleteCellWithConfirm, restoreCellFromSnapshot, addCell,
11
- addImportedCells, registerExecutor, collectDataServers,
11
+ addImportedCells, registerExecutor, collectDataServers, collectWebmcpServers,
12
12
  autosize, openShareModal, registerHistoryObserver,
13
- renderCellLogs,
13
+ renderCellLogs, uid, defaultCellContent,
14
14
  createPublishControls, autoConnectFrontmatterServers,
15
15
  createRuntimeOverlay, effectiveResult, cellRuntimeStatus,
16
16
  lastRefreshedAt, bootstrapLiveRefresh, fmtRelTime, preserveScrollAround,
@@ -25,6 +25,42 @@ import { extractCellsFromRecipe, extractCellFromMarkdown } from './resource-extr
25
25
  import { mountLeftPane } from './left-pane.js';
26
26
  import { highlightCode } from '../../primitives/markdown-renderer.js';
27
27
  import { createSqlExecutor } from './executors/sql.js';
28
+ import { runCode } from '@webmcp-auto-ui/sdk';
29
+ import { canvas } from '@webmcp-auto-ui/sdk/canvas';
30
+ import { mountWidget } from '@webmcp-auto-ui/core';
31
+
32
+ // Side-effect imports — register native widget custom elements so that
33
+ // `mountWidget(host, name, params, servers)` can resolve `<auto-${name}>`
34
+ // even when WidgetRenderer.svelte is not mounted on the page (e.g. notebook-
35
+ // viewer, which mounts the notebook widget via vanilla renderNotebook but
36
+ // never imports WidgetRenderer). Without these imports `customElements.get`
37
+ // returns undefined and every `await widget(...)` result falls through to the
38
+ // silent `container.textContent = '[${type}]'` branch.
39
+ import '../simple/stat.svelte';
40
+ import '../simple/kv.svelte';
41
+ import '../simple/list.svelte';
42
+ import '../simple/chart.svelte';
43
+ import '../simple/alert.svelte';
44
+ import '../simple/code.svelte';
45
+ import '../simple/text.svelte';
46
+ import '../simple/actions.svelte';
47
+ import '../simple/tags.svelte';
48
+ import '../rich/stat-card.svelte';
49
+ import '../rich/profile.svelte';
50
+ import '../rich/json-viewer.svelte';
51
+ import '../rich/chart-rich.svelte';
52
+ import '../rich/sankey.svelte';
53
+ import '../rich/hemicycle.svelte';
54
+ import '../rich/data-table.svelte';
55
+ import '../rich/timeline.svelte';
56
+ import '../rich/trombinoscope.svelte';
57
+ import '../rich/cards.svelte';
58
+ import '../rich/grid-data.svelte';
59
+ import '../rich/js-sandbox.svelte';
60
+ import '../rich/log.svelte';
61
+ import '../rich/gallery.svelte';
62
+ import '../rich/carousel.svelte';
63
+ import '../rich/map.svelte';
28
64
 
29
65
  export async function render(container: HTMLElement, data: Record<string, unknown>): Promise<() => void> {
30
66
  injectStyles();
@@ -36,7 +72,15 @@ export async function render(container: HTMLElement, data: Record<string, unknow
36
72
  mode: (data.mode as any) ?? 'edit',
37
73
  cells: data.cells as any,
38
74
  autoRun: (data as any).autoRun === true,
39
- });
75
+ publishedSlug: (data as any).publishedSlug,
76
+ publishedToken: (data as any).publishedToken,
77
+ webmcpServers: (data as any).webmcpServers,
78
+ chatApiBase: (data as any).chatApiBase,
79
+ hidePublishBadge: (data as any).hidePublishBadge === true,
80
+ } as any);
81
+
82
+ // Default src visibility per mode (toggle remains usable for per-cell override).
83
+ for (const c of state.cells) c.hideSource = state.mode === 'view';
40
84
 
41
85
  // Live mode runtime overlay (created lazily). Never mutates state.
42
86
  let overlay: RuntimeOverlay | null = null;
@@ -54,10 +98,9 @@ export async function render(container: HTMLElement, data: Record<string, unknow
54
98
  <div class="nbe-leftpane-slot"></div>
55
99
  <div class="nbe-shell">
56
100
  <div class="nbe-kicker">
57
- <span class="nbe-live-toggle-slot"></span>
58
101
  <div class="nb-mode-switch" style="margin-left:auto;">
59
- <button class="nb-mode-edit nb-on">edit</button>
60
- <button class="nb-mode-view">view</button>
102
+ <button class="nb-mode-edit ${state.mode === 'edit' ? 'nb-on' : ''}">edit</button>
103
+ <button class="nb-mode-view ${state.mode === 'view' ? 'nb-on' : ''}">view</button>
61
104
  </div>
62
105
  <button class="nb-btn nbe-history-btn">⟲ history</button>
63
106
  <span class="nbe-publish-badge-slot"></span>
@@ -70,11 +113,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
70
113
  <div class="nb-history-panel nbe-history-panel"></div>
71
114
  <div class="nbe-cells"></div>
72
115
  <div class="nbe-footer">
73
- <button class="nb-btn nb-add-cell" data-add="md">+ prose</button>
74
- <button class="nb-btn nb-add-cell" data-add="sql">+ sql</button>
75
- <button class="nb-btn nb-add-cell" data-add="js">+ chart</button>
76
- <button class="nb-btn nb-add-cell" data-add-modal="md">+ md</button>
77
- <button class="nb-btn nb-add-cell" data-add-modal="recipe">+ recipe</button>
78
116
  <span class="nbe-share-btn" title="Share">share</span>
79
117
  <span class="nbe-publish-slot"></span>
80
118
  </div>
@@ -104,24 +142,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
104
142
  });
105
143
  }
106
144
 
107
- const hideLiveToggle = (data as any).hideLiveToggle === true;
108
-
109
- function renderLiveToggle() {
110
- const slot = shell.querySelector('.nbe-live-toggle-slot') as HTMLElement;
111
- if (hideLiveToggle) { slot.innerHTML = ''; return; }
112
- if (state.mode === 'edit') {
113
- const checked = state.autoRun === true ? 'checked' : '';
114
- slot.innerHTML = `<label class="nbe-live-toggle" title="Re-execute SQL cells against connected servers when this notebook is opened in view mode."><input type="checkbox" ${checked} />Live data</label>`;
115
- const cb = slot.querySelector('input[type=checkbox]') as HTMLInputElement;
116
- cb.addEventListener('change', () => {
117
- state.autoRun = cb.checked;
118
- rerender();
119
- });
120
- } else {
121
- slot.innerHTML = '';
122
- }
123
- }
124
-
125
145
  function renderLiveBadge() {
126
146
  const slot = shell.querySelector('.nbe-live-badge-slot') as HTMLElement;
127
147
  if (state.mode === 'view' && state.autoRun === true) {
@@ -194,7 +214,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
194
214
  function rerender() {
195
215
  const restore = preserveScrollAround(cellsEl);
196
216
  mountHistoryPanel(historyPanel, state, (snap) => { restoreCellFromSnapshot(state, snap); rerender(); });
197
- renderLiveToggle();
198
217
  renderLiveBadge();
199
218
  renderEmptyState();
200
219
  renderCells();
@@ -221,9 +240,8 @@ export async function render(container: HTMLElement, data: Record<string, unknow
221
240
  rerender();
222
241
  });
223
242
  } else if (which === 'recipe') {
224
- const mcpServers = (Array.isArray((data as any)?.servers) ? (data as any).servers : [])
225
- .map((s: any) => ({ name: String(s?.name ?? ''), url: s?.url ? String(s.url) : undefined }))
226
- .filter((s: any) => s.name);
243
+ const mcpServers = collectDataServers(data)
244
+ .map((s) => ({ name: s.name, url: s.url }));
227
245
  openAddRecipeModal({
228
246
  mcpServers,
229
247
  scope: 'data',
@@ -252,7 +270,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
252
270
  });
253
271
  const publishCleanup = createPublishControls(state, {
254
272
  buttonSlot: shell.querySelector('.nbe-publish-slot') as HTMLElement,
255
- badgeSlot: shell.querySelector('.nbe-publish-badge-slot') as HTMLElement,
273
+ badgeSlot: state.hidePublishBadge ? undefined : (shell.querySelector('.nbe-publish-badge-slot') as HTMLElement),
256
274
  footerSlot: shell.querySelector('.nbe-publish-footer-slot') as HTMLElement,
257
275
  onPublished: () => rerender(),
258
276
  });
@@ -264,6 +282,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
264
282
  const viewBtn = shell.querySelector('.nb-mode-view') as HTMLElement;
265
283
  editBtn.addEventListener('click', () => {
266
284
  state.mode = 'edit';
285
+ for (const c of state.cells) c.hideSource = false;
267
286
  container.classList.remove('nb-view-mode');
268
287
  editBtn.classList.add('nb-on'); viewBtn.classList.remove('nb-on');
269
288
  // Leaving view: stop live refresh and clear overlay so frozen snapshots show.
@@ -272,14 +291,21 @@ export async function render(container: HTMLElement, data: Record<string, unknow
272
291
  });
273
292
  viewBtn.addEventListener('click', () => {
274
293
  state.mode = 'view';
294
+ for (const c of state.cells) c.hideSource = true;
275
295
  container.classList.add('nb-view-mode');
276
296
  viewBtn.classList.add('nb-on'); editBtn.classList.remove('nb-on');
277
297
  if (state.autoRun === true) bootstrapLive();
278
298
  rerender();
279
299
  });
280
300
 
281
- // Left pane (collapsed by default)
282
- const pane = mountLeftPane(leftPaneHost, state, collectDataServers(data), {
301
+
302
+ // Left pane (collapsed by default). Lists remote data servers AND bundled
303
+ // WebMCP servers (autoui, d3, …) in two distinct groups.
304
+ const collectAll = () => [
305
+ ...collectDataServers(data),
306
+ ...collectWebmcpServers(state.webmcpServers),
307
+ ];
308
+ const pane = mountLeftPane(leftPaneHost, state, collectAll(), {
283
309
  onInjectCells: (cells) => {
284
310
  addImportedCells(state, cells, activeCellIdx());
285
311
  rerender();
@@ -287,15 +313,15 @@ export async function render(container: HTMLElement, data: Record<string, unknow
287
313
  });
288
314
 
289
315
  // Auto-connect data servers declared in the recipe frontmatter (data.servers).
290
- // The notebook reads MCP state passively from globalThis.__multiMcp (singleton).
291
- autoConnectFrontmatterServers(data, () => pane.setServers(collectDataServers(data)));
316
+ // The notebook reads MCP state passively from canvas.dataServers.
317
+ autoConnectFrontmatterServers(data, () => pane.setServers(collectAll()));
292
318
 
293
319
  // Keep pane servers in sync with canvas changes
294
320
  let canvasUnsub: (() => void) | null = null;
295
321
  try {
296
322
  const canvasAny: any = (globalThis as any).__canvasVanilla || (globalThis as any).canvasVanilla;
297
323
  if (canvasAny?.subscribe) {
298
- canvasUnsub = canvasAny.subscribe(() => pane.setServers(collectDataServers(data)));
324
+ canvasUnsub = canvasAny.subscribe(() => pane.setServers(collectAll()));
299
325
  }
300
326
  } catch { /* ignore */ }
301
327
 
@@ -323,38 +349,50 @@ export async function render(container: HTMLElement, data: Record<string, unknow
323
349
  // Executors (same pattern as compact/workspace/document agents)
324
350
  // ---------------------------------------------------------------------------
325
351
 
352
+ /**
353
+ * Convert RunLog[] (objects with {t, msg}) returned by runCode into the
354
+ * string[] format expected by CellResult.logs. Each entry becomes a
355
+ * human-readable "<offset>ms <message>" line. Without this conversion every
356
+ * log entry would coerce to "[object Object]" in renderCellLogs.
357
+ */
358
+ function logsToStrings(logs: Array<{ t: number; msg: string }> | undefined): string[] {
359
+ if (!logs || logs.length === 0) return [];
360
+ return logs.map((l) => `${l.t}ms ${l.msg}`);
361
+ }
362
+
326
363
  async function jsExecutor(ctx: CellExecContext): Promise<CellResult> {
327
364
  const start = Date.now();
328
365
  const { cell, scope } = ctx;
329
- try {
330
- const keys = Object.keys(scope);
331
- const values = keys.map((k) => scope[k]);
332
- const src = cell.content.trim();
333
- const body = /^\s*(return|var|let|const|function|class|if|for|while|\/\/|\/\*)/.test(src)
334
- ? src
335
- : `return (async () => { return (${src}); })();`;
336
- // eslint-disable-next-line no-new-func
337
- const fn = new Function(...keys, body);
338
- let result = fn(...values);
339
- if (result && typeof result.then === 'function') result = await result;
340
- const durationMs = Date.now() - start;
341
-
342
- if (result === undefined || result === null) return { ok: true, kind: 'empty', durationMs };
343
- if (Array.isArray(result)) {
344
- const rows = result.filter((r) => r && typeof r === 'object') as Record<string, unknown>[];
345
- const columns = rows.length ? Array.from(new Set(rows.flatMap((r) => Object.keys(r)))) : [];
346
- return { ok: true, kind: 'table', rows, columns, rowCount: rows.length, durationMs };
347
- }
348
- if (result && typeof result === 'object') {
349
- const r: any = result;
350
- if (r.data || r.marks || r.mark || r.$schema) {
351
- return { ok: true, kind: 'chart', spec: result, durationMs };
352
- }
366
+ // Accept both `call(...)` (SDK) and `callTool(...)` (legacy / AI-generated)
367
+ // as aliases. runCode injects `call` and `widget` helpers around an
368
+ // AsyncFunction body, so top-level `await` and MCP calls work uniformly.
369
+ const code = cell.content.replace(/\bcallTool\s*\(/g, 'call(');
370
+ const res = await runCode(code, 'js', canvas.multiClient, scope);
371
+ const durationMs = Date.now() - start;
372
+ const logs = logsToStrings(res.logs as any);
373
+ if (res.status === 'error') {
374
+ return { ok: false, error: res.error ?? 'error', errorKind: 'runtime', durationMs, logs };
375
+ }
376
+ // Widgets emitted via `widget(name, params)` or via `*_widget_display(...)`
377
+ // calls surface them as a dedicated kind so the host can mount them.
378
+ const widgets = res.widgets ?? (res.widget ? [res.widget] : []);
379
+ if (widgets.length > 0) {
380
+ return { ok: true, kind: 'widget', widgets, durationMs, logs };
381
+ }
382
+ const result = res.output;
383
+ if (result === undefined || result === null) return { ok: true, kind: 'empty', durationMs, logs };
384
+ if (Array.isArray(result)) {
385
+ const rows = result.filter((r) => r && typeof r === 'object') as Record<string, unknown>[];
386
+ const columns = rows.length ? Array.from(new Set(rows.flatMap((r) => Object.keys(r)))) : [];
387
+ return { ok: true, kind: 'table', rows, columns, rowCount: rows.length, durationMs, logs };
388
+ }
389
+ if (result && typeof result === 'object') {
390
+ const r: any = result;
391
+ if (r.data || r.marks || r.mark || r.$schema) {
392
+ return { ok: true, kind: 'chart', spec: result, durationMs, logs };
353
393
  }
354
- return { ok: true, kind: 'value', value: result, durationMs };
355
- } catch (err: any) {
356
- return { ok: false, error: String(err?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - start };
357
394
  }
395
+ return { ok: true, kind: 'value', value: result, durationMs, logs };
358
396
  }
359
397
 
360
398
  // ---------------------------------------------------------------------------
@@ -453,7 +491,7 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
453
491
  if (!cell.hideResult) {
454
492
  const res = document.createElement('div');
455
493
  res.className = 'nbe-result';
456
- renderResultInto(res, cell, overlay);
494
+ renderResultInto(res, cell, overlay, state);
457
495
  codeCell.appendChild(res);
458
496
  }
459
497
 
@@ -462,9 +500,324 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
462
500
  (head.querySelector('.nb-toggle-src') as HTMLElement).addEventListener('click', () => { cell.hideSource = !cell.hideSource; rerender(); });
463
501
  (head.querySelector('.nb-toggle-res') as HTMLElement).addEventListener('click', () => { cell.hideResult = !cell.hideResult; rerender(); });
464
502
 
503
+ if (state.mode !== 'view') wrap.appendChild(renderCellActionBar(state, cell, rerender));
504
+
465
505
  return wrap;
466
506
  }
467
507
 
508
+ /**
509
+ * Per-cell action bar: [+ text] [+ sql] [+ JS] [+ widget] [+ agent]
510
+ * Inserts a new cell directly after this one, or opens a picker / agent input.
511
+ * `widget` and `agent` are wired in dedicated handlers below.
512
+ */
513
+ function renderCellActionBar(state: NotebookState, cell: NotebookCell, rerender: () => void): HTMLElement {
514
+ const bar = document.createElement('div');
515
+ bar.className = 'nbe-cell-actionbar';
516
+ const idx = state.cells.findIndex((c) => c.id === cell.id);
517
+ const insertAfter = (type: 'md' | 'sql' | 'js', content?: string) => {
518
+ const newCell: NotebookCell = {
519
+ id: uid(),
520
+ type,
521
+ content: content ?? defaultCellContent(type),
522
+ hideSource: false, hideResult: false, status: 'stale',
523
+ };
524
+ addImportedCells(state, [newCell], idx);
525
+ rerender();
526
+ };
527
+ bar.innerHTML = `
528
+ <button class="nb-btn nb-cellbar-btn" data-add="md">+ text</button>
529
+ <button class="nb-btn nb-cellbar-btn" data-add="sql">+ sql</button>
530
+ <button class="nb-btn nb-cellbar-btn" data-add="js">+ JS</button>
531
+ <button class="nb-btn nb-cellbar-btn" data-cellbar="widget">+ widget</button>
532
+ <button class="nb-btn nb-cellbar-btn" data-cellbar="agent">+ agent</button>`;
533
+ bar.querySelectorAll<HTMLElement>('[data-add]').forEach((btn) => {
534
+ btn.addEventListener('click', () => insertAfter(btn.dataset.add as 'md' | 'sql' | 'js'));
535
+ });
536
+ bar.querySelector<HTMLElement>('[data-cellbar="widget"]')?.addEventListener('click', () => {
537
+ openWidgetPicker(state, cell, rerender);
538
+ });
539
+ bar.querySelector<HTMLElement>('[data-cellbar="agent"]')?.addEventListener('click', () => {
540
+ toggleAgentBar(bar, state, cell, rerender);
541
+ });
542
+ return bar;
543
+ }
544
+
545
+ /**
546
+ * +widget picker — lists the recipes exposed by every active WebMCP server
547
+ * (state.webmcpServers, populated from frontmatter.webmcp_servers). On pick,
548
+ * inserts a JS cell after the current one with a template that reads the
549
+ * upstream cell's varname (default 'rows') and renders the widget.
550
+ */
551
+ function openWidgetPicker(state: NotebookState, cell: NotebookCell, rerender: () => void): void {
552
+ const servers = state.webmcpServers ?? [];
553
+ type Item = { server: string; name: string; description?: string };
554
+ const items: Item[] = [];
555
+ for (const s of servers) {
556
+ const recipes = s.layer().recipes ?? [];
557
+ for (const r of recipes) items.push({ server: s.name, name: r.name, description: r.description });
558
+ }
559
+ if (items.length === 0) {
560
+ toastNoServers();
561
+ return;
562
+ }
563
+ const upstreamVar = findUpstreamVarname(state, cell);
564
+ const overlay = document.createElement('div');
565
+ overlay.className = 'nbe-picker-overlay';
566
+ overlay.innerHTML = `
567
+ <div class="nbe-picker">
568
+ <header>
569
+ <input class="nbe-picker-q" placeholder="filter widgets…" autofocus />
570
+ <button class="nbe-picker-close" type="button">✕</button>
571
+ </header>
572
+ <ul class="nbe-picker-list"></ul>
573
+ </div>`;
574
+ document.body.appendChild(overlay);
575
+ const close = () => { try { overlay.remove(); } catch { /* ignore */ } };
576
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
577
+ (overlay.querySelector('.nbe-picker-close') as HTMLElement).addEventListener('click', close);
578
+ const list = overlay.querySelector('.nbe-picker-list') as HTMLElement;
579
+ const q = overlay.querySelector('.nbe-picker-q') as HTMLInputElement;
580
+ const render = (filter = '') => {
581
+ const f = filter.toLowerCase().trim();
582
+ list.innerHTML = items
583
+ .filter((i) => !f || i.name.toLowerCase().includes(f) || (i.description ?? '').toLowerCase().includes(f) || i.server.toLowerCase().includes(f))
584
+ .map((i) => `<li data-name="${escapeAttr(i.name)}" data-server="${escapeAttr(i.server)}">
585
+ <span class="nbe-picker-name">${escapeHtml(i.name)}</span>
586
+ <span class="nbe-picker-server">${escapeHtml(i.server)}</span>
587
+ <span class="nbe-picker-desc">${escapeHtml(i.description ?? '')}</span>
588
+ </li>`).join('');
589
+ };
590
+ render();
591
+ q.addEventListener('input', () => render(q.value));
592
+ list.addEventListener('click', (e) => {
593
+ const li = (e.target as HTMLElement).closest('li[data-name]') as HTMLElement | null;
594
+ if (!li) return;
595
+ const name = li.dataset.name as string;
596
+ const idx = state.cells.findIndex((c) => c.id === cell.id);
597
+ const template = upstreamVar
598
+ ? `return widget('${name}', { rows: ${upstreamVar} })`
599
+ : `return widget('${name}', {})`;
600
+ const newCell: NotebookCell = {
601
+ id: uid(), type: 'js',
602
+ content: template,
603
+ hideSource: false, hideResult: false, status: 'stale',
604
+ };
605
+ addImportedCells(state, [newCell], idx);
606
+ close();
607
+ rerender();
608
+ });
609
+ function toastNoServers() {
610
+ const t = document.createElement('div');
611
+ t.className = 'nbe-toast nbe-toast-error nbe-toast-in';
612
+ t.textContent = 'No WebMCP server is enabled — add some in flex first (autoui, d3, observable-plot…)';
613
+ document.body.appendChild(t);
614
+ setTimeout(() => t.remove(), 3500);
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Walk back from `cell` to find the nearest cell with a varname (typically a
620
+ * SQL cell). The widget template will bind to that variable so the chosen
621
+ * widget visualises the upstream cell's output. Returns null if none.
622
+ */
623
+ function findUpstreamVarname(state: NotebookState, cell: NotebookCell): string | null {
624
+ const idx = state.cells.findIndex((c) => c.id === cell.id);
625
+ for (let i = idx; i >= 0; i--) {
626
+ const c = state.cells[i];
627
+ if (c?.varname) return c.varname;
628
+ }
629
+ return null;
630
+ }
631
+
632
+ /**
633
+ * +agent bar — toggled inline below the cell action bar. The user types a
634
+ * prompt in <auto-chat-input>, runAgentLoop is invoked with a focused tool
635
+ * layer that lets the LLM:
636
+ * - read the current cell + scope
637
+ * - rewrite the current cell content
638
+ * - insert a new cell after it (text / sql / js / widget)
639
+ * Every mutation logs into state.history so the user can ⟲ revert.
640
+ * Provider: RemoteLLMProvider via /api/chat (proxy reads env LLM_API_KEY).
641
+ */
642
+ function toggleAgentBar(host: HTMLElement, state: NotebookState, cell: NotebookCell, rerender: () => void): void {
643
+ const existing = host.parentElement?.querySelector(':scope > .nbe-agent-bar');
644
+ if (existing) { existing.remove(); return; }
645
+ const bar = document.createElement('div');
646
+ bar.className = 'nbe-agent-bar';
647
+ bar.innerHTML = `
648
+ <auto-chat-input placeholder="ask agent — e.g. 'filter rows where votes > 50' / 'add a sankey of this'"></auto-chat-input>
649
+ <div class="nbe-agent-status" hidden></div>`;
650
+ host.insertAdjacentElement('afterend', bar);
651
+ const input = bar.querySelector('auto-chat-input') as HTMLElement & { disabled?: boolean };
652
+ const status = bar.querySelector('.nbe-agent-status') as HTMLElement;
653
+ let aborter: AbortController | null = null;
654
+ input.addEventListener('widget:interact', (e: Event) => {
655
+ const detail = (e as CustomEvent).detail ?? {};
656
+ const action = (detail as { action?: string }).action;
657
+ // Prevent the event bubbling to the host (flex/etc.) — otherwise their
658
+ // own widget-interaction handler would launch a SECOND, unconstrained
659
+ // agent loop that renders widgets outside the notebook (canvas blocks).
660
+ if (action === 'stop') { e.stopPropagation(); aborter?.abort(); return; }
661
+ if (action !== 'submit') return;
662
+ e.stopPropagation();
663
+ const text = ((detail as { payload?: { text?: string } }).payload?.text ?? '').trim();
664
+ if (!text) return;
665
+ void runAgentForCell(text, state, cell, rerender, status, input, () => aborter ??= new AbortController());
666
+ });
667
+ }
668
+
669
+ async function runAgentForCell(
670
+ prompt: string,
671
+ state: NotebookState,
672
+ cell: NotebookCell,
673
+ rerender: () => void,
674
+ status: HTMLElement,
675
+ input: HTMLElement & { disabled?: boolean },
676
+ getAborter: () => AbortController,
677
+ ): Promise<void> {
678
+ status.hidden = false;
679
+ status.textContent = '…';
680
+ input.disabled = true;
681
+ const aborter = getAborter();
682
+ try {
683
+ // Lazy import to keep notebook bundle slim when agent is unused.
684
+ const { RemoteLLMProvider, runAgentLoop } = await import('@webmcp-auto-ui/agent');
685
+ // Host injects chatApiBase via data (flex serves under /flex, notebook-viewer
686
+ // at root). Default '/api/chat' kept for hosts mounting at the root.
687
+ const proxyUrl = (state as { chatApiBase?: string }).chatApiBase ?? '/api/chat';
688
+ const provider = new RemoteLLMProvider({ proxyUrl, model: 'haiku' });
689
+ const layer = buildAgentLayerForCell(state, cell, rerender);
690
+ const systemPrompt = buildAgentSystemPromptForCell(state, cell);
691
+ await runAgentLoop(prompt, {
692
+ provider,
693
+ layers: [layer],
694
+ systemPrompt,
695
+ maxIterations: 6,
696
+ signal: aborter.signal,
697
+ callbacks: {
698
+ onToolCall: (call: { name?: string }) => { status.textContent = `· ${call?.name ?? '?'}…`; },
699
+ },
700
+ });
701
+ status.textContent = '✓ done';
702
+ } catch (err) {
703
+ status.textContent = 'error: ' + String((err as { message?: unknown })?.message ?? err);
704
+ } finally {
705
+ input.disabled = false;
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Build the WebMCP tool layer the agent sees. Tools mutate state via the
711
+ * shared helpers (addImportedCells, logHistory) so revert via ⟲ history works
712
+ * exactly like a manual edit.
713
+ */
714
+ function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerender: () => void) {
715
+ const idx = () => state.cells.findIndex((c) => c.id === cell.id);
716
+ const tools = [
717
+ {
718
+ name: 'get_current_cell',
719
+ description: 'Read the cell the user invoked the agent on (id, type, content, varname).',
720
+ inputSchema: { type: 'object', properties: {} },
721
+ execute: async () => ({
722
+ id: cell.id, type: cell.type,
723
+ content: cell.content,
724
+ varname: (cell as { varname?: string }).varname,
725
+ }),
726
+ },
727
+ {
728
+ name: 'list_cells',
729
+ description: 'List all cells in the notebook (id, type, short content preview).',
730
+ inputSchema: { type: 'object', properties: {} },
731
+ execute: async () => state.cells.map((c) => ({
732
+ id: c.id, type: c.type,
733
+ preview: (c.content ?? '').slice(0, 120),
734
+ varname: (c as { varname?: string }).varname,
735
+ })),
736
+ },
737
+ {
738
+ name: 'update_cell',
739
+ description: 'Replace the current cell content with new code/markdown. The cell type is preserved.',
740
+ inputSchema: {
741
+ type: 'object',
742
+ properties: { content: { type: 'string', description: 'New full content for the cell.' } },
743
+ required: ['content'],
744
+ },
745
+ execute: async (args: Record<string, unknown>) => {
746
+ const next = String(args.content ?? '');
747
+ const before = cell.content;
748
+ if (before === next) return { ok: true, unchanged: true };
749
+ // Snapshot prior content so logHistory can support revert.
750
+ cell.content = next;
751
+ cell.status = 'stale';
752
+ state.lastEditAt = Date.now();
753
+ // Manual history entry — reuse `edit` kind, summary mentions agent.
754
+ try {
755
+ (state.history ?? []).push({
756
+ ts: Date.now(), kind: 'edit',
757
+ summary: `agent edited ${cell.type} cell`,
758
+ snapshot: { cellId: cell.id, before, after: next },
759
+ } as never);
760
+ } catch { /* ignore — best-effort */ }
761
+ rerender();
762
+ return { ok: true };
763
+ },
764
+ },
765
+ {
766
+ name: 'insert_cell_after',
767
+ description: 'Insert a new cell directly after the current one. Use type=text for prose, sql for queries, js for scripts/widgets.',
768
+ inputSchema: {
769
+ type: 'object',
770
+ properties: {
771
+ type: { type: 'string', enum: ['text', 'sql', 'js'], description: '"text" for markdown prose, "sql" for a query, "js" for a script (use widget(name, params) to render a widget).' },
772
+ content: { type: 'string', description: 'Cell content.' },
773
+ varname: { type: 'string', description: 'Optional varname for sql/js cells — exposes the result as a variable to subsequent cells.' },
774
+ },
775
+ required: ['type', 'content'],
776
+ },
777
+ execute: async (args: Record<string, unknown>) => {
778
+ const t = String(args.type ?? '');
779
+ const type: NotebookCell['type'] = t === 'text' ? 'md' : t === 'sql' ? 'sql' : 'js';
780
+ const newCell: NotebookCell = {
781
+ id: uid(), type,
782
+ content: String(args.content ?? ''),
783
+ hideSource: false, hideResult: false, status: 'stale',
784
+ ...(args.varname ? { varname: String(args.varname) } : {}),
785
+ };
786
+ addImportedCells(state, [newCell], idx());
787
+ rerender();
788
+ return { ok: true, id: newCell.id };
789
+ },
790
+ },
791
+ {
792
+ name: 'list_widgets',
793
+ description: 'List widgets exposed by the connected WebMCP servers — pickable in JS cells via widget(name, params).',
794
+ inputSchema: { type: 'object', properties: {} },
795
+ execute: async () => {
796
+ const out: Array<{ server: string; name: string; description?: string }> = [];
797
+ for (const s of state.webmcpServers ?? []) {
798
+ for (const r of s.layer().recipes ?? []) out.push({ server: s.name, name: r.name, description: r.description });
799
+ }
800
+ return out;
801
+ },
802
+ },
803
+ ];
804
+ return { protocol: 'webmcp' as const, serverName: 'notebook-editor', tools, recipes: [] };
805
+ }
806
+
807
+ function buildAgentSystemPromptForCell(_state: NotebookState, cell: NotebookCell): string {
808
+ return [
809
+ 'You are an in-notebook editing assistant. The user invoked you on a specific cell.',
810
+ 'Your job: rewrite that cell, OR insert a follow-up cell after it, based on the user\'s prompt.',
811
+ `The current cell is of type "${cell.type}".`,
812
+ 'Workflow:',
813
+ ' 1. Call get_current_cell to read the cell content + varname.',
814
+ ' 2. If the user wants to TRANSFORM the existing cell (filter, refactor, fix), call update_cell.',
815
+ ' 3. If the user wants a FOLLOW-UP step (chart, summary, follow-up query), call insert_cell_after.',
816
+ ' 4. For widgets: call list_widgets first to discover available names, then write a JS cell with `return widget("<name>", { rows, ...params })`.',
817
+ 'Be terse. One or two tool calls is usually enough. Do not explain at length.',
818
+ ].join('\n');
819
+ }
820
+
468
821
  // ---------------------------------------------------------------------------
469
822
  // Result rendering — editorial flavour (serif headers, mono cells, discreet)
470
823
  // ---------------------------------------------------------------------------
@@ -490,7 +843,7 @@ function formatMs(ms: number): string {
490
843
  return (ms / 1000).toFixed(2) + 's';
491
844
  }
492
845
 
493
- function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null): void {
846
+ function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null, stateRef?: NotebookState): void {
494
847
  const r = effectiveResult(cell, overlay) ?? cell.lastResult;
495
848
  el.innerHTML = '';
496
849
  if (!r) {
@@ -528,6 +881,22 @@ function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeO
528
881
  renderChart(chart, r.spec).catch(() => { /* fallback handled internally */ });
529
882
  return;
530
883
  }
884
+ if (r.kind === 'widget') {
885
+ const fallbackServers = stateRef?.webmcpServers ?? [];
886
+ for (const w of r.widgets) {
887
+ const host = document.createElement('div');
888
+ host.className = 'nb-widget-host';
889
+ el.appendChild(host);
890
+ try { mountWidget(host, w.name, w.params, fallbackServers); }
891
+ catch (err) {
892
+ const pre = document.createElement('pre');
893
+ pre.className = 'nbe-result-error';
894
+ pre.textContent = `widget "${w.name}" failed: ${String((err as { message?: unknown })?.message ?? err)}`;
895
+ host.appendChild(pre);
896
+ }
897
+ }
898
+ return;
899
+ }
531
900
  // table — editorial style: serif header row, mono cells, minimal chrome.
532
901
  const cols = r.columns && r.columns.length ? r.columns
533
902
  : (r.rows[0] ? Object.keys(r.rows[0]) : []);
@@ -801,18 +1170,6 @@ function injectLayoutStyles(): void {
801
1170
  .nbe-toast.nbe-toast-in { opacity: 1; transform: translateX(-50%) translateY(0); }
802
1171
  .nbe-toast.nbe-toast-error { color: var(--color-accent2); border-color: var(--color-accent2); }
803
1172
 
804
- /* Live mode — discreet toggle in header (edit mode only) */
805
- .nbe-live-toggle {
806
- display: inline-flex; align-items: center; gap: 6px;
807
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
808
- font-size: 10.5px; color: var(--color-text2);
809
- letter-spacing: 0.06em; text-transform: uppercase;
810
- cursor: pointer; user-select: none;
811
- padding: 2px 7px; border: 1px solid var(--color-border); border-radius: 4px;
812
- }
813
- .nbe-live-toggle:hover { color: var(--color-text1); border-color: var(--color-border2); }
814
- .nbe-live-toggle input { margin: 0; cursor: pointer; }
815
-
816
1173
  /* Title row + Live badge (view mode + autoRun) */
817
1174
  .nbe-title-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
818
1175
  .nbe-title-row .nbe-title { flex: 1; min-width: 0; }