From fe40912eb9aedbaa6a16b943d44528b2c61eb778 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 24 Jun 2026 09:56:06 +0000 Subject: [PATCH 1/4] fix(#4702): don't false-unpin a freshly-opened session on iOS portrait toolbar settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS Safari portrait resolves its dynamic toolbar height after first paint; when the toolbar collapses the #messages scroller grows (clientHeight up), firing a scroll event with decreased scrollTop that was misread as an upward user scroll -> falsely set _messageUserUnpinned, and the ResizeObserver settle then self-cancelled, leaving the reader stranded at the TOP of a freshly-opened conversation (landscape unaffected since it doesn't resize late). Sibling of #4701. Fix (touch-safe, no-op on desktop/landscape where clientHeight is stable): - Track _lastMessageClientHeight; gate movedUp on !grew so a container-height INCREASE can't be read as an upward scroll. Reset on session switch. - For explicit (open) settles, also observe the scroller itself so a late portrait toolbar collapse re-anchors the bottom. Independent of #4818 (overflow-anchor paint-gap) — different mechanism, coexists. Diagnosis credit: investigation. Co-authored-by: reinocheong --- static/ui.js | 20 +++++++- ...t_issue4702_portrait_open_scroll_bottom.py | 47 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue4702_portrait_open_scroll_bottom.py diff --git a/static/ui.js b/static/ui.js index 965d41e2..2fb9026c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3710,6 +3710,7 @@ let _programmaticScrollResetTimer=0; function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);} let _nearBottomCount=0; let _lastScrollTop=null; +let _lastMessageClientHeight=null; // #4702: track scroller height to ignore iOS portrait toolbar-settle reflows (a clientHeight increase fires a scroll event with decreased scrollTop that is NOT a user scroll) // Sticky-unpin model (#3343 supersedes #3330's proximity re-pin): once the user // scrolls up, streaming stops auto-following until they return to the bottom or // click ↓. The upward-intent TIMEOUT mechanism (_lastMessageUpwardIntentMs / @@ -3846,6 +3847,7 @@ if(typeof document!=='undefined'){ function _resetScrollDirectionTracker(){ _clearNewMessageScrollCue(); _lastScrollTop=null; + _lastMessageClientHeight=null; _messageUserUnpinned=false; _scrollPinned=true; _nearBottomCount=0; @@ -3967,7 +3969,17 @@ if(typeof window!=='undefined'){ const top=el.scrollTop; const bottomDistance=el.scrollHeight-top-el.clientHeight; const nearBottom=bottomDistance<250; - const movedUp=_lastScrollTop!==null&&top<_lastScrollTop-2; + // #4702: iOS Safari (esp. portrait) resolves its dynamic toolbar height + // AFTER first paint. When the toolbar collapses the scroller GROWS + // (clientHeight increases), which fires a scroll event with a DECREASED + // scrollTop even though the user never scrolled. Without this guard that + // reflow is misread as an upward scroll and falsely unpins a freshly-opened + // session, stranding portrait readers at the top (sibling: #4701). On + // desktop/landscape the scroller height is stable, so `grew` is always + // false and behavior is byte-identical. + const grew=_lastMessageClientHeight!==null&&el.clientHeight>_lastMessageClientHeight+1; + _lastMessageClientHeight=el.clientHeight; + const movedUp=!grew&&_lastScrollTop!==null&&top<_lastScrollTop-2; const movedDown=_lastScrollTop!==null&&top>_lastScrollTop+2; _lastScrollTop=top; if(movedUp){ @@ -4568,6 +4580,12 @@ function _settleMessageScrollToBottom(force, explicit){ }); _settleRO=ro; ro.observe(observed); + // #4702: for an explicit (user/open) settle, also observe the SCROLLER itself. + // On iOS the transcript content (#msgInner) may not resize, but the scroller + // grows when the portrait toolbar collapses after first paint — observing both + // re-anchors the bottom after that late viewport settle. Desktop never resizes + // here, so this is a no-op off-mobile. + if(explicit&&observed!==el){ try{ ro.observe(el); }catch(_){ } } // Static-content safety net: a fully-static response (no Prism/KaTeX/Mermaid/ // late images) never resizes after the initial sync write, so the diff --git a/tests/test_issue4702_portrait_open_scroll_bottom.py b/tests/test_issue4702_portrait_open_scroll_bottom.py new file mode 100644 index 00000000..706afeae --- /dev/null +++ b/tests/test_issue4702_portrait_open_scroll_bottom.py @@ -0,0 +1,47 @@ +"""Regression test for #4702 (sibling #4701). + +iOS Safari in PORTRAIT resolves its dynamic toolbar height *after* first paint. +When the toolbar collapses, the #messages scroller grows (clientHeight increases), +which fires a native scroll event with a DECREASED scrollTop even though the user +never scrolled. Before the fix that reflow was misread as an upward scroll +(`movedUp`), which falsely set `_messageUserUnpinned=true; _scrollPinned=false` on +a freshly-opened session — stranding portrait readers at the top, and the late +ResizeObserver settle then self-cancelled because of the false unpin. + +These are source-level guards (the runtime behavior is iOS-Safari-specific and not +reproducible in CI), mirroring the static-assertion style of +test_issue1360_streaming_scroll_hardening.py. +""" +import pathlib + +REPO = pathlib.Path(__file__).resolve().parent.parent +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") + + +def test_client_height_growth_guard_declared(): + """The scroller-height tracker must be declared so a toolbar-settle reflow can + be distinguished from a real user scroll.""" + assert "let _lastMessageClientHeight=null;" in UI_JS + + +def test_moved_up_ignores_container_growth(): + """`movedUp` must be gated on `!grew` so a clientHeight increase (toolbar + collapse) can't be misread as an upward user scroll (#4702).""" + assert "const grew=_lastMessageClientHeight!==null&&el.clientHeight>_lastMessageClientHeight+1;" in UI_JS + assert "const movedUp=!grew&&_lastScrollTop!==null&&top<_lastScrollTop-2;" in UI_JS + # The height must be sampled every scroll event (so the next delta compares fresh). + assert "_lastMessageClientHeight=el.clientHeight;" in UI_JS + + +def test_client_height_tracker_reset_on_session_switch(): + """A genuine session switch must reset the tracker so a stale cross-session + height comparison can't suppress a real first scroll.""" + reset_idx = UI_JS.index("function _resetScrollDirectionTracker(){") + body = UI_JS[reset_idx: reset_idx + 600] + assert "_lastMessageClientHeight=null;" in body + + +def test_explicit_settle_observes_scroller_for_portrait_toolbar(): + """An explicit (open/user) settle must also observe the scroller itself, so a + late portrait toolbar collapse re-anchors the bottom (#4702 defense-in-depth).""" + assert "if(explicit&&observed!==el){ try{ ro.observe(el); }catch(_){ } }" in UI_JS From 671f4504ae7249c938eb8f70a58b11b5c30e9d86 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 24 Jun 2026 10:02:52 +0000 Subject: [PATCH 2/4] fix(#4702): co-seed _lastMessageClientHeight with every programmatic _lastScrollTop write (Codex gate) Codex found the guard had a first-event gap: programmatic seeds of _lastScrollTop (_setMessageScrollToBottom, scrollToBottom, snapshot restore paths) didn't seed _lastMessageClientHeight, so the FIRST native toolbar-collapse scroll saw a null prior height -> grew=false -> the false-unpin still fired. Now every programmatic _lastScrollTop=el.scrollTop is paired with _lastMessageClientHeight=el.clientHeight. --- static/ui.js | 10 +++++----- .../test_issue4702_portrait_open_scroll_bottom.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/static/ui.js b/static/ui.js index 2fb9026c..625e4d1e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4472,7 +4472,7 @@ function _setMessageScrollToBottom(){ if(!el) return; _programmaticScroll=true;_programmaticScrollSetAt=performance.now(); el.scrollTop=el.scrollHeight; - _lastScrollTop=el.scrollTop; + _lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight; _nearBottomCount=2; _scrollPinned=true; requestAnimationFrame(()=>{ @@ -4487,7 +4487,7 @@ function _setMessageScrollToBottom(){ return; } el.scrollTop=el.scrollHeight; - _lastScrollTop=el.scrollTop; + _lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight; _nearBottomCount=2; _scrollPinned=true; _deferClearProgrammaticScroll(); @@ -4612,7 +4612,7 @@ function _settleFinalScroll(token){ } _programmaticScroll=true;_programmaticScrollSetAt=performance.now(); el.scrollTop=el.scrollHeight; - _lastScrollTop=el.scrollTop; + _lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight; _nearBottomCount=2; _scrollPinned=true; _deferClearProgrammaticScroll(); @@ -11002,7 +11002,7 @@ 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; + _lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight; if(snapshot.userUnpinned===true){ _messageUserUnpinned=true; _scrollPinned=false; @@ -11068,7 +11068,7 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){ _programmaticScroll=true;_programmaticScrollSetAt=performance.now(); el.scrollTop=Math.max(0,Math.min(target,maxTop)); } - _lastScrollTop=el.scrollTop; + _lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight; if(snapshot.pinned===true){ _messageUserUnpinned=false; _scrollPinned=true; diff --git a/tests/test_issue4702_portrait_open_scroll_bottom.py b/tests/test_issue4702_portrait_open_scroll_bottom.py index 706afeae..a89716be 100644 --- a/tests/test_issue4702_portrait_open_scroll_bottom.py +++ b/tests/test_issue4702_portrait_open_scroll_bottom.py @@ -18,6 +18,20 @@ REPO = pathlib.Path(__file__).resolve().parent.parent UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +def test_client_height_seeded_with_scrolltop_on_programmatic_writes(): + """Every programmatic seed of `_lastScrollTop` must also seed + `_lastMessageClientHeight`, else the FIRST native toolbar-collapse scroll event + sees a null/stale prior height, `grew` is false, and the false-unpin still + fires (Codex gate finding). The two must always be written together.""" + # No bare `_lastScrollTop=el.scrollTop;` without the height co-seed remains. + assert "_lastScrollTop=el.scrollTop;\n" not in UI_JS, ( + "A programmatic _lastScrollTop=el.scrollTop write is missing the paired " + "_lastMessageClientHeight=el.clientHeight seed (#4702)." + ) + # The paired form is present at the programmatic-write sites. + assert UI_JS.count("_lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight;") >= 4 + + def test_client_height_growth_guard_declared(): """The scroller-height tracker must be declared so a toolbar-settle reflow can be distinguished from a real user scroll.""" From 7fe6861a54cd143c228d64dff3ee0cc4730707a0 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 24 Jun 2026 10:15:04 +0000 Subject: [PATCH 3/4] test(#4702): thread _lastMessageClientHeight through the scroll-pin-reentry JS harness The #4295 harness runs the extracted scroll-handler body in a Node new Function(); the body now references _lastMessageClientHeight, so it must be a param + threaded through state (seeded null, samples keep clientHeight stable so grew stays false and the pin-reentry assertions are unchanged). --- tests/test_issue4295_scroll_pin_reentry.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_issue4295_scroll_pin_reentry.py b/tests/test_issue4295_scroll_pin_reentry.py index d1c5216e..8e3cc3eb 100644 --- a/tests/test_issue4295_scroll_pin_reentry.py +++ b/tests/test_issue4295_scroll_pin_reentry.py @@ -58,6 +58,7 @@ def _run_scroll_listener(samples: list[dict[str, int]]) -> dict[str, int | bool const step = new Function( 'el', '_lastScrollTop', + '_lastMessageClientHeight', '_nearBottomCount', '_scrollPinned', '_messageUserUnpinned', @@ -73,6 +74,7 @@ const step = new Function( payload.body + ` return { _lastScrollTop, + _lastMessageClientHeight, _nearBottomCount, _scrollPinned, _messageUserUnpinned, @@ -82,6 +84,7 @@ return { let state = { _lastScrollTop: 800, + _lastMessageClientHeight: null, _nearBottomCount: 0, _scrollPinned: true, _messageUserUnpinned: false, @@ -92,6 +95,7 @@ for (const sample of payload.samples) { state = step( sample, state._lastScrollTop, + state._lastMessageClientHeight, state._nearBottomCount, state._scrollPinned, state._messageUserUnpinned, From 718cefc2642e6f83eca3616d670561a078b46d73 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 24 Jun 2026 10:25:20 +0000 Subject: [PATCH 4/4] release: stamp v0.51.623 (#4702 iOS portrait open-scroll-to-bottom) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a24bf44..4be5c6c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.623] — 2026-06-24 — Release WD (iOS portrait: opening a conversation lands at the bottom) + +### Fixed + +- **On iOS Safari in portrait, opening or switching a conversation no longer jumps to the top of the transcript.** iOS resolves its dynamic toolbar height after first paint; when the toolbar collapsed, the message scroller grew and fired a scroll event with a decreased `scrollTop` that the WebUI misread as the user scrolling up — so it unpinned the freshly-opened session and the late re-anchor settle then cancelled itself, stranding the reader at the oldest message (landscape was unaffected because it doesn't resize late). The scroll tracker now distinguishes a container-height increase (toolbar/keyboard reflow) from a real upward scroll, and an open-time settle also re-anchors the bottom after the late portrait viewport change. Desktop and landscape behavior is unchanged. Also addresses the sibling symptom in #4701. (#4702) + ## [v0.51.622] — 2026-06-24 — Release WC (Tasks tab no longer 500s when the cron package is absent) ### Fixed