From 55e713b9456e77d00f362cf25b4438b6d44729de Mon Sep 17 00:00:00 2001 From: Ming Date: Wed, 10 Jun 2026 11:17:28 +0200 Subject: [PATCH 1/5] fix(scroll): replace rAF polling with ResizeObserver to fix Firefox chat scroll jump Firefox settles layout more slowly than Safari. The previous rAF-based settlePoll approach still triggered visible step-wise scroll jumps in Firefox because each requestAnimationFrame read of scrollHeight caused an independent reflow that Firefox composited as a visible step. Replace the multi-rAF polling loop with a ResizeObserver on the messages container. The observer is notified passively when layout changes (KaTeX, Mermaid, Prism, images), then a 50ms debounce timer fires a single final scrollTop write. No intermediate scrollTop writes means Firefox never paints intermediate positions. Changes: - Replace _settlePoll() loop with ResizeObserver in _settleMessageScrollToBottom() - Add _settleRO and _settleTimer globals for cleanup - Update _cancelBottomSettle() to disconnect observer and clear timer - Add 2s safety timeout for static content edge case Fixes Firefox-specific scroll jump after streaming responses complete. Safari behavior unchanged (was already smooth). --- static/ui.js | 85 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 15 deletions(-) diff --git a/static/ui.js b/static/ui.js index 81399b38..d89bd36d 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2352,10 +2352,13 @@ let _lastScrollTop=null; let _lastNonMessageScrollIntentMs=-Infinity; let _messageUserUnpinned=false; let _bottomSettleToken=0; +let _settleRAF=0; +let _settleRO=null; +let _settleTimer=0; const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350; let _touchStartY=null; let _newMessageCueVisible=false; -function _cancelBottomSettle(){ _bottomSettleToken++; } +function _cancelBottomSettle(){ _bottomSettleToken++; if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } clearTimeout(_settleTimer); } function _recordNonMessageScrollIntent(e){ const el=document.getElementById('messages'); const target=e&&e.target; @@ -3032,23 +3035,75 @@ function _followMessagesAfterDomReplace(){ function _settleMessageScrollToBottom(force){ // Markdown post-processing (Prism, tables, Mermaid/KaTeX/PDF placeholders) // can grow the transcript after the first scroll write. Re-apply the bottom - // position across a few frames while pinned so late layout does not leave the - // viewport a few lines above the real end. User scroll increments - // _bottomSettleToken and cancels the delayed passes. + // position when content settles so late layout does not leave the viewport + // above the real end. User scroll increments _bottomSettleToken and cancels. + // + // Firefox settles layout more slowly than Safari. The old rAF-polling + // approach read scrollHeight across frames and wrote scrollTop once stable, + // but Firefox still paints each rAF check as a visible reflow jitter. + // + // ResizeObserver approach: write scrollTop ONCE now, then observe the + // messages container for size changes. When no resize fires for 50ms, + // do a single final scrollTop write. No intermediate scrollTop writes, + // no rAF polling — Firefox never paints intermediate positions. const token=++_bottomSettleToken; - const passes=[0,16,80,180]; - passes.forEach(delay=>setTimeout(()=>{ + cancelAnimationFrame(_settleRAF); + if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } + clearTimeout(_settleTimer); + + // Write the first bottom position synchronously. A final renderMessages() + // rebuild can queue a native scroll event from the temporary scrollTop=0 + // layout state; writing early prevents that event from cancelling the + // settle (#3319). + _setMessageScrollToBottom(); + + if(force) return; + + const el=document.getElementById('messages'); + if(!el) return; + + // Observe the messages container for layout changes (KaTeX, Mermaid, images, + // Prism — anything that grows the DOM after initial render). + let settleTimer; + _settleRO=new ResizeObserver(()=>{ + if(token!==_bottomSettleToken){ _settleRO.disconnect(); _settleRO=null; return; } + // Guard: user scrolled away during settling + if(!_scrollPinned||_messageUserUnpinned||_recentNonMessageScrollIntent()){ + _settleRO.disconnect(); _settleRO=null; + _programmaticScroll=false; + return; + } + clearTimeout(settleTimer); + settleTimer=setTimeout(()=>{ + if(token!==_bottomSettleToken){ _settleRO.disconnect(); _settleRO=null; return; } + _settleFinalScroll(token); + _settleRO.disconnect(); + _settleRO=null; + },50); + }); + _settleRO.observe(el); + _settleTimer=settleTimer; + + // Safety cap: if nothing fires for 2s (static content), still do a final + // scroll to be safe. + _settleRAF=setTimeout(()=>{ if(token!==_bottomSettleToken) return; - if(!force && (!_scrollPinned||_messageUserUnpinned||_recentNonMessageScrollIntent())) return; - _setMessageScrollToBottom(); - },delay)); + if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } + _settleFinalScroll(token); + },2000); +} + +function _settleFinalScroll(token){ + if(token!==_bottomSettleToken) return; + const el=document.getElementById('messages'); + if(!el){ _programmaticScroll=false; return; } + _programmaticScroll=true; + el.scrollTop=el.scrollHeight; + _lastScrollTop=el.scrollTop; + _nearBottomCount=2; + _scrollPinned=true; requestAnimationFrame(()=>{ - if(token!==_bottomSettleToken) return; - if(force || (_scrollPinned&&!_messageUserUnpinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom(); - requestAnimationFrame(()=>{ - if(token!==_bottomSettleToken) return; - if(force || (_scrollPinned&&!_messageUserUnpinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom(); - }); + setTimeout(()=>{ _programmaticScroll=false; },0); }); } function scrollIfPinned(){ From 5ded20c443b3668fedb9a8756d7d94d68f8a4e25 Mon Sep 17 00:00:00 2001 From: Ming Date: Wed, 10 Jun 2026 14:02:25 +0200 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20Greptile=20review=20?= =?UTF-8?q?=E2=80=94=20null=20guards,=20cancelAnimationFrame,=20overflow-a?= =?UTF-8?q?nchor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard _settleRO against null in ResizeObserver callbacks (P1 crash) - _cancelBottomSettle: also cancelAnimationFrame(_settleRAF) (P2 stale timers) - Remove overflow-anchor:none from .messages — let Firefox use native scroll anchoring to handle DOM-growth scroll adjustments Addresses review comments from @greptile-apps on #3920. --- static/style.css | 4 +-- static/ui.js | 70 +++++++++++++++++++++--------------------------- 2 files changed, 33 insertions(+), 41 deletions(-) diff --git a/static/style.css b/static/style.css index 641aa089..500f5d77 100644 --- a/static/style.css +++ b/static/style.css @@ -992,7 +992,7 @@ .sidebar-section{padding:14px 14px 8px;} .new-chat-btn{width:100%;padding:9px 12px;border-radius:9px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);color:var(--accent-text);font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:all .15s;margin-bottom:8px;font-weight:500;} .new-chat-btn:hover{background:var(--accent-bg-strong);border-color:var(--accent);} - .session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;overflow-anchor:none;} + .session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;} .sidebar-search{position:relative;padding:8px 12px;flex-shrink:0;} .session-search-field{position:relative;display:flex;align-items:center;width:100%;} .sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;} @@ -1588,7 +1588,7 @@ .workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;} .chip.model{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);} .messages-shell{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;} - .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;overflow-anchor:none;} + .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;} /* Overlay scroll controls so they do not affect the transcript's native scroll geometry. */ .scroll-to-bottom-btn{position:absolute;right:20px;bottom:16px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s,transform .12s;} .scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);} diff --git a/static/ui.js b/static/ui.js index d89bd36d..06d93202 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2358,7 +2358,7 @@ let _settleTimer=0; const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350; let _touchStartY=null; let _newMessageCueVisible=false; -function _cancelBottomSettle(){ _bottomSettleToken++; if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } clearTimeout(_settleTimer); } +function _cancelBottomSettle(){ _bottomSettleToken++; if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } clearTimeout(_settleTimer); cancelAnimationFrame(_settleRAF); } function _recordNonMessageScrollIntent(e){ const el=document.getElementById('messages'); const target=e&&e.target; @@ -3038,23 +3038,20 @@ function _settleMessageScrollToBottom(force){ // position when content settles so late layout does not leave the viewport // above the real end. User scroll increments _bottomSettleToken and cancels. // - // Firefox settles layout more slowly than Safari. The old rAF-polling - // approach read scrollHeight across frames and wrote scrollTop once stable, - // but Firefox still paints each rAF check as a visible reflow jitter. + // Firefox paints each scrollTop write as a visible reflow step. The old + // rAF-polling approach read scrollHeight across frames — the read itself + // forced a reflow in Firefox, causing visible jitter. // - // ResizeObserver approach: write scrollTop ONCE now, then observe the - // messages container for size changes. When no resize fires for 50ms, - // do a single final scrollTop write. No intermediate scrollTop writes, - // no rAF polling — Firefox never paints intermediate positions. + // ResizeObserver approach: the browser notifies us when the container + // resizes (no scrollHeight polling needed). On each notification we write + // scrollTop once via rAF (batches multiple resize callbacks per frame into + // a single write). After 300ms of no resize events, the observer disconnects. const token=++_bottomSettleToken; cancelAnimationFrame(_settleRAF); if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } clearTimeout(_settleTimer); - // Write the first bottom position synchronously. A final renderMessages() - // rebuild can queue a native scroll event from the temporary scrollTop=0 - // layout state; writing early prevents that event from cancelling the - // settle (#3319). + // Sync write anchors the viewport immediately. _setMessageScrollToBottom(); if(force) return; @@ -3062,35 +3059,29 @@ function _settleMessageScrollToBottom(force){ const el=document.getElementById('messages'); if(!el) return; - // Observe the messages container for layout changes (KaTeX, Mermaid, images, - // Prism — anything that grows the DOM after initial render). - let settleTimer; _settleRO=new ResizeObserver(()=>{ - if(token!==_bottomSettleToken){ _settleRO.disconnect(); _settleRO=null; return; } - // Guard: user scrolled away during settling + if(token!==_bottomSettleToken){ if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } return; } if(!_scrollPinned||_messageUserUnpinned||_recentNonMessageScrollIntent()){ - _settleRO.disconnect(); _settleRO=null; + if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } _programmaticScroll=false; return; } - clearTimeout(settleTimer); - settleTimer=setTimeout(()=>{ - if(token!==_bottomSettleToken){ _settleRO.disconnect(); _settleRO=null; return; } - _settleFinalScroll(token); - _settleRO.disconnect(); - _settleRO=null; - },50); + // Write scrollTop once per frame — ResizeObserver batches multiple + // notifications per frame, so this is at most one write per frame. + cancelAnimationFrame(_settleRAF); + _settleRAF=requestAnimationFrame(()=>{ + if(token!==_bottomSettleToken) return; + _setMessageScrollToBottom(); + }); + // After 300ms of quiet, disconnect — layout is stable. + clearTimeout(_settleTimer); + _settleTimer=setTimeout(()=>{ + if(token!==_bottomSettleToken) return; + if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } + _setMessageScrollToBottom(); + },300); }); _settleRO.observe(el); - _settleTimer=settleTimer; - - // Safety cap: if nothing fires for 2s (static content), still do a final - // scroll to be safe. - _settleRAF=setTimeout(()=>{ - if(token!==_bottomSettleToken) return; - if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } - _settleFinalScroll(token); - },2000); } function _settleFinalScroll(token){ @@ -3117,12 +3108,13 @@ function scrollToBottom(){ _clearNewMessageScrollCue(); _scrollPinned=true; _messageUserUnpinned=false; - // Write the first bottom position synchronously. A final renderMessages() - // rebuild can queue a native scroll event from the temporary scrollTop=0 - // layout state; if we only schedule delayed settles, that event can cancel - // them before the viewport ever reaches the bottom. + // Write scrollTop once synchronously to anchor the viewport, then let + // ResizeObserver settle handle any late layout growth (Prism, KaTeX, + // Mermaid, images). Using force=false so the observer runs — force=true + // was skipping the observer and causing Firefox paint jumps when + // renderMessages({preserveScroll:true}) + scrollToBottom() fired back-to-back. _setMessageScrollToBottom(); - _settleMessageScrollToBottom(true); + _settleMessageScrollToBottom(false); _syncScrollToBottomCue(false,{newMessage:false}); if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton(); } From f9055c5d975c794bf04640b6c1c3ea3afce43f35 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 13 Jun 2026 03:25:48 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix(#3920):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20restore=20sidebar=20overflow-anchor,=20wire=20=5FsettleFinal?= =?UTF-8?q?Scroll=20via=202s=20fallback,=20+=20CHANGELOG=20v0.51.377?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 maintainer-review items the contributor hadn't addressed: 1. (blocking) restored .session-list overflow-anchor:none — the PR accidentally reverted the deliberate Firefox sidebar fix (2dfe765b); only the on-topic .messages removal is kept. 2/3. _settleFinalScroll was dead code + the promised 2s static-content safety timeout was missing — wired _settleFinalScroll(token) via a single 2s top-level fallback after _settleRO.observe(el), declared _settleFinalTimer + cleared it in _cancelBottomSettle and at settle re-entry. --- CHANGELOG.md | 6 ++++++ static/style.css | 2 +- static/ui.js | 18 +++++++++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32bdc2b3..413ea96d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.377] — 2026-06-13 — Release MP (Firefox post-stream scroll jitter, #3920) + +### Fixed + +- **Firefox no longer jitters/steps the chat scroll after a streamed turn settles (#3920).** The post-stream bottom-settle replaced its `setTimeout` fan-out + double-`requestAnimationFrame` `scrollHeight` polling (which forced a reflow per frame on Firefox) with a single synchronous write plus a `ResizeObserver` that re-anchors once per frame as late layout (Prism/KaTeX/Mermaid/images) grows, disconnecting after 300ms of quiet. A 2s top-level fallback covers fully-static responses that never resize, and the deliberate Firefox session-sidebar `overflow-anchor:none` fix is preserved. (#3920) + ## [v0.51.376] — 2026-06-13 — Release MO (Hide Thinking also hides Worklog reasoning, #3903) ### Fixed diff --git a/static/style.css b/static/style.css index 3fb8b709..a4515f7e 100644 --- a/static/style.css +++ b/static/style.css @@ -992,7 +992,7 @@ .sidebar-section{padding:14px 14px 8px;} .new-chat-btn{width:100%;padding:9px 12px;border-radius:9px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);color:var(--accent-text);font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:all .15s;margin-bottom:8px;font-weight:500;} .new-chat-btn:hover{background:var(--accent-bg-strong);border-color:var(--accent);} - .session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;} + .session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;overflow-anchor:none;} .sidebar-search{position:relative;padding:8px 12px;flex-shrink:0;} .session-search-field{position:relative;display:flex;align-items:center;width:100%;} .sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;} diff --git a/static/ui.js b/static/ui.js index fdae80fc..d93dcfb8 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2428,10 +2428,11 @@ let _bottomSettleToken=0; let _settleRAF=0; let _settleRO=null; let _settleTimer=0; +let _settleFinalTimer=0; const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350; let _touchStartY=null; let _newMessageCueVisible=false; -function _cancelBottomSettle(){ _bottomSettleToken++; if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } clearTimeout(_settleTimer); cancelAnimationFrame(_settleRAF); } +function _cancelBottomSettle(){ _bottomSettleToken++; if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } clearTimeout(_settleTimer); clearTimeout(_settleFinalTimer); cancelAnimationFrame(_settleRAF); } function _recordNonMessageScrollIntent(e){ const el=document.getElementById('messages'); const target=e&&e.target; @@ -3129,6 +3130,7 @@ function _settleMessageScrollToBottom(force){ cancelAnimationFrame(_settleRAF); if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } clearTimeout(_settleTimer); + clearTimeout(_settleFinalTimer); // Sync write anchors the viewport immediately. _setMessageScrollToBottom(); @@ -3161,6 +3163,20 @@ function _settleMessageScrollToBottom(force){ },300); }); _settleRO.observe(el); + + // Static-content safety net: a fully-static response (no Prism/KaTeX/Mermaid/ + // late images) never resizes after the initial sync write, so the + // ResizeObserver callback above never fires and its 300ms quiet-timer is never + // armed. Arm a single 2s top-level fallback so a late settle still runs for + // that case. The token check inside _settleFinalScroll makes this a no-op if a + // newer settle started, and it self-skips if the user unpinned. (Review #2/#3.) + clearTimeout(_settleFinalTimer); + _settleFinalTimer=setTimeout(()=>{ + if(token!==_bottomSettleToken) return; + if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } + if(!_scrollPinned||_messageUserUnpinned||_recentNonMessageScrollIntent()){ _programmaticScroll=false; return; } + _settleFinalScroll(token); + },2000); } function _settleFinalScroll(token){ From b5eabe14941ab5876129c6fc742844a0ebe06db0 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 13 Jun 2026 03:32:16 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix(#3920):=20apply=20Codex=20gate=20findin?= =?UTF-8?q?gs=20=E2=80=94=20observe=20#msgInner,=20instance-owned=20RO=20c?= =?UTF-8?q?leanup,=20restore=20.messages=20overflow-anchor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex SHIP-ONLY-WITH-FIXES (3 real issues; Codex+Opus disagreed on #1, stricter wins): 1. CORE — .messages lost overflow-anchor:none, reopening the #1360 (d21c9720) streaming-scroll-anchor regression. Restored it (native anchoring stays OFF; the RO does the settle). Opus had judged this removal acceptable; took Codex's stricter call. 2. SILENT — the ResizeObserver observed #messages (the scroll container, fixed by flex layout, never resizes) instead of #msgInner (.messages-inner, the growing transcript node) → callback would never fire. Now observes #msgInner. 3. SILENT — stale RO callbacks mutated the global _settleRO, which could disconnect a newer active observer after settle re-entry. Now instance-owned: close over const ro, only clear the global when _settleRO===ro. --- static/style.css | 2 +- static/ui.js | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/static/style.css b/static/style.css index a4515f7e..8a4d10c0 100644 --- a/static/style.css +++ b/static/style.css @@ -1588,7 +1588,7 @@ .workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;} .chip.model{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);} .messages-shell{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;} - .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;} + .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;overflow-anchor:none;} /* Overlay scroll controls so they do not affect the transcript's native scroll geometry. */ .scroll-to-bottom-btn{position:absolute;right:20px;bottom:16px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s,transform .12s;} .scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);} diff --git a/static/ui.js b/static/ui.js index d93dcfb8..c82a5733 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3139,11 +3139,19 @@ function _settleMessageScrollToBottom(force){ const el=document.getElementById('messages'); if(!el) return; + // Observe the GROWING content node, not the scroll container. #messages is the + // scroller but its box is fixed by the flex layout, so it never resizes — the + // transcript grows inside #msgInner (.messages-inner). Observing #messages + // would mean the callback never fires. (Codex review #2.) + const observed=document.getElementById('msgInner')||el; - _settleRO=new ResizeObserver(()=>{ - if(token!==_bottomSettleToken){ if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } return; } + // Instance-owned cleanup: close over THIS observer so a stale callback (from a + // superseded settle) only ever disconnects its own observer, never the newer + // active one that may now be in the global _settleRO. (Codex review #3.) + const ro=new ResizeObserver(()=>{ + if(token!==_bottomSettleToken){ ro.disconnect(); if(_settleRO===ro) _settleRO=null; return; } if(!_scrollPinned||_messageUserUnpinned||_recentNonMessageScrollIntent()){ - if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } + ro.disconnect(); if(_settleRO===ro) _settleRO=null; _programmaticScroll=false; return; } @@ -3158,11 +3166,12 @@ function _settleMessageScrollToBottom(force){ clearTimeout(_settleTimer); _settleTimer=setTimeout(()=>{ if(token!==_bottomSettleToken) return; - if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } + ro.disconnect(); if(_settleRO===ro) _settleRO=null; _setMessageScrollToBottom(); },300); }); - _settleRO.observe(el); + _settleRO=ro; + ro.observe(observed); // Static-content safety net: a fully-static response (no Prism/KaTeX/Mermaid/ // late images) never resizes after the initial sync write, so the @@ -3173,7 +3182,7 @@ function _settleMessageScrollToBottom(force){ clearTimeout(_settleFinalTimer); _settleFinalTimer=setTimeout(()=>{ if(token!==_bottomSettleToken) return; - if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } + ro.disconnect(); if(_settleRO===ro) _settleRO=null; if(!_scrollPinned||_messageUserUnpinned||_recentNonMessageScrollIntent()){ _programmaticScroll=false; return; } _settleFinalScroll(token); },2000); From d3c694251f3da7fcf9f40658e006460a1ca7ab63 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 13 Jun 2026 03:39:11 +0000 Subject: [PATCH 5/5] test(#3920): re-anchor tars scroll regression tests to the ResizeObserver settle The 2 failing assertions pinned the removed implementation (const passes=[0,16,80,180] setTimeout fan-out + _settleMessageScrollToBottom(true) in scrollToBottom). Updated to assert the new behavior contract: ResizeObserver on #msgInner + 2s static fallback + force=false so the observer runs. Behavioral intent (settle survives late markdown growth; sync write before settle scheduling) preserved. --- tests/test_tars_scroll_reset_regressions.py | 22 ++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/test_tars_scroll_reset_regressions.py b/tests/test_tars_scroll_reset_regressions.py index fc80f6fa..873e30cf 100644 --- a/tests/test_tars_scroll_reset_regressions.py +++ b/tests/test_tars_scroll_reset_regressions.py @@ -43,10 +43,21 @@ def test_scroll_to_bottom_settles_across_late_markdown_layout_growth(): scroll = _function_body(UI_JS, "function scrollToBottom") pinned = _function_body(UI_JS, "function scrollIfPinned") + # The settle survives late markdown layout growth (Prism/KaTeX/Mermaid/images) + # via a ResizeObserver on the growing content node (#msgInner) plus a 2s + # static-content fallback — this replaced the old [0,16,80,180]ms setTimeout + # fan-out + double-rAF scrollHeight polling (#3920, Firefox reflow jitter). assert "requestAnimationFrame" in settle assert "setTimeout" in settle - assert "const passes=[0,16,80,180]" in settle - assert "_settleMessageScrollToBottom(true)" in scroll + assert "new ResizeObserver" in settle + assert "getElementById('msgInner')" in settle, ( + "the ResizeObserver must observe the growing content node (#msgInner), " + "not the fixed #messages scroll container" + ) + assert "2000" in settle, "a 2s fallback must cover fully-static content that never resizes" + # scrollToBottom uses force=false so the observer actually runs (the old + # force=true skipped it and caused Firefox paint jumps). + assert "_settleMessageScrollToBottom(false)" in scroll assert "_settleMessageScrollToBottom(false)" in pinned assert "!_scrollPinned" in settle assert "const token=++_bottomSettleToken" in settle @@ -57,11 +68,12 @@ def test_scroll_to_bottom_writes_scroll_position_immediately_before_delayed_sett scroll = _function_body(UI_JS, "function scrollToBottom") immediate_idx = scroll.index("_setMessageScrollToBottom();") - settle_idx = scroll.index("_settleMessageScrollToBottom(true)") + settle_idx = scroll.index("_settleMessageScrollToBottom(false)") assert immediate_idx < settle_idx, ( - "scrollToBottom() must write scrollTop synchronously before scheduling delayed settles; " - "otherwise a DOM-rebuild scroll event can cancel the delayed passes and strand the viewport at the top" + "scrollToBottom() must write scrollTop synchronously before scheduling the " + "ResizeObserver settle; otherwise a DOM-rebuild scroll event can cancel the " + "delayed settle and strand the viewport at the top" )