From e2338f696fdc3a0df069019c64fdf4f315645538 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 17:14:36 +0000 Subject: [PATCH] =?UTF-8?q?Stage=20397:=20PR=20#2688=20=E2=80=94=20fix:=20?= =?UTF-8?q?resync=20virtualized=20sidebar=20after=20scroll=20clamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ai-ag2026 --- static/sessions.js | 17 ++++++ ...st_issue500_session_list_virtualization.py | 52 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/static/sessions.js b/static/sessions.js index 768dc35e..5e57b354 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2932,6 +2932,22 @@ function _markSessionListPointerUp(){ if(_pendingSessionListPayload) _schedulePendingSessionListApply(); } +let _sessionVirtualResyncRaf = 0; +function _resyncSessionVirtualWindowAfterRender(list, expectedScrollTop, virtualWindow){ + if(!list||!virtualWindow||!virtualWindow.virtualized) return; + expectedScrollTop=Number(expectedScrollTop)||0; + if(expectedScrollTop<=0) return; + if(_sessionVirtualResyncRaf) cancelAnimationFrame(_sessionVirtualResyncRaf); + _sessionVirtualResyncRaf=requestAnimationFrame(()=>{ + _sessionVirtualResyncRaf=0; + if(_renamingSid) return; + const actualScrollTop=Number(list.scrollTop)||0; + const tolerance=Math.max(2, Number(virtualWindow.itemHeight||SESSION_VIRTUAL_ROW_HEIGHT)/2); + if(Math.abs(actualScrollTop-expectedScrollTop)<=tolerance) return; + renderSessionListFromCache(); + }); +} + function renderSessionListFromCache(){ // Don't re-render while user is actively renaming a session (would destroy the input) if(_renamingSid) return; @@ -3214,6 +3230,7 @@ function renderSessionListFromCache(){ // scrollTop drops to 0 — producing a "scroll keeps jumping back" feel // when the list scrolls naturally. Fixed for #1669 follow-up. list.scrollTop=listScrollTopBeforeRender; + _resyncSessionVirtualWindowAfterRender(list, listScrollTopBeforeRender, virtualWindow); } // Select mode toggle button (only when NOT in select mode) if(!_sessionSelectMode){ diff --git a/tests/test_issue500_session_list_virtualization.py b/tests/test_issue500_session_list_virtualization.py index dc6270f8..6d663e04 100644 --- a/tests/test_issue500_session_list_virtualization.py +++ b/tests/test_issue500_session_list_virtualization.py @@ -138,3 +138,55 @@ def test_session_list_only_moves_to_active_when_active_row_is_not_visible(): assert before_idx < visible_idx < move_idx < final_idx < anchor_idx assert "activeIndex:-1" in render_body[before_idx:visible_idx] assert "activeIndex:shouldAnchorActive?activeIndex:-1" not in render_body + + +def test_session_list_resyncs_when_browser_clamps_virtual_scroll_restore(): + """If a hidden/reflowed sidebar rejects restored scrollTop, re-render the visible window.""" + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + render_start = js.index("function renderSessionListFromCache()") + render_end = js.index("async function _handleActiveSessionStorageEvent", render_start) + render_body = js[render_start:render_end] + + assert "_resyncSessionVirtualWindowAfterRender(list, listScrollTopBeforeRender, virtualWindow);" in render_body + + source = _extract_func_script(js) + """ +let renderCount = 0; +let rafCount = 0; +let _renamingSid = null; +const SESSION_VIRTUAL_ROW_HEIGHT = 52; +function requestAnimationFrame(cb){ rafCount += 1; cb(); return rafCount; } +function cancelAnimationFrame(_id){} +function renderSessionListFromCache(){ renderCount += 1; } +const makeHelper = new Function( + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'renderSessionListFromCache', + `let _sessionVirtualResyncRaf = 0; + let _renamingSid = null; + const SESSION_VIRTUAL_ROW_HEIGHT = 52; + ${extractFunc('_resyncSessionVirtualWindowAfterRender')} + return _resyncSessionVirtualWindowAfterRender;` +); +const _resyncSessionVirtualWindowAfterRender = makeHelper( + requestAnimationFrame, + cancelAnimationFrame, + renderSessionListFromCache +); + +_resyncSessionVirtualWindowAfterRender( + {scrollTop: 0}, + 52 * 10, + {virtualized: true, itemHeight: 52} +); +const afterClamp = renderCount; +_resyncSessionVirtualWindowAfterRender( + {scrollTop: 52 * 10}, + 52 * 10, + {virtualized: true, itemHeight: 52} +); +console.log(JSON.stringify({afterClamp, final: renderCount, rafCount})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["afterClamp"] == 1 + assert metrics["final"] == 1 + assert metrics["rafCount"] == 2