mixdog 0.9.39 → 0.9.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 +1 -1
- package/scripts/agent-terminal-reap-test.mjs +6 -6
- package/scripts/execution-resume-esc-integration-test.mjs +4 -2
- package/scripts/steering-drain-buckets-test.mjs +140 -5
- package/scripts/tui-transcript-perf-test.mjs +279 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
- package/src/runtime/shared/buffered-appender.mjs +13 -2
- package/src/session-runtime/config-helpers.mjs +6 -6
- package/src/session-runtime/lifecycle-api.mjs +4 -0
- package/src/session-runtime/runtime-core.mjs +1 -0
- package/src/session-runtime/session-turn-api.mjs +12 -0
- package/src/standalone/agent-tool.mjs +8 -1
- package/src/tui/App.jsx +2 -1
- package/src/tui/app/transcript-window.mjs +9 -10
- package/src/tui/app/use-transcript-window.mjs +38 -56
- package/src/tui/dist/index.mjs +354 -163
- package/src/tui/engine/agent-job-feed.mjs +76 -6
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine/tool-card-results.mjs +10 -5
- package/src/tui/engine/turn.mjs +96 -36
- package/src/tui/engine.mjs +136 -37
- package/src/tui/index.jsx +2 -2
|
@@ -289,7 +289,10 @@ function _getOrSpawnWorker() {
|
|
|
289
289
|
// (the originating call plus any that coalesced onto it before it was
|
|
290
290
|
// posted). A supersede never lands here as a rejection — only a real
|
|
291
291
|
// worker failure does.
|
|
292
|
-
if (ok) {
|
|
292
|
+
if (ok) {
|
|
293
|
+
clearSessionSaveError(id);
|
|
294
|
+
for (const w of waiters) w.resolve();
|
|
295
|
+
}
|
|
293
296
|
else {
|
|
294
297
|
const e = new Error(`[session-store] worker save failed: ${error}`);
|
|
295
298
|
for (const w of waiters) w.reject(e);
|
|
@@ -441,6 +444,7 @@ function _doSaveSync(payload) {
|
|
|
441
444
|
}
|
|
442
445
|
_renameWithRetrySync(tmp, target);
|
|
443
446
|
_upsertSessionSummary(session);
|
|
447
|
+
clearSessionSaveError(id);
|
|
444
448
|
} catch (err) {
|
|
445
449
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
446
450
|
throw err;
|
|
@@ -580,6 +584,7 @@ async function _doSave(payload) {
|
|
|
580
584
|
}
|
|
581
585
|
_renameWithRetrySync(tmp, target);
|
|
582
586
|
_upsertSessionSummary(session);
|
|
587
|
+
clearSessionSaveError(id);
|
|
583
588
|
} catch (err) {
|
|
584
589
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
585
590
|
_savePending.delete(id);
|
|
@@ -643,6 +648,7 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
643
648
|
return null;
|
|
644
649
|
}
|
|
645
650
|
_savePending.delete(id);
|
|
651
|
+
clearSessionSaveError(id);
|
|
646
652
|
_clearLiveSession(id);
|
|
647
653
|
_deleteHeartbeat(id);
|
|
648
654
|
_upsertSessionSummary(tombstone);
|
|
@@ -702,6 +708,7 @@ export function bumpSessionGeneration(id, reason = 'detach') {
|
|
|
702
708
|
return null;
|
|
703
709
|
}
|
|
704
710
|
_savePending.delete(id);
|
|
711
|
+
clearSessionSaveError(id);
|
|
705
712
|
_clearLiveSession(id);
|
|
706
713
|
_deleteHeartbeat(id);
|
|
707
714
|
_upsertSessionSummary(detached);
|
|
@@ -748,6 +755,7 @@ export function deleteSession(id, options = {}) {
|
|
|
748
755
|
catch { /* fall through to .hb cleanup */ }
|
|
749
756
|
}
|
|
750
757
|
_deleteHeartbeat(id);
|
|
758
|
+
if (removed || !existsSync(path)) clearSessionSaveError(id);
|
|
751
759
|
// deferSummaryUpdate: bulk callers (tombstone sweep) remove thousands of
|
|
752
760
|
// rows — a per-id _removeSessionSummary would parse+rewrite the multi-MB
|
|
753
761
|
// summary index once PER DELETION. They batch the index update themselves.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'fs';
|
|
2
|
-
import { readdir, rmdir, unlink, writeFile } from 'fs/promises';
|
|
2
|
+
import { readdir, rmdir, stat, unlink, writeFile } from 'fs/promises';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { getPluginData } from '../config.mjs';
|
|
5
5
|
import { normalizeOutputPath } from '../tools/builtin/path-utils.mjs';
|
|
@@ -11,6 +11,7 @@ const TOOL_RESULT_SHELL_THRESHOLD_CHARS = 30_000;
|
|
|
11
11
|
const TOOL_RESULT_SEARCH_THRESHOLD_CHARS = 50_000;
|
|
12
12
|
const TOOL_RESULT_GREP_THRESHOLD_CHARS = 20_000;
|
|
13
13
|
export const TOOL_RESULT_OFFLOAD_PREFIX = '[tool output offloaded:';
|
|
14
|
+
const OFFLOAD_PRUNE_MIN_AGE_MS = 10 * 60 * 1000;
|
|
14
15
|
|
|
15
16
|
// Per-tool persistence limits mirror reference per-tool maxResultSizeChars
|
|
16
17
|
// rather than a single global value: Grep persists at 20k (CC GrepTool), Glob
|
|
@@ -147,6 +148,47 @@ export async function clearOffloadSession(sessionId) {
|
|
|
147
148
|
} catch { /* best-effort */ }
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
// Remove sidecars that no longer occur in the live transcript. A serialized
|
|
152
|
+
// path match is conservative: if messages cannot be serialized, or a path is
|
|
153
|
+
// mentioned anywhere in a message, retain the file rather than risk deleting
|
|
154
|
+
// one that can still be read by the model.
|
|
155
|
+
export async function pruneOffloadSession(sessionId, getMessages) {
|
|
156
|
+
if (!sessionId || typeof getMessages !== 'function') return;
|
|
157
|
+
const dir = join(getPluginData(), 'tool-results', safeSessionSegment(sessionId));
|
|
158
|
+
if (!existsSync(dir)) return;
|
|
159
|
+
let candidates;
|
|
160
|
+
try {
|
|
161
|
+
const entries = await readdir(dir);
|
|
162
|
+
candidates = (await Promise.all(entries
|
|
163
|
+
.filter((name) => name.endsWith('.txt'))
|
|
164
|
+
.map(async (name) => {
|
|
165
|
+
const filePath = join(dir, name);
|
|
166
|
+
try {
|
|
167
|
+
const fileStat = await stat(filePath);
|
|
168
|
+
if (Date.now() - fileStat.mtimeMs < OFFLOAD_PRUNE_MIN_AGE_MS) return null;
|
|
169
|
+
return { name, filePath };
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
)).filter(Boolean);
|
|
175
|
+
} catch { /* best-effort */ }
|
|
176
|
+
if (!candidates) return;
|
|
177
|
+
let serialized;
|
|
178
|
+
try { serialized = JSON.stringify(getMessages()); } catch { return; }
|
|
179
|
+
const haystack = process.platform === 'win32' ? serialized.toLowerCase() : serialized;
|
|
180
|
+
await Promise.all(candidates
|
|
181
|
+
.filter(({ name, filePath }) => {
|
|
182
|
+
const normalizedPath = normalizeOutputPath(filePath);
|
|
183
|
+
const needles = [normalizedPath, name];
|
|
184
|
+
return !needles.some((needle) => {
|
|
185
|
+
const value = process.platform === 'win32' ? needle.toLowerCase() : needle;
|
|
186
|
+
return haystack.includes(value);
|
|
187
|
+
});
|
|
188
|
+
})
|
|
189
|
+
.map(({ filePath }) => unlink(filePath).catch(() => { /* best-effort */ })));
|
|
190
|
+
}
|
|
191
|
+
|
|
150
192
|
export function isOffloadedToolResultText(text) {
|
|
151
193
|
return typeof text === 'string' && text.startsWith(TOOL_RESULT_OFFLOAD_PREFIX);
|
|
152
194
|
}
|
|
@@ -124,23 +124,31 @@ export function _isBenignSearchExitOne(command, exitCode, signal, stderr) {
|
|
|
124
124
|
function _combineAbortSignals(sessionSignal, externalSignal) {
|
|
125
125
|
const a = sessionSignal || null;
|
|
126
126
|
const b = externalSignal || null;
|
|
127
|
-
if (!a && !b) return null;
|
|
128
|
-
if (!a) return b;
|
|
129
|
-
if (!b) return a;
|
|
130
|
-
if (a === b) return a;
|
|
127
|
+
if (!a && !b) return { signal: null, cleanup() {} };
|
|
128
|
+
if (!a) return { signal: b, cleanup() {} };
|
|
129
|
+
if (!b) return { signal: a, cleanup() {} };
|
|
130
|
+
if (a === b) return { signal: a, cleanup() {} };
|
|
131
131
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
|
|
132
|
-
try { return AbortSignal.any([a, b]); } catch { /* fall through */ }
|
|
132
|
+
try { return { signal: AbortSignal.any([a, b]), cleanup() {} }; } catch { /* fall through */ }
|
|
133
133
|
}
|
|
134
134
|
const ctl = new AbortController();
|
|
135
135
|
const onAbort = (sig) => {
|
|
136
136
|
if (ctl.signal.aborted) return;
|
|
137
137
|
try { ctl.abort(sig?.reason); } catch { try { ctl.abort(); } catch {} }
|
|
138
138
|
};
|
|
139
|
-
if (a.aborted) { onAbort(a); return ctl.signal; }
|
|
140
|
-
if (b.aborted) { onAbort(b); return ctl.signal; }
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
if (a.aborted) { onAbort(a); return { signal: ctl.signal, cleanup() {} }; }
|
|
140
|
+
if (b.aborted) { onAbort(b); return { signal: ctl.signal, cleanup() {} }; }
|
|
141
|
+
const onAbortA = () => onAbort(a);
|
|
142
|
+
const onAbortB = () => onAbort(b);
|
|
143
|
+
try { a.addEventListener('abort', onAbortA, { once: true }); } catch {}
|
|
144
|
+
try { b.addEventListener('abort', onAbortB, { once: true }); } catch {}
|
|
145
|
+
return {
|
|
146
|
+
signal: ctl.signal,
|
|
147
|
+
cleanup() {
|
|
148
|
+
try { a.removeEventListener('abort', onAbortA); } catch {}
|
|
149
|
+
try { b.removeEventListener('abort', onAbortB); } catch {}
|
|
150
|
+
},
|
|
151
|
+
};
|
|
144
152
|
}
|
|
145
153
|
|
|
146
154
|
function _prefixPowerShellUtf8(command) {
|
|
@@ -228,7 +236,11 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
228
236
|
const userProvidedSession = typeof args.session_id === 'string' && args.session_id.trim().length > 0;
|
|
229
237
|
const shouldCreate = args.create === true || !userProvidedSession;
|
|
230
238
|
effectiveArgs = { ...effectiveArgs, create: shouldCreate };
|
|
231
|
-
|
|
239
|
+
try {
|
|
240
|
+
return await executeBashSessionTool('bash_session', effectiveArgs, bashWorkDir, { abortSignal: combinedPersistAbort.signal, sessionId: options?.sessionId });
|
|
241
|
+
} finally {
|
|
242
|
+
combinedPersistAbort.cleanup();
|
|
243
|
+
}
|
|
232
244
|
}
|
|
233
245
|
|
|
234
246
|
let command = args.command;
|
|
@@ -276,6 +288,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
276
288
|
}
|
|
277
289
|
|
|
278
290
|
let shellEffects;
|
|
291
|
+
let combinedBashAbort = null;
|
|
279
292
|
try {
|
|
280
293
|
shellEffects = await analyzeShellCommandEffects(command, bashWorkDir);
|
|
281
294
|
} catch (err) {
|
|
@@ -423,7 +436,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
423
436
|
let bashAbortSignal = null;
|
|
424
437
|
try { bashAbortSignal = (await getAbortSignalForSession(options?.sessionId)) || null; }
|
|
425
438
|
catch { bashAbortSignal = null; }
|
|
426
|
-
|
|
439
|
+
combinedBashAbort = _combineAbortSignals(bashAbortSignal, options?.abortSignal || null);
|
|
427
440
|
// Sync path only: chain a trailing cwd probe so the session's final
|
|
428
441
|
// working directory persists to the next shell call. Async jobs run
|
|
429
442
|
// detached and are intentionally excluded (they never reach here). The
|
|
@@ -456,7 +469,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
456
469
|
env: spawnEnv,
|
|
457
470
|
cwd: bashWorkDir,
|
|
458
471
|
timeoutMs: timeout,
|
|
459
|
-
abortSignal: combinedBashAbort,
|
|
472
|
+
abortSignal: combinedBashAbort.signal,
|
|
460
473
|
autoBackgroundMs,
|
|
461
474
|
// On a foreground timeout, promote the still-running child to a
|
|
462
475
|
// tracked background job (unlimited) instead of killing it.
|
|
@@ -592,6 +605,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
592
605
|
return _prependDestructiveWarning(command, warningBlock ? `${warningBlock}\n${payload}` : payload);
|
|
593
606
|
}
|
|
594
607
|
finally {
|
|
608
|
+
combinedBashAbort?.cleanup?.();
|
|
595
609
|
if (shellEffects.mutationMode === 'paths') {
|
|
596
610
|
invalidateBuiltinResultCache(shellEffects.paths);
|
|
597
611
|
markCodeGraphDirtyPaths(shellEffects.paths);
|
|
@@ -38,6 +38,13 @@ function scheduleFlush(path, q) {
|
|
|
38
38
|
q.timer.unref?.();
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function deleteIfIdle(path, q) {
|
|
42
|
+
if (!q.timer && !q.flushing && !q.inFlight && q.chunks.length === 0
|
|
43
|
+
&& queues.get(path) === q) {
|
|
44
|
+
queues.delete(path);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
function _flush(path, q) {
|
|
42
49
|
if (q.flushing) return;
|
|
43
50
|
if (q.chunks.length === 0) return;
|
|
@@ -70,7 +77,7 @@ function _flush(path, q) {
|
|
|
70
77
|
if (q.chunks.length > 0) {
|
|
71
78
|
if (q.bytes >= BUFFER_FLUSH_BYTES) _flush(path, q);
|
|
72
79
|
else scheduleFlush(path, q);
|
|
73
|
-
}
|
|
80
|
+
} else deleteIfIdle(path, q);
|
|
74
81
|
});
|
|
75
82
|
}
|
|
76
83
|
|
|
@@ -120,7 +127,10 @@ export function drainPathSync(path) {
|
|
|
120
127
|
}
|
|
121
128
|
q.inFlight = null;
|
|
122
129
|
}
|
|
123
|
-
if (q.chunks.length === 0)
|
|
130
|
+
if (q.chunks.length === 0) {
|
|
131
|
+
deleteIfIdle(path, q);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
124
134
|
const data = q.chunks.join('');
|
|
125
135
|
q.chunks = [];
|
|
126
136
|
q.bytes = 0;
|
|
@@ -129,6 +139,7 @@ export function drainPathSync(path) {
|
|
|
129
139
|
} catch {
|
|
130
140
|
// Best-effort exit drain; nothing to recover from here.
|
|
131
141
|
}
|
|
142
|
+
deleteIfIdle(path, q);
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
/**
|
|
@@ -176,16 +176,16 @@ export function autoClearProviderDefaults(providerIdleMs = null) {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
// Agent terminal retention is deliberately narrower than Auto Clear's general
|
|
179
|
-
// resolution:
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
// Null means leave the completed agent alone (no live timer, row expiry, or
|
|
183
|
-
// durable store sweep).
|
|
179
|
+
// resolution: listed providers use their provider cache lifetime. Unlisted
|
|
180
|
+
// providers use the Advanced "default" row, or the built-in default when no
|
|
181
|
+
// override is configured, so completed agents are still eventually reclaimed.
|
|
184
182
|
export function resolveAgentTerminalReapMs(config, provider) {
|
|
185
183
|
const key = clean(provider).toLowerCase();
|
|
186
|
-
if (!key || key === 'default' || !Object.hasOwn(AUTO_CLEAR_PROVIDER_IDLE_MS, key)) return null;
|
|
187
184
|
const raw = config?.autoClear && typeof config.autoClear === 'object' ? config.autoClear : {};
|
|
188
185
|
const overrides = normalizeAutoClearProviderIdleMs(raw.providerIdleMs);
|
|
186
|
+
if (!key || key === 'default' || !Object.hasOwn(AUTO_CLEAR_PROVIDER_IDLE_MS, key)) {
|
|
187
|
+
return overrides.default ?? AUTO_CLEAR_PROVIDER_IDLE_MS.default;
|
|
188
|
+
}
|
|
189
189
|
return overrides[key] ?? AUTO_CLEAR_PROVIDER_IDLE_MS[key];
|
|
190
190
|
}
|
|
191
191
|
|
|
@@ -30,6 +30,7 @@ export function createLifecycleApi(deps) {
|
|
|
30
30
|
invalidateContextStatusCache, invalidatePreSessionToolSurface,
|
|
31
31
|
applyResolvedCwd, resolveRoute, applyDeferredToolSurface, standaloneTools,
|
|
32
32
|
pushTranscriptRebind,
|
|
33
|
+
notificationListeners, remoteStateListeners,
|
|
33
34
|
} = deps;
|
|
34
35
|
return {
|
|
35
36
|
async close(reason = 'cli-exit', options = {}) {
|
|
@@ -132,6 +133,9 @@ export function createLifecycleApi(deps) {
|
|
|
132
133
|
ok = mgr.closeSession(session.id, reason, { tombstone });
|
|
133
134
|
setSession(null);
|
|
134
135
|
}
|
|
136
|
+
invalidateContextStatusCache();
|
|
137
|
+
notificationListeners?.clear?.();
|
|
138
|
+
remoteStateListeners?.clear?.();
|
|
135
139
|
const shellJobsStop = globalThis.__mixdogShellJobsRuntimeLoaded === true
|
|
136
140
|
? import('../runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs')
|
|
137
141
|
.then((mod) => mod?.shutdownShellJobs?.(reason, { sync: !detach }))
|
|
@@ -33,6 +33,18 @@ export function createSessionTurnApi(deps) {
|
|
|
33
33
|
awaitInitialMcpConnect,
|
|
34
34
|
} = deps;
|
|
35
35
|
return {
|
|
36
|
+
getTurnLiveness() {
|
|
37
|
+
const sessionId = getSession()?.id;
|
|
38
|
+
if (!sessionId || typeof mgr.getSessionProgressSnapshot !== 'function') return null;
|
|
39
|
+
const snapshot = mgr.getSessionProgressSnapshot(sessionId);
|
|
40
|
+
if (!snapshot) return null;
|
|
41
|
+
return {
|
|
42
|
+
stage: snapshot.stage,
|
|
43
|
+
lastProgressAt: snapshot.lastProgressAt,
|
|
44
|
+
toolStartedAt: snapshot.toolStartedAt,
|
|
45
|
+
toolSelfDeadlineMs: snapshot.toolSelfDeadlineMs,
|
|
46
|
+
};
|
|
47
|
+
},
|
|
36
48
|
async ask(prompt, options = {}) {
|
|
37
49
|
setActiveTurnCount(getActiveTurnCount() + 1);
|
|
38
50
|
// Lazy code-graph prewarm: kick off the build ONCE, on the first real
|
|
@@ -509,7 +509,11 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
509
509
|
for (const [tag, sessionId] of [...tags.entries()]) {
|
|
510
510
|
if (indexedKeys.has(`${tag}\0${sessionId}`)) continue;
|
|
511
511
|
const session = getLiveSession(sessionId);
|
|
512
|
-
if (!session || session.closed)
|
|
512
|
+
if (!session || session.closed) {
|
|
513
|
+
tags.delete(tag);
|
|
514
|
+
tagAgents.delete(tag);
|
|
515
|
+
tagCwds.delete(tag);
|
|
516
|
+
}
|
|
513
517
|
}
|
|
514
518
|
if (!scanSessions) return;
|
|
515
519
|
for (const session of mgr.listSessions({ includeClosed: false }) || []) {
|
|
@@ -1514,6 +1518,9 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
1514
1518
|
}
|
|
1515
1519
|
for (const timer of reapTimers.values()) clearTimeout(timer);
|
|
1516
1520
|
reapTimers.clear();
|
|
1521
|
+
tags.clear();
|
|
1522
|
+
tagAgents.clear();
|
|
1523
|
+
tagCwds.clear();
|
|
1517
1524
|
flushWorkerIndexMutations();
|
|
1518
1525
|
writeWorkerRows((byKey) => byKey.clear());
|
|
1519
1526
|
return { closed, failed };
|
package/src/tui/App.jsx
CHANGED
|
@@ -107,7 +107,6 @@ import {
|
|
|
107
107
|
transcriptItemVariantKey,
|
|
108
108
|
transcriptMeasuredRowsCache,
|
|
109
109
|
buildTranscriptRowIndex,
|
|
110
|
-
transcriptStructureSignature,
|
|
111
110
|
transcriptRenderWindow,
|
|
112
111
|
} from './app/transcript-window.mjs';
|
|
113
112
|
import {
|
|
@@ -2951,6 +2950,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2951
2950
|
transcriptMeasureRef,
|
|
2952
2951
|
} = useTranscriptWindow({
|
|
2953
2952
|
items: state.items,
|
|
2953
|
+
structureRevision: state.structureRevision,
|
|
2954
|
+
streamingTail: state.streamingTail,
|
|
2954
2955
|
themeEpoch: state.themeEpoch,
|
|
2955
2956
|
frameColumns,
|
|
2956
2957
|
toolOutputExpanded,
|
|
@@ -832,7 +832,7 @@ export function buildTranscriptRowIndexIncremental(items, {
|
|
|
832
832
|
suppressMeasuredRowHeights = false,
|
|
833
833
|
measuredRowsVersion = 0,
|
|
834
834
|
cacheRef = null,
|
|
835
|
-
|
|
835
|
+
prefixRevision = null,
|
|
836
836
|
} = {}) {
|
|
837
837
|
const allItems = Array.isArray(items) ? items : [];
|
|
838
838
|
const tail = trailingStreamingItem(allItems);
|
|
@@ -850,11 +850,10 @@ export function buildTranscriptRowIndexIncremental(items, {
|
|
|
850
850
|
}
|
|
851
851
|
const prefixLen = allItems.length - 1;
|
|
852
852
|
const cache = holder.current;
|
|
853
|
-
// Prefix identity is carried by
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
// Fast path: same prefix length + tail id + prefixSig, and columns/expanded/
|
|
853
|
+
// Prefix identity is carried by the engine's monotonic structure revision.
|
|
854
|
+
// Every settled-item mutation, including same-length middle tool-card patches,
|
|
855
|
+
// bumps it without requiring a render-time walk over the transcript.
|
|
856
|
+
// Fast path: same prefix length + tail id + revision, and columns/expanded/
|
|
858
857
|
// suppress/version all match. Only the tail row can differ → recompute + append.
|
|
859
858
|
if (cache
|
|
860
859
|
&& cache.columns === columns
|
|
@@ -862,10 +861,10 @@ export function buildTranscriptRowIndexIncremental(items, {
|
|
|
862
861
|
&& cache.suppress === suppressMeasuredRowHeights
|
|
863
862
|
&& cache.version === measuredRowsVersion
|
|
864
863
|
&& cache.prefixLen === prefixLen
|
|
865
|
-
&&
|
|
866
|
-
&& cache.
|
|
864
|
+
&& prefixRevision != null
|
|
865
|
+
&& cache.prefixRevision === prefixRevision
|
|
867
866
|
&& cache.tailId === tail.id) {
|
|
868
|
-
//
|
|
867
|
+
// revision matches → prefix rows provably unchanged; NO prefix walk / no
|
|
869
868
|
// re-estimation. Only the tail row can differ, recompute it.
|
|
870
869
|
{
|
|
871
870
|
const tailMeasured = suppressMeasuredRowHeights
|
|
@@ -894,7 +893,7 @@ export function buildTranscriptRowIndexIncremental(items, {
|
|
|
894
893
|
suppress: suppressMeasuredRowHeights,
|
|
895
894
|
version: measuredRowsVersion,
|
|
896
895
|
prefixLen,
|
|
897
|
-
|
|
896
|
+
prefixRevision,
|
|
898
897
|
tailId: tail.id,
|
|
899
898
|
prefixRowsArr: full.rows.slice(0, prefixLen),
|
|
900
899
|
prefixPrefixRows: full.prefixRows.slice(0, prefixLen + 1),
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
hasStreamingRowStateToPrune,
|
|
19
19
|
transcriptItemVariantKey,
|
|
20
20
|
buildTranscriptRowIndexIncremental,
|
|
21
|
-
transcriptStructureSignature,
|
|
22
21
|
estimateTranscriptItemRowsCached,
|
|
23
22
|
setStreamingBottomPinned,
|
|
24
23
|
transcriptRenderWindow,
|
|
@@ -32,7 +31,9 @@ import {
|
|
|
32
31
|
import { shouldSuppressFullyFailedToolItem } from '../transcript-tool-failures.mjs';
|
|
33
32
|
|
|
34
33
|
export function useTranscriptWindow({
|
|
35
|
-
items,
|
|
34
|
+
items: settledItems,
|
|
35
|
+
structureRevision,
|
|
36
|
+
streamingTail,
|
|
36
37
|
themeEpoch,
|
|
37
38
|
frameColumns,
|
|
38
39
|
toolOutputExpanded,
|
|
@@ -129,42 +130,12 @@ export function useTranscriptWindow({
|
|
|
129
130
|
return fn;
|
|
130
131
|
}, []);
|
|
131
132
|
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// changes, so steady per-character growth keeps both memos warm.
|
|
139
|
-
//
|
|
140
|
-
// The signature itself is memoized on `items` identity (+columns/
|
|
141
|
-
// expanded) so re-renders that DO NOT touch items — drag motion, scroll,
|
|
142
|
-
// input typing — skip the O(n) signature walk entirely. During streaming the
|
|
143
|
-
// engine hands us a fresh `items` array each flush, so this memo
|
|
144
|
-
// recomputes and still tracks the streaming item's height correctly.
|
|
145
|
-
// Split the structure signature into prefix (all items except the trailing
|
|
146
|
-
// streaming item) + tail. On a streaming flush only the tail item object is
|
|
147
|
-
// replaced; every prefix item keeps identity, so the prefix sig is O(1)-
|
|
148
|
-
const streamingTailItem = useMemo(() => {
|
|
149
|
-
const last = (items || []).length > 0 ? items[items.length - 1] : null;
|
|
150
|
-
return last && last.kind === 'assistant' && last.streaming ? last : null;
|
|
151
|
-
}, [items]);
|
|
152
|
-
const prefixLen = streamingTailItem ? (items || []).length - 1 : (items || []).length;
|
|
153
|
-
// Key on `items` identity (engine swaps the array every flush — INCLUDING a
|
|
154
|
-
// tool-card patch that replaces a MIDDLE item object), so a same-length
|
|
155
|
-
// middle-item replacement is caught (new object → fresh WeakMap fragment →
|
|
156
|
-
// different prefixSig string). The walk itself is cheap: transcript-
|
|
157
|
-
// StructureSignature reads a WeakMap sigPart per item (no re-estimation for
|
|
158
|
-
// unchanged objects) and joins — O(n) map lookups, not O(n) measurement. The
|
|
159
|
-
// row-index memo stays keyed on the prefixSig STRING, so a tail-only flush
|
|
160
|
-
// (identical prefix objects → identical string) still skips the row rebuild.
|
|
161
|
-
const prefixSig = useMemo(
|
|
162
|
-
() => transcriptStructureSignature(
|
|
163
|
-
streamingTailItem ? (items || []).slice(0, prefixLen) : (items || []),
|
|
164
|
-
frameColumns, toolOutputExpanded,
|
|
165
|
-
),
|
|
166
|
-
[items, streamingTailItem, prefixLen, frameColumns, toolOutputExpanded],
|
|
167
|
-
);
|
|
133
|
+
// The settled array no longer changes during streaming. Geometry is keyed by
|
|
134
|
+
// the engine revision plus the live tail's resolved height, so same-height
|
|
135
|
+
// text flushes do not copy/walk the settled prefix or rerun heavy memos.
|
|
136
|
+
const streamingTailItem = streamingTail?.kind === 'assistant' && streamingTail.streaming
|
|
137
|
+
? streamingTail
|
|
138
|
+
: null;
|
|
168
139
|
const scrolledUpRowsForPin = Math.max(0, Number(scrollTargetRef.current) || 0);
|
|
169
140
|
const transcriptPinnedForStreaming = followingRef.current
|
|
170
141
|
|| scrolledUpRowsForPin <= transcriptBottomSlackRows;
|
|
@@ -173,13 +144,18 @@ export function useTranscriptWindow({
|
|
|
173
144
|
// applies while bottom-pinned (right geometry on first commit, no judder) and
|
|
174
145
|
// every tail-resolving path reads the same height (no sig/geometry divergence).
|
|
175
146
|
setStreamingBottomPinned(transcriptPinnedForStreaming);
|
|
176
|
-
const
|
|
177
|
-
?
|
|
178
|
-
:
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
147
|
+
const tailRows = streamingTailItem
|
|
148
|
+
? estimateTranscriptItemRowsCached(streamingTailItem, frameColumns, toolOutputExpanded)
|
|
149
|
+
: 0;
|
|
150
|
+
const tailSig = streamingTailItem ? `${streamingTailItem.id}:${tailRows}` : '_';
|
|
151
|
+
const revision = Math.max(0, Number(structureRevision) || 0);
|
|
152
|
+
const transcriptItems = useMemo(
|
|
153
|
+
() => streamingTailItem ? [...(settledItems || []), streamingTailItem] : (settledItems || []),
|
|
154
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- tail text at the same resolved height is injected into the bounded visible slice below
|
|
155
|
+
[settledItems, revision, streamingTailItem?.id, tailRows],
|
|
182
156
|
);
|
|
157
|
+
const transcriptStructureSig = `${revision}#${tailSig}`;
|
|
158
|
+
const transcriptStreamingActive = !!streamingTailItem;
|
|
183
159
|
const scrolledUpForStreamingMeasure = scrolledUpRowsForPin > transcriptBottomSlackRows;
|
|
184
160
|
const prevEstimateGeometry = transcriptGeomRef.current?.suppressMeasuredRowHeights === true;
|
|
185
161
|
const hasStreamingReadingAnchor = !!transcriptAnchorRef.current
|
|
@@ -199,15 +175,15 @@ export function useTranscriptWindow({
|
|
|
199
175
|
// rebuild for the settled prefix. All those invalidators are folded into the
|
|
200
176
|
// memo deps below (sig captures item/column/expanded structure; the rest are
|
|
201
177
|
// listed explicitly), so the memo only recomputes when one of them changes.
|
|
202
|
-
const transcriptRowIndex = useMemo(() => buildTranscriptRowIndexIncremental(
|
|
178
|
+
const transcriptRowIndex = useMemo(() => buildTranscriptRowIndexIncremental(transcriptItems, {
|
|
203
179
|
columns: frameColumns,
|
|
204
180
|
toolOutputExpanded,
|
|
205
181
|
suppressMeasuredRowHeights,
|
|
206
182
|
measuredRowsVersion,
|
|
207
183
|
cacheRef: incrementalRowIndexCacheRef,
|
|
208
|
-
|
|
209
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps --
|
|
210
|
-
}), [
|
|
184
|
+
prefixRevision: revision,
|
|
185
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- revision/tail height capture structural geometry; measuredRowsVersion folds in measured corrections
|
|
186
|
+
}), [revision, tailSig, frameColumns, toolOutputExpanded, measuredRowsVersion, suppressMeasuredRowHeights]);
|
|
211
187
|
// ── Same-frame anchor lock ───────────────────────────────────────────────
|
|
212
188
|
// While the user reads older transcript (anchor captured, not dirty), resolve
|
|
213
189
|
// the scroll offset that keeps the anchored viewport-top row fixed for THIS
|
|
@@ -243,7 +219,7 @@ export function useTranscriptWindow({
|
|
|
243
219
|
if (anchorLockActive) {
|
|
244
220
|
const locked = resolveAnchorScrollOffset({
|
|
245
221
|
anchor: transcriptAnchorRef.current,
|
|
246
|
-
items:
|
|
222
|
+
items: transcriptItems,
|
|
247
223
|
curPrefix: curPrefixForLock,
|
|
248
224
|
totalRows: lockTotalRows,
|
|
249
225
|
viewRows: lockViewRows,
|
|
@@ -288,7 +264,7 @@ export function useTranscriptWindow({
|
|
|
288
264
|
const captured = { id: anchorItem.id, offset: Math.max(0, prevTopRow - (prevPrefix[idx] || 0)) };
|
|
289
265
|
const locked = resolveAnchorScrollOffset({
|
|
290
266
|
anchor: captured,
|
|
291
|
-
items:
|
|
267
|
+
items: transcriptItems,
|
|
292
268
|
curPrefix: curPrefixForLock,
|
|
293
269
|
totalRows: lockTotalRows,
|
|
294
270
|
viewRows: lockViewRows,
|
|
@@ -343,7 +319,7 @@ export function useTranscriptWindow({
|
|
|
343
319
|
const captured = { id: anchorItem.id, offset: Math.max(0, prevTopRow - (prevPrefix[idx] || 0)) };
|
|
344
320
|
const locked = resolveAnchorScrollOffset({
|
|
345
321
|
anchor: captured,
|
|
346
|
-
items:
|
|
322
|
+
items: transcriptItems,
|
|
347
323
|
curPrefix: curPrefixForLock,
|
|
348
324
|
totalRows: lockTotalRows,
|
|
349
325
|
viewRows: lockViewRows,
|
|
@@ -376,7 +352,7 @@ export function useTranscriptWindow({
|
|
|
376
352
|
// measuredRowsVersion bumps, the row index absorbs the growth (idEntry.rows ==
|
|
377
353
|
// the new measured height) and the live estimate matches it, so delta → 0.
|
|
378
354
|
if (scrolledUp && !followingRef.current) {
|
|
379
|
-
const growth = streamingTailMountedGrowth(
|
|
355
|
+
const growth = streamingTailMountedGrowth(transcriptItems, frameColumns, toolOutputExpanded);
|
|
380
356
|
// Only compensate when the tail is actually mounted in the rendered slice
|
|
381
357
|
// (viewport + overscan). Off-slice it is represented by a row-index-sized
|
|
382
358
|
// bottom spacer that does NOT physically grow this frame, so shifting the
|
|
@@ -394,7 +370,7 @@ export function useTranscriptWindow({
|
|
|
394
370
|
}
|
|
395
371
|
}
|
|
396
372
|
}
|
|
397
|
-
const transcriptWindow = useMemo(() => transcriptRenderWindow(
|
|
373
|
+
const transcriptWindow = useMemo(() => transcriptRenderWindow(transcriptItems, {
|
|
398
374
|
scrollOffset: renderScrollOffset,
|
|
399
375
|
viewportHeight: transcriptContentHeight,
|
|
400
376
|
columns: frameColumns,
|
|
@@ -451,7 +427,7 @@ export function useTranscriptWindow({
|
|
|
451
427
|
prefixRows: transcriptRowIndex?.prefixRows || null,
|
|
452
428
|
totalRows: Math.max(0, Number(transcriptWindow.totalRows) || 0),
|
|
453
429
|
viewRows: Math.max(1, Number(transcriptContentHeight) || 1),
|
|
454
|
-
items:
|
|
430
|
+
items: transcriptItems || null,
|
|
455
431
|
// The offset THIS frame actually rendered with. The same-frame anchor
|
|
456
432
|
// CAPTURE for a missing/dirty anchor (above) reads this from the PREVIOUS
|
|
457
433
|
// frame to reconstruct the exact top-edge row that was on screen, so the
|
|
@@ -469,10 +445,16 @@ export function useTranscriptWindow({
|
|
|
469
445
|
// height changes. Re-slice the live `items` over the memo's stable
|
|
470
446
|
// [startIndex, endIndex) bounds so the on-screen text is always current
|
|
471
447
|
// while the expensive indexing/windowing stays warm.
|
|
472
|
-
const transcriptVisibleItems = (
|
|
448
|
+
const transcriptVisibleItems = (transcriptItems || []).slice(
|
|
473
449
|
transcriptWindow.startIndex,
|
|
474
450
|
transcriptWindow.endIndex,
|
|
475
451
|
);
|
|
452
|
+
if (streamingTailItem && transcriptVisibleItems.length > 0) {
|
|
453
|
+
const last = transcriptVisibleItems.length - 1;
|
|
454
|
+
if (transcriptVisibleItems[last]?.id === streamingTailItem.id) {
|
|
455
|
+
transcriptVisibleItems[last] = streamingTailItem;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
476
458
|
// The bottom meta band is spinner-only, so nothing is pulled out of the
|
|
477
459
|
// transcript for it. A finished turn's done row (turndone/statusdone) renders
|
|
478
460
|
// inline in scrollback like any other item — no filtering, no double-paint.
|
|
@@ -703,7 +685,7 @@ export function useTranscriptWindow({
|
|
|
703
685
|
// move the item's prefix start and are absorbed the same way. No deltas, no
|
|
704
686
|
// fallback, no drift.
|
|
705
687
|
const viewRows = Math.max(1, Number(transcriptContentHeight) || 1);
|
|
706
|
-
const itemList =
|
|
688
|
+
const itemList = transcriptItems || [];
|
|
707
689
|
let anchor = transcriptAnchorRef.current;
|
|
708
690
|
// (Re)capture the anchor from the current viewport-top edge when missing or
|
|
709
691
|
// invalidated by a manual scroll. anchorRow = absolute row at the top edge.
|