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.
- package/README.md +8 -4
- package/dist/bin/commands/doctor.js +48 -0
- package/dist/bin/commands/doctor.js.map +1 -1
- package/dist/bin/commands/init.js +2 -1
- package/dist/bin/commands/init.js.map +1 -1
- package/dist/bin/postinstall.js +66 -1
- package/dist/bin/postinstall.js.map +1 -1
- package/dist/server.js +8 -3
- package/dist/server.js.map +1 -1
- package/dist/src/agent/spawn.js +38 -9
- package/dist/src/agent/spawn.js.map +1 -1
- package/dist/src/orchestrator/distribute.js +4 -1
- package/dist/src/orchestrator/distribute.js.map +1 -1
- package/dist/src/orchestrator/parser.js +2 -1
- package/dist/src/orchestrator/parser.js.map +1 -1
- package/dist/src/orchestrator/pipeline.js +5 -96
- package/dist/src/orchestrator/pipeline.js.map +1 -1
- package/dist/src/orchestrator/state-machine.js +11 -14
- package/dist/src/orchestrator/state-machine.js.map +1 -1
- package/dist/src/prompt/builder.js +5 -15
- package/dist/src/prompt/builder.js.map +1 -1
- package/dist/src/prompt/templates/a1-system.md +33 -32
- package/dist/src/prompt/templates/employee.md +7 -3
- package/dist/src/prompt/templates/orchestration.md +13 -37
- package/package.json +7 -1
- package/public/css/chat.css +12 -0
- package/public/dist/assets/employees-B11suLXa.js +39 -0
- package/public/dist/assets/index-C3xIEYRH.css +1 -0
- package/public/dist/assets/index-CMUmeewA.js +49 -0
- package/public/dist/assets/render-C5gpc065.js +25 -0
- package/public/dist/assets/{settings-BJR-IGM5.js → settings-BbG1hQmA.js} +14 -14
- package/public/dist/assets/settings-tOEsbIIs.js +1 -0
- package/public/dist/assets/skills-DL7wTirZ.js +12 -0
- package/public/dist/assets/skills-kOx6rq1X.js +1 -0
- package/public/dist/assets/slash-commands-C5vUKzNP.js +1 -0
- package/public/dist/assets/{slash-commands-DWvL-VDU.js → slash-commands-D_tV86er.js} +1 -1
- package/public/dist/assets/ui-ByJAyywC.js +1 -0
- package/public/dist/assets/ui-ORW7tzea.js +131 -0
- package/public/dist/assets/ws-CDrAtY9-.js +2 -0
- package/public/dist/index.html +2 -2
- package/public/js/features/chat.ts +3 -1
- package/public/js/render.ts +65 -22
- package/public/js/streaming-render.ts +33 -10
- package/public/js/ui.ts +45 -13
- package/public/js/virtual-scroll.ts +50 -16
- package/public/js/ws.ts +2 -1
- package/scripts/ensure-native-modules.cjs +54 -0
- package/scripts/install-officecli.ps1 +96 -0
- package/scripts/install-officecli.sh +37 -3
- package/scripts/install-wsl.sh +113 -27
- package/public/dist/assets/employees-D5n7mX5v.js +0 -39
- package/public/dist/assets/index-CLd0BsAu.js +0 -49
- package/public/dist/assets/index-D6ci1wCN.css +0 -1
- package/public/dist/assets/render-C8N0rp4L.js +0 -25
- package/public/dist/assets/settings-Dm6OnPmY.js +0 -1
- package/public/dist/assets/skills-D6jv9AIs.js +0 -1
- package/public/dist/assets/skills-uywskdFh.js +0 -12
- package/public/dist/assets/slash-commands-CCDonT40.js +0 -1
- package/public/dist/assets/ui-C1daR00l.js +0 -1
- package/public/dist/assets/ui-D-oFkXed.js +0 -131
- package/public/dist/assets/ws-Dnn8HG8B.js +0 -2
package/public/js/render.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
//
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
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(
|
|
348
|
-
: `<div class="msg msg-${role}"><div class="msg-label">${label}</div><div class="msg-content" data-raw="${escapeHtml(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.');
|