mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
Fix pinned stream scroll restoration
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user