From 0fdcd008584cb1123e047bafa7d5c185129b4fec Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 14 Jun 2026 01:43:16 +0800 Subject: [PATCH 1/3] Fix PWA final answer scroll refresh --- CHANGELOG.md | 4 +++ static/sessions.js | 9 ++++-- static/ui.js | 17 +++++++++- .../test_issue3479_ios_stream_scroll_jump.py | 31 +++++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 802540fb..6fbf7853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- **Long final answers no longer snap back to the bottom while you are reading them in PWA/mobile mode (#4123).** Same-session background refreshes now preserve a reader's current transcript position whenever the viewport is clearly away from the bottom, and they no longer clear the sticky scroll-unpin state before rebuilding the transcript. (#4123) + ## [v0.51.388] — 2026-06-13 — Release NA (Stable Assistant Turn Anchors dual-run reconciler, inert, #3926) ### Added diff --git a/static/sessions.js b/static/sessions.js index e887e314..d944d497 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1038,10 +1038,13 @@ async function loadSession(sid){ if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session); S.session._modelResolutionDeferred=true; S.lastUsage={...(data.session.last_usage||{})}; - // Reset scroll-direction tracker on session switch so the new chat's - // first scroll doesn't compare against the previous chat's scrollTop + // Reset scroll-direction tracker only on real session switches so the new + // chat's first scroll doesn't compare against the previous chat's scrollTop // and false-trigger an unpin (#1731 follow-up — Opus stage-302 SHOULD-FIX). - if (typeof window !== 'undefined' && typeof window._resetScrollDirectionTracker === 'function') { + // Same-session force refreshes reuse the current transcript viewport; clearing + // the sticky-unpin state here makes preserveScroll treat a reader mid-answer + // as pinned and snap them back to the bottom on the next render. + if (currentSid !== sid && typeof window !== 'undefined' && typeof window._resetScrollDirectionTracker === 'function') { try { window._resetScrollDirectionTracker(); } catch (_) {} } if(typeof _applyPendingSessionModelForSession==='function') _applyPendingSessionModelForSession(sid); diff --git a/static/ui.js b/static/ui.js index 3717f447..44afe003 100644 --- a/static/ui.js +++ b/static/ui.js @@ -8718,6 +8718,16 @@ function _restoreMessageScrollSnapshot(snapshot){ el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop)); // Sync _lastScrollTop after programmatic restore so sticky-unpin does not false-trigger (#1731). _lastScrollTop=el.scrollTop; + const bottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight; + if(bottomDistance>250){ + _messageUserUnpinned=true; + _scrollPinned=false; + _nearBottomCount=0; + }else if(bottomDistance<=120){ + _messageUserUnpinned=false; + _scrollPinned=true; + _nearBottomCount=2; + } requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); }); } function _restoreMessageScrollSnapshotSameFrame(snapshot){ @@ -8773,6 +8783,11 @@ function _scrollAfterMessageRender(preserveScroll, scrollSnapshot){ // pinned users stay at bottom; users who manually scrolled up get their // pre-render scrollTop restored after the DOM replacement. if(preserveScroll){ + const readerAwayFromBottom=!!( + scrollSnapshot && + Number.isFinite(Number(scrollSnapshot.bottom)) && + Number(scrollSnapshot.bottom)>250 + ); // Keep master's follow heuristic for pinned / still-near-bottom users: // _followMessagesAfterDomReplace() does a FORCED scrollToBottom() (synchronous // bottom write + forced settle), so the final settled response can't leave a @@ -8781,7 +8796,7 @@ function _scrollAfterMessageRender(preserveScroll, scrollSnapshot){ // new-message cue. (Using scrollIfPinned() here instead would skip the forced // write unless distance>500 and let the DOM-rebuild scroll event cancel the // delayed settles — Codex CORE catch on #3631.) - if(!_messageUserUnpinned && _followMessagesAfterDomReplace()) return; + if(!readerAwayFromBottom && !_messageUserUnpinned && _followMessagesAfterDomReplace()) return; _restoreMessageScrollSnapshot(scrollSnapshot); _maybeShowNewMessageScrollCue(scrollSnapshot); return; diff --git a/tests/test_issue3479_ios_stream_scroll_jump.py b/tests/test_issue3479_ios_stream_scroll_jump.py index fe85e2b9..121ccc97 100644 --- a/tests/test_issue3479_ios_stream_scroll_jump.py +++ b/tests/test_issue3479_ios_stream_scroll_jump.py @@ -10,6 +10,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") @@ -78,6 +79,36 @@ def test_same_frame_snapshot_preserves_bottom_distance_and_unpinned_state(): assert "_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);" in wrapper +def test_preserve_scroll_restores_reader_away_from_bottom_before_following(): + body = _function_body(UI_JS, "_scrollAfterMessageRender") + compact = _compact(body) + + reader_idx = compact.index("constreaderAwayFromBottom=") + follow_idx = compact.index("if(!readerAwayFromBottom&&!_messageUserUnpinned&&_followMessagesAfterDomReplace())return;") + restore_idx = compact.index("_restoreMessageScrollSnapshot(scrollSnapshot);") + + assert "Number(scrollSnapshot.bottom)>250" in compact + assert reader_idx < follow_idx < restore_idx + + +def test_scroll_snapshot_restore_reinstates_unpinned_state_when_reader_is_mid_answer(): + restore = _function_body(UI_JS, "_restoreMessageScrollSnapshot") + compact = _compact(restore) + + assert "constbottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight;" in compact + assert "if(bottomDistance>250)" in compact + assert "_messageUserUnpinned=true" in compact + assert "_scrollPinned=false" in compact + + +def test_same_session_force_refresh_does_not_reset_scroll_direction_tracker(): + body = _function_body(SESSIONS_JS, "loadSession") + compact = _compact(body) + + assert "constcurrentSid=S.session?S.session.session_id:null;" in compact + assert "if(currentSid!==sid&&typeofwindow!=='undefined'&&typeofwindow._resetScrollDirectionTracker==='function')" in compact + + def test_clarify_card_is_height_clamped_and_scrollable_on_mobile_viewports(): compact = _compact(STYLE_CSS) From 8db88c882d653697039e8787718ec7246b4ae9f8 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 13 Jun 2026 17:53:53 +0000 Subject: [PATCH 2/3] fix(#4123): preserve reader position on PWA same-session refresh + re-anchor 2 scroll tests #4124: treat preserved snapshot bottom>250px as active reader (restore before follow-to-bottom), re-mark restored mid-answer as unpinned, scope _resetScrollDirectionTracker to real session switches only. Re-anchored test_preserve_scroll_restores_unpinned_viewport + test_render_messages_ preserve_scroll to the new readerAwayFromBottom-guarded early-return (was pinning the old exact line). CHANGELOG stamped v0.51.390 (NC). --- tests/test_issue1690_scroll_completion.py | 9 +++++++-- tests/test_tars_scroll_reset_regressions.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_issue1690_scroll_completion.py b/tests/test_issue1690_scroll_completion.py index cf8f61be..ab05785d 100644 --- a/tests/test_issue1690_scroll_completion.py +++ b/tests/test_issue1690_scroll_completion.py @@ -57,8 +57,13 @@ def test_render_messages_preserve_scroll_option_uses_user_pin_state_not_stream_l assert "const preserveScroll=!!(options&&options.preserveScroll);" in render_body assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in render_body assert "const scrollSnapshot=(preserveScroll||(!_autoScrollFollow&&_messageUserUnpinned))?_captureMessageScrollSnapshot():null" in render_body - assert "if(preserveScroll){\n // Keep master's follow heuristic" in scroll_helper - assert "if(!_messageUserUnpinned && _followMessagesAfterDomReplace()) return;\n _restoreMessageScrollSnapshot(scrollSnapshot);\n _maybeShowNewMessageScrollCue(scrollSnapshot);\n return;\n }" in scroll_helper + assert "if(preserveScroll){" in scroll_helper + # #4124: a reader clearly away from the bottom (>250px) is treated as an active + # reading position, so the forced follow-to-bottom is gated behind it. + assert "const readerAwayFromBottom=" in scroll_helper + assert "Number(scrollSnapshot.bottom)>250" in scroll_helper + assert "// Keep master's follow heuristic" in scroll_helper + assert "if(!readerAwayFromBottom && !_messageUserUnpinned && _followMessagesAfterDomReplace()) return;\n _restoreMessageScrollSnapshot(scrollSnapshot);\n _maybeShowNewMessageScrollCue(scrollSnapshot);\n return;\n }" in scroll_helper assert "_shouldFollowMessagesOnDomReplace()" in follow_helper assert "scrollToBottom();" in follow_helper assert "if(S.activeStreamId){\n scrollIfPinned();\n return;\n }" in scroll_helper diff --git a/tests/test_tars_scroll_reset_regressions.py b/tests/test_tars_scroll_reset_regressions.py index c59c9c46..c0c17119 100644 --- a/tests/test_tars_scroll_reset_regressions.py +++ b/tests/test_tars_scroll_reset_regressions.py @@ -116,7 +116,7 @@ def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild(): "renderMessages({preserveScroll:true}) must capture #messages.scrollTop before " "replacing transcript DOM, then pass that snapshot to the post-render scroll helper" ) - assert "if(!_messageUserUnpinned && _followMessagesAfterDomReplace()) return;" in after_render + assert "if(!readerAwayFromBottom && !_messageUserUnpinned && _followMessagesAfterDomReplace()) return;" in after_render assert "_restoreMessageScrollSnapshot(scrollSnapshot);\n _maybeShowNewMessageScrollCue(scrollSnapshot);" in after_render assert "_shouldFollowMessagesOnDomReplace()" in follow assert "scrollToBottom();" in follow From 0805f557e9ae8fd766414af01e665608b6c72097 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 13 Jun 2026 18:07:59 +0000 Subject: [PATCH 3/3] test(#4124): re-anchor scroll-cue test to readerAwayFromBottom-guarded early-return --- tests/test_issue3545_streaming_scroll_cue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_issue3545_streaming_scroll_cue.py b/tests/test_issue3545_streaming_scroll_cue.py index bf99ede8..049fa268 100644 --- a/tests/test_issue3545_streaming_scroll_cue.py +++ b/tests/test_issue3545_streaming_scroll_cue.py @@ -34,7 +34,7 @@ def test_preserve_scroll_unpinned_branch_shows_new_message_cue_after_restore(): # only the genuinely-scrolled-up cohort restores their viewport + gets the # new-message cue. (Codex CORE catch: scrollIfPinned() in the pinned branch # could leave a pinned reader short of the settled response.) - assert "if(!_messageUserUnpinned && _followMessagesAfterDomReplace()) return;" in helper + assert "if(!readerAwayFromBottom && !_messageUserUnpinned && _followMessagesAfterDomReplace()) return;" in helper assert "_restoreMessageScrollSnapshot(scrollSnapshot);" in helper assert helper.index("_restoreMessageScrollSnapshot(scrollSnapshot)") < helper.index( "_maybeShowNewMessageScrollCue(scrollSnapshot)"