perf(chat): virtualize long message transcripts (#500) (#4214)

* perf(chat): virtualize long message transcripts (#500)

* fix(chat): preserve virtual height cache on append (#500)

* fix(chat): bound virtual tail follow-up (#500)

* fix(chat): surface older history and bound virtual rerenders (#500)

* fix(chat): stabilize virtual history fallback (#500)

* fix(chat): preserve virtual compression anchors (#500)

* Keep virtualization regressions aligned with rebased sources (#500)

* fix(chat): preserve virtual heights across prepends (#500)

* test(update): isolate restart timing checks (#814)

* fix(chat): restore render contracts around virtual cache

* fix(chat): preserve legacy assistant anchor predicates

* fix(#3714): restore worklog-disclosure capture after HTML-cache early-return

Deep-review finding: the capture of _captureWorklogDetailDisclosureState was
moved before the HTML-cache fast-path return, failing the invariant test
test_render_rebuild_preserves_worklog_detail_disclosure_click_state (it must
not traverse the previous-session DOM when the cache path returns early).
Moved it back to just before inner.innerHTML='' (after the cache return).

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* fix(#3714): restore Transparent Stream rehydration in HTML-cache fast path

Full-suite gate caught test_issue3820_chat_activity_display_mode failure: the
virtualization rewrite of the renderMessages HTML-cache fast-path block dropped
the _rehydrateTransparentStreamDom(inner) call (master has it; PR had 2 of 3
occurrences). On a cache-restore session switch, Transparent Stream controls
lost their property-assigned handlers. Restored the call in the same position
master has it (right after _sessionHtmlCacheSid=sid).

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* fix(#3714): preserve live turn on scroll-rebuild even with zero streamed text

Opus ship-review residual (now fixed, not deferred): the live-turn re-attach
after a scroll-triggered virtualized rebuild was gated on _preservedLen>0, so a
turn showing only Activity/tool/worklog blocks with no streamed text yet (mid
tool-call) would not be preserved — scrolling up into history on a >80-row
transcript mid-stream could blink those live-only blocks for a frame. Broaden
the gate to also restore when the preserved turn has MORE structural blocks than
the rebuilt (lagging-S.messages) turn. Hoisted the _structuralCount helper above
the gate; whole-turn-vs-segment restore logic unchanged.

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* chore(release): stamp v0.51.423 (Release OJ) for #500 transcript virtualization

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
nesquena-hermes
2026-06-14 19:11:32 -07:00
committed by GitHub
parent 87fa0a2ace
commit fe12eebbf3
13 changed files with 981 additions and 181 deletions
+6
View File
@@ -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
+28 -10
View File
@@ -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) {
+1
View File
@@ -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);
+484 -125
View File
@@ -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&&offset+rowHeightFor(start)<=targetTop){
offset+=rowHeightFor(start);
start++;
}
if(start>=tailStart){
return {virtualized:true,start:tailStart,end:tailStart,topPad:offset,bottomPad:0,total,tailStart};
}
let end=start;
let cursor=offset;
while(end<tailStart&&cursor<targetBottom){
cursor+=rowHeightFor(end);
end++;
}
if(end<=start) end=Math.min(total, start+1);
let bottomPad=0;
for(let i=end;i<tailStart;i++) bottomPad+=rowHeightFor(i);
return {
virtualized:true,
start,
end,
topPad:offset,
bottomPad,
total,
tailStart,
};
}
function _messageVirtualSpacer(height, where){
const spacer=document.createElement('div');
spacer.className='message-virtual-spacer';
spacer.dataset.virtualSpacer=where||'gap';
spacer.setAttribute('aria-hidden','true');
spacer.style.height=Math.max(0,Math.round(height||0))+'px';
spacer.style.flex='0 0 auto';
return spacer;
}
function _messageVirtualWindowKeyFor(windowMetrics){
if(!windowMetrics) return '';
return [
windowMetrics.virtualized?1:0,
windowMetrics.start,
windowMetrics.end,
Math.round(windowMetrics.topPad||0),
Math.round(windowMetrics.bottomPad||0),
windowMetrics.tailStart||0,
].join(':');
}
function _messageVirtualMeasurementCycleKeyFor(windowMetrics){
if(!windowMetrics) return '';
return [
windowMetrics.virtualized?1:0,
windowMetrics.start,
windowMetrics.end,
windowMetrics.tailStart||0,
].join(':');
}
function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){
const cycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics);
if(_messageVirtualMeasurementCycleKey!==cycleKey){
_messageVirtualMeasurementCycleKey=cycleKey;
_messageVirtualMeasurementRetryCount=0;
}
if(_messageVirtualMeasurementRetryCount>=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<sharedPrefix;i++){
if(!_messageVirtualHeightPrefixEntryMatches(previousEntries[i], nextEntries[i])){
prefixMatches=false;
break;
}
}
if(prefixMatches){
nextHeights=previousHeights.slice(0, sharedPrefix);
nextHeights.length=nextEntries.length;
}else if(nextEntries.length>=previousEntries.length){
const prependedCount=nextEntries.length-previousEntries.length;
let suffixMatches=true;
for(let i=0;i<previousEntries.length;i++){
if(!_messageVirtualHeightEntryMatches(previousEntries[i], nextEntries[i+prependedCount])){
suffixMatches=false;
break;
}
}
if(suffixMatches){
nextHeights=new Array(nextEntries.length);
for(let i=0;i<previousEntries.length;i++){
nextHeights[prependedCount+i]=previousHeights[i];
}
}
}
}
if(nextHeights===null){
_clearMessageVirtualHeightCache();
_messageVirtualHeightCache=new Array(nextEntries.length);
}else{
_messageVirtualHeightCache=nextHeights;
_messageVirtualWindowKey='';
}
_messageVirtualHeightCacheEntries=nextEntries;
_messageVirtualHeightCacheLen=S.messages.length;
_messageVirtualHeightCacheSrc=S.messages;
}
function _currentMessageVirtualWindow(visWithIdx, keepTailCount){
_syncMessageVirtualHeightCache(visWithIdx);
const container=$('messages');
return _messageVirtualWindow({
total:visWithIdx.length,
scrollTop:container?container.scrollTop:0,
viewportHeight:container?container.clientHeight:(_messageVirtualEstimatedRowHeight*6),
heights:_messageVirtualHeightCache,
defaultHeight:_messageVirtualEstimatedRowHeight,
keepTailCount,
});
}
function _messageVirtualPrependedHeightDelta(prependedRenderableCount){
const count=Math.max(0, Number(prependedRenderableCount)||0);
if(count<=0) return null;
const visWithIdx=_getVisibleMessagesWithIdx();
const virtualWindow=_currentMessageVirtualWindow(visWithIdx,_messageVirtualKeepTailCount());
if(!virtualWindow||!virtualWindow.virtualized) return null;
const limit=Math.min(count,_messageVirtualHeightCache.length);
let total=0;
for(let i=0;i<limit;i++){
const cached=Number(_messageVirtualHeightCache[i]);
total+=(Number.isFinite(cached)&&cached>0)?cached:_messageVirtualEstimatedRowHeight;
}
return Math.max(0,Math.round(total));
}
function _messageVisibleIndexForRawIdx(rawIdx, visWithIdx){
const list=Array.isArray(visWithIdx)?visWithIdx:_getVisibleMessagesWithIdx();
for(let i=0;i<list.length;i++){
if(list[i]&&list[i].rawIdx===rawIdx) return i;
}
return -1;
}
function _messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container){
const idx=Math.max(0,Number(visibleIdx)||0);
_syncMessageVirtualHeightCache(visWithIdx);
const limit=Math.min(idx,_messageVirtualHeightCache.length);
let offset=0;
for(let i=0;i<limit;i++){
const cached=Number(_messageVirtualHeightCache[i]);
offset+=(Number.isFinite(cached)&&cached>0)?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;vi<renderVisWithIdx.length;vi++){
const entry=renderVisWithIdx[vi];
if(!entry) continue;
const totalHeight=_measureMessageVirtualRow(inner, entry);
if(totalHeight<=0) continue;
const visibleIdx=Number(renderVisibleIdxs&&renderVisibleIdxs[vi]);
if(!Number.isFinite(visibleIdx)) continue;
if(Math.abs((Number(_messageVirtualHeightCache[visibleIdx])||0)-totalHeight)>1){
_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&&renderTailStart<visWithIdx.length
? visWithIdx.slice(renderTailStart)
: [];
const renderVisWithIdx=renderHeadVisWithIdx.concat(renderTailVisWithIdx);
const renderVisibleIdxs=[
...renderHeadVisWithIdx.map((_,idx)=>windowStart+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=`<div class="compression-turn"><div class="compression-turn-blocks">${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}</div></div>`;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(insertionAnchorFull<windowStart) insertionAnchor=renderVisWithIdx.length?0:null;
else if(insertionAnchorFull<windowStart+renderVisWithIdx.length) insertionAnchor=insertionAnchorFull-windowStart;
else insertionAnchor=renderVisWithIdx.length?renderVisWithIdx.length-1:null;
const hasVirtualRenderGap=renderVisibleIdxs.some((idx,pos)=>idx!==windowStart+pos);
if(!hasVirtualRenderGap){
if(insertionAnchorFull<windowStart) insertionAnchor=renderVisWithIdx.length?0:null;
else if(insertionAnchorFull<windowStart+renderVisWithIdx.length) insertionAnchor=insertionAnchorFull-windowStart;
else insertionAnchor=renderVisWithIdx.length?renderVisWithIdx.length-1:null;
}else if(renderVisibleIdxs.length){
let previousVisibleIdx=-1;
for(let i=0;i<renderVisibleIdxs.length;i++){
if(renderVisibleIdxs[i]<=insertionAnchorFull) previousVisibleIdx=i;
else break;
}
insertionAnchor=previousVisibleIdx>=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;i<windowStart;i++){
const role=visWithIdx[i]?.m?.role;
if(role==='user') lastQuestionRawIdx=visWithIdx[i].rawIdx;
}
for(const entry of renderVisWithIdx){
const renderedRawIdxs=new Set(renderVisWithIdx.map(e=>e.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;vi<visWithIdx.length;vi++)
for(let vi=0;vi<renderVisWithIdx.length;vi++){
if(virtualWindow.virtualized&&virtualWindow.bottomPad>0&&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){
+16 -20
View File
@@ -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<windowStart" in block
assert "insertionAnchorFull-windowStart" in block
assert "windowStart+renderVisWithIdx.length" in block
assert "let previousVisibleIdx=-1;" in block
assert "if(renderVisibleIdxs[i]<=insertionAnchorFull) previousVisibleIdx=i;" in block
assert "insertionAnchor=previousVisibleIdx>=0?previousVisibleIdx:0;" in block
def test_reference_message_uses_raw_transcript_position_before_anchor_fallback():
+5
View File
@@ -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
+4 -2
View File
@@ -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():
@@ -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
+34 -19
View File
@@ -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;vi<renderVisWithIdx.length;vi++)" in UI_JS
assert "Load earlier messages (${hiddenBeforeCount} hidden)" in UI_JS
def test_message_virtualization_switches_render_messages_to_scroll_driven_window():
assert "function _messageVirtualWindow(opts)" in UI_JS
assert "function _messageVirtualKeepTailCount()" in UI_JS
assert "const virtualWindow=_currentMessageVirtualWindow(visWithIdx,_messageVirtualKeepTailCount())" in UI_JS
assert "const renderHeadVisWithIdx=visWithIdx.slice(windowStart, windowEnd)" in UI_JS
assert "const renderTailStart=virtualWindow.virtualized?Math.max(windowEnd, virtualWindow.tailStart):windowEnd" in UI_JS
assert "const renderTailVisWithIdx=virtualWindow.virtualized&&renderTailStart<visWithIdx.length" in UI_JS
assert "const renderVisWithIdx=renderHeadVisWithIdx.concat(renderTailVisWithIdx)" in UI_JS
assert "if(virtualWindow.virtualized&&virtualWindow.bottomPad>0&&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
+2 -1
View File
@@ -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
@@ -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
+5
View File
@@ -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
+8
View File
@@ -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)