diff --git a/static/ui.js b/static/ui.js index 2d8cc95f4..ccc498e69 100644 --- a/static/ui.js +++ b/static/ui.js @@ -11332,10 +11332,33 @@ function _captureMessageScrollSnapshot(){ userUnpinned:_messageUserUnpinned, }; } +function _restorePinnedMessageScrollSnapshot(snapshot){ + const el=$('messages'); + if(!el||!snapshot||snapshot.pinned!==true||snapshot.userUnpinned===true) return false; + const maxTop=Math.max(0,el.scrollHeight-el.clientHeight); + const bottom=Number(snapshot.bottom); + const target=Number.isFinite(bottom)?maxTop-Math.max(0,bottom):maxTop; + _programmaticScroll=true;_programmaticScrollSetAt=performance.now(); + el.scrollTop=Math.max(0,Math.min(target,maxTop)); + // Sync _lastScrollTop after programmatic restore so sticky-unpin does not false-trigger (#1731). + _lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight; + _messageUserUnpinned=false; + _scrollPinned=true; + _nearBottomCount=2; + if(typeof _deferClearProgrammaticScroll==='function') _deferClearProgrammaticScroll(); + else requestAnimationFrame(()=>{ setTimeout(()=>{ _programmaticScroll=false; },0); }); + return true; +} function _restoreMessageScrollSnapshot(snapshot){ const el=$('messages'); if(!el||!snapshot) return; const maxTop=Math.max(0,el.scrollHeight-el.clientHeight); + // If the reader was following the live tail, preserve the tail-relative bottom + // distance. Do not semantic-anchor to the first visible row: live Worklog/ + // activity rebuilds can remount an older top-of-viewport anchor and yank a + // pinned streaming transcript upward. Semantic anchors remain for manual + // unpinned reading positions below. + if(_restorePinnedMessageScrollSnapshot(snapshot)) return; let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function') ? _restoreMessageViewportAnchor(snapshot.anchor,0) : false; @@ -11395,6 +11418,10 @@ window._fixMobileScrollJank=function _fixMobileScrollJank(){ function _restoreMessageScrollSnapshotSameFrame(snapshot){ const el=$('messages'); if(!el||!snapshot) return; + // Same-frame live DOM updates (tool/worklog/activity rows) are the hot path for + // streaming. Pinned followers must stay tail-relative here too; restoring the + // semantic viewport anchor is only safe for explicitly unpinned readers. + if(_restorePinnedMessageScrollSnapshot(snapshot)) return; let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function') ? _restoreMessageViewportAnchor(snapshot.anchor,0) : false; diff --git a/tests/test_issue4295_midstream_scroll_anchor.py b/tests/test_issue4295_midstream_scroll_anchor.py index ae549ea45..a27e9fc5b 100644 --- a/tests/test_issue4295_midstream_scroll_anchor.py +++ b/tests/test_issue4295_midstream_scroll_anchor.py @@ -86,6 +86,7 @@ function renderMessages(options) {{ }} {_function_body(UI_JS, "_restoreMessageViewportAnchor")} {_function_body(UI_JS, "_remountMessageViewportAnchor")} +{_function_body(UI_JS, "_restorePinnedMessageScrollSnapshot")} {_function_body(UI_JS, "_restoreMessageScrollSnapshot")} _restoreMessageScrollSnapshot({{ anchor: {{ rawIdx: 20, topOffset: 15 }}, @@ -235,6 +236,7 @@ function _messageVirtualScrollTopForVisibleIdx() {{ throw new Error('not expecte function renderMessages() {{ throw new Error('not expected'); }} {_function_body(UI_JS, "_restoreMessageViewportAnchor")} {_function_body(UI_JS, "_remountMessageViewportAnchor")} +{_function_body(UI_JS, "_restorePinnedMessageScrollSnapshot")} {_function_body(UI_JS, "_restoreMessageScrollSnapshot")} _restoreMessageScrollSnapshot({{ anchor: null, diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 3079ce951..90c3ec899 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -506,6 +506,7 @@ eval(extractFunc('_messageSessionIndexForRawIdx')); eval(extractFunc('_messageRawIdxForSessionIndex')); eval(extractFunc('_messageVirtualScrollTopForVisibleIdx')); eval(extractFunc('_remountMessageViewportAnchor')); +eval(extractFunc('_restorePinnedMessageScrollSnapshot')); eval(extractFunc('_restoreMessageScrollSnapshotSameFrame')); const snapshot = { diff --git a/tests/test_tars_scroll_reset_regressions.py b/tests/test_tars_scroll_reset_regressions.py index 62c1cf7b7..2a125623d 100644 --- a/tests/test_tars_scroll_reset_regressions.py +++ b/tests/test_tars_scroll_reset_regressions.py @@ -171,6 +171,33 @@ def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild(): assert "_programmaticScroll=true" in restore +def test_pinned_preserve_scroll_uses_bottom_distance_before_viewport_anchor(): + """Pinned/following streams must not remount an older viewport anchor. + + Live Worklog/activity rebuilds call the same snapshot helpers as reader + preservation. For pinned followers, the stable invariant is tail-relative + bottom distance, not the first visible row. Restoring the semantic anchor in + that state can yank a long streaming transcript upward/topward. + """ + helper = _function_body(UI_JS, "function _restorePinnedMessageScrollSnapshot") + restore = _function_body(UI_JS, "function _restoreMessageScrollSnapshot") + same_frame = _function_body(UI_JS, "function _restoreMessageScrollSnapshotSameFrame") + + assert "snapshot.pinned!==true||snapshot.userUnpinned===true" in helper + assert "maxTop-Math.max(0,bottom)" in helper + assert "_messageUserUnpinned=false;" in helper + assert "_scrollPinned=true;" in helper + assert "_nearBottomCount=2;" in helper + + regular_guard_idx = restore.index("_restorePinnedMessageScrollSnapshot(snapshot)") + regular_anchor_idx = restore.index("_restoreMessageViewportAnchor(snapshot.anchor,0)") + same_guard_idx = same_frame.index("_restorePinnedMessageScrollSnapshot(snapshot)") + same_anchor_idx = same_frame.index("_restoreMessageViewportAnchor(snapshot.anchor,0)") + + assert regular_guard_idx < regular_anchor_idx + assert same_guard_idx < same_anchor_idx + + def test_same_session_reload_anchor_uses_absolute_session_message_index(): assert "function _messageSessionIndexBase()" in UI_JS assert "return _messageSessionIndexBase()+n;" in UI_JS