@webmcp-auto-ui/ui 2.5.38 → 2.5.40

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webmcp-auto-ui/ui",
3
- "version": "2.5.38",
3
+ "version": "2.5.40",
4
4
  "description": "Svelte 5 UI components — primitives, widgets, window manager",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -26,6 +26,12 @@
26
26
  * written back so subsequent blocks can read them. Owner: the host modal.
27
27
  */
28
28
  scope?: Record<string, unknown>;
29
+ /**
30
+ * Recipe-level widget hint (from frontmatter `widget:`). When set and
31
+ * lang === 'json', the JSON content is treated as widget params instead
32
+ * of being dispatched as a script.
33
+ */
34
+ widget?: string;
29
35
  }
30
36
 
31
37
  let {
@@ -34,6 +40,7 @@
34
40
  actions = undefined,
35
41
  onrun,
36
42
  scope,
43
+ widget,
37
44
  }: Props = $props();
38
45
 
39
46
  let editable = $state('');
@@ -93,7 +100,15 @@
93
100
  startTimer(t0);
94
101
 
95
102
  const multi = canvas.multiClient as McpMultiClient | undefined;
96
- const result = await runCode(editable, lang, multi, scope);
103
+ // For widget recipes (frontmatter `widget:`), JSON fences carry params —
104
+ // wrap as widget_display(...) so runCode renders the widget instead of
105
+ // dispatching the JSON to run_script.
106
+ const isJsonWidgetParams = widget && /^json\b/i.test(lang);
107
+ const runCodeStr = isJsonWidgetParams
108
+ ? `widget_display({name: ${JSON.stringify(widget)}, params: ${editable}})`
109
+ : editable;
110
+ const runLang = isJsonWidgetParams ? 'text' : lang;
111
+ const result = await runCode(runCodeStr, runLang, multi, scope);
97
112
 
98
113
  stopTimer();
99
114
  lastDuration = result.durationMs;
@@ -1,5 +1,6 @@
1
1
  import { canvas } from '@webmcp-auto-ui/sdk/canvas';
2
2
  import { findCodeParamName, buildToolArgs } from '@webmcp-auto-ui/sdk';
3
+ import { stripSqlPreamble } from '../shared.js';
3
4
  import type { CellExecutor, CellExecContext, CellResult, DataServerDescriptor, DataServerTool } from '../shared.js';
4
5
 
5
6
  const PATTERN_PRIMARY = /^.*query_sql$/i;
@@ -24,7 +25,7 @@ export function createSqlExecutor(getServers: () => DataServerDescriptor[]): Cel
24
25
  return { ok: false, error: 'No SQL tool available on connected servers.', errorKind: 'schema', durationMs: Date.now() - startedAt };
25
26
  }
26
27
  const { srv, tool } = hit;
27
- const sql = (ctx.cell.content ?? '').trim();
28
+ const sql = stripSqlPreamble((ctx.cell.content ?? '').trim());
28
29
  if (!sql) return { ok: true, kind: 'empty', durationMs: Date.now() - startedAt };
29
30
 
30
31
  // Build args from the tool's inputSchema:
@@ -14,6 +14,7 @@
14
14
  status?: 'idle' | 'running' | 'done' | 'error' | 'stale';
15
15
  hideSource?: boolean;
16
16
  hideResult?: boolean;
17
+ chatOpen?: boolean;
17
18
  lastResult?: unknown;
18
19
  lastMs?: number;
19
20
  }
@@ -13,7 +13,7 @@ import {
13
13
  renderCellLogs, uid, defaultCellContent,
14
14
  createPublishControls, autoConnectFrontmatterServers,
15
15
  createRuntimeOverlay, effectiveResult, cellRuntimeStatus,
16
- lastRefreshedAt, bootstrapLiveRefresh, fmtRelTime, preserveScrollAround,
16
+ lastRefreshedAt, bootstrapLiveRefresh, fmtRelTime, preserveScrollAround, startRun,
17
17
  type NotebookState, type NotebookCell, type CellResult, type CellExecContext,
18
18
  type RuntimeOverlay,
19
19
  } from './shared.js';
@@ -79,8 +79,11 @@ export async function render(container: HTMLElement, data: Record<string, unknow
79
79
  hidePublishBadge: (data as any).hidePublishBadge === true,
80
80
  } as any);
81
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';
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
+ }
84
87
 
85
88
  // Live mode runtime overlay (created lazily). Never mutates state.
86
89
  let overlay: RuntimeOverlay | null = null;
@@ -282,7 +285,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
282
285
  const viewBtn = shell.querySelector('.nb-mode-view') as HTMLElement;
283
286
  editBtn.addEventListener('click', () => {
284
287
  state.mode = 'edit';
285
- for (const c of state.cells) c.hideSource = false;
288
+ for (const c of state.cells) { c.hideSource = false; c.hideLogs = false; c.chatOpen = false; }
286
289
  container.classList.remove('nb-view-mode');
287
290
  editBtn.classList.add('nb-on'); viewBtn.classList.remove('nb-on');
288
291
  // Leaving view: stop live refresh and clear overlay so frozen snapshots show.
@@ -291,7 +294,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
291
294
  });
292
295
  viewBtn.addEventListener('click', () => {
293
296
  state.mode = 'view';
294
- for (const c of state.cells) c.hideSource = true;
297
+ for (const c of state.cells) { c.hideSource = true; c.hideLogs = true; }
295
298
  container.classList.add('nb-view-mode');
296
299
  viewBtn.classList.add('nb-on'); editBtn.classList.remove('nb-on');
297
300
  if (state.autoRun === true) bootstrapLive();
@@ -336,12 +339,28 @@ export async function render(container: HTMLElement, data: Record<string, unknow
336
339
  rerender();
337
340
  }
338
341
 
342
+ // Real-time elapsed timer for running cells. Updates every 200ms in-place
343
+ // (no full rerender) by reading data-running-since attributes.
344
+ const tickRunningTimers = () => {
345
+ const els = container.querySelectorAll<HTMLElement>('.nbe-running-time[data-running-since]');
346
+ if (els.length === 0) return;
347
+ const now = Date.now();
348
+ els.forEach((el) => {
349
+ const since = Number(el.getAttribute('data-running-since'));
350
+ if (!since) return;
351
+ const ms = now - since;
352
+ el.textContent = ms < 1000 ? `running ${ms}ms` : `running ${(ms / 1000).toFixed(1)}s`;
353
+ });
354
+ };
355
+ const runningTimerHandle = setInterval(tickRunningTimers, 200);
356
+
339
357
  return () => {
340
358
  unsubHistory();
341
359
  canvasUnsub?.();
342
360
  pane.destroy();
343
361
  publishCleanup();
344
362
  liveCleanup?.();
363
+ clearInterval(runningTimerHandle);
345
364
  };
346
365
  }
347
366
 
@@ -447,24 +466,34 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
447
466
  let liveBadge = '';
448
467
  if (showLive) {
449
468
  if (rtStatus === 'running') {
450
- liveBadge = `<span class="nbe-cell-badge nbe-cell-running" title="re-executing"><span class="nbe-spinner"></span>running</span>`;
469
+ const startedAt = overlay?.cellStartedAt.get(cell.id) ?? Date.now();
470
+ liveBadge = `<span class="nbe-cell-badge nbe-cell-running" title="re-executing"><span class="nbe-spinner"></span><span class="nbe-running-time" data-running-since="${startedAt}">running</span></span>`;
451
471
  } else if (rtStatus === 'stale') {
452
472
  liveBadge = `<span class="nbe-cell-badge nbe-cell-stale" title="last live refresh failed">stale</span>`;
453
473
  } else if (rtStatus === 'frozen') {
454
474
  liveBadge = `<span class="nbe-cell-badge nbe-cell-frozen" title="JS cells are not re-executed in live mode">frozen</span>`;
455
475
  }
456
476
  }
477
+ const lastRes = effectiveResult(cell, overlay) ?? cell.lastResult;
478
+ const logCount = (lastRes?.logs as string[] | undefined)?.length ?? 0;
479
+ const logsToggle = logCount > 0
480
+ ? `<button class="nb-icon-btn nb-toggle-logs" title="toggle console">${cell.hideLogs ? '▸' : '▾'} ${logCount}</button>`
481
+ : '';
457
482
  head.innerHTML = `
458
483
  <span class="nbe-run-controls"></span>
459
484
  <span class="nbe-type-${cell.type}">${cell.type}</span>
460
485
  <span class="nbe-meta-info">${escapeHtml(metaInfoFor(cell, overlay))}</span>
461
486
  ${liveBadge}
462
487
  <div class="nbe-actions">
488
+ ${logsToggle}
463
489
  <button class="nb-icon-btn nb-toggle-src">${cell.hideSource ? '▸ src' : '◂ src'}</button>
464
490
  <button class="nb-icon-btn nb-toggle-res">${cell.hideResult ? '▸ res' : '◂ res'}</button>
491
+ ${state.mode === 'view' ? `<span class="nbe-actions-sep">│</span><button class="nb-icon-btn nb-toggle-chat">${cell.chatOpen ? '◂ chat' : '▸ chat'}</button>` : ''}
465
492
  </div>`;
466
493
  codeCell.appendChild(head);
467
494
  mountRunControls(head.querySelector('.nbe-run-controls') as HTMLElement, cell, wrap, state, rerender);
495
+ const logsBtn = head.querySelector('.nb-toggle-logs') as HTMLElement | null;
496
+ if (logsBtn) logsBtn.addEventListener('click', () => { cell.hideLogs = !cell.hideLogs; rerender(); });
468
497
 
469
498
  const body = document.createElement('div');
470
499
  body.className = 'nbe-code-body' + (cell.hideSource ? ' nbe-hidden' : '');
@@ -499,6 +528,13 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
499
528
 
500
529
  (head.querySelector('.nb-toggle-src') as HTMLElement).addEventListener('click', () => { cell.hideSource = !cell.hideSource; rerender(); });
501
530
  (head.querySelector('.nb-toggle-res') as HTMLElement).addEventListener('click', () => { cell.hideResult = !cell.hideResult; rerender(); });
531
+ const chatBtn = head.querySelector('.nb-toggle-chat') as HTMLElement | null;
532
+ if (chatBtn) chatBtn.addEventListener('click', () => { cell.chatOpen = !cell.chatOpen; rerender(); });
533
+
534
+ if (state.mode === 'view' && cell.chatOpen) {
535
+ wrap.classList.add('nb-cell-chat-active');
536
+ mountAgentBarAbove(codeCell, state, cell, rerender, overlay);
537
+ }
502
538
 
503
539
  if (state.mode !== 'view') wrap.appendChild(renderCellActionBar(state, cell, rerender));
504
540
 
@@ -666,27 +702,77 @@ function toggleAgentBar(host: HTMLElement, state: NotebookState, cell: NotebookC
666
702
  });
667
703
  }
668
704
 
705
+ /**
706
+ * View-mode variant — renders the agent bar ABOVE the code cell (beforebegin)
707
+ * and persists open state via cell.chatOpen so the bar survives the rerender
708
+ * triggered by `update_cell`. Reuses runAgentForCell unchanged.
709
+ */
710
+ function mountAgentBarAbove(codeCell: HTMLElement, state: NotebookState, cell: NotebookCell, rerender: () => void, overlay: RuntimeOverlay | null): void {
711
+ const bar = document.createElement('div');
712
+ bar.className = 'nbe-agent-bar';
713
+ bar.innerHTML = `
714
+ <auto-chat-input placeholder="ask agent — e.g. 'filter rows where votes > 50' / 'add a sankey of this'"></auto-chat-input>
715
+ <div class="nbe-agent-progress" hidden>
716
+ <span class="nbe-progress-track"><span class="nbe-progress-fill"></span></span>
717
+ <span class="nbe-progress-label"></span>
718
+ </div>`;
719
+ codeCell.insertAdjacentElement('beforebegin', bar);
720
+ const input = bar.querySelector('auto-chat-input') as HTMLElement & { disabled?: boolean };
721
+ const progress = bar.querySelector('.nbe-agent-progress') as HTMLElement;
722
+ const fill = bar.querySelector('.nbe-progress-fill') as HTMLElement;
723
+ const label = bar.querySelector('.nbe-progress-label') as HTMLElement;
724
+ let aborter: AbortController | null = null;
725
+ input.addEventListener('widget:interact', (e: Event) => {
726
+ const detail = (e as CustomEvent).detail ?? {};
727
+ const action = (detail as { action?: string }).action;
728
+ if (action === 'stop') { e.stopPropagation(); aborter?.abort(); return; }
729
+ if (action !== 'submit') return;
730
+ e.stopPropagation();
731
+ const text = ((detail as { payload?: { text?: string } }).payload?.text ?? '').trim();
732
+ if (!text) return;
733
+ void runAgentForCell(text, state, cell, rerender, progress, fill, label, input, () => aborter ??= new AbortController(), overlay);
734
+ });
735
+ }
736
+
669
737
  async function runAgentForCell(
670
738
  prompt: string,
671
739
  state: NotebookState,
672
740
  cell: NotebookCell,
673
741
  rerender: () => void,
674
- status: HTMLElement,
742
+ progress: HTMLElement,
743
+ fill: HTMLElement,
744
+ label: HTMLElement,
675
745
  input: HTMLElement & { disabled?: boolean },
676
746
  getAborter: () => AbortController,
747
+ overlay: RuntimeOverlay | null,
677
748
  ): Promise<void> {
678
- status.hidden = false;
679
- status.textContent = '';
749
+ progress.hidden = false;
750
+ progress.classList.remove('nbe-progress-error', 'nbe-progress-done');
751
+ let currentTool = '…';
752
+ const startedAt = Date.now();
753
+ // Indeterminate slide: a 3-block fill traverses the 8-block track.
754
+ const TRACK = 8;
755
+ const FILL = 3;
756
+ let pos = 0;
757
+ const tick = () => {
758
+ const cells: string[] = [];
759
+ for (let i = 0; i < TRACK; i++) {
760
+ const inFill = i >= pos && i < pos + FILL;
761
+ cells.push(inFill ? '▰' : '▱');
762
+ }
763
+ fill.textContent = cells.join('');
764
+ label.textContent = `${currentTool} · ${((Date.now() - startedAt) / 1000).toFixed(1)}s`;
765
+ pos = (pos + 1) % (TRACK - FILL + 1 + 2); // small pause at each end
766
+ };
767
+ tick();
768
+ const interval = setInterval(tick, 120);
680
769
  input.disabled = true;
681
770
  const aborter = getAborter();
682
771
  try {
683
- // Lazy import to keep notebook bundle slim when agent is unused.
684
772
  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
773
  const proxyUrl = (state as { chatApiBase?: string }).chatApiBase ?? '/api/chat';
688
774
  const provider = new RemoteLLMProvider({ proxyUrl, model: 'haiku' });
689
- const layer = buildAgentLayerForCell(state, cell, rerender);
775
+ const layer = buildAgentLayerForCell(state, cell, rerender, overlay);
690
776
  const systemPrompt = buildAgentSystemPromptForCell(state, cell);
691
777
  await runAgentLoop(prompt, {
692
778
  provider,
@@ -695,12 +781,20 @@ async function runAgentForCell(
695
781
  maxIterations: 6,
696
782
  signal: aborter.signal,
697
783
  callbacks: {
698
- onToolCall: (call: { name?: string }) => { status.textContent = `· ${call?.name ?? '?'}…`; },
784
+ onToolCall: (call: { name?: string }) => { currentTool = call?.name ?? '?'; },
699
785
  },
700
786
  });
701
- status.textContent = '✓ done';
787
+ clearInterval(interval);
788
+ progress.classList.add('nbe-progress-done');
789
+ fill.textContent = '▰▰▰▰▰▰▰▰';
790
+ label.textContent = `done · ${((Date.now() - startedAt) / 1000).toFixed(1)}s`;
791
+ // Auto-close happens earlier inside update_cell.execute (avoids the lag of
792
+ // Haiku's trailing assistant turn). Nothing to do here.
702
793
  } catch (err) {
703
- status.textContent = 'error: ' + String((err as { message?: unknown })?.message ?? err);
794
+ clearInterval(interval);
795
+ progress.classList.add('nbe-progress-error');
796
+ fill.textContent = '▱▱▱▱▱▱▱▱';
797
+ label.textContent = 'error: ' + String((err as { message?: unknown })?.message ?? err);
704
798
  } finally {
705
799
  input.disabled = false;
706
800
  }
@@ -711,8 +805,12 @@ async function runAgentForCell(
711
805
  * shared helpers (addImportedCells, logHistory) so revert via ⟲ history works
712
806
  * exactly like a manual edit.
713
807
  */
714
- function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerender: () => void) {
808
+ function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerender: () => void, overlay: RuntimeOverlay | null) {
715
809
  const idx = () => state.cells.findIndex((c) => c.id === cell.id);
810
+ // View-mode chat is per-cell and meant to TRANSFORM the targeted cell.
811
+ // Exception: when the cell is `sql`, the natural follow-up is "add a chart on
812
+ // top of these rows", so we keep insert_cell_after + list_widgets available.
813
+ const transformOnly = state.mode === 'view' && cell.type !== 'sql';
716
814
  const tools = [
717
815
  {
718
816
  name: 'get_current_cell',
@@ -758,7 +856,20 @@ function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerend
758
856
  snapshot: { cellId: cell.id, before, after: next },
759
857
  } as never);
760
858
  } catch { /* ignore — best-effort */ }
859
+ // Drop the live-overlay cache for this cell. effectiveResult prefers
860
+ // overlay.outputs over cell.lastResult, so without this the pre-edit
861
+ // render lingers until the next bootstrapLive (= view-edit-view).
862
+ overlay?.outputs.delete(cell.id);
863
+ overlay?.status.delete(cell.id);
864
+ // Close the chat bar immediately on success in view mode — waiting for
865
+ // runAgentLoop to fully resolve adds a 1-2s lag from Haiku's trailing
866
+ // assistant turn ("Done!") after the tool result.
867
+ if (state.mode === 'view') cell.chatOpen = false;
761
868
  rerender();
869
+ // Re-run so the result panel reflects the new content.
870
+ if (cell.type !== 'md' && state.executors?.[cell.type]) {
871
+ startRun(cell, state, rerender);
872
+ }
762
873
  return { ok: true };
763
874
  },
764
875
  },
@@ -790,21 +901,65 @@ function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerend
790
901
  },
791
902
  {
792
903
  name: 'list_widgets',
793
- description: 'List widgets exposed by the connected WebMCP servers pickable in JS cells via widget(name, params).',
904
+ description: 'List widgets exposed by the connected WebMCP servers, with one usage example each. Pickable in JS cells via widget(name, params).',
794
905
  inputSchema: { type: 'object', properties: {} },
795
906
  execute: async () => {
796
- const out: Array<{ server: string; name: string; description?: string }> = [];
907
+ const out: Array<{ server: string; name: string; description?: string; example?: string }> = [];
797
908
  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 });
909
+ for (const r of s.layer().recipes ?? []) {
910
+ const body = (r as { body?: string }).body ?? '';
911
+ // First fenced block in the recipe body is the canonical example,
912
+ // showing the exact param keys the renderer expects.
913
+ const m = /```[a-zA-Z]*\n([\s\S]*?)\n```/.exec(body);
914
+ out.push({
915
+ server: s.name,
916
+ name: r.name,
917
+ description: r.description,
918
+ ...(m ? { example: m[1] } : {}),
919
+ });
920
+ }
799
921
  }
800
922
  return out;
801
923
  },
802
924
  },
803
925
  ];
804
- return { protocol: 'webmcp' as const, serverName: 'notebook-editor', tools, recipes: [] };
926
+ // list_widgets is read-only and essential without it, the agent hallucinates
927
+ // widget names. Only insert_cell_after is dropped in transformOnly.
928
+ const filtered = transformOnly
929
+ ? tools.filter((t) => t.name !== 'insert_cell_after')
930
+ : tools;
931
+ return { protocol: 'webmcp' as const, serverName: 'notebook-editor', tools: filtered, recipes: [] };
805
932
  }
806
933
 
807
- function buildAgentSystemPromptForCell(_state: NotebookState, cell: NotebookCell): string {
934
+ function buildAgentSystemPromptForCell(state: NotebookState, cell: NotebookCell): string {
935
+ if (state.mode === 'view') {
936
+ if (cell.type === 'sql') {
937
+ return [
938
+ 'You are an in-notebook editing assistant. The user invoked you on a SQL cell.',
939
+ 'Two valid actions:',
940
+ ' - TRANSFORM: rewrite this SQL cell. Call get_current_cell, then update_cell.',
941
+ ' - FOLLOW-UP (chart/viz/widget): insert a JS cell after this one that renders a widget on top of the rows.',
942
+ 'For a follow-up widget:',
943
+ ' 1. Call get_current_cell to read this cell\'s `varname` (the rows of this SQL are bound to that name in cross-cell scope).',
944
+ ' 2. Call list_widgets to discover the exact widget name + the example showing the required param keys. NEVER invent a widget name.',
945
+ ' 3. Call insert_cell_after with type="js" and content like: `return widget("<name>", { ...params per the example, with rows substituted for <varname> });`',
946
+ 'For JS cells: use the `widget(name, params)` helper. `widget_display` is not available as a global.',
947
+ 'Never write a JS cell that re-issues the SQL — reuse the varname from cross-cell scope.',
948
+ 'Be terse. Two or three tool calls is enough. Do not explain at length.',
949
+ ].join('\n');
950
+ }
951
+ return [
952
+ 'You are an in-notebook editing assistant. The user invoked you on a specific cell to TRANSFORM it.',
953
+ `The current cell is of type "${cell.type}".`,
954
+ 'Workflow:',
955
+ ' 1. Call get_current_cell to read the cell content.',
956
+ ' 2. If the user asks for a chart/widget/viz, call list_widgets to learn the exact widget names + the example showing the required param keys. NEVER invent a widget name.',
957
+ ' 3. Call update_cell with the rewritten content.',
958
+ 'Always rewrite the existing cell. Never insert new cells.',
959
+ 'For JS cells: use the `widget(name, params)` helper. `widget_display` is not available as a global. Call widget once with the final result; do NOT also call widget_display after.',
960
+ 'Be terse. Two tool calls is usually enough. Do not explain at length.',
961
+ ].join('\n');
962
+ }
808
963
  return [
809
964
  'You are an in-notebook editing assistant. The user invoked you on a specific cell.',
810
965
  'Your job: rewrite that cell, OR insert a follow-up cell after it, based on the user\'s prompt.',
@@ -847,12 +1002,25 @@ function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeO
847
1002
  const r = effectiveResult(cell, overlay) ?? cell.lastResult;
848
1003
  el.innerHTML = '';
849
1004
  if (!r) {
850
- el.innerHTML = `<div class="nbe-result-empty">press to run</div>`;
1005
+ const isView = stateRef?.mode === 'view';
1006
+ const rtStatus = cellRuntimeStatus(cell, overlay);
1007
+ const startedAt = overlay?.cellStartedAt.get(cell.id);
1008
+ if (rtStatus === 'running' && startedAt != null) {
1009
+ el.innerHTML = `<div class="nbe-result-running"><span class="nbe-spinner"></span> <span class="nbe-running-time" data-running-since="${startedAt}">running</span></div>`;
1010
+ } else if (isView && (rtStatus === 'pending' || rtStatus === 'idle')) {
1011
+ // Queued: cell will run sequentially after upstream cells finish.
1012
+ // Spinner without timer — there's nothing to count yet.
1013
+ el.innerHTML = `<div class="nbe-result-running"><span class="nbe-spinner"></span> queued</div>`;
1014
+ } else {
1015
+ el.innerHTML = `<div class="nbe-result-empty">press ▶ to run</div>`;
1016
+ }
851
1017
  return;
852
1018
  }
853
1019
  // Logs panel (shared across all widgets), prepended above the main result
854
- const logsEl = renderCellLogs(r);
855
- if (logsEl) el.appendChild(logsEl);
1020
+ if (!cell.hideLogs) {
1021
+ const logsEl = renderCellLogs(r);
1022
+ if (logsEl) el.appendChild(logsEl);
1023
+ }
856
1024
  if (!r.ok) {
857
1025
  const err = document.createElement('div');
858
1026
  err.className = 'nbe-result-error';
@@ -1080,6 +1248,30 @@ function injectLayoutStyles(): void {
1080
1248
  .nbe-code-body { padding: 14px 16px; }
1081
1249
  .nbe-hidden { display: none !important; }
1082
1250
 
1251
+ /* View-mode Option B: chrome hidden by default, revealed on hover in top-right corner. */
1252
+ .nb-root.nb-view-mode .nbe-code-cell { position: relative; min-height: 28px; }
1253
+ .nb-root.nb-view-mode .nbe-cell-head {
1254
+ position: absolute; top: 0; right: 0; z-index: 2;
1255
+ padding: 4px 8px; gap: 6px;
1256
+ background: var(--color-surface2);
1257
+ border: 1px solid var(--color-border);
1258
+ border-top: none; border-right: none;
1259
+ border-bottom-left-radius: 6px;
1260
+ opacity: 0; pointer-events: none;
1261
+ transition: opacity 120ms ease;
1262
+ font-size: 9.5px;
1263
+ }
1264
+ .nb-root.nb-view-mode .nbe-code-cell:hover .nbe-cell-head,
1265
+ .nb-root.nb-view-mode .nbe-cell-head:focus-within {
1266
+ opacity: 0.95; pointer-events: auto;
1267
+ }
1268
+ .nb-root.nb-view-mode .nbe-cell-head .nbe-meta-info,
1269
+ .nb-root.nb-view-mode .nbe-cell-head .nbe-type-sql,
1270
+ .nb-root.nb-view-mode .nbe-cell-head .nbe-type-js,
1271
+ .nb-root.nb-view-mode .nbe-cell-head .nbe-type-md { display: none; }
1272
+ .nb-root.nb-view-mode .nbe-code-body { padding: 10px 14px; }
1273
+ .nb-root.nb-view-mode .nbe-result { padding: 8px 14px; }
1274
+
1083
1275
  .nbe-result {
1084
1276
  background: var(--color-bg);
1085
1277
  border-top: 1px solid var(--color-border);
@@ -1090,6 +1282,10 @@ function injectLayoutStyles(): void {
1090
1282
  .nbe-result-empty {
1091
1283
  color: var(--color-text2); font-style: italic; font-size: 11.5px;
1092
1284
  }
1285
+ .nbe-result-running {
1286
+ display: inline-flex; align-items: center; gap: 6px;
1287
+ color: #2ea043; font-size: 11.5px;
1288
+ }
1093
1289
  .nbe-result-error {
1094
1290
  color: var(--color-accent2); white-space: pre-wrap; font-size: 12px;
1095
1291
  }
@@ -4,7 +4,7 @@
4
4
  // 4 formats: JSON, Markdown, Hyperskill link (+ short), PNG snapshot.
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
- import { encode, buildShortUrl } from '@webmcp-auto-ui/sdk';
7
+ import { encode, buildShortUrl, pickFence } from '@webmcp-auto-ui/sdk';
8
8
  import { canvasVanilla } from '@webmcp-auto-ui/sdk/canvas-vanilla';
9
9
  import type { NotebookState, NotebookCell } from './shared.js';
10
10
 
@@ -56,7 +56,9 @@ export function serializeToMarkdown(state: NotebookState): string {
56
56
  const metaLine = cell.args && Object.keys(cell.args).length > 0
57
57
  ? `${commentPrefix} @meta ${JSON.stringify(cell.args)}\n`
58
58
  : '';
59
- parts.push('```' + lang + varname, metaLine + cell.content.trim(), '```', '');
59
+ const body = metaLine + cell.content.trim();
60
+ const fence = pickFence(body);
61
+ parts.push(fence + lang + varname, body, fence, '');
60
62
  }
61
63
  }
62
64
  return parts.join('\n').trim() + '\n';
@@ -5,6 +5,7 @@
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
7
  import { serializeToMarkdown } from './share-handlers.js';
8
+ import { extractTopLevelDecls, hasIdentifierReference, findCodeParamName, buildToolArgs } from '@webmcp-auto-ui/sdk';
8
9
 
9
10
  export const NB_PUBLISH_HOST: string = (() => {
10
11
  try {
@@ -47,6 +48,10 @@ export interface NotebookCell {
47
48
  varname?: string; // named output (compact)
48
49
  hideSource?: boolean;
49
50
  hideResult?: boolean;
51
+ /** view-mode: agent chat bar opened above this cell (persists across rerender). */
52
+ chatOpen?: boolean;
53
+ /** view-mode: collapse the console logs panel under the head bar. Default true in view mode. */
54
+ hideLogs?: boolean;
50
55
  runState?: RunState;
51
56
  lastMs?: number;
52
57
  status?: 'fresh' | 'stale';
@@ -95,6 +100,25 @@ export interface HistoryEntry {
95
100
  // Utilities
96
101
  // ---------------------------------------------------------------------------
97
102
 
103
+ /**
104
+ * Strip leading SQL preamble — line comments and block comments,
105
+ * BOM, and whitespace — so the first non-trivial token is the real keyword.
106
+ * Some upstream MCP SQL servers (Tricoteuses) enforce a strict
107
+ * `^(SELECT|WITH)` regex on the raw string; a comment header would fail.
108
+ */
109
+ export function stripSqlPreamble(sql: string): string {
110
+ let s = sql.replace(/^/, '');
111
+ // Repeat: there can be a mix of line + block comments + blank lines.
112
+ for (;;) {
113
+ const before = s;
114
+ s = s.replace(/^[\s]+/, '');
115
+ s = s.replace(/^--[^\n]*\n?/, '');
116
+ s = s.replace(/^\/\*[\s\S]*?\*\//, '');
117
+ if (s === before) break;
118
+ }
119
+ return s;
120
+ }
121
+
98
122
  export function uid(): string {
99
123
  return 'c_' + Math.random().toString(36).slice(2, 9);
100
124
  }
@@ -245,6 +269,8 @@ export interface RuntimeOverlay {
245
269
  outputs: Map<string, { result: CellResult; refreshedAt: number }>;
246
270
  /** Per-cell status during/after the refresh cycle. */
247
271
  status: Map<string, CellRuntimeStatus>;
272
+ /** Per-cell start timestamp — set when status flips to 'running', cleared on completion. */
273
+ cellStartedAt: Map<string, number>;
248
274
  startedAt: number | null;
249
275
  finishedAt: number | null;
250
276
  /** Last fatal reason (e.g. "no reachable server"). */
@@ -255,6 +281,7 @@ export function createRuntimeOverlay(): RuntimeOverlay {
255
281
  return {
256
282
  outputs: new Map(),
257
283
  status: new Map(),
284
+ cellStartedAt: new Map(),
258
285
  startedAt: null,
259
286
  finishedAt: null,
260
287
  error: null,
@@ -324,34 +351,77 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
324
351
  const summary: AutoRefreshSummary = { rerun: 0, frozen: 0, stale: 0, failed: 0 };
325
352
  const sharedSignal = signal ?? new AbortController().signal;
326
353
 
327
- const tasks = state.cells.map(async (cell) => {
328
- if (sharedSignal.aborted) return;
329
- if (!isReRunnable(cell)) { summary.frozen++; return; }
354
+ // Build dependency graph: a JS cell depends on the most recent prior cell
355
+ // that declares a top-level identifier referenced in this cell's body.
356
+ // Cells with no inter-cell deps run in parallel; dependent cells await
357
+ // their providers via a Promise map (no batching, no sequential bottleneck).
358
+ const declToProvider = new Map<string, string>(); // identifier → cell id of latest declarer
359
+ const cellDeps = new Map<string, string[]>(); // cell id → [provider cell ids]
360
+ for (const cell of state.cells) {
361
+ if (!isReRunnable(cell)) continue;
362
+ const deps = new Set<string>();
363
+ if (cell.type === 'js') {
364
+ for (const [name, providerId] of declToProvider) {
365
+ if (providerId === cell.id) continue;
366
+ if (hasIdentifierReference(cell.content, name)) deps.add(providerId);
367
+ }
368
+ }
369
+ cellDeps.set(cell.id, [...deps]);
370
+ // Register this cell's provided names AFTER computing its deps so a cell
371
+ // doesn't depend on itself when it both reads and re-declares a name.
372
+ if (cell.type === 'js') {
373
+ for (const decl of extractTopLevelDecls(cell.content)) {
374
+ declToProvider.set(decl, cell.id);
375
+ }
376
+ } else if (cell.type === 'sql' && cell.varname) {
377
+ declToProvider.set(cell.varname, cell.id);
378
+ }
379
+ }
380
+
381
+ const cellPromises = new Map<string, Promise<void>>();
382
+ for (const cell of state.cells) {
383
+ cellPromises.set(cell.id, (async () => {
384
+ if (sharedSignal.aborted) return;
385
+ if (!isReRunnable(cell)) { summary.frozen++; return; }
330
386
 
331
- overlay.status.set(cell.id, 'running');
332
- onCellChange?.(cell.id);
333
- onTick?.(overlay);
387
+ // Wait for dependencies before starting.
388
+ const deps = cellDeps.get(cell.id) ?? [];
389
+ if (deps.length > 0) {
390
+ await Promise.all(deps.map((d) => cellPromises.get(d)).filter(Boolean) as Promise<void>[]);
391
+ }
392
+ if (sharedSignal.aborted) return;
334
393
 
335
- try {
336
- const result = await runner(cell, sharedSignal);
337
- if (result.ok) {
394
+ overlay.status.set(cell.id, 'running');
395
+ overlay.cellStartedAt.set(cell.id, Date.now());
396
+ onCellChange?.(cell.id);
397
+ onTick?.(overlay);
398
+
399
+ try {
400
+ const result = await runner(cell, sharedSignal);
338
401
  overlay.outputs.set(cell.id, { result, refreshedAt: Date.now() });
339
- overlay.status.set(cell.id, 'fresh');
340
- summary.rerun++;
341
- } else {
402
+ if (result.ok) {
403
+ overlay.status.set(cell.id, 'fresh');
404
+ summary.rerun++;
405
+ } else {
406
+ overlay.status.set(cell.id, 'stale');
407
+ summary.stale++;
408
+ }
409
+ } catch (err) {
410
+ const message = err instanceof Error ? err.message : String(err);
411
+ overlay.outputs.set(cell.id, {
412
+ result: { ok: false, error: message, errorKind: 'runtime', durationMs: 0 },
413
+ refreshedAt: Date.now(),
414
+ });
342
415
  overlay.status.set(cell.id, 'stale');
343
- summary.stale++;
416
+ summary.failed++;
417
+ if (!overlay.error) overlay.error = message;
344
418
  }
345
- } catch (err) {
346
- overlay.status.set(cell.id, 'stale');
347
- summary.failed++;
348
- if (!overlay.error) overlay.error = err instanceof Error ? err.message : String(err);
349
- }
350
- onCellChange?.(cell.id);
351
- onTick?.(overlay);
352
- });
353
-
354
- await Promise.all(tasks);
419
+ overlay.cellStartedAt.delete(cell.id);
420
+ onCellChange?.(cell.id);
421
+ onTick?.(overlay);
422
+ })());
423
+ }
424
+ await Promise.all([...cellPromises.values()]);
355
425
 
356
426
  overlay.finishedAt = Date.now();
357
427
  onTick?.(overlay);
@@ -381,15 +451,15 @@ export function createBridgeSqlRunner(
381
451
  const PATTERN_PRIMARY = /^.*query_sql$/i;
382
452
  const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
383
453
 
384
- function findSqlTool(servers: DataServerDescriptor[]): { serverName: string; toolName: string } | null {
454
+ function findSqlTool(servers: DataServerDescriptor[]): { serverName: string; toolName: string; tool: DataServerTool } | null {
385
455
  for (const srv of servers) {
386
456
  for (const t of srv.tools ?? []) {
387
- if (PATTERN_PRIMARY.test(t.name)) return { serverName: srv.name, toolName: t.name };
457
+ if (PATTERN_PRIMARY.test(t.name)) return { serverName: srv.name, toolName: t.name, tool: t };
388
458
  }
389
459
  }
390
460
  for (const srv of servers) {
391
461
  for (const t of srv.tools ?? []) {
392
- if (PATTERN_FALLBACK.test(t.name)) return { serverName: srv.name, toolName: t.name };
462
+ if (PATTERN_FALLBACK.test(t.name)) return { serverName: srv.name, toolName: t.name, tool: t };
393
463
  }
394
464
  }
395
465
  return null;
@@ -437,7 +507,11 @@ export function createBridgeSqlRunner(
437
507
  if (!hit) {
438
508
  return { ok: false, error: 'No SQL tool exposed by reachable servers', errorKind: 'schema', durationMs: 0 };
439
509
  }
440
- const raw = await callTool(hit.serverName, hit.toolName, { sql: cell.content });
510
+ const sql = stripSqlPreamble((cell.content ?? '').trim());
511
+ const codeParam = findCodeParamName(hit.tool.inputSchema) ?? 'sql';
512
+ const args: Record<string, unknown> = { ...buildToolArgs(hit.tool.inputSchema, codeParam, sql, 'sql'), ...(cell.args ?? {}) };
513
+ args[codeParam] = sql;
514
+ const raw = await callTool(hit.serverName, hit.toolName, args);
441
515
  return parseResult(raw, startedAt);
442
516
  };
443
517
  }
@@ -464,12 +538,21 @@ interface CanvasLike {
464
538
  callTool: (name: string, toolName: string, args: unknown) => Promise<unknown>;
465
539
  }
466
540
 
467
- async function waitForEnabledServers(canvas: CanvasLike, timeoutMs: number): Promise<void> {
541
+ async function waitForEnabledServers(
542
+ canvas: CanvasLike,
543
+ timeoutMs: number,
544
+ expectedCount: number,
545
+ ): Promise<void> {
546
+ // expectedCount = number of servers declared by the notebook frontmatter.
547
+ // Without this, an empty canvas.dataServers (autoConnect lost a race against
548
+ // canvas.__canvasVanilla being set, or a notify loop hasn't fired yet)
549
+ // would return immediately and the runner would execute against zero servers.
468
550
  const deadline = Date.now() + Math.max(0, timeoutMs);
469
551
  while (Date.now() < deadline) {
470
552
  const enabled = (canvas.dataServers ?? []).filter((s) => s.enabled !== false);
471
- if (enabled.length === 0) return;
472
- if (enabled.every((s) => s.connected)) return;
553
+ // Notebook declared no servers — nothing to wait for.
554
+ if (expectedCount === 0 && enabled.length === 0) return;
555
+ if (enabled.length >= expectedCount && enabled.every((s) => s.connected)) return;
473
556
  await new Promise((r) => setTimeout(r, 100));
474
557
  }
475
558
  }
@@ -489,7 +572,10 @@ export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => v
489
572
  onTick?.(overlay);
490
573
  return;
491
574
  }
492
- await waitForEnabledServers(canvas, timeoutMs ?? 5000);
575
+ const declaredServers = Array.isArray((data as { servers?: unknown }).servers)
576
+ ? (data as { servers: unknown[] }).servers.length
577
+ : 0;
578
+ await waitForEnabledServers(canvas, timeoutMs ?? 10_000, declaredServers);
493
579
 
494
580
  const sqlRunner = createBridgeSqlRunner(
495
581
  canvas.callTool.bind(canvas),
@@ -1248,8 +1334,6 @@ const NOTEBOOK_STYLES = `
1248
1334
 
1249
1335
  .nb-root.nb-view-mode .nb-drag-handle,
1250
1336
  .nb-root.nb-view-mode .nb-icon-btn.nb-danger,
1251
- .nb-root.nb-view-mode .nb-toggle-src,
1252
- .nb-root.nb-view-mode .nb-toggle-res,
1253
1337
  .nb-root.nb-view-mode .nb-add-cell,
1254
1338
  .nb-root.nb-view-mode .nbe-cell-actionbar { display: none !important; }
1255
1339
 
@@ -1318,11 +1402,23 @@ const NOTEBOOK_STYLES = `
1318
1402
  display: flex; flex-direction: column; gap: 6px;
1319
1403
  }
1320
1404
  .nbe-agent-bar auto-chat-input { display: block; }
1405
+ .nbe-actions-sep { color: var(--color-border, #c8c2b4); margin: 0 4px; user-select: none; }
1406
+ .nb-cell-chat-active { outline: 2px solid var(--color-accent, #1d6f5f); outline-offset: 4px; border-radius: 6px; }
1321
1407
  .nbe-agent-status {
1322
1408
  font-family: var(--font-mono, monospace); font-size: 11px;
1323
1409
  color: var(--color-text2, #645d4f);
1324
1410
  padding: 0 4px;
1325
1411
  }
1412
+ .nbe-agent-progress {
1413
+ display: flex; align-items: center; gap: 10px;
1414
+ font-family: var(--font-mono, monospace); font-size: 11px;
1415
+ color: var(--color-text2, #645d4f);
1416
+ padding: 0 4px;
1417
+ }
1418
+ .nbe-progress-track { letter-spacing: 1px; color: var(--color-accent, #1d6f5f); }
1419
+ .nbe-progress-fill { display: inline-block; }
1420
+ .nbe-agent-progress.nbe-progress-done .nbe-progress-track { color: var(--color-accent, #1d6f5f); }
1421
+ .nbe-agent-progress.nbe-progress-error .nbe-progress-track { color: #b54141; }
1326
1422
  /* Run controls (.nb-ctl-pill) remain active in view mode — users can execute cells
1327
1423
  * even when the notebook is read-only; only editing the source is locked. */
1328
1424
  .nb-root.nb-view-mode textarea,