diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e393a5..b8521ad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. - **Settings:** OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech). - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. +- **Chat:** an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech). - Usage/Claude: Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes. - Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech). - Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 69690752..6d4665e2 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -835,7 +835,6 @@ const useMorphdomMarkdown = ({ containerRef, text, streaming, - cacheKey, imageMode = 'inline', syntaxVars, ctx, @@ -843,7 +842,6 @@ const useMorphdomMarkdown = ({ containerRef: React.RefObject; text: string; streaming: boolean; - cacheKey: string; imageMode?: MarkdownImageMode; syntaxVars: Record; ctx: DecorateContext; @@ -908,7 +906,7 @@ const useMorphdomMarkdown = ({ const target = container.querySelector('[data-markdown-content]') ?? container; let active = true; - void renderMarkdownBlocks(text, streaming, cacheKey, imageMode).then((blocks) => { + void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => { if (!active) return; const existing = Array.from(target.children) as HTMLElement[]; @@ -959,7 +957,7 @@ const useMorphdomMarkdown = ({ return () => { active = false; }; - }, [containerRef, text, streaming, cacheKey, imageMode, ctx, refreshMermaidViewers]); + }, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]); React.useEffect(() => { const container = containerRef.current; @@ -1040,13 +1038,13 @@ const MarkdownRendererImpl: React.FC = ({ const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS); - const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; + // Identity for the fade-in wrapper: a new part/message restarts the animation. + const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; useMorphdomMarkdown({ containerRef, text: content, streaming: live, - cacheKey, imageMode: variant === 'assistant' ? 'label' : 'inline', syntaxVars, ctx, @@ -1060,7 +1058,7 @@ const MarkdownRendererImpl: React.FC = ({ if (isAnimated) { return ( - + {markdownContent} ); @@ -1137,7 +1135,6 @@ const SimpleMarkdownRendererImpl: React.FC<{ containerRef, text: renderedContent, streaming: false, - cacheKey: `simple:${variant}`, syntaxVars, ctx, }); diff --git a/packages/ui/src/components/chat/markdown/highlightResultCache.ts b/packages/ui/src/components/chat/markdown/highlightResultCache.ts new file mode 100644 index 00000000..a73fd0bc --- /dev/null +++ b/packages/ui/src/components/chat/markdown/highlightResultCache.ts @@ -0,0 +1,125 @@ +// Bounded LRU for rendered markdown / Shiki highlight results. +// +// Used by `markdownCore` (per-block HTML) and by the main-thread markdown +// worker client (highlight results) so unchanged content is never re-rendered +// or re-tokenized. Keys are short content fingerprints (not the full source) so +// cache maps do not duplicate large strings. Entry byte sizes are recorded once +// at insert time — get/evict never re-walk the payload. + +export type HighlightResultCacheOptions = { + maxEntries: number; + maxBytes: number; +}; + +type CacheEntry = { + value: T; + bytes: number; +}; + +/** UTF-16 storage estimate for a JS string (chars × 2). Avoids TextEncoder allocs. */ +export const utf16Bytes = (value: string): number => value.length * 2; + +/** Final avalanche so near-identical sources do not land in adjacent buckets. */ +const mix32 = (hash: number): number => { + let h = hash; + h ^= h >>> 16; + h = Math.imul(h, 0x85ebca6b); + h ^= h >>> 13; + return h >>> 0; +}; + +/** + * Short stable fingerprint for cache keys: length + two independent 32-bit + * multiplicative hashes (~64 bits of key space). + * + * These caches are content-addressed and global, so a collision does not merely + * mis-color a block — the cache returns a *different* block's rendered HTML and + * the user is shown source they never wrote. One 32-bit hash is not enough for + * that failure mode: a few thousand same-length entries reach a birthday + * collision probability worth caring about, and the result would be + * undiagnosable in the field. Two multiplies per character are free next to + * Shiki tokenization. + */ +export const contentFingerprint = (value: string): string => { + let h1 = 0x811c9dc5; + let h2 = 0xc2b2ae35; + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + h1 = Math.imul(h1 ^ code, 0x01000193); + h2 = Math.imul(h2 ^ code, 0x27220a95); + } + return `${value.length.toString(36)}_${mix32(h1).toString(36)}_${mix32(h2).toString(36)}`; +}; + +/** Approximate byte cost of token-run lines without JSON.stringify. */ +export const estimateTokenRunsBytes = ( + lines: ReadonlyArray>, +): number => { + let total = 0; + for (const line of lines) { + total += 4; + for (const run of line) { + total += 8 + utf16Bytes(run[1]); + } + } + return total; +}; + +export class HighlightResultCache { + private readonly maxEntries: number; + private readonly maxBytes: number; + private readonly map = new Map>(); + private totalBytes = 0; + + constructor(options: HighlightResultCacheOptions) { + this.maxEntries = Math.max(1, options.maxEntries); + this.maxBytes = Math.max(1, options.maxBytes); + } + + get size(): number { + return this.map.size; + } + + get bytes(): number { + return this.totalBytes; + } + + get(key: string): T | undefined { + const entry = this.map.get(key); + if (entry === undefined) return undefined; + // Refresh LRU order without recomputing size. + this.map.delete(key); + this.map.set(key, entry); + return entry.value; + } + + set(key: string, value: T, bytes: number): void { + const existing = this.map.get(key); + if (existing !== undefined) { + this.totalBytes -= existing.bytes; + this.map.delete(key); + } + + const entryBytes = Math.max(0, bytes); + while ( + this.map.size > 0 + && (this.map.size >= this.maxEntries || this.totalBytes + entryBytes > this.maxBytes) + ) { + const oldest = this.map.keys().next().value; + if (oldest === undefined) break; + const oldestEntry = this.map.get(oldest); + if (oldestEntry !== undefined) this.totalBytes -= oldestEntry.bytes; + this.map.delete(oldest); + // Always allow a single oversized entry so huge files still cache once. + if (this.map.size === 0) break; + } + + this.map.set(key, { value, bytes: entryBytes }); + this.totalBytes += entryBytes; + } + + clear(): void { + this.map.clear(); + this.totalBytes = 0; + } +} diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index 85d061bd..ab5b6eb6 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -1,4 +1,10 @@ import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url'; +import { + contentFingerprint, + estimateTokenRunsBytes, + HighlightResultCache, + utf16Bytes, +} from './highlightResultCache'; import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; // Main-thread client for the markdown Shiki worker. Moves syntax tokenization @@ -6,9 +12,39 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } // ready-to-splice Shiki HTML. On any failure (no worker support, worker crash, // tokenization error) the promise resolves to `null` and the caller keeps the // escaped plain-text code — highlighting never falls back onto the main thread. +// +// Results are memoized by content fingerprint (+ lang / theme). Unchanged +// content must not re-enter the worker — that was the sustained ~40 msg/s +// re-highlight load in openchamber/openchamber#2769. In-flight requests with +// the same key coalesce so remount storms share one round-trip. Cache keys are +// fingerprints (not full source) so large files are not duplicated in the Map. +// +// This module is the only sender to the worker, so memoizing here is sufficient +// and the worker itself stays stateless apart from the Shiki instance. A second +// cache inside the worker would only duplicate these payloads in another heap. +// +// `highlight` / `highlightLines` results are theme-independent: the worker +// tokenizes with the CSS-variable `MARKDOWN_SHIKI_THEME`, so a theme switch +// repaints via CSS and must not invalidate these entries. Only +// `highlightTokens` resolves concrete colors, so only its key carries a theme. type PendingResolver = (response: MarkdownWorkerResponse | null) => void; +type CachedHighlight = + | { type: 'highlight'; html: string } + | { type: 'highlightLines'; lines: string[] } + | { type: 'highlightTokens'; lines: MarkdownTokenRun[][] }; + +const CLIENT_CACHE_MAX_ENTRIES = 2000; +const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024; + +const resultCache = new HighlightResultCache({ + maxEntries: CLIENT_CACHE_MAX_ENTRIES, + maxBytes: CLIENT_CACHE_MAX_BYTES, +}); + +const inflight = new Map>(); + let worker: Worker | undefined; let nextId = 0; const pending = new Map(); @@ -16,10 +52,23 @@ const pending = new Map(); // repeat tokenization sends only the name (not the whole theme object) again. const sentThemes = new Set(); +const entryBytes = (key: string, value: CachedHighlight): number => { + const keyBytes = utf16Bytes(key); + if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html); + if (value.type === 'highlightLines') { + let total = keyBytes; + for (const line of value.lines) total += utf16Bytes(line); + return total; + } + return keyBytes + estimateTokenRunsBytes(value.lines); +}; + const failAll = (): void => { pending.forEach((resolve) => resolve(null)); pending.clear(); sentThemes.clear(); + // Drop in-flight waiters; cached results remain valid (pure fn of inputs). + inflight.clear(); worker?.terminate(); worker = undefined; }; @@ -55,13 +104,47 @@ const request = (payload: (id: number) => MarkdownWorkerRequest): Promise Promise, +): Promise => { + const existing = inflight.get(key); + if (existing) return existing; + const pendingRequest = run().finally(() => { + inflight.delete(key); + }); + inflight.set(key, pendingRequest); + return pendingRequest; +}; + +const cacheKeyFor = (kind: string, lang: string, code: string, themeName?: string): string => { + const fp = contentFingerprint(code); + return themeName === undefined ? `${kind}:${lang}:${fp}` : `${kind}:${themeName}:${lang}:${fp}`; +}; + +/** Test-only: clear client-side highlight memoization. */ +export const resetMarkdownWorkerClientCacheForTests = (): void => { + resultCache.clear(); + inflight.clear(); +}; + /** * Highlight a complete code block in the worker. Resolves to Shiki `
` HTML,
  * or `null` if highlighting is unavailable or failed (caller keeps plain code).
  */
 export const highlightCodeInWorker = async (code: string, lang: string): Promise => {
-  const response = await request((id) => ({ type: 'highlight', id, code, lang }));
-  return response?.type === 'highlight' ? response.html : null;
+  const key = cacheKeyFor('highlight', lang, code);
+  const cached = resultCache.get(key);
+  if (cached?.type === 'highlight') return cached.html;
+
+  const result = await coalesce(key, async () => {
+    const response = await request((id) => ({ type: 'highlight', id, code, lang }));
+    if (response?.type !== 'highlight') return null;
+    const entry: CachedHighlight = { type: 'highlight', html: response.html };
+    resultCache.set(key, entry, entryBytes(key, entry));
+    return entry;
+  });
+  return result?.type === 'highlight' ? result.html : null;
 };
 
 /**
@@ -70,8 +153,18 @@ export const highlightCodeInWorker = async (code: string, lang: string): Promise
  * round-trip instead of one per line. Resolves to `null` on failure.
  */
 export const highlightLinesInWorker = async (code: string, lang: string): Promise => {
-  const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
-  return response?.type === 'highlightLines' ? response.lines : null;
+  const key = cacheKeyFor('highlightLines', lang, code);
+  const cached = resultCache.get(key);
+  if (cached?.type === 'highlightLines') return cached.lines;
+
+  const result = await coalesce(key, async () => {
+    const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
+    if (response?.type !== 'highlightLines') return null;
+    const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines };
+    resultCache.set(key, entry, entryBytes(key, entry));
+    return entry;
+  });
+  return result?.type === 'highlightLines' ? result.lines : null;
 };
 
 /**
@@ -86,18 +179,25 @@ export const highlightTokensInWorker = async (
   themeName: string,
   theme: unknown,
 ): Promise => {
-  const needsTheme = !sentThemes.has(themeName);
-  const response = await request((id) => ({
-    type: 'highlightTokens',
-    id,
-    code,
-    lang,
-    themeName,
-    ...(needsTheme ? { theme } : {}),
-  }));
-  if (response?.type === 'highlightTokens') {
+  const key = cacheKeyFor('highlightTokens', lang, code, themeName);
+  const cached = resultCache.get(key);
+  if (cached?.type === 'highlightTokens') return cached.lines;
+
+  const result = await coalesce(key, async () => {
+    const needsTheme = !sentThemes.has(themeName);
+    const response = await request((id) => ({
+      type: 'highlightTokens',
+      id,
+      code,
+      lang,
+      themeName,
+      ...(needsTheme ? { theme } : {}),
+    }));
+    if (response?.type !== 'highlightTokens') return null;
     sentThemes.add(themeName);
-    return response.lines;
-  }
-  return null;
+    const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
+    resultCache.set(key, entry, entryBytes(key, entry));
+    return entry;
+  });
+  return result?.type === 'highlightTokens' ? result.lines : null;
 };
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts
index c82c2218..2d3cb7a9 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.ts
@@ -4,6 +4,7 @@ import katex from 'katex';
 import DOMPurify from 'dompurify';
 import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
 import { isVSCodeRuntime } from '@/lib/desktop';
+import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
 import { highlightCodeInWorker } from './markdown-worker';
 import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
 
@@ -415,32 +416,37 @@ const highlightCodeBlocks = async (html: string): Promise => {
 
   const lineLimit = isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT;
 
-  let result = html;
-  for (const match of matches) {
-    const [full, rawLang, escapedCode] = match;
-    const requested = (rawLang || 'text').toLowerCase();
-    // Leave mermaid fences untouched so the decorate pass can render them as
-    // diagrams (highlighting would strip the `language-mermaid` class).
-    if (requested === 'mermaid') continue;
+  // Highlight all eligible fences concurrently — sequential await was O(n)
+  // worker round-trips for messages with multiple code blocks.
+  const replacements = await Promise.all(
+    matches.map(async (match) => {
+      const [full, rawLang, escapedCode] = match;
+      const requested = (rawLang || 'text').toLowerCase();
+      // Leave mermaid fences untouched so the decorate pass can render them as
+      // diagrams (highlighting would strip the `language-mermaid` class).
+      if (requested === 'mermaid') return null;
 
-    const code = unescapeHtml(escapedCode ?? '');
+      const code = unescapeHtml(escapedCode ?? '');
 
-    // Oversized block: skip highlight, keep plain code but stamp the language.
-    if (exceedsLineLimit(code, lineLimit)) {
-      result = result.replace(full, () => full.replace(' (no main-thread highlight).
-    const highlighted = await highlightCodeInWorker(code, requested);
-    if (highlighted) {
+      // Tokenize off the main thread. On failure the worker resolves to null and
+      // we keep the original escaped 
 (no main-thread highlight).
+      const highlighted = await highlightCodeInWorker(code, requested);
+      if (!highlighted) return null;
       // Stamp the language so the decorate pass can show a header label.
-      const stamped = highlighted.replace(/^
 stamped);
-    }
-  }
+      return { full, next: highlighted.replace(/^
 replacement.next);
+  }
   return result;
 };
 
@@ -483,29 +489,60 @@ const sanitize = (html: string): string => {
 
 
 // ---------------------------------------------------------------------------
-// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
+// Per-block HTML cache (content-addressed LRU)
 // ---------------------------------------------------------------------------
+//
+// Keyed by content hash + mode + highlight flag + image mode — NOT by renderer
+// instance id. `SimpleMarkdownRenderer` historically used a shared
+// `simple:${variant}` key, so every same-variant instance fought over one cache
+// slot and re-highlighted unchanged content on every pass
+// (openchamber/openchamber#2769). Content addressing makes identical blocks
+// share one entry and stops that thrash. Bounds are high enough for long
+// sessions; byte cap keeps memory bounded.
+//
+// `full` (settled) and `live` (trailing, still streaming) blocks get separate
+// caches. A live block's content changes on every stream step, so under one
+// shared content-addressed cache each step would insert a new entry and a long
+// streaming message would evict the settled blocks this fix exists to keep
+// warm. The live cache is small on purpose: it only has to absorb repeat
+// renders of the *same* step.
 
-const CACHE_MAX = 240;
-const htmlCache = new Map();
+const FULL_CACHE_MAX_ENTRIES = 2000;
+const FULL_CACHE_MAX_BYTES = 24 * 1024 * 1024;
+const LIVE_CACHE_MAX_ENTRIES = 32;
+const LIVE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
 
-// FNV-1a 32-bit hash of the block content.
-const hash = (value: string): string => {
-  let h = 0x811c9dc5;
-  for (let i = 0; i < value.length; i += 1) {
-    h ^= value.charCodeAt(i);
-    h = Math.imul(h, 0x01000193);
-  }
-  return (h >>> 0).toString(36);
+const fullBlockCache = new HighlightResultCache({
+  maxEntries: FULL_CACHE_MAX_ENTRIES,
+  maxBytes: FULL_CACHE_MAX_BYTES,
+});
+const liveBlockCache = new HighlightResultCache({
+  maxEntries: LIVE_CACHE_MAX_ENTRIES,
+  maxBytes: LIVE_CACHE_MAX_BYTES,
+});
+
+const cacheForMode = (mode: MarkdownBlock['mode']): HighlightResultCache =>
+  (mode === 'live' ? liveBlockCache : fullBlockCache);
+
+/** Content-addressed cache key for a markdown block. */
+const markdownBlockCacheKey = (
+  contentHash: string,
+  mode: MarkdownBlock['mode'],
+  highlight: boolean,
+  imageMode: MarkdownImageMode,
+): string => `${contentHash}:${mode}:${highlight ? 1 : 0}:${imageMode}`;
+
+/** Test-only: clear the render HTML caches between cases. */
+export const resetMarkdownHtmlCacheForTests = (): void => {
+  fullBlockCache.clear();
+  liveBlockCache.clear();
 };
 
-const touch = (key: string, entry: { hash: string; html: string }): void => {
-  htmlCache.delete(key);
-  htmlCache.set(key, entry);
-  if (htmlCache.size <= CACHE_MAX) return;
-  const oldest = htmlCache.keys().next().value;
-  if (oldest) htmlCache.delete(oldest);
-};
+/** Test-only: entry counts per block cache, for churn/eviction assertions. */
+export const __markdownBlockCacheSizesForTests = (): { full: number; live: number } => ({
+  full: fullBlockCache.size,
+  live: liveBlockCache.size,
+});
 
 const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise => {
   const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
@@ -545,28 +582,29 @@ export type RenderedBlock = {
  * splits into blocks, caches per-block, heals incomplete syntax. Returning
  * blocks (instead of one joined string) lets the renderer re-morph only the
  * block that changed, keeping per-step streaming cost ~O(last block).
+ *
+ * Lookup is content-addressed: distinct renderers holding identical blocks
+ * share one entry and cannot evict each other by identity collision.
  */
 export const renderMarkdownBlocks = async (
   text: string,
   streaming: boolean,
-  cacheKey: string,
   imageMode: MarkdownImageMode = 'inline',
 ): Promise => {
   if (!text) return [];
 
   const blocks = streamBlocks(text, streaming);
   return Promise.all(
-    blocks.map(async (block, index) => {
-      const contentHash = hash(block.raw);
-      const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}:${imageMode}`;
-      const key = `${cacheKey}:${index}:${block.mode}:${imageMode}`;
-      const cached = htmlCache.get(key);
-      if (cached && cached.hash === contentHash) {
-        touch(key, cached);
-        return { id, html: cached.html };
+    blocks.map(async (block) => {
+      const contentHash = contentFingerprint(block.raw);
+      const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
+      const cache = cacheForMode(block.mode);
+      const cached = cache.get(id);
+      if (cached !== undefined) {
+        return { id, html: cached };
       }
       const html = await parseBlock(block, imageMode);
-      touch(key, { hash: contentHash, html });
+      cache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
       return { id, html };
     }),
   );
diff --git a/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts
new file mode 100644
index 00000000..db51aad5
--- /dev/null
+++ b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts
@@ -0,0 +1,242 @@
+/**
+ * Regression tests for https://github.com/openchamber/openchamber/issues/2769
+ *
+ * Sustained Shiki worker CPU came from re-tokenizing unchanged content:
+ *  1. `htmlCache` keyed by renderer identity (`simple:${variant}`) so
+ *     same-variant instances evicted each other every pass.
+ *  2. LRU capped at 240 entries, so long sessions missed 100% on every pass.
+ *  3. Worker/client had no result memoization.
+ *
+ * These tests assert the fixed contracts: content-addressed caching, room for
+ * long sessions, bounded LRU behavior, and fingerprint-key helpers.
+ */
+import { beforeEach, describe, expect, mock, test } from 'bun:test';
+
+import {
+  contentFingerprint,
+  estimateTokenRunsBytes,
+  HighlightResultCache,
+  utf16Bytes,
+} from './highlightResultCache';
+
+let highlightCalls = 0;
+let highlightInflight = 0;
+let highlightMaxInflight = 0;
+
+const highlightCodeInWorkerMock = mock(async (code: string, lang: string) => {
+  highlightCalls += 1;
+  highlightInflight += 1;
+  highlightMaxInflight = Math.max(highlightMaxInflight, highlightInflight);
+  await Promise.resolve();
+  highlightInflight -= 1;
+  return `
${code}
`; +}); + +mock.module('./markdown-worker', () => ({ + highlightCodeInWorker: highlightCodeInWorkerMock, + highlightLinesInWorker: mock(async () => null), + highlightTokensInWorker: mock(async () => null), + resetMarkdownWorkerClientCacheForTests: mock(() => undefined), +})); + +const { + renderMarkdownBlocks, + resetMarkdownHtmlCacheForTests, + __markdownBlockCacheSizesForTests, +} = await import('./markdownCore'); + +const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker'); + +beforeEach(() => { + resetMarkdownHtmlCacheForTests(); + resetMarkdownWorkerClientCacheForTests(); + highlightCalls = 0; + highlightInflight = 0; + highlightMaxInflight = 0; +}); + +describe('HighlightResultCache', () => { + test('returns cached values for identical keys and refreshes LRU order', () => { + const cache = new HighlightResultCache({ maxEntries: 2, maxBytes: 10_000 }); + cache.set('a', 'one', utf16Bytes('a') + utf16Bytes('one')); + cache.set('b', 'two', utf16Bytes('b') + utf16Bytes('two')); + expect(cache.get('a')).toBe('one'); + // Touch `a` so `b` is oldest; inserting `c` should evict `b`. + cache.set('c', 'three', utf16Bytes('c') + utf16Bytes('three')); + expect(cache.get('b')).toEqual(undefined); + expect(cache.get('a')).toBe('one'); + expect(cache.get('c')).toBe('three'); + }); + + test('evicts by byte budget while still caching a single oversized entry', () => { + const cache = new HighlightResultCache({ maxEntries: 10, maxBytes: 64 }); + cache.set('small', 'x', utf16Bytes('small') + utf16Bytes('x')); + cache.set('huge', 'y'.repeat(200), utf16Bytes('huge') + utf16Bytes('y'.repeat(200))); + expect(cache.get('huge')).toBe('y'.repeat(200)); + // Oversized insert cleared prior entries to make room. + expect(cache.size).toBe(1); + }); + + test('contentFingerprint is stable and length-qualified', () => { + expect(contentFingerprint('const x = 1')).toBe(contentFingerprint('const x = 1')); + expect(contentFingerprint('const x = 1')).not.toBe(contentFingerprint('const x = 2')); + expect(contentFingerprint('ab')).not.toBe(contentFingerprint('abc')); + }); + + test('contentFingerprint stays collision-free across a realistic session', () => { + // A collision here does not mis-color a block — it returns a *different* + // block's HTML, showing the user source they never wrote. Keep enough key + // space that a session-sized working set never collides. + const seen = new Map(); + for (let i = 0; i < 20_000; i += 1) { + // Same-length, near-identical sources are the realistic worst case: + // repeated tool output differing by a few characters. + const source = `const value_${String(i).padStart(6, '0')} = ${String(i).padStart(6, '0')};`; + const fingerprint = contentFingerprint(source); + expect(seen.get(fingerprint) ?? source).toBe(source); + seen.set(fingerprint, source); + } + expect(seen.size).toBe(20_000); + }); + + test('estimateTokenRunsBytes avoids JSON and stays positive', () => { + const lines: Array> = [ + [[3, '#fff', 0], [1, '', 1]], + [[8, 'var(--md-syntax-keyword)', 0]], + ]; + expect(estimateTokenRunsBytes(lines)).toBeGreaterThan(0); + }); +}); + +describe('markdownCore content-addressed htmlCache (#2769)', () => { + test('repeat renders of unchanged content never re-enter the worker', async () => { + const toolOutputA = '```ts\nconst a = 1;\n```'; + const toolOutputB = '```ts\nconst b = 2;\n```'; + + // First pass: cold miss for each distinct block. + await renderMarkdownBlocks(toolOutputA, false); + await renderMarkdownBlocks(toolOutputB, false); + const coldCalls = highlightCalls; + expect(coldCalls).toBeGreaterThan(0); + + // 100 more passes. Renderers used to pass a shared `simple:${variant}` + // identity key here and evict each other every pass; lookup is now + // content-addressed, so no additional worker calls may happen. + for (let pass = 0; pass < 100; pass += 1) { + await renderMarkdownBlocks(toolOutputA, false); + await renderMarkdownBlocks(toolOutputB, false); + } + + expect(highlightCalls).toBe(coldCalls); + }); + + test('long sessions (working set > former 240 cap) stay warm across re-render passes', async () => { + const parts = Array.from({ length: 600 }, (_, i) => ({ + content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``, + })); + + for (const part of parts) { + await renderMarkdownBlocks(part.content, false); + } + const afterCold = highlightCalls; + expect(afterCold).toBe(parts.length); + + for (let pass = 0; pass < 5; pass += 1) { + for (const part of parts) { + await renderMarkdownBlocks(part.content, false); + } + } + + // Unchanged content must not re-enter the worker. + expect(highlightCalls).toBe(afterCold); + }); + + test('content changes invalidate only the changed block', async () => { + const stable = '```ts\nconst stable = true;\n```'; + const changing = '```ts\nconst n = 1;\n```'; + + await renderMarkdownBlocks(stable, false); + await renderMarkdownBlocks(changing, false); + const afterFirst = highlightCalls; + + await renderMarkdownBlocks(stable, false); + await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false); + expect(highlightCalls).toBe(afterFirst + 1); + + await renderMarkdownBlocks(stable, false); + expect(highlightCalls).toBe(afterFirst + 1); + }); + + test('image mode is part of the cache identity, not shared across modes', async () => { + const source = '![diagram](https://example.com/a.png)'; + + const [inline] = await renderMarkdownBlocks(source, false, 'inline'); + expect(__markdownBlockCacheSizesForTests().full).toBe(1); + + // Same source, different rendering: content addressing must not let the + // first-rendered mode answer for both. + const [label] = await renderMarkdownBlocks(source, false, 'label'); + expect(inline?.id).not.toBe(label?.id); + expect(__markdownBlockCacheSizesForTests().full).toBe(2); + + // Re-rendering a mode already seen stays a cache hit. + const [inlineAgain] = await renderMarkdownBlocks(source, false, 'inline'); + expect(inlineAgain?.id).toBe(inline?.id); + expect(__markdownBlockCacheSizesForTests().full).toBe(2); + }); + + test('streaming a message does not evict settled blocks (live cache is separate)', async () => { + const settled = Array.from( + { length: 40 }, + (_, i) => `\`\`\`ts\nconst settled_${i} = ${i};\n\`\`\``, + ); + for (const block of settled) { + await renderMarkdownBlocks(block, false); + } + const settledEntries = __markdownBlockCacheSizesForTests().full; + expect(settledEntries).toBe(settled.length); + const afterSettled = highlightCalls; + + // Stream a message: every step is new content for the trailing live block, + // so a single shared content-addressed cache would insert one entry per + // step and evict the settled working set this fix exists to keep warm. + let streamed = ''; + for (let step = 0; step < 150; step += 1) { + streamed += `word_${step} `; + await renderMarkdownBlocks(streamed, true); + } + + const sizes = __markdownBlockCacheSizesForTests(); + expect(sizes.live).toBeLessThanOrEqual(32); + expect(sizes.full).toBe(settledEntries); + + for (const block of settled) { + await renderMarkdownBlocks(block, false); + } + expect(highlightCalls).toBe(afterSettled); + }); + + test('a repeated streaming step is served from the live cache', async () => { + const step = 'partial answer text'; + const [first] = await renderMarkdownBlocks(step, true); + const [second] = await renderMarkdownBlocks(step, true); + + expect(second?.id).toBe(first?.id); + expect(__markdownBlockCacheSizesForTests()).toEqual({ full: 0, live: 1 }); + }); + + test('multiple code fences in one document highlight concurrently', async () => { + const multi = [ + '```ts\nconst a = 1;\n```', + '', + '```ts\nconst b = 2;\n```', + '', + '```ts\nconst c = 3;\n```', + ].join('\n'); + + await renderMarkdownBlocks(multi, false); + expect(highlightCalls).toBe(3); + // Sequential awaits would keep max inflight at 1. + expect(highlightMaxInflight).toBeGreaterThan(1); + }); +}); diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts index 921149c1..3a918acb 100644 --- a/packages/ui/src/types/bun-test.d.ts +++ b/packages/ui/src/types/bun-test.d.ts @@ -19,6 +19,7 @@ declare module "bun:test" { toBeGreaterThan(expected: number): void; toBeGreaterThanOrEqual(expected: number): void; toBeLessThan(expected: number): void; + toBeLessThanOrEqual(expected: number): void; toHaveLength(expected: number): void; toBeInstanceOf(expected: unknown): void; not: {