Fix PWA final answer scroll refresh
This commit is contained in:
parent
c76e219076
commit
0fdcd00858
4 changed files with 57 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
17
static/ui.js
17
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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue