fix(stream): preserve reader viewport anchor through live-to-settlement collapse

Issue #6385: when a streaming turn settles, the two-render sequence
(keep-open expanded worklog → collapsed worklog) could displace the
reader's viewport because the second render captured its scroll snapshot
from the intermediate expanded state, not from the original live DOM.

Root cause
----------
The STREAM_DONE handler in messages.js:

1. Arms keep-settled-worklog-open token → renderMessages({preserveScroll:true})
   → worklog rendered EXPANDED (height-stable swap preventing shrink jump)

2. Disarms token → _renderMessagesWithScrollSnapshot()
   → This function called _captureMessageScrollSnapshot() which captured
     the scroll anchor from the expanded-worklog DOM (step 1 output),
     then called renderMessages with the worklog COLLAPSED (keep-open gone),
     then tried to restore from the expanded-state snapshot.

   The snapshot's semantic anchor (row key, session idx, top offset) was
   captured from a DOM where the worklog was expanded. After the collapse
   render the worklog is no longer at that position — anchor keys don't
   match, the semantic restore fails, and the viewport jumps to a
   unrelated scrollTop.

Fix
---
- Capture the scroll snapshot from the LIVE DOM (before any settlement
  renders) and pass it as  to the second render.
- Modify _renderMessagesWithScrollSnapshot() to accept a pre-captured
  snapshot via options._prescrollSnapshot, using it instead of capturing
  a fresh one from the current DOM state.

This ensures the collapse render anchors to the content the reader was
actually viewing, not to a stale intermediate expanded-worklog position.

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
This commit is contained in:
webtecnica committed 2026-07-21 02:12:06 -03:00
1 parent c0d9d81512
commit 25d39da00e
2 files changed
+15 -2

No files matched your search

+8 -1
View File
@@ -5941,10 +5941,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// render also populates the cache with the correctly-collapsed DOM, and
// the same-frame JS restore absorbs the collapse so there is no jump.
// (#5260 gate-cert: keep-open must be transient + uncached for everyone.)
// #6385: capture the scroll snapshot from the LIVE DOM before arming
// keep-open, so the collapse render below anchors to the content the
// reader was actually viewing — not to a stale intermediate state where
// the worklog was temporarily expanded.
const _doneLiveScrollSnapshot=typeof _captureMessageScrollSnapshot==='function'
? _captureMessageScrollSnapshot()
: null;
if(typeof _armKeepSettledWorklogOpen==='function') _armKeepSettledWorklogOpen(_settledStreamId);
syncTopbar();renderMessages({preserveScroll:true});
if(typeof _disarmKeepSettledWorklogOpen==='function') _disarmKeepSettledWorklogOpen();
if(typeof _renderMessagesWithScrollSnapshot==='function') _renderMessagesWithScrollSnapshot();
if(typeof _renderMessagesWithScrollSnapshot==='function') _renderMessagesWithScrollSnapshot({_prescrollSnapshot:_doneLiveScrollSnapshot});
else renderMessages({preserveScroll:true});
if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();
if(typeof noteWorkspaceMutationsFromToolCalls==='function') noteWorkspaceMutationsFromToolCalls(S.toolCalls);
+7 -1
View File
@@ -15086,7 +15086,13 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){
}
}
function _renderMessagesWithScrollSnapshot(options){
const scrollSnapshot=_captureMessageScrollSnapshot();
// Accept an optional pre-captured scroll snapshot via _prescrollSnapshot.
// When provided, it is used INSTEAD of capturing a fresh one from the current
// DOM state — essential for the STREAM_DONE collapse render: the caller has
// already captured the snapshot from the LIVE DOM (before keep-open was armed),
// and re-capturing from the intermediate expanded-worklog state would capture
// stale anchors that no longer exist after the worklog collapses. (#6385)
const scrollSnapshot=(options&&options._prescrollSnapshot)||_captureMessageScrollSnapshot();
renderMessages({...(options||{}),preserveScroll:true});
_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
}