Release XR (v0.51.662): ignore DOM-wipe scroll clamp during render (#4934)

Release XR (v0.51.662): ignore DOM-wipe scroll clamp during render (#4934)
This commit is contained in:
nesquena-hermes 2026-06-25 13:38:51 -07:00 committed by GitHub
commit a569d6ba3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 35 additions and 0 deletions

View file

@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.662] — 2026-06-25 — Release XR (live replies keep following during transcript rebuilds)
### Fixed
- **A streaming reply no longer stops following (or appears to jump backward) when the transcript rebuilds mid-stream.** `renderMessages` rebuilds the message pane by clearing it (`innerHTML=''`), and during that brief gap the scroll container can collapse and the browser clamps `scrollTop` to 0, emitting a scroll event. The scroll listener was interpreting that render artifact as a deliberate user scroll-up and unpinning the reader, so the live reply stopped auto-following. The DOM-wipe scroll is now marked programmatic and ignored; genuine user scroll-up (wheel/touch) during a render is still detected and still unpins. Thanks @allenliang2022. (#4934)
## [v0.51.661] — 2026-06-25 — Release XQ (no more fabricated multi-hour processing time)
### Fixed

View file

@ -11316,6 +11316,13 @@ function renderMessages(options){
// Mobile scroll-jank fix: temporarily disable overflow-anchor so Chromium
// cannot re-anchor to the topmost row during the DOM wipe-and-rebuild gap.
if(window._fixMobileScrollJank) window._fixMobileScrollJank();
// The DOM wipe can briefly collapse #msgInner to zero height, causing the
// browser to clamp #messages.scrollTop to 0 and emit a scroll event. That
// event is a render artifact, not user intent; if the scroll listener sees it
// with _programmaticScroll=false, it marks the reader manually unpinned and
// the live reply stops following / appears to jump backward.
_programmaticScroll=true;
_programmaticScrollSetAt=performance.now();
inner.innerHTML='';
const compressionNode=compressionState?_compressionCardsNode(compressionState):null;
const {message:referenceMessage, rawIdx:referenceMessageRawIdx}=_latestCompressionReferenceMessage(
@ -12524,6 +12531,7 @@ function renderMessages(options){
}
_updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, virtualWindow);
_recycleStash.clear();
if(typeof _deferClearProgrammaticScroll==='function') _deferClearProgrammaticScroll(160);
}
function _toolDisplayName(tc){

View file

@ -83,6 +83,27 @@ def test_rebuild_path_calls_fix_before_wipe():
)
def test_rebuild_path_marks_dom_wipe_scroll_as_programmatic():
# During innerHTML='' the scroller can transiently collapse to clientHeight
# and clamp scrollTop to 0. That browser event must be suppressed as
# programmatic; otherwise the scroll listener treats it as user upward
# intent and disables live auto-follow.
fix_idx = UI_JS.find("window._fixMobileScrollJank()")
assert fix_idx != -1, "renderMessages() guard call not found"
wipe_idx = UI_JS.find("innerHTML=''", fix_idx)
assert wipe_idx != -1, "innerHTML='' not found after _fixMobileScrollJank()"
window = UI_JS[fix_idx:wipe_idx]
assert "_programmaticScroll=true" in window, (
"renderMessages() must mark the DOM wipe/rebuild scroll event as "
"programmatic before innerHTML='' can clamp scrollTop."
)
assert "_programmaticScrollSetAt=performance.now()" in window
assert UI_JS.find("_deferClearProgrammaticScroll(160)", wipe_idx) != -1, (
"renderMessages() must clear the programmatic-scroll suppression after "
"the rebuild/post-render paint window."
)
def test_streaming_tick_calls_fix_before_dom_writes():
# The streaming render tick in messages.js must call _fixMobileScrollJank()
# before _lastRenderMs=performance.now() so anchor suppression covers every