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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-terminal-reap-test.mjs +6 -6
  3. package/scripts/execution-resume-esc-integration-test.mjs +4 -2
  4. package/scripts/steering-drain-buckets-test.mjs +140 -5
  5. package/scripts/tui-transcript-perf-test.mjs +279 -0
  6. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
  7. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -0
  8. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
  9. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  10. package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
  11. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  12. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
  13. package/src/runtime/shared/buffered-appender.mjs +13 -2
  14. package/src/session-runtime/config-helpers.mjs +6 -6
  15. package/src/session-runtime/lifecycle-api.mjs +4 -0
  16. package/src/session-runtime/runtime-core.mjs +1 -0
  17. package/src/session-runtime/session-turn-api.mjs +12 -0
  18. package/src/standalone/agent-tool.mjs +8 -1
  19. package/src/tui/App.jsx +2 -1
  20. package/src/tui/app/transcript-window.mjs +9 -10
  21. package/src/tui/app/use-transcript-window.mjs +38 -56
  22. package/src/tui/dist/index.mjs +354 -163
  23. package/src/tui/engine/agent-job-feed.mjs +76 -6
  24. package/src/tui/engine/session-api.mjs +7 -1
  25. package/src/tui/engine/session-flow.mjs +3 -1
  26. package/src/tui/engine/tool-card-results.mjs +10 -5
  27. package/src/tui/engine/turn.mjs +96 -36
  28. package/src/tui/engine.mjs +136 -37
  29. 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) { for (const w of waiters) w.resolve(); }
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
- try { a.addEventListener('abort', () => onAbort(a), { once: true }); } catch {}
142
- try { b.addEventListener('abort', () => onAbort(b), { once: true }); } catch {}
143
- return ctl.signal;
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
- return executeBashSessionTool('bash_session', effectiveArgs, bashWorkDir, { abortSignal: combinedPersistAbort, sessionId: options?.sessionId });
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
- const combinedBashAbort = _combineAbortSignals(bashAbortSignal, options?.abortSignal || null);
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) return;
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: only listed provider rows participate. In particular, neither
180
- // the global idleMs nor the Advanced "default" row can retain a completed
181
- // agent, because those values do not describe a provider cache lifetime.
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 }))
@@ -2259,6 +2259,7 @@ export async function createMixdogSessionRuntime({
2259
2259
  resolveCwdPath,
2260
2260
  agentStatusState,
2261
2261
  notificationListeners,
2262
+ remoteStateListeners,
2262
2263
  awaitInitialMcpConnect,
2263
2264
  });
2264
2265
 
@@ -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) tags.delete(tag);
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
- prefixSig = null,
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 `prefixSig` (a hash of every prefix item's
854
- // WeakMap sigPart, computed once in the hook). String equality catches a
855
- // same-length MIDDLE-item replacement (tool-card patch swaps a middle object
856
- // fresh fragment different sig), which a last-item ref check would miss.
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
- && prefixSig != null
866
- && cache.prefixSig === prefixSig
864
+ && prefixRevision != null
865
+ && cache.prefixRevision === prefixRevision
867
866
  && cache.tailId === tail.id) {
868
- // prefixSig matches → prefix rows provably unchanged; NO prefix walk / no
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
- prefixSig,
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
- // Key the heavy O(n) row-index + windowing memos on a STRUCTURE signature
133
- // instead of the `items` array identity. The engine swaps `items`
134
- // for a new array on every streaming flush (~8ms) while only the final
135
- // assistant item's text grows; depending on array identity re-ran both memos
136
- // each delta frame and visibly throttled the stream. The signature changes
137
- // only when transcript structure or the streaming item's estimated height
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 tailSig = streamingTailItem
177
- ? `a${streamingTailItem.id}:${estimateTranscriptItemRowsCached(streamingTailItem, frameColumns, toolOutputExpanded)}`
178
- : '_';
179
- const transcriptStructureSig = `${prefixSig}#${tailSig}`;
180
- const transcriptStreamingActive = (items || []).some(
181
- (item) => item?.kind === 'assistant' && item?.streaming,
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(items, {
178
+ const transcriptRowIndex = useMemo(() => buildTranscriptRowIndexIncremental(transcriptItems, {
203
179
  columns: frameColumns,
204
180
  toolOutputExpanded,
205
181
  suppressMeasuredRowHeights,
206
182
  measuredRowsVersion,
207
183
  cacheRef: incrementalRowIndexCacheRef,
208
- prefixSig,
209
- // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: prefixSig/tailSig capture the relevant item changes (raw `items` dropped so a tail-only flush never re-enters the builder via array identity); measuredRowsVersion folds in app-level measured height corrections
210
- }), [prefixSig, tailSig, measuredRowsVersion, suppressMeasuredRowHeights]);
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: 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: 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: 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(items, frameColumns, toolOutputExpanded);
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(items, {
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: items || null,
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 = (items || []).slice(
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 = items || [];
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.