Sticky-unpin across idle renderMessages(), 3-tier anchor restore (data-message-anchor-key -> data-session-msg-idx -> data-msg-idx) for tail-window->full-session reloads, deferred external refresh while unpinned (flushed on explicit scrollToBottom), iOS/touch momentum protection. CONFLICT RESOLVED with tonight's shipped #4584: kept #4584's deltaY<-30 jitter-resistant unpin threshold AND #4613's _markMessageTouchScrollIntent(true) on touchmove (both present, 34 scroll-family tests pass). Authoritative head 03343e04b3f2. Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: dso2ng <dso2ng@users.noreply.github.com>
This commit is contained in:
9 files changed
+276
-38
No files matched your search
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.578] — 2026-06-22 — Release UK (preserve reader scroll position across re-render)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Your scroll position is preserved when the transcript re-renders or reloads.** Reading earlier in a conversation no longer gets bumped when the message list refreshes (idle re-render, external update, or a tail-window→full-session reload): the view stays unpinned where you left it, with a 3-tier anchor restore and iOS/touch momentum protection, and deferred external refreshes are held until you explicitly return to the bottom. Thanks @dso2ng. (#4613)
|
||||
|
||||
## [v0.51.577] — 2026-06-22 — Release UJ (failed steer no longer cancels active run)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -421,6 +421,10 @@ function _scheduleActiveSessionIdleReload(sid) {
|
||||
setTimeout(async () => {
|
||||
if(!S||!S.session||S.session.session_id !== sid) return;
|
||||
if(S.busy || S.activeStreamId) return;
|
||||
if(typeof _isMessageReaderUnpinned==='function'&&_isMessageReaderUnpinned()){
|
||||
_deferActiveSessionExternalRefresh('idle-reconcile');
|
||||
return;
|
||||
}
|
||||
try{
|
||||
await loadSession(sid, {force:true, externalRefreshReason:'idle-reconcile'});
|
||||
}catch(_){}
|
||||
@@ -1128,6 +1132,9 @@ async function loadSession(sid){
|
||||
// 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) {
|
||||
_clearDeferredActiveSessionExternalRefresh();
|
||||
}
|
||||
if (currentSid !== sid && typeof window !== 'undefined' && typeof window._resetScrollDirectionTracker === 'function') {
|
||||
try { window._resetScrollDirectionTracker(); } catch (_) {}
|
||||
}
|
||||
@@ -3815,6 +3822,7 @@ let _streamingPollTimer = null;
|
||||
let _sessionTimeRefreshTimer = null;
|
||||
let _activeSessionExternalRefreshTimer = null;
|
||||
let _activeSessionExternalRefreshInFlight = false;
|
||||
let _deferredActiveSessionExternalRefreshReason = '';
|
||||
let _sessionEventsSSE = null;
|
||||
let _sessionEventsRefreshTimer = 0;
|
||||
let _sessionEventsReconnectTimer = 0;
|
||||
@@ -3852,10 +3860,31 @@ function ensureSessionTimeRefreshPoll(){
|
||||
}, _sessionTimeRefreshMs);
|
||||
}
|
||||
|
||||
function _deferActiveSessionExternalRefresh(reason){
|
||||
const nextReason = reason || 'poll';
|
||||
if(_deferredActiveSessionExternalRefreshReason==='idle-reconcile'&&nextReason==='poll') return;
|
||||
_deferredActiveSessionExternalRefreshReason = nextReason;
|
||||
}
|
||||
|
||||
function _clearDeferredActiveSessionExternalRefresh(){
|
||||
_deferredActiveSessionExternalRefreshReason = '';
|
||||
}
|
||||
|
||||
function _flushDeferredActiveSessionExternalRefresh(){
|
||||
const reason = _deferredActiveSessionExternalRefreshReason;
|
||||
if(!reason) return;
|
||||
_deferredActiveSessionExternalRefreshReason = '';
|
||||
void refreshActiveSessionIfExternallyUpdated(reason);
|
||||
}
|
||||
|
||||
async function refreshActiveSessionIfExternallyUpdated(reason){
|
||||
if(_activeSessionExternalRefreshInFlight) return;
|
||||
if(!S.session || !S.session.session_id) return;
|
||||
if(S.busy || S.activeStreamId) return;
|
||||
if(typeof _isMessageReaderUnpinned==='function'&&_isMessageReaderUnpinned()){
|
||||
_deferActiveSessionExternalRefresh(reason||'poll');
|
||||
return;
|
||||
}
|
||||
// #3916: the 30s timer is only a fallback for imported/external sessions.
|
||||
// WebUI-native sessions should not keep probing forever when the sidebar SSE
|
||||
// is healthy, but they still must reconcile when an actual sessions_changed
|
||||
|
||||
+130
-24
@@ -734,6 +734,35 @@ function _messageVisibleIndexForRawIdx(rawIdx, visWithIdx){
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function _messageViewportAnchorKeyForMessage(m){
|
||||
if(typeof _compressionMessageAnchorKey!=='function') return '';
|
||||
const key=_compressionMessageAnchorKey(m);
|
||||
if(!key) return '';
|
||||
return [key.role||'',key.ts??'',key.attachments??0,key.text||''].map(v=>encodeURIComponent(String(v))).join('|');
|
||||
}
|
||||
function _messageVisibleIndexForAnchorKey(anchorKey, visWithIdx){
|
||||
const key=String(anchorKey||'');
|
||||
if(!key) return -1;
|
||||
const list=Array.isArray(visWithIdx)?visWithIdx:_getVisibleMessagesWithIdx();
|
||||
for(let i=0;i<list.length;i++){
|
||||
if(list[i]&&_messageViewportAnchorKeyForMessage(list[i].m)===key) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function _messageSessionIndexBase(){
|
||||
const n=Number(typeof _oldestIdx!=='undefined'?_oldestIdx:0);
|
||||
return Number.isFinite(n)?Math.max(0,n):0;
|
||||
}
|
||||
function _messageSessionIndexForRawIdx(rawIdx){
|
||||
const n=Number(rawIdx);
|
||||
if(!Number.isFinite(n)) return null;
|
||||
return _messageSessionIndexBase()+n;
|
||||
}
|
||||
function _messageRawIdxForSessionIndex(sessionIdx){
|
||||
const n=Number(sessionIdx);
|
||||
if(!Number.isFinite(n)) return null;
|
||||
return n-_messageSessionIndexBase();
|
||||
}
|
||||
function _messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container){
|
||||
const idx=Math.max(0,Number(visibleIdx)||0);
|
||||
_syncMessageVirtualHeightCache(visWithIdx);
|
||||
@@ -759,7 +788,13 @@ function _captureMessageViewportAnchor(){
|
||||
if(!Number.isFinite(rawIdx)) continue;
|
||||
const rect=row.getBoundingClientRect();
|
||||
if(rect.bottom>containerRect.top+1){
|
||||
return {rawIdx, topOffset:rect.top-containerRect.top};
|
||||
const sessionIdx=Number(row&&row.dataset&&row.dataset.sessionMsgIdx);
|
||||
return {
|
||||
rawIdx,
|
||||
sessionIdx:Number.isFinite(sessionIdx)?sessionIdx:_messageSessionIndexForRawIdx(rawIdx),
|
||||
key:row&&row.dataset?String(row.dataset.messageAnchorKey||''):'',
|
||||
topOffset:rect.top-containerRect.top,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -767,9 +802,16 @@ function _captureMessageViewportAnchor(){
|
||||
function _restoreMessageViewportAnchor(anchor, rawIdxDelta){
|
||||
const container=$('messages');
|
||||
if(!container||!anchor) return false;
|
||||
const anchorKey=String(anchor.key||'');
|
||||
let row=anchorKey?Array.from(container.querySelectorAll('[data-message-anchor-key]')).find(el=>el&&el.dataset&&el.dataset.messageAnchorKey===anchorKey):null;
|
||||
if(row&&row.getClientRects&&row.getClientRects().length===0) row=null;
|
||||
if(!row&&anchorKey) return false;
|
||||
const sessionIdx=Number(anchor.sessionIdx);
|
||||
const hasSessionIdx=Number.isFinite(sessionIdx);
|
||||
if(!row&&hasSessionIdx) row=container.querySelector(`[data-session-msg-idx="${sessionIdx}"]`);
|
||||
if(!row&&hasSessionIdx) return false;
|
||||
const targetIdx=Number(anchor.rawIdx)+Number(rawIdxDelta||0);
|
||||
if(!Number.isFinite(targetIdx)) return false;
|
||||
const row=container.querySelector(`[data-msg-idx="${targetIdx}"]`);
|
||||
if(!row&&Number.isFinite(targetIdx)) row=container.querySelector(`[data-msg-idx="${targetIdx}"]`);
|
||||
if(!row) return false;
|
||||
const containerRect=container.getBoundingClientRect();
|
||||
const rect=row.getBoundingClientRect();
|
||||
@@ -783,15 +825,27 @@ let _messageViewportAnchorRemounting=false;
|
||||
function _remountMessageViewportAnchor(anchor){
|
||||
const container=$('messages');
|
||||
if(!container||!anchor||_messageViewportAnchorRemounting) return false;
|
||||
const anchorKey=String(anchor.key||'');
|
||||
const visibleKeyNode=anchorKey
|
||||
? Array.from(container.querySelectorAll('[data-message-anchor-key]')).find(node=>node&&node.dataset&&node.dataset.messageAnchorKey===anchorKey&&(!node.getClientRects||node.getClientRects().length>0))
|
||||
: null;
|
||||
if(visibleKeyNode) return true;
|
||||
const sessionIdx=Number(anchor.sessionIdx);
|
||||
const hasSessionIdx=Number.isFinite(sessionIdx);
|
||||
if(!anchorKey&&hasSessionIdx&&container.querySelector(`[data-session-msg-idx="${sessionIdx}"]`)) return true;
|
||||
const targetIdx=Number(anchor.rawIdx);
|
||||
if(!Number.isFinite(targetIdx)) return false;
|
||||
if(container.querySelector(`[data-msg-idx="${targetIdx}"]`)) return true;
|
||||
if(!anchorKey&&!hasSessionIdx&&Number.isFinite(targetIdx)&&container.querySelector(`[data-msg-idx="${targetIdx}"]`)) return true;
|
||||
if(typeof _getVisibleMessagesWithIdx!=='function'||
|
||||
typeof _messageVisibleIndexForRawIdx!=='function'||
|
||||
typeof _messageVirtualScrollTopForVisibleIdx!=='function'||
|
||||
typeof renderMessages!=='function') return false;
|
||||
const visWithIdx=_getVisibleMessagesWithIdx();
|
||||
const visIdx=_messageVisibleIndexForRawIdx(targetIdx,visWithIdx);
|
||||
let visIdx=anchorKey?_messageVisibleIndexForAnchorKey(anchorKey,visWithIdx):-1;
|
||||
if(visIdx<0&&hasSessionIdx){
|
||||
const rawFromSession=_messageRawIdxForSessionIndex(sessionIdx);
|
||||
if(Number.isFinite(rawFromSession)) visIdx=_messageVisibleIndexForRawIdx(rawFromSession,visWithIdx);
|
||||
}
|
||||
if(visIdx<0&&Number.isFinite(targetIdx)) visIdx=_messageVisibleIndexForRawIdx(targetIdx,visWithIdx);
|
||||
if(visIdx<0) return false;
|
||||
// A virtualized anchor may be outside the current DOM. Scroll to its virtual
|
||||
// row and render once so the semantic restore below has a real target.
|
||||
@@ -805,7 +859,11 @@ function _remountMessageViewportAnchor(anchor){
|
||||
_messageViewportAnchorRemounting=false;
|
||||
requestAnimationFrame(()=>{ setTimeout(()=>{ _programmaticScroll=false; },0); });
|
||||
}
|
||||
return !!container.querySelector(`[data-msg-idx="${targetIdx}"]`);
|
||||
if(anchorKey){
|
||||
return !!Array.from(container.querySelectorAll('[data-message-anchor-key]')).find(node=>node&&node.dataset&&node.dataset.messageAnchorKey===anchorKey&&(!node.getClientRects||node.getClientRects().length>0));
|
||||
}
|
||||
if(hasSessionIdx) return !!container.querySelector(`[data-session-msg-idx="${sessionIdx}"]`);
|
||||
return Number.isFinite(targetIdx)&&!!container.querySelector(`[data-msg-idx="${targetIdx}"]`);
|
||||
}
|
||||
function _compensateScrollForMeasurementDelta(renderFn){
|
||||
const container=$('messages');
|
||||
@@ -3625,8 +3683,39 @@ let _settleTimer=0;
|
||||
let _settleFinalTimer=0;
|
||||
const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350;
|
||||
let _touchStartY=null;
|
||||
let _messageTouchScrollActive=false;
|
||||
let _lastMessageTouchScrollIntentMs=-Infinity;
|
||||
let _deferredOlderMessagesTimer=0;
|
||||
const MESSAGE_TOUCH_SCROLL_SUPPRESS_MS=1200;
|
||||
let _newMessageCueVisible=false;
|
||||
function _cancelBottomSettle(){ _bottomSettleToken++; if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } clearTimeout(_settleTimer); clearTimeout(_settleFinalTimer); cancelAnimationFrame(_settleRAF); }
|
||||
function _markMessageTouchScrollIntent(active=true){
|
||||
_messageTouchScrollActive=!!active;
|
||||
_lastMessageTouchScrollIntentMs=performance.now();
|
||||
}
|
||||
function _recentMessageTouchScrollIntent(){
|
||||
return _messageTouchScrollActive || performance.now()-_lastMessageTouchScrollIntentMs<MESSAGE_TOUCH_SCROLL_SUPPRESS_MS;
|
||||
}
|
||||
function _isMessageReaderUnpinned(){
|
||||
return !!_messageUserUnpinned;
|
||||
}
|
||||
function _olderMessagesPrefetchReady(){
|
||||
const el=document.getElementById('messages');
|
||||
if(!el) return false;
|
||||
const olderPrefetchPx=Math.max(600,el.clientHeight*1.5);
|
||||
return _isSessionEndlessScrollEnabled()&&el.scrollTop<olderPrefetchPx && typeof _messagesTruncated!=='undefined' && _messagesTruncated && typeof _loadOlderMessages==='function';
|
||||
}
|
||||
function _scheduleDeferredOlderMessagesLoad(){
|
||||
clearTimeout(_deferredOlderMessagesTimer);
|
||||
_deferredOlderMessagesTimer=setTimeout(()=>{
|
||||
_deferredOlderMessagesTimer=0;
|
||||
if(_recentMessageTouchScrollIntent()){
|
||||
_scheduleDeferredOlderMessagesLoad();
|
||||
return;
|
||||
}
|
||||
if(_olderMessagesPrefetchReady()) _loadOlderMessages();
|
||||
},MESSAGE_TOUCH_SCROLL_SUPPRESS_MS+50);
|
||||
}
|
||||
function _recordNonMessageScrollIntent(e){
|
||||
const el=document.getElementById('messages');
|
||||
const target=e&&e.target;
|
||||
@@ -3634,6 +3723,7 @@ function _recordNonMessageScrollIntent(e){
|
||||
if(!el.contains(target)) _lastNonMessageScrollIntentMs=performance.now();
|
||||
else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY< -30)){
|
||||
_cancelBottomSettle();
|
||||
if(e.type==='touchmove') _markMessageTouchScrollIntent(true);
|
||||
if(typeof e.deltaY==='number'&&e.deltaY< -30){
|
||||
_messageUserUnpinned=true;
|
||||
_nearBottomCount=0;
|
||||
@@ -3700,10 +3790,12 @@ if(typeof document!=='undefined'){
|
||||
document.addEventListener('wheel',_recordNonMessageScrollIntent,{capture:true,passive:true});
|
||||
document.addEventListener('touchmove',_recordNonMessageScrollIntent,{capture:true,passive:true});
|
||||
document.addEventListener('touchstart',function(e){
|
||||
const el=document.getElementById('messages');
|
||||
if(e.touches&&e.touches[0]) _touchStartY=e.touches[0].clientY;
|
||||
if(el&&e.target&&el.contains(e.target)) _markMessageTouchScrollIntent(true);
|
||||
},{capture:true,passive:true});
|
||||
document.addEventListener('touchend',function(){ _touchStartY=null; },{capture:true,passive:true});
|
||||
document.addEventListener('touchcancel',function(){ _touchStartY=null; },{capture:true,passive:true});
|
||||
document.addEventListener('touchend',function(){ _touchStartY=null; if(_messageTouchScrollActive) _markMessageTouchScrollIntent(false); },{capture:true,passive:true});
|
||||
document.addEventListener('touchcancel',function(){ _touchStartY=null; if(_messageTouchScrollActive) _markMessageTouchScrollIntent(false); },{capture:true,passive:true});
|
||||
}
|
||||
// Reset hook for session-switch — called from sessions.js loadSession() to
|
||||
// prevent the new chat's first scroll comparing against the previous chat's
|
||||
@@ -3715,6 +3807,10 @@ function _resetScrollDirectionTracker(){
|
||||
_scrollPinned=true;
|
||||
_nearBottomCount=0;
|
||||
_touchStartY=null;
|
||||
_messageTouchScrollActive=false;
|
||||
_lastMessageTouchScrollIntentMs=-Infinity;
|
||||
clearTimeout(_deferredOlderMessagesTimer);
|
||||
_deferredOlderMessagesTimer=0;
|
||||
}
|
||||
function _resetStreamScrollFollow(){
|
||||
_clearNewMessageScrollCue();
|
||||
@@ -3848,7 +3944,8 @@ if(typeof window!=='undefined'){
|
||||
// the user's continued upward wheel/touch movement.
|
||||
const olderPrefetchPx=Math.max(600,el.clientHeight*1.5);
|
||||
if(_isSessionEndlessScrollEnabled()&&el.scrollTop<olderPrefetchPx && typeof _messagesTruncated!=='undefined' && _messagesTruncated && typeof _loadOlderMessages==='function'){
|
||||
_loadOlderMessages();
|
||||
if(_recentMessageTouchScrollIntent()) _scheduleDeferredOlderMessagesLoad();
|
||||
else _loadOlderMessages();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4430,6 +4527,10 @@ function _settleFinalScroll(token){
|
||||
if(token!==_bottomSettleToken) return;
|
||||
const el=document.getElementById('messages');
|
||||
if(!el){ _programmaticScroll=false; return; }
|
||||
if(_messageUserUnpinned||!_scrollPinned||_recentNonMessageScrollIntent()||_recentMessageTouchScrollIntent()){
|
||||
_programmaticScroll=false;
|
||||
return;
|
||||
}
|
||||
_programmaticScroll=true;
|
||||
el.scrollTop=el.scrollHeight;
|
||||
_lastScrollTop=el.scrollTop;
|
||||
@@ -4460,6 +4561,7 @@ function scrollToBottom(){
|
||||
_settleMessageScrollToBottom(false, true);
|
||||
_syncScrollToBottomCue(false,{newMessage:false});
|
||||
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
|
||||
if(typeof _flushDeferredActiveSessionExternalRefresh==='function') _flushDeferredActiveSessionExternalRefresh();
|
||||
}
|
||||
|
||||
function _fmtOllamaLabel(mid){
|
||||
@@ -5633,8 +5735,7 @@ function _renderQueueChips(sid){
|
||||
if(!card.classList.contains('visible')) return;
|
||||
const h=card.getBoundingClientRect().height;
|
||||
if(h>0) _msgs.style.setProperty('--queue-card-height', h+'px');
|
||||
if(S.activeStreamId&&typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
else if(!S.activeStreamId&&typeof scrollToBottom==='function') scrollToBottom();
|
||||
if(typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
}, 360);
|
||||
}
|
||||
|
||||
@@ -5824,8 +5925,7 @@ function _updateQueuePill(sid,count){
|
||||
}, 360);
|
||||
}
|
||||
if(pillOuter) pillOuter.classList.remove('show');
|
||||
if(S.activeStreamId&&typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
else if(!S.activeStreamId&&typeof scrollToBottom==='function') scrollToBottom();
|
||||
if(typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
};
|
||||
} else {
|
||||
if(pillOuter) pillOuter.classList.remove('show');
|
||||
@@ -7030,6 +7130,8 @@ async function refreshSession() {
|
||||
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
|
||||
S.session = data.session;
|
||||
S.messages = data.session.messages || [];
|
||||
_messagesTruncated = !!data.session._messages_truncated;
|
||||
_oldestIdx = data.session._messages_offset || 0;
|
||||
const pendingMsg=getPendingSessionMessage(data.session,S.messages);
|
||||
if(pendingMsg) S.messages.push(pendingMsg);
|
||||
S.activeStreamId=data.session.active_stream_id||null;
|
||||
@@ -10856,15 +10958,15 @@ function _scrollAfterMessageRender(preserveScroll, scrollSnapshot){
|
||||
scrollIfPinned();
|
||||
return;
|
||||
}
|
||||
// Auto-follow OFF + the user has scrolled up: don't yank them to the bottom on
|
||||
// an automatic (non-preserve) re-render. This also covers the send() race where
|
||||
// renderMessages() runs before S.activeStreamId is set, so a stream-start
|
||||
// wouldn't otherwise be caught by the scrollIfPinned() branch above. A fresh
|
||||
// session load (not unpinned) still lands at the bottom as expected. (Codex #4006.)
|
||||
// Manual unpin is sticky: once the reader scrolls away, automatic idle/non-
|
||||
// preserve re-renders must restore their viewport rather than clearing the
|
||||
// unpin state with scrollToBottom(). A fresh session load (not unpinned) still
|
||||
// lands at the bottom as expected. (Codex #4006 follow-up.)
|
||||
// renderMessages() captures the pre-wipe snapshot for this case too (see its
|
||||
// scrollSnapshot init), so restoring here lands the reader where they were.
|
||||
if(!_autoScrollFollow && _messageUserUnpinned){
|
||||
if(_messageUserUnpinned){
|
||||
_restoreMessageScrollSnapshot(scrollSnapshot);
|
||||
_maybeShowNewMessageScrollCue(scrollSnapshot);
|
||||
return;
|
||||
}
|
||||
scrollToBottom();
|
||||
@@ -10884,10 +10986,10 @@ function _maybeRecoverVirtualizedBlankViewport(options, preserveScroll, virtualW
|
||||
function renderMessages(options){
|
||||
const preserveScroll=!!(options&&options.preserveScroll);
|
||||
const virtualFallback=!!(options&&options._virtualFallback);
|
||||
// Capture the pre-wipe scroll position when preserving OR when Auto-follow is
|
||||
// off and the user has scrolled up — both need to restore the reader's position
|
||||
// after the DOM rebuild rather than snap to the bottom. (Codex #4006 r3.)
|
||||
const scrollSnapshot=(preserveScroll||(!_autoScrollFollow&&_messageUserUnpinned))?_captureMessageScrollSnapshot():null;
|
||||
// Capture the pre-wipe scroll position when preserving OR when the reader has
|
||||
// manually unpinned; both need to restore the reader's position after the DOM
|
||||
// rebuild rather than snap to the bottom. (Codex #4006 r3 follow-up.)
|
||||
const scrollSnapshot=(preserveScroll||_messageUserUnpinned)?_captureMessageScrollSnapshot():null;
|
||||
const inner=$('msgInner');
|
||||
const sid=S.session?S.session.session_id:null;
|
||||
const msgCount=S.messages.length;
|
||||
@@ -11262,6 +11364,8 @@ function renderMessages(options){
|
||||
row.className='msg-row';
|
||||
row.id=_userMessageDomId(rawIdx);
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
|
||||
row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);
|
||||
row.dataset.role='user';
|
||||
row.dataset.rawText=String(displayContent).trim();
|
||||
row.innerHTML=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
|
||||
@@ -11277,6 +11381,8 @@ function renderMessages(options){
|
||||
const seg=document.createElement('div');
|
||||
seg.className='assistant-segment';
|
||||
seg.dataset.msgIdx=rawIdx;
|
||||
seg.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
|
||||
seg.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);
|
||||
seg.dataset.rawText=String(content).trim();
|
||||
if(m._activityBurstId!==undefined&&m._activityBurstId!==null) seg.setAttribute('data-activity-burst-id',String(m._activityBurstId));
|
||||
if(Number.isFinite(Number(m._liveSegmentSeq))) seg.setAttribute('data-live-segment-seq',String(Number(m._liveSegmentSeq)));
|
||||
|
||||
@@ -36,27 +36,44 @@ def test_scroll_repin_dead_zone_is_wider_for_mac_app_windows():
|
||||
)
|
||||
|
||||
|
||||
def test_queue_card_measurement_does_not_force_repin_during_streaming():
|
||||
def test_queue_card_measurement_respects_manual_unpin_even_when_idle():
|
||||
fn = _extract_function(UI_JS, "_renderQueueChips")
|
||||
measurement_idx = fn.find("setTimeout(()=>")
|
||||
assert measurement_idx != -1, "queue card measurement timeout not found"
|
||||
measurement_block = fn[measurement_idx:measurement_idx + 500]
|
||||
|
||||
assert "S.activeStreamId" in measurement_block
|
||||
assert "scrollIfPinned()" in measurement_block
|
||||
assert "!S.activeStreamId" in measurement_block
|
||||
assert "scrollToBottom()" in measurement_block
|
||||
assert measurement_block.find("scrollIfPinned()") < measurement_block.find("scrollToBottom()")
|
||||
assert "scrollToBottom()" not in measurement_block, (
|
||||
"queue-card layout measurement is a background update; it must not clear "
|
||||
"manual _messageUserUnpinned state by calling explicit scrollToBottom()."
|
||||
)
|
||||
|
||||
|
||||
def test_queue_pill_click_does_not_force_repin_during_streaming():
|
||||
def test_queue_pill_click_respects_manual_unpin_even_when_idle():
|
||||
fn = _extract_function(UI_JS, "_updateQueuePill")
|
||||
click_idx = fn.find("pill.onclick=()=>")
|
||||
assert click_idx != -1, "queue pill click handler not found"
|
||||
click_block = fn[click_idx:click_idx + 700]
|
||||
|
||||
assert "S.activeStreamId" in click_block
|
||||
assert "scrollIfPinned()" in click_block
|
||||
assert "!S.activeStreamId" in click_block
|
||||
assert "scrollToBottom()" in click_block
|
||||
assert click_block.find("scrollIfPinned()") < click_block.find("scrollToBottom()")
|
||||
assert "scrollToBottom()" not in click_block, (
|
||||
"queue-pill expansion is a layout update, not an explicit transcript jump; "
|
||||
"it must not reset manual unpin state."
|
||||
)
|
||||
|
||||
|
||||
def test_idle_render_preserves_manual_unpin_until_explicit_bottom():
|
||||
render_body = _extract_function(UI_JS, "renderMessages")
|
||||
scroll_helper = _extract_function(UI_JS, "_scrollAfterMessageRender")
|
||||
|
||||
assert "preserveScroll||_messageUserUnpinned" in render_body.replace(" ", ""), (
|
||||
"renderMessages() must capture a scroll snapshot whenever the reader is "
|
||||
"manually unpinned, even for idle/non-preserve renders."
|
||||
)
|
||||
assert "if(_messageUserUnpinned){" in scroll_helper.replace(" ", ""), (
|
||||
"idle render must restore the manually-unpinned viewport instead of "
|
||||
"falling through to scrollToBottom()."
|
||||
)
|
||||
manual_unpin_block = scroll_helper[scroll_helper.index("if(_messageUserUnpinned)") : scroll_helper.index("scrollToBottom();")]
|
||||
assert "_restoreMessageScrollSnapshot(scrollSnapshot);" in manual_unpin_block
|
||||
assert "_maybeShowNewMessageScrollCue(scrollSnapshot);" in manual_unpin_block
|
||||
@@ -56,7 +56,7 @@ def test_render_messages_preserve_scroll_option_uses_user_pin_state_not_stream_l
|
||||
assert "function renderMessages(options)" in render_body
|
||||
assert "const preserveScroll=!!(options&&options.preserveScroll);" in render_body
|
||||
assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in render_body
|
||||
assert "const scrollSnapshot=(preserveScroll||(!_autoScrollFollow&&_messageUserUnpinned))?_captureMessageScrollSnapshot():null" in render_body
|
||||
assert "const scrollSnapshot=(preserveScroll||_messageUserUnpinned)?_captureMessageScrollSnapshot():null" in render_body
|
||||
assert "if(preserveScroll){" in scroll_helper
|
||||
# #4124: a reader clearly away from the bottom (>250px) is treated as an active
|
||||
# reading position, so the forced follow-to-bottom is gated behind it.
|
||||
|
||||
@@ -39,11 +39,12 @@ def test_preserve_scroll_unpinned_branch_shows_new_message_cue_after_restore():
|
||||
assert helper.index("_restoreMessageScrollSnapshot(scrollSnapshot)") < helper.index(
|
||||
"_maybeShowNewMessageScrollCue(scrollSnapshot)"
|
||||
)
|
||||
# The cue is only shown on the non-follow (restore) path, after the restore.
|
||||
# The preserve-scroll cue is shown on the non-follow (restore) path, after
|
||||
# restore. A separate idle/manual-unpin branch may also show the same cue.
|
||||
assert helper.index("_followMessagesAfterDomReplace()") < helper.index(
|
||||
"_maybeShowNewMessageScrollCue(scrollSnapshot)"
|
||||
)
|
||||
assert helper.count("_maybeShowNewMessageScrollCue(scrollSnapshot)") == 1
|
||||
assert helper.count("_maybeShowNewMessageScrollCue(scrollSnapshot)") >= 1
|
||||
|
||||
|
||||
def test_scroll_cue_uses_growth_below_restored_viewport():
|
||||
|
||||
@@ -501,6 +501,9 @@ function _restoreMessageViewportAnchor(anchor, delta){
|
||||
function requestAnimationFrame(fn){ fn(); }
|
||||
|
||||
eval(extractFunc('_messageVisibleIndexForRawIdx'));
|
||||
eval(extractFunc('_messageSessionIndexBase'));
|
||||
eval(extractFunc('_messageSessionIndexForRawIdx'));
|
||||
eval(extractFunc('_messageRawIdxForSessionIndex'));
|
||||
eval(extractFunc('_messageVirtualScrollTopForVisibleIdx'));
|
||||
eval(extractFunc('_remountMessageViewportAnchor'));
|
||||
eval(extractFunc('_restoreMessageScrollSnapshotSameFrame'));
|
||||
|
||||
@@ -47,6 +47,14 @@ def test_scroll_listener_prefetches_older_messages_only_when_enabled():
|
||||
assert "el.scrollTop<80 && typeof _messagesTruncated" not in UI_JS
|
||||
|
||||
|
||||
def test_touch_momentum_defers_endless_scroll_prefetch():
|
||||
assert "const MESSAGE_TOUCH_SCROLL_SUPPRESS_MS=1200" in UI_JS
|
||||
assert "function _recentMessageTouchScrollIntent()" in UI_JS
|
||||
assert "function _scheduleDeferredOlderMessagesLoad()" in UI_JS
|
||||
assert "if(_recentMessageTouchScrollIntent()) _scheduleDeferredOlderMessagesLoad();" in UI_JS
|
||||
assert "else _loadOlderMessages();" in UI_JS
|
||||
|
||||
|
||||
def test_endless_scroll_i18n_keys_exist_for_each_locale():
|
||||
assert I18N_JS.count("settings_label_session_endless_scroll") == I18N_JS.count("settings_label_workspace_panel_open")
|
||||
assert I18N_JS.count("settings_desc_session_endless_scroll") == I18N_JS.count("settings_desc_workspace_panel_open")
|
||||
@@ -92,16 +92,50 @@ def test_user_scroll_cancels_delayed_bottom_settling():
|
||||
listener_block = _scroll_listener_block()
|
||||
record = _function_body(UI_JS, "function _recordNonMessageScrollIntent")
|
||||
pinned = _function_body(UI_JS, "function scrollIfPinned")
|
||||
final = _function_body(UI_JS, "function _settleFinalScroll")
|
||||
|
||||
assert "function _cancelBottomSettle" in UI_JS
|
||||
assert "_cancelBottomSettle();" in listener_block
|
||||
assert "e.deltaY< -30" in record
|
||||
assert "_cancelBottomSettle();" in record
|
||||
assert "_lastNonMessageScrollIntentMs=performance.now();" in record
|
||||
assert "_scrollPinned=false" in record
|
||||
assert "if(_messageUserUnpinned) return;" in pinned
|
||||
assert "_messageUserUnpinned" in final and "return" in final
|
||||
assert "_recentMessageUpwardIntent()" not in pinned
|
||||
|
||||
|
||||
def test_external_active_refresh_defers_while_reader_is_manually_unpinned():
|
||||
refresh = _function_body(SESSIONS_JS, "async function refreshActiveSessionIfExternallyUpdated")
|
||||
|
||||
assert "_isMessageReaderUnpinned" in UI_JS
|
||||
assert "_deferActiveSessionExternalRefresh" in SESSIONS_JS
|
||||
assert "typeof _isMessageReaderUnpinned==='function'&&_isMessageReaderUnpinned()" in refresh
|
||||
assert "_deferActiveSessionExternalRefresh(reason||'poll');" in refresh
|
||||
assert "await loadSession(sid, {force:true, externalRefreshReason:reason||'poll'});" in refresh
|
||||
|
||||
|
||||
def test_session_switch_clears_deferred_active_refresh_reason():
|
||||
load = _function_body(SESSIONS_JS, "async function loadSession")
|
||||
assert "function _clearDeferredActiveSessionExternalRefresh()" in SESSIONS_JS
|
||||
assert "_deferredActiveSessionExternalRefreshReason = '';" in SESSIONS_JS
|
||||
assert "if (currentSid !== sid) {\n _clearDeferredActiveSessionExternalRefresh();\n }" in load
|
||||
|
||||
|
||||
def test_deferred_active_refresh_keeps_idle_reconcile_over_poll():
|
||||
defer = _function_body(SESSIONS_JS, "function _deferActiveSessionExternalRefresh")
|
||||
assert "const nextReason = reason || 'poll';" in defer
|
||||
assert "_deferredActiveSessionExternalRefreshReason==='idle-reconcile'&&nextReason==='poll'" in defer
|
||||
assert "_deferredActiveSessionExternalRefreshReason = nextReason;" in defer
|
||||
|
||||
|
||||
def test_scroll_to_bottom_flushes_deferred_active_refresh_after_explicit_repin():
|
||||
scroll = _function_body(UI_JS, "function scrollToBottom")
|
||||
|
||||
assert "_flushDeferredActiveSessionExternalRefresh" in SESSIONS_JS
|
||||
assert "_flushDeferredActiveSessionExternalRefresh" in scroll
|
||||
|
||||
|
||||
def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild():
|
||||
render = _function_body(UI_JS, "function renderMessages")
|
||||
after_render = _function_body(UI_JS, "function _scrollAfterMessageRender")
|
||||
@@ -109,7 +143,7 @@ def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild():
|
||||
capture = _function_body(UI_JS, "function _captureMessageScrollSnapshot")
|
||||
restore = _function_body(UI_JS, "function _restoreMessageScrollSnapshot")
|
||||
|
||||
snapshot_idx = render.index("const scrollSnapshot=(preserveScroll||(!_autoScrollFollow&&_messageUserUnpinned))?_captureMessageScrollSnapshot():null")
|
||||
snapshot_idx = render.index("const scrollSnapshot=(preserveScroll||_messageUserUnpinned)?_captureMessageScrollSnapshot():null")
|
||||
inner_idx = render.index("const inner=$('msgInner')")
|
||||
final_scroll_idx = render.rindex("_scrollAfterMessageRender(preserveScroll, scrollSnapshot)")
|
||||
|
||||
@@ -122,7 +156,41 @@ def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild():
|
||||
assert "_shouldFollowMessagesOnDomReplace()" in follow
|
||||
assert "scrollToBottom();" in follow
|
||||
assert "anchor:(typeof _captureMessageViewportAnchor==='function')?_captureMessageViewportAnchor():null" in capture
|
||||
assert "sessionIdx:Number.isFinite(sessionIdx)?sessionIdx:_messageSessionIndexForRawIdx(rawIdx)" in UI_JS
|
||||
assert "key:row&&row.dataset?String(row.dataset.messageAnchorKey||''):''" in UI_JS
|
||||
assert "row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);" in UI_JS
|
||||
assert "seg.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);" in UI_JS
|
||||
assert "row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);" in UI_JS
|
||||
assert "seg.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);" in UI_JS
|
||||
assert "container.querySelector(`[data-session-msg-idx=\"${sessionIdx}\"]`)" in UI_JS
|
||||
assert "if(!row&&anchorKey) return false;" in UI_JS
|
||||
assert "if(!row&&hasSessionIdx) return false;" in UI_JS
|
||||
assert "_restoreMessageViewportAnchor(snapshot.anchor,0)" in restore
|
||||
assert "if(!restoredViaAnchor){" in restore
|
||||
assert "el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop))" in restore
|
||||
assert "_programmaticScroll=true" in restore
|
||||
|
||||
|
||||
def test_same_session_reload_anchor_uses_absolute_session_message_index():
|
||||
assert "function _messageSessionIndexBase()" in UI_JS
|
||||
assert "return _messageSessionIndexBase()+n;" in UI_JS
|
||||
assert "return n-_messageSessionIndexBase();" in UI_JS
|
||||
assert "data-session-msg-idx" in UI_JS
|
||||
assert "data-message-anchor-key" in UI_JS
|
||||
assert "function _messageViewportAnchorKeyForMessage" in UI_JS
|
||||
assert "function _messageVisibleIndexForAnchorKey" in UI_JS
|
||||
assert "function _remountMessageViewportAnchor" in UI_JS
|
||||
assert "visibleKeyNode=anchorKey" in UI_JS
|
||||
assert "visIdx=anchorKey?_messageVisibleIndexForAnchorKey(anchorKey,visWithIdx):-1" in UI_JS
|
||||
assert "if(!restoredViaAnchor&&typeof _remountMessageViewportAnchor==='function'&&_remountMessageViewportAnchor(snapshot.anchor))" in UI_JS
|
||||
assert "const rawFromSession=_messageRawIdxForSessionIndex(sessionIdx);" in UI_JS
|
||||
|
||||
|
||||
def test_refresh_session_updates_message_window_offset_before_rerender():
|
||||
refresh = _function_body(UI_JS, "function refreshSession")
|
||||
messages_idx = refresh.index("S.messages = data.session.messages || [];")
|
||||
truncated_idx = refresh.index("_messagesTruncated = !!data.session._messages_truncated;")
|
||||
offset_idx = refresh.index("_oldestIdx = data.session._messages_offset || 0;")
|
||||
render_idx = refresh.index("_renderMessagesWithScrollSnapshot();")
|
||||
|
||||
assert messages_idx < truncated_idx < offset_idx < render_idx
|
||||
Reference in new issue
Block a user