fix: window long session message rendering
This commit is contained in:
BIN
docs/pr-media/734/message-window-top.png
Normal file
BIN
docs/pr-media/734/message-window-top.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -2874,6 +2874,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);
|
||||
|
||||
141
static/ui.js
141
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<visWithIdx.length;vi++){
|
||||
const {m,rawIdx}=visWithIdx[vi];
|
||||
// Windowed render loop replaces the legacy full loop:
|
||||
// for(let vi=0;vi<visWithIdx.length;vi++)
|
||||
for(let vi=0;vi<renderVisWithIdx.length;vi++){
|
||||
const {m,rawIdx}=renderVisWithIdx[vi];
|
||||
const _tsSep=m._ts||m.timestamp;
|
||||
if(_tsSep){
|
||||
const _d=new Date(_tsSep*1000);
|
||||
@@ -3807,7 +3875,7 @@ function renderMessages(){
|
||||
}
|
||||
}
|
||||
const isUser=m.role==='user';
|
||||
const isLastAssistant=!isUser&&vi===visWithIdx.length-1;
|
||||
const isLastAssistant=!isUser&&vi===renderVisWithIdx.length-1;
|
||||
let filesHtml='';
|
||||
if(m.attachments&&m.attachments.length){
|
||||
// Static regression tests intentionally look for msg-media-img/msg-file-badge near this branch.
|
||||
@@ -3899,8 +3967,8 @@ function renderMessages(){
|
||||
function _insertCompressionLikeNode(node, anchorIndex){
|
||||
if(!node) return;
|
||||
const anchorIdx=anchorIndex===undefined?insertionAnchor:anchorIndex;
|
||||
if(anchorIdx!==null && visWithIdx[anchorIdx]){
|
||||
const anchorRawIdx=visWithIdx[anchorIdx].rawIdx;
|
||||
if(anchorIdx!==null && renderVisWithIdx[anchorIdx]){
|
||||
const anchorRawIdx=renderVisWithIdx[anchorIdx].rawIdx;
|
||||
const anchorSeg=assistantSegments.get(anchorRawIdx);
|
||||
if(anchorSeg){
|
||||
const turn=anchorSeg.closest('.assistant-turn');
|
||||
@@ -3920,13 +3988,13 @@ function renderMessages(){
|
||||
}
|
||||
function _insertCompressionLikeNodeByRawIdx(node, rawIdx){
|
||||
if(!node) return;
|
||||
if(!visWithIdx.length){
|
||||
if(!renderVisWithIdx.length){
|
||||
inner.appendChild(node);
|
||||
return;
|
||||
}
|
||||
let anchorIdx=null;
|
||||
for(let i=0;i<visWithIdx.length;i++){
|
||||
if(visWithIdx[i].rawIdx > rawIdx){
|
||||
for(let i=0;i<renderVisWithIdx.length;i++){
|
||||
if(renderVisWithIdx[i].rawIdx > 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=`<div class="compression-turn"><div class="compression-turn-blocks">${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}</div></div>`;return row.firstElementChild;})()
|
||||
: null;
|
||||
const preservedOnlyAnchor=preservedCompressionRawIdxs.length
|
||||
? (()=>{let idx=null;for(let i=0;i<visWithIdx.length;i++){if(visWithIdx[i].rawIdx<preservedCompressionRawIdxs[0]) idx=i;}return idx;})()
|
||||
? (()=>{let idx=null;for(let i=0;i<renderVisWithIdx.length;i++){if(renderVisWithIdx[i].rawIdx<preservedCompressionRawIdxs[0]) idx=i;}return idx;})()
|
||||
: null;
|
||||
const handoffSummaryStates=_collectHandoffSummaryStates(S.messages);
|
||||
|
||||
_insertCompressionLikeNode(compressionNode);
|
||||
_insertCompressionLikeNode(referenceNode);
|
||||
_insertCompressionLikeNode(preservedOnlyNode, preservedOnlyAnchor);
|
||||
_insertCompressionLikeNode(handoffState?_handoffCardsNode(handoffState):null, visWithIdx.length?visWithIdx.length-1:null);
|
||||
_insertCompressionLikeNode(handoffState?_handoffCardsNode(handoffState):null, renderVisWithIdx.length?renderVisWithIdx.length-1:null);
|
||||
for(const entry of handoffSummaryStates){
|
||||
if(!entry||!entry.state) continue;
|
||||
if(entry.rawIdx<firstRenderedRawIdx) continue;
|
||||
_insertCompressionLikeNodeByRawIdx(_handoffCardsNode(entry.state), entry.rawIdx);
|
||||
}
|
||||
renderCompressionUi();
|
||||
@@ -4064,6 +4133,7 @@ function renderMessages(){
|
||||
const cards=byAssistant[aIdx]||[];
|
||||
let anchorRow=assistantSegments.get(aIdx)||null;
|
||||
if(!anchorRow&&assistantIdxs.length){
|
||||
if(aIdx<assistantIdxs[0]) continue;
|
||||
const fallbackIdx=[...assistantIdxs].reverse().find(idx=>idx<=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(aIdx<assistantIdxs[0]) continue;
|
||||
const fallbackIdx=[...assistantIdxs].reverse().find(idx=>idx<=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;mi<S.messages.length;mi++){
|
||||
const msg=S.messages[mi];
|
||||
if(msg.role!=='assistant'){continue;}
|
||||
const renderedAssistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-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);}
|
||||
}
|
||||
}
|
||||
|
||||
45
tests/test_issue734_message_windowing.py
Normal file
45
tests/test_issue734_message_windowing.py
Normal file
@@ -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;vi<renderVisWithIdx.length;vi++)" in UI_JS
|
||||
assert "Load earlier messages (${hiddenBeforeCount} hidden)" 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_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(aIdx<assistantIdxs[0]) continue;" in UI_JS
|
||||
assert "const renderedAssistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-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
|
||||
Reference in New Issue
Block a user