Fix pinned stream scroll restoration

This commit is contained in:
santastabber
2026-06-28 20:56:19 +10:00
parent e24e6c54a6
commit b6b7f1df1d
4 changed files with 57 additions and 0 deletions
+27
View File
@@ -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;
@@ -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,
@@ -506,6 +506,7 @@ eval(extractFunc('_messageSessionIndexForRawIdx'));
eval(extractFunc('_messageRawIdxForSessionIndex'));
eval(extractFunc('_messageVirtualScrollTopForVisibleIdx'));
eval(extractFunc('_remountMessageViewportAnchor'));
eval(extractFunc('_restorePinnedMessageScrollSnapshot'));
eval(extractFunc('_restoreMessageScrollSnapshotSameFrame'));
const snapshot = {
@@ -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