Merge #3920 (Firefox scroll ResizeObserver) onto master
This commit is contained in:
@@ -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);}
|
||||
|
||||
87
static/ui.js
87
static/ui.js
@@ -2425,10 +2425,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); cancelAnimationFrame(_settleRAF); }
|
||||
function _recordNonMessageScrollIntent(e){
|
||||
const el=document.getElementById('messages');
|
||||
const target=e&&e.target;
|
||||
@@ -3111,23 +3114,66 @@ 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 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: 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;
|
||||
const passes=[0,16,80,180];
|
||||
passes.forEach(delay=>setTimeout(()=>{
|
||||
if(token!==_bottomSettleToken) return;
|
||||
if(!force && (!_scrollPinned||_messageUserUnpinned||_recentNonMessageScrollIntent())) return;
|
||||
_setMessageScrollToBottom();
|
||||
},delay));
|
||||
requestAnimationFrame(()=>{
|
||||
if(token!==_bottomSettleToken) return;
|
||||
if(force || (_scrollPinned&&!_messageUserUnpinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
|
||||
requestAnimationFrame(()=>{
|
||||
cancelAnimationFrame(_settleRAF);
|
||||
if(_settleRO){ _settleRO.disconnect(); _settleRO=null; }
|
||||
clearTimeout(_settleTimer);
|
||||
|
||||
// Sync write anchors the viewport immediately.
|
||||
_setMessageScrollToBottom();
|
||||
|
||||
if(force) return;
|
||||
|
||||
const el=document.getElementById('messages');
|
||||
if(!el) return;
|
||||
|
||||
_settleRO=new ResizeObserver(()=>{
|
||||
if(token!==_bottomSettleToken){ if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } return; }
|
||||
if(!_scrollPinned||_messageUserUnpinned||_recentNonMessageScrollIntent()){
|
||||
if(_settleRO){ _settleRO.disconnect(); _settleRO=null; }
|
||||
_programmaticScroll=false;
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
if(force || (_scrollPinned&&!_messageUserUnpinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
|
||||
_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);
|
||||
}
|
||||
|
||||
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(()=>{
|
||||
setTimeout(()=>{ _programmaticScroll=false; },0);
|
||||
});
|
||||
}
|
||||
function scrollIfPinned(){
|
||||
@@ -3141,12 +3187,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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user