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."""