diff --git a/docs/pr-media/734/message-window-top.png b/docs/pr-media/734/message-window-top.png new file mode 100644 index 000000000..4b20d8410 Binary files /dev/null and b/docs/pr-media/734/message-window-top.png differ diff --git a/static/style.css b/static/style.css index e428982e9..d41d4a798 100644 --- a/static/style.css +++ b/static/style.css @@ -2872,6 +2872,8 @@ main.main.showing-profiles > #mainProfiles{display:flex;} /* ── Day-change separator ── */ .load-older-indicator{cursor:pointer;color:var(--accent-text,var(--blue));font-size:13px;padding:10px 0;text-align:center;user-select: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);} .msg-date-sep { display: flex; align-items: center; gap: 10px; margin: 22px 0 10px; padding: 0 var(--msg-rail); diff --git a/static/ui.js b/static/ui.js index c45ab3da8..a0af57075 100644 --- a/static/ui.js +++ b/static/ui.js @@ -90,6 +90,54 @@ function _renderUserFencedBlocks(text){ return s; } +const MESSAGE_RENDER_WINDOW_DEFAULT=50; +let _messageRenderWindowSid=null; +let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT; +function _resetMessageRenderWindow(sid){ + _messageRenderWindowSid=sid||null; + _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT; +} +function _currentMessageRenderWindowSize(){ + return Math.max( + MESSAGE_RENDER_WINDOW_DEFAULT, + Number(_messageRenderWindowSize)||MESSAGE_RENDER_WINDOW_DEFAULT + ); +} +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; + 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.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) count++; + } + return count; +} +function _messageHiddenBeforeCount(){ + return Math.max(0,_messageRenderableMessageCount()-_currentMessageRenderWindowSize()); +} +function _wireMessageWindowLoadEarlierButton(){ + const indicator=$('loadOlderIndicator'); + if(!indicator) return; + indicator.onclick=()=>{ + if(_messageHiddenBeforeCount()>0) _showEarlierRenderedMessages(); + else 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; +} + /* ── Image lightbox — click any .msg-media-img to enlarge ─────────────────── */ function _openImgLightbox(src, alt) { const lb = document.createElement('div'); @@ -3665,6 +3713,8 @@ function renderMessages(){ const inner=$('msgInner'); const sid=S.session?S.session.session_id:null; const msgCount=S.messages.length; + if(sid!==_messageRenderWindowSid) _resetMessageRenderWindow(sid); + const renderWindowSize=_currentMessageRenderWindowSize(); const hasTransientTranscriptUi=!!( (window._compressionUi&&(!window._compressionUi.sessionId||window._compressionUi.sessionId===sid)) || (window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid)) @@ -3680,9 +3730,10 @@ function renderMessages(){ // before those cards can be inserted. if(sid&&sid!==_sessionHtmlCacheSid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){ const cached=_sessionHtmlCache.get(sid); - if(cached&&cached.msgCount===msgCount){ + if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize){ inner.innerHTML=cached.html; _sessionHtmlCacheSid=sid; + _wireMessageWindowLoadEarlierButton(); if(S.activeStreamId){scrollIfPinned();}else{scrollToBottom();} requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); @@ -3716,15 +3767,6 @@ function renderMessages(){ }); $('emptyState').style.display=(vis.length||preservedCompressionTaskMessages.length)?'none':''; inner.innerHTML=''; - // Show "load older" indicator when older messages are available - if(typeof _messagesTruncated!=='undefined' && _messagesTruncated && S.messages.length>0){ - const indicator=document.createElement('div'); - indicator.id='loadOlderIndicator'; - indicator.className='load-older-indicator'; - indicator.textContent=typeof t==='function'?t('load_older_messages'):'↑ Scroll up or click to load older messages'; - indicator.onclick=()=>{if(typeof _loadOlderMessages==='function') _loadOlderMessages();}; - inner.appendChild(indicator); - } const compressionNode=compressionState?_compressionCardsNode(compressionState):null; const referenceMessage=S.messages.find(m=>_isContextCompactionMessage(m)); const referenceText=referenceMessage?msgContent(referenceMessage)||String(referenceMessage.content||''):''; @@ -3743,6 +3785,30 @@ function renderMessages(){ if(msgContent(m)||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) visWithIdx.push({m,rawIdx}); 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 hasServerOlder=!!(typeof _messagesTruncated!=='undefined' && _messagesTruncated && S.messages.length>0); + if(hiddenBeforeCount>0 || 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)` + : (typeof t==='function'?t('load_older_messages'):'Load earlier messages'); + indicator.onclick=()=>{ + if(hiddenBeforeCount>0) _showEarlierRenderedMessages(); + else if(typeof _loadOlderMessages==='function') _loadOlderMessages(); + }; + inner.appendChild(indicator); + _wireMessageWindowLoadEarlierButton(); + } let lastUserRawIdx=-1; for(let i=visWithIdx.length-1;i>=0;i--){ if(visWithIdx[i].m&&visWithIdx[i].m.role==='user'){ @@ -3751,7 +3817,7 @@ function renderMessages(){ } } const insertionAnchor=_compressionAnchorIndex( - visWithIdx, + renderVisWithIdx, compressionState ? compressionState.anchorMessageKey : sessionCompressionAnchorKey, compressionState ? (typeof compressionState.anchorVisibleIdx==='number' ? compressionState.anchorVisibleIdx : compressionState.anchorRawIdx) @@ -3762,8 +3828,10 @@ function renderMessages(){ const assistantSegments=new Map(); const assistantThinking=new Map(); const userRows=new Map(); - for(let vi=0;vi rawIdx){ + for(let i=0;i rawIdx){ anchorIdx=i; break; } @@ -3935,7 +4003,7 @@ function renderMessages(){ inner.appendChild(node); return; } - const anchorRawIdx=visWithIdx[anchorIdx].rawIdx; + const anchorRawIdx=renderVisWithIdx[anchorIdx].rawIdx; const anchorSeg=assistantSegments.get(anchorRawIdx); if(anchorSeg){ const turn=anchorSeg.closest('.assistant-turn'); @@ -3961,16 +4029,17 @@ function renderMessages(){ ? (()=>{const row=document.createElement('div');row.innerHTML=`
${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}
`;return row.firstElementChild;})() : null; const preservedOnlyAnchor=preservedCompressionRawIdxs.length - ? (()=>{let idx=null;for(let i=0;i{let idx=null;for(let i=0;iidx<=aIdx); anchorRow=fallbackIdx!==undefined?assistantSegments.get(fallbackIdx):assistantSegments.get(assistantIdxs[assistantIdxs.length-1]); } @@ -4088,6 +4158,7 @@ function renderMessages(){ const aIdx = parseInt(key); let anchorRow=assistantSegments.get(aIdx)||null; if(!anchorRow&&assistantIdxs.length){ + if(aIdxidx<=aIdx); anchorRow=fallbackIdx!==undefined?assistantSegments.get(fallbackIdx):assistantSegments.get(assistantIdxs[assistantIdxs.length-1]); } @@ -4126,25 +4197,26 @@ function renderMessages(){ } // 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. + // the basic "how long did that turn take?" UX question. Only walk rendered + // assistant segments so hidden messages above the DOM window cannot skew the + // footer-to-message mapping. { - const asstRows=inner.querySelectorAll('.assistant-turn'); - let ai=0; // assistant-only index for DOM rows - for(let mi=0;mia-b); + for(const mi of renderedAssistantIdxs){ + const msg=S.messages[mi]||{}; + if(msg.role!=='assistant') continue; const hasTurnUsage=!!msg._turnUsage; const compactActivityForMessage=isSimplifiedToolCalling()&&( assistantThinking.has(mi)|| (S.toolCalls||[]).some(tc=>tc&&(tc.assistant_msg_idx!==undefined?tc.assistant_msg_idx:-1)===mi) ); const durationText=compactActivityForMessage?'':_formatTurnDuration(msg._turnDuration); - if(!hasTurnUsage&&!durationText){ai++;continue;} - if(ai>=asstRows.length) continue; - const row=asstRows[ai]; - const footerRows=row.querySelectorAll('.msg-foot'); + if(!hasTurnUsage&&!durationText) continue; + const seg=assistantSegments.get(mi); + const row=seg?seg.closest('.assistant-turn'):null; + const footerRows=row?row.querySelectorAll('.msg-foot'):[]; const targetFoot=footerRows.length?footerRows[footerRows.length-1]:null; - if(!targetFoot||targetFoot.querySelector('.msg-usage-inline,.msg-duration-inline')){ai++;continue;} + if(!targetFoot||targetFoot.querySelector('.msg-usage-inline,.msg-duration-inline')) continue; const fragments=[]; if(durationText){ const duration=document.createElement('span'); @@ -4167,7 +4239,6 @@ function renderMessages(){ targetFoot.classList.add('msg-foot-with-usage'); for(let i=fragments.length-1;i>=0;i--) targetFoot.insertBefore(fragments[i], targetFoot.firstChild); } - ai++; } } // Only force-scroll when not actively streaming — mid-stream re-renders @@ -4193,7 +4264,7 @@ function renderMessages(){ const _html=inner.innerHTML; // Only cache sessions with <300KB rendered HTML; evict oldest beyond 8 sessions. if(_html.length<300_000){ - _sessionHtmlCache.set(sid,{html:_html,msgCount}); + _sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize}); if(_sessionHtmlCache.size>8){_sessionHtmlCache.delete(_sessionHtmlCache.keys().next().value);} } } diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py new file mode 100644 index 000000000..f0b59ae0a --- /dev/null +++ b/tests/test_issue734_message_windowing.py @@ -0,0 +1,45 @@ +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() + + +def test_message_windowing_caps_initial_dom_to_recent_messages(): + assert "const MESSAGE_RENDER_WINDOW_DEFAULT=50" in UI_JS + assert "_messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT" in UI_JS + assert "const windowStart=Math.max(0, visWithIdx.length-renderWindowSize)" in UI_JS + assert "const renderVisWithIdx=visWithIdx.slice(windowStart)" in UI_JS + assert "for(let vi=0;vi0) _showEarlierRenderedMessages();" in UI_JS + assert "else if(typeof _loadOlderMessages==='function') _loadOlderMessages();" in UI_JS + + +def test_windowed_render_keeps_streaming_and_tool_activity_anchored_to_rendered_messages(): + assert "if(S.activeStreamId){\n scrollIfPinned();" in UI_JS + assert "const assistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);" in UI_JS + assert "if(aIdxa-b);" in UI_JS + assert "const seg=assistantSegments.get(mi);" in UI_JS + + +def test_window_state_participates_in_cache_and_cached_button_is_rewired(): + assert "cached.renderWindowSize===renderWindowSize" in UI_JS + assert "_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize})" 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(): + assert "message-window-load-earlier" in UI_JS + assert ".message-window-load-earlier" in CSS + assert "border-radius:999px" in CSS