From 3edbec7c972900e7308af2e4226a5de3e28ea2e2 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 21 Jun 2026 23:57:24 -0700 Subject: [PATCH] stage #4613 (dso2ng): preserve reader scroll position across re-render/reload (#4675) 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 Co-authored-by: dso2ng --- CHANGELOG.md | 6 + static/sessions.js | 29 ++++ static/ui.js | 154 +++++++++++++++--- ...st_issue1360_streaming_scroll_hardening.py | 37 +++-- tests/test_issue1690_scroll_completion.py | 2 +- tests/test_issue3545_streaming_scroll_cue.py | 5 +- ...st_issue500_message_list_virtualization.py | 3 + tests/test_session_endless_scroll.py | 8 + tests/test_tars_scroll_reset_regressions.py | 70 +++++++- 9 files changed, 276 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dcc0751..b203c67b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/sessions.js b/static/sessions.js index 7bd4d5d7..6322ec08 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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 diff --git a/static/ui.js b/static/ui.js index fe172398..d530bb47 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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;icontainerRect.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{ + _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.scrollTop0) _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}
${bodyHtml}
${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))); diff --git a/tests/test_issue1360_streaming_scroll_hardening.py b/tests/test_issue1360_streaming_scroll_hardening.py index 1520bc18..3a59db8c 100644 --- a/tests/test_issue1360_streaming_scroll_hardening.py +++ b/tests/test_issue1360_streaming_scroll_hardening.py @@ -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 diff --git a/tests/test_issue1690_scroll_completion.py b/tests/test_issue1690_scroll_completion.py index ab05785d..ffacaa83 100644 --- a/tests/test_issue1690_scroll_completion.py +++ b/tests/test_issue1690_scroll_completion.py @@ -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. diff --git a/tests/test_issue3545_streaming_scroll_cue.py b/tests/test_issue3545_streaming_scroll_cue.py index 049fa268..9f7fba1a 100644 --- a/tests/test_issue3545_streaming_scroll_cue.py +++ b/tests/test_issue3545_streaming_scroll_cue.py @@ -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(): diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 647a8724..80aeabbb 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -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')); diff --git a/tests/test_session_endless_scroll.py b/tests/test_session_endless_scroll.py index 44d5fa45..03a6d34c 100644 --- a/tests/test_session_endless_scroll.py +++ b/tests/test_session_endless_scroll.py @@ -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") diff --git a/tests/test_tars_scroll_reset_regressions.py b/tests/test_tars_scroll_reset_regressions.py index c4821c25..62c1cf7b 100644 --- a/tests/test_tars_scroll_reset_regressions.py +++ b/tests/test_tars_scroll_reset_regressions.py @@ -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