@webmcp-auto-ui/ui 2.5.39 → 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.39",
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';
@@ -285,7 +285,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
285
285
  const viewBtn = shell.querySelector('.nb-mode-view') as HTMLElement;
286
286
  editBtn.addEventListener('click', () => {
287
287
  state.mode = 'edit';
288
- for (const c of state.cells) { c.hideSource = false; c.hideLogs = false; }
288
+ for (const c of state.cells) { c.hideSource = false; c.hideLogs = false; c.chatOpen = false; }
289
289
  container.classList.remove('nb-view-mode');
290
290
  editBtn.classList.add('nb-on'); viewBtn.classList.remove('nb-on');
291
291
  // Leaving view: stop live refresh and clear overlay so frozen snapshots show.
@@ -339,12 +339,28 @@ export async function render(container: HTMLElement, data: Record<string, unknow
339
339
  rerender();
340
340
  }
341
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
+
342
357
  return () => {
343
358
  unsubHistory();
344
359
  canvasUnsub?.();
345
360
  pane.destroy();
346
361
  publishCleanup();
347
362
  liveCleanup?.();
363
+ clearInterval(runningTimerHandle);
348
364
  };
349
365
  }
350
366
 
@@ -450,7 +466,8 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
450
466
  let liveBadge = '';
451
467
  if (showLive) {
452
468
  if (rtStatus === 'running') {
453
- 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>`;
454
471
  } else if (rtStatus === 'stale') {
455
472
  liveBadge = `<span class="nbe-cell-badge nbe-cell-stale" title="last live refresh failed">stale</span>`;
456
473
  } else if (rtStatus === 'frozen') {
@@ -471,6 +488,7 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
471
488
  ${logsToggle}
472
489
  <button class="nb-icon-btn nb-toggle-src">${cell.hideSource ? '▸ src' : '◂ src'}</button>
473
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>` : ''}
474
492
  </div>`;
475
493
  codeCell.appendChild(head);
476
494
  mountRunControls(head.querySelector('.nbe-run-controls') as HTMLElement, cell, wrap, state, rerender);
@@ -510,6 +528,13 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
510
528
 
511
529
  (head.querySelector('.nb-toggle-src') as HTMLElement).addEventListener('click', () => { cell.hideSource = !cell.hideSource; rerender(); });
512
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
+ }
513
538
 
514
539
  if (state.mode !== 'view') wrap.appendChild(renderCellActionBar(state, cell, rerender));
515
540
 
@@ -677,27 +702,77 @@ function toggleAgentBar(host: HTMLElement, state: NotebookState, cell: NotebookC
677
702
  });
678
703
  }
679
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
+
680
737
  async function runAgentForCell(
681
738
  prompt: string,
682
739
  state: NotebookState,
683
740
  cell: NotebookCell,
684
741
  rerender: () => void,
685
- status: HTMLElement,
742
+ progress: HTMLElement,
743
+ fill: HTMLElement,
744
+ label: HTMLElement,
686
745
  input: HTMLElement & { disabled?: boolean },
687
746
  getAborter: () => AbortController,
747
+ overlay: RuntimeOverlay | null,
688
748
  ): Promise<void> {
689
- status.hidden = false;
690
- 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);
691
769
  input.disabled = true;
692
770
  const aborter = getAborter();
693
771
  try {
694
- // Lazy import to keep notebook bundle slim when agent is unused.
695
772
  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
773
  const proxyUrl = (state as { chatApiBase?: string }).chatApiBase ?? '/api/chat';
699
774
  const provider = new RemoteLLMProvider({ proxyUrl, model: 'haiku' });
700
- const layer = buildAgentLayerForCell(state, cell, rerender);
775
+ const layer = buildAgentLayerForCell(state, cell, rerender, overlay);
701
776
  const systemPrompt = buildAgentSystemPromptForCell(state, cell);
702
777
  await runAgentLoop(prompt, {
703
778
  provider,
@@ -706,12 +781,20 @@ async function runAgentForCell(
706
781
  maxIterations: 6,
707
782
  signal: aborter.signal,
708
783
  callbacks: {
709
- onToolCall: (call: { name?: string }) => { status.textContent = `· ${call?.name ?? '?'}…`; },
784
+ onToolCall: (call: { name?: string }) => { currentTool = call?.name ?? '?'; },
710
785
  },
711
786
  });
712
- 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.
713
793
  } catch (err) {
714
- 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);
715
798
  } finally {
716
799
  input.disabled = false;
717
800
  }
@@ -722,8 +805,12 @@ async function runAgentForCell(
722
805
  * shared helpers (addImportedCells, logHistory) so revert via ⟲ history works
723
806
  * exactly like a manual edit.
724
807
  */
725
- function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerender: () => void) {
808
+ function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerender: () => void, overlay: RuntimeOverlay | null) {
726
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';
727
814
  const tools = [
728
815
  {
729
816
  name: 'get_current_cell',
@@ -769,7 +856,20 @@ function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerend
769
856
  snapshot: { cellId: cell.id, before, after: next },
770
857
  } as never);
771
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;
772
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
+ }
773
873
  return { ok: true };
774
874
  },
775
875
  },
@@ -801,21 +901,65 @@ function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerend
801
901
  },
802
902
  {
803
903
  name: 'list_widgets',
804
- 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).',
805
905
  inputSchema: { type: 'object', properties: {} },
806
906
  execute: async () => {
807
- const out: Array<{ server: string; name: string; description?: string }> = [];
907
+ const out: Array<{ server: string; name: string; description?: string; example?: string }> = [];
808
908
  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 });
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
+ }
810
921
  }
811
922
  return out;
812
923
  },
813
924
  },
814
925
  ];
815
- 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: [] };
816
932
  }
817
933
 
818
- 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
+ }
819
963
  return [
820
964
  'You are an in-notebook editing assistant. The user invoked you on a specific cell.',
821
965
  'Your job: rewrite that cell, OR insert a follow-up cell after it, based on the user\'s prompt.',
@@ -860,13 +1004,13 @@ function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeO
860
1004
  if (!r) {
861
1005
  const isView = stateRef?.mode === 'view';
862
1006
  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>`;
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>`;
870
1014
  } else {
871
1015
  el.innerHTML = `<div class="nbe-result-empty">press ▶ to run</div>`;
872
1016
  }
@@ -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,8 @@ 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;
50
53
  /** view-mode: collapse the console logs panel under the head bar. Default true in view mode. */
51
54
  hideLogs?: boolean;
52
55
  runState?: RunState;
@@ -97,6 +100,25 @@ export interface HistoryEntry {
97
100
  // Utilities
98
101
  // ---------------------------------------------------------------------------
99
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
+
100
122
  export function uid(): string {
101
123
  return 'c_' + Math.random().toString(36).slice(2, 9);
102
124
  }
@@ -247,6 +269,8 @@ export interface RuntimeOverlay {
247
269
  outputs: Map<string, { result: CellResult; refreshedAt: number }>;
248
270
  /** Per-cell status during/after the refresh cycle. */
249
271
  status: Map<string, CellRuntimeStatus>;
272
+ /** Per-cell start timestamp — set when status flips to 'running', cleared on completion. */
273
+ cellStartedAt: Map<string, number>;
250
274
  startedAt: number | null;
251
275
  finishedAt: number | null;
252
276
  /** Last fatal reason (e.g. "no reachable server"). */
@@ -257,6 +281,7 @@ export function createRuntimeOverlay(): RuntimeOverlay {
257
281
  return {
258
282
  outputs: new Map(),
259
283
  status: new Map(),
284
+ cellStartedAt: new Map(),
260
285
  startedAt: null,
261
286
  finishedAt: null,
262
287
  error: null,
@@ -326,34 +351,77 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
326
351
  const summary: AutoRefreshSummary = { rerun: 0, frozen: 0, stale: 0, failed: 0 };
327
352
  const sharedSignal = signal ?? new AbortController().signal;
328
353
 
329
- const tasks = state.cells.map(async (cell) => {
330
- if (sharedSignal.aborted) return;
331
- 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; }
332
386
 
333
- overlay.status.set(cell.id, 'running');
334
- onCellChange?.(cell.id);
335
- 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;
336
393
 
337
- try {
338
- const result = await runner(cell, sharedSignal);
339
- 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);
340
401
  overlay.outputs.set(cell.id, { result, refreshedAt: Date.now() });
341
- overlay.status.set(cell.id, 'fresh');
342
- summary.rerun++;
343
- } 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
+ });
344
415
  overlay.status.set(cell.id, 'stale');
345
- summary.stale++;
416
+ summary.failed++;
417
+ if (!overlay.error) overlay.error = message;
346
418
  }
347
- } catch (err) {
348
- overlay.status.set(cell.id, 'stale');
349
- summary.failed++;
350
- if (!overlay.error) overlay.error = err instanceof Error ? err.message : String(err);
351
- }
352
- onCellChange?.(cell.id);
353
- onTick?.(overlay);
354
- });
355
-
356
- 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()]);
357
425
 
358
426
  overlay.finishedAt = Date.now();
359
427
  onTick?.(overlay);
@@ -383,15 +451,15 @@ export function createBridgeSqlRunner(
383
451
  const PATTERN_PRIMARY = /^.*query_sql$/i;
384
452
  const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
385
453
 
386
- function findSqlTool(servers: DataServerDescriptor[]): { serverName: string; toolName: string } | null {
454
+ function findSqlTool(servers: DataServerDescriptor[]): { serverName: string; toolName: string; tool: DataServerTool } | null {
387
455
  for (const srv of servers) {
388
456
  for (const t of srv.tools ?? []) {
389
- 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 };
390
458
  }
391
459
  }
392
460
  for (const srv of servers) {
393
461
  for (const t of srv.tools ?? []) {
394
- 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 };
395
463
  }
396
464
  }
397
465
  return null;
@@ -439,7 +507,11 @@ export function createBridgeSqlRunner(
439
507
  if (!hit) {
440
508
  return { ok: false, error: 'No SQL tool exposed by reachable servers', errorKind: 'schema', durationMs: 0 };
441
509
  }
442
- 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);
443
515
  return parseResult(raw, startedAt);
444
516
  };
445
517
  }
@@ -466,12 +538,21 @@ interface CanvasLike {
466
538
  callTool: (name: string, toolName: string, args: unknown) => Promise<unknown>;
467
539
  }
468
540
 
469
- 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.
470
550
  const deadline = Date.now() + Math.max(0, timeoutMs);
471
551
  while (Date.now() < deadline) {
472
552
  const enabled = (canvas.dataServers ?? []).filter((s) => s.enabled !== false);
473
- if (enabled.length === 0) return;
474
- 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;
475
556
  await new Promise((r) => setTimeout(r, 100));
476
557
  }
477
558
  }
@@ -491,7 +572,10 @@ export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => v
491
572
  onTick?.(overlay);
492
573
  return;
493
574
  }
494
- 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);
495
579
 
496
580
  const sqlRunner = createBridgeSqlRunner(
497
581
  canvas.callTool.bind(canvas),
@@ -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,