diff --git a/static/ui.js b/static/ui.js index 3827d926..db17b3ca 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4674,12 +4674,28 @@ function clearMessageRenderCache(){ _sessionHtmlCacheSid=null; } -function _scrollAfterMessageRender(preserveScroll){ +function _captureMessageScrollSnapshot(){ + const el=$('messages'); + if(!el) return null; + return {top:el.scrollTop}; +} +function _restoreMessageScrollSnapshot(snapshot){ + const el=$('messages'); + if(!el||!snapshot) return; + const maxTop=Math.max(0,el.scrollHeight-el.clientHeight); + _programmaticScroll=true; + el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop)); + _lastScrollTop=el.scrollTop; + requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); }); +} +function _scrollAfterMessageRender(preserveScroll, scrollSnapshot){ // Terminal stream renders can happen after S.activeStreamId is cleared. // In that case, preserveScroll asks the normal pin-state helper to decide: - // pinned users stay at bottom; users who manually scrolled up stay put. + // pinned users stay at bottom; users who manually scrolled up get their + // pre-render scrollTop restored after the DOM replacement. if(preserveScroll){ - scrollIfPinned(); + if(_scrollPinned) scrollIfPinned(); + else _restoreMessageScrollSnapshot(scrollSnapshot); return; } if(S.activeStreamId){ @@ -4691,6 +4707,7 @@ function _scrollAfterMessageRender(preserveScroll){ function renderMessages(options){ const preserveScroll=!!(options&&options.preserveScroll); + const scrollSnapshot=preserveScroll?_captureMessageScrollSnapshot():null; const inner=$('msgInner'); const sid=S.session?S.session.session_id:null; const msgCount=S.messages.length; @@ -4716,7 +4733,7 @@ function renderMessages(options){ _sessionHtmlCacheSid=sid; _wireMessageWindowLoadEarlierButton(); if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs(); - _scrollAfterMessageRender(preserveScroll); + _scrollAfterMessageRender(preserveScroll, scrollSnapshot); requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver(); @@ -5256,7 +5273,7 @@ function renderMessages(options){ // Only force-scroll when not actively streaming — mid-stream re-renders // (tool completion, session switch) must not override the user's scroll position. // scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up. - _scrollAfterMessageRender(preserveScroll); + _scrollAfterMessageRender(preserveScroll, scrollSnapshot); // Apply syntax highlighting after DOM is built requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); diff --git a/tests/test_issue1690_scroll_completion.py b/tests/test_issue1690_scroll_completion.py index 21c202f9..66233b65 100644 --- a/tests/test_issue1690_scroll_completion.py +++ b/tests/test_issue1690_scroll_completion.py @@ -54,8 +54,9 @@ def test_render_messages_preserve_scroll_option_uses_user_pin_state_not_stream_l assert "function renderMessages(options)" in render_body assert "const preserveScroll=!!(options&&options.preserveScroll);" in render_body - assert "_scrollAfterMessageRender(preserveScroll);" in render_body - assert "if(preserveScroll){\n scrollIfPinned();\n return;\n }" in scroll_helper + assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in render_body + assert "const scrollSnapshot=preserveScroll?_captureMessageScrollSnapshot():null" in render_body + assert "if(preserveScroll){\n if(_scrollPinned) scrollIfPinned();\n else _restoreMessageScrollSnapshot(scrollSnapshot);\n return;\n }" in scroll_helper assert "if(S.activeStreamId){\n scrollIfPinned();\n return;\n }" in scroll_helper @@ -63,7 +64,7 @@ def test_cached_render_path_uses_same_scroll_policy_as_fresh_render(): render_body = _function_body(UI_JS, "renderMessages") cached_branch = render_body[render_body.index("if(sid&&sid!==_sessionHtmlCacheSid") : render_body.index("const compressionState=")] - assert "_scrollAfterMessageRender(preserveScroll);" in cached_branch + assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in cached_branch assert "if(S.activeStreamId){scrollIfPinned();}else{scrollToBottom();}" not in cached_branch diff --git a/tests/test_issue677.py b/tests/test_issue677.py index 09554e12..ff5dfbf6 100644 --- a/tests/test_issue677.py +++ b/tests/test_issue677.py @@ -40,7 +40,7 @@ class TestScrollPinningFix: "unconditional scrollToBottom() overrides user scroll position (#677)" ) # scrollIfPinned must be called through the renderMessages scroll policy (stream path) - assert "_scrollAfterMessageRender(preserveScroll);" in rm_body + assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in rm_body assert "scrollIfPinned()" in helper_body, ( "renderMessages() must call scrollIfPinned() during streaming (#677)" ) diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index f93e2f6b..992c5046 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -24,7 +24,7 @@ def test_load_earlier_expands_local_window_before_server_pagination_and_preserve def test_windowed_render_keeps_streaming_and_tool_activity_anchored_to_rendered_messages(): - assert "_scrollAfterMessageRender(preserveScroll);" in UI_JS + assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in UI_JS assert "const assistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);" in UI_JS assert "if(aIdxa-b);" in UI_JS diff --git a/tests/test_tars_scroll_reset_regressions.py b/tests/test_tars_scroll_reset_regressions.py index b05fa4e8..a37abf2e 100644 --- a/tests/test_tars_scroll_reset_regressions.py +++ b/tests/test_tars_scroll_reset_regressions.py @@ -84,3 +84,22 @@ def test_user_scroll_cancels_delayed_bottom_settling(): assert "e.deltaY<0" in record assert "_cancelBottomSettle();" in record assert "_scrollPinned=false" in record + + +def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild(): + render = _function_body(UI_JS, "function renderMessages") + after_render = _function_body(UI_JS, "function _scrollAfterMessageRender") + restore = _function_body(UI_JS, "function _restoreMessageScrollSnapshot") + + snapshot_idx = render.index("const scrollSnapshot=preserveScroll?_captureMessageScrollSnapshot():null") + inner_idx = render.index("const inner=$('msgInner')") + final_scroll_idx = render.rindex("_scrollAfterMessageRender(preserveScroll, scrollSnapshot)") + + assert snapshot_idx < inner_idx < final_scroll_idx, ( + "renderMessages({preserveScroll:true}) must capture #messages.scrollTop before " + "replacing transcript DOM, then pass that snapshot to the post-render scroll helper" + ) + assert "if(_scrollPinned) scrollIfPinned()" in after_render + assert "else _restoreMessageScrollSnapshot(scrollSnapshot)" in after_render + assert "el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop))" in restore + assert "_programmaticScroll=true" in restore