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 d819cc7e5a6..e4649f54055 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 @@ -153,6 +153,21 @@ describe('a revealed chat survives the frames the reveal provokes', () => { expect(chatTabIds(state)).toEqual([]) }) + it('does not retire the renderer\u2019s own epoch when a reveal republishes under a new one', () => { + // The consumer is also the publisher here. If the retraction leaves its epoch history behind, + // recording the reveal's fresh epoch retires the renderer's own, and the NEXT chat it + // publishes under that epoch is dropped — the same symptom, one cycle later. + let state = apply(baseState(), chatFrame(RENDERER_EPOCH, 120)) + state = apply(state, emptyFrame(RENDERER_EPOCH, 121)) + state = apply(state, { ...emptyFrame('removed:abc', 0), removed: true } as never) + state = apply(state, chatFrame('structured:mf3k1', 1)) // reveal, fresh lineage + + state = apply(state, emptyFrame('structured:mf3k1', 2)) // closed again + state = apply(state, chatFrame(RENDERER_EPOCH, 130)) // renderer publishes a new chat + + 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. 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 b5563e5a71b..75d1bce6108 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 @@ -110,10 +110,15 @@ export function applyLocalStructuredSessionTabSnapshots< // is one string for the whole process lifetime, and every republication afterwards would be // dropped until a reload minted a new one. That is what left a revealed chat invisible. // - // Only the recording is skipped: the cursor and the epoch history stay. The host mints a - // fresh epoch when it rebuilds a pruned entry, so a republication is never gated by the - // retained cursor — while dropping it would leave an in-flight frame from before the close - // free to re-apply and strand a row for a worktree the host no longer publishes. + // The cursor stays: the host mints a fresh epoch when it rebuilds a pruned entry, so a + // republication is never gated by it, while dropping it would leave a frame issued before + // the close free to land afterwards and strand a row nothing republishes. + // + // The epoch history goes. Unlike the mainstream path — where the epochs belong to a remote + // publisher and the history is a useful tombstone — here the consumer IS the publisher, and + // `current` is the renderer's own lifetime epoch. Keep it, and the next frame under any + // other epoch retires that one for good, which is this bug again one cycle later. + localStructuredSessionEpochHistoryByWorktree.delete(snapshot.worktree) continue } localStructuredSessionVersionByWorktree.set(snapshot.worktree, {