cli-jaw 1.6.3 → 1.6.6

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.
Files changed (61) hide show
  1. package/README.md +8 -4
  2. package/dist/bin/commands/doctor.js +48 -0
  3. package/dist/bin/commands/doctor.js.map +1 -1
  4. package/dist/bin/commands/init.js +2 -1
  5. package/dist/bin/commands/init.js.map +1 -1
  6. package/dist/bin/postinstall.js +66 -1
  7. package/dist/bin/postinstall.js.map +1 -1
  8. package/dist/server.js +8 -3
  9. package/dist/server.js.map +1 -1
  10. package/dist/src/agent/spawn.js +38 -9
  11. package/dist/src/agent/spawn.js.map +1 -1
  12. package/dist/src/orchestrator/distribute.js +4 -1
  13. package/dist/src/orchestrator/distribute.js.map +1 -1
  14. package/dist/src/orchestrator/parser.js +2 -1
  15. package/dist/src/orchestrator/parser.js.map +1 -1
  16. package/dist/src/orchestrator/pipeline.js +5 -96
  17. package/dist/src/orchestrator/pipeline.js.map +1 -1
  18. package/dist/src/orchestrator/state-machine.js +11 -14
  19. package/dist/src/orchestrator/state-machine.js.map +1 -1
  20. package/dist/src/prompt/builder.js +5 -15
  21. package/dist/src/prompt/builder.js.map +1 -1
  22. package/dist/src/prompt/templates/a1-system.md +33 -32
  23. package/dist/src/prompt/templates/employee.md +7 -3
  24. package/dist/src/prompt/templates/orchestration.md +13 -37
  25. package/package.json +7 -1
  26. package/public/css/chat.css +12 -0
  27. package/public/dist/assets/employees-B11suLXa.js +39 -0
  28. package/public/dist/assets/index-C3xIEYRH.css +1 -0
  29. package/public/dist/assets/index-CMUmeewA.js +49 -0
  30. package/public/dist/assets/render-C5gpc065.js +25 -0
  31. package/public/dist/assets/{settings-BJR-IGM5.js → settings-BbG1hQmA.js} +14 -14
  32. package/public/dist/assets/settings-tOEsbIIs.js +1 -0
  33. package/public/dist/assets/skills-DL7wTirZ.js +12 -0
  34. package/public/dist/assets/skills-kOx6rq1X.js +1 -0
  35. package/public/dist/assets/slash-commands-C5vUKzNP.js +1 -0
  36. package/public/dist/assets/{slash-commands-DWvL-VDU.js → slash-commands-D_tV86er.js} +1 -1
  37. package/public/dist/assets/ui-ByJAyywC.js +1 -0
  38. package/public/dist/assets/ui-ORW7tzea.js +131 -0
  39. package/public/dist/assets/ws-CDrAtY9-.js +2 -0
  40. package/public/dist/index.html +2 -2
  41. package/public/js/features/chat.ts +3 -1
  42. package/public/js/render.ts +65 -22
  43. package/public/js/streaming-render.ts +33 -10
  44. package/public/js/ui.ts +45 -13
  45. package/public/js/virtual-scroll.ts +50 -16
  46. package/public/js/ws.ts +2 -1
  47. package/scripts/ensure-native-modules.cjs +54 -0
  48. package/scripts/install-officecli.ps1 +96 -0
  49. package/scripts/install-officecli.sh +37 -3
  50. package/scripts/install-wsl.sh +113 -27
  51. package/public/dist/assets/employees-D5n7mX5v.js +0 -39
  52. package/public/dist/assets/index-CLd0BsAu.js +0 -49
  53. package/public/dist/assets/index-D6ci1wCN.css +0 -1
  54. package/public/dist/assets/render-C8N0rp4L.js +0 -25
  55. package/public/dist/assets/settings-Dm6OnPmY.js +0 -1
  56. package/public/dist/assets/skills-D6jv9AIs.js +0 -1
  57. package/public/dist/assets/skills-uywskdFh.js +0 -12
  58. package/public/dist/assets/slash-commands-CCDonT40.js +0 -1
  59. package/public/dist/assets/ui-C1daR00l.js +0 -1
  60. package/public/dist/assets/ui-D-oFkXed.js +0 -131
  61. package/public/dist/assets/ws-Dnn8HG8B.js +0 -2
@@ -101,6 +101,9 @@ async function getMermaid() {
101
101
  const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
102
102
  if (!mermaidModule) {
103
103
  mermaidModule = await import('mermaid');
104
+ mermaidModule.default.setParseErrorHandler(() => {
105
+ // Keep Mermaid syntax failures local to the message block fallback UI.
106
+ });
104
107
  }
105
108
  if (mermaidTheme !== currentTheme) {
106
109
  mermaidTheme = currentTheme;
@@ -109,6 +112,7 @@ async function getMermaid() {
109
112
  theme: 'base',
110
113
  themeVariables: getMermaidThemeVars(),
111
114
  securityLevel: 'strict',
115
+ suppressErrorRendering: true,
112
116
  });
113
117
  }
114
118
  return mermaidModule.default;
@@ -284,10 +288,18 @@ export function shieldMath(text: string): { text: string; blocks: MathBlock[] }
284
288
  return { text: processed, blocks };
285
289
  }
286
290
 
287
- export function unshieldMath(html: string, blocks: MathBlock[]): string {
291
+ export function unshieldMath(html: string, blocks: MathBlock[], isStreaming = false): string {
288
292
  return html.replace(/\x00MATH-(\d+)\x00/g, (_, i) => {
289
293
  const block = blocks[Number(i)];
290
294
  if (!block) return `<code title="math placeholder error">[math error]</code>`;
295
+
296
+ // During streaming: lightweight placeholder, defer KaTeX to finalize
297
+ if (isStreaming) {
298
+ return block.displayMode
299
+ ? `<div class="math-placeholder">${escapeHtml(block.tex)}</div>`
300
+ : `<code class="math-placeholder">${escapeHtml(block.tex)}</code>`;
301
+ }
302
+
291
303
  try {
292
304
  return katex.renderToString(block.tex, {
293
305
  displayMode: block.displayMode,
@@ -360,6 +372,16 @@ function appendMermaidActionBtns(el: HTMLElement): void {
360
372
  el.appendChild(copyBtn);
361
373
  }
362
374
 
375
+ function renderMermaidError(el: HTMLElement, code: string, errMsg: string): void {
376
+ el.classList.remove('mermaid-rendered');
377
+ el.innerHTML = `
378
+ <div class="mermaid-error">
379
+ <div class="mermaid-error-title">${ICONS.warning} ${escapeHtml(t('mermaid.renderFail') || 'Mermaid render failed')}</div>
380
+ <div class="mermaid-error-msg">${escapeHtml(errMsg.slice(0, 200))}</div>
381
+ <pre class="mermaid-error-code"><code>${escapeHtml(code)}</code></pre>
382
+ </div>`;
383
+ }
384
+
363
385
  async function renderSingleMermaid(el: HTMLElement): Promise<void> {
364
386
  el.classList.remove('mermaid-pending');
365
387
  const code = el.textContent || '';
@@ -367,24 +389,25 @@ async function renderSingleMermaid(el: HTMLElement): Promise<void> {
367
389
  const id = `mermaid-${++mermaidId}`;
368
390
  try {
369
391
  const mm = await getMermaid();
392
+ const parsed = await mm.parse(code, { suppressErrors: true });
393
+ if (parsed === false) {
394
+ renderMermaidError(el, code, 'Syntax error in Mermaid block');
395
+ return;
396
+ }
370
397
  const { svg } = await mm.render(id, code);
371
- el.innerHTML = svg;
398
+ el.innerHTML = sanitizeMermaidSvg(svg);
372
399
  el.classList.add('mermaid-rendered');
373
400
  appendMermaidActionBtns(el);
374
401
  } catch (err: unknown) {
375
402
  const errMsg = (err as { message?: string; str?: string })?.message
376
403
  || (err as { str?: string })?.str || 'Unknown error';
377
- el.innerHTML = `
378
- <div class="mermaid-error">
379
- <div class="mermaid-error-title">${ICONS.warning} ${escapeHtml(t('mermaid.renderFail') || 'Mermaid render failed')}</div>
380
- <div class="mermaid-error-msg">${escapeHtml(errMsg.slice(0, 200))}</div>
381
- <pre class="mermaid-error-code"><code>${escapeHtml(code)}</code></pre>
382
- </div>`;
404
+ renderMermaidError(el, code, errMsg);
383
405
  }
384
406
  }
385
407
 
386
- async function renderMermaidBlocks(): Promise<void> {
387
- const pending = document.querySelectorAll('.mermaid-pending');
408
+ async function renderMermaidBlocks(scope?: HTMLElement | Document): Promise<void> {
409
+ const root = scope || document;
410
+ const pending = root.querySelectorAll('.mermaid-pending');
388
411
  if (!pending.length) return;
389
412
  ensureMermaidObserver();
390
413
  for (const el of pending) {
@@ -439,8 +462,9 @@ function ensureMarked(): boolean {
439
462
  }
440
463
 
441
464
  // ── Rehighlight all code blocks ──
442
- export function rehighlightAll(): void {
443
- document.querySelectorAll('.code-block pre code, .code-block-wrapper pre code').forEach(el => {
465
+ export function rehighlightAll(scope?: HTMLElement | Document): void {
466
+ const root = scope || document;
467
+ root.querySelectorAll('.code-block pre code, .code-block-wrapper pre code').forEach(el => {
444
468
  if ((el as HTMLElement).dataset.highlighted === 'yes') return;
445
469
  const lang = [...el.classList].find(c => c.startsWith('language-'))?.replace('language-', '');
446
470
  const raw = el.textContent || '';
@@ -662,14 +686,14 @@ function unshieldSvgBlocks(html: string, blocks: SvgBlock[]): string {
662
686
 
663
687
  // ── Diagram Zoom Overlay ──
664
688
 
665
- export function bindDiagramZoom(): void {
666
- document.querySelectorAll('.diagram-zoom-btn').forEach(btn => {
689
+ export function bindDiagramZoom(scope?: HTMLElement | Document): void {
690
+ const root = scope || document;
691
+ root.querySelectorAll('.diagram-zoom-btn').forEach(btn => {
667
692
  if ((btn as HTMLElement).dataset.bound) return;
668
693
  (btn as HTMLElement).dataset.bound = '1';
669
694
  btn.addEventListener('click', () => {
670
695
  const container = btn.closest('.diagram-container');
671
696
  if (!container) return;
672
- // Clone without zoom button to prevent nesting
673
697
  const clone = container.cloneNode(true) as HTMLElement;
674
698
  clone.querySelectorAll('.diagram-zoom-btn, .diagram-copy-btn, .diagram-save-btn').forEach(b => b.remove());
675
699
  openDiagramOverlay(clone.innerHTML);
@@ -759,7 +783,7 @@ export function renderMarkdown(text: string, isStreaming = false): string {
759
783
  html = html.replace(/<table/g, '<div class="table-wrapper"><table').replace(/<\/table>/g, '</table></div>');
760
784
 
761
785
  // 6. Unshield math
762
- html = unshieldMath(html, mathBlocks);
786
+ html = unshieldMath(html, mathBlocks, isStreaming);
763
787
 
764
788
  // 7. Sanitize
765
789
  html = sanitizeHtml(html);
@@ -767,15 +791,34 @@ export function renderMarkdown(text: string, isStreaming = false): string {
767
791
  // 8. Unshield SVGs (after sanitize — SVGs sanitized individually in renderSvgBlock)
768
792
  html = unshieldSvgBlocks(html, svgBlocks);
769
793
 
770
- // 9. Post-render async tasks
771
- requestAnimationFrame(() => {
772
- renderMermaidBlocks();
773
- rehighlightAll();
774
- bindDiagramZoom();
775
- });
794
+ // 9. Post-render async tasks — skip during streaming (deferred to finalize)
795
+ if (!isStreaming) {
796
+ schedulePostRender();
797
+ }
776
798
 
777
799
  ensureCopyDelegation();
778
800
  ensureDiagramActionDelegation();
779
801
 
780
802
  return html;
781
803
  }
804
+
805
+ // ── Batched post-render scheduler ──
806
+ // Coalesces multiple renderMarkdown() calls into a single post-render pass.
807
+ let postRenderRAF: number | null = null;
808
+
809
+ function schedulePostRender(): void {
810
+ if (postRenderRAF) return;
811
+ postRenderRAF = requestAnimationFrame(() => {
812
+ postRenderRAF = null;
813
+ renderMermaidBlocks();
814
+ rehighlightAll();
815
+ bindDiagramZoom();
816
+ });
817
+ }
818
+
819
+ export function cancelPostRender(): void {
820
+ if (postRenderRAF) {
821
+ cancelAnimationFrame(postRenderRAF);
822
+ postRenderRAF = null;
823
+ }
824
+ }
@@ -1,11 +1,14 @@
1
1
  // ── Streaming rAF Renderer ──
2
- // Phase 3/8: throttled markdown rendering during agent streaming
2
+ // Throttled markdown rendering during agent streaming.
3
3
  // Prevents flicker by batching renders to 1 per animation frame.
4
+ // Array buffer avoids O(n) string copy per chunk.
4
5
 
5
6
  import { renderMarkdown } from './render.js';
6
7
 
7
8
  export interface StreamState {
9
+ chunks: string[];
8
10
  fullText: string;
11
+ textDirty: boolean;
9
12
  element: HTMLElement;
10
13
  pendingRAF: number | null;
11
14
  isFinalized: boolean;
@@ -16,26 +19,39 @@ const FULL_RENDER_THRESHOLD = 2000;
16
19
  const THROTTLE_MS = 80; // ~12fps — was 32ms (30fps), reduced to avoid blocking input
17
20
 
18
21
  export function createStreamRenderer(el: HTMLElement): StreamState {
19
- return { fullText: '', element: el, pendingRAF: null, isFinalized: false, lastRenderTime: 0 };
22
+ return {
23
+ chunks: [], fullText: '', textDirty: false,
24
+ element: el, pendingRAF: null, isFinalized: false, lastRenderTime: 0,
25
+ };
26
+ }
27
+
28
+ function getFullText(ss: StreamState): string {
29
+ if (ss.textDirty) {
30
+ ss.fullText = ss.chunks.join('');
31
+ ss.textDirty = false;
32
+ }
33
+ return ss.fullText;
20
34
  }
21
35
 
22
36
  export function appendChunk(ss: StreamState, chunk: string): void {
23
- ss.fullText += chunk;
37
+ ss.chunks.push(chunk);
38
+ ss.textDirty = true;
39
+
24
40
  if (!ss.pendingRAF && !ss.isFinalized) {
25
41
  ss.pendingRAF = requestAnimationFrame(() => {
26
42
  ss.pendingRAF = null;
27
43
  if (ss.isFinalized) return;
28
44
  const now = performance.now();
29
- if (ss.fullText.length < FULL_RENDER_THRESHOLD || now - ss.lastRenderTime > THROTTLE_MS) {
30
- ss.element.innerHTML = renderMarkdown(ss.fullText, true) +
45
+ const text = getFullText(ss);
46
+ if (text.length < FULL_RENDER_THRESHOLD || now - ss.lastRenderTime > THROTTLE_MS) {
47
+ ss.element.innerHTML = renderMarkdown(text, true) +
31
48
  '<span class="stream-cursor" aria-hidden="true"></span>';
32
49
  ss.lastRenderTime = now;
33
50
  } else {
34
- // Throttled — schedule trailing render to ensure latest text paints
35
51
  ss.pendingRAF = requestAnimationFrame(() => {
36
52
  ss.pendingRAF = null;
37
53
  if (ss.isFinalized) return;
38
- ss.element.innerHTML = renderMarkdown(ss.fullText, true) +
54
+ ss.element.innerHTML = renderMarkdown(getFullText(ss), true) +
39
55
  '<span class="stream-cursor" aria-hidden="true"></span>';
40
56
  ss.lastRenderTime = performance.now();
41
57
  });
@@ -44,12 +60,19 @@ export function appendChunk(ss: StreamState, chunk: string): void {
44
60
  }
45
61
  }
46
62
 
47
- export function finalizeStream(ss: StreamState): string {
63
+ /**
64
+ * Finalize streaming session. Returns accumulated text.
65
+ * @param skipRender true when caller (finalizeAgent) will do its own full render.
66
+ */
67
+ export function finalizeStream(ss: StreamState, skipRender = false): string {
48
68
  ss.isFinalized = true;
49
69
  if (ss.pendingRAF) {
50
70
  cancelAnimationFrame(ss.pendingRAF);
51
71
  ss.pendingRAF = null;
52
72
  }
53
- ss.element.innerHTML = renderMarkdown(ss.fullText);
54
- return ss.fullText;
73
+ const text = getFullText(ss);
74
+ if (!skipRender) {
75
+ ss.element.innerHTML = renderMarkdown(text);
76
+ }
77
+ return text;
55
78
  }
package/public/js/ui.ts CHANGED
@@ -85,8 +85,7 @@ export function updateQueueBadge(count: number): void {
85
85
  function showSkeleton(): void {
86
86
  const container = document.getElementById('chatMessages');
87
87
  if (!container || container.querySelector('.skeleton-msg')) return;
88
- const vs = getVirtualScroll();
89
- if (vs.active) vs.flushToDOM();
88
+ // No flushToDOM — skeleton goes directly into container as overlay
90
89
  hideEmptyState();
91
90
  const skel = document.createElement('div');
92
91
  skel.className = 'skeleton-msg';
@@ -236,7 +235,7 @@ export function finalizeAgent(text: string, toolLog?: ToolLogEntry[]): void {
236
235
  }
237
236
  const content = (state.currentAgentDiv as HTMLElement)?.querySelector('.msg-content');
238
237
  // Live stream is preview-only; agent_done text stays authoritative.
239
- const streamedText = currentStream ? finalizeStream(currentStream) : '';
238
+ const streamedText = currentStream ? finalizeStream(currentStream, true) : '';
240
239
  const finalText = text || streamedText;
241
240
  currentStream = null;
242
241
  // Skip static tool HTML when process block already shows tool summary
@@ -244,6 +243,13 @@ export function finalizeAgent(text: string, toolLog?: ToolLogEntry[]): void {
244
243
  if (content) content.innerHTML = toolHtml + renderMarkdown(finalText);
245
244
  if (content) content.setAttribute('data-raw', stripOrchestration(finalText));
246
245
  if (content) activateWidgets(content as HTMLElement);
246
+
247
+ // Promote streaming div from real DOM into VS if active
248
+ const vs = getVirtualScroll();
249
+ if (vs.active && state.currentAgentDiv && state.currentAgentDiv.isConnected) {
250
+ vs.appendLiveItem(state.currentAgentDiv);
251
+ state.currentAgentDiv.remove();
252
+ }
247
253
  }
248
254
  currentStream = null;
249
255
  state.currentAgentDiv = null;
@@ -257,7 +263,6 @@ export function finalizeAgent(text: string, toolLog?: ToolLogEntry[]): void {
257
263
  export function addMessage(role: string, text: string, cli?: string | null): HTMLDivElement {
258
264
  const container = document.getElementById('chatMessages');
259
265
  const vs = getVirtualScroll();
260
- if (vs.active) vs.flushToDOM();
261
266
  hideEmptyState();
262
267
  removeSkeleton();
263
268
 
@@ -274,8 +279,17 @@ export function addMessage(role: string, text: string, cli?: string | null): HTM
274
279
  }
275
280
  const contentEl = div.querySelector('.msg-content');
276
281
  if (contentEl) contentEl.setAttribute('data-raw', stripOrchestration(text));
277
- container?.appendChild(div);
278
- activateWidgets(div);
282
+
283
+ // Streaming placeholder (agent + empty text) must stay in real DOM
284
+ // so state.currentAgentDiv reference remains valid during streaming.
285
+ const isStreamingPlaceholder = role === 'agent' && !text;
286
+
287
+ if (vs.active && !isStreamingPlaceholder) {
288
+ vs.appendLiveItem(div);
289
+ } else {
290
+ container?.appendChild(div);
291
+ activateWidgets(div);
292
+ }
279
293
  scrollToBottom();
280
294
  return div;
281
295
  }
@@ -337,22 +351,40 @@ export async function loadMessages(): Promise<void> {
337
351
  if (chatEl) chatEl.innerHTML = '';
338
352
 
339
353
  if (msgs.length >= VS_THRESHOLD) {
354
+ // Phase 2: lazy render — store skeleton HTML, render on viewport entry
340
355
  for (const m of msgs) {
341
356
  const role = m.role === 'assistant' ? 'agent' : m.role;
342
- const rendered = renderMarkdown(m.content);
357
+ const rawContent = stripOrchestration(m.content);
343
358
  const label = escapeHtml(role === 'user' ? t('msg.you') : getAppName());
344
359
  const tools = m.role === 'assistant' ? parseToolLog(m.tool_log) : [];
345
360
  const toolHtml = tools.length > 0 ? buildProcessBlockHtml(toProcessSteps(tools), true) : '';
361
+ // Skeleton placeholder — lazy-pending class triggers render on viewport entry
362
+ const skeletonContent = '<div class="skeleton-line"></div><div class="skeleton-line"></div>';
346
363
  const html = role === 'agent'
347
- ? `<div class="msg msg-agent"><div class="agent-icon" aria-hidden="true">${getAgentIcon(m.cli)}</div><div class="agent-body">${toolHtml}<div class="msg-content" data-raw="${escapeHtml(stripOrchestration(m.content))}">${rendered}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div></div>`
348
- : `<div class="msg msg-${role}"><div class="msg-label">${label}</div><div class="msg-content" data-raw="${escapeHtml(stripOrchestration(m.content))}">${rendered}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div>`;
364
+ ? `<div class="msg msg-agent"><div class="agent-icon" aria-hidden="true">${getAgentIcon(m.cli)}</div><div class="agent-body">${toolHtml}<div class="msg-content lazy-pending" data-raw="${escapeHtml(rawContent)}">${skeletonContent}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div></div>`
365
+ : `<div class="msg msg-${role}"><div class="msg-label">${label}</div><div class="msg-content lazy-pending" data-raw="${escapeHtml(rawContent)}">${skeletonContent}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div>`;
349
366
  vs.addItem(crypto.randomUUID(), html);
350
367
  }
368
+
369
+ // Register lazy render callback
370
+ vs.onLazyRender = (targets: HTMLElement[]) => {
371
+ for (const el of targets) {
372
+ if (!el.classList.contains('lazy-pending')) continue;
373
+ const raw = el.getAttribute('data-raw') || '';
374
+ el.innerHTML = raw ? renderMarkdown(raw) : '';
375
+ el.classList.remove('lazy-pending');
376
+ activateWidgets(el);
377
+
378
+ // Persist rendered HTML back into VS cache
379
+ const msgEl = el.closest('[data-vs-idx]') as HTMLElement | null;
380
+ if (msgEl) {
381
+ const idx = Number(msgEl.dataset.vsIdx);
382
+ vs.updateItemHtml(idx, msgEl.outerHTML);
383
+ }
384
+ }
385
+ };
386
+
351
387
  vs.scrollToBottom();
352
- requestAnimationFrame(() => {
353
- const chatElRef = document.getElementById('chatMessages');
354
- if (chatElRef) activateWidgets(chatElRef);
355
- });
356
388
  } else {
357
389
  msgs.forEach(m => {
358
390
  const div = addMessage(m.role === 'assistant' ? 'agent' : m.role, m.content, m.cli);
@@ -2,7 +2,7 @@
2
2
  // Activates at THRESHOLD messages to prevent DOM bloat
3
3
  // Below threshold: standard DOM append (zero overhead)
4
4
 
5
- const THRESHOLD = 200;
5
+ const THRESHOLD = 80;
6
6
  const BUFFER = 5;
7
7
  const EST_HEIGHT = 80;
8
8
 
@@ -12,6 +12,8 @@ export interface VirtualItem {
12
12
  height: number;
13
13
  }
14
14
 
15
+ export type LazyRenderCallback = (targets: HTMLElement[]) => void;
16
+
15
17
  export class VirtualScroll {
16
18
  private items: VirtualItem[] = [];
17
19
  private container: HTMLElement;
@@ -19,10 +21,14 @@ export class VirtualScroll {
19
21
  private spacerBottom: HTMLDivElement;
20
22
  private viewport: HTMLDivElement;
21
23
  private _active = false;
24
+ private _totalHeight = 0;
22
25
  private rafId: number | null = null;
23
26
  private firstVisible = 0;
24
27
  private lastVisible = 0;
25
28
 
29
+ /** Called after render() mounts items in viewport — for lazy rendering */
30
+ onLazyRender: LazyRenderCallback | null = null;
31
+
26
32
  constructor(containerId: string) {
27
33
  this.container = document.getElementById(containerId)!;
28
34
  this.spacerTop = document.createElement('div');
@@ -37,22 +43,23 @@ export class VirtualScroll {
37
43
  get count(): number { return this.items.length; }
38
44
 
39
45
  /** Flush all virtual items to real DOM and deactivate VS.
40
- * Called before live message append to prevent spacer/DOM conflicts. */
46
+ * Called on conversation clear or explicit reset. */
41
47
  flushToDOM(): void {
42
48
  if (!this._active) return;
43
49
  this.container.removeEventListener('scroll', this.scrollHandler);
44
50
  if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; }
45
- // Render all items as real DOM nodes
46
51
  this.container.innerHTML = this.items.map(it => it.html).join('');
47
52
  this._active = false;
48
53
  this.firstVisible = 0;
49
54
  this.lastVisible = 0;
50
- // Release items to free memory (DOM now owns the content)
51
55
  this.items = [];
56
+ this._totalHeight = 0;
52
57
  }
53
58
 
54
59
  addItem(id: string, html: string): void {
55
- this.items.push({ id, html, height: EST_HEIGHT });
60
+ const item: VirtualItem = { id, html, height: EST_HEIGHT };
61
+ this.items.push(item);
62
+ this._totalHeight += EST_HEIGHT;
56
63
  if (!this._active && this.items.length >= THRESHOLD) {
57
64
  this.activate();
58
65
  }
@@ -61,18 +68,38 @@ export class VirtualScroll {
61
68
  }
62
69
  }
63
70
 
71
+ /** Append a live DOM element while keeping VS active.
72
+ * Serializes to HTML for virtual storage. */
73
+ appendLiveItem(div: HTMLElement): void {
74
+ if (!this._active) return;
75
+ const html = div.outerHTML;
76
+ const id = crypto.randomUUID();
77
+ const item: VirtualItem = { id, html, height: EST_HEIGHT };
78
+ this.items.push(item);
79
+ this._totalHeight += EST_HEIGHT;
80
+ this.scheduleRender();
81
+ this.scrollToBottom();
82
+ }
83
+
84
+ /** Update cached HTML for a specific item index (used by lazy render). */
85
+ updateItemHtml(idx: number, html: string): void {
86
+ if (this.items[idx]) {
87
+ this.items[idx].html = html;
88
+ }
89
+ }
90
+
64
91
  private scrollHandler = () => this.scheduleRender();
65
92
 
66
93
  private activate(): void {
67
94
  this._active = true;
68
- // Measure existing DOM nodes
95
+ this._totalHeight = 0;
69
96
  const existing = this.container.querySelectorAll('.msg');
70
97
  existing.forEach((el, i) => {
71
98
  if (this.items[i]) {
72
99
  this.items[i].height = el.getBoundingClientRect().height;
100
+ this._totalHeight += this.items[i].height;
73
101
  }
74
102
  });
75
- // Replace DOM with virtual structure
76
103
  this.container.innerHTML = '';
77
104
  this.container.append(this.spacerTop, this.viewport, this.spacerBottom);
78
105
  this.container.addEventListener('scroll', this.scrollHandler, { passive: true });
@@ -91,7 +118,6 @@ export class VirtualScroll {
91
118
  const scrollTop = this.container.scrollTop;
92
119
  const viewHeight = this.container.clientHeight;
93
120
 
94
- // Binary-ish search for start index
95
121
  let accum = 0;
96
122
  let startIdx = 0;
97
123
  for (let i = 0; i < this.items.length; i++) {
@@ -112,12 +138,10 @@ export class VirtualScroll {
112
138
  }
113
139
  const last = Math.min(this.items.length - 1, endIdx + BUFFER);
114
140
 
115
- // Skip re-render if range unchanged
116
141
  if (first === this.firstVisible && last === this.lastVisible) return;
117
142
  this.firstVisible = first;
118
143
  this.lastVisible = last;
119
144
 
120
- // Compute spacer heights
121
145
  let topSpace = 0;
122
146
  for (let i = 0; i < first; i++) topSpace += this.items[i].height;
123
147
  let bottomSpace = 0;
@@ -126,7 +150,6 @@ export class VirtualScroll {
126
150
  this.spacerTop.style.height = `${topSpace}px`;
127
151
  this.spacerBottom.style.height = `${bottomSpace}px`;
128
152
 
129
- // Render visible items
130
153
  const frag = document.createDocumentFragment();
131
154
  for (let i = first; i <= last; i++) {
132
155
  const item = this.items[i];
@@ -141,26 +164,36 @@ export class VirtualScroll {
141
164
  this.viewport.innerHTML = '';
142
165
  this.viewport.appendChild(frag);
143
166
 
144
- // Re-measure rendered heights
167
+ // Re-measure rendered heights and update totalHeight
145
168
  this.viewport.querySelectorAll('[data-vs-idx]').forEach(el => {
146
169
  const idx = Number((el as HTMLElement).dataset.vsIdx);
147
170
  if (this.items[idx]) {
148
- this.items[idx].height = el.getBoundingClientRect().height;
171
+ const oldH = this.items[idx].height;
172
+ const newH = el.getBoundingClientRect().height;
173
+ this.items[idx].height = newH;
174
+ this._totalHeight += (newH - oldH);
149
175
  }
150
176
  });
177
+
178
+ // Fire lazy render callback for newly visible items
179
+ if (this.onLazyRender) {
180
+ const lazyTargets = this.viewport.querySelectorAll<HTMLElement>('.lazy-pending');
181
+ if (lazyTargets.length > 0) {
182
+ this.onLazyRender(Array.from(lazyTargets));
183
+ }
184
+ }
151
185
  }
152
186
 
153
187
  scrollToBottom(): void {
154
- const total = this.items.reduce((sum, it) => sum + it.height, 0);
155
- this.container.scrollTop = total;
188
+ this.container.scrollTop = this._totalHeight;
156
189
  this.scheduleRender();
157
190
  }
158
191
 
159
192
  clear(): void {
160
193
  this.items = [];
194
+ this._totalHeight = 0;
161
195
  if (this._active) {
162
196
  this.container.removeEventListener('scroll', this.scrollHandler);
163
- // Restore normal DOM structure
164
197
  this.viewport.innerHTML = '';
165
198
  this.spacerTop.style.height = '0';
166
199
  this.spacerBottom.style.height = '0';
@@ -169,6 +202,7 @@ export class VirtualScroll {
169
202
  this._active = false;
170
203
  this.firstVisible = 0;
171
204
  this.lastVisible = 0;
205
+ this.onLazyRender = null;
172
206
  if (this.rafId) {
173
207
  cancelAnimationFrame(this.rafId);
174
208
  this.rafId = null;
package/public/js/ws.ts CHANGED
@@ -4,7 +4,7 @@ import { setStatus, updateQueueBadge, addSystemMsg, appendAgentText, finalizeAge
4
4
  import { t, getLang } from './features/i18n.js';
5
5
  import { getVirtualScroll } from './virtual-scroll.js';
6
6
  import { ICONS, emojiToIcon } from './icons.js';
7
- import { escapeHtml } from './render.js';
7
+ import { escapeHtml, cancelPostRender } from './render.js';
8
8
  import type { OrcStateName } from './state.js';
9
9
 
10
10
  const ROADMAP_PHASES = ['P', 'A', 'B', 'C'] as const;
@@ -258,6 +258,7 @@ export function connect(): void {
258
258
  } else if (msg.type === 'orchestrate_done') {
259
259
  finalizeAgent(msg.text || '');
260
260
  } else if (msg.type === 'clear') {
261
+ cancelPostRender();
261
262
  cleanupToolActivity();
262
263
  getVirtualScroll().clear();
263
264
  const el = document.getElementById('chatMessages');
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ensure-native-modules.cjs
4
+ *
5
+ * Verifies that native addons needed for local runtime are loadable.
6
+ * If better-sqlite3 exists but was built against a different Node ABI,
7
+ * rebuild it in-place before build/test steps continue.
8
+ */
9
+ const { execFileSync } = require('child_process');
10
+
11
+ const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
12
+
13
+ function loadBetterSqlite3() {
14
+ try {
15
+ require('better-sqlite3');
16
+ return null;
17
+ } catch (error) {
18
+ return error;
19
+ }
20
+ }
21
+
22
+ function isRecoverableNativeMismatch(error) {
23
+ const text = String(error && (error.stack || error.message || error));
24
+ return (
25
+ text.includes('better_sqlite3.node') ||
26
+ text.includes('better-sqlite3') ||
27
+ text.includes('NODE_MODULE_VERSION') ||
28
+ text.includes('ERR_DLOPEN_FAILED')
29
+ );
30
+ }
31
+
32
+ const firstError = loadBetterSqlite3();
33
+ if (!firstError) process.exit(0);
34
+
35
+ if (!isRecoverableNativeMismatch(firstError)) {
36
+ console.error('[jaw:native] better-sqlite3 load failed with a non-recoverable error.');
37
+ console.error(firstError);
38
+ process.exit(1);
39
+ }
40
+
41
+ console.warn('[jaw:native] better-sqlite3 load failed — rebuilding native module for current Node runtime...');
42
+ execFileSync(npmBin, ['rebuild', 'better-sqlite3'], {
43
+ stdio: 'inherit',
44
+ cwd: process.cwd(),
45
+ });
46
+
47
+ const secondError = loadBetterSqlite3();
48
+ if (secondError) {
49
+ console.error('[jaw:native] better-sqlite3 is still not loadable after rebuild.');
50
+ console.error(secondError);
51
+ process.exit(1);
52
+ }
53
+
54
+ console.log('[jaw:native] better-sqlite3 is ready for this Node runtime.');