@webmcp-auto-ui/ui 2.5.37 → 2.5.39

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();
@@ -38,7 +74,16 @@ export async function render(container: HTMLElement, data: Record<string, unknow
38
74
  autoRun: (data as any).autoRun === true,
39
75
  publishedSlug: (data as any).publishedSlug,
40
76
  publishedToken: (data as any).publishedToken,
41
- });
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 + logs visibility per mode (toggle remains usable for per-cell override).
83
+ for (const c of state.cells) {
84
+ c.hideSource = state.mode === 'view';
85
+ if (state.mode === 'view' && c.hideLogs === undefined) c.hideLogs = true;
86
+ }
42
87
 
43
88
  // Live mode runtime overlay (created lazily). Never mutates state.
44
89
  let overlay: RuntimeOverlay | null = null;
@@ -57,8 +102,8 @@ export async function render(container: HTMLElement, data: Record<string, unknow
57
102
  <div class="nbe-shell">
58
103
  <div class="nbe-kicker">
59
104
  <div class="nb-mode-switch" style="margin-left:auto;">
60
- <button class="nb-mode-edit nb-on">edit</button>
61
- <button class="nb-mode-view">view</button>
105
+ <button class="nb-mode-edit ${state.mode === 'edit' ? 'nb-on' : ''}">edit</button>
106
+ <button class="nb-mode-view ${state.mode === 'view' ? 'nb-on' : ''}">view</button>
62
107
  </div>
63
108
  <button class="nb-btn nbe-history-btn">⟲ history</button>
64
109
  <span class="nbe-publish-badge-slot"></span>
@@ -71,11 +116,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
71
116
  <div class="nb-history-panel nbe-history-panel"></div>
72
117
  <div class="nbe-cells"></div>
73
118
  <div class="nbe-footer">
74
- <button class="nb-btn nb-add-cell" data-add="md">+ prose</button>
75
- <button class="nb-btn nb-add-cell" data-add="sql">+ sql</button>
76
- <button class="nb-btn nb-add-cell" data-add="js">+ chart</button>
77
- <button class="nb-btn nb-add-cell" data-add-modal="md">+ md</button>
78
- <button class="nb-btn nb-add-cell" data-add-modal="recipe">+ recipe</button>
79
119
  <span class="nbe-share-btn" title="Share">share</span>
80
120
  <span class="nbe-publish-slot"></span>
81
121
  </div>
@@ -233,7 +273,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
233
273
  });
234
274
  const publishCleanup = createPublishControls(state, {
235
275
  buttonSlot: shell.querySelector('.nbe-publish-slot') as HTMLElement,
236
- badgeSlot: shell.querySelector('.nbe-publish-badge-slot') as HTMLElement,
276
+ badgeSlot: state.hidePublishBadge ? undefined : (shell.querySelector('.nbe-publish-badge-slot') as HTMLElement),
237
277
  footerSlot: shell.querySelector('.nbe-publish-footer-slot') as HTMLElement,
238
278
  onPublished: () => rerender(),
239
279
  });
@@ -245,6 +285,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
245
285
  const viewBtn = shell.querySelector('.nb-mode-view') as HTMLElement;
246
286
  editBtn.addEventListener('click', () => {
247
287
  state.mode = 'edit';
288
+ for (const c of state.cells) { c.hideSource = false; c.hideLogs = false; }
248
289
  container.classList.remove('nb-view-mode');
249
290
  editBtn.classList.add('nb-on'); viewBtn.classList.remove('nb-on');
250
291
  // Leaving view: stop live refresh and clear overlay so frozen snapshots show.
@@ -253,14 +294,21 @@ export async function render(container: HTMLElement, data: Record<string, unknow
253
294
  });
254
295
  viewBtn.addEventListener('click', () => {
255
296
  state.mode = 'view';
297
+ for (const c of state.cells) { c.hideSource = true; c.hideLogs = true; }
256
298
  container.classList.add('nb-view-mode');
257
299
  viewBtn.classList.add('nb-on'); editBtn.classList.remove('nb-on');
258
300
  if (state.autoRun === true) bootstrapLive();
259
301
  rerender();
260
302
  });
261
303
 
262
- // Left pane (collapsed by default)
263
- const pane = mountLeftPane(leftPaneHost, state, collectDataServers(data), {
304
+
305
+ // Left pane (collapsed by default). Lists remote data servers AND bundled
306
+ // WebMCP servers (autoui, d3, …) in two distinct groups.
307
+ const collectAll = () => [
308
+ ...collectDataServers(data),
309
+ ...collectWebmcpServers(state.webmcpServers),
310
+ ];
311
+ const pane = mountLeftPane(leftPaneHost, state, collectAll(), {
264
312
  onInjectCells: (cells) => {
265
313
  addImportedCells(state, cells, activeCellIdx());
266
314
  rerender();
@@ -269,14 +317,14 @@ export async function render(container: HTMLElement, data: Record<string, unknow
269
317
 
270
318
  // Auto-connect data servers declared in the recipe frontmatter (data.servers).
271
319
  // The notebook reads MCP state passively from canvas.dataServers.
272
- autoConnectFrontmatterServers(data, () => pane.setServers(collectDataServers(data)));
320
+ autoConnectFrontmatterServers(data, () => pane.setServers(collectAll()));
273
321
 
274
322
  // Keep pane servers in sync with canvas changes
275
323
  let canvasUnsub: (() => void) | null = null;
276
324
  try {
277
325
  const canvasAny: any = (globalThis as any).__canvasVanilla || (globalThis as any).canvasVanilla;
278
326
  if (canvasAny?.subscribe) {
279
- canvasUnsub = canvasAny.subscribe(() => pane.setServers(collectDataServers(data)));
327
+ canvasUnsub = canvasAny.subscribe(() => pane.setServers(collectAll()));
280
328
  }
281
329
  } catch { /* ignore */ }
282
330
 
@@ -304,38 +352,50 @@ export async function render(container: HTMLElement, data: Record<string, unknow
304
352
  // Executors (same pattern as compact/workspace/document agents)
305
353
  // ---------------------------------------------------------------------------
306
354
 
355
+ /**
356
+ * Convert RunLog[] (objects with {t, msg}) returned by runCode into the
357
+ * string[] format expected by CellResult.logs. Each entry becomes a
358
+ * human-readable "<offset>ms <message>" line. Without this conversion every
359
+ * log entry would coerce to "[object Object]" in renderCellLogs.
360
+ */
361
+ function logsToStrings(logs: Array<{ t: number; msg: string }> | undefined): string[] {
362
+ if (!logs || logs.length === 0) return [];
363
+ return logs.map((l) => `${l.t}ms ${l.msg}`);
364
+ }
365
+
307
366
  async function jsExecutor(ctx: CellExecContext): Promise<CellResult> {
308
367
  const start = Date.now();
309
368
  const { cell, scope } = ctx;
310
- try {
311
- const keys = Object.keys(scope);
312
- const values = keys.map((k) => scope[k]);
313
- const src = cell.content.trim();
314
- const body = /^\s*(return|var|let|const|function|class|if|for|while|\/\/|\/\*)/.test(src)
315
- ? src
316
- : `return (async () => { return (${src}); })();`;
317
- // eslint-disable-next-line no-new-func
318
- const fn = new Function(...keys, body);
319
- let result = fn(...values);
320
- if (result && typeof result.then === 'function') result = await result;
321
- const durationMs = Date.now() - start;
322
-
323
- if (result === undefined || result === null) return { ok: true, kind: 'empty', durationMs };
324
- if (Array.isArray(result)) {
325
- const rows = result.filter((r) => r && typeof r === 'object') as Record<string, unknown>[];
326
- const columns = rows.length ? Array.from(new Set(rows.flatMap((r) => Object.keys(r)))) : [];
327
- return { ok: true, kind: 'table', rows, columns, rowCount: rows.length, durationMs };
328
- }
329
- if (result && typeof result === 'object') {
330
- const r: any = result;
331
- if (r.data || r.marks || r.mark || r.$schema) {
332
- return { ok: true, kind: 'chart', spec: result, durationMs };
333
- }
369
+ // Accept both `call(...)` (SDK) and `callTool(...)` (legacy / AI-generated)
370
+ // as aliases. runCode injects `call` and `widget` helpers around an
371
+ // AsyncFunction body, so top-level `await` and MCP calls work uniformly.
372
+ const code = cell.content.replace(/\bcallTool\s*\(/g, 'call(');
373
+ const res = await runCode(code, 'js', canvas.multiClient, scope);
374
+ const durationMs = Date.now() - start;
375
+ const logs = logsToStrings(res.logs as any);
376
+ if (res.status === 'error') {
377
+ return { ok: false, error: res.error ?? 'error', errorKind: 'runtime', durationMs, logs };
378
+ }
379
+ // Widgets emitted via `widget(name, params)` or via `*_widget_display(...)`
380
+ // calls surface them as a dedicated kind so the host can mount them.
381
+ const widgets = res.widgets ?? (res.widget ? [res.widget] : []);
382
+ if (widgets.length > 0) {
383
+ return { ok: true, kind: 'widget', widgets, durationMs, logs };
384
+ }
385
+ const result = res.output;
386
+ if (result === undefined || result === null) return { ok: true, kind: 'empty', durationMs, logs };
387
+ if (Array.isArray(result)) {
388
+ const rows = result.filter((r) => r && typeof r === 'object') as Record<string, unknown>[];
389
+ const columns = rows.length ? Array.from(new Set(rows.flatMap((r) => Object.keys(r)))) : [];
390
+ return { ok: true, kind: 'table', rows, columns, rowCount: rows.length, durationMs, logs };
391
+ }
392
+ if (result && typeof result === 'object') {
393
+ const r: any = result;
394
+ if (r.data || r.marks || r.mark || r.$schema) {
395
+ return { ok: true, kind: 'chart', spec: result, durationMs, logs };
334
396
  }
335
- return { ok: true, kind: 'value', value: result, durationMs };
336
- } catch (err: any) {
337
- return { ok: false, error: String(err?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - start };
338
397
  }
398
+ return { ok: true, kind: 'value', value: result, durationMs, logs };
339
399
  }
340
400
 
341
401
  // ---------------------------------------------------------------------------
@@ -397,17 +457,25 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
397
457
  liveBadge = `<span class="nbe-cell-badge nbe-cell-frozen" title="JS cells are not re-executed in live mode">frozen</span>`;
398
458
  }
399
459
  }
460
+ const lastRes = effectiveResult(cell, overlay) ?? cell.lastResult;
461
+ const logCount = (lastRes?.logs as string[] | undefined)?.length ?? 0;
462
+ const logsToggle = logCount > 0
463
+ ? `<button class="nb-icon-btn nb-toggle-logs" title="toggle console">${cell.hideLogs ? '▸' : '▾'} ${logCount}</button>`
464
+ : '';
400
465
  head.innerHTML = `
401
466
  <span class="nbe-run-controls"></span>
402
467
  <span class="nbe-type-${cell.type}">${cell.type}</span>
403
468
  <span class="nbe-meta-info">${escapeHtml(metaInfoFor(cell, overlay))}</span>
404
469
  ${liveBadge}
405
470
  <div class="nbe-actions">
471
+ ${logsToggle}
406
472
  <button class="nb-icon-btn nb-toggle-src">${cell.hideSource ? '▸ src' : '◂ src'}</button>
407
473
  <button class="nb-icon-btn nb-toggle-res">${cell.hideResult ? '▸ res' : '◂ res'}</button>
408
474
  </div>`;
409
475
  codeCell.appendChild(head);
410
476
  mountRunControls(head.querySelector('.nbe-run-controls') as HTMLElement, cell, wrap, state, rerender);
477
+ const logsBtn = head.querySelector('.nb-toggle-logs') as HTMLElement | null;
478
+ if (logsBtn) logsBtn.addEventListener('click', () => { cell.hideLogs = !cell.hideLogs; rerender(); });
411
479
 
412
480
  const body = document.createElement('div');
413
481
  body.className = 'nbe-code-body' + (cell.hideSource ? ' nbe-hidden' : '');
@@ -434,7 +502,7 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
434
502
  if (!cell.hideResult) {
435
503
  const res = document.createElement('div');
436
504
  res.className = 'nbe-result';
437
- renderResultInto(res, cell, overlay);
505
+ renderResultInto(res, cell, overlay, state);
438
506
  codeCell.appendChild(res);
439
507
  }
440
508
 
@@ -443,9 +511,324 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
443
511
  (head.querySelector('.nb-toggle-src') as HTMLElement).addEventListener('click', () => { cell.hideSource = !cell.hideSource; rerender(); });
444
512
  (head.querySelector('.nb-toggle-res') as HTMLElement).addEventListener('click', () => { cell.hideResult = !cell.hideResult; rerender(); });
445
513
 
514
+ if (state.mode !== 'view') wrap.appendChild(renderCellActionBar(state, cell, rerender));
515
+
446
516
  return wrap;
447
517
  }
448
518
 
519
+ /**
520
+ * Per-cell action bar: [+ text] [+ sql] [+ JS] [+ widget] [+ agent]
521
+ * Inserts a new cell directly after this one, or opens a picker / agent input.
522
+ * `widget` and `agent` are wired in dedicated handlers below.
523
+ */
524
+ function renderCellActionBar(state: NotebookState, cell: NotebookCell, rerender: () => void): HTMLElement {
525
+ const bar = document.createElement('div');
526
+ bar.className = 'nbe-cell-actionbar';
527
+ const idx = state.cells.findIndex((c) => c.id === cell.id);
528
+ const insertAfter = (type: 'md' | 'sql' | 'js', content?: string) => {
529
+ const newCell: NotebookCell = {
530
+ id: uid(),
531
+ type,
532
+ content: content ?? defaultCellContent(type),
533
+ hideSource: false, hideResult: false, status: 'stale',
534
+ };
535
+ addImportedCells(state, [newCell], idx);
536
+ rerender();
537
+ };
538
+ bar.innerHTML = `
539
+ <button class="nb-btn nb-cellbar-btn" data-add="md">+ text</button>
540
+ <button class="nb-btn nb-cellbar-btn" data-add="sql">+ sql</button>
541
+ <button class="nb-btn nb-cellbar-btn" data-add="js">+ JS</button>
542
+ <button class="nb-btn nb-cellbar-btn" data-cellbar="widget">+ widget</button>
543
+ <button class="nb-btn nb-cellbar-btn" data-cellbar="agent">+ agent</button>`;
544
+ bar.querySelectorAll<HTMLElement>('[data-add]').forEach((btn) => {
545
+ btn.addEventListener('click', () => insertAfter(btn.dataset.add as 'md' | 'sql' | 'js'));
546
+ });
547
+ bar.querySelector<HTMLElement>('[data-cellbar="widget"]')?.addEventListener('click', () => {
548
+ openWidgetPicker(state, cell, rerender);
549
+ });
550
+ bar.querySelector<HTMLElement>('[data-cellbar="agent"]')?.addEventListener('click', () => {
551
+ toggleAgentBar(bar, state, cell, rerender);
552
+ });
553
+ return bar;
554
+ }
555
+
556
+ /**
557
+ * +widget picker — lists the recipes exposed by every active WebMCP server
558
+ * (state.webmcpServers, populated from frontmatter.webmcp_servers). On pick,
559
+ * inserts a JS cell after the current one with a template that reads the
560
+ * upstream cell's varname (default 'rows') and renders the widget.
561
+ */
562
+ function openWidgetPicker(state: NotebookState, cell: NotebookCell, rerender: () => void): void {
563
+ const servers = state.webmcpServers ?? [];
564
+ type Item = { server: string; name: string; description?: string };
565
+ const items: Item[] = [];
566
+ for (const s of servers) {
567
+ const recipes = s.layer().recipes ?? [];
568
+ for (const r of recipes) items.push({ server: s.name, name: r.name, description: r.description });
569
+ }
570
+ if (items.length === 0) {
571
+ toastNoServers();
572
+ return;
573
+ }
574
+ const upstreamVar = findUpstreamVarname(state, cell);
575
+ const overlay = document.createElement('div');
576
+ overlay.className = 'nbe-picker-overlay';
577
+ overlay.innerHTML = `
578
+ <div class="nbe-picker">
579
+ <header>
580
+ <input class="nbe-picker-q" placeholder="filter widgets…" autofocus />
581
+ <button class="nbe-picker-close" type="button">✕</button>
582
+ </header>
583
+ <ul class="nbe-picker-list"></ul>
584
+ </div>`;
585
+ document.body.appendChild(overlay);
586
+ const close = () => { try { overlay.remove(); } catch { /* ignore */ } };
587
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
588
+ (overlay.querySelector('.nbe-picker-close') as HTMLElement).addEventListener('click', close);
589
+ const list = overlay.querySelector('.nbe-picker-list') as HTMLElement;
590
+ const q = overlay.querySelector('.nbe-picker-q') as HTMLInputElement;
591
+ const render = (filter = '') => {
592
+ const f = filter.toLowerCase().trim();
593
+ list.innerHTML = items
594
+ .filter((i) => !f || i.name.toLowerCase().includes(f) || (i.description ?? '').toLowerCase().includes(f) || i.server.toLowerCase().includes(f))
595
+ .map((i) => `<li data-name="${escapeAttr(i.name)}" data-server="${escapeAttr(i.server)}">
596
+ <span class="nbe-picker-name">${escapeHtml(i.name)}</span>
597
+ <span class="nbe-picker-server">${escapeHtml(i.server)}</span>
598
+ <span class="nbe-picker-desc">${escapeHtml(i.description ?? '')}</span>
599
+ </li>`).join('');
600
+ };
601
+ render();
602
+ q.addEventListener('input', () => render(q.value));
603
+ list.addEventListener('click', (e) => {
604
+ const li = (e.target as HTMLElement).closest('li[data-name]') as HTMLElement | null;
605
+ if (!li) return;
606
+ const name = li.dataset.name as string;
607
+ const idx = state.cells.findIndex((c) => c.id === cell.id);
608
+ const template = upstreamVar
609
+ ? `return widget('${name}', { rows: ${upstreamVar} })`
610
+ : `return widget('${name}', {})`;
611
+ const newCell: NotebookCell = {
612
+ id: uid(), type: 'js',
613
+ content: template,
614
+ hideSource: false, hideResult: false, status: 'stale',
615
+ };
616
+ addImportedCells(state, [newCell], idx);
617
+ close();
618
+ rerender();
619
+ });
620
+ function toastNoServers() {
621
+ const t = document.createElement('div');
622
+ t.className = 'nbe-toast nbe-toast-error nbe-toast-in';
623
+ t.textContent = 'No WebMCP server is enabled — add some in flex first (autoui, d3, observable-plot…)';
624
+ document.body.appendChild(t);
625
+ setTimeout(() => t.remove(), 3500);
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Walk back from `cell` to find the nearest cell with a varname (typically a
631
+ * SQL cell). The widget template will bind to that variable so the chosen
632
+ * widget visualises the upstream cell's output. Returns null if none.
633
+ */
634
+ function findUpstreamVarname(state: NotebookState, cell: NotebookCell): string | null {
635
+ const idx = state.cells.findIndex((c) => c.id === cell.id);
636
+ for (let i = idx; i >= 0; i--) {
637
+ const c = state.cells[i];
638
+ if (c?.varname) return c.varname;
639
+ }
640
+ return null;
641
+ }
642
+
643
+ /**
644
+ * +agent bar — toggled inline below the cell action bar. The user types a
645
+ * prompt in <auto-chat-input>, runAgentLoop is invoked with a focused tool
646
+ * layer that lets the LLM:
647
+ * - read the current cell + scope
648
+ * - rewrite the current cell content
649
+ * - insert a new cell after it (text / sql / js / widget)
650
+ * Every mutation logs into state.history so the user can ⟲ revert.
651
+ * Provider: RemoteLLMProvider via /api/chat (proxy reads env LLM_API_KEY).
652
+ */
653
+ function toggleAgentBar(host: HTMLElement, state: NotebookState, cell: NotebookCell, rerender: () => void): void {
654
+ const existing = host.parentElement?.querySelector(':scope > .nbe-agent-bar');
655
+ if (existing) { existing.remove(); return; }
656
+ const bar = document.createElement('div');
657
+ bar.className = 'nbe-agent-bar';
658
+ bar.innerHTML = `
659
+ <auto-chat-input placeholder="ask agent — e.g. 'filter rows where votes > 50' / 'add a sankey of this'"></auto-chat-input>
660
+ <div class="nbe-agent-status" hidden></div>`;
661
+ host.insertAdjacentElement('afterend', bar);
662
+ const input = bar.querySelector('auto-chat-input') as HTMLElement & { disabled?: boolean };
663
+ const status = bar.querySelector('.nbe-agent-status') as HTMLElement;
664
+ let aborter: AbortController | null = null;
665
+ input.addEventListener('widget:interact', (e: Event) => {
666
+ const detail = (e as CustomEvent).detail ?? {};
667
+ const action = (detail as { action?: string }).action;
668
+ // Prevent the event bubbling to the host (flex/etc.) — otherwise their
669
+ // own widget-interaction handler would launch a SECOND, unconstrained
670
+ // agent loop that renders widgets outside the notebook (canvas blocks).
671
+ if (action === 'stop') { e.stopPropagation(); aborter?.abort(); return; }
672
+ if (action !== 'submit') return;
673
+ e.stopPropagation();
674
+ const text = ((detail as { payload?: { text?: string } }).payload?.text ?? '').trim();
675
+ if (!text) return;
676
+ void runAgentForCell(text, state, cell, rerender, status, input, () => aborter ??= new AbortController());
677
+ });
678
+ }
679
+
680
+ async function runAgentForCell(
681
+ prompt: string,
682
+ state: NotebookState,
683
+ cell: NotebookCell,
684
+ rerender: () => void,
685
+ status: HTMLElement,
686
+ input: HTMLElement & { disabled?: boolean },
687
+ getAborter: () => AbortController,
688
+ ): Promise<void> {
689
+ status.hidden = false;
690
+ status.textContent = '…';
691
+ input.disabled = true;
692
+ const aborter = getAborter();
693
+ try {
694
+ // Lazy import to keep notebook bundle slim when agent is unused.
695
+ const { RemoteLLMProvider, runAgentLoop } = await import('@webmcp-auto-ui/agent');
696
+ // Host injects chatApiBase via data (flex serves under /flex, notebook-viewer
697
+ // at root). Default '/api/chat' kept for hosts mounting at the root.
698
+ const proxyUrl = (state as { chatApiBase?: string }).chatApiBase ?? '/api/chat';
699
+ const provider = new RemoteLLMProvider({ proxyUrl, model: 'haiku' });
700
+ const layer = buildAgentLayerForCell(state, cell, rerender);
701
+ const systemPrompt = buildAgentSystemPromptForCell(state, cell);
702
+ await runAgentLoop(prompt, {
703
+ provider,
704
+ layers: [layer],
705
+ systemPrompt,
706
+ maxIterations: 6,
707
+ signal: aborter.signal,
708
+ callbacks: {
709
+ onToolCall: (call: { name?: string }) => { status.textContent = `· ${call?.name ?? '?'}…`; },
710
+ },
711
+ });
712
+ status.textContent = '✓ done';
713
+ } catch (err) {
714
+ status.textContent = 'error: ' + String((err as { message?: unknown })?.message ?? err);
715
+ } finally {
716
+ input.disabled = false;
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Build the WebMCP tool layer the agent sees. Tools mutate state via the
722
+ * shared helpers (addImportedCells, logHistory) so revert via ⟲ history works
723
+ * exactly like a manual edit.
724
+ */
725
+ function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerender: () => void) {
726
+ const idx = () => state.cells.findIndex((c) => c.id === cell.id);
727
+ const tools = [
728
+ {
729
+ name: 'get_current_cell',
730
+ description: 'Read the cell the user invoked the agent on (id, type, content, varname).',
731
+ inputSchema: { type: 'object', properties: {} },
732
+ execute: async () => ({
733
+ id: cell.id, type: cell.type,
734
+ content: cell.content,
735
+ varname: (cell as { varname?: string }).varname,
736
+ }),
737
+ },
738
+ {
739
+ name: 'list_cells',
740
+ description: 'List all cells in the notebook (id, type, short content preview).',
741
+ inputSchema: { type: 'object', properties: {} },
742
+ execute: async () => state.cells.map((c) => ({
743
+ id: c.id, type: c.type,
744
+ preview: (c.content ?? '').slice(0, 120),
745
+ varname: (c as { varname?: string }).varname,
746
+ })),
747
+ },
748
+ {
749
+ name: 'update_cell',
750
+ description: 'Replace the current cell content with new code/markdown. The cell type is preserved.',
751
+ inputSchema: {
752
+ type: 'object',
753
+ properties: { content: { type: 'string', description: 'New full content for the cell.' } },
754
+ required: ['content'],
755
+ },
756
+ execute: async (args: Record<string, unknown>) => {
757
+ const next = String(args.content ?? '');
758
+ const before = cell.content;
759
+ if (before === next) return { ok: true, unchanged: true };
760
+ // Snapshot prior content so logHistory can support revert.
761
+ cell.content = next;
762
+ cell.status = 'stale';
763
+ state.lastEditAt = Date.now();
764
+ // Manual history entry — reuse `edit` kind, summary mentions agent.
765
+ try {
766
+ (state.history ?? []).push({
767
+ ts: Date.now(), kind: 'edit',
768
+ summary: `agent edited ${cell.type} cell`,
769
+ snapshot: { cellId: cell.id, before, after: next },
770
+ } as never);
771
+ } catch { /* ignore — best-effort */ }
772
+ rerender();
773
+ return { ok: true };
774
+ },
775
+ },
776
+ {
777
+ name: 'insert_cell_after',
778
+ description: 'Insert a new cell directly after the current one. Use type=text for prose, sql for queries, js for scripts/widgets.',
779
+ inputSchema: {
780
+ type: 'object',
781
+ properties: {
782
+ 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).' },
783
+ content: { type: 'string', description: 'Cell content.' },
784
+ varname: { type: 'string', description: 'Optional varname for sql/js cells — exposes the result as a variable to subsequent cells.' },
785
+ },
786
+ required: ['type', 'content'],
787
+ },
788
+ execute: async (args: Record<string, unknown>) => {
789
+ const t = String(args.type ?? '');
790
+ const type: NotebookCell['type'] = t === 'text' ? 'md' : t === 'sql' ? 'sql' : 'js';
791
+ const newCell: NotebookCell = {
792
+ id: uid(), type,
793
+ content: String(args.content ?? ''),
794
+ hideSource: false, hideResult: false, status: 'stale',
795
+ ...(args.varname ? { varname: String(args.varname) } : {}),
796
+ };
797
+ addImportedCells(state, [newCell], idx());
798
+ rerender();
799
+ return { ok: true, id: newCell.id };
800
+ },
801
+ },
802
+ {
803
+ name: 'list_widgets',
804
+ description: 'List widgets exposed by the connected WebMCP servers — pickable in JS cells via widget(name, params).',
805
+ inputSchema: { type: 'object', properties: {} },
806
+ execute: async () => {
807
+ const out: Array<{ server: string; name: string; description?: string }> = [];
808
+ for (const s of state.webmcpServers ?? []) {
809
+ for (const r of s.layer().recipes ?? []) out.push({ server: s.name, name: r.name, description: r.description });
810
+ }
811
+ return out;
812
+ },
813
+ },
814
+ ];
815
+ return { protocol: 'webmcp' as const, serverName: 'notebook-editor', tools, recipes: [] };
816
+ }
817
+
818
+ function buildAgentSystemPromptForCell(_state: NotebookState, cell: NotebookCell): string {
819
+ return [
820
+ 'You are an in-notebook editing assistant. The user invoked you on a specific cell.',
821
+ 'Your job: rewrite that cell, OR insert a follow-up cell after it, based on the user\'s prompt.',
822
+ `The current cell is of type "${cell.type}".`,
823
+ 'Workflow:',
824
+ ' 1. Call get_current_cell to read the cell content + varname.',
825
+ ' 2. If the user wants to TRANSFORM the existing cell (filter, refactor, fix), call update_cell.',
826
+ ' 3. If the user wants a FOLLOW-UP step (chart, summary, follow-up query), call insert_cell_after.',
827
+ ' 4. For widgets: call list_widgets first to discover available names, then write a JS cell with `return widget("<name>", { rows, ...params })`.',
828
+ 'Be terse. One or two tool calls is usually enough. Do not explain at length.',
829
+ ].join('\n');
830
+ }
831
+
449
832
  // ---------------------------------------------------------------------------
450
833
  // Result rendering — editorial flavour (serif headers, mono cells, discreet)
451
834
  // ---------------------------------------------------------------------------
@@ -471,16 +854,29 @@ function formatMs(ms: number): string {
471
854
  return (ms / 1000).toFixed(2) + 's';
472
855
  }
473
856
 
474
- function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null): void {
857
+ function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null, stateRef?: NotebookState): void {
475
858
  const r = effectiveResult(cell, overlay) ?? cell.lastResult;
476
859
  el.innerHTML = '';
477
860
  if (!r) {
478
- el.innerHTML = `<div class="nbe-result-empty">press to run</div>`;
861
+ const isView = stateRef?.mode === 'view';
862
+ const rtStatus = cellRuntimeStatus(cell, overlay);
863
+ // In view mode (autoRun), any unresolved cell is effectively loading —
864
+ // the auto-runner will pick it up shortly. Show a continuous spinner so
865
+ // users don't see a static "—" placeholder during the idle→running gap.
866
+ if (isView && rtStatus !== 'frozen') {
867
+ el.innerHTML = `<div class="nbe-result-running"><span class="nbe-spinner"></span> running</div>`;
868
+ } else if (rtStatus === 'running') {
869
+ el.innerHTML = `<div class="nbe-result-running"><span class="nbe-spinner"></span> running</div>`;
870
+ } else {
871
+ el.innerHTML = `<div class="nbe-result-empty">press ▶ to run</div>`;
872
+ }
479
873
  return;
480
874
  }
481
875
  // Logs panel (shared across all widgets), prepended above the main result
482
- const logsEl = renderCellLogs(r);
483
- if (logsEl) el.appendChild(logsEl);
876
+ if (!cell.hideLogs) {
877
+ const logsEl = renderCellLogs(r);
878
+ if (logsEl) el.appendChild(logsEl);
879
+ }
484
880
  if (!r.ok) {
485
881
  const err = document.createElement('div');
486
882
  err.className = 'nbe-result-error';
@@ -509,6 +905,22 @@ function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeO
509
905
  renderChart(chart, r.spec).catch(() => { /* fallback handled internally */ });
510
906
  return;
511
907
  }
908
+ if (r.kind === 'widget') {
909
+ const fallbackServers = stateRef?.webmcpServers ?? [];
910
+ for (const w of r.widgets) {
911
+ const host = document.createElement('div');
912
+ host.className = 'nb-widget-host';
913
+ el.appendChild(host);
914
+ try { mountWidget(host, w.name, w.params, fallbackServers); }
915
+ catch (err) {
916
+ const pre = document.createElement('pre');
917
+ pre.className = 'nbe-result-error';
918
+ pre.textContent = `widget "${w.name}" failed: ${String((err as { message?: unknown })?.message ?? err)}`;
919
+ host.appendChild(pre);
920
+ }
921
+ }
922
+ return;
923
+ }
512
924
  // table — editorial style: serif header row, mono cells, minimal chrome.
513
925
  const cols = r.columns && r.columns.length ? r.columns
514
926
  : (r.rows[0] ? Object.keys(r.rows[0]) : []);
@@ -692,6 +1104,30 @@ function injectLayoutStyles(): void {
692
1104
  .nbe-code-body { padding: 14px 16px; }
693
1105
  .nbe-hidden { display: none !important; }
694
1106
 
1107
+ /* View-mode Option B: chrome hidden by default, revealed on hover in top-right corner. */
1108
+ .nb-root.nb-view-mode .nbe-code-cell { position: relative; min-height: 28px; }
1109
+ .nb-root.nb-view-mode .nbe-cell-head {
1110
+ position: absolute; top: 0; right: 0; z-index: 2;
1111
+ padding: 4px 8px; gap: 6px;
1112
+ background: var(--color-surface2);
1113
+ border: 1px solid var(--color-border);
1114
+ border-top: none; border-right: none;
1115
+ border-bottom-left-radius: 6px;
1116
+ opacity: 0; pointer-events: none;
1117
+ transition: opacity 120ms ease;
1118
+ font-size: 9.5px;
1119
+ }
1120
+ .nb-root.nb-view-mode .nbe-code-cell:hover .nbe-cell-head,
1121
+ .nb-root.nb-view-mode .nbe-cell-head:focus-within {
1122
+ opacity: 0.95; pointer-events: auto;
1123
+ }
1124
+ .nb-root.nb-view-mode .nbe-cell-head .nbe-meta-info,
1125
+ .nb-root.nb-view-mode .nbe-cell-head .nbe-type-sql,
1126
+ .nb-root.nb-view-mode .nbe-cell-head .nbe-type-js,
1127
+ .nb-root.nb-view-mode .nbe-cell-head .nbe-type-md { display: none; }
1128
+ .nb-root.nb-view-mode .nbe-code-body { padding: 10px 14px; }
1129
+ .nb-root.nb-view-mode .nbe-result { padding: 8px 14px; }
1130
+
695
1131
  .nbe-result {
696
1132
  background: var(--color-bg);
697
1133
  border-top: 1px solid var(--color-border);
@@ -702,6 +1138,10 @@ function injectLayoutStyles(): void {
702
1138
  .nbe-result-empty {
703
1139
  color: var(--color-text2); font-style: italic; font-size: 11.5px;
704
1140
  }
1141
+ .nbe-result-running {
1142
+ display: inline-flex; align-items: center; gap: 6px;
1143
+ color: #2ea043; font-size: 11.5px;
1144
+ }
705
1145
  .nbe-result-error {
706
1146
  color: var(--color-accent2); white-space: pre-wrap; font-size: 12px;
707
1147
  }