Fix PWA final answer scroll refresh

This commit is contained in:
Frank Song 2026-06-14 01:43:16 +08:00
parent c76e219076
commit 0fdcd00858
4 changed files with 57 additions and 4 deletions

View file

@ -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

View file

@ -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);

View file

@ -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;

View file

@ -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)