diff --git a/static/ui.js b/static/ui.js
index 81152feb..4e7b95e6 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -268,6 +268,34 @@ let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
function _resetMessageRenderWindow(sid){
_messageRenderWindowSid=sid||null;
_messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
+ _clearRenderCache();
+}
+
+// ── renderMd / _renderUserFencedBlocks cache ──────────────────────────────
+// Long sessions re-render the same messages on every renderMessages() call.
+// Cache the rendered HTML so unchanged messages skip the expensive regex
+// pipeline entirely. ~95% of messages are identical between renders.
+const _renderCache = new Map();
+const _renderCacheMax = 300;
+function _clearRenderCache(){ _renderCache.clear(); }
+function _renderCacheKey(text, isUser){
+ const p = isUser ? 'u' : 'a';
+ // Short content: use the full string as key (cheap Map lookup).
+ // Long content: length + prefix + suffix is good enough — collisions on
+ // 20-char prefix+suffix are vanishingly rare for chat messages.
+ if(text.length <= 500) return p + ':' + text;
+ return p + ':' + text.length + ':' + text.slice(0,20) + ':' + text.slice(-20);
+}
+function _getCachedRender(text, isUser){
+ const key = _renderCacheKey(text, isUser);
+ const hit = _renderCache.get(key);
+ if(hit !== undefined) return hit;
+ const rendered = isUser
+ ? _renderUserFencedBlocks(text)
+ : renderMd(_stripXmlToolCallsDisplay(String(text)));
+ if(_renderCache.size > _renderCacheMax) _renderCache.clear();
+ _renderCache.set(key, rendered);
+ return rendered;
}
function _currentMessageRenderWindowSize(){
return Math.max(
@@ -6236,7 +6264,7 @@ function renderMessages(options){
return _renderAttachmentHtml(fname,fileUrl);
}).join('')}`;
}
- let bodyHtml = isUser ? _renderUserFencedBlocks(displayContent) : renderMd(_stripXmlToolCallsDisplay(String(displayContent)));
+ let bodyHtml = _getCachedRender(displayContent, isUser);
if(!isUser&&m.provider_details){
const summary=m.provider_details_label||'Provider details';
bodyHtml += `${esc(String(summary))}
${esc(String(m.provider_details))}
in the container — expensive in
+ // long sessions with dozens of code blocks. Now we only touch blocks that
+ // don't already have the data-highlighted marker.
+ if(typeof Prism === 'undefined') return;
const el = container || $('msgInner');
if(!el) return;
- Prism.highlightAllUnder(el);
+ // Prefer per-element highlight (avoids the full DOM walk of highlightAllUnder)
+ const blocks = el.querySelectorAll('pre code:not([data-highlighted])');
+ if(blocks.length === 0) return;
+ for(let i = 0; i < blocks.length; i++){
+ const block = blocks[i];
+ if(typeof Prism.highlightElement === 'function') Prism.highlightElement(block);
+ block.dataset.highlighted = '1';
+ }
}
// Lazy load js-yaml for YAML tree view support
diff --git a/tests/test_workspace_display_prefix.py b/tests/test_workspace_display_prefix.py
index 0933c58f..d95014f1 100644
--- a/tests/test_workspace_display_prefix.py
+++ b/tests/test_workspace_display_prefix.py
@@ -42,5 +42,9 @@ def test_user_render_uses_stripped_display_content_without_preempting_context_ca
assert context_idx != -1, "context compaction branch not found"
assert user_idx != -1, "user render branch not found"
assert display_idx < context_idx < user_idx
- assert "_renderUserFencedBlocks(displayContent)" in render_prefix
+ # The render call may be a direct _renderUserFencedBlocks call or go
+ # through the cached wrapper _getCachedRender. Both paths accept the
+ # already-stripped displayContent, so the invariant holds either way.
+ assert ("_renderUserFencedBlocks(displayContent)" in render_prefix or
+ "_getCachedRender(displayContent, isUser)" in render_prefix)
assert "row.dataset.rawText=String(displayContent).trim();" in render_prefix