From 548cdfb43bc0a643a0510e010b5a6afa5ecbb46f Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 19:36:31 -0700 Subject: [PATCH] fix(native-chat): let a retraction prune its rows without retiring the epoch Correcting the previous commit. Skipping a retraction frame outright stopped it pruning the mirrored rows, so a worktree the host no longer publishes would have kept a chat on screen with nothing behind it. Apply the frame as before and clear its cursors instead of recording them, which is what the mainstream session-tabs path does. The unpublished sentinel keeps its cursor now too: it is skipped rather than cleared, so a stale frame arriving late is still fenced. Adds the case the earlier version would have broken. --- ...ructured-session-reveal-visibility.test.ts | 11 ++++++++ .../snapshot-apply.ts | 27 ++++++++++++------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts b/src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts index a814972dfe5..d3f7a71f6ac 100644 --- a/src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts +++ b/src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts @@ -140,6 +140,17 @@ describe('a revealed chat survives the frames the reveal provokes', () => { expect(chatTabIds(state)).toEqual([SESSION_TAB]) }) + it('still prunes the mirrored rows when the host retracts the worktree', () => { + // The retraction must not merely stop fencing later frames — it has to take the rows with it, + // or a worktree the host no longer publishes keeps a chat on screen that nothing backs. + let state = apply(baseState(), chatFrame(RENDERER_EPOCH, 120)) + expect(chatTabIds(state)).toEqual([SESSION_TAB]) + + state = apply(state, { ...chatFrame('removed:abc', 0), tabs: [], removed: true } as never) + + expect(chatTabIds(state)).toEqual([]) + }) + it('still ignores a genuinely superseded republication', () => { // The fences exist for a reason: without an intervening non-publication frame, an older // version under the same lineage must still lose. diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index b4ddd770323..da60ed99844 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -74,16 +74,11 @@ export function applyLocalStructuredSessionTabSnapshots< if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') { continue } - // A frame that carries no publication is not a later publication to fence against. Recording - // its epoch retires the renderer's own — which is a module constant for the process lifetime, - // and nothing un-retires it — so every republication afterwards is dropped until a reload - // mints a new one. That is what made a revealed chat invisible: the click's own inventory - // refresh reads `none`/v0 for a worktree the host holds no entry for, and poisons the reveal - // that follows it. The mainstream session-tabs path clears its tracking here for the same - // reason; this one recorded the sentinel instead. - if (snapshot.removed === true || !hostSnapshotAffirmsWorktreeContents(snapshot)) { - localStructuredSessionVersionByWorktree.delete(snapshot.worktree) - localStructuredSessionEpochHistoryByWorktree.delete(snapshot.worktree) + // "Ask me later", not an answer: a worktree the host holds no entry for still answers a forced + // inventory, with `none` at version 0. Absence there proves nothing, so it neither applies nor + // records — recording it would retire the epoch below. Its cursor is left alone, so a genuinely + // stale frame arriving late is still fenced. + if (!hostSnapshotAffirmsWorktreeContents(snapshot)) { continue } const prior = localStructuredSessionVersionByWorktree.get(snapshot.worktree) @@ -109,6 +104,18 @@ export function applyLocalStructuredSessionTabSnapshots< } ) next = patch === next ? next : ({ ...next, ...patch } as State) + if (snapshot.removed === true) { + // A retraction still had to be applied above — the mirrored rows must go — but it is not a + // publication to fence later frames against. The renderer publishes under one epoch string + // for its whole lifetime, so recording a retraction retires that epoch permanently, and + // every republication afterwards is dropped until a reload mints a new one. That is what + // left a revealed chat invisible: closing the last chat prunes the host's entry, the + // retraction retired the renderer's epoch, and the tab the reveal published was discarded. + // The mainstream session-tabs path clears its tracking here for exactly this reason. + localStructuredSessionVersionByWorktree.delete(snapshot.worktree) + localStructuredSessionEpochHistoryByWorktree.delete(snapshot.worktree) + continue + } localStructuredSessionVersionByWorktree.set(snapshot.worktree, { publicationEpoch: snapshot.publicationEpoch, snapshotVersion: snapshot.snapshotVersion