From 25d39da00ee9d6565736b345fd7c0815ccd83c6e Mon Sep 17 00:00:00 2001 From: webtecnica Date: Tue, 21 Jul 2026 02:12:06 -0300 Subject: [PATCH] fix(stream): preserve reader viewport anchor through live-to-settlement collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/messages.js | 9 ++++++++- static/ui.js | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/static/messages.js b/static/messages.js index 9f1532f1..75a3ddbe 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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); diff --git a/static/ui.js b/static/ui.js index 3e92cd34..a156eb3a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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); }