diff --git a/CHANGELOG.md b/CHANGELOG.md index 310b90aec..2704aba3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.423] — 2026-06-15 — Release OJ (virtualize long message transcripts, #500) + +### Changed + +- **Long chat transcripts are now virtualized for smoother scrolling and rendering.** Sessions with more than 80 messages render only a viewport window (plus a buffer and the most recent 50 messages always-rendered) instead of the entire transcript, with spacer elements preserving scroll geometry. Shorter sessions are unchanged. This removes the jank and slow rebuilds that long (hundreds-of-turn) sessions previously hit. Scroll restoration, jump-to-question, live streaming, and session-switch all preserve their behavior. Note: the browser's native in-page find (Ctrl+F) only matches messages currently in the rendered window on very long transcripts. (#500) + ## [v0.51.422] — 2026-06-14 — Release OI (optional Todos tab in workspace panel, #3564) ### Added diff --git a/static/sessions.js b/static/sessions.js index 98fd6ca2e..118f531d9 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2393,6 +2393,10 @@ async function _loadOlderMessages() { // Use $('messages') — the scrollable container (#msgInner is not scrollable). const container = $('messages'); const prevScrollH = container ? container.scrollHeight : 0; + const oldTop = container ? container.scrollTop : 0; + const viewportAnchor = (container && typeof _captureMessageViewportAnchor === 'function') + ? _captureMessageViewportAnchor() + : null; // Carry forward ephemeral turn fields (_turnUsage/_turnDuration/_turnTps/ // _gatewayRouting/_statusCard/_anchor_stream_id) before the wholesale replace so the badge // does not briefly appear and disappear during older-message expansion. @@ -2404,28 +2408,42 @@ async function _loadOlderMessages() { // renderMessages() windows long transcripts from the end. If we do not // expand that window before rendering, the newly prepended page stays // hidden and the "hidden" counter rises while the viewport appears stuck. - // Count roughly by the same visible-message rules used by renderMessages(). + // Count by the same visible-message rules used by renderMessages(); the + // virtual fallback below uses this as a pixel-height prefix length. const addedRenderable = olderMsgs.filter(m=>{ + if(typeof _messageIsRenderable==='function') return _messageIsRenderable(m); if(!m||!m.role||m.role==='tool') return false; if(typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m)) return false; if(typeof _isPreservedCompressionTaskListMessage==='function'&&_isPreservedCompressionTaskListMessage(m)) return false; + if(typeof _isRecoveryControlMessage==='function'&&_isRecoveryControlMessage(m)) return false; const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'); - return !!(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||(typeof _messageHasReasoningPayload==='function'&&_messageHasReasoningPayload(m))))); + const hasPartialTc=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0; + return !!(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||hasPartialTc||(typeof _messageHasReasoningPayload==='function'&&_messageHasReasoningPayload(m))||(typeof _assistantMessageHasVisibleContent==='function'&&_assistantMessageHasVisibleContent(m))))); }).length; _messageRenderWindowSize=_currentMessageRenderWindowSize()+Math.max(addedRenderable, MESSAGE_RENDER_WINDOW_DEFAULT); _messagesTruncated = !!responseSession._messages_truncated; _oldestIdx = responseSession._messages_offset || 0; renderMessages({ preserveScroll: true }); if (container) { - // Prepending older messages must not teleport the reader. Preserve the - // currently visible viewport by adding the inserted height to scrollTop. - const oldTop = container.scrollTop; - const newScrollH = container.scrollHeight; - const addedHeight = Math.max(0, newScrollH - prevScrollH); - _programmaticScroll = true; - container.scrollTop = oldTop + addedHeight; - requestAnimationFrame(()=>{ _programmaticScroll = false; }); + // Prepending older messages must not teleport the reader. Anchor to the + // first visible rendered row and restore that row's top offset after the + // prepend so synthetic virtual spacer heights cannot skew the delta. + const restoredViaAnchor = (viewportAnchor && typeof _restoreMessageViewportAnchor === 'function') + ? _restoreMessageViewportAnchor(viewportAnchor, olderMsgs.length) + : false; + if (!restoredViaAnchor) { + const virtualAddedHeight = (typeof _messageVirtualPrependedHeightDelta === 'function') + ? _messageVirtualPrependedHeightDelta(addedRenderable) + : null; + const newScrollH = container.scrollHeight; + const addedHeight = Number.isFinite(virtualAddedHeight) + ? virtualAddedHeight + : Math.max(0, newScrollH - prevScrollH); + _programmaticScroll = true; + container.scrollTop = oldTop + addedHeight; + requestAnimationFrame(()=>{ _programmaticScroll = false; }); + } } _scrollPinned = false; } catch(e) { diff --git a/static/style.css b/static/style.css index afa99bac2..201d50dff 100644 --- a/static/style.css +++ b/static/style.css @@ -5012,6 +5012,7 @@ main.main > #mainPlugin{display:none;} .load-older-indicator:hover{opacity:.75;} .message-window-load-earlier{align-self:center;margin:4px auto 14px;padding:7px 14px;border:1px solid var(--border);border-radius:999px;background:var(--surface);box-shadow:var(--shadow-sm);} .message-window-load-earlier:hover{background:var(--hover-bg);border-color:var(--accent);} +.message-virtual-spacer{width:100%;pointer-events:none;} .msg-date-sep { display: flex; align-items: center; gap: 10px; margin: 22px 0 10px; padding: 0 var(--msg-rail); diff --git a/static/ui.js b/static/ui.js index 1883e8a34..862301b1e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -374,8 +374,21 @@ function _statusCardHtml(card){ } const MESSAGE_RENDER_WINDOW_DEFAULT=50; +const MESSAGE_VIRTUAL_THRESHOLD_ROWS=80; +const MESSAGE_VIRTUAL_BUFFER_PX=900; +const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT=140; +const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=2; let _messageRenderWindowSid=null; let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT; +let _messageVirtualHeightCache=[]; +let _messageVirtualHeightCacheEntries=[]; +let _messageVirtualHeightCacheLen=0; +let _messageVirtualHeightCacheSrc=null; +let _messageVirtualEstimatedRowHeight=MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT; +let _messageVirtualScrollRaf=0; +let _messageVirtualWindowKey=''; +let _messageVirtualMeasurementCycleKey=''; +let _messageVirtualMeasurementRetryCount=0; // Cached visWithIdx array — invalidated when S.messages.length changes. let _visWithIdxCache=null; let _visWithIdxCacheLen=0; @@ -385,11 +398,361 @@ function clearVisibleMessageRowCache(){ _visWithIdxCacheLen=0; _visWithIdxCacheSrc=null; } +function _clearMessageVirtualHeightCache(){ + _messageVirtualHeightCache=[]; + _messageVirtualHeightCacheEntries=[]; + _messageVirtualHeightCacheLen=0; + _messageVirtualHeightCacheSrc=null; + _messageVirtualEstimatedRowHeight=MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT; + _messageVirtualWindowKey=''; + _messageVirtualMeasurementCycleKey=''; + _messageVirtualMeasurementRetryCount=0; +} function _resetMessageRenderWindow(sid){ _messageRenderWindowSid=sid||null; _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT; + _cancelMessageVirtualizedRender(); _clearRenderCache(); clearVisibleMessageRowCache(); + _clearMessageVirtualHeightCache(); +} +function _cancelMessageVirtualizedRender(){ + if(_messageVirtualScrollRaf){ + cancelAnimationFrame(_messageVirtualScrollRaf); + _messageVirtualScrollRaf=0; + } +} +function _messageIsRenderable(m){ + if(!m||!m.role||m.role==='tool') return false; + if(_isContextCompactionMessage(m)||_isPreservedCompressionTaskListMessage(m)) return false; + if(_isRecoveryControlMessage(m)) return false; + const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; + const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'); + const hasPartialTc=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0; + const hasReasoningAnchor=hasTc||hasTu||_messageHasReasoningPayload(m); + const hasAssistantVisibleAnchor=hasTc||hasTu||hasPartialTc||_messageHasReasoningPayload(m)||_assistantMessageHasVisibleContent(m); + return !!(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasReasoningAnchor||hasAssistantVisibleAnchor))); +} +function _getVisibleMessagesWithIdx(){ + if(!_visWithIdxCache || _visWithIdxCacheLen !== S.messages.length || _visWithIdxCacheSrc !== S.messages){ + const rebuilt=[]; + let rawIdx=0; + for(const m of (S.messages||[])){ + if(_messageIsRenderable(m)) rebuilt.push({m,rawIdx}); + rawIdx++; + } + _visWithIdxCache=rebuilt; + _visWithIdxCacheLen=S.messages.length; + _visWithIdxCacheSrc=S.messages; + } + return _visWithIdxCache; +} +function _messageVirtualWindow(opts){ + const total=Math.max(0, Number(opts&&opts.total)||0); + const threshold=Math.max(1, Number(opts&&opts.threshold)||MESSAGE_VIRTUAL_THRESHOLD_ROWS); + const defaultHeight=Math.max(1, Number(opts&&opts.defaultHeight)||MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT); + const bufferPx=Math.max(0, Number(opts&&opts.bufferPx)||MESSAGE_VIRTUAL_BUFFER_PX); + const viewportHeight=Math.max(defaultHeight, Number(opts&&opts.viewportHeight)||defaultHeight*6); + const keepTailCount=Math.max(0, Number(opts&&opts.keepTailCount)||0); + const tailStart=Math.max(0, total-keepTailCount); + const heights=Array.isArray(opts&&opts.heights)?opts.heights:[]; + const rowHeightFor=(idx)=>{ + const cached=Number(heights[idx]); + return Number.isFinite(cached)&&cached>0?cached:defaultHeight; + }; + if(total<=Math.max(threshold, keepTailCount)){ + return {virtualized:false,start:0,end:total,topPad:0,bottomPad:0,total,tailStart}; + } + const scrollTop=Math.max(0, Number(opts&&opts.scrollTop)||0); + const targetTop=Math.max(0, scrollTop-bufferPx); + const targetBottom=scrollTop+viewportHeight+bufferPx; + let start=0; + let offset=0; + while(start=tailStart){ + return {virtualized:true,start:tailStart,end:tailStart,topPad:offset,bottomPad:0,total,tailStart}; + } + let end=start; + let cursor=offset; + while(end=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return; + _messageVirtualMeasurementRetryCount++; + requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true); }); +} +function _markMessageVirtualMeasurementsSettled(windowMetrics){ + _messageVirtualMeasurementCycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics); + _messageVirtualMeasurementRetryCount=0; +} +function _messageVirtualHeightEntryMatches(previousEntry, nextEntry){ + return !!( + previousEntry&&nextEntry&& + previousEntry.m===nextEntry.m + ); +} +function _messageVirtualHeightPrefixEntryMatches(previousEntry, nextEntry){ + return !!( + previousEntry&&nextEntry&& + previousEntry.rawIdx===nextEntry.rawIdx&& + _messageVirtualHeightEntryMatches(previousEntry, nextEntry) + ); +} +function _syncMessageVirtualHeightCache(visWithIdx){ + const nextEntries=Array.isArray(visWithIdx) + ? visWithIdx.map(entry=>entry?{rawIdx:entry.rawIdx,m:entry.m}:entry) + : []; + if( + _messageVirtualHeightCacheLen===S.messages.length && + _messageVirtualHeightCacheSrc===S.messages && + _messageVirtualHeightCacheEntries.length===nextEntries.length + ) return; + const previousEntries=Array.isArray(_messageVirtualHeightCacheEntries)?_messageVirtualHeightCacheEntries:[]; + const previousHeights=Array.isArray(_messageVirtualHeightCache)?_messageVirtualHeightCache.slice():[]; + let nextHeights=null; + if(!previousEntries.length){ + nextHeights=new Array(nextEntries.length); + }else if(!nextEntries.length){ + _clearMessageVirtualHeightCache(); + _messageVirtualHeightCacheLen=S.messages.length; + _messageVirtualHeightCacheSrc=S.messages; + return; + }else{ + const sharedPrefix=Math.min(previousEntries.length,nextEntries.length); + let prefixMatches=true; + for(let i=0;i=previousEntries.length){ + const prependedCount=nextEntries.length-previousEntries.length; + let suffixMatches=true; + for(let i=0;i0)?cached:_messageVirtualEstimatedRowHeight; + } + return Math.max(0,Math.round(total)); +} +function _messageVisibleIndexForRawIdx(rawIdx, visWithIdx){ + const list=Array.isArray(visWithIdx)?visWithIdx:_getVisibleMessagesWithIdx(); + for(let i=0;i0)?cached:_messageVirtualEstimatedRowHeight; + } + const viewport=container?Math.max(0,Number(container.clientHeight)||0):0; + return Math.max(0,Math.round(offset-(viewport*0.35))); +} +function _messageVirtualKeepTailCount(){ + return Math.min(_currentMessageRenderWindowSize(), MESSAGE_RENDER_WINDOW_DEFAULT); +} +function _captureMessageViewportAnchor(){ + const container=$('messages'); + if(!container) return null; + const containerRect=container.getBoundingClientRect(); + const rows=Array.from(container.querySelectorAll('[data-msg-idx]')); + for(const row of rows){ + const rawIdx=Number(row&&row.dataset&&row.dataset.msgIdx); + if(!Number.isFinite(rawIdx)) continue; + const rect=row.getBoundingClientRect(); + if(rect.bottom>containerRect.top+1){ + return {rawIdx, topOffset:rect.top-containerRect.top}; + } + } + return null; +} +function _restoreMessageViewportAnchor(anchor, rawIdxDelta){ + const container=$('messages'); + if(!container||!anchor) 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) return false; + const containerRect=container.getBoundingClientRect(); + const rect=row.getBoundingClientRect(); + const targetTop=Number(anchor.topOffset)||0; + _programmaticScroll=true; + container.scrollTop+=(rect.top-containerRect.top)-targetTop; + requestAnimationFrame(()=>{ _programmaticScroll=false; }); + return true; +} +function _measureMessageVirtualRow(inner, entry){ + if(!inner||!entry) return 0; + const primary=inner.querySelector(`[data-msg-idx="${entry.rawIdx}"]`); + if(!primary) return 0; + let totalHeight=Math.max(0, primary.getBoundingClientRect().height||0); + if(primary.classList.contains('assistant-segment')){ + let sibling=primary.nextElementSibling; + while(sibling){ + if(sibling.hasAttribute('data-msg-idx')) break; + if(!(sibling.matches&&sibling.matches('.tool-call-group,.tool-card-row,.agent-activity-thinking,.thinking-card-row'))) break; + totalHeight+=Math.max(0, sibling.getBoundingClientRect().height||0); + sibling=sibling.nextElementSibling; + } + } + return totalHeight; +} +function _updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, virtualWindow){ + const inner=$('msgInner'); + if(!inner||!virtualWindow||!virtualWindow.virtualized||!renderVisWithIdx.length) return; + let changed=false; + let measuredCount=0; + let measuredTotal=0; + for(let vi=0;vi1){ + _messageVirtualHeightCache[visibleIdx]=totalHeight; + changed=true; + } + measuredTotal+=totalHeight; + measuredCount++; + } + if(measuredCount>0){ + _messageVirtualEstimatedRowHeight=Math.max(60, Math.round(measuredTotal/measuredCount)); + } + if(changed){ + _scheduleMessageVirtualMeasurementRefresh(virtualWindow); + }else{ + _markMessageVirtualMeasurementsSettled(virtualWindow); + } +} +function _scheduleMessageVirtualizedRender(force){ + const container=$('messages'); + const inner=$('msgInner'); + if(!container||!inner) return; + const visWithIdx=_getVisibleMessagesWithIdx(); + const virtualWindow=_currentMessageVirtualWindow(visWithIdx,_messageVirtualKeepTailCount()); + const nextKey=_messageVirtualWindowKeyFor(virtualWindow); + if(!force&&nextKey===_messageVirtualWindowKey) return; + if(!virtualWindow.virtualized){ + _messageVirtualWindowKey=nextKey; + return; + } + if(_messageVirtualScrollRaf) return; + _messageVirtualScrollRaf=requestAnimationFrame(()=>{ + _messageVirtualScrollRaf=0; + const liveVisWithIdx=_getVisibleMessagesWithIdx(); + const liveWindow=_currentMessageVirtualWindow(liveVisWithIdx,_messageVirtualKeepTailCount()); + const liveKey=_messageVirtualWindowKeyFor(liveWindow); + if(!force&&liveKey===_messageVirtualWindowKey) return; + renderMessages({ preserveScroll:true }); + }); } // ── renderMd / _renderUserFencedBlocks cache ────────────────────────────── @@ -425,16 +788,7 @@ function _currentMessageRenderWindowSize(){ ); } function _messageRenderableMessageCount(){ - let count=0; - for(const m of (S.messages||[])){ - if(!m||!m.role||m.role==='tool') continue; - if(_isContextCompactionMessage(m)||_isPreservedCompressionTaskListMessage(m)) continue; - if(_isRecoveryControlMessage(m)) continue; - const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; - const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'); - if(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)||_assistantMessageHasVisibleContent(m)))) count++; - } - return count; + return _getVisibleMessagesWithIdx().length; } function _messageHiddenBeforeCount(){ return Math.max(0,_messageRenderableMessageCount()-_currentMessageRenderWindowSize()); @@ -446,22 +800,9 @@ function _wireMessageWindowLoadEarlierButton(){ const indicator=$('loadOlderIndicator'); if(!indicator) return; indicator.onclick=()=>{ - if(_messageHiddenBeforeCount()>0) _showEarlierRenderedMessages(); - else if(typeof _loadOlderMessages==='function') _loadOlderMessages(); + if(typeof _loadOlderMessages==='function') _loadOlderMessages(); }; } -function _showEarlierRenderedMessages(){ - const container=$('messages'); - const prevScrollH=container?container.scrollHeight:0; - const prevScrollTop=container?container.scrollTop:0; - _messageRenderWindowSize=_currentMessageRenderWindowSize()+MESSAGE_RENDER_WINDOW_DEFAULT; - renderMessages(); - if(container){ - const newScrollH=container.scrollHeight; - container.scrollTop=prevScrollTop+(newScrollH-prevScrollH); - } - _scrollPinned=false; -} function _isSessionJumpButtonsEnabled(){ return window._sessionJumpButtonsEnabled===true; } @@ -498,6 +839,8 @@ async function jumpToSessionStart(){ if(typeof _ensureAllMessagesLoaded==='function') await _ensureAllMessagesLoaded(); } _messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount()); + container.scrollTop=0; + _messageVirtualWindowKey=''; // During streaming, skip renderMessages — it rebuilds the DOM but tool card // insertion is blocked by !S.busy, losing Activity until "done" fires. if(!(S.busy||S.activeStreamId)){ @@ -560,8 +903,29 @@ async function jumpToTurnQuestion(questionRawIdx, assistantRawIdx){ return true; }; if(scrollToTarget()) return; + const visWithIdx=_getVisibleMessagesWithIdx(); + const visibleIdx=_messageVisibleIndexForRawIdx(questionRawIdx, visWithIdx); + if(visibleIdx>=0){ + _scrollPinned=false; + _messageUserUnpinned=true; + _programmaticScroll=true; + container.scrollTop=_messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container); + _messageVirtualWindowKey=''; + renderMessages({ preserveScroll:true }); + requestAnimationFrame(()=>{ + if(!scrollToTarget()&&_messageHiddenBeforeCount()>0){ + _messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount()); + _messageVirtualWindowKey=''; + renderMessages({ preserveScroll:true }); + requestAnimationFrame(scrollToTarget); + } + requestAnimationFrame(()=>{ _programmaticScroll=false; }); + }); + return; + } if(_messageHiddenBeforeCount()>0){ _messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount()); + _messageVirtualWindowKey=''; renderMessages({ preserveScroll:true }); requestAnimationFrame(scrollToTarget); } @@ -2727,6 +3091,7 @@ if(typeof window!=='undefined'){ const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80; _syncScrollToBottomCue(showBottomButton,{newMessage:_newMessageCueVisible}); if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton(); + _scheduleMessageVirtualizedRender(); // Prefetch older messages before the reader hits the hard top. Prepending // then preserving scrollTop is seamless only if there is runway left for // the user's continued upward wheel/touch movement. @@ -8615,6 +8980,7 @@ function clearMessageRenderCache(){ _sessionHtmlCache.clear(); _sessionHtmlCacheSid=null; clearVisibleMessageRowCache(); + _clearMessageVirtualHeightCache(); } function _messageRenderCacheSignature(){ @@ -8938,13 +9304,31 @@ function renderMessages(options){ // renderMessages() in this window. Keep the existing loading placeholder. if(_loadingSessionId===sid&&msgCount===0&&inner) return; if(sid!==_messageRenderWindowSid) _resetMessageRenderWindow(sid); - const renderWindowSize=_currentMessageRenderWindowSize(); let cachedRenderSignature=null; const hasTransientTranscriptUi=!!( (window._compressionUi&&(!window._compressionUi.sessionId||window._compressionUi.sessionId===sid)) || (window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid)) ); + const preservedCompressionTaskMessages=_latestPreservedCompressionTaskListMessages(S.messages); + const visWithIdx=_getVisibleMessagesWithIdx(); + $('emptyState').style.display=(visWithIdx.length||preservedCompressionTaskMessages.length)?'none':''; + const virtualWindow=_currentMessageVirtualWindow(visWithIdx,_messageVirtualKeepTailCount()); + const renderWindowKey=_messageVirtualWindowKeyFor(virtualWindow); + const windowStart=virtualWindow.start; + const windowEnd=virtualWindow.end; + const renderHeadVisWithIdx=visWithIdx.slice(windowStart, windowEnd); + const renderTailStart=virtualWindow.virtualized?Math.max(windowEnd, virtualWindow.tailStart):windowEnd; + const renderTailVisWithIdx=virtualWindow.virtualized&&renderTailStartwindowStart+idx), + ...renderTailVisWithIdx.map((_,idx)=>renderTailStart+idx), + ]; + const headRenderCount=renderHeadVisWithIdx.length; + // Fast path: switching back to a previously rendered session with same count. // Guard: sid !== _sessionHtmlCacheSid ensures in-session updates (edits, // new messages, tool_complete) always get a fresh rebuild. @@ -8957,21 +9341,38 @@ function renderMessages(options){ const renderSignature=_messageRenderCacheSignature(); cachedRenderSignature=renderSignature; const cached=_sessionHtmlCache.get(sid); - if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize&&cached.signature===renderSignature){ + if(cached&&cached.msgCount===msgCount&&cached.renderWindowKey===renderWindowKey&&cached.signature===renderSignature){ inner.innerHTML=cached.html; + _messageVirtualWindowKey=renderWindowKey; _sessionHtmlCacheSid=sid; _rehydrateTransparentStreamDom(inner); _wireMessageWindowLoadEarlierButton(); if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs(); _scrollAfterMessageRender(preserveScroll, scrollSnapshot); + _updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, virtualWindow); requestAnimationFrame(()=>postProcessRenderedMessages(inner)); if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver(); if(typeof loadTodos==='function'&&document.getElementById('panelTodos')&&document.getElementById('panelTodos').classList.contains('active')){loadTodos();} return; } } - const worklogDetailDisclosureState=_captureWorklogDetailDisclosureState(inner); - + // Mid-stream flicker fix (#3877): when a renderMessages() rebuild is reached + // while THIS session is actively streaming (e.g. the clarify-response echo at + // messages.js, or a CLI-import refresh), the `inner.innerHTML=''` below detaches + // the live `#liveAssistantTurn` node — and the smd parser keeps writing into + // that now-orphaned node, so the streamed text vanishes until the next stream + // event rebuilds the turn ("disappears, then reappears"). Capture the live + // turn's actual DOM node (not its HTML — the parser holds a live reference into + // it) so it can be re-attached after the rebuild, keeping the parser target + // connected and the streamed text visible. Only for the streaming session's own + // live turn; never affects settled transcripts. + let _preservedLiveTurn=null; + if(sid&&INFLIGHT[sid]){ + const _lt=document.getElementById('liveAssistantTurn'); + if(_lt&&(!_lt.dataset||!_lt.dataset.sessionId||_lt.dataset.sessionId===sid)){ + _preservedLiveTurn=_lt; + } + } const compressionState=(()=>{ let compressionState=_compressionStateForCurrentSession(); if(!S.busy && compressionState && compressionState.automatic){ @@ -8994,41 +9395,7 @@ function renderMessages(options){ const sessionCompressionSummary=( S.session && typeof S.session.compression_anchor_summary==='string' ) ? S.session.compression_anchor_summary.trim() : ''; - const preservedCompressionTaskMessages=_latestPreservedCompressionTaskListMessages(S.messages); - const vis=S.messages.filter(m=>{ - if(!m||!m.role||m.role==='tool')return false; - if(_isContextCompactionMessage(m)) return false; - if(_isPreservedCompressionTaskListMessage(m)) return false; - if(_isRecoveryControlMessage(m)) return false; - if(m.role==='assistant'){ - const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; - const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'); - const hasPartialTc=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0; - if(hasTc||hasTu||hasPartialTc||_messageHasReasoningPayload(m)) return true; - if(_assistantMessageHasVisibleContent(m)) return true; - const visibleText=_isAssistantEmptyPlaceholderContent(m,msgContent(m))?'':msgContent(m); - return m._statusCard||visibleText||m.attachments?.length; - } - return m._statusCard||msgContent(m)||m.attachments?.length; - }); - $('emptyState').style.display=(vis.length||preservedCompressionTaskMessages.length)?'none':''; - // Mid-stream flicker fix (#3877): when a renderMessages() rebuild is reached - // while THIS session is actively streaming (e.g. the clarify-response echo at - // messages.js, or a CLI-import refresh), the `inner.innerHTML=''` below detaches - // the live `#liveAssistantTurn` node — and the smd parser keeps writing into - // that now-orphaned node, so the streamed text vanishes until the next stream - // event rebuilds the turn ("disappears, then reappears"). Capture the live - // turn's actual DOM node (not its HTML — the parser holds a live reference into - // it) so it can be re-attached after the rebuild, keeping the parser target - // connected and the streamed text visible. Only for the streaming session's own - // live turn; never affects settled transcripts. - let _preservedLiveTurn=null; - if(sid&&INFLIGHT[sid]){ - const _lt=document.getElementById('liveAssistantTurn'); - if(_lt&&(!_lt.dataset||!_lt.dataset.sessionId||_lt.dataset.sessionId===sid)){ - _preservedLiveTurn=_lt; - } - } + const worklogDetailDisclosureState=_captureWorklogDetailDisclosureState(inner); inner.innerHTML=''; const compressionNode=compressionState?_compressionCardsNode(compressionState):null; const {message:referenceMessage, rawIdx:referenceMessageRawIdx}=_latestCompressionReferenceMessage( @@ -9042,29 +9409,6 @@ function renderMessages(options){ ? (()=>{const row=document.createElement('div');row.innerHTML=`
${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}
`;return row.firstElementChild;})() : null; let preservedCompressionTaskCardsAttached=!!referenceNode; - // Cache visWithIdx so expanding the render window (Load earlier) doesn't - // re-scan S.messages from scratch. Invalidate only when the message array - // length changes — i.e. new messages arrived or session was truncated. - if(!_visWithIdxCache || _visWithIdxCacheLen !== S.messages.length || _visWithIdxCacheSrc !== S.messages){ - const rebuilt=[]; - let ri=0; - for(const m of S.messages){ - if(!m||!m.role||m.role==='tool'){ri++;continue;} - if(_isContextCompactionMessage(m)){ri++;continue;} - if(_isPreservedCompressionTaskListMessage(m)){ri++;continue;} - if(_isRecoveryControlMessage(m)){ri++;continue;} - const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; - const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'); - const hasPartialTc=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0; - const visibleText=_isAssistantEmptyPlaceholderContent(m,msgContent(m))?'':msgContent(m); - if(visibleText||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||hasPartialTc||_messageHasReasoningPayload(m)||_assistantMessageHasVisibleContent(m)))) rebuilt.push({m,rawIdx:ri}); - ri++; - } - _visWithIdxCache=rebuilt; - _visWithIdxCacheLen=S.messages.length; - _visWithIdxCacheSrc=S.messages; - } - const visWithIdx=_visWithIdxCache; const preservedCompressionRawIdxs=[]; let rawIdx=0; for(const m of S.messages){ @@ -9072,33 +9416,23 @@ function renderMessages(options){ if(_isPreservedCompressionTaskListMessage(m)){preservedCompressionRawIdxs.push(rawIdx);rawIdx++;continue;} rawIdx++; } - // Show a top affordance when earlier transcript content exists either in - // memory (DOM windowing) or on the server (paginated session fetch). - // Prefer expanding the local render window first so a fully loaded long - // session can reduce DOM nodes without losing in-memory transcript data. - const windowStart=Math.max(0, visWithIdx.length-renderWindowSize); - const hiddenBeforeCount=windowStart; - const renderVisWithIdx=visWithIdx.slice(windowStart); const firstRenderedRawIdx=renderVisWithIdx.length?renderVisWithIdx[0].rawIdx:Infinity; const assistantTurnFinalVisibleContentByRawIdx=_assistantTurnFinalVisibleContentMap(visWithIdx); const assistantTurnVisibleContentByRawIdx=_assistantTurnVisibleContentMap(visWithIdx); const hasServerOlder=!!(typeof _messagesTruncated!=='undefined' && _messagesTruncated && S.messages.length>0); const serverOlderCount=hasServerOlder&&Number.isFinite(Number(_oldestIdx))?Math.max(0,Number(_oldestIdx)):0; if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs(); - if(hiddenBeforeCount>0 || hasServerOlder){ + if(virtualWindow.virtualized&&virtualWindow.topPad>0){ + inner.appendChild(_messageVirtualSpacer(virtualWindow.topPad,'before')); + } + if(hasServerOlder){ const indicator=document.createElement('button'); indicator.type='button'; indicator.id='loadOlderIndicator'; indicator.className='load-older-indicator message-window-load-earlier'; - indicator.textContent=hiddenBeforeCount>0 - ? `Load earlier messages (${hiddenBeforeCount} hidden)` - : (serverOlderCount>0 - ? `Load earlier messages (${serverOlderCount} older)` - : (typeof t==='function'?t('load_older_messages'):'Load earlier messages')); - indicator.onclick=()=>{ - if(hiddenBeforeCount>0) _showEarlierRenderedMessages(); - else if(typeof _loadOlderMessages==='function') _loadOlderMessages(); - }; + indicator.textContent=serverOlderCount>0 + ? `Load earlier messages (${serverOlderCount} older)` + : (typeof t==='function'?t('load_older_messages'):'Load earlier messages'); inner.appendChild(indicator); _wireMessageWindowLoadEarlierButton(); } @@ -9118,9 +9452,21 @@ function renderMessages(options){ ); let insertionAnchor=null; if(typeof insertionAnchorFull==='number'){ - if(insertionAnchorFullidx!==windowStart+pos); + if(!hasVirtualRenderGap){ + if(insertionAnchorFull=0?previousVisibleIdx:0; + }else{ + insertionAnchor=null; + } } let _prevSepKey=null; let currentAssistantTurn=null; @@ -9128,18 +9474,13 @@ function renderMessages(options){ // full visWithIdx. The jump-to-question button is only rendered for // assistant messages that appear in the current render window anyway. const questionRawIdxByAssistantRawIdx=new Map(); - // Seed lastQuestionRawIdx from hidden messages so the first visible - // assistant message still gets a valid jump target even when its - // corresponding user message sits just before the render window. let lastQuestionRawIdx=-1; - for(let i=0;ie.rawIdx)); + const renderableRawIdxs=new Set(visWithIdx.map(e=>e.rawIdx)); + for(const entry of visWithIdx){ const role=entry&&entry.m&&entry.m.role; if(role==='user') lastQuestionRawIdx=entry.rawIdx; - else if(role==='assistant') questionRawIdxByAssistantRawIdx.set(entry.rawIdx,lastQuestionRawIdx); + else if(role==='assistant'&&renderedRawIdxs.has(entry.rawIdx)) questionRawIdxByAssistantRawIdx.set(entry.rawIdx,lastQuestionRawIdx); } const assistantRawIdxByQuestionRawIdx=new Map(); for(const [aIdx,qIdx] of questionRawIdxByAssistantRawIdx){ @@ -9187,7 +9528,6 @@ function renderMessages(options){ // range. const toolCallAssistantIdxs=new Set(); if(Array.isArray(S.toolCalls)){ - const renderedRawIdxs=new Set(renderVisWithIdx.map(e=>e.rawIdx)); for(const tc of S.toolCalls){ if(!tc) continue; const idx=tc.assistant_msg_idx; @@ -9199,6 +9539,13 @@ function renderMessages(options){ // Windowed render loop replaces the legacy full loop: // for(let vi=0;vi0&&vi===headRenderCount){ + // The virtual gap breaks assistant-turn adjacency. Reset the current + // turn before rendering the always-visible tail so assistant segments do + // not merge across the spacer boundary. + currentAssistantTurn=null; + inner.appendChild(_messageVirtualSpacer(virtualWindow.bottomPad,'after')); + } const {m,rawIdx}=renderVisWithIdx[vi]; const _tsSep=m._ts||m.timestamp; if(_tsSep){ @@ -9708,6 +10055,7 @@ function renderMessages(options){ for(const tc of (S.toolCalls||[])){ if(!tc) continue; const aIdx=tc.assistant_msg_idx!==undefined?parseInt(tc.assistant_msg_idx):-1; + if(virtualWindow.virtualized&&renderableRawIdxs.has(aIdx)&&!renderedRawIdxs.has(aIdx)) continue; const segmentSeq=normalizeToken(tc.activitySegmentSeq); const burstId=normalizeToken(tc.activityBurstId); const key=segmentSeq?`segment:${segmentSeq}`:(burstId?`burst:${burstId}`:`assistant:${aIdx}`); @@ -9716,6 +10064,7 @@ function renderMessages(options){ entry.includeAnchorReason=true; } for(const aIdx of assistantThinking.keys()){ + if(virtualWindow.virtualized&&renderableRawIdxs.has(aIdx)&&!renderedRawIdxs.has(aIdx)) continue; const seg=assistantSegments.get(aIdx); const segmentSeq=seg&&seg.getAttribute('data-live-segment-seq')||''; const burstId=seg&&seg.getAttribute('data-activity-burst-id')||''; @@ -9725,6 +10074,7 @@ function renderMessages(options){ } for(const [aIdx,seg] of assistantSegments){ if(!seg||!seg.classList||!seg.classList.contains('assistant-segment-worklog-source')) continue; + if(virtualWindow.virtualized&&renderableRawIdxs.has(aIdx)&&!renderedRawIdxs.has(aIdx)) continue; if(!_worklogReasonHtmlFromAnchor(seg)) continue; const segmentSeq=seg&&seg.getAttribute('data-live-segment-seq')||''; const burstId=seg&&seg.getAttribute('data-activity-burst-id')||''; @@ -9864,6 +10214,7 @@ function renderMessages(options){ } } _restoreWorklogDetailDisclosureState(inner, worklogDetailDisclosureState); + _messageVirtualWindowKey=renderWindowKey; // Render per-turn duration and optional token usage on assistant messages. // Duration stays visible even when token usage is disabled, because it answers // the basic "how long did that turn take?" UX question. Only walk rendered @@ -10104,7 +10455,21 @@ function renderMessages(options){ } } const _preservedLen=_liveAssistantSegmentTextLength(_preservedSeg||_preservedLiveTurn); - if(_preservedLen>0){ + // Structural-block counts: a live turn can be AHEAD of S.messages with + // Activity/tool/worklog blocks that haven't persisted yet — even with ZERO + // streamed text (e.g. an Activity-only turn mid-tool-call). The text-length + // gate alone would skip preservation in that case, so a scroll-triggered + // rebuild on a long (virtualized) transcript could blink those live-only + // blocks for a frame. Also restore when the preserved turn carries more + // structure than the rebuilt (lagging-S.messages) turn. (#3714 ship-review) + const _structuralCount=(turn)=> turn?turn.querySelectorAll( + '[data-live-assistant="1"],.tool-call-group,.tool-card-row,'+ + '.tool-worklog-group,.live-worklog[data-live-worklog-shell="1"],'+ + '.wl-reason,.agent-activity-thinking,.thinking-card-row' + ).length:0; + const _preservedStructure=_structuralCount(_preservedLiveTurn); + const _rebuiltStructure=_structuralCount(_rebuilt); + if(_preservedLen>0 || _preservedStructure>_rebuiltStructure){ const _rebuiltLen=_rebuilt?_liveAssistantSegmentTextLength(_rebuiltSeg||_rebuilt):-1; if(_rebuiltLen<=_preservedLen){ // Decide segment-level vs whole-turn restore. Segment-level keeps the @@ -10117,13 +10482,6 @@ function renderMessages(options){ // blocks for a frame — so restore the WHOLE preserved turn instead. // Otherwise (rebuild has >= the preserved turn's structural blocks) do // the precise segment swap so rebuilt-only structure is kept. - const _structuralCount=(turn)=> turn?turn.querySelectorAll( - '[data-live-assistant="1"],.tool-call-group,.tool-card-row,'+ - '.tool-worklog-group,.live-worklog[data-live-worklog-shell="1"],'+ - '.wl-reason,.agent-activity-thinking,.thinking-card-row' - ).length:0; - const _preservedStructure=_structuralCount(_preservedLiveTurn); - const _rebuiltStructure=_structuralCount(_rebuilt); if(_rebuilt&&_rebuiltSeg&&_preservedSeg&&_rebuiltStructure>=_preservedStructure){ // Rebuild is the structural superset — swap only the parser-owned // (tail) live segment, keeping rebuilt-only segments / tool groups. @@ -10163,10 +10521,11 @@ function renderMessages(options){ // Only cache sessions with <300KB rendered HTML; evict oldest beyond 8 sessions. if(_html.length<300_000){ const renderSignature=cachedRenderSignature===null?_messageRenderCacheSignature():cachedRenderSignature; - _sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature}); + _sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowKey,signature:renderSignature}); if(_sessionHtmlCache.size>8){_sessionHtmlCache.delete(_sessionHtmlCache.keys().next().value);} } } + _updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, virtualWindow); } function _toolDisplayName(tc){ diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py index f65a2d0cf..68b900dd2 100644 --- a/tests/test_auto_compression_card.py +++ b/tests/test_auto_compression_card.py @@ -589,31 +589,25 @@ def test_settled_transcript_suppresses_context_compaction_reference_cards(): assert "function _shouldShowSettledCompressionReference" in src assert "!_isContextCompactionText(referenceText)" in src - visible_filter_start = src.find("const vis=S.messages.filter") - assert visible_filter_start != -1, "visible message filter not found" - visible_filter_end = src.find("$('emptyState')", visible_filter_start) - visible_filter = src[visible_filter_start:visible_filter_end] - assert "if(_isContextCompactionMessage(m)) return false;" in visible_filter - - vis_idx_start = src.find("for(const m of S.messages)", visible_filter_end) - assert vis_idx_start != -1, "raw message index loop not found" - vis_idx_end = src.find("let lastUserRawIdx", vis_idx_start) - vis_idx_loop = src[vis_idx_start:vis_idx_end] - assert "if(_isContextCompactionMessage(m)){ri++;continue;}" in vis_idx_loop + helper_start = src.find("function _messageIsRenderable") + assert helper_start != -1, "renderable message helper not found" + helper_end = src.find("function _getVisibleMessagesWithIdx", helper_start) + assert helper_end != -1, "visible message index helper not found after renderable helper" + helper = src[helper_start:helper_end] + assert "_isContextCompactionMessage(m)||_isPreservedCompressionTaskListMessage(m)" in helper def test_preserved_task_list_skips_normal_visible_message_path(): src = _read("static/ui.js") - visible_filter_start = src.find("const vis=S.messages.filter") - assert visible_filter_start != -1, "visible message filter not found" - visible_filter_end = src.find("$('emptyState')", visible_filter_start) - assert visible_filter_end != -1, "empty state update after visible filter not found" - visible_filter = src[visible_filter_start:visible_filter_end] - assert "if(_isContextCompactionMessage(m)) return false;" in visible_filter - assert "if(_isPreservedCompressionTaskListMessage(m)) return false;" in visible_filter + helper_start = src.find("function _messageIsRenderable") + assert helper_start != -1, "renderable message helper not found" + helper_end = src.find("function _getVisibleMessagesWithIdx", helper_start) + assert helper_end != -1, "visible message index helper not found after renderable helper" + helper = src[helper_start:helper_end] + assert "_isContextCompactionMessage(m)||_isPreservedCompressionTaskListMessage(m)" in helper - vis_idx_start = src.find("for(const m of S.messages)", visible_filter_end) + vis_idx_start = src.find("for(const m of S.messages)", helper_end) assert vis_idx_start != -1, "raw message index loop not found" vis_idx_end = src.find("let lastUserRawIdx", vis_idx_start) assert vis_idx_end != -1, "last user index lookup after raw message loop not found" @@ -675,7 +669,9 @@ def test_compression_anchor_index_is_translated_into_render_window(): assert "_compressionAnchorIndex(\n visWithIdx," in block assert "insertionAnchorFull=0?previousVisibleIdx:0;" in block def test_reference_message_uses_raw_transcript_position_before_anchor_fallback(): diff --git a/tests/test_issue2246_question_jump.py b/tests/test_issue2246_question_jump.py index 1498b7871..dbf8cb597 100644 --- a/tests/test_issue2246_question_jump.py +++ b/tests/test_issue2246_question_jump.py @@ -36,6 +36,11 @@ def test_multi_segment_turn_jumps_to_first_assistant_segment(): def test_question_jump_expands_windowed_history_and_highlights_question(): + assert "function _messageVisibleIndexForRawIdx(rawIdx, visWithIdx)" in UI_JS + assert "function _messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container)" in UI_JS + assert "const visibleIdx=_messageVisibleIndexForRawIdx(questionRawIdx, visWithIdx);" in UI_JS + assert "container.scrollTop=_messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container);" in UI_JS + assert "_messageVirtualWindowKey='';" in UI_JS assert "_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount())" in UI_JS assert "renderMessages({ preserveScroll:true })" in UI_JS assert "row.scrollIntoView({block:'center',behavior:'smooth'})" in UI_JS diff --git a/tests/test_issue401.py b/tests/test_issue401.py index d63477573..d82f7650e 100644 --- a/tests/test_issue401.py +++ b/tests/test_issue401.py @@ -72,14 +72,16 @@ def test_loadsession_uses_session_toolcalls_only_as_fallback(): def test_rendermessages_treats_openai_toolcall_assistants_as_visible(): """OpenAI assistant rows with empty content but tool_calls must stay anchorable.""" + assert "function _messageIsRenderable(m)" in UI_JS assert "const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;" in UI_JS - assert "if(hasTc||hasTu||hasPartialTc||_messageHasReasoningPayload(m)) return true;" in UI_JS + assert "hasTc||hasTu||hasPartialTc||_messageHasReasoningPayload(m)||_assistantMessageHasVisibleContent(m)" in UI_JS def test_rendermessages_treats_partial_toolcall_assistants_as_visible(): """Assistant rows carrying `_partial_tool_calls` must stay anchorable.""" + assert "function _messageIsRenderable(m)" in UI_JS assert "const hasPartialTc=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0;" in UI_JS - assert "if(hasTc||hasTu||hasPartialTc||_messageHasReasoningPayload(m)) return true;" in UI_JS + assert "hasTc||hasTu||hasPartialTc||_messageHasReasoningPayload(m)||_assistantMessageHasVisibleContent(m)" in UI_JS def test_rendermessages_rebuilds_tool_cards_from_partial_tool_calls(): diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py new file mode 100644 index 000000000..3401f5cf6 --- /dev/null +++ b/tests/test_issue500_message_list_virtualization.py @@ -0,0 +1,364 @@ +"""Regression coverage for issue #500 transcript virtualization.""" +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent.resolve() +UI_JS_PATH = REPO_ROOT / "static" / "ui.js" +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _run_node(source: str) -> str: + with tempfile.NamedTemporaryFile( + "w", suffix=".cjs", encoding="utf-8", dir=REPO_ROOT, delete=False + ) as script: + script.write(source) + script_path = Path(script.name) + try: + result = subprocess.run( + [NODE, str(script_path)], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=10, + ) + finally: + script_path.unlink(missing_ok=True) + if result.returncode != 0: + raise RuntimeError(result.stderr) + return result.stdout.strip() + + +def _extract_func_script(js: str) -> str: + return f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +""" + + +def test_message_virtual_window_virtualizes_older_history_but_keeps_recent_tail(): + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +eval(extractFunc('_messageVirtualWindow')); +const metrics = _messageVirtualWindow({ + total: 240, + scrollTop: 120 * 70, + viewportHeight: 720, + heights: Array.from({length: 240}, (_, i) => i >= 190 ? 220 : 120), + defaultHeight: 120, + bufferPx: 240, + threshold: 80, + keepTailCount: 50, +}); +console.log(JSON.stringify(metrics)); +""" + metrics = json.loads(_run_node(source)) + assert metrics["virtualized"] is True + assert 60 <= metrics["start"] <= 75 + assert metrics["end"] <= metrics["tailStart"] == 190 + assert metrics["topPad"] > 0 + assert metrics["bottomPad"] > 0 + + +def test_message_virtual_window_collapses_to_tail_only_near_bottom(): + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +eval(extractFunc('_messageVirtualWindow')); +const metrics = _messageVirtualWindow({ + total: 240, + scrollTop: 120 * 260, + viewportHeight: 720, + heights: Array.from({length: 240}, () => 120), + defaultHeight: 120, + bufferPx: 240, + threshold: 80, + keepTailCount: 50, +}); +console.log(JSON.stringify(metrics)); +""" + metrics = json.loads(_run_node(source)) + assert metrics["virtualized"] is True + assert metrics["start"] == metrics["tailStart"] == 190 + assert metrics["end"] == metrics["tailStart"] + assert metrics["bottomPad"] == 0 + + +def test_render_messages_uses_virtual_window_and_spacer_measurement_path(): + js = UI_JS_PATH.read_text(encoding="utf-8") + render_start = js.index("function renderMessages(options)") + render_end = js.index("function _toolDisplayName", render_start) + render_body = js[render_start:render_end] + + assert "_currentMessageVirtualWindow(visWithIdx,_messageVirtualKeepTailCount())" in render_body + assert "const renderVisibleIdxs=[" in render_body + assert "_messageVirtualSpacer(virtualWindow.topPad,'before')" in render_body + assert "_messageVirtualSpacer(virtualWindow.bottomPad,'after')" in render_body + assert "_updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, virtualWindow);" in render_body + assert "const renderableRawIdxs=new Set(visWithIdx.map(e=>e.rawIdx));" in render_body + assert "if(virtualWindow.virtualized&&renderableRawIdxs.has(aIdx)&&!renderedRawIdxs.has(aIdx)) continue;" in render_body + assert "if(hasServerOlder){" in render_body + assert "_showEarlierRenderedMessages();" not in render_body + top_spacer_idx = render_body.index("_messageVirtualSpacer(virtualWindow.topPad,'before')") + indicator_idx = render_body.index("indicator.id='loadOlderIndicator';") + assert top_spacer_idx < indicator_idx, ( + "renderMessages() must place the load-older affordance after the top " + "virtual spacer so it stays visible at the top of the rendered window." + ) + gap_reset_idx = render_body.index("currentAssistantTurn=null;", render_body.index("_messageVirtualSpacer(virtualWindow.bottomPad,'after')") - 220) + gap_spacer_idx = render_body.index("_messageVirtualSpacer(virtualWindow.bottomPad,'after')") + assert gap_reset_idx < gap_spacer_idx, ( + "renderMessages() must reset currentAssistantTurn before inserting the " + "virtual gap spacer so assistant bubbles do not merge across the head/tail boundary." + ) + + +def test_measurement_uses_one_primary_row_and_adjacent_activity_siblings_only(): + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +eval(extractFunc('_measureMessageVirtualRow')); +const nextMessage = { + hasAttribute(name){ return name === 'data-msg-idx'; }, + getBoundingClientRect(){ return {height: 999}; }, + nextElementSibling: null, +}; +const activityGroup = { + hasAttribute(){ return false; }, + matches(selector){ return selector === '.tool-call-group,.tool-card-row,.agent-activity-thinking,.thinking-card-row'; }, + getBoundingClientRect(){ return {height: 60}; }, + nextElementSibling: { + hasAttribute(){ return false; }, + matches(){ return false; }, + getBoundingClientRect(){ return {height: 5000}; }, + nextElementSibling: nextMessage, + }, +}; +const primary = { + classList: { contains(name){ return name === 'assistant-segment'; } }, + getBoundingClientRect(){ return {height: 120}; }, + nextElementSibling: activityGroup, +}; +const inner = { + querySelector(selector){ + if(selector === '[data-msg-idx="42"]') return primary; + return null; + }, +}; +console.log(JSON.stringify({ + total: _measureMessageVirtualRow(inner, {rawIdx: 42}), +})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["total"] == 180 + + +def test_virtual_keep_tail_count_stays_bounded_after_history_expands_render_window(): + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +const MESSAGE_RENDER_WINDOW_DEFAULT = 50; +let _messageRenderWindowSize = 240; +eval(extractFunc('_currentMessageRenderWindowSize')); +eval(extractFunc('_messageVirtualKeepTailCount')); +console.log(JSON.stringify({ + renderWindowSize: _currentMessageRenderWindowSize(), + keepTailCount: _messageVirtualKeepTailCount(), +})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["renderWindowSize"] == 240 + assert metrics["keepTailCount"] == 50 + + +def test_virtual_prepended_height_delta_uses_prefix_cache_only_when_virtualized(): + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +let virtualized = true; +let _messageVirtualHeightCache = [0, 220, 180, 120]; +let _messageVirtualEstimatedRowHeight = 140; +function _getVisibleMessagesWithIdx(){ + return [{rawIdx: 0}, {rawIdx: 1}, {rawIdx: 2}, {rawIdx: 3}]; +} +function _messageVirtualKeepTailCount(){ return 2; } +function _currentMessageVirtualWindow(){ + return {virtualized}; +} +eval(extractFunc('_messageVirtualPrependedHeightDelta')); +const active = _messageVirtualPrependedHeightDelta(3); +virtualized = false; +const inactive = _messageVirtualPrependedHeightDelta(3); +console.log(JSON.stringify({active, inactive})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["active"] == 540 + assert metrics["inactive"] is None + + +def test_virtual_question_jump_scroll_target_uses_visible_index_height_prefix(): + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +let _messageVirtualHeightCache = [100, 120, 80, 140]; +let _messageVirtualHeightCacheEntries = []; +let _messageVirtualHeightCacheLen = 4; +let _messageVirtualHeightCacheSrc = null; +let _messageVirtualEstimatedRowHeight = 110; +let _messageVirtualWindowKey = 'old'; +let S = {messages: [{}, {}, {}, {}]}; +function _messageIsRenderable(){ return true; } +eval(extractFunc('_messageVirtualHeightEntryMatches')); +eval(extractFunc('_syncMessageVirtualHeightCache')); +eval(extractFunc('_messageVisibleIndexForRawIdx')); +eval(extractFunc('_messageVirtualScrollTopForVisibleIdx')); +const visWithIdx = [ + {rawIdx: 10, m: S.messages[0]}, + {rawIdx: 12, m: S.messages[1]}, + {rawIdx: 14, m: S.messages[2]}, + {rawIdx: 16, m: S.messages[3]}, +]; +_messageVirtualHeightCacheEntries = visWithIdx; +_messageVirtualHeightCacheSrc = S.messages; +const visibleIdx = _messageVisibleIndexForRawIdx(14, visWithIdx); +const scrollTop = _messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, {clientHeight: 200}); +console.log(JSON.stringify({visibleIdx, scrollTop})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["visibleIdx"] == 2 + assert metrics["scrollTop"] == 150 + + +def test_height_cache_preserves_measured_prefix_across_append_only_growth(): + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140; +let _messageVirtualHeightCache = [180, 220]; +let _messageVirtualHeightCacheEntries = []; +let _messageVirtualHeightCacheLen = 2; +let _messageVirtualHeightCacheSrc = null; +let _messageVirtualEstimatedRowHeight = 200; +let _messageVirtualWindowKey = 'stale-key'; +function _clearMessageVirtualHeightCache() { + _messageVirtualHeightCache = []; + _messageVirtualHeightCacheEntries = []; + _messageVirtualHeightCacheLen = 0; + _messageVirtualHeightCacheSrc = null; + _messageVirtualEstimatedRowHeight = MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT; + _messageVirtualWindowKey = ''; +} +eval(extractFunc('_messageVirtualHeightEntryMatches')); +eval(extractFunc('_messageVirtualHeightPrefixEntryMatches')); +eval(extractFunc('_syncMessageVirtualHeightCache')); +const first = {id: 'first'}; +const second = {id: 'second'}; +let S = {messages: [first, second]}; +_messageVirtualHeightCacheEntries = [ + {rawIdx: 0, m: first}, + {rawIdx: 1, m: second}, +]; +_messageVirtualHeightCacheSrc = S.messages; +S = {messages: [first, second, {id: 'third'}]}; +_syncMessageVirtualHeightCache([ + {rawIdx: 0, m: first}, + {rawIdx: 1, m: second}, + {rawIdx: 2, m: S.messages[2]}, +]); +console.log(JSON.stringify({ + cache: _messageVirtualHeightCache, + estimated: _messageVirtualEstimatedRowHeight, + windowKey: _messageVirtualWindowKey, +})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["cache"][:2] == [180, 220] + assert len(metrics["cache"]) == 3 + assert metrics["estimated"] == 200 + assert metrics["windowKey"] == "" + + +def test_height_cache_preserves_measured_suffix_across_prepended_history(): + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140; +let _messageVirtualHeightCache = [180, 220]; +let _messageVirtualHeightCacheEntries = []; +let _messageVirtualHeightCacheLen = 2; +let _messageVirtualHeightCacheSrc = null; +let _messageVirtualEstimatedRowHeight = 200; +let _messageVirtualWindowKey = 'stale-key'; +function _clearMessageVirtualHeightCache() { + _messageVirtualHeightCache = []; + _messageVirtualHeightCacheEntries = []; + _messageVirtualHeightCacheLen = 0; + _messageVirtualHeightCacheSrc = null; + _messageVirtualEstimatedRowHeight = MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT; + _messageVirtualWindowKey = ''; +} +eval(extractFunc('_messageVirtualHeightEntryMatches')); +eval(extractFunc('_messageVirtualHeightPrefixEntryMatches')); +eval(extractFunc('_syncMessageVirtualHeightCache')); +const first = {id: 'first'}; +const second = {id: 'second'}; +let S = {messages: [first, second]}; +_messageVirtualHeightCacheEntries = [ + {rawIdx: 0, m: first}, + {rawIdx: 1, m: second}, +]; +_messageVirtualHeightCacheSrc = S.messages; +const olderA = {id: 'older-a'}; +const olderB = {id: 'older-b'}; +S = {messages: [olderA, olderB, first, second]}; +_syncMessageVirtualHeightCache([ + {rawIdx: 0, m: olderA}, + {rawIdx: 1, m: olderB}, + {rawIdx: 2, m: first}, + {rawIdx: 3, m: second}, +]); +console.log(JSON.stringify({ + cache: _messageVirtualHeightCache, + estimated: _messageVirtualEstimatedRowHeight, + windowKey: _messageVirtualWindowKey, +})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["cache"][2:] == [180, 220] + assert len(metrics["cache"]) == 4 + assert metrics["estimated"] == 200 + assert metrics["windowKey"] == "" + + +def test_measurement_refresh_budget_is_keyed_to_window_shape_not_pad_height(): + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +eval(extractFunc('_messageVirtualMeasurementCycleKeyFor')); +console.log(JSON.stringify({ + a: _messageVirtualMeasurementCycleKeyFor({virtualized: true, start: 10, end: 20, topPad: 1000, bottomPad: 2000, tailStart: 190}), + b: _messageVirtualMeasurementCycleKeyFor({virtualized: true, start: 10, end: 20, topPad: 1001, bottomPad: 1999, tailStart: 190}), +})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["a"] == metrics["b"] + + +def test_tool_rows_do_not_carry_message_measurement_hook(): + js = UI_JS_PATH.read_text(encoding="utf-8") + build_start = js.index("function buildToolCard(tc){") + build_end = js.index("function _colorDiffLines", build_start) + build_body = js[build_start:build_end] + + assert "row.dataset.msgIdx" not in build_body + assert "querySelectorAll(`[data-msg-idx=" not in js diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index 0352f0b66..adf7df9e6 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -1,26 +1,30 @@ from pathlib import Path REPO = Path(__file__).resolve().parents[1] -UI_JS = (REPO / "static" / "ui.js").read_text() -CSS = (REPO / "static" / "style.css").read_text() +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") +CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") -def test_message_windowing_caps_initial_dom_to_recent_messages(): - assert "const MESSAGE_RENDER_WINDOW_DEFAULT=50" in UI_JS - assert "_messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT" in UI_JS - assert "const windowStart=Math.max(0, visWithIdx.length-renderWindowSize)" in UI_JS - assert "const renderVisWithIdx=visWithIdx.slice(windowStart)" in UI_JS - assert "for(let vi=0;vi0&&vi===headRenderCount)" in UI_JS -def test_load_earlier_expands_local_window_before_server_pagination_and_preserves_scroll(): - assert "function _showEarlierRenderedMessages()" in UI_JS - assert "prevScrollH=container?container.scrollHeight:0" in UI_JS - assert "prevScrollTop=container?container.scrollTop:0" in UI_JS - assert "container.scrollTop=prevScrollTop+(newScrollH-prevScrollH)" in UI_JS - assert "if(_messageHiddenBeforeCount()>0) _showEarlierRenderedMessages();" in UI_JS - assert "else if(typeof _loadOlderMessages==='function') _loadOlderMessages();" in UI_JS +def test_load_earlier_only_pages_server_history_and_preserves_scroll(): + assert "function _wireMessageWindowLoadEarlierButton()" in UI_JS + assert "if(typeof _loadOlderMessages==='function') _loadOlderMessages();" in UI_JS + assert "if(hasServerOlder){" in UI_JS + assert "if(virtualWindow.virtualized&&virtualWindow.topPad>0)" in UI_JS + assert "_messageRenderWindowSize=_currentMessageRenderWindowSize()+Math.max(addedRenderable, MESSAGE_RENDER_WINDOW_DEFAULT);" in SESSIONS_JS + assert "renderMessages({ preserveScroll: true });" in SESSIONS_JS + assert "_scheduleMessageVirtualizedRender();" in UI_JS def test_windowed_render_keeps_streaming_and_tool_activity_anchored_to_rendered_messages(): @@ -32,15 +36,26 @@ def test_windowed_render_keeps_streaming_and_tool_activity_anchored_to_rendered_ def test_window_state_participates_in_cache_and_cached_button_is_rewired(): - assert "cached.renderWindowSize===renderWindowSize" in UI_JS + assert "cached.renderWindowKey===renderWindowKey" in UI_JS assert "cached.signature===renderSignature" in UI_JS - assert "_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature})" in UI_JS + assert "_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowKey,signature:renderSignature})" in UI_JS + assert "_messageVirtualWindowKey=renderWindowKey" in UI_JS assert "function _wireMessageWindowLoadEarlierButton()" in UI_JS assert "_wireMessageWindowLoadEarlierButton();" in UI_JS assert UI_JS.count("_wireMessageWindowLoadEarlierButton();") >= 2 -def test_load_earlier_affordance_has_button_styling_hook(): +def test_virtualization_affordances_have_styling_hooks(): assert "message-window-load-earlier" in UI_JS assert ".message-window-load-earlier" in CSS + assert ".message-virtual-spacer" in CSS assert "border-radius:999px" in CSS + + +def test_measurement_rerenders_are_bounded_per_virtual_window_cycle(): + assert "const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=2;" in UI_JS + assert "function _messageVirtualMeasurementCycleKeyFor(windowMetrics)" in UI_JS + assert "function _scheduleMessageVirtualMeasurementRefresh(windowMetrics)" in UI_JS + assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;" in UI_JS + assert "_scheduleMessageVirtualMeasurementRefresh(virtualWindow);" in UI_JS + assert "_markMessageVirtualMeasurementsSettled(virtualWindow);" in UI_JS diff --git a/tests/test_live_stream_ux.py b/tests/test_live_stream_ux.py index 2f03f34bf..abbf4e77c 100644 --- a/tests/test_live_stream_ux.py +++ b/tests/test_live_stream_ux.py @@ -27,7 +27,8 @@ def test_ui_rejects_recovery_control_as_visible_assistant_content(): assert "function _isRecoveryControlMessageText" in UI_JS assert "function _assistantMessageHasVisibleContent" in UI_JS assert "if(_isRecoveryControlMessage(m)) return false;" in UI_JS - assert "if(_isRecoveryControlMessage(m)){ri++;continue;}" in UI_JS + assert "function _messageIsRenderable(m)" in UI_JS + assert "if(_isRecoveryControlMessage(m)) return false;" in UI_JS[UI_JS.index("function _messageIsRenderable(m)"):] assert "_assistantMessageHasVisibleContent(m)" in UI_JS diff --git a/tests/test_older_history_viewport_preservation.py b/tests/test_older_history_viewport_preservation.py index d34d50860..a9cf50143 100644 --- a/tests/test_older_history_viewport_preservation.py +++ b/tests/test_older_history_viewport_preservation.py @@ -29,6 +29,7 @@ def test_loading_older_messages_expands_render_window_before_rendering(): "scroll-to-top paging must expand the DOM render window before renderMessages(); " "otherwise fetched older messages stay hidden and only the hidden counter changes" ) + assert "if(typeof _messageIsRenderable==='function') return _messageIsRenderable(m);" in body assert "Math.max(addedRenderable, MESSAGE_RENDER_WINDOW_DEFAULT)" in body @@ -36,14 +37,22 @@ def test_loading_older_messages_preserves_viewport_without_bottom_snap(): body = _function_body(SESSIONS_JS, "async function _loadOlderMessages") assert "renderMessages({ preserveScroll: true });" in body - assert "const oldTop = container.scrollTop" in body - assert "const addedHeight = Math.max(0, newScrollH - prevScrollH)" in body + assert "const viewportAnchor = (container && typeof _captureMessageViewportAnchor === 'function')" in body + assert "_captureMessageViewportAnchor()" in body + assert "_restoreMessageViewportAnchor(viewportAnchor, olderMsgs.length)" in body + assert "const restoredViaAnchor = (viewportAnchor && typeof _restoreMessageViewportAnchor === 'function')" in body + assert "if (!restoredViaAnchor) {" in body + assert "const virtualAddedHeight = (typeof _messageVirtualPrependedHeightDelta === 'function')" in body + assert "_messageVirtualPrependedHeightDelta(addedRenderable)" in body + assert "const addedHeight = Number.isFinite(virtualAddedHeight)" in body assert "container.scrollTop = oldTop + addedHeight" in body assert "container.scrollTop = newScrollH - prevScrollH" not in body - restore_idx = body.index("container.scrollTop = oldTop + addedHeight") + restore_idx = body.index("_restoreMessageViewportAnchor(viewportAnchor, olderMsgs.length)") + virtual_idx = body.index("_messageVirtualPrependedHeightDelta(addedRenderable)") + scroll_delta_idx = body.index("Math.max(0, newScrollH - prevScrollH)") unpin_idx = body.rindex("_scrollPinned = false") - assert restore_idx < unpin_idx + assert restore_idx < virtual_idx < scroll_delta_idx < unpin_idx def test_loading_older_messages_marks_scroll_programmatic_while_anchoring(): @@ -53,3 +62,14 @@ def test_loading_older_messages_marks_scroll_programmatic_while_anchoring(): restore_idx = body.index("container.scrollTop = oldTop + addedHeight") clear_idx = body.index("requestAnimationFrame(()=>{ _programmaticScroll = false; })") assert set_idx < restore_idx < clear_idx + + +def test_loading_older_messages_captures_anchor_before_replacing_messages(): + body = _function_body(SESSIONS_JS, "async function _loadOlderMessages") + + anchor_idx = body.index("const viewportAnchor = (container && typeof _captureMessageViewportAnchor === 'function')") + replace_idx = body.index("S.messages = nextMessages") + render_idx = body.index("renderMessages({ preserveScroll: true });") + restore_idx = body.index("_restoreMessageViewportAnchor(viewportAnchor, olderMsgs.length)") + + assert anchor_idx < replace_idx < render_idx < restore_idx diff --git a/tests/test_session_jump_buttons.py b/tests/test_session_jump_buttons.py index fee564760..8229f170f 100644 --- a/tests/test_session_jump_buttons.py +++ b/tests/test_session_jump_buttons.py @@ -50,6 +50,11 @@ def test_jump_to_session_start_button_loads_full_history_and_scrolls_top(): assert "_ensureAllMessagesLoaded" in jump assert "_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount())" in jump + expand_idx = jump.index("_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount())") + top_idx = jump.index("container.scrollTop=0") + render_idx = jump.index("renderMessages({ preserveScroll:true })") + assert expand_idx < top_idx < render_idx + assert "_messageVirtualWindowKey=''" in jump assert "renderMessages({ preserveScroll:true })" in jump assert "container.scrollTop=0" in jump assert "btn.style.display=(hasSession&&canRevealStart&&awayFromStart)?'flex':'none'" in update diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 1cb2b80a5..b7ec83667 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -409,6 +409,8 @@ class TestScheduleRestart: import os as _os original_execv = _os.execv + monkeypatch.setattr(sys, 'platform', 'linux') + monkeypatch.setattr(upd, '_wait_until_restart_safe', lambda *a, **k: {'restart_blocked': False}) monkeypatch.setattr(_os, 'execv', fake_execv) start = time.monotonic() @@ -439,6 +441,8 @@ class TestScheduleRestart: events.append(("execv", exe)) # Override the autouse no-op stub with a recording spy. + monkeypatch.setattr(sys, 'platform', 'linux') + monkeypatch.setattr(upd, '_wait_until_restart_safe', lambda *a, **k: {'restart_blocked': False}) monkeypatch.setattr(upd, "_purge_agent_pycache", spy_purge) monkeypatch.setattr(os, "execv", fake_execv) @@ -1523,6 +1527,8 @@ class TestSequentialUpdateRestartCoordination: execv_time.append(_t.monotonic()) execv_called.set() + monkeypatch.setattr(sys, 'platform', 'linux') + monkeypatch.setattr(upd, '_wait_until_restart_safe', lambda *a, **k: {'restart_blocked': False}) monkeypatch.setattr(os, 'execv', fake_execv) # Hold _apply_lock from another thread (simulating an in-flight @@ -1570,6 +1576,8 @@ class TestSequentialUpdateRestartCoordination: execv_called = [] def fake_execv(exe, args): execv_called.append(True) + monkeypatch.setattr(sys, 'platform', 'linux') + monkeypatch.setattr(upd, '_wait_until_restart_safe', lambda *a, **k: {'restart_blocked': False}) monkeypatch.setattr(os, 'execv', fake_execv) upd._schedule_restart(delay=0.05)