mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
* 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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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){
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user