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))}
`; @@ -7017,11 +7045,22 @@ function postProcessRenderedMessages(container) { } function highlightCode(container) { - // Apply Prism.js syntax highlighting to all code blocks in container (or whole messages area) - if(typeof Prism === 'undefined' || !Prism.highlightAllUnder) return; + // Apply Prism.js syntax highlighting only to *new* code blocks. + // Previously every renderMessages() called Prism.highlightAllUnder() which + // re-scanned and re-highlighted every
 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