From 6ab12fdb61823ca0bbabb740db18ac7ded26c82a Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Mon, 10 Aug 2026 13:50:20 +0000 Subject: [PATCH 001/215] fix(ui): stop sustained Shiki re-highlight of unchanged markdown Content-address the markdown HTML cache and memoize Shiki worker/client results so remounts and long sessions no longer re-tokenize stable code blocks (openchamber/openchamber#2769). Co-authored-by: Serhii Dziupin --- .../components/chat/MarkdownRendererImpl.tsx | 8 +- .../chat/markdown/highlightResultCache.ts | 97 ++++++++++++ .../chat/markdown/markdown-shiki.worker.ts | 47 +++++- .../chat/markdown/markdown-worker.ts | 114 ++++++++++++--- .../components/chat/markdown/markdownCore.ts | 55 ++++--- .../markdownHighlightingRepro.test.ts | 138 ++++++++++++++++++ 6 files changed, 423 insertions(+), 36 deletions(-) create mode 100644 packages/ui/src/components/chat/markdown/highlightResultCache.ts create mode 100644 packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 0a8bef4c..c5294f10 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -1117,12 +1117,18 @@ const SimpleMarkdownRendererImpl: React.FC<{ const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls); + // Content-addressed render cache (see markdownCore) ignores this string for + // lookup; keep a stable per-content key so effect deps stay aligned with text. + const cacheKey = React.useMemo( + () => `simple:${variant}:${renderedContent.length}:${renderedContent.slice(0, 64)}`, + [variant, renderedContent], + ); useMorphdomMarkdown({ containerRef, text: renderedContent, streaming: false, - cacheKey: `simple:${variant}`, + cacheKey, 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..1f5fa2cb --- /dev/null +++ b/packages/ui/src/components/chat/markdown/highlightResultCache.ts @@ -0,0 +1,97 @@ +// Bounded LRU for Shiki highlight results. +// +// Used on both the main-thread worker client and inside the markdown-shiki +// worker so unchanged code is never re-tokenized. Keys are exact +// (lang + source [+ theme]); eviction is by entry count and approximate byte +// size so long sessions cannot grow unbounded. + +export type HighlightResultCacheOptions = { + maxEntries: number; + maxBytes: number; +}; + +const utf8Bytes = (value: string): number => { + // Prefer TextEncoder when available (browser / modern Bun); fall back to + // UTF-16 length × 2 as a conservative upper bound for older runtimes. + if (typeof TextEncoder !== 'undefined') { + return new TextEncoder().encode(value).length; + } + return value.length * 2; +}; + +export class HighlightResultCache { + private readonly maxEntries: number; + private readonly maxBytes: number; + private readonly sizeOf: (key: string, value: T) => number; + private readonly map = new Map(); + private totalBytes = 0; + + constructor( + options: HighlightResultCacheOptions, + sizeOf: (key: string, value: T) => number, + ) { + this.maxEntries = Math.max(1, options.maxEntries); + this.maxBytes = Math.max(1, options.maxBytes); + this.sizeOf = sizeOf; + } + + get size(): number { + return this.map.size; + } + + get bytes(): number { + return this.totalBytes; + } + + get(key: string): T | undefined { + const value = this.map.get(key); + if (value === undefined) return undefined; + // Refresh LRU order. + this.map.delete(key); + this.map.set(key, value); + return value; + } + + set(key: string, value: T): void { + const existing = this.map.get(key); + if (existing !== undefined) { + this.totalBytes -= this.sizeOf(key, existing); + this.map.delete(key); + } + + const entrySize = this.sizeOf(key, value); + while ( + this.map.size > 0 + && (this.map.size >= this.maxEntries || this.totalBytes + entrySize > this.maxBytes) + ) { + const oldest = this.map.keys().next().value; + if (oldest === undefined) break; + const oldestValue = this.map.get(oldest); + if (oldestValue !== undefined) { + this.totalBytes -= this.sizeOf(oldest, oldestValue); + } + 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); + this.totalBytes += entrySize; + } + + clear(): void { + this.map.clear(); + this.totalBytes = 0; + } +} + +/** Byte size for string→string highlight HTML caches (key + value). */ +export const stringPairSize = (key: string, value: string): number => + utf8Bytes(key) + utf8Bytes(value); + +/** Byte size for string→string[] line caches. */ +export const stringLinesSize = (key: string, value: string[]): number => { + let total = utf8Bytes(key); + for (const line of value) total += utf8Bytes(line); + return total; +}; diff --git a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts index a06b81fc..7e19b73c 100644 --- a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts @@ -1,8 +1,13 @@ /// import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki'; +import { + HighlightResultCache, + stringLinesSize, + stringPairSize, +} from './highlightResultCache'; import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition'; -import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; +import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; // Shiki FontStyle bitmask (from @shikijs/types). Inlined to avoid an extra import. const FONT_STYLE_ITALIC = 1; @@ -32,6 +37,25 @@ let highlighter: ReturnType | undefined; // Serialize work so language loading / tokenization never overlaps. let queue = Promise.resolve(); +// Memoize tokenization by exact source. Without this, every remount / cache +// miss on the main thread re-runs codeToHtml for unchanged content and pins a +// core (openchamber/openchamber#2769). +const WORKER_CACHE_MAX_ENTRIES = 1500; +const WORKER_CACHE_MAX_BYTES = 16 * 1024 * 1024; + +const htmlCache = new HighlightResultCache( + { maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES }, + stringPairSize, +); +const linesCache = new HighlightResultCache( + { maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES }, + stringLinesSize, +); +const tokensCache = new HighlightResultCache( + { maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES }, + (key, value) => stringPairSize(key, JSON.stringify(value)), +); + const ensureHighlighter = (): ReturnType => { highlighter ??= createHighlighter({ // Cast: the theme is a CSS-variable TextMate theme; Shiki accepts the shape. @@ -74,6 +98,12 @@ const resolveLanguage = async (instance: Instance, requested: string): Promise): Promise { try { + const cacheKey = `${request.lang}\0${request.code}`; + const cached = htmlCache.get(cacheKey); + if (cached !== undefined) { + post({ type: 'highlight', id: request.id, html: cached }); + return; + } const instance = await ensureHighlighter(); const lang = await resolveLanguage(instance, request.lang); const html = instance.codeToHtml(request.code, { @@ -81,6 +111,7 @@ async function highlight(request: Extract): Promise { try { + const cacheKey = `${request.themeName}\0${request.lang}\0${request.code}`; + const cached = tokensCache.get(cacheKey); + if (cached !== undefined) { + post({ type: 'highlightTokens', id: request.id, lines: cached }); + return; + } const instance = await ensureHighlighter(); if (request.theme && !instance.getLoadedThemes().includes(request.themeName)) { // Cast: a resolved TextMate theme object from the app theme registry. @@ -102,6 +139,7 @@ async function highlightTokens(request: Extract line.map((token) => [token.content.length, token.color ?? '', token.fontStyle ?? 0] as [number, string, number]), ); + tokensCache.set(cacheKey, lines); post({ type: 'highlightTokens', id: request.id, lines }); } catch (error) { post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); @@ -110,6 +148,12 @@ async function highlightTokens(request: Extract): Promise { try { + const cacheKey = `${request.lang}\0${request.code}`; + const cached = linesCache.get(cacheKey); + if (cached !== undefined) { + post({ type: 'highlightLines', id: request.id, lines: cached }); + return; + } const instance = await ensureHighlighter(); const lang = await resolveLanguage(instance, request.lang); const { tokens } = instance.codeToTokens(request.code, { @@ -117,6 +161,7 @@ async function highlightLines(request: Extract line.map(tokenSpan).join('')); + linesCache.set(cacheKey, lines); post({ type: 'highlightLines', id: request.id, lines }); } catch (error) { post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index 85d061bd..913e6511 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -1,4 +1,9 @@ import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url'; +import { + HighlightResultCache, + stringLinesSize, + stringPairSize, +} from './highlightResultCache'; import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; // Main-thread client for the markdown Shiki worker. Moves syntax tokenization @@ -6,9 +11,36 @@ 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 exact source (+ 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. 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 cachedHighlightSize = (key: string, value: CachedHighlight): number => { + if (value.type === 'highlight') return stringPairSize(key, value.html); + if (value.type === 'highlightLines') return stringLinesSize(key, value.lines); + // Token runs: approximate as JSON length of the lines payload. + return stringPairSize(key, JSON.stringify(value.lines)); +}; + +const resultCache = new HighlightResultCache( + { maxEntries: CLIENT_CACHE_MAX_ENTRIES, maxBytes: CLIENT_CACHE_MAX_BYTES }, + cachedHighlightSize, +); + +const inflight = new Map>(); + let worker: Worker | undefined; let nextId = 0; const pending = new Map(); @@ -20,6 +52,8 @@ 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 +89,42 @@ 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; +}; + +/** 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 = `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);
+    return entry;
+  });
+  return result?.type === 'highlight' ? result.html : null;
 };
 
 /**
@@ -70,8 +133,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 = `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);
+    return entry;
+  });
+  return result?.type === 'highlightLines' ? result.lines : null;
 };
 
 /**
@@ -86,18 +159,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 = `highlightTokens:${themeName}:${lang}:${code}`;
+  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);
+    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 ad906d99..ef998e57 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 { HighlightResultCache, stringPairSize } from './highlightResultCache';
 import { highlightCodeInWorker } from './markdown-worker';
 import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
 
@@ -317,11 +318,23 @@ 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 — 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.
 
-const CACHE_MAX = 240;
-const htmlCache = new Map();
+const HTML_CACHE_MAX_ENTRIES = 2000;
+const HTML_CACHE_MAX_BYTES = 24 * 1024 * 1024;
+
+const htmlCache = new HighlightResultCache(
+  { maxEntries: HTML_CACHE_MAX_ENTRIES, maxBytes: HTML_CACHE_MAX_BYTES },
+  stringPairSize,
+);
 
 // FNV-1a 32-bit hash of the block content.
 const hash = (value: string): string => {
@@ -333,12 +346,16 @@ const hash = (value: string): string => {
   return (h >>> 0).toString(36);
 };
 
-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);
+/** Content-addressed cache key for a markdown block (exported for tests). */
+export const markdownBlockCacheKey = (
+  contentHash: string,
+  mode: MarkdownBlock['mode'],
+  highlight: boolean,
+): string => `${contentHash}:${mode}:${highlight ? 1 : 0}`;
+
+/** Test-only: clear the render HTML cache between cases. */
+export const resetMarkdownHtmlCacheForTests = (): void => {
+  htmlCache.clear();
 };
 
 const parseBlock = async (block: MarkdownBlock): Promise => {
@@ -377,27 +394,31 @@ 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).
+ *
+ * `cacheKey` is retained for call-site compatibility / debugging; the HTML
+ * cache itself is content-addressed so distinct renderers with identical
+ * blocks share results and cannot evict each other by identity collision.
  */
 export const renderMarkdownBlocks = async (
   text: string,
   streaming: boolean,
   cacheKey: string,
 ): Promise => {
+  // Retained for call-site compatibility / debugging; lookup is content-addressed.
+  void cacheKey;
   if (!text) return [];
 
   const blocks = streamBlocks(text, streaming);
   return Promise.all(
-    blocks.map(async (block, index) => {
+    blocks.map(async (block) => {
       const contentHash = hash(block.raw);
-      const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}`;
-      const key = `${cacheKey}:${index}:${block.mode}`;
-      const cached = htmlCache.get(key);
-      if (cached && cached.hash === contentHash) {
-        touch(key, cached);
-        return { id, html: cached.html };
+      const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight);
+      const cached = htmlCache.get(id);
+      if (cached !== undefined) {
+        return { id, html: cached };
       }
       const html = await parseBlock(block);
-      touch(key, { hash: contentHash, html });
+      htmlCache.set(id, 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..82cbb614
--- /dev/null
+++ b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts
@@ -0,0 +1,138 @@
+/**
+ * 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, and bounded LRU behavior.
+ */
+import { beforeEach, describe, expect, mock, test } from 'bun:test';
+
+import { HighlightResultCache, stringPairSize } from './highlightResultCache';
+
+let highlightCalls = 0;
+
+const highlightCodeInWorkerMock = mock(async (code: string, lang: string) => {
+  highlightCalls += 1;
+  return `
${code}
`; +}); + +mock.module('./markdown-worker', () => ({ + highlightCodeInWorker: highlightCodeInWorkerMock, + highlightLinesInWorker: mock(async () => null), + highlightTokensInWorker: mock(async () => null), + resetMarkdownWorkerClientCacheForTests: mock(() => undefined), +})); + +const { + renderMarkdownBlocks, + resetMarkdownHtmlCacheForTests, + markdownBlockCacheKey, +} = await import('./markdownCore'); + +const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker'); + +beforeEach(() => { + resetMarkdownHtmlCacheForTests(); + resetMarkdownWorkerClientCacheForTests(); + highlightCalls = 0; +}); + +describe('HighlightResultCache', () => { + test('returns cached values for identical keys and refreshes LRU order', () => { + const cache = new HighlightResultCache( + { maxEntries: 2, maxBytes: 10_000 }, + stringPairSize, + ); + cache.set('a', 'one'); + cache.set('b', 'two'); + expect(cache.get('a')).toBe('one'); + // Touch `a` so `b` is oldest; inserting `c` should evict `b`. + cache.set('c', '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 }, + stringPairSize, + ); + cache.set('small', 'x'); + cache.set('huge', 'y'.repeat(200)); + expect(cache.get('huge')).toBe('y'.repeat(200)); + // Oversized insert cleared prior entries to make room. + expect(cache.size).toBe(1); + }); +}); + +describe('markdownCore content-addressed htmlCache (#2769)', () => { + test('two same-variant SimpleMarkdown-style keys do not re-highlight unchanged content', 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, 'simple:tool'); + await renderMarkdownBlocks(toolOutputB, false, 'simple:tool'); + const coldCalls = highlightCalls; + expect(coldCalls).toBeGreaterThan(0); + + // 100 more passes with the legacy shared `simple:tool` identity keys — + // must not produce additional worker calls. + for (let pass = 0; pass < 100; pass += 1) { + await renderMarkdownBlocks(toolOutputA, false, 'simple:tool'); + await renderMarkdownBlocks(toolOutputB, false, 'simple:tool'); + } + + 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) => ({ + key: `markdown-part-part_${i}`, + content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``, + })); + + for (const part of parts) { + await renderMarkdownBlocks(part.content, false, part.key); + } + 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, part.key); + } + } + + // 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, 'a'); + await renderMarkdownBlocks(changing, false, 'b'); + const afterFirst = highlightCalls; + + await renderMarkdownBlocks(stable, false, 'a'); + await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false, 'b'); + expect(highlightCalls).toBe(afterFirst + 1); + + await renderMarkdownBlocks(stable, false, 'a'); + expect(highlightCalls).toBe(afterFirst + 1); + }); + + test('block cache keys are content-addressed (mode + highlight + hash)', () => { + expect(markdownBlockCacheKey('abc', 'full', true)).toBe('abc:full:1'); + expect(markdownBlockCacheKey('abc', 'live', false)).toBe('abc:live:0'); + expect(markdownBlockCacheKey('abc', 'full', true)).not.toBe(markdownBlockCacheKey('abc', 'full', false)); + }); +}); From c3c47e8956619a70d7bc3d4182196ce683274fd3 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Tue, 11 Aug 2026 13:23:43 +0000 Subject: [PATCH 002/215] perf(ui): tighten Shiki highlight caches and parallelize fences Use content fingerprints instead of full source as cache keys, record entry sizes once (no TextEncoder/JSON.stringify on get/evict), and highlight multiple markdown fences concurrently. Co-authored-by: Serhii Dziupin --- .../components/chat/MarkdownRendererImpl.tsx | 10 +- .../chat/markdown/highlightResultCache.ts | 96 ++++++++++--------- .../chat/markdown/markdown-shiki.worker.ts | 54 ++++++----- .../chat/markdown/markdown-worker.ts | 57 ++++++----- .../components/chat/markdown/markdownCore.ts | 71 +++++++------- .../markdownHighlightingRepro.test.ts | 66 ++++++++++--- 6 files changed, 205 insertions(+), 149 deletions(-) diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index c5294f10..2e0482e7 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -1117,18 +1117,14 @@ const SimpleMarkdownRendererImpl: React.FC<{ const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls); - // Content-addressed render cache (see markdownCore) ignores this string for - // lookup; keep a stable per-content key so effect deps stay aligned with text. - const cacheKey = React.useMemo( - () => `simple:${variant}:${renderedContent.length}:${renderedContent.slice(0, 64)}`, - [variant, renderedContent], - ); useMorphdomMarkdown({ containerRef, text: renderedContent, streaming: false, - cacheKey, + // Identity is unused for cache lookup (content-addressed in markdownCore); + // keep a stable per-variant key for effect deps alongside `text`. + 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 index 1f5fa2cb..1f1b8440 100644 --- a/packages/ui/src/components/chat/markdown/highlightResultCache.ts +++ b/packages/ui/src/components/chat/markdown/highlightResultCache.ts @@ -1,38 +1,61 @@ // Bounded LRU for Shiki highlight results. // -// Used on both the main-thread worker client and inside the markdown-shiki -// worker so unchanged code is never re-tokenized. Keys are exact -// (lang + source [+ theme]); eviction is by entry count and approximate byte -// size so long sessions cannot grow unbounded. +// Used on the main-thread worker client and inside the markdown-shiki worker so +// unchanged code is never 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; }; -const utf8Bytes = (value: string): number => { - // Prefer TextEncoder when available (browser / modern Bun); fall back to - // UTF-16 length × 2 as a conservative upper bound for older runtimes. - if (typeof TextEncoder !== 'undefined') { - return new TextEncoder().encode(value).length; +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; + +/** + * Short stable fingerprint for cache keys: length + FNV-1a 32-bit. + * Collision of same length + same hash is vanishingly rare at session scale; + * highlight results are pure functions of (kind, lang, theme, source). + */ +export const contentFingerprint = (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 value.length * 2; + return `${value.length.toString(36)}_${(h >>> 0).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 sizeOf: (key: string, value: T) => number; - private readonly map = new Map(); + private readonly map = new Map>(); private totalBytes = 0; - constructor( - options: HighlightResultCacheOptions, - sizeOf: (key: string, value: T) => number, - ) { + constructor(options: HighlightResultCacheOptions) { this.maxEntries = Math.max(1, options.maxEntries); this.maxBytes = Math.max(1, options.maxBytes); - this.sizeOf = sizeOf; } get size(): number { @@ -44,39 +67,37 @@ export class HighlightResultCache { } get(key: string): T | undefined { - const value = this.map.get(key); - if (value === undefined) return undefined; - // Refresh LRU order. + 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, value); - return value; + this.map.set(key, entry); + return entry.value; } - set(key: string, value: T): void { + set(key: string, value: T, bytes: number): void { const existing = this.map.get(key); if (existing !== undefined) { - this.totalBytes -= this.sizeOf(key, existing); + this.totalBytes -= existing.bytes; this.map.delete(key); } - const entrySize = this.sizeOf(key, value); + const entryBytes = Math.max(0, bytes); while ( this.map.size > 0 - && (this.map.size >= this.maxEntries || this.totalBytes + entrySize > this.maxBytes) + && (this.map.size >= this.maxEntries || this.totalBytes + entryBytes > this.maxBytes) ) { const oldest = this.map.keys().next().value; if (oldest === undefined) break; - const oldestValue = this.map.get(oldest); - if (oldestValue !== undefined) { - this.totalBytes -= this.sizeOf(oldest, oldestValue); - } + 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); - this.totalBytes += entrySize; + this.map.set(key, { value, bytes: entryBytes }); + this.totalBytes += entryBytes; } clear(): void { @@ -84,14 +105,3 @@ export class HighlightResultCache { this.totalBytes = 0; } } - -/** Byte size for string→string highlight HTML caches (key + value). */ -export const stringPairSize = (key: string, value: string): number => - utf8Bytes(key) + utf8Bytes(value); - -/** Byte size for string→string[] line caches. */ -export const stringLinesSize = (key: string, value: string[]): number => { - let total = utf8Bytes(key); - for (const line of value) total += utf8Bytes(line); - return total; -}; diff --git a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts index 7e19b73c..3877d26a 100644 --- a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts @@ -2,9 +2,10 @@ import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki'; import { + contentFingerprint, + estimateTokenRunsBytes, HighlightResultCache, - stringLinesSize, - stringPairSize, + utf16Bytes, } from './highlightResultCache'; import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition'; import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; @@ -37,24 +38,24 @@ let highlighter: ReturnType | undefined; // Serialize work so language loading / tokenization never overlaps. let queue = Promise.resolve(); -// Memoize tokenization by exact source. Without this, every remount / cache -// miss on the main thread re-runs codeToHtml for unchanged content and pins a -// core (openchamber/openchamber#2769). +// Memoize tokenization by content fingerprint. Defense in depth for the +// main-thread client cache (openchamber/openchamber#2769): keys stay short so +// large sources are not duplicated in the Map. const WORKER_CACHE_MAX_ENTRIES = 1500; const WORKER_CACHE_MAX_BYTES = 16 * 1024 * 1024; -const htmlCache = new HighlightResultCache( - { maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES }, - stringPairSize, -); -const linesCache = new HighlightResultCache( - { maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES }, - stringLinesSize, -); -const tokensCache = new HighlightResultCache( - { maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES }, - (key, value) => stringPairSize(key, JSON.stringify(value)), -); +const htmlCache = new HighlightResultCache({ + maxEntries: WORKER_CACHE_MAX_ENTRIES, + maxBytes: WORKER_CACHE_MAX_BYTES, +}); +const linesCache = new HighlightResultCache({ + maxEntries: WORKER_CACHE_MAX_ENTRIES, + maxBytes: WORKER_CACHE_MAX_BYTES, +}); +const tokensCache = new HighlightResultCache({ + maxEntries: WORKER_CACHE_MAX_ENTRIES, + maxBytes: WORKER_CACHE_MAX_BYTES, +}); const ensureHighlighter = (): ReturnType => { highlighter ??= createHighlighter({ @@ -96,9 +97,14 @@ const resolveLanguage = async (instance: Instance, requested: string): Promise `${lang}:${contentFingerprint(code)}`; +const linesKey = (lang: string, code: string): string => `${lang}:${contentFingerprint(code)}`; +const tokensKey = (themeName: string, lang: string, code: string): string => + `${themeName}:${lang}:${contentFingerprint(code)}`; + async function highlight(request: Extract): Promise { try { - const cacheKey = `${request.lang}\0${request.code}`; + const cacheKey = htmlKey(request.lang, request.code); const cached = htmlCache.get(cacheKey); if (cached !== undefined) { post({ type: 'highlight', id: request.id, html: cached }); @@ -111,7 +117,7 @@ async function highlight(request: Extract): Promise { try { - const cacheKey = `${request.themeName}\0${request.lang}\0${request.code}`; + const cacheKey = tokensKey(request.themeName, request.lang, request.code); const cached = tokensCache.get(cacheKey); if (cached !== undefined) { post({ type: 'highlightTokens', id: request.id, lines: cached }); @@ -139,7 +145,7 @@ async function highlightTokens(request: Extract line.map((token) => [token.content.length, token.color ?? '', token.fontStyle ?? 0] as [number, string, number]), ); - tokensCache.set(cacheKey, lines); + tokensCache.set(cacheKey, lines, utf16Bytes(cacheKey) + estimateTokenRunsBytes(lines)); post({ type: 'highlightTokens', id: request.id, lines }); } catch (error) { post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); @@ -148,7 +154,7 @@ async function highlightTokens(request: Extract): Promise { try { - const cacheKey = `${request.lang}\0${request.code}`; + const cacheKey = linesKey(request.lang, request.code); const cached = linesCache.get(cacheKey); if (cached !== undefined) { post({ type: 'highlightLines', id: request.id, lines: cached }); @@ -161,7 +167,9 @@ async function highlightLines(request: Extract line.map(tokenSpan).join('')); - linesCache.set(cacheKey, lines); + let bytes = utf16Bytes(cacheKey); + for (const line of lines) bytes += utf16Bytes(line); + linesCache.set(cacheKey, lines, bytes); post({ type: 'highlightLines', id: request.id, lines }); } catch (error) { post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index 913e6511..a2234c61 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -1,8 +1,9 @@ import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url'; import { + contentFingerprint, + estimateTokenRunsBytes, HighlightResultCache, - stringLinesSize, - stringPairSize, + utf16Bytes, } from './highlightResultCache'; import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; @@ -12,10 +13,11 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } // 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 exact source (+ 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. +// 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. type PendingResolver = (response: MarkdownWorkerResponse | null) => void; @@ -27,17 +29,10 @@ type CachedHighlight = const CLIENT_CACHE_MAX_ENTRIES = 2000; const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024; -const cachedHighlightSize = (key: string, value: CachedHighlight): number => { - if (value.type === 'highlight') return stringPairSize(key, value.html); - if (value.type === 'highlightLines') return stringLinesSize(key, value.lines); - // Token runs: approximate as JSON length of the lines payload. - return stringPairSize(key, JSON.stringify(value.lines)); -}; - -const resultCache = new HighlightResultCache( - { maxEntries: CLIENT_CACHE_MAX_ENTRIES, maxBytes: CLIENT_CACHE_MAX_BYTES }, - cachedHighlightSize, -); +const resultCache = new HighlightResultCache({ + maxEntries: CLIENT_CACHE_MAX_ENTRIES, + maxBytes: CLIENT_CACHE_MAX_BYTES, +}); const inflight = new Map>(); @@ -48,6 +43,17 @@ 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(); @@ -102,6 +108,11 @@ const coalesce = ( 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(); @@ -113,7 +124,7 @@ export const resetMarkdownWorkerClientCacheForTests = (): void => { * or `null` if highlighting is unavailable or failed (caller keeps plain code). */ export const highlightCodeInWorker = async (code: string, lang: string): Promise => { - const key = `highlight:${lang}:${code}`; + const key = cacheKeyFor('highlight', lang, code); const cached = resultCache.get(key); if (cached?.type === 'highlight') return cached.html; @@ -121,7 +132,7 @@ export const highlightCodeInWorker = async (code: string, lang: string): Promise 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); + resultCache.set(key, entry, entryBytes(key, entry)); return entry; }); return result?.type === 'highlight' ? result.html : null; @@ -133,7 +144,7 @@ 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 key = `highlightLines:${lang}:${code}`; + const key = cacheKeyFor('highlightLines', lang, code); const cached = resultCache.get(key); if (cached?.type === 'highlightLines') return cached.lines; @@ -141,7 +152,7 @@ export const highlightLinesInWorker = async (code: string, lang: string): Promis 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); + resultCache.set(key, entry, entryBytes(key, entry)); return entry; }); return result?.type === 'highlightLines' ? result.lines : null; @@ -159,7 +170,7 @@ export const highlightTokensInWorker = async ( themeName: string, theme: unknown, ): Promise => { - const key = `highlightTokens:${themeName}:${lang}:${code}`; + const key = cacheKeyFor('highlightTokens', lang, code, themeName); const cached = resultCache.get(key); if (cached?.type === 'highlightTokens') return cached.lines; @@ -176,7 +187,7 @@ export const highlightTokensInWorker = async ( if (response?.type !== 'highlightTokens') return null; sentThemes.add(themeName); const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines }; - resultCache.set(key, entry); + 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 ef998e57..96bc0cc0 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -4,7 +4,7 @@ import katex from 'katex'; import DOMPurify from 'dompurify'; import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks'; import { isVSCodeRuntime } from '@/lib/desktop'; -import { HighlightResultCache, stringPairSize } from './highlightResultCache'; +import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache'; import { highlightCodeInWorker } from './markdown-worker'; import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity'; @@ -254,32 +254,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;
 };
 
@@ -331,20 +336,10 @@ const sanitize = (html: string): string => {
 const HTML_CACHE_MAX_ENTRIES = 2000;
 const HTML_CACHE_MAX_BYTES = 24 * 1024 * 1024;
 
-const htmlCache = new HighlightResultCache(
-  { maxEntries: HTML_CACHE_MAX_ENTRIES, maxBytes: HTML_CACHE_MAX_BYTES },
-  stringPairSize,
-);
-
-// 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 htmlCache = new HighlightResultCache({
+  maxEntries: HTML_CACHE_MAX_ENTRIES,
+  maxBytes: HTML_CACHE_MAX_BYTES,
+});
 
 /** Content-addressed cache key for a markdown block (exported for tests). */
 export const markdownBlockCacheKey = (
@@ -411,14 +406,14 @@ export const renderMarkdownBlocks = async (
   const blocks = streamBlocks(text, streaming);
   return Promise.all(
     blocks.map(async (block) => {
-      const contentHash = hash(block.raw);
+      const contentHash = contentFingerprint(block.raw);
       const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight);
       const cached = htmlCache.get(id);
       if (cached !== undefined) {
         return { id, html: cached };
       }
       const html = await parseBlock(block);
-      htmlCache.set(id, html);
+      htmlCache.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
index 82cbb614..e35d5ac9 100644
--- a/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts
+++ b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts
@@ -8,16 +8,27 @@
  *  3. Worker/client had no result memoization.
  *
  * These tests assert the fixed contracts: content-addressed caching, room for
- * long sessions, and bounded LRU behavior.
+ * long sessions, bounded LRU behavior, and fingerprint-key helpers.
  */
 import { beforeEach, describe, expect, mock, test } from 'bun:test';
 
-import { HighlightResultCache, stringPairSize } from './highlightResultCache';
+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}
`; }); @@ -40,35 +51,45 @@ 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 }, - stringPairSize, - ); - cache.set('a', 'one'); - cache.set('b', 'two'); + 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'); + 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 }, - stringPairSize, - ); - cache.set('small', 'x'); - cache.set('huge', 'y'.repeat(200)); + 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('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)', () => { @@ -135,4 +156,19 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => { expect(markdownBlockCacheKey('abc', 'live', false)).toBe('abc:live:0'); expect(markdownBlockCacheKey('abc', 'full', true)).not.toBe(markdownBlockCacheKey('abc', 'full', false)); }); + + 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, 'multi'); + expect(highlightCalls).toBe(3); + // Sequential awaits would keep max inflight at 1. + expect(highlightMaxInflight).toBeGreaterThan(1); + }); }); From 4cc090130cae6e1eda1f7d6fda21739d80bd901c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 04:25:40 +0000 Subject: [PATCH 003/215] fix(config): refuse partial JSONC parses that wipe opencode.jsonc jsonc-parser was returning truncated trees (often only `$schema`) when configs contained JSON5-style unquoted keys. Config mutations then backed up and overwrote the full file with that stub. Check parse errors on read and refuse to overwrite unparseable files on write in web and VS Code. Fixes #2923 Co-authored-by: serkraser --- CHANGELOG.md | 1 + packages/vscode/CHANGELOG.md | 4 + packages/vscode/src/DOCUMENTATION.md | 1 + .../src/opencodeConfig.config-parse.test.ts | 96 ++++++++++++ packages/vscode/src/opencodeConfig.ts | 30 +++- .../web/server/lib/opencode/DOCUMENTATION.md | 3 +- packages/web/server/lib/opencode/shared.js | 43 +++++- .../web/server/lib/opencode/shared.test.js | 140 +++++++++++++++++- 8 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 packages/vscode/src/opencodeConfig.config-parse.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a980e698..ddc38b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **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). - **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. - **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: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 8132a578..18c11df8 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,3 +1,7 @@ +## [Unreleased] + +- **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). + ## [1.18.4] - 2026-08-14 - **Chat:** new messages now remain at the end of the conversation instead of jumping before older messages after the message ID sequence rolls over; history loading, revert, and redo follow the same chronological order. diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 3a4d3dde..e7d6e610 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -58,6 +58,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r - `bridge-config-runtime.ts` - Config and skills message handlers (`api:config/*`). - Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`). + - OpenCode JSONC reads in `opencodeConfig.ts` fail closed on any `jsonc-parser` error or non-object result (`INVALID_JSONC`) so mutations cannot rewrite a partial `$schema`-only stub over an existing config. - `bridge-settings-runtime.ts` - Settings read/write and OpenCode skills discovery via API for bridge consumers. diff --git a/packages/vscode/src/opencodeConfig.config-parse.test.ts b/packages/vscode/src/opencodeConfig.config-parse.test.ts new file mode 100644 index 00000000..bd6f08b2 --- /dev/null +++ b/packages/vscode/src/opencodeConfig.config-parse.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { updateMcpConfig } from './opencodeConfig'; + +const PARTIAL_PARSE_CONFIG = [ + '{', + ' "$schema": "https://opencode.ai/config.json",', + ' plugin: ["opencode-see-image"],', + ' mcp: {', + ' openproject: {', + ' type: "remote",', + ' url: "https://openproject.example.com/mcp",', + ' enabled: true', + ' }', + ' },', + ' provider: {', + ' "ollama-cloud": {', + ' npm: "@ai-sdk/openai-compatible",', + ' name: "Ollama Cloud"', + ' }', + ' }', + '}', + '', +].join('\n'); + +const VALID_CONFIG = [ + '{', + ' "$schema": "https://opencode.ai/config.json",', + ' "plugin": ["opencode-see-image"],', + ' "mcp": {', + ' "openproject": {', + ' "type": "remote",', + ' "url": "https://openproject.example.com/mcp",', + ' "enabled": true', + ' }', + ' },', + ' "provider": {', + ' "ollama-cloud": {', + ' "npm": "@ai-sdk/openai-compatible",', + ' "name": "Ollama Cloud"', + ' }', + ' }', + '}', + '', +].join('\n'); + +describe('opencodeConfig JSONC parse safety (issue #2923)', () => { + let tempDir: string; + let previousOpenCodeConfig: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-config-parse-')); + previousOpenCodeConfig = process.env.OPENCODE_CONFIG; + }); + + afterEach(() => { + if (previousOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG; + else process.env.OPENCODE_CONFIG = previousOpenCodeConfig; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('refuses MCP updates that would overwrite a partial-parse config', () => { + const configPath = path.join(tempDir, 'opencode.jsonc'); + fs.writeFileSync(configPath, PARTIAL_PARSE_CONFIG, 'utf8'); + process.env.OPENCODE_CONFIG = configPath; + + assert.throws( + () => updateMcpConfig('openproject', { enabled: true }), + (error: unknown) => ( + error instanceof Error + && /cannot be loaded safely/.test(error.message) + && (error as Error & { code?: string }).code === 'INVALID_JSONC' + ), + ); + assert.equal(fs.readFileSync(configPath, 'utf8'), PARTIAL_PARSE_CONFIG); + assert.equal(fs.existsSync(`${configPath}.openchamber.backup`), false); + }); + + test('preserves unrelated keys when updating a valid MCP config', () => { + const configPath = path.join(tempDir, 'opencode.jsonc'); + fs.writeFileSync(configPath, VALID_CONFIG, 'utf8'); + process.env.OPENCODE_CONFIG = configPath; + + updateMcpConfig('openproject', { enabled: false }); + + const rewritten = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.deepEqual(rewritten.plugin, ['opencode-see-image']); + assert.equal(rewritten.provider['ollama-cloud'].name, 'Ollama Cloud'); + assert.equal(rewritten.mcp.openproject.enabled, false); + assert.equal(fs.readFileSync(`${configPath}.openchamber.backup`, 'utf8'), VALID_CONFIG); + }); +}); diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index a14ef031..199f348e 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import yaml from 'yaml'; -import { parse as parseJsonc } from 'jsonc-parser'; +import { parse as parseJsonc, printParseErrorCode, type ParseError } from 'jsonc-parser'; const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents'); @@ -554,12 +554,33 @@ const getPrimaryUserConfigPath = (userPaths: string[]): string => { return CONFIG_FILE; }; +const INVALID_JSONC = 'INVALID_JSONC'; + +const formatJsoncParseError = (filePath: string, errors: ParseError[]): string => { + const first = errors.length > 0 ? errors[0] : null; + const location = first && Number.isFinite(first.offset) + ? ` (${printParseErrorCode(first.error)} at offset ${first.offset})` + : ''; + return `OpenCode configuration at ${filePath} contains invalid JSONC and cannot be loaded safely${location}`; +}; + +const parseConfigObject = (content: string, filePath: string): Record => { + const errors: ParseError[] = []; + const parsed = parseJsonc(content, errors, { allowTrailingComma: true }); + if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw codedError(formatJsoncParseError(filePath, errors), INVALID_JSONC); + } + return parsed as Record; +}; + const readConfigFile = (filePath?: string | null): Record => { if (!filePath || !fs.existsSync(filePath)) return {}; const content = fs.readFileSync(filePath, 'utf8'); const normalized = content.trim(); if (!normalized) return {}; - return parseJsonc(normalized, [], { allowTrailingComma: true }) as Record; + // Refuse partial jsonc-parser trees. Ignoring errors previously let mutations + // rewrite a truncated object (often only `$schema`) over the full config. + return parseConfigObject(normalized, filePath); }; const isPlainObject = (value: unknown): value is Record => @@ -695,6 +716,11 @@ const getConfigForPath = (layers: ReturnType, targetPat const writeConfig = (config: Record, filePath: string = CONFIG_FILE) => { if (fs.existsSync(filePath)) { + // Defense in depth: never overwrite a file we cannot fully parse. + const existing = fs.readFileSync(filePath, 'utf8').trim(); + if (existing) { + parseConfigObject(existing, filePath); + } const backupFile = `${filePath}.openchamber.backup`; try { fs.copyFileSync(filePath, backupFile); diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 3b6c453c..cbe9f6c8 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -71,7 +71,8 @@ This module provides OpenCode server integration utilities for the web server ru - `ensureDirs()`: Creates required OpenCode directories. - `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter. - `getConfigPaths(workingDirectory)`, `readConfigLayers(workingDirectory)`, `readConfig(workingDirectory)`: Config file operations with layer merging (user, project, custom). -- `writeConfig(config, filePath)`: Writes config with automatic backup. +- `readConfigFile(filePath)`: Reads one config file. Empty/missing files return `{}`. Any `jsonc-parser` error or non-object result throws `INVALID_JSONC` — partial parse trees must never be treated as authoritative (avoids rewriting a `$schema`-only stub over a full config). +- `writeConfig(config, filePath)`: Writes config with automatic backup. Refuses to overwrite an existing non-empty file that fails the same JSONC parse check. - `getJsonEntrySource(layers, sectionKey, entryName)`: Resolves which config layer provides an entry. - `getJsonWriteTarget(layers, preferredScope)`: Determines write target for config updates. - `getAncestors(startDir, stopDir)`, `findWorktreeRoot(startDir)`: Git worktree helpers. diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js index 062d3d0f..f99360d8 100644 --- a/packages/web/server/lib/opencode/shared.js +++ b/packages/web/server/lib/opencode/shared.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import yaml from 'yaml'; -import { parse as parseJsonc } from 'jsonc-parser'; +import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser'; // ============== PATH CONSTANTS ============== @@ -168,6 +168,31 @@ function getPrimaryUserConfigPath(userPaths) { return CONFIG_FILE; } +const INVALID_JSONC = 'INVALID_JSONC'; + +function isInvalidJsoncError(error) { + return Boolean(error && typeof error === 'object' && error.code === INVALID_JSONC); +} + +function formatJsoncParseError(filePath, errors) { + const first = Array.isArray(errors) && errors.length > 0 ? errors[0] : null; + const location = first && Number.isFinite(first.offset) + ? ` (${printParseErrorCode(first.error)} at offset ${first.offset})` + : ''; + return `OpenCode configuration at ${filePath} contains invalid JSONC and cannot be loaded safely${location}`; +} + +function parseConfigObject(content, filePath) { + const errors = []; + const parsed = parseJsonc(content, errors, { allowTrailingComma: true }); + if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + const error = new Error(formatJsoncParseError(filePath, errors)); + error.code = INVALID_JSONC; + throw error; + } + return parsed; +} + function readConfigFile(filePath) { if (!filePath || !fs.existsSync(filePath)) { return {}; @@ -178,8 +203,13 @@ function readConfigFile(filePath) { if (!normalized) { return {}; } - return parseJsonc(normalized, [], { allowTrailingComma: true }); + // Refuse partial jsonc-parser trees. Ignoring errors previously let mutations + // rewrite a truncated object (often only `$schema`) over the full config. + return parseConfigObject(normalized, filePath); } catch (error) { + if (isInvalidJsoncError(error)) { + throw error; + } console.error(`Failed to read config file: ${filePath}`, error); throw new Error('Failed to read OpenCode configuration'); } @@ -246,6 +276,12 @@ function getConfigForPath(layers, targetPath) { function writeConfig(config, filePath = CONFIG_FILE) { try { if (fs.existsSync(filePath)) { + // Defense in depth: never overwrite a file we cannot fully parse. + const existing = fs.readFileSync(filePath, 'utf8').trim(); + if (existing) { + parseConfigObject(existing, filePath); + } + const backupFile = `${filePath}.openchamber.backup`; fs.copyFileSync(filePath, backupFile); console.log(`Created config backup: ${backupFile}`); @@ -255,6 +291,9 @@ function writeConfig(config, filePath = CONFIG_FILE) { fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8'); console.log(`Successfully wrote config file: ${filePath}`); } catch (error) { + if (isInvalidJsoncError(error)) { + throw error; + } console.error(`Failed to write config file: ${filePath}`, error); throw new Error('Failed to write OpenCode configuration'); } diff --git a/packages/web/server/lib/opencode/shared.test.js b/packages/web/server/lib/opencode/shared.test.js index e131aa0c..a3e646b7 100644 --- a/packages/web/server/lib/opencode/shared.test.js +++ b/packages/web/server/lib/opencode/shared.test.js @@ -3,8 +3,9 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { parseMdFile, writeMdFile } from './shared.js'; +import { parseMdFile, writeMdFile, readConfigFile, writeConfig } from './shared.js'; import { updateAgent } from './agents.js'; +import { updateMcpConfig } from './mcp.js'; const FIXTURE_DIR = path.join(os.tmpdir(), `openchamber-shared-test-${process.pid}`); @@ -200,3 +201,140 @@ describe('updateAgent frontmatter preservation', () => { expect(parsed.body).toBe('Body of strateg.'); }); }); + +describe('readConfigFile / writeConfig JSONC safety (issue #2923)', () => { + beforeEach(() => { + fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); + fs.mkdirSync(FIXTURE_DIR, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); + }); + + const VALID_CONFIG = [ + '{', + ' "$schema": "https://opencode.ai/config.json",', + ' // keep me', + ' "plugin": ["opencode-see-image"],', + ' "mcp": {', + ' "openproject": {', + ' "type": "remote",', + ' "url": "https://openproject.example.com/mcp",', + ' "enabled": true,', + ' }', + ' },', + ' "provider": {', + ' "ollama-cloud": {', + ' "npm": "@ai-sdk/openai-compatible",', + ' "name": "Ollama Cloud"', + ' }', + ' }', + '}', + '', + ].join('\n'); + + // JSON5-style unquoted keys after $schema — jsonc-parser returns a partial + // tree of only `{ $schema }` when errors are ignored. + const PARTIAL_PARSE_CONFIG = [ + '{', + ' "$schema": "https://opencode.ai/config.json",', + ' plugin: ["opencode-see-image"],', + ' mcp: {', + ' openproject: {', + ' type: "remote",', + ' url: "https://openproject.example.com/mcp",', + ' enabled: true', + ' }', + ' },', + ' provider: {', + ' "ollama-cloud": {', + ' npm: "@ai-sdk/openai-compatible",', + ' name: "Ollama Cloud"', + ' }', + ' }', + '}', + '', + ].join('\n'); + + it('parses valid JSONC with comments and trailing commas without dropping keys', () => { + const file = writeFixture('opencode.jsonc', VALID_CONFIG); + expect(readConfigFile(file)).toEqual({ + $schema: 'https://opencode.ai/config.json', + plugin: ['opencode-see-image'], + mcp: { + openproject: { + type: 'remote', + url: 'https://openproject.example.com/mcp', + enabled: true, + }, + }, + provider: { + 'ollama-cloud': { + npm: '@ai-sdk/openai-compatible', + name: 'Ollama Cloud', + }, + }, + }); + }); + + it('returns an empty object for a missing or whitespace-only file', () => { + expect(readConfigFile(path.join(FIXTURE_DIR, 'missing.jsonc'))).toEqual({}); + const empty = writeFixture('empty.jsonc', ' \n'); + expect(readConfigFile(empty)).toEqual({}); + }); + + it('throws INVALID_JSONC on partial-parse JSONC instead of returning a $schema-only stub', () => { + const file = writeFixture('opencode.jsonc', PARTIAL_PARSE_CONFIG); + expect(() => readConfigFile(file)).toThrow(/cannot be loaded safely/); + try { + readConfigFile(file); + } catch (error) { + expect(error.code).toBe('INVALID_JSONC'); + } + }); + + it('throws INVALID_JSONC for a non-object JSONC root', () => { + const file = writeFixture('array.jsonc', '["plugin"]\n'); + expect(() => readConfigFile(file)).toThrow(/cannot be loaded safely/); + }); + + it('refuses to overwrite an unparseable config file', () => { + const file = writeFixture('opencode.jsonc', PARTIAL_PARSE_CONFIG); + expect(() => writeConfig({ $schema: 'https://opencode.ai/config.json' }, file)).toThrow( + /cannot be loaded safely/, + ); + expect(fs.readFileSync(file, 'utf8')).toBe(PARTIAL_PARSE_CONFIG); + expect(fs.existsSync(`${file}.openchamber.backup`)).toBe(false); + }); + + it('preserves a valid config across MCP updates', () => { + const file = writeFixture('opencode.jsonc', VALID_CONFIG); + const config = readConfigFile(file); + config.mcp.openproject.enabled = false; + writeConfig(config, file); + + const rewritten = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(rewritten.plugin).toEqual(['opencode-see-image']); + expect(rewritten.provider['ollama-cloud'].name).toBe('Ollama Cloud'); + expect(rewritten.mcp.openproject.enabled).toBe(false); + expect(fs.readFileSync(`${file}.openchamber.backup`, 'utf8')).toBe(VALID_CONFIG); + }); + + it('does not wipe an unparseable user config during MCP mutation attempts', () => { + const file = writeFixture('opencode.jsonc', PARTIAL_PARSE_CONFIG); + const previousOpenCodeConfig = process.env.OPENCODE_CONFIG; + + try { + process.env.OPENCODE_CONFIG = file; + expect(() => updateMcpConfig('openproject', { enabled: true })).toThrow( + /cannot be loaded safely/, + ); + expect(fs.readFileSync(file, 'utf8')).toBe(PARTIAL_PARSE_CONFIG); + expect(fs.existsSync(`${file}.openchamber.backup`)).toBe(false); + } finally { + if (previousOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG; + else process.env.OPENCODE_CONFIG = previousOpenCodeConfig; + } + }); +}); From 35563dd78d5310deb0b2711064d3bd11b9c1c42a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 05:13:32 +0000 Subject: [PATCH 004/215] fix(config): isolate broken JSONC layers and treat comment-only as empty Address review findings on the #2923 fail-closed parse fix: - Comment-only files produce ValueExpected with no JSON value; treat that as empty config instead of INVALID_JSONC. Partial object trees still throw. - readConfigLayers no longer lets one unparseable layer abort valid sibling layers. Mutations still fail closed on the custom/user write target. Co-authored-by: serkraser --- packages/vscode/src/DOCUMENTATION.md | 2 +- .../src/opencodeConfig.config-parse.test.ts | 18 ++++ packages/vscode/src/opencodeConfig.ts | 92 ++++++++++++++++--- .../web/server/lib/opencode/DOCUMENTATION.md | 8 +- packages/web/server/lib/opencode/shared.js | 86 ++++++++++++++--- .../web/server/lib/opencode/shared.test.js | 38 +++++++- 6 files changed, 210 insertions(+), 34 deletions(-) diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index e7d6e610..fe638f97 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -58,7 +58,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r - `bridge-config-runtime.ts` - Config and skills message handlers (`api:config/*`). - Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`). - - OpenCode JSONC reads in `opencodeConfig.ts` fail closed on any `jsonc-parser` error or non-object result (`INVALID_JSONC`) so mutations cannot rewrite a partial `$schema`-only stub over an existing config. + - OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, and writes still refuse to overwrite the broken file. - `bridge-settings-runtime.ts` - Settings read/write and OpenCode skills discovery via API for bridge consumers. diff --git a/packages/vscode/src/opencodeConfig.config-parse.test.ts b/packages/vscode/src/opencodeConfig.config-parse.test.ts index bd6f08b2..c12572e8 100644 --- a/packages/vscode/src/opencodeConfig.config-parse.test.ts +++ b/packages/vscode/src/opencodeConfig.config-parse.test.ts @@ -93,4 +93,22 @@ describe('opencodeConfig JSONC parse safety (issue #2923)', () => { assert.equal(rewritten.mcp.openproject.enabled, false); assert.equal(fs.readFileSync(`${configPath}.openchamber.backup`, 'utf8'), VALID_CONFIG); }); + + test('keeps a valid custom layer writable when a project layer is unparseable', () => { + const customPath = path.join(tempDir, 'custom.jsonc'); + const projectDir = path.join(tempDir, 'project'); + const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc'); + fs.writeFileSync(customPath, VALID_CONFIG, 'utf8'); + fs.mkdirSync(path.dirname(projectFile), { recursive: true }); + fs.writeFileSync(projectFile, PARTIAL_PARSE_CONFIG, 'utf8'); + process.env.OPENCODE_CONFIG = customPath; + + updateMcpConfig('openproject', { enabled: false }, projectDir); + + const rewritten = JSON.parse(fs.readFileSync(customPath, 'utf8')); + assert.deepEqual(rewritten.plugin, ['opencode-see-image']); + assert.equal(rewritten.mcp.openproject.enabled, false); + assert.equal(fs.readFileSync(projectFile, 'utf8'), PARTIAL_PARSE_CONFIG); + assert.equal(fs.existsSync(`${projectFile}.openchamber.backup`), false); + }); }); diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 199f348e..50a40082 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -564,9 +564,17 @@ const formatJsoncParseError = (filePath: string, errors: ParseError[]): string = return `OpenCode configuration at ${filePath} contains invalid JSONC and cannot be loaded safely${location}`; }; +const isInvalidJsoncError = (error: unknown): error is Error & { code: string } => + Boolean(error && typeof error === 'object' && 'code' in error && error.code === INVALID_JSONC); + const parseConfigObject = (content: string, filePath: string): Record => { const errors: ParseError[] = []; const parsed = parseJsonc(content, errors, { allowTrailingComma: true }); + // Comment-only / no JSON value: jsonc-parser returns undefined plus ValueExpected. + // That is empty config, not a partial tree. The data-loss bug is errors + object. + if (parsed === undefined) { + return {}; + } if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw codedError(formatJsoncParseError(filePath, errors), INVALID_JSONC); } @@ -603,20 +611,50 @@ const mergeConfigs = (base: Record, override: Record; + error: (Error & { code: string }) | null; +} => { + try { + return { config: readConfigFile(filePath), error: null }; + } catch (error) { + if (isInvalidJsoncError(error)) { + console.error(error.message); + return { config: {}, error }; + } + throw error; + } +}; + const readConfigLayers = (workingDirectory?: string) => { const { userPaths, projectPath, customPath } = getConfigPaths(workingDirectory); const userPath = getPrimaryUserConfigPath(userPaths); - const userConfig = readConfigFile(userPath); - const projectConfig = readConfigFile(projectPath); - const customConfig = readConfigFile(customPath); - const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig); + const userLayer = readConfigLayer(userPath); + const projectLayer = readConfigLayer(projectPath); + const customLayer = readConfigLayer(customPath); + const mergedConfig = mergeConfigs( + mergeConfigs(userLayer.config, projectLayer.config), + customLayer.config, + ); + + const layerErrors: Array<{ path: string; code: string; message: string }> = []; + if (userLayer.error) { + layerErrors.push({ path: userPath, code: userLayer.error.code, message: userLayer.error.message }); + } + if (projectLayer.error && projectPath) { + layerErrors.push({ path: projectPath, code: projectLayer.error.code, message: projectLayer.error.message }); + } + if (customLayer.error && customPath) { + layerErrors.push({ path: customPath, code: customLayer.error.code, message: customLayer.error.message }); + } return { - userConfig, - projectConfig, - customConfig, + userConfig: userLayer.config, + projectConfig: projectLayer.config, + customConfig: customLayer.config, mergedConfig, - paths: { userPath, projectPath, customPath } + paths: { userPath, projectPath, customPath }, + layerErrors, }; }; @@ -1402,22 +1440,45 @@ export const deleteMcpConfig = (name: string, workingDirectory?: string): void = writeConfig(config, targetPath); }; +const getLayerError = ( + layers: ReturnType, + filePath?: string | null, +) => { + if (!filePath) return null; + return layers.layerErrors.find((entry) => entry.path === filePath) || null; +}; + +const throwIfLayerError = ( + layers: ReturnType, + filePath?: string | null, +) => { + const failed = getLayerError(layers, filePath); + if (!failed) return; + throw codedError(failed.message, failed.code); +}; + const getJsonEntrySource = ( layers: ReturnType, sectionKey: 'agent' | 'command' | 'mcp', entryName: string ) => { const { userConfig, projectConfig, customConfig, paths } = layers; - const customSection = (customConfig as Record)?.[sectionKey] as Record | undefined; - if (customSection?.[entryName] !== undefined) { - return { section: customSection[entryName], config: customConfig, path: paths.customPath, exists: true }; + if (paths.customPath) { + throwIfLayerError(layers, paths.customPath); + const customSection = (customConfig as Record)?.[sectionKey] as Record | undefined; + if (customSection?.[entryName] !== undefined) { + return { section: customSection[entryName], config: customConfig, path: paths.customPath, exists: true }; + } } - const projectSection = (projectConfig as Record)?.[sectionKey] as Record | undefined; - if (projectSection?.[entryName] !== undefined) { - return { section: projectSection[entryName], config: projectConfig, path: paths.projectPath, exists: true }; + if (paths.projectPath && !getLayerError(layers, paths.projectPath)) { + const projectSection = (projectConfig as Record)?.[sectionKey] as Record | undefined; + if (projectSection?.[entryName] !== undefined) { + return { section: projectSection[entryName], config: projectConfig, path: paths.projectPath, exists: true }; + } } + throwIfLayerError(layers, paths.userPath); const userSection = (userConfig as Record)?.[sectionKey] as Record | undefined; if (userSection?.[entryName] !== undefined) { return { section: userSection[entryName], config: userConfig, path: paths.userPath, exists: true }; @@ -1432,11 +1493,14 @@ const getJsonWriteTarget = ( ) => { const { userConfig, projectConfig, customConfig, paths } = layers; if (paths.customPath) { + throwIfLayerError(layers, paths.customPath); return { config: customConfig, path: paths.customPath }; } if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) { + throwIfLayerError(layers, paths.projectPath); return { config: projectConfig, path: paths.projectPath }; } + throwIfLayerError(layers, paths.userPath); return { config: userConfig, path: paths.userPath }; }; diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index cbe9f6c8..cb35ed11 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -70,11 +70,11 @@ This module provides OpenCode server integration utilities for the web server ru - `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values. - `ensureDirs()`: Creates required OpenCode directories. - `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter. -- `getConfigPaths(workingDirectory)`, `readConfigLayers(workingDirectory)`, `readConfig(workingDirectory)`: Config file operations with layer merging (user, project, custom). -- `readConfigFile(filePath)`: Reads one config file. Empty/missing files return `{}`. Any `jsonc-parser` error or non-object result throws `INVALID_JSONC` — partial parse trees must never be treated as authoritative (avoids rewriting a `$schema`-only stub over a full config). +- `getConfigPaths(workingDirectory)`, `readConfigLayers(workingDirectory)`, `readConfig(workingDirectory)`: Config file operations with layer merging (user, project, custom). `readConfigLayers` isolates `INVALID_JSONC` per layer: a broken file is omitted from the merge (`{}` for that layer only), recorded on `layerErrors`, and does not block valid sibling layers. Writes still refuse to overwrite the broken file. +- `readConfigFile(filePath)`: Reads one config file. Missing, whitespace-only, and comment-only files (no JSON value) return `{}`. A `jsonc-parser` error that produces a partial or non-object tree throws `INVALID_JSONC` — partial parse trees must never be treated as authoritative (avoids rewriting a `$schema`-only stub over a full config). - `writeConfig(config, filePath)`: Writes config with automatic backup. Refuses to overwrite an existing non-empty file that fails the same JSONC parse check. -- `getJsonEntrySource(layers, sectionKey, entryName)`: Resolves which config layer provides an entry. -- `getJsonWriteTarget(layers, preferredScope)`: Determines write target for config updates. +- `getJsonEntrySource(layers, sectionKey, entryName)`: Resolves which config layer provides an entry. A failed custom or user layer throws `INVALID_JSONC` instead of treating that file as empty. A failed project layer is skipped so a valid user/custom entry can still be found. +- `getJsonWriteTarget(layers, preferredScope)`: Determines write target for config updates. Throws `INVALID_JSONC` when the chosen target file is the unparseable layer. - `getAncestors(startDir, stopDir)`, `findWorktreeRoot(startDir)`: Git worktree helpers. - `isPromptFileReference(value)`, `resolvePromptFilePath(reference)`, `writePromptFile(filePath, content)`: Prompt file reference handling. - `walkSkillMdFiles(rootDir)`: Recursively finds all SKILL.md files. diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js index f99360d8..87a105d4 100644 --- a/packages/web/server/lib/opencode/shared.js +++ b/packages/web/server/lib/opencode/shared.js @@ -185,6 +185,11 @@ function formatJsoncParseError(filePath, errors) { function parseConfigObject(content, filePath) { const errors = []; const parsed = parseJsonc(content, errors, { allowTrailingComma: true }); + // Comment-only / no JSON value: jsonc-parser returns undefined plus ValueExpected. + // That is empty config, not a partial tree. The data-loss bug is errors + object. + if (parsed === undefined) { + return {}; + } if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { const error = new Error(formatJsoncParseError(filePath, errors)); error.code = INVALID_JSONC; @@ -239,20 +244,47 @@ function mergeConfigs(base, override) { return result; } +function readConfigLayer(filePath) { + try { + return { config: readConfigFile(filePath), error: null }; + } catch (error) { + if (isInvalidJsoncError(error)) { + console.error(error.message); + return { config: {}, error }; + } + throw error; + } +} + function readConfigLayers(workingDirectory) { const { userPaths, projectPath, customPath } = getConfigPaths(workingDirectory); const userPath = getPrimaryUserConfigPath(userPaths); - const userConfig = readConfigFile(userPath); - const projectConfig = readConfigFile(projectPath); - const customConfig = readConfigFile(customPath); - const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig); + const userLayer = readConfigLayer(userPath); + const projectLayer = readConfigLayer(projectPath); + const customLayer = readConfigLayer(customPath); + const mergedConfig = mergeConfigs( + mergeConfigs(userLayer.config, projectLayer.config), + customLayer.config, + ); + + const layerErrors = []; + if (userLayer.error) { + layerErrors.push({ path: userPath, code: userLayer.error.code, message: userLayer.error.message }); + } + if (projectLayer.error && projectPath) { + layerErrors.push({ path: projectPath, code: projectLayer.error.code, message: projectLayer.error.message }); + } + if (customLayer.error && customPath) { + layerErrors.push({ path: customPath, code: customLayer.error.code, message: customLayer.error.message }); + } return { - userConfig, - projectConfig, - customConfig, + userConfig: userLayer.config, + projectConfig: projectLayer.config, + customConfig: customLayer.config, mergedConfig, - paths: { userPath, projectPath, customPath } + paths: { userPath, projectPath, customPath }, + layerErrors, }; } @@ -299,18 +331,41 @@ function writeConfig(config, filePath = CONFIG_FILE) { } } +function getLayerError(layers, filePath) { + if (!filePath || !Array.isArray(layers?.layerErrors)) { + return null; + } + return layers.layerErrors.find((entry) => entry.path === filePath) || null; +} + +function throwIfLayerError(layers, filePath) { + const failed = getLayerError(layers, filePath); + if (!failed) { + return; + } + const error = new Error(failed.message); + error.code = failed.code; + throw error; +} + function getJsonEntrySource(layers, sectionKey, entryName) { const { userConfig, projectConfig, customConfig, paths } = layers; - const customSection = customConfig?.[sectionKey]?.[entryName]; - if (customSection !== undefined) { - return { section: customSection, config: customConfig, path: paths.customPath, exists: true }; + if (paths.customPath) { + throwIfLayerError(layers, paths.customPath); + const customSection = customConfig?.[sectionKey]?.[entryName]; + if (customSection !== undefined) { + return { section: customSection, config: customConfig, path: paths.customPath, exists: true }; + } } - const projectSection = projectConfig?.[sectionKey]?.[entryName]; - if (projectSection !== undefined) { - return { section: projectSection, config: projectConfig, path: paths.projectPath, exists: true }; + if (paths.projectPath && !getLayerError(layers, paths.projectPath)) { + const projectSection = projectConfig?.[sectionKey]?.[entryName]; + if (projectSection !== undefined) { + return { section: projectSection, config: projectConfig, path: paths.projectPath, exists: true }; + } } + throwIfLayerError(layers, paths.userPath); const userSection = userConfig?.[sectionKey]?.[entryName]; if (userSection !== undefined) { return { section: userSection, config: userConfig, path: paths.userPath, exists: true }; @@ -322,11 +377,14 @@ function getJsonEntrySource(layers, sectionKey, entryName) { function getJsonWriteTarget(layers, preferredScope) { const { userConfig, projectConfig, customConfig, paths } = layers; if (paths.customPath) { + throwIfLayerError(layers, paths.customPath); return { config: customConfig, path: paths.customPath }; } if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) { + throwIfLayerError(layers, paths.projectPath); return { config: projectConfig, path: paths.projectPath }; } + throwIfLayerError(layers, paths.userPath); return { config: userConfig, path: paths.userPath }; } diff --git a/packages/web/server/lib/opencode/shared.test.js b/packages/web/server/lib/opencode/shared.test.js index a3e646b7..88384ae9 100644 --- a/packages/web/server/lib/opencode/shared.test.js +++ b/packages/web/server/lib/opencode/shared.test.js @@ -3,7 +3,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { parseMdFile, writeMdFile, readConfigFile, writeConfig } from './shared.js'; +import { parseMdFile, writeMdFile, readConfigFile, readConfigLayers, writeConfig } from './shared.js'; import { updateAgent } from './agents.js'; import { updateMcpConfig } from './mcp.js'; @@ -337,4 +337,40 @@ describe('readConfigFile / writeConfig JSONC safety (issue #2923)', () => { else process.env.OPENCODE_CONFIG = previousOpenCodeConfig; } }); + + it('returns an empty object for a comment-only config file', () => { + const file = writeFixture('comments.jsonc', '// placeholder\n/* still empty */\n'); + expect(readConfigFile(file)).toEqual({}); + }); + + it('keeps a valid custom layer readable when a project layer is unparseable', () => { + const custom = writeFixture('custom.jsonc', VALID_CONFIG); + const projectDir = path.join(FIXTURE_DIR, 'project'); + const projectFile = writeFixture(path.join('project', '.opencode', 'opencode.jsonc'), PARTIAL_PARSE_CONFIG); + const previousOpenCodeConfig = process.env.OPENCODE_CONFIG; + + try { + process.env.OPENCODE_CONFIG = custom; + const layers = readConfigLayers(projectDir); + expect(layers.customConfig.plugin).toEqual(['opencode-see-image']); + expect(layers.projectConfig).toEqual({}); + expect(layers.mergedConfig.plugin).toEqual(['opencode-see-image']); + expect(layers.layerErrors).toEqual([ + expect.objectContaining({ + path: projectFile, + code: 'INVALID_JSONC', + }), + ]); + + updateMcpConfig('openproject', { enabled: false }, projectDir); + const rewritten = JSON.parse(fs.readFileSync(custom, 'utf8')); + expect(rewritten.plugin).toEqual(['opencode-see-image']); + expect(rewritten.mcp.openproject.enabled).toBe(false); + expect(fs.readFileSync(projectFile, 'utf8')).toBe(PARTIAL_PARSE_CONFIG); + expect(fs.existsSync(`${projectFile}.openchamber.backup`)).toBe(false); + } finally { + if (previousOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG; + else process.env.OPENCODE_CONFIG = previousOpenCodeConfig; + } + }); }); From 6751c7dc7ad9c4e95272cd6495e37f314297b793 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 06:30:36 +0000 Subject: [PATCH 005/215] fix(config): isolate plugin list reads from a broken JSONC layer Plugin listing still called readConfigFile per layer, so one unparseable project file made GET /api/config/plugins and VS Code listPluginEntries fail. Reuse readConfigLayer isolation and pin comment-only empty parse in the VS Code suite. Co-authored-by: serkraser --- packages/vscode/src/DOCUMENTATION.md | 2 +- .../src/opencodeConfig.config-parse.test.ts | 25 ++++++++++++++++++- packages/vscode/src/opencodeConfig.ts | 6 ++--- .../web/server/lib/opencode/DOCUMENTATION.md | 1 + packages/web/server/lib/opencode/plugins.js | 15 ++++++++--- .../web/server/lib/opencode/plugins.test.js | 18 +++++++++++++ packages/web/server/lib/opencode/shared.js | 1 + 7 files changed, 60 insertions(+), 8 deletions(-) diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index fe638f97..f6345cae 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -58,7 +58,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r - `bridge-config-runtime.ts` - Config and skills message handlers (`api:config/*`). - Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`). - - OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, and writes still refuse to overwrite the broken file. + - OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, including plugin list/read via `getPluginConfigSources`. Writes still refuse to overwrite the broken file. - `bridge-settings-runtime.ts` - Settings read/write and OpenCode skills discovery via API for bridge consumers. diff --git a/packages/vscode/src/opencodeConfig.config-parse.test.ts b/packages/vscode/src/opencodeConfig.config-parse.test.ts index c12572e8..7c696696 100644 --- a/packages/vscode/src/opencodeConfig.config-parse.test.ts +++ b/packages/vscode/src/opencodeConfig.config-parse.test.ts @@ -4,7 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { updateMcpConfig } from './opencodeConfig'; +import { listPluginEntries, updateMcpConfig } from './opencodeConfig'; const PARTIAL_PARSE_CONFIG = [ '{', @@ -94,6 +94,29 @@ describe('opencodeConfig JSONC parse safety (issue #2923)', () => { assert.equal(fs.readFileSync(`${configPath}.openchamber.backup`, 'utf8'), VALID_CONFIG); }); + test('returns an empty object for a comment-only config file', () => { + const configPath = path.join(tempDir, 'comments.jsonc'); + fs.writeFileSync(configPath, '// placeholder\n/* still empty */\n', 'utf8'); + process.env.OPENCODE_CONFIG = configPath; + + assert.deepEqual(listPluginEntries(), []); + }); + + test('lists custom-layer plugins when a project layer is unparseable', () => { + const customPath = path.join(tempDir, 'custom.jsonc'); + const projectDir = path.join(tempDir, 'project'); + const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc'); + fs.writeFileSync(customPath, VALID_CONFIG, 'utf8'); + fs.mkdirSync(path.dirname(projectFile), { recursive: true }); + fs.writeFileSync(projectFile, PARTIAL_PARSE_CONFIG, 'utf8'); + process.env.OPENCODE_CONFIG = customPath; + + const specs = listPluginEntries(projectDir).map((entry) => entry.spec); + assert.deepEqual(specs, ['opencode-see-image']); + assert.equal(fs.readFileSync(projectFile, 'utf8'), PARTIAL_PARSE_CONFIG); + assert.equal(fs.existsSync(`${projectFile}.openchamber.backup`), false); + }); + test('keeps a valid custom layer writable when a project layer is unparseable', () => { const customPath = path.join(tempDir, 'custom.jsonc'); const projectDir = path.join(tempDir, 'project'); diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 50a40082..66142c36 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -926,10 +926,10 @@ const getPluginConfigSources = (workingDirectory?: string | null): Array<{ scope const projectPath = getProjectConfigPath(workingDirectory || undefined); return [ customPath - ? { scope: 'user', path: customPath, config: readConfigFile(customPath) } - : { scope: 'user', path: userPath, config: readConfigFile(userPath) }, + ? { scope: 'user', path: customPath, config: readConfigLayer(customPath).config } + : { scope: 'user', path: userPath, config: readConfigLayer(userPath).config }, ...(projectPath - ? [{ scope: 'project' as const, path: projectPath, config: readConfigFile(projectPath) }] + ? [{ scope: 'project' as const, path: projectPath, config: readConfigLayer(projectPath).config }] : []), ]; }; diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index cb35ed11..e783bb36 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -72,6 +72,7 @@ This module provides OpenCode server integration utilities for the web server ru - `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter. - `getConfigPaths(workingDirectory)`, `readConfigLayers(workingDirectory)`, `readConfig(workingDirectory)`: Config file operations with layer merging (user, project, custom). `readConfigLayers` isolates `INVALID_JSONC` per layer: a broken file is omitted from the merge (`{}` for that layer only), recorded on `layerErrors`, and does not block valid sibling layers. Writes still refuse to overwrite the broken file. - `readConfigFile(filePath)`: Reads one config file. Missing, whitespace-only, and comment-only files (no JSON value) return `{}`. A `jsonc-parser` error that produces a partial or non-object tree throws `INVALID_JSONC` — partial parse trees must never be treated as authoritative (avoids rewriting a `$schema`-only stub over a full config). +- `readConfigLayer(filePath)`: Same parse as `readConfigFile`, but isolates `INVALID_JSONC` to `{ config: {}, error }` so plugin/MCP/agent readers can skip one broken layer without aborting valid siblings. Writes still refuse to overwrite the broken file. - `writeConfig(config, filePath)`: Writes config with automatic backup. Refuses to overwrite an existing non-empty file that fails the same JSONC parse check. - `getJsonEntrySource(layers, sectionKey, entryName)`: Resolves which config layer provides an entry. A failed custom or user layer throws `INVALID_JSONC` instead of treating that file as empty. A failed project layer is skipped so a valid user/custom entry can still be found. - `getJsonWriteTarget(layers, preferredScope)`: Determines write target for config updates. Throws `INVALID_JSONC` when the chosen target file is the unparseable layer. diff --git a/packages/web/server/lib/opencode/plugins.js b/packages/web/server/lib/opencode/plugins.js index 3eebfce9..143298fd 100644 --- a/packages/web/server/lib/opencode/plugins.js +++ b/packages/web/server/lib/opencode/plugins.js @@ -4,6 +4,7 @@ import path from 'path'; import { AGENT_SCOPE, readConfigFile, + readConfigLayer, writeConfig, } from './shared.js'; import { isPathSpec } from './plugin-spec.js'; @@ -111,15 +112,23 @@ function readPluginConfigLayers(workingDirectory) { const customPath = getActiveCustomConfigPath(); const userPath = getPrimaryUserConfigPath(); const projectPath = getProjectConfigPath(workingDirectory); + const userLayer = readConfigLayer(userPath); + const projectLayer = readConfigLayer(projectPath); + const customLayer = readConfigLayer(customPath); return { - userConfig: readConfigFile(userPath), - projectConfig: readConfigFile(projectPath), - customConfig: readConfigFile(customPath), + userConfig: userLayer.config, + projectConfig: projectLayer.config, + customConfig: customLayer.config, paths: { userPath, projectPath, customPath, }, + layerErrors: [ + userLayer.error && { path: userPath, code: userLayer.error.code, message: userLayer.error.message }, + projectLayer.error && projectPath && { path: projectPath, code: projectLayer.error.code, message: projectLayer.error.message }, + customLayer.error && customPath && { path: customPath, code: customLayer.error.code, message: customLayer.error.message }, + ].filter(Boolean), }; } diff --git a/packages/web/server/lib/opencode/plugins.test.js b/packages/web/server/lib/opencode/plugins.test.js index 1e624445..974b788a 100644 --- a/packages/web/server/lib/opencode/plugins.test.js +++ b/packages/web/server/lib/opencode/plugins.test.js @@ -121,6 +121,24 @@ describe('opencode plugins data layer', () => { expect(readJson(userConfigPath)).toEqual({}); }); + test('lists user plugins when a project layer is unparseable', () => { + const partialProject = [ + '{', + ' "$schema": "https://opencode.ai/config.json",', + ' plugin: ["broken-project-plugin"],', + '}', + '', + ].join('\n'); + writeJson(userConfigPath, { plugin: ['user-plugin'] }); + const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc'); + fs.mkdirSync(path.dirname(projectFile), { recursive: true }); + fs.writeFileSync(projectFile, partialProject, 'utf8'); + + expect(plugins.listPluginEntries(projectDir).map((entry) => entry.spec)).toEqual(['user-plugin']); + expect(fs.readFileSync(projectFile, 'utf8')).toBe(partialProject); + expect(fs.existsSync(`${projectFile}.openchamber.backup`)).toBe(false); + }); + test('lists entries from user and project layers with scopes and parsed kinds', () => { writeJson(userConfigPath, { plugin: ['npm-plugin', '/abs/plugin.js', '@scope/pkg@1.0.0'] }); writeJson(path.join(projectDir, '.opencode', 'opencode.json'), { plugin: ['./local-plugin.js'] }); diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js index 87a105d4..bc90ff31 100644 --- a/packages/web/server/lib/opencode/shared.js +++ b/packages/web/server/lib/opencode/shared.js @@ -644,6 +644,7 @@ export { parseMdFile, writeMdFile, readConfigFile, + readConfigLayer, isPlainObject, readConfigLayers, readConfig, From 9e1a9b59b1d09212d5b7330b2c274a12a458a618 Mon Sep 17 00:00:00 2001 From: dibanez Date: Fri, 14 Aug 2026 23:06:56 +0200 Subject: [PATCH 006/215] fix(ui): stop the context meter from counting every internal round-trip The token breakdown of an assistant message accumulates across every API round-trip inside the turn: each tool call re-reads the whole cached prompt, so input/cache.read add up to several times the context window. Every context-usage surface summed those fields, which is why the meter could read 330% of a 1M window whose real fill was 232,872 tokens (23.3%), and why reopening an older session jumps the readout (#2562). The server reports the final round-trip's window as tokens.total (optional in the message schema; opencode 1.18.18 returns it, verified against its live /session/:id/message API). Prefer it everywhere the window fill is displayed and fall back to summing only when the server did not send it: contextTokensFromBreakdown in tokenUtils now owns that rule, and the context store extractor, sync store getter, work status panel, context sidebar, VS Code layout, mini chat, and mobile metadata all use it instead of their own inline sums. Fixes #2562 --- CHANGELOG.md | 1 + .../ui/src/apps/MobileSessionMetadata.tsx | 6 ++ .../chat/work-status/contextUsage.test.ts | 24 ++++++- .../chat/work-status/contextUsage.ts | 20 +++--- .../components/layout/ContextSidebarTab.tsx | 7 +- .../ui/src/components/layout/VSCodeLayout.tsx | 5 +- .../components/mini-chat/MiniChatLayout.tsx | 7 +- .../ui/src/stores/utils/tokenUtils.test.ts | 72 ++++++++++++++++++- packages/ui/src/stores/utils/tokenUtils.ts | 31 +++++++- packages/ui/src/sync/session-ui-store.ts | 7 +- packages/vscode/CHANGELOG.md | 1 + 11 files changed, 156 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46731e19..6f206cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - 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. - Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). - Chat: saved chats in the context panel open again instead of staying blank. +- Chat: the context meter no longer climbs over 100% (330% readouts) after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds, everywhere the value appears — header, context sidebar, work status panel, mini chat, and mobile. - Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude` in the sidebar, window title, settings and notifications; names you renamed yourself are kept. - Settings: the session retention action you pick is now saved instead of being dropped (thanks to @Gautam0507). - Browser: typing a comment on a page no longer triggers app shortcuts. diff --git a/packages/ui/src/apps/MobileSessionMetadata.tsx b/packages/ui/src/apps/MobileSessionMetadata.tsx index 9ffe0d66..3d239d1f 100644 --- a/packages/ui/src/apps/MobileSessionMetadata.tsx +++ b/packages/ui/src/apps/MobileSessionMetadata.tsx @@ -388,6 +388,7 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & { tokens?: { + total?: unknown; input?: unknown; output?: unknown; reasoning?: unknown; @@ -395,6 +396,11 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta }; }; if (message.role !== 'assistant' || !message.tokens) continue; + // Multi-step turns accumulate the fields across API round-trips, so + // summing them overstates the window. The server-reported total is the + // final round-trip's window; sum only when the server did not send it. + const reportedTotal = getTokenCount(message.tokens.total); + if (reportedTotal > 0) return reportedTotal; const total = getTokenCount(message.tokens.input) + getTokenCount(message.tokens.output) + getTokenCount(message.tokens.reasoning) diff --git a/packages/ui/src/components/chat/work-status/contextUsage.test.ts b/packages/ui/src/components/chat/work-status/contextUsage.test.ts index c2e0a6e4..34f538f6 100644 --- a/packages/ui/src/components/chat/work-status/contextUsage.test.ts +++ b/packages/ui/src/components/chat/work-status/contextUsage.test.ts @@ -14,8 +14,8 @@ describe('computeContextUsage', () => { }); test('reports the latest turn rather than a sum across turns', () => { - // Each assistant turn reports the whole window it saw, so adding them up - // would report several times the real fill. + // A turn's tokens describe that turn's window, so adding turns up would + // report several times the real fill. const usage = computeContextUsage( [ assistant({ input: 400, output: 0, reasoning: 0 }, 'old'), @@ -61,4 +61,24 @@ describe('computeContextUsage', () => { const usage = computeContextUsage([assistant({ input: 10 })], 100); expect(usage?.totalTokens).toBe(10); }); + + test('prefers the server-reported total over summing round-trip fields', () => { + // Real payload from opencode 1.18.18: ~14 tool-call round-trips accumulated + // cache.read to 3.29M while the 1M window really held 232,872. Summing + // rendered 330.6%; the reported total renders the real 23.3%. + const usage = computeContextUsage( + [assistant({ total: 232_872, input: 0, output: 14_523, reasoning: 0, cache: { read: 3_291_956, write: 0 } })], + 1_000_000, + ); + expect(usage?.totalTokens).toBe(232_872); + expect(usage?.percent.toFixed(4)).toBe('23.2872'); + }); + + test('selects a message whose only signal is the reported total', () => { + const usage = computeContextUsage( + [assistant({ total: 5_000, input: 0, output: 0, reasoning: 0 })], + 100_000, + ); + expect(usage?.totalTokens).toBe(5_000); + }); }); diff --git a/packages/ui/src/components/chat/work-status/contextUsage.ts b/packages/ui/src/components/chat/work-status/contextUsage.ts index c30d57f6..920d6963 100644 --- a/packages/ui/src/components/chat/work-status/contextUsage.ts +++ b/packages/ui/src/components/chat/work-status/contextUsage.ts @@ -13,7 +13,11 @@ * global read to race with. */ +import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils'; + type MessageTokens = { + /** Server-reported window of the turn's final round-trip; absent on older servers. */ + total?: number; input?: number; output?: number; reasoning?: number; @@ -37,18 +41,12 @@ type WorkStatusContextUsage = { /** The store's own fallback when a model exposes no context limit. */ export const DEFAULT_CONTEXT_LIMIT = 200_000; -const sumTokens = (tokens: MessageTokens): number => ( - (tokens.input ?? 0) - + (tokens.output ?? 0) - + (tokens.reasoning ?? 0) - + (tokens.cache?.read ?? 0) - + (tokens.cache?.write ?? 0) -); - /** * Usage from the newest assistant message that reported a non-zero token count. - * Each assistant turn reports the whole window it saw, so the latest one is the - * current fill — not a sum across turns. + * The latest turn describes the current fill — not a sum across turns. Within + * a turn, the server-reported `total` is the final round-trip's window; + * summing the breakdown fields instead overstates multi-step turns, whose + * input/cache fields accumulate across round-trips. */ export const computeContextUsage = ( messages: readonly MessageLike[], @@ -60,7 +58,7 @@ export const computeContextUsage = ( const message = messages[index]; if (message?.role !== 'assistant' || !message.tokens) continue; - const totalTokens = sumTokens(message.tokens); + const totalTokens = contextTokensFromBreakdown(message.tokens); if (totalTokens <= 0) continue; const limit = contextLimit > 0 ? contextLimit : DEFAULT_CONTEXT_LIMIT; diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx index 1e08bd0b..3f41e331 100644 --- a/packages/ui/src/components/layout/ContextSidebarTab.tsx +++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx @@ -92,6 +92,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => { } const breakdown = source as { + total?: unknown; input?: unknown; output?: unknown; reasoning?: unknown; @@ -103,6 +104,10 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => { const reasoning = toNonNegativeNumber(breakdown.reasoning); const cacheRead = toNonNegativeNumber(breakdown.cache?.read); const cacheWrite = toNonNegativeNumber(breakdown.cache?.write); + // Multi-step turns accumulate the fields across API round-trips (every tool + // call re-reads the whole cached prompt), so summing them overstates the + // window. The server-reported total is the final round-trip's window. + const reportedTotal = toNonNegativeNumber(breakdown.total); return { input, @@ -110,7 +115,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => { reasoning, cacheRead, cacheWrite, - total: input + output + reasoning + cacheRead + cacheWrite, + total: reportedTotal > 0 ? reportedTotal : input + output + reasoning + cacheRead + cacheWrite, }; }; diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index de1f1772..0b592fe0 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -8,6 +8,7 @@ import { useViewportStore } from '@/sync/viewport-store'; import { useSessions, useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context'; import { useConfigStore } from '@/stores/useConfigStore'; import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { McpDropdown } from '@/components/mcp/McpDropdown'; import { ArchiveAllDropdown } from '@/components/session/ArchiveAllDropdown'; @@ -702,7 +703,7 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on } if (!lastTokens && message.tokens) { - const total = message.tokens.input + message.tokens.output + message.tokens.reasoning + (message.tokens.cache?.read ?? 0) + (message.tokens.cache?.write ?? 0); + const total = contextTokensFromBreakdown(message.tokens); if (total > 0) { lastTokens = message.tokens; lastMessageId = (currentSessionMessages[i] as { id?: string }).id; @@ -730,7 +731,7 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on } const lastTokens = headerMessageSummary.lastTokens; - const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0); + const totalTokens = contextTokensFromBreakdown(lastTokens); const thresholdLimit = contextLimit > 0 ? contextLimit : 200000; const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0; const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined; diff --git a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx index 4eea6d8f..a32779a1 100644 --- a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx +++ b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx @@ -18,6 +18,7 @@ import { useGitBranchLabel, useGitStore } from '@/stores/useGitStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { Icon } from "@/components/icon/Icon"; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; type MiniChatMode = 'session' | 'draft'; @@ -157,7 +158,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { return null; } - type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } }; + type AssistantTokens = { total?: number; input: number; output: number; reasoning: number; cache: { read: number; write: number } }; let lastTokens: AssistantTokens | undefined; let lastMessageId: string | undefined; @@ -166,7 +167,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { if (message.role !== 'assistant') continue; const tokens = (message as { tokens?: AssistantTokens }).tokens; if (!tokens) continue; - const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0); + const total = contextTokensFromBreakdown(tokens); if (total > 0) { lastTokens = tokens; lastMessageId = message.id; @@ -178,7 +179,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { return null; } - const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0); + const totalTokens = contextTokensFromBreakdown(lastTokens); const thresholdLimit = contextLimit > 0 ? contextLimit : 200000; const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0; const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined; diff --git a/packages/ui/src/stores/utils/tokenUtils.test.ts b/packages/ui/src/stores/utils/tokenUtils.test.ts index 63d8cf6d..494fa711 100644 --- a/packages/ui/src/stores/utils/tokenUtils.test.ts +++ b/packages/ui/src/stores/utils/tokenUtils.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test" -import { computeCacheHitRate, sumTokenBreakdown } from "./tokenUtils" +import type { Message, Part } from "@opencode-ai/sdk/v2" +import { computeCacheHitRate, contextTokensFromBreakdown, extractTokensFromMessage, sumTokenBreakdown } from "./tokenUtils" + +const assistantMessage = (tokens: unknown): { info: Message; parts: Part[] } => ({ + info: { tokens } as unknown as Message, + parts: [], +}) describe("computeCacheHitRate", () => { test("returns zero and hasInput=false for null input", () => { @@ -95,3 +101,67 @@ describe("sumTokenBreakdown (regression)", () => { expect(sumTokenBreakdown(undefined)).toBe(0) }) }) + +describe("contextTokensFromBreakdown", () => { + test("prefers the server-reported total over the summed fields", () => { + const breakdown = { total: 500, input: 100, output: 50, reasoning: 20, cache: { read: 800, write: 20 } } + expect(contextTokensFromBreakdown(breakdown)).toBe(500) + }) + + test("real multi-step turn: summing overstates a 1M window 14x, the total matches it", () => { + // Captured from opencode 1.18.18 (/session/:id/message) after a turn with + // ~14 tool-call round-trips. Every round-trip re-reads the whole cached + // prompt, so cache.read accumulates to 3.29M while the window really held + // 232,872. Summing rendered the context meter at 330.6% of a 1M window. + const breakdown = { total: 232_872, input: 0, output: 14_523, reasoning: 0, cache: { read: 3_291_956, write: 0 } } + expect(contextTokensFromBreakdown(breakdown)).toBe(232_872) + expect(sumTokenBreakdown(breakdown)).toBe(3_306_479) + }) + + test("single-step turn: the total and the summed fields agree", () => { + // Captured from the same server: one round-trip, nothing accumulates. + const breakdown = { total: 117_714, input: 1_116, output: 87, reasoning: 543, cache: { read: 115_968, write: 0 } } + expect(contextTokensFromBreakdown(breakdown)).toBe(sumTokenBreakdown(breakdown)) + }) + + test("falls back to summing when the server sends no total (older servers)", () => { + expect(contextTokensFromBreakdown({ input: 100, output: 50, reasoning: 20, cache: { read: 80, write: 20 } })).toBe(270) + }) + + test("falls back to summing when the total is zero or not a finite number", () => { + expect(contextTokensFromBreakdown({ total: 0, input: 40 })).toBe(40) + expect(contextTokensFromBreakdown({ total: Number.NaN, input: 40 })).toBe(40) + }) + + test("handles null and undefined", () => { + expect(contextTokensFromBreakdown(null)).toBe(0) + expect(contextTokensFromBreakdown(undefined)).toBe(0) + }) +}) + +describe("extractTokensFromMessage", () => { + test("uses the reported total from the message info breakdown", () => { + const message = assistantMessage({ total: 232_872, input: 0, output: 14_523, reasoning: 0, cache: { read: 3_291_956, write: 0 } }) + expect(extractTokensFromMessage(message)).toBe(232_872) + }) + + test("sums the info breakdown when no total is reported", () => { + expect(extractTokensFromMessage(assistantMessage({ input: 100, output: 50, reasoning: 20, cache: { read: 80, write: 20 } }))).toBe(270) + }) + + test("returns plain numeric tokens as-is", () => { + expect(extractTokensFromMessage(assistantMessage(1234))).toBe(1234) + }) + + test("prefers the reported total when tokens live on a part", () => { + const message: { info: Message; parts: Part[] } = { + info: {} as Message, + parts: [{ tokens: { total: 500, input: 2_000 } } as unknown as Part], + } + expect(extractTokensFromMessage(message)).toBe(500) + }) + + test("returns 0 when neither info nor parts carry tokens", () => { + expect(extractTokensFromMessage({ info: {} as Message, parts: [] })).toBe(0) + }) +}) diff --git a/packages/ui/src/stores/utils/tokenUtils.ts b/packages/ui/src/stores/utils/tokenUtils.ts index 4b944449..0f89ec3b 100644 --- a/packages/ui/src/stores/utils/tokenUtils.ts +++ b/packages/ui/src/stores/utils/tokenUtils.ts @@ -1,6 +1,8 @@ import type { Message, Part } from "@opencode-ai/sdk/v2"; type TokenBreakdown = { + /** Server-reported window of the turn's final round-trip. Optional in the schema; absent on older servers. */ + total?: number; input?: number; output?: number; reasoning?: number; @@ -24,6 +26,31 @@ export const sumTokenBreakdown = (breakdown: TokenBreakdown | null | undefined): return inputTokens + outputTokens + reasoningTokens + cacheReadTokens + cacheWriteTokens; }; +/** + * Tokens the context window actually holds, from one message's token payload. + * + * The breakdown fields accumulate across every API round-trip inside a single + * assistant turn: each tool call re-reads the whole (cached) prompt, so on a + * multi-step turn `cache.read` alone can add up to several times the context + * window (observed on opencode 1.18.18: cache.read 3,291,956 on a turn whose + * 1M window really held 232,872 — rendered as a 330% context readout). The + * server reports the final round-trip's window as `tokens.total` (optional in + * the message schema, absent on older servers). Prefer it; fall back to + * summing the fields only when the server did not send it. + */ +export const contextTokensFromBreakdown = (breakdown: TokenBreakdown | null | undefined): number => { + if (!breakdown || typeof breakdown !== 'object') { + return 0; + } + + const reportedTotal = breakdown.total; + if (typeof reportedTotal === 'number' && Number.isFinite(reportedTotal) && reportedTotal > 0) { + return reportedTotal; + } + + return sumTokenBreakdown(breakdown); +}; + export const extractTokensFromMessage = (message: { info: Message; parts: Part[] }): number => { const tokens = (message.info as { tokens?: number | TokenBreakdown }).tokens; @@ -32,7 +59,7 @@ export const extractTokensFromMessage = (message: { info: Message; parts: Part[] } if (tokens && typeof tokens === 'object') { - return sumTokenBreakdown(tokens); + return contextTokensFromBreakdown(tokens); } const tokenPart = message.parts.find( @@ -47,7 +74,7 @@ export const extractTokensFromMessage = (message: { info: Message; parts: Part[] return tokenPart.tokens; } - return sumTokenBreakdown(tokenPart.tokens); + return contextTokensFromBreakdown(tokenPart.tokens); }; type CacheHitRateResult = { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 0fc4ebb1..6df5d456 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -86,6 +86,7 @@ import { getRuntimeKey } from "@/lib/runtime-switch" import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache" import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache" import { rememberRuntimeLiveStatus } from "./runtime-live-memory" +import { contextTokensFromBreakdown } from "@/stores/utils/tokenUtils" export type { AttachedFile } @@ -1173,7 +1174,7 @@ export const useSessionUIStore = create()((set, get) => ({ const messages = getSyncMessages(sessionId) if (messages.length === 0) return null - type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } } + type AssistantTokens = { total?: number; input: number; output: number; reasoning: number; cache: { read: number; write: number } } let lastTokens: AssistantTokens | undefined let lastMessageId: string | undefined for (let i = messages.length - 1; i >= 0; i--) { @@ -1181,7 +1182,7 @@ export const useSessionUIStore = create()((set, get) => ({ if (msg.role !== "assistant") continue const tokens = (msg as { tokens?: AssistantTokens }).tokens if (!tokens) continue - const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0) + const total = contextTokensFromBreakdown(tokens) if (total > 0) { lastTokens = tokens lastMessageId = msg.id @@ -1191,7 +1192,7 @@ export const useSessionUIStore = create()((set, get) => ({ if (!lastTokens) return null - const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0) + const totalTokens = contextTokensFromBreakdown(lastTokens) const thresholdLimit = contextLimit > 0 ? contextLimit : 200000 const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0 const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 44f43241..78719fbe 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -2,6 +2,7 @@ - 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. - Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). +- The context usage readout no longer climbs over 100% after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds. - Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude`; names you renamed yourself are kept. - Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech). From 450d30b3eef91803a6a895687ba37bdb28cd01a1 Mon Sep 17 00:00:00 2001 From: dibanez Date: Fri, 14 Aug 2026 23:34:03 +0200 Subject: [PATCH 007/215] docs(changelog): credit the context meter fix contributor Requested by review: the changelog-authoring skill requires inline contributor credit for non-owner contributors. --- CHANGELOG.md | 2 +- packages/vscode/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f206cb6..abbf518d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to this project will be documented in this file. - 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. - Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). - Chat: saved chats in the context panel open again instead of staying blank. -- Chat: the context meter no longer climbs over 100% (330% readouts) after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds, everywhere the value appears — header, context sidebar, work status panel, mini chat, and mobile. +- Chat: the context meter no longer climbs over 100% (330% readouts) after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds, everywhere the value appears — header, context sidebar, work status panel, mini chat, and mobile (thanks to @pocharlies). - Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude` in the sidebar, window title, settings and notifications; names you renamed yourself are kept. - Settings: the session retention action you pick is now saved instead of being dropped (thanks to @Gautam0507). - Browser: typing a comment on a page no longer triggers app shortcuts. diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 78719fbe..fefde588 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -2,7 +2,7 @@ - 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. - Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). -- The context usage readout no longer climbs over 100% after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds. +- The context usage readout no longer climbs over 100% after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds (thanks to @pocharlies). - Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude`; names you renamed yourself are kept. - Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech). From 1c76dbefe40f320e9bb318be4b592711b5e2c161 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Mon, 17 Aug 2026 14:24:39 +0300 Subject: [PATCH 008/215] fix(chat): defer composer value writeback during IME composition (Fixes #2527) (#2691) * fix(chat): defer composer value writeback during IME composition The controlled-writeback effect compared the value prop against the CodeMirror document and, on mismatch, dispatched a wholesale replacement with the caret forced to the end. While the browser composes (pinyin, kana, hangul) the uncommitted text lives in the DOM, not in the document, so the mismatch is expected and the dispatch interrupted the IME session and jumped the cursor. Skip the writeback while the view is composing, using CodeMirror's public compositionStarted getter; the composition commits through its own pipeline and reports via onChange. Fixes #2527 * fix(chat): preserve external composer writes during IME * fix(chat): restore composition-wide writeback guard --------- Co-authored-by: Bohdan Triapitsyn --- .../chat/composer/editor/ComposerEditor.tsx | 4 +++ .../writebackCompositionGuard.test.ts | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 packages/ui/src/components/chat/composer/editor/__tests__/writebackCompositionGuard.test.ts diff --git a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx index 41a2825f..c248a53f 100644 --- a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx +++ b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx @@ -344,6 +344,10 @@ export const ComposerEditor = React.forwardRef { + const start = composerEditorSource.indexOf('// Controlled value:'); + expect(start).toBeGreaterThan(-1); + const end = composerEditorSource.indexOf('}, [value]);', start); + expect(end).toBeGreaterThan(start); + return composerEditorSource.slice(start, end); +}; + +describe('composer value writeback composition guard (issue #2527)', () => { + test('checks equality, then composition, before dispatching', () => { + const effect = writebackEffect(); + const equalityCheck = effect.indexOf('if (current === value) return;'); + const compositionGuard = effect.indexOf('if (view.compositionStarted) return;'); + const dispatch = effect.indexOf('view.dispatch({'); + + expect(equalityCheck).toBeGreaterThan(-1); + expect(compositionGuard).toBeGreaterThan(equalityCheck); + expect(dispatch).toBeGreaterThan(compositionGuard); + }); +}); From 6d6ece685627b3e6ded9439be5ac88c33b3eb5dd Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Mon, 17 Aug 2026 16:29:12 +0300 Subject: [PATCH 009/215] fix(config): fail closed when config content yields no JSON value Treating every undefined parse as empty config let a file that is not JSON at all (YAML, plain text) read as {}, so a later write would back it up and replace it - the same data loss this fix is meant to prevent. Only a comment-only parse, where ValueExpected is the sole error, counts as empty. --- packages/vscode/src/DOCUMENTATION.md | 2 +- .../src/opencodeConfig.config-parse.test.ts | 28 +++++++++++++------ packages/vscode/src/opencodeConfig.ts | 11 ++++++-- .../web/server/lib/opencode/DOCUMENTATION.md | 2 +- packages/web/server/lib/opencode/shared.js | 12 ++++++-- .../web/server/lib/opencode/shared.test.js | 10 +++++++ 6 files changed, 49 insertions(+), 16 deletions(-) diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index f6345cae..e3447e21 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -58,7 +58,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r - `bridge-config-runtime.ts` - Config and skills message handlers (`api:config/*`). - Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`). - - OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, including plugin list/read via `getPluginConfigSources`. Writes still refuse to overwrite the broken file. + - OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty, while other content that yields no JSON value (YAML, plain text) fails closed. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, including plugin list/read via `getPluginConfigSources`. Writes still refuse to overwrite the broken file. - `bridge-settings-runtime.ts` - Settings read/write and OpenCode skills discovery via API for bridge consumers. diff --git a/packages/vscode/src/opencodeConfig.config-parse.test.ts b/packages/vscode/src/opencodeConfig.config-parse.test.ts index 7c696696..74d6d737 100644 --- a/packages/vscode/src/opencodeConfig.config-parse.test.ts +++ b/packages/vscode/src/opencodeConfig.config-parse.test.ts @@ -48,6 +48,14 @@ const VALID_CONFIG = [ '', ].join('\n'); +const isInvalidJsoncError = (error: unknown): boolean => { + if (!(error instanceof Error) || !/cannot be loaded safely/.test(error.message)) { + return false; + } + // SAFETY: the config layer throws Error instances carrying the coded `code` field. + return (error as Error & { code?: string }).code === 'INVALID_JSONC'; +}; + describe('opencodeConfig JSONC parse safety (issue #2923)', () => { let tempDir: string; let previousOpenCodeConfig: string | undefined; @@ -68,14 +76,7 @@ describe('opencodeConfig JSONC parse safety (issue #2923)', () => { fs.writeFileSync(configPath, PARTIAL_PARSE_CONFIG, 'utf8'); process.env.OPENCODE_CONFIG = configPath; - assert.throws( - () => updateMcpConfig('openproject', { enabled: true }), - (error: unknown) => ( - error instanceof Error - && /cannot be loaded safely/.test(error.message) - && (error as Error & { code?: string }).code === 'INVALID_JSONC' - ), - ); + assert.throws(() => updateMcpConfig('openproject', { enabled: true }), isInvalidJsoncError); assert.equal(fs.readFileSync(configPath, 'utf8'), PARTIAL_PARSE_CONFIG); assert.equal(fs.existsSync(`${configPath}.openchamber.backup`), false); }); @@ -102,6 +103,17 @@ describe('opencodeConfig JSONC parse safety (issue #2923)', () => { assert.deepEqual(listPluginEntries(), []); }); + test('refuses MCP updates against content that yields no JSON value at all', () => { + const configPath = path.join(tempDir, 'yamlish.jsonc'); + const contents = 'mcp:\n openproject:\n type: remote\n'; + fs.writeFileSync(configPath, contents, 'utf8'); + process.env.OPENCODE_CONFIG = configPath; + + assert.throws(() => updateMcpConfig('openproject', { enabled: true }), isInvalidJsoncError); + assert.equal(fs.readFileSync(configPath, 'utf8'), contents); + assert.equal(fs.existsSync(`${configPath}.openchamber.backup`), false); + }); + test('lists custom-layer plugins when a project layer is unparseable', () => { const customPath = path.join(tempDir, 'custom.jsonc'); const projectDir = path.join(tempDir, 'project'); diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 66142c36..78297fc6 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -567,12 +567,17 @@ const formatJsoncParseError = (filePath: string, errors: ParseError[]): string = const isInvalidJsoncError = (error: unknown): error is Error & { code: string } => Boolean(error && typeof error === 'object' && 'code' in error && error.code === INVALID_JSONC); +// Comment-only / whitespace-only files parse to undefined with nothing but +// ValueExpected. Any other error means real content we failed to understand +// (YAML, plain text, a stray leading token), which must not read as empty. +const isCommentOnlyParse = (parsed: unknown, errors: ParseError[]): boolean => + parsed === undefined + && errors.every((entry) => printParseErrorCode(entry.error) === 'ValueExpected'); + const parseConfigObject = (content: string, filePath: string): Record => { const errors: ParseError[] = []; const parsed = parseJsonc(content, errors, { allowTrailingComma: true }); - // Comment-only / no JSON value: jsonc-parser returns undefined plus ValueExpected. - // That is empty config, not a partial tree. The data-loss bug is errors + object. - if (parsed === undefined) { + if (isCommentOnlyParse(parsed, errors)) { return {}; } if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index e783bb36..de3afd8d 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -71,7 +71,7 @@ This module provides OpenCode server integration utilities for the web server ru - `ensureDirs()`: Creates required OpenCode directories. - `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter. - `getConfigPaths(workingDirectory)`, `readConfigLayers(workingDirectory)`, `readConfig(workingDirectory)`: Config file operations with layer merging (user, project, custom). `readConfigLayers` isolates `INVALID_JSONC` per layer: a broken file is omitted from the merge (`{}` for that layer only), recorded on `layerErrors`, and does not block valid sibling layers. Writes still refuse to overwrite the broken file. -- `readConfigFile(filePath)`: Reads one config file. Missing, whitespace-only, and comment-only files (no JSON value) return `{}`. A `jsonc-parser` error that produces a partial or non-object tree throws `INVALID_JSONC` — partial parse trees must never be treated as authoritative (avoids rewriting a `$schema`-only stub over a full config). +- `readConfigFile(filePath)`: Reads one config file. Missing, whitespace-only, and comment-only files return `{}`; a comment-only file is recognized by `ValueExpected` being the only parse error. A `jsonc-parser` error that produces a partial or non-object tree throws `INVALID_JSONC` — partial parse trees must never be treated as authoritative (avoids rewriting a `$schema`-only stub over a full config). Content that yields no JSON value for any other reason (YAML, plain text) also throws instead of reading as empty. - `readConfigLayer(filePath)`: Same parse as `readConfigFile`, but isolates `INVALID_JSONC` to `{ config: {}, error }` so plugin/MCP/agent readers can skip one broken layer without aborting valid siblings. Writes still refuse to overwrite the broken file. - `writeConfig(config, filePath)`: Writes config with automatic backup. Refuses to overwrite an existing non-empty file that fails the same JSONC parse check. - `getJsonEntrySource(layers, sectionKey, entryName)`: Resolves which config layer provides an entry. A failed custom or user layer throws `INVALID_JSONC` instead of treating that file as empty. A failed project layer is skipped so a valid user/custom entry can still be found. diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js index bc90ff31..d01168f3 100644 --- a/packages/web/server/lib/opencode/shared.js +++ b/packages/web/server/lib/opencode/shared.js @@ -182,12 +182,18 @@ function formatJsoncParseError(filePath, errors) { return `OpenCode configuration at ${filePath} contains invalid JSONC and cannot be loaded safely${location}`; } +function isCommentOnlyParse(parsed, errors) { + // Comment-only / whitespace-only files parse to undefined with nothing but + // ValueExpected. Any other error means real content we failed to understand + // (YAML, plain text, a stray leading token), which must not read as empty. + return parsed === undefined + && errors.every((entry) => printParseErrorCode(entry.error) === 'ValueExpected'); +} + function parseConfigObject(content, filePath) { const errors = []; const parsed = parseJsonc(content, errors, { allowTrailingComma: true }); - // Comment-only / no JSON value: jsonc-parser returns undefined plus ValueExpected. - // That is empty config, not a partial tree. The data-loss bug is errors + object. - if (parsed === undefined) { + if (isCommentOnlyParse(parsed, errors)) { return {}; } if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { diff --git a/packages/web/server/lib/opencode/shared.test.js b/packages/web/server/lib/opencode/shared.test.js index 88384ae9..2c644b19 100644 --- a/packages/web/server/lib/opencode/shared.test.js +++ b/packages/web/server/lib/opencode/shared.test.js @@ -343,6 +343,16 @@ describe('readConfigFile / writeConfig JSONC safety (issue #2923)', () => { expect(readConfigFile(file)).toEqual({}); }); + it('throws INVALID_JSONC for content that yields no JSON value at all', () => { + const yamlish = writeFixture('yamlish.jsonc', 'mcp:\n openproject:\n type: remote\n'); + expect(() => readConfigFile(yamlish)).toThrow(/cannot be loaded safely/); + expect(() => writeConfig({ $schema: 'https://opencode.ai/config.json' }, yamlish)).toThrow( + /cannot be loaded safely/, + ); + expect(fs.readFileSync(yamlish, 'utf8')).toBe('mcp:\n openproject:\n type: remote\n'); + expect(fs.existsSync(`${yamlish}.openchamber.backup`)).toBe(false); + }); + it('keeps a valid custom layer readable when a project layer is unparseable', () => { const custom = writeFixture('custom.jsonc', VALID_CONFIG); const projectDir = path.join(FIXTURE_DIR, 'project'); From 69150366fd3ee4e500e0c01193d5a039c057bbb0 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Mon, 17 Aug 2026 16:45:53 +0300 Subject: [PATCH 010/215] fix(ui): correct markdown cache identity, streaming churn, and redundant tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the #2769 highlight caches. Fingerprint strength. The block/highlight caches are now global and content-addressed, so a hash collision no longer mis-colors a block — it returns a *different* block's rendered HTML and shows the user source they never wrote. Length + one 32-bit FNV-1a is not enough key space for that failure mode at session scale. `contentFingerprint` now combines two independent 32-bit multiplicative hashes with a final avalanche (~64 bits); two multiplies per character are free next to Shiki tokenization. Streaming churn. Content addressing made every streaming step of the trailing `live` block insert a new cache entry, so one long message evicted the settled `full` blocks the fix exists to keep warm. `full` and `live` blocks now use separate caches; the live cache is small (32 entries / 2MB) because it only has to absorb repeat renders of the same step. Redundant worker-side caches. `markdown-worker.ts` is the only sender to the Shiki worker, and its client cache is larger than the worker-side ones, so the worker caches could not serve a hit the client had not already served — they only duplicated up to 48MB of payloads in a second heap. Removed; the reason memoization belongs on the client is now documented there, along with why only `highlightTokens` carries a theme in its key. Dead `cacheKey` plumbing. `renderMarkdownBlocks` kept a `cacheKey` parameter it only `void`-ed. Removed it and the now-unused `useMorphdomMarkdown` prop; the remaining call-site local is renamed `fadeKey` for what it actually keys. Tests: image-mode cache identity, streaming-does-not-evict-settled-blocks, live-cache reuse, and a 20k same-length-source fingerprint collision check. Each new guard was verified to fail without its fix. --- CHANGELOG.md | 1 + .../components/chat/MarkdownRendererImpl.tsx | 15 +-- .../chat/markdown/highlightResultCache.ts | 44 +++++-- .../chat/markdown/markdown-shiki.worker.ts | 55 +-------- .../chat/markdown/markdown-worker.ts | 9 ++ .../components/chat/markdown/markdownCore.ts | 67 +++++++---- .../markdownHighlightingRepro.test.ts | 112 ++++++++++++++---- packages/ui/src/types/bun-test.d.ts | 1 + 8 files changed, 181 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46731e19..35576c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - **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 19312197..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,9 +1135,6 @@ const SimpleMarkdownRendererImpl: React.FC<{ containerRef, text: renderedContent, streaming: false, - // Identity is unused for cache lookup (content-addressed in markdownCore); - // keep a stable per-variant key for effect deps alongside `text`. - 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 index 1f1b8440..a73fd0bc 100644 --- a/packages/ui/src/components/chat/markdown/highlightResultCache.ts +++ b/packages/ui/src/components/chat/markdown/highlightResultCache.ts @@ -1,10 +1,10 @@ -// Bounded LRU for Shiki highlight results. +// Bounded LRU for rendered markdown / Shiki highlight results. // -// Used on the main-thread worker client and inside the markdown-shiki worker so -// unchanged code is never 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. +// 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; @@ -19,18 +19,36 @@ type CacheEntry = { /** 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 + FNV-1a 32-bit. - * Collision of same length + same hash is vanishingly rare at session scale; - * highlight results are pure functions of (kind, lang, theme, source). + * 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 h = 0x811c9dc5; + let h1 = 0x811c9dc5; + let h2 = 0xc2b2ae35; for (let i = 0; i < value.length; i += 1) { - h ^= value.charCodeAt(i); - h = Math.imul(h, 0x01000193); + const code = value.charCodeAt(i); + h1 = Math.imul(h1 ^ code, 0x01000193); + h2 = Math.imul(h2 ^ code, 0x27220a95); } - return `${value.length.toString(36)}_${(h >>> 0).toString(36)}`; + return `${value.length.toString(36)}_${mix32(h1).toString(36)}_${mix32(h2).toString(36)}`; }; /** Approximate byte cost of token-run lines without JSON.stringify. */ diff --git a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts index 3877d26a..a06b81fc 100644 --- a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts @@ -1,14 +1,8 @@ /// import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki'; -import { - contentFingerprint, - estimateTokenRunsBytes, - HighlightResultCache, - utf16Bytes, -} from './highlightResultCache'; import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition'; -import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; +import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; // Shiki FontStyle bitmask (from @shikijs/types). Inlined to avoid an extra import. const FONT_STYLE_ITALIC = 1; @@ -38,25 +32,6 @@ let highlighter: ReturnType | undefined; // Serialize work so language loading / tokenization never overlaps. let queue = Promise.resolve(); -// Memoize tokenization by content fingerprint. Defense in depth for the -// main-thread client cache (openchamber/openchamber#2769): keys stay short so -// large sources are not duplicated in the Map. -const WORKER_CACHE_MAX_ENTRIES = 1500; -const WORKER_CACHE_MAX_BYTES = 16 * 1024 * 1024; - -const htmlCache = new HighlightResultCache({ - maxEntries: WORKER_CACHE_MAX_ENTRIES, - maxBytes: WORKER_CACHE_MAX_BYTES, -}); -const linesCache = new HighlightResultCache({ - maxEntries: WORKER_CACHE_MAX_ENTRIES, - maxBytes: WORKER_CACHE_MAX_BYTES, -}); -const tokensCache = new HighlightResultCache({ - maxEntries: WORKER_CACHE_MAX_ENTRIES, - maxBytes: WORKER_CACHE_MAX_BYTES, -}); - const ensureHighlighter = (): ReturnType => { highlighter ??= createHighlighter({ // Cast: the theme is a CSS-variable TextMate theme; Shiki accepts the shape. @@ -97,19 +72,8 @@ const resolveLanguage = async (instance: Instance, requested: string): Promise `${lang}:${contentFingerprint(code)}`; -const linesKey = (lang: string, code: string): string => `${lang}:${contentFingerprint(code)}`; -const tokensKey = (themeName: string, lang: string, code: string): string => - `${themeName}:${lang}:${contentFingerprint(code)}`; - async function highlight(request: Extract): Promise { try { - const cacheKey = htmlKey(request.lang, request.code); - const cached = htmlCache.get(cacheKey); - if (cached !== undefined) { - post({ type: 'highlight', id: request.id, html: cached }); - return; - } const instance = await ensureHighlighter(); const lang = await resolveLanguage(instance, request.lang); const html = instance.codeToHtml(request.code, { @@ -117,7 +81,6 @@ async function highlight(request: Extract): Promise { try { - const cacheKey = tokensKey(request.themeName, request.lang, request.code); - const cached = tokensCache.get(cacheKey); - if (cached !== undefined) { - post({ type: 'highlightTokens', id: request.id, lines: cached }); - return; - } const instance = await ensureHighlighter(); if (request.theme && !instance.getLoadedThemes().includes(request.themeName)) { // Cast: a resolved TextMate theme object from the app theme registry. @@ -145,7 +102,6 @@ async function highlightTokens(request: Extract line.map((token) => [token.content.length, token.color ?? '', token.fontStyle ?? 0] as [number, string, number]), ); - tokensCache.set(cacheKey, lines, utf16Bytes(cacheKey) + estimateTokenRunsBytes(lines)); post({ type: 'highlightTokens', id: request.id, lines }); } catch (error) { post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); @@ -154,12 +110,6 @@ async function highlightTokens(request: Extract): Promise { try { - const cacheKey = linesKey(request.lang, request.code); - const cached = linesCache.get(cacheKey); - if (cached !== undefined) { - post({ type: 'highlightLines', id: request.id, lines: cached }); - return; - } const instance = await ensureHighlighter(); const lang = await resolveLanguage(instance, request.lang); const { tokens } = instance.codeToTokens(request.code, { @@ -167,9 +117,6 @@ async function highlightLines(request: Extract line.map(tokenSpan).join('')); - let bytes = utf16Bytes(cacheKey); - for (const line of lines) bytes += utf16Bytes(line); - linesCache.set(cacheKey, lines, bytes); post({ type: 'highlightLines', id: request.id, lines }); } catch (error) { post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index a2234c61..ab5b6eb6 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -18,6 +18,15 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } // 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; diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 0c60c204..2d3cb7a9 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -492,34 +492,58 @@ const sanitize = (html: string): string => { // Per-block HTML cache (content-addressed LRU) // --------------------------------------------------------------------------- // -// Keyed by content hash + mode + highlight flag — 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. +// 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 HTML_CACHE_MAX_ENTRIES = 2000; -const HTML_CACHE_MAX_BYTES = 24 * 1024 * 1024; +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; -const htmlCache = new HighlightResultCache({ - maxEntries: HTML_CACHE_MAX_ENTRIES, - maxBytes: HTML_CACHE_MAX_BYTES, +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, }); -/** Content-addressed cache key for a markdown block (exported for tests). */ -export const markdownBlockCacheKey = ( +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 cache between cases. */ +/** Test-only: clear the render HTML caches between cases. */ export const resetMarkdownHtmlCacheForTests = (): void => { - htmlCache.clear(); + fullBlockCache.clear(); + liveBlockCache.clear(); }; +/** 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; const parsed = await Promise.resolve(parser.parse(block.src)); @@ -559,18 +583,14 @@ export type RenderedBlock = { * blocks (instead of one joined string) lets the renderer re-morph only the * block that changed, keeping per-step streaming cost ~O(last block). * - * `cacheKey` is retained for call-site compatibility / debugging; the HTML - * cache itself is content-addressed so distinct renderers with identical - * blocks share results and cannot evict each other by identity collision. + * 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 => { - // Retained for call-site compatibility / debugging; lookup is content-addressed. - void cacheKey; if (!text) return []; const blocks = streamBlocks(text, streaming); @@ -578,12 +598,13 @@ export const renderMarkdownBlocks = async ( blocks.map(async (block) => { const contentHash = contentFingerprint(block.raw); const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode); - const cached = htmlCache.get(id); + const cache = cacheForMode(block.mode); + const cached = cache.get(id); if (cached !== undefined) { return { id, html: cached }; } const html = await parseBlock(block, imageMode); - htmlCache.set(id, html, utf16Bytes(id) + utf16Bytes(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 index 96a24a7c..db51aad5 100644 --- a/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts +++ b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts @@ -42,7 +42,7 @@ mock.module('./markdown-worker', () => ({ const { renderMarkdownBlocks, resetMarkdownHtmlCacheForTests, - markdownBlockCacheKey, + __markdownBlockCacheSizesForTests, } = await import('./markdownCore'); const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker'); @@ -83,6 +83,22 @@ describe('HighlightResultCache', () => { 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]], @@ -93,21 +109,22 @@ describe('HighlightResultCache', () => { }); describe('markdownCore content-addressed htmlCache (#2769)', () => { - test('two same-variant SimpleMarkdown-style keys do not re-highlight unchanged content', async () => { + 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, 'simple:tool'); - await renderMarkdownBlocks(toolOutputB, false, 'simple:tool'); + await renderMarkdownBlocks(toolOutputA, false); + await renderMarkdownBlocks(toolOutputB, false); const coldCalls = highlightCalls; expect(coldCalls).toBeGreaterThan(0); - // 100 more passes with the legacy shared `simple:tool` identity keys — - // must not produce additional worker calls. + // 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, 'simple:tool'); - await renderMarkdownBlocks(toolOutputB, false, 'simple:tool'); + await renderMarkdownBlocks(toolOutputA, false); + await renderMarkdownBlocks(toolOutputB, false); } expect(highlightCalls).toBe(coldCalls); @@ -115,19 +132,18 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => { test('long sessions (working set > former 240 cap) stay warm across re-render passes', async () => { const parts = Array.from({ length: 600 }, (_, i) => ({ - key: `markdown-part-part_${i}`, content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``, })); for (const part of parts) { - await renderMarkdownBlocks(part.content, false, part.key); + 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, part.key); + await renderMarkdownBlocks(part.content, false); } } @@ -139,24 +155,74 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => { const stable = '```ts\nconst stable = true;\n```'; const changing = '```ts\nconst n = 1;\n```'; - await renderMarkdownBlocks(stable, false, 'a'); - await renderMarkdownBlocks(changing, false, 'b'); + await renderMarkdownBlocks(stable, false); + await renderMarkdownBlocks(changing, false); const afterFirst = highlightCalls; - await renderMarkdownBlocks(stable, false, 'a'); - await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false, 'b'); + await renderMarkdownBlocks(stable, false); + await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false); expect(highlightCalls).toBe(afterFirst + 1); - await renderMarkdownBlocks(stable, false, 'a'); + await renderMarkdownBlocks(stable, false); expect(highlightCalls).toBe(afterFirst + 1); }); - test('block cache keys are content-addressed (mode + highlight + imageMode + hash)', () => { - expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).toBe('abc:full:1:inline'); - expect(markdownBlockCacheKey('abc', 'live', false, 'inline')).toBe('abc:live:0:inline'); - expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).not.toBe(markdownBlockCacheKey('abc', 'full', false, 'inline')); - // Image mode changes the rendered HTML, so it must not share a cache entry. - expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).not.toBe(markdownBlockCacheKey('abc', 'full', true, 'label')); + 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 () => { @@ -168,7 +234,7 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => { '```ts\nconst c = 3;\n```', ].join('\n'); - await renderMarkdownBlocks(multi, false, 'multi'); + 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: { From 344c1b3ce380661b7e377e9d6b4726527e4ea023 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 17 Aug 2026 21:49:23 +0300 Subject: [PATCH 011/215] docs: let maintenance clones self-heal and abort without leaving debris A failed nightly run left edits in the maintenance clone, and every later run correctly refused to work on a dirty worktree, so one failure stalled the whole pipeline until morning. Maintenance task commands now recognise a gitignored .maintenance-clone marker. In a marked disposable clone they discard leftover debris, return to main, and continue; in a human working copy they still stop and touch nothing. Add an explicit abort protocol: revert your own edits, confirm the worktree is clean, release the claim, and report. Restore the honest skip that the complete-file rule had squeezed out, since a laundered fix is worse than a documented skip, and describe how to handle a file that is entirely an external-data boundary instead of inventing generic JSON contracts. --- .gitignore | 3 ++ .opencode/commands/as-fixes.md | 43 ++++++++++++++++++++++-- .opencode/commands/as-follow-up.md | 15 ++++++++- .opencode/commands/maintenance-review.md | 4 ++- .opencode/commands/rd-fixes.md | 33 ++++++++++++++++-- .opencode/commands/rd-follow-up.md | 15 ++++++++- 6 files changed, 106 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 51ed5714..03d5f68f 100644 --- a/.gitignore +++ b/.gitignore @@ -68,5 +68,8 @@ data/ workspaces/ *.pid .worktrees/ + +# Marks a disposable clone dedicated to unattended maintenance tasks. +.maintenance-clone test-results/ artifacts/ diff --git a/.opencode/commands/as-fixes.md b/.opencode/commands/as-fixes.md index fb9152b6..8a4d87fc 100644 --- a/.opencode/commands/as-fixes.md +++ b/.opencode/commands/as-fixes.md @@ -13,7 +13,20 @@ First, verify the worktree is safe to use: `git status --porcelain` -If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. Local work in progress must never end up in a maintenance PR. +If the output is not empty, decide which of two situations you are in. + +If the repository root contains a `.maintenance-clone` marker file, this working copy is a disposable clone dedicated to unattended maintenance. Nothing in it is human work in progress, so leftover changes are debris from an earlier task that failed to clean up after itself. Recover the clone rather than stopping: + +``` +git checkout -- . +git clean -fd +git checkout main +git pull +``` + +Report exactly which files you discarded, then continue with the task. A failed predecessor must not be able to jam the pipeline for every later run. + +If the marker file is absent, this is a working copy a person uses. Stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. Then run: @@ -295,7 +308,18 @@ So, before you consider a selected file done: - A group of findings sharing one root cause counts as one reason, and that root cause is usually worth fixing. If eleven findings in a file all come from one untyped parser, fixing that parser is the point of the batch, not a reason to skip. - Leaving more than roughly a quarter of a file's findings behind means you have not finished. Either finish them or explain, per group, why the file was a bad selection in the first place. -Skip a finding only when the fix would require unclear behavior changes, or a change so large it would stop the pull request from being reviewable. Difficulty alone is not a reason. If skipped, give the specific reason in the PR body under `## Non-goals`. +Skip a finding when the fix would require unclear behavior changes, when the change would be so large that the pull request stops being reviewable, or when the only way you can see to close it is one of the forbidden patterns. That last case is not a loophole, it is the required outcome: an honest skip is always better than a laundered fix, and choosing the forbidden pattern to satisfy "finish the file" is the worse failure of the two. Ordinary difficulty, on its own, is still not a reason. If skipped, give the specific reason in the PR body under `## Non-goals`. + +### When the whole file is an external-data boundary + +Some files exist to receive data from outside the program: provider APIs, quota endpoints, extension host messages, configuration on disk. In such a file, most or all findings can share one root cause, and the honest fix is a real parsed boundary with named contracts, which is a substantial piece of work rather than a lint cleanup. + +Recognize this early, before editing. Read the file first and ask whether closing its findings means designing a data contract that does not exist yet. If it does, choose one of two outcomes, and never a third: + +- Do the work properly for a coherent part of the file: define the contract for one provider, one endpoint, or one message, parse it at its boundary, and leave the rest with a clear explanation of the remaining root cause. A correct partial fix with a named boundary is a good pull request. +- Conclude that the file is a poor batch selection, abort per "Aborting cleanly", and say in your report that the file needs a deliberate data-contract change rather than an unattended cleanup. + +What you must not do is invent a generic JSON contract to make the findings disappear. Generic record types, primitive unions, and `unknown`-based aliases over external data are exactly the patterns these rules exist to reject, and reintroducing them under time pressure defeats the purpose of the whole task. Hard prohibitions. Each of these makes the lint output greener while making the code worse, and each is grounds for rejecting the whole PR: - Do not disable, downgrade, or ignore anti-slop rules, in configuration or with inline comments. @@ -311,6 +335,21 @@ Hard prohibitions. Each of these makes the lint output greener while making the - Do not edit `CHANGELOG.md`, package versions, or release metadata. This is internal maintenance with no user-facing change. - Do not fix findings outside the selected files. +## Aborting cleanly + +You may reach a point where the batch cannot be completed correctly: validation keeps failing, or the only remaining way to close the findings is a pattern this task forbids. Stopping there is the right decision. Stopping there and walking away from a modified working copy is not. + +Whatever edits exist in the working copy at that moment are your own, made minutes ago in this session. They are not human work in progress, and nothing is lost by removing them. Leaving them behind jams every scheduled run that follows, because those runs correctly refuse to operate on a dirty worktree. + +So when you abort, in this order: + +1. Revert every file you modified: `git checkout -- `, plus `git clean -fd` for files you created. Verify with `git status --porcelain` that the result is empty. +2. Release the claim so the files return to the pool: ``bun run deslop -- release --run ``. +3. Return to `main`. +4. Report what you attempted, precisely why you stopped, and confirm that both the worktree is clean and the claim is released. + +Never leave a partially fixed working copy as a message to the next run. If a file resists a correct fix, that belongs in your report, not on disk. + After edits, run: `bun run deslop -- check-batch --run ` diff --git a/.opencode/commands/as-follow-up.md b/.opencode/commands/as-follow-up.md index 6e1c6faf..c7877b7e 100644 --- a/.opencode/commands/as-follow-up.md +++ b/.opencode/commands/as-follow-up.md @@ -13,7 +13,20 @@ First, verify the worktree is safe to use: `git status --porcelain` -If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, or switch branches. +If the output is not empty, decide which of two situations you are in. + +If the repository root contains a `.maintenance-clone` marker file, this working copy is a disposable clone dedicated to unattended maintenance. Nothing in it is human work in progress, so leftover changes are debris from an earlier task that failed to clean up after itself. Recover the clone rather than stopping: + +``` +git checkout -- . +git clean -fd +git checkout main +git pull +``` + +Report exactly which files you discarded, then continue with the task. A failed predecessor must not be able to jam the pipeline for every later run. + +If the marker file is absent, this is a working copy a person uses. Stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. List the active batches: diff --git a/.opencode/commands/maintenance-review.md b/.opencode/commands/maintenance-review.md index 74eddeea..d0ec290f 100644 --- a/.opencode/commands/maintenance-review.md +++ b/.opencode/commands/maintenance-review.md @@ -29,7 +29,9 @@ Verify the worktree is clean: `git status --porcelain` -If the output is not empty, stop immediately and report it. Do not stash, reset, or discard anything. +If the output is not empty and the repository root contains a `.maintenance-clone` marker file, this is a disposable maintenance clone and the changes are debris from an earlier failed task. Recover it with `git checkout -- .`, `git clean -fd`, `git checkout main`, `git pull`, report exactly which files you discarded, and continue. + +If the marker file is absent, stop immediately and report it. Do not stash, reset, or discard anything. Read `AGENTS.md`, and read `.opencode/commands/as-fixes.md` in full, including the sections "What a good fix looks like" and "Hard prohibitions". Those describe the standard the anti-slop PRs were supposed to meet. Your job includes verifying they actually met it. diff --git a/.opencode/commands/rd-fixes.md b/.opencode/commands/rd-fixes.md index b610a4d0..df2d6bb8 100644 --- a/.opencode/commands/rd-fixes.md +++ b/.opencode/commands/rd-fixes.md @@ -13,7 +13,20 @@ First, verify the worktree is safe to use: `git status --porcelain` -If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. Local work in progress must never end up in a maintenance PR. +If the output is not empty, decide which of two situations you are in. + +If the repository root contains a `.maintenance-clone` marker file, this working copy is a disposable clone dedicated to unattended maintenance. Nothing in it is human work in progress, so leftover changes are debris from an earlier task that failed to clean up after itself. Recover the clone rather than stopping: + +``` +git checkout -- . +git clean -fd +git checkout main +git pull +``` + +Report exactly which files you discarded, then continue with the task. A failed predecessor must not be able to jam the pipeline for every later run. + +If the marker file is absent, this is a working copy a person uses. Stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. Then run: @@ -36,10 +49,26 @@ Workflow: - Finish each selected file. A file is finished when it has zero React Doctor diagnostics, or when every remaining diagnostic has an individual, specific reason to stay. A half-fixed file will be selected again later and cost a second pull request, a second review, and a second merge over the same code. - Before considering a file done, re-run `bun run doctor -- file ` and read what is left. Leaving more than roughly a quarter of a file's diagnostics behind means you have not finished. - A group of diagnostics sharing one root cause counts as one reason, and that root cause is usually worth fixing rather than deferring. -- Skip a diagnostic only when the fix would require unclear behavior changes, or a change so large it would stop the pull request from being reviewable. Difficulty alone is not a reason. If skipped, give the specific reason in the PR body under `## Non-goals`. +- Skip a diagnostic when the fix would require unclear behavior changes, when the change would be so large that the pull request stops being reviewable, or when the only way you can see to close it is a change you would not defend in review. An honest skip is always better than a forced fix. Ordinary difficulty, on its own, is still not a reason. If skipped, give the specific reason in the PR body under `## Non-goals`. +- If a whole selected file turns out to need a deliberate architectural change rather than a cleanup, abort per "Aborting cleanly" and report that the file was a poor batch selection. - Do not suppress React Doctor diagnostics unless there is a clear false positive. - If a listed diagnostic requires changes outside the selected files, make only the minimal required supporting change. Do not expand the cleanup scope. +## Aborting cleanly + +You may reach a point where the batch cannot be completed correctly: validation keeps failing, or the only remaining way to close the findings is a pattern this task forbids. Stopping there is the right decision. Stopping there and walking away from a modified working copy is not. + +Whatever edits exist in the working copy at that moment are your own, made minutes ago in this session. They are not human work in progress, and nothing is lost by removing them. Leaving them behind jams every scheduled run that follows, because those runs correctly refuse to operate on a dirty worktree. + +So when you abort, in this order: + +1. Revert every file you modified: `git checkout -- `, plus `git clean -fd` for files you created. Verify with `git status --porcelain` that the result is empty. +2. Release the claim so the files return to the pool: ``bun run doctor -- release --run ``. +3. Return to `main`. +4. Report what you attempted, precisely why you stopped, and confirm that both the worktree is clean and the claim is released. + +Never leave a partially fixed working copy as a message to the next run. If a file resists a correct fix, that belongs in your report, not on disk. + After edits, run: `bun run doctor -- check-batch --run ` diff --git a/.opencode/commands/rd-follow-up.md b/.opencode/commands/rd-follow-up.md index 478a03a7..7bac4435 100644 --- a/.opencode/commands/rd-follow-up.md +++ b/.opencode/commands/rd-follow-up.md @@ -13,7 +13,20 @@ First, verify the worktree is safe to use: `git status --porcelain` -If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, or switch branches. +If the output is not empty, decide which of two situations you are in. + +If the repository root contains a `.maintenance-clone` marker file, this working copy is a disposable clone dedicated to unattended maintenance. Nothing in it is human work in progress, so leftover changes are debris from an earlier task that failed to clean up after itself. Recover the clone rather than stopping: + +``` +git checkout -- . +git clean -fd +git checkout main +git pull +``` + +Report exactly which files you discarded, then continue with the task. A failed predecessor must not be able to jam the pipeline for every later run. + +If the marker file is absent, this is a working copy a person uses. Stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. List the active batches: From 7611076436fc79d763af8143c688a1cf9a907d1a Mon Sep 17 00:00:00 2001 From: Aaron Hogue Date: Mon, 17 Aug 2026 16:44:38 -0400 Subject: [PATCH 012/215] fix(proxy): reuse upstream connections for OpenCode API requests (#2916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(proxy): reuse upstream connections for OpenCode API requests `createProxyMiddleware` was constructed without an `agent`, so `http-proxy` fell back to `agent: false`. That disables connection pooling and forces `Connection: close` on every proxied request, consuming one ephemeral port per request. Measured against a real `opencode serve` instance, 200 sequential requests through the proxy created 201 TIME_WAIT entries (1.005 ports/request). With a keep-alive agent the same load creates 0. On macOS the ephemeral range is 16,384 ports and TIME_WAIT lasts 30s, so sustained traffic around 546 req/sec exhausts the pool — after which every process on the host fails to open outbound connections with EADDRNOTAVAIL. `maxSockets: Infinity` preserves the unbounded concurrency of `agent: false`, so this changes connection reuse only, not request throughput. Partially addresses #2915. * fix(proxy): derive proxy agent class from the target scheme Addresses review feedback on #2916. The first commit created an unconditional `http.Agent`, which regresses external OpenCode servers configured over https via `OPENCODE_HOST` (accepted by env-config.js). http-proxy dispatches through `https.request` when the target protocol is `https:` (http-proxy/lib/http-proxy/passes/web-incoming.js:126), and `http.Agent#createConnection` is plain `net.createConnection` — so an http.Agent would open a plaintext socket to a TLS port and fail every proxied request. `agent: false` previously worked for both schemes. `createOpenCodeProxyAgent(target)` now returns an `https.Agent` for https targets and an `http.Agent` otherwise, derived once from `resolveProxyTarget()` at registration so the single shared instance is preserved across `apiProxy` and `interactiveOAuthProxy`. Guarded in both test layers, verified to fail when the selection is reverted to an unconditional http.Agent. `https.Agent` extends `http.Agent`, so the http cases assert `not.toBeInstanceOf(https.Agent)`. * Round 2: fix: resolve the proxy agent lazily so cold starts honor https Addresses the round-2 blocker on #2916. Deriving the agent class at registration is too early: startup-pipeline-runtime.js calls setupProxy() (line 104) before bootstrapOpenCodeAtStartup() (line 141), so on a fresh process state.openCodePort is null, buildOpenCodeUrl() throws (network-runtime.js:86-88), and resolveProxyTarget() returns the http loopback fallback. An external server configured via OPENCODE_HOST=https:// only appears on state.openCodeBaseUrl after bootstrap, so it was still getting a plain http.Agent — the regression the previous commit intended to fix. `agent` is now a getter backed by a per-scheme memoizing resolver. http-proxy-middleware rebuilds per-request options with `Object.assign({}, this.proxyOptions)` in prepareProxyRequest, which invokes getters, so resolution happens at request time while still yielding one shared pool per scheme. Tests now model the production ordering — registration while the port is null and buildOpenCodeUrl throws, then an https base URL appearing after bootstrap — and fail against the eager implementation. A behavioral test pins the http-proxy-middleware option re-read the fix depends on, so a library change that froze options would fail loudly instead of silently regressing https targets. The resolver is module-private; `bun run dead-code` flagged it as an unused export when it was exported. * Round 3: docs(changelog): note upstream connection reuse under [Unreleased] Repo precedent adds [Unreleased] bullets for comparable proxy/stability fixes (1.18.4 Stability, 1.9.3 Reliability/Proxy). Non-blocker raised in review on #2916. * Round 3: docs(changelog): use repo-standard 'behavior' spelling * Round 4: docs(changelog): don't imply a restart is the only recovery The ephemeral port pool drains on its own once the exhausting traffic stops (TIME_WAIT expiry), so a restart is sufficient but not necessary. Optional nit raised in review on #2916. * Round 5: fix: construct the proxy agent through one factory; widen the pool Review found the https branch was mutation-uncovered: the resolver re-implemented agent construction inline instead of calling the exported `createOpenCodeProxyAgent(target)`, so replacing its https branch with `new https.Agent()` — dropping OPENCODE_AGENT_OPTIONS, and with it keep-alive — left the entire suite green. Since `createOpenCodeProxyAgent` also had no production callers, its four tests were pinning dead code. Delegating collapses both: the factory is now the single construction path, and the mutation fails 2 tests including the live resolver path. Also from review: - maxFreeSockets 32 -> 256 (Node's own default). The lower cap evicted pooled sockets under concurrency, reintroducing the churn this agent exists to prevent: at 64 concurrent requests it left 303 sockets in TIME_WAIT versus 0 at 256. - Added `timeout` to OPENCODE_AGENT_OPTIONS. Free-socket eviction is governed by agent.options.timeout, which was unset, so idle sockets persisted until the peer closed them. `keepAliveMsecs` is the TCP probe delay, not the idle lifetime. - resolveProxyTarget() now checks openCodePort before calling buildOpenCodeUrl instead of relying on it throwing. The port is nulled on several runtime paths (health-check failure, failed restart), so a degraded OpenCode made every proxied request pay for a thrown-and-caught exception — and the getter added a second call per request. - Test fixtures use :4096 rather than :443; WHATWG URL elides the default port, so parseInt('') is NaN and env-config rejects that host. The fixtures modeled a state that cannot reach production. - The getter-read assertion is now exact (0 at construction, 1, then 2) rather than >= 2, which would have passed if the getter were read twice at construction and never per-request. - listen() rejects on 'error' and servers start inside try/finally, so a bind failure fails the test instead of hanging to timeout. --- CHANGELOG.md | 1 + .../lib/opencode/proxy-agent-wiring.test.js | 152 +++++++++++++++++ packages/web/server/lib/opencode/proxy.js | 122 +++++++++++++- .../web/server/lib/opencode/proxy.test.js | 159 +++++++++++++++++- 4 files changed, 427 insertions(+), 7 deletions(-) create mode 100644 packages/web/server/lib/opencode/proxy-agent-wiring.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b8521ad5..c32c38b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,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). +- **Stability/Proxy:** the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja). - 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/web/server/lib/opencode/proxy-agent-wiring.test.js b/packages/web/server/lib/opencode/proxy-agent-wiring.test.js new file mode 100644 index 00000000..33a32de5 --- /dev/null +++ b/packages/web/server/lib/opencode/proxy-agent-wiring.test.js @@ -0,0 +1,152 @@ +import http from 'node:http'; +import https from 'node:https'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { createProxyMiddlewareMock } = vi.hoisted(() => ({ + createProxyMiddlewareMock: vi.fn(), +})); + +vi.mock('http-proxy-middleware', () => ({ + createProxyMiddleware: createProxyMiddlewareMock, +})); + +const { registerOpenCodeProxy } = await import('./proxy.js'); + +const createStubApp = () => { + const settings = new Map(); + const noop = () => {}; + + return { + get: (...args) => (args.length === 1 ? settings.get(args[0]) : undefined), + set: (key, value) => { + settings.set(key, value); + }, + use: noop, + post: noop, + put: noop, + patch: noop, + delete: noop, + all: noop, + }; +}; + +/** + * `state` is intentionally mutable so a test can model the production ordering: + * the proxy is registered before OpenCode bootstraps, so the port/base URL only + * become resolvable afterwards. + */ +const createStubDeps = (state) => ({ + fs: { promises: { realpath: async (value) => value } }, + os: {}, + path: {}, + OPEN_CODE_READY_GRACE_MS: 0, + LONG_REQUEST_TIMEOUT_MS: 1_000, + getRuntime: () => ({ openCodePort: state.port, openCodeBaseUrl: state.baseUrl }), + getOpenCodeAuthHeaders: () => ({}), + // Mirrors network-runtime.js: throws until the port is known. + buildOpenCodeUrl: (pathname) => { + if (!state.port) { + throw new Error('OpenCode port is not available'); + } + return `${state.baseUrl}${pathname}`; + }, + ensureOpenCodeApiPrefix: (pathname) => pathname, +}); + +const managedState = () => ({ port: 49303, baseUrl: 'http://127.0.0.1:49303' }); +const coldState = () => ({ port: null, baseUrl: null }); + +const agentsFromCalls = () => createProxyMiddlewareMock.mock.calls.map(([options]) => options.agent); + +describe('OpenCode API proxy agent wiring', () => { + beforeEach(() => { + createProxyMiddlewareMock.mockReset(); + createProxyMiddlewareMock.mockImplementation(() => (_req, _res, next) => next?.()); + }); + + it('constructs every proxy with a keep-alive agent', () => { + registerOpenCodeProxy(createStubApp(), createStubDeps(managedState())); + + expect(createProxyMiddlewareMock).toHaveBeenCalled(); + + for (const agent of agentsFromCalls()) { + // Without an explicit agent, http-proxy falls back to `agent: false`, + // which forces `Connection: close` and burns one ephemeral port per + // request. See createOpenCodeProxyAgent in ./proxy.js. + expect(agent).toBeTruthy(); + expect(agent.options?.keepAlive).toBe(true); + } + }); + + it('shares one agent instance across the API and OAuth proxies', () => { + registerOpenCodeProxy(createStubApp(), createStubDeps(managedState())); + + const agents = agentsFromCalls(); + + expect(agents.length).toBeGreaterThan(1); + expect(agents.every(Boolean)).toBe(true); + expect(new Set(agents).size).toBe(1); + }); + + it('memoizes the agent per scheme rather than allocating one per resolution', () => { + registerOpenCodeProxy(createStubApp(), createStubDeps(managedState())); + + const [options] = createProxyMiddlewareMock.mock.calls[0]; + + expect(options.agent).toBe(options.agent); + }); + + // Production ordering: startup-pipeline-runtime.js calls setupProxy() before + // bootstrapOpenCodeAtStartup(), so at registration the port is null, + // buildOpenCodeUrl throws, and resolveProxyTarget() falls back to the http + // loopback default. An external https server configured via OPENCODE_HOST is + // only visible after bootstrap, so the agent must be resolved lazily. + it('resolves an https agent after bootstrap even though registration ran cold', () => { + const state = coldState(); + registerOpenCodeProxy(createStubApp(), createStubDeps(state)); + + // Cold: nothing resolvable yet, so the http fallback target applies. + for (const agent of agentsFromCalls()) { + expect(agent).not.toBeInstanceOf(https.Agent); + } + + // Bootstrap completes against an external https server. + state.baseUrl = 'https://opencode.example.com:4096'; + + for (const agent of agentsFromCalls()) { + expect(agent).toBeInstanceOf(https.Agent); + // Asserted on the live resolver path, not just the exported factory: + // the https branch is the one a mutation could silently strip. + expect(agent.options?.keepAlive).toBe(true); + expect(agent.options?.maxFreeSockets).toBe(256); + } + }); + + it('keeps a plain http agent when bootstrap resolves an http target', () => { + const state = coldState(); + registerOpenCodeProxy(createStubApp(), createStubDeps(state)); + + Object.assign(state, managedState()); + + for (const agent of agentsFromCalls()) { + // https.Agent extends http.Agent, so the negative assertion is load-bearing. + expect(agent).toBeInstanceOf(http.Agent); + expect(agent).not.toBeInstanceOf(https.Agent); + } + }); + + it('derives an https agent when the target is already https at registration', () => { + registerOpenCodeProxy( + createStubApp(), + createStubDeps({ port: 4096, baseUrl: 'https://opencode.example.com:4096' }), + ); + + const agents = agentsFromCalls(); + + expect(agents.length).toBeGreaterThan(0); + for (const agent of agents) { + expect(agent).toBeInstanceOf(https.Agent); + } + }); +}); diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js index 48a14f38..adcf9ae4 100644 --- a/packages/web/server/lib/opencode/proxy.js +++ b/packages/web/server/lib/opencode/proxy.js @@ -1,3 +1,6 @@ +import http from 'node:http'; +import https from 'node:https'; + import { createProxyMiddleware } from 'http-proxy-middleware'; import { @@ -11,6 +14,96 @@ import { recordStartupPerformance } from './startup-performance.js'; const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 20_000; +const OPENCODE_AGENT_KEEP_ALIVE_MS = 30_000; +// Node's own default. A lower cap evicts pooled sockets under concurrency, +// which reintroduces exactly the per-request connection churn this agent +// exists to prevent (measured: at 64 concurrent requests, a cap of 32 left +// 303 sockets in TIME_WAIT versus 0 at 256). +const OPENCODE_AGENT_MAX_FREE_SOCKETS = 256; +// Evicts idle free sockets from our side. Without it the only thing that +// retires an idle pooled socket is the upstream closing it. Note this is +// distinct from `keepAliveMsecs`, which is the TCP keep-alive probe delay. +const OPENCODE_AGENT_IDLE_TIMEOUT_MS = 60_000; + +const OPENCODE_AGENT_OPTIONS = { + keepAlive: true, + keepAliveMsecs: OPENCODE_AGENT_KEEP_ALIVE_MS, + maxSockets: Infinity, + maxFreeSockets: OPENCODE_AGENT_MAX_FREE_SOCKETS, + timeout: OPENCODE_AGENT_IDLE_TIMEOUT_MS, +}; + +const isHttpsProxyTarget = (target) => { + if (typeof target !== 'string') { + return false; + } + try { + return new URL(target).protocol === 'https:'; + } catch { + return /^https:/i.test(target.trim()); + } +}; + +/** + * Agent for proxied OpenCode API requests. + * + * When no agent is supplied, `http-proxy` falls back to `agent: false`, which + * both disables connection pooling and forces `Connection: close` on every + * proxied request (http-proxy/lib/http-proxy/common.js). That consumes one + * ephemeral port per request, and sustained traffic can exhaust the host's + * ephemeral port range — after which every process on the machine fails to + * open outbound connections with EADDRNOTAVAIL. + * + * The agent must match the target scheme: http-proxy dispatches through + * `https.request` when `target.protocol === 'https:'` + * (http-proxy/lib/http-proxy/passes/web-incoming.js), and an `http.Agent` + * would open a plaintext socket to a TLS port. External servers may be + * configured over https via `OPENCODE_HOST` (see env-config.js), so derive the + * agent class from the resolved target. + * + * `maxSockets: Infinity` preserves the unbounded concurrency of `agent: false`, + * so this changes connection reuse only, not request throughput. + */ +export const createOpenCodeProxyAgent = (target) => ( + isHttpsProxyTarget(target) + ? new https.Agent(OPENCODE_AGENT_OPTIONS) + : new http.Agent(OPENCODE_AGENT_OPTIONS) +); + +/** + * Lazily resolves the proxy agent, memoized per scheme. + * + * The scheme cannot be decided at registration time: `setupProxy()` runs before + * `bootstrapOpenCodeAtStartup()` (startup-pipeline-runtime.js), so on a cold + * start `state.openCodePort` is still null, `buildOpenCodeUrl()` throws + * (network-runtime.js) and `resolveProxyTarget()` falls back to the http + * loopback default. An external server configured over https via + * `OPENCODE_HOST` only becomes visible on `state.openCodeBaseUrl` after + * bootstrap completes. + * + * http-proxy-middleware rebuilds its per-request options with + * `Object.assign({}, this.proxyOptions)` inside `prepareProxyRequest`, which + * invokes getters, so exposing `agent` as a getter defers resolution to request + * time. Memoizing per scheme keeps a single shared pool per scheme rather than + * allocating an agent per request. + */ +const createOpenCodeProxyAgentResolver = (resolveTarget) => { + const agents = new Map(); + + return () => { + const target = resolveTarget(); + const scheme = isHttpsProxyTarget(target) ? 'https:' : 'http:'; + let agent = agents.get(scheme); + if (!agent) { + // Construct through the shared factory rather than inline, so both + // schemes are built from OPENCODE_AGENT_OPTIONS by the same code path. + agent = createOpenCodeProxyAgent(target); + agents.set(scheme, agent); + } + return agent; + }; +}; + export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } = {}) => { const realpathCache = createRealpathCache({ fallbackOnError: true, realpath, ...cacheOptions }); @@ -285,15 +378,22 @@ export const registerOpenCodeProxy = (app, deps) => { // and direct fetch helpers use. This avoids split-brain state where /health // succeeds against an external host but /api/* still proxies to 127.0.0.1. const resolveProxyTarget = () => { - try { - const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', '')); - if (resolved) { - return resolved; + const runtimeState = getRuntime(); + + // `buildOpenCodeUrl` throws while the port is unknown, and the port is + // nulled on several runtime paths (health-check failure, failed restart), + // not just cold start. Checking first keeps a degraded OpenCode from + // making every proxied request pay for a thrown-and-caught exception. + if (runtimeState.openCodePort) { + try { + const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', '')); + if (resolved) { + return resolved; + } + } catch { } - } catch { } - const runtimeState = getRuntime(); const externalBase = normalizeProxyTarget(runtimeState.openCodeBaseUrl); if (externalBase) { return externalBase; @@ -767,8 +867,18 @@ export const registerOpenCodeProxy = (app, deps) => { }); // Generic proxy for non-SSE OpenCode API routes. + // The agent is exposed as a getter so its class is resolved per request, not + // at registration: the proxy is registered before OpenCode bootstraps, so an + // https target configured via OPENCODE_HOST is not yet visible here. Agents + // are memoized per scheme, so this is still one shared pool per scheme across + // `apiProxy` and `interactiveOAuthProxy`. + const resolveOpenCodeProxyAgent = createOpenCodeProxyAgentResolver(resolveProxyTarget); + const createApiProxy = (timeoutMs) => createProxyMiddleware({ target: resolveProxyTarget(), + get agent() { + return resolveOpenCodeProxyAgent(); + }, changeOrigin: true, pathRewrite: { '^/api': '' }, timeout: timeoutMs, diff --git a/packages/web/server/lib/opencode/proxy.test.js b/packages/web/server/lib/opencode/proxy.test.js index 91326d71..77346d47 100644 --- a/packages/web/server/lib/opencode/proxy.test.js +++ b/packages/web/server/lib/opencode/proxy.test.js @@ -1,6 +1,14 @@ +import http from 'node:http'; +import https from 'node:https'; + +import { createProxyMiddleware } from 'http-proxy-middleware'; import { describe, expect, it } from 'vitest'; -import { createDirectoryQueryCanonicalizer, normalizeForwardedDirectoryHeaders } from './proxy.js'; +import { + createDirectoryQueryCanonicalizer, + createOpenCodeProxyAgent, + normalizeForwardedDirectoryHeaders, +} from './proxy.js'; describe('createDirectoryQueryCanonicalizer', () => { it('canonicalizes directory query params and preserves other params', async () => { @@ -93,3 +101,152 @@ describe('normalizeForwardedDirectoryHeaders', () => { }); }); }); + +const listen = (server) => new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(server.address().port); + }); +}); + +const closeServer = (server) => new Promise((resolve) => { + server.close(resolve); +}); + +const request = (port, agent) => new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path: '/', method: 'GET', agent }, (res) => { + res.resume(); + res.on('end', resolve); + res.on('error', reject); + }); + req.on('error', reject); + req.end(); +}); + +/** + * Proxies two sequential requests through `createProxyMiddleware` and reports + * what the upstream server observed for each one. + */ +const proxyTwoRequests = async (proxyAgent) => { + const seen = []; + let middleware; + const upstream = http.createServer((req, res) => { + seen.push({ connection: req.headers.connection, remotePort: req.socket.remotePort }); + res.end('ok'); + }); + const front = http.createServer((req, res) => { + middleware(req, res, () => { + res.statusCode = 502; + res.end(); + }); + }); + const clientAgent = new http.Agent({ keepAlive: true }); + + try { + const upstreamPort = await listen(upstream); + middleware = createProxyMiddleware({ + target: `http://127.0.0.1:${upstreamPort}`, + ...(proxyAgent ? { agent: proxyAgent } : {}), + }); + + const frontPort = await listen(front); + await request(frontPort, clientAgent); + await request(frontPort, clientAgent); + } finally { + clientAgent.destroy(); + proxyAgent?.destroy(); + await closeServer(front); + await closeServer(upstream); + } + + return seen; +}; + +describe('createOpenCodeProxyAgent', () => { + it('reuses a single upstream socket across sequential proxied requests', async () => { + const seen = await proxyTwoRequests(createOpenCodeProxyAgent('http://127.0.0.1')); + + expect(seen).toHaveLength(2); + expect(seen[0].connection).not.toBe('close'); + expect(seen[1].remotePort).toBe(seen[0].remotePort); + }); + + it('without an agent, http-proxy forces Connection: close and a new socket per request', async () => { + const seen = await proxyTwoRequests(null); + + expect(seen).toHaveLength(2); + expect(seen[0].connection).toBe('close'); + expect(seen[1].remotePort).not.toBe(seen[0].remotePort); + }); + + // http-proxy dispatches through `https.request` when the target protocol is + // `https:`, so an http.Agent would open a plaintext socket to a TLS port. + // External OpenCode servers can be configured over https via OPENCODE_HOST. + it('returns an https agent for https targets', () => { + const agent = createOpenCodeProxyAgent('https://opencode.example.com:4096'); + + expect(agent).toBeInstanceOf(https.Agent); + expect(agent.options.keepAlive).toBe(true); + }); + + it('returns a plain http agent for http targets', () => { + const agent = createOpenCodeProxyAgent('http://127.0.0.1:4096'); + + // https.Agent extends http.Agent, so the negative assertion is the load-bearing one. + expect(agent).toBeInstanceOf(http.Agent); + expect(agent).not.toBeInstanceOf(https.Agent); + expect(agent.options.keepAlive).toBe(true); + }); + + it('falls back to an http agent for missing or unparseable targets', () => { + expect(createOpenCodeProxyAgent(undefined)).not.toBeInstanceOf(https.Agent); + expect(createOpenCodeProxyAgent('not a url')).not.toBeInstanceOf(https.Agent); + }); + + // The cold-start fix relies on http-proxy-middleware rebuilding its per-request + // options via `Object.assign({}, this.proxyOptions)` in prepareProxyRequest, + // which invokes getters. If that ever changes to a cached or shallow-reference + // copy, the agent would freeze at its registration-time value and https targets + // would silently regress — so pin the behavior here against the real library. + it('http-proxy-middleware re-reads the agent option on every proxied request', async () => { + let reads = 0; + let middleware; + const agent = createOpenCodeProxyAgent('http://127.0.0.1'); + const upstream = http.createServer((_req, res) => res.end('ok')); + const front = http.createServer((req, res) => { + middleware(req, res, () => { + res.statusCode = 502; + res.end(); + }); + }); + const clientAgent = new http.Agent({ keepAlive: true }); + + try { + const upstreamPort = await listen(upstream); + middleware = createProxyMiddleware({ + target: `http://127.0.0.1:${upstreamPort}`, + get agent() { + reads += 1; + return agent; + }, + }); + + // Construction itself must not read the getter — otherwise the assertion + // below could be satisfied without any per-request resolution happening. + expect(reads).toBe(0); + + const frontPort = await listen(front); + await request(frontPort, clientAgent); + expect(reads).toBe(1); + + await request(frontPort, clientAgent); + expect(reads).toBe(2); + } finally { + clientAgent.destroy(); + agent.destroy(); + await closeServer(front); + await closeServer(upstream); + } + }); +}); From 34e8a24b202769fd3a40510ddbd8d61301e96433 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 18 Aug 2026 02:59:04 +0300 Subject: [PATCH 013/215] feat(knowledge): rebuild the project notes panel as Project knowledge (#2973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel stored notes, todos and plans inside one shared JSON file that six unrelated domains also wrote to, synchronised itself through window CustomEvents, and could only read plans. It is now Project knowledge: server-owned storage with explicit routes, a store with rollback, a section sidebar, plans that open and edit in place, and search across all of it. Notes and plans the user pins travel with every message sent in that project. Pinning is project state, not an attachment to one message, so it holds until unpinned and the work status panel names what is riding along and can detach it. Agent memory is added alongside, in two scopes: what is true about the user, and what is true about this codebase. The split is not cosmetic — a wrong project fact costs one project and is noticed, while a wrong global fact quietly shapes every session everywhere and the user has no code to check it against. It stays separate from notes so an agent mistake cannot land in what the user wrote. Sessions receive an index of titles only; bodies are read on demand, because an index carrying full text grows until it crowds out the conversation. Deciding what a session must be told, and whether it has been told, now lives on the server. The client owned it before, which meant sessions started without a UI — scheduled tasks, sessions the agent dispatches — received nothing at all, and a tab's record of what it had sent outlived the conversation: after compaction the agent no longer held the block while the tab went on believing it did. What was delivered is recorded in the session's own metadata, and compaction restores it through the runtime that already restores pinned messages, in the same turn. Agent memory ships dark behind OPENCHAMBER_MEMORY_ENABLE: unset, there is no tool, no routes, no session index, no settings row and no panel tab. Absent rather than switched off, so nothing invites turning on a feature that has not been announced. Pinned notes and plans are unaffected and ship as normal. --- CHANGELOG.md | 2 + packages/ui/src/App.tsx | 5 + packages/ui/src/apps/MobileApp.tsx | 4 +- .../ui/src/apps/MobileWorkspaceDrawer.tsx | 2 +- packages/ui/src/apps/runtimeEndpointReset.ts | 4 + .../components/chat/message/MessageBody.tsx | 7 +- .../chat/message/TextSelectionMenu.tsx | 34 +- .../chat/message/parts/ToolPart.tsx | 1 + .../chat/message/parts/toolPresentation.tsx | 3 + .../work-status/WorkStatusContextSection.tsx | 128 +- packages/ui/src/components/icon/sprite.ts | 2 + .../ui/src/components/layout/ContextPanel.tsx | 2 +- .../components/layout/RightSidebarTabs.tsx | 7 +- .../openchamber/OpenChamberToolsSettings.tsx | 35 + .../session/ProjectNotesTodoPanel.tsx | 1056 ----------------- .../session/project-context/DOCUMENTATION.md | 224 ++++ .../session/project-context/KnowledgeCard.tsx | 82 ++ .../session/project-context/MemorySection.tsx | 277 +++++ .../session/project-context/NotesSection.tsx | 296 +++++ .../session/project-context/PlansSection.tsx | 255 ++++ .../project-context/ProjectNotesTodoPanel.tsx | 490 ++++++++ .../session/project-context/TodosSection.tsx | 348 ++++++ .../project-context/useProjectTodoSend.ts | 203 ++++ packages/ui/src/components/ui/textarea.tsx | 35 +- packages/ui/src/components/views/PlanView.tsx | 70 +- packages/ui/src/hooks/useAgentMemorySync.ts | 65 + packages/ui/src/lib/agentMemoryApi.ts | 199 ++++ packages/ui/src/lib/agentMemoryBadges.test.ts | 74 ++ packages/ui/src/lib/agentMemoryBadges.ts | 44 + packages/ui/src/lib/desktop.ts | 2 + .../ui/src/lib/i18n/messages/de.settings.ts | 3 + packages/ui/src/lib/i18n/messages/de.ts | 59 +- .../ui/src/lib/i18n/messages/en.settings.ts | 3 + packages/ui/src/lib/i18n/messages/en.ts | 59 +- .../ui/src/lib/i18n/messages/es.settings.ts | 3 + packages/ui/src/lib/i18n/messages/es.ts | 59 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 3 + packages/ui/src/lib/i18n/messages/fr.ts | 59 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 3 + packages/ui/src/lib/i18n/messages/ja.ts | 59 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 3 + packages/ui/src/lib/i18n/messages/ko.ts | 59 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 3 + packages/ui/src/lib/i18n/messages/pl.ts | 59 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 3 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 59 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 3 + packages/ui/src/lib/i18n/messages/uk.ts | 59 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 3 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 59 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 3 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 59 +- packages/ui/src/lib/openchamberConfig.ts | 384 +----- packages/ui/src/lib/openchamberEvents.ts | 33 +- packages/ui/src/lib/persistence.ts | 17 + packages/ui/src/lib/projectContextApi.ts | 339 ++++++ packages/ui/src/lib/sessionKnowledgeApi.ts | 114 ++ packages/ui/src/lib/settings/search.ts | 11 + packages/ui/src/lib/surfaces/registry.ts | 7 +- packages/ui/src/lib/toolHelpers.ts | 7 + packages/ui/src/stores/DOCUMENTATION.md | 3 + .../ui/src/stores/useAgentMemoryStore.test.ts | 213 ++++ packages/ui/src/stores/useAgentMemoryStore.ts | 159 +++ .../src/stores/useProjectContextStore.test.ts | 458 +++++++ .../ui/src/stores/useProjectContextStore.ts | 450 +++++++ packages/ui/src/stores/useUIStore.ts | 71 +- packages/ui/src/sync/session-ui-store.ts | 56 +- packages/web/server/index.js | 117 +- .../web/server/lib/agent-memory/actions.js | 243 ++++ .../server/lib/agent-memory/actions.test.js | 343 ++++++ .../server/lib/agent-memory/feature-flag.js | 19 + .../lib/agent-memory/feature-flag.test.js | 38 + .../lib/agent-memory/project-resolution.js | 56 + .../agent-memory/project-resolution.test.js | 85 ++ .../lib/agent-memory/routes.http.test.js | 270 +++++ .../web/server/lib/agent-memory/routes.js | 169 +++ .../web/server/lib/agent-memory/runtime.js | 427 +++++++ .../server/lib/agent-memory/runtime.test.js | 344 ++++++ .../lib/agent-memory/threat-patterns.js | 61 + .../lib/agent-memory/threat-patterns.test.js | 61 + .../server/lib/agent-tool/DOCUMENTATION.md | 14 + packages/web/server/lib/agent-tool/runtime.js | 76 +- .../web/server/lib/agent-tool/runtime.test.js | 87 +- .../server/lib/context-obligatory/runtime.js | 31 +- .../lib/context-obligatory/runtime.test.js | 99 ++ .../action-resolution.test.js | 65 + .../server/lib/openchamber-control/actions.js | 78 ++ .../server/lib/openchamber-control/service.js | 7 + .../server/lib/openchamber-sessions/routes.js | 14 + .../lib/opencode/feature-routes-runtime.js | 11 + .../server/lib/opencode/settings-helpers.js | 8 + .../server/lib/opencode/settings-runtime.js | 51 + .../lib/project-context/DOCUMENTATION.md | 157 +++ .../lib/project-context/routes.http.test.js | 264 +++++ .../web/server/lib/project-context/routes.js | 237 ++++ .../web/server/lib/project-context/runtime.js | 667 +++++++++++ .../lib/project-context/runtime.test.js | 498 ++++++++ .../web/server/lib/scheduled-tasks/runtime.js | 23 +- .../lib/session-knowledge/DOCUMENTATION.md | 82 ++ .../server/lib/session-knowledge/routes.js | 87 ++ .../server/lib/session-knowledge/runtime.js | 281 +++++ .../lib/session-knowledge/runtime.test.js | 240 ++++ 102 files changed, 10640 insertions(+), 1630 deletions(-) delete mode 100644 packages/ui/src/components/session/ProjectNotesTodoPanel.tsx create mode 100644 packages/ui/src/components/session/project-context/DOCUMENTATION.md create mode 100644 packages/ui/src/components/session/project-context/KnowledgeCard.tsx create mode 100644 packages/ui/src/components/session/project-context/MemorySection.tsx create mode 100644 packages/ui/src/components/session/project-context/NotesSection.tsx create mode 100644 packages/ui/src/components/session/project-context/PlansSection.tsx create mode 100644 packages/ui/src/components/session/project-context/ProjectNotesTodoPanel.tsx create mode 100644 packages/ui/src/components/session/project-context/TodosSection.tsx create mode 100644 packages/ui/src/components/session/project-context/useProjectTodoSend.ts create mode 100644 packages/ui/src/hooks/useAgentMemorySync.ts create mode 100644 packages/ui/src/lib/agentMemoryApi.ts create mode 100644 packages/ui/src/lib/agentMemoryBadges.test.ts create mode 100644 packages/ui/src/lib/agentMemoryBadges.ts create mode 100644 packages/ui/src/lib/projectContextApi.ts create mode 100644 packages/ui/src/lib/sessionKnowledgeApi.ts create mode 100644 packages/ui/src/stores/useAgentMemoryStore.test.ts create mode 100644 packages/ui/src/stores/useAgentMemoryStore.ts create mode 100644 packages/ui/src/stores/useProjectContextStore.test.ts create mode 100644 packages/ui/src/stores/useProjectContextStore.ts create mode 100644 packages/web/server/lib/agent-memory/actions.js create mode 100644 packages/web/server/lib/agent-memory/actions.test.js create mode 100644 packages/web/server/lib/agent-memory/feature-flag.js create mode 100644 packages/web/server/lib/agent-memory/feature-flag.test.js create mode 100644 packages/web/server/lib/agent-memory/project-resolution.js create mode 100644 packages/web/server/lib/agent-memory/project-resolution.test.js create mode 100644 packages/web/server/lib/agent-memory/routes.http.test.js create mode 100644 packages/web/server/lib/agent-memory/routes.js create mode 100644 packages/web/server/lib/agent-memory/runtime.js create mode 100644 packages/web/server/lib/agent-memory/runtime.test.js create mode 100644 packages/web/server/lib/agent-memory/threat-patterns.js create mode 100644 packages/web/server/lib/agent-memory/threat-patterns.test.js create mode 100644 packages/web/server/lib/openchamber-control/action-resolution.test.js create mode 100644 packages/web/server/lib/project-context/DOCUMENTATION.md create mode 100644 packages/web/server/lib/project-context/routes.http.test.js create mode 100644 packages/web/server/lib/project-context/routes.js create mode 100644 packages/web/server/lib/project-context/runtime.js create mode 100644 packages/web/server/lib/project-context/runtime.test.js create mode 100644 packages/web/server/lib/session-knowledge/DOCUMENTATION.md create mode 100644 packages/web/server/lib/session-knowledge/routes.js create mode 100644 packages/web/server/lib/session-knowledge/runtime.js create mode 100644 packages/web/server/lib/session-knowledge/runtime.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c32c38b5..5e76cc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans you pin travel with every message you send in that project until you unpin them. +- **Work status:** the Context sources section now names each pinned note and plan riding along with your messages, and its pin button unpins them from there. - **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). diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ad4bc2f4..ff0ffe7c 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -14,6 +14,7 @@ import { useTraySync } from '@/hooks/useTraySync'; import { useRouter } from '@/hooks/useRouter'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useWebNotificationStream } from '@/hooks/useWebNotificationStream'; +import { useAgentMemorySync } from '@/hooks/useAgentMemorySync'; import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -703,6 +704,10 @@ function App({ apis }: AppProps) { usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled }); useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled }); + // Loaded here rather than by the Memory tab: the session index is built from + // this snapshot, so leaving it to the panel meant a user who never opened + // Project notes sent every message with no memory index at all. + useAgentMemorySync(currentDirectory || null); usePwaInstallPrompt(); useWindowTitle(); diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 42d419c8..b790270c 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -109,7 +109,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc const [workspaceTab, setWorkspaceTab] = React.useState('changes'); // A plan opened from the workspace drawer's Notes tab, shown as a fullscreen // layer on top of it (back returns to the notes). - const [openPlan, setOpenPlan] = React.useState<{ path: string; title: string } | null>(null); + const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string } | null>(null); const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav'); // When set, the Changes surface opens directly into the per-file diff for this path. const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null); @@ -540,7 +540,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc > { closeSurface(); closeWorkspace(); diff --git a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx index b3c94c24..f0847aa1 100644 --- a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx +++ b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx @@ -105,7 +105,7 @@ export const MobileWorkspaceDrawer: React.FC<{ /** When set, the Changes tab opens directly into the per-file diff. */ pendingChangesDiff: { path: string; staged: boolean } | null; /** Notes tab: opens a plan fullscreen (layered above the drawer). */ - onOpenPlan: (plan: { path: string; title: string }) => void; + onOpenPlan: (plan: { id: string; title: string }) => void; /** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */ onOpenMcpSettings: () => void; variant?: 'drawer' | 'panel'; diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index b430cfc9..39c325fd 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -3,6 +3,7 @@ import type { RuntimeEndpointChangedDetail } from '@/lib/runtime-switch'; import { disposeTerminalInputTransport } from '@/lib/terminalApi'; import { useConfigStore } from '@/stores/useConfigStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useUIStore } from '@/stores/useUIStore'; @@ -52,6 +53,9 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD lastDisconnectReason: null, }); useProjectsStore.getState().resetForRuntimeSwitch(); + // Notes, todos, plans and the pinned-context bookkeeping are keyed by a + // path-derived project id, which two runtimes can collide on. + useProjectContextStore.getState().reset(); // Cross-project session list (mobile sessions sheet & co) belongs to the // previous instance — drop it so stale sessions can't linger after a switch. useGlobalSessionsStore.getState().resetForRuntimeSwitch(); diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 44e53291..fa0bbfa3 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -41,7 +41,7 @@ import { ToolRevealOnMount } from './parts/ToolRevealOnMount'; import { StaticToolRow } from './parts/ProgressiveGroup'; import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils'; import TurnActivity from '../components/TurnActivity'; -import { createProjectPlanFile } from '@/lib/openchamberConfig'; +import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useI18n } from '@/lib/i18n'; @@ -1509,7 +1509,7 @@ const AssistantMessageBody = React.memo(({ setIsSavingPlan(true); try { - const created = await createProjectPlanFile(currentProjectRef, { + const created = await useProjectContextStore.getState().createPlan(currentProjectRef, { title, body: assistantPlanText, }); @@ -1517,9 +1517,6 @@ const AssistantMessageBody = React.memo(({ toast.error(t('chat.messageBody.toast.savePlanFailed')); return; } - window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { - detail: { projectId: currentProjectRef.id }, - })); setIsPlanDialogOpen(false); toast.success(t('chat.messageBody.toast.planSaved')); } finally { diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index c8ecc9f4..ab1947c1 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -9,7 +9,8 @@ import { cn } from '@/lib/utils'; import { copyTextToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; -import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig'; +import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi'; +import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { summarizeSelectionForNotes } from '@/lib/smallModel'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; @@ -34,15 +35,9 @@ interface SelectionPayload { rect: DOMRect; } -const appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => { - const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '').slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH); - if (!trimmedInsight) { - return existingNotes; - } - - const trimmedNotes = existingNotes.trimEnd(); - return trimmedNotes ? `${trimmedNotes}\n${trimmedInsight}` : trimmedInsight; -}; +const normalizeDistilledInsight = (insight: string): string => ( + insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH) +); const DESKTOP_MENU_SIDE_MARGIN_PX = 8; const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280; @@ -366,19 +361,22 @@ export const TextSelectionMenu: React.FC = ({ containerR // Long selections are distilled into a compact note by the small model; // short ones (and any generation failure) go in verbatim. const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId); - const projectData = await getProjectNotesAndTodos(currentProjectRef); - const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText); - const saved = await saveProjectNotesAndTodos(currentProjectRef, { - notes: nextNotes, - todos: projectData.todos, + const insight = normalizeDistilledInsight(noteText); + if (!insight) { + toast.error(t('chat.textSelection.toast.addToNotesFailed')); + return; + } + // Recorded as its own note with provenance, so the distilled insight can + // later be traced back to the conversation it came from. + const saved = await useProjectContextStore.getState().createNote(currentProjectRef, { + body: insight, + source: 'selection', + ...(currentSessionId ? { origin: { sessionId: currentSessionId } } : {}), }); if (!saved) { toast.error(t('chat.textSelection.toast.addToNotesFailed')); return; } - window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', { - detail: { projectId: currentProjectRef.id }, - })); toast.success(t('chat.textSelection.toast.addToNotesSuccess')); hideMenu(); window.getSelection()?.removeAllRanges(); diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 9b627aef..29b5553b 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1259,6 +1259,7 @@ const ToolExpandedContent: React.FC = React.memo(({ const hasVisualDiffEntry = diffEntries.some((entry) => entry.renderMode === 'diff'); const hideToolInputPreview = part.tool === 'openchamber' || part.tool === 'openchamber_web' + || part.tool === 'openchamber_memory' || part.tool === 'apply_patch' || part.tool === 'edit' || part.tool === 'multiedit'; diff --git a/packages/ui/src/components/chat/message/parts/toolPresentation.tsx b/packages/ui/src/components/chat/message/parts/toolPresentation.tsx index 9219fde1..f4b627ba 100644 --- a/packages/ui/src/components/chat/message/parts/toolPresentation.tsx +++ b/packages/ui/src/components/chat/message/parts/toolPresentation.tsx @@ -59,6 +59,9 @@ export const getToolIcon = (toolName: string) => { if (tool === 'openchamber_web') { return ; } + if (tool === 'openchamber_memory') { + return ; + } if (tool === 'question') { return ; } diff --git a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx index 254b974a..73671442 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx @@ -5,6 +5,12 @@ import { useSkillsStore } from '@/stores/useSkillsStore'; import { useMcpStore } from '@/stores/useMcpStore'; import { useSession } from '@/sync/sync-context'; import { getLinkedIssues } from '@/lib/linkedIssues'; +import { fetchSessionKnowledgeSummary, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi'; +import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; +import { useProjectContextStore } from '@/stores/useProjectContextStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; import { useReportWorkStatusPresence } from './presenceContext'; @@ -43,6 +49,54 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory void loadSkills(); }, [directory, loadSkills]); + /** + * What the project sends along with every message. Read from the server + * rather than from the notes panel's store, because this must be right + * whether or not that panel has ever been opened. + */ + const [knowledge, setKnowledge] = React.useState( + { notes: [], plans: [], memory: { global: 0, project: 0 } }, + ); + + // Re-read whenever the stores that own pins or memory change, not only when + // the directory does. Unpinning is a write those stores make, and a panel + // that keeps listing what was just unpinned tells the user it is still going + // to the agent when it is not. + const contextEntries = useProjectContextStore((state) => state.entries); + const memoryProject = useAgentMemoryStore((state) => state.project); + const memoryGlobal = useAgentMemoryStore((state) => state.global); + + React.useEffect(() => { + let cancelled = false; + void fetchSessionKnowledgeSummary(directory).then((summary) => { + if (!cancelled) setKnowledge(summary); + }); + return () => { cancelled = true; }; + }, [directory, contextEntries, memoryProject, memoryGlobal]); + + const projects = useProjectsStore((state) => state.projects); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const setNotePinned = useProjectContextStore((state) => state.setNotePinned); + const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned); + + const projectRef = React.useMemo(() => { + const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory ?? ''); + return resolved ? { id: resolved.id, path: resolved.path } : null; + }, [availableWorktreesByProject, directory, projects]); + + // Unpinning from here, like the pinned-messages section: a panel that says + // what is attached should be able to detach it, or the user has to go find + // the surface that can. + const unpinNote = React.useCallback((noteId: string) => { + if (projectRef) void setNotePinned(projectRef, noteId, false); + }, [projectRef, setNotePinned]); + const unpinPlan = React.useCallback((planId: string) => { + if (projectRef) void setPlanPinned(projectRef, planId, false); + }, [projectRef, setPlanPinned]); + + const memoryCount = knowledge.memory.global + knowledge.memory.project; + const pinnedCount = knowledge.notes.length + knowledge.plans.length; + const linked = React.useMemo(() => getLinkedIssues(session), [session]); // Connected servers only. A disabled server contributes nothing to the // context, so counting it here contradicts the MCP section right above, @@ -52,9 +106,14 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory [mcpStatus], ); - useReportWorkStatusPresence('context-sources', linked.length > 0 || skills.length > 0 || mcpCount > 0); + useReportWorkStatusPresence( + 'context-sources', + linked.length > 0 || skills.length > 0 || mcpCount > 0 || pinnedCount > 0 || memoryCount > 0, + ); - if (linked.length === 0 && skills.length === 0 && mcpCount === 0) return null; + if (linked.length === 0 && skills.length === 0 && mcpCount === 0 && pinnedCount === 0 && memoryCount === 0) { + return null; + } // The heading names what is distinctive about this session when there is // something — an attached thread — and falls back to the ambient counts @@ -72,6 +131,14 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory ? t('chat.workStatus.breakdown.prCountSingle', { count: prCount }) : t('chat.workStatus.breakdown.prCountPlural', { count: prCount })); } + // Pinned knowledge outranks the ambient counts in the summary: it is + // something the user chose for this project, not something that happens to + // be installed. + if (summaryParts.length === 0 && pinnedCount > 0) { + summaryParts.push(pinnedCount === 1 + ? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount }) + : t('chat.workStatus.breakdown.pinnedKnowledgePlural', { count: pinnedCount })); + } if (summaryParts.length === 0) { if (skills.length > 0) { summaryParts.push(skills.length === 1 @@ -115,6 +182,63 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory /> ))} + {/* Named individually: a count alone would not tell the user which note + is riding along with every message they send. */} + {/* The pin is the control, exactly as in the pinned-messages section + above: same icon, same placement, same behaviour. Two pins that look + different in one panel would read as two different things. */} + {knowledge.notes.map((note) => ( + { + event.stopPropagation(); + unpinNote(note.id); + }} + className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40" + > + + + )} + label={note.body.trim().split('\n')[0] || note.body.trim()} + value={{t('chat.workStatus.breakdown.pinnedNote')}} + /> + ))} + {knowledge.plans.map((plan) => ( + { + event.stopPropagation(); + unpinPlan(plan.id); + }} + className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40" + > + + + )} + label={plan.title} + value={{t('chat.workStatus.breakdown.pinnedPlan')}} + /> + ))} + {memoryCount > 0 ? ( + {memoryCount}} + /> + ) : null} + `, "bar-chart-box": ``, "book": ``, + "book-marked": ``, "book-open": ``, "booklet": ``, "braces": ``, "brain": ``, + "brain-4": ``, "brain-ai-3": ``, "briefcase": ``, "bug": ``, diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index efdb91eb..8c24e134 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -940,7 +940,7 @@ export const ContextPanel: React.FC = () => { : activeTab?.mode === 'notes' ? : activeTab?.mode === 'plan' - ? + ? : null; const browserTabs = React.useMemo( diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index dbfb46f4..cd8b2f1e 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { ProjectNotesTodoPanel } from '@/components/session/ProjectNotesTodoPanel'; +import { ProjectNotesTodoPanel } from '@/components/session/project-context/ProjectNotesTodoPanel'; import { useGitStore } from '@/stores/useGitStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -8,7 +8,7 @@ import { formatDirectoryName } from '@/lib/utils'; export const ProjectContextPanel: React.FC<{ onActionComplete?: () => void; - onOpenPlan?: (plan: { path: string; title: string }) => void; + onOpenPlan?: (plan: { id: string; title: string }) => void; }> = ({ onActionComplete, onOpenPlan }) => { const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); @@ -49,7 +49,8 @@ export const ProjectContextPanel: React.FC<{ }, [activeProject, gitDirectories]); return ( -
+ /* The panel scrolls its own tab content; a scroller here would nest. */ +
{ const setAgentControlToolEnabled = useUIStore((state) => state.setAgentControlToolEnabled); const agentWebToolEnabled = useUIStore((state) => state.agentWebToolEnabled); const setAgentWebToolEnabled = useUIStore((state) => state.setAgentWebToolEnabled); + const agentMemoryToolEnabled = useUIStore((state) => state.agentMemoryToolEnabled); + // Absent, not merely off: the feature is finished but unreleased, and a + // visible switch invites turning on something that was never announced. + const agentMemoryAvailable = useUIStore((state) => state.agentMemoryFeatureAvailable); + const setAgentMemoryToolEnabled = useUIStore((state) => state.setAgentMemoryToolEnabled); const handleAgentControlToolChange = React.useCallback((enabled: boolean) => { setAgentControlToolEnabled(enabled); @@ -40,6 +46,24 @@ export const OpenChamberToolsSettings: React.FC = () => { recordDeferredOpenCodeRestart('cli', { id: 'agent-web-tool' }); }, [setAgentWebToolEnabled]); + // Turning memory off removes the whole feature, not just the tool: the panel + // tab goes with it and sessions stop being given the index. Showing the user + // what is stored would be pointless once the agent can no longer manage it. + const handleAgentMemoryToolChange = React.useCallback((enabled: boolean) => { + setAgentMemoryToolEnabled(enabled); + // Re-read after the write lands, not before. The switch flips the client + // immediately, which makes the panel ask the server straight away — and + // while the setting is still being written the server truthfully answers + // "disabled", which used to leave the tab hidden until a restart. + void updateDesktopSettings({ agentMemoryToolEnabled: enabled }) + .finally(() => { + if (enabled) { + void useAgentMemoryStore.getState().refresh(); + } + }); + recordDeferredOpenCodeRestart('cli', { id: 'agent-memory-tool' }); + }, [setAgentMemoryToolEnabled]); + return (
@@ -60,6 +84,17 @@ export const OpenChamberToolsSettings: React.FC = () => { ariaLabel={t('settings.openchamber.tools.field.agentWebToolAria')} info={t('settings.openchamber.tools.field.agentWebToolInfo')} /> + + {agentMemoryAvailable ? ( + + ) : null}
); diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx deleted file mode 100644 index 5f912444..00000000 --- a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx +++ /dev/null @@ -1,1056 +0,0 @@ -import React from 'react'; -import { - DndContext, - PointerSensor, - closestCenter, - useSensor, - useSensors, - type DragEndEvent, -} from '@dnd-kit/core'; -import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'; -import { CSS as DndCSS } from '@dnd-kit/utilities'; -import { toast } from '@/components/ui'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { Icon } from "@/components/icon/Icon"; -import { - deleteProjectPlanFile, - getProjectContextData, - importProjectPlanFileFromContent, - OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, - readProjectPlanFile, - OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH, - saveProjectNotesAndTodos, - type OpenChamberProjectPlanFileLink, - type OpenChamberProjectTodoItem, - type ProjectRef, -} from '@/lib/openchamberConfig'; -import { requestFileAccess } from '@/lib/desktop'; -import { generateBranchName } from '@/lib/git/branchNameGenerator'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useUIStore } from '@/stores/useUIStore'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSelectionStore } from '@/sync/selection-store'; -import { useInputStore } from '@/sync/input-store'; -import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; -import { cn } from '@/lib/utils'; -import { renderMagicPrompt } from '@/lib/magicPrompts'; -import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; -import { runtimeFetch } from '@/lib/runtime-fetch'; -import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog'; - -const TODO_PANEL_MIN_ITEMS = 5; -const TODO_PANEL_MAX_ITEMS = 15; - -// Per-project chain of in-flight saveProjectNotesAndTodos calls. Subsequent -// saves await the previous one so a fast todo toggle or blur that lands -// while the debounced notes save is still on the wire is appended, not -// racing against it. The chain is module-scoped so it survives remounts -// (e.g. when the user switches the right sidebar tab away and back). -const projectSaveChainByProject = new Map>(); - -const getEffectiveItemHeight = (padding: number) => { - const scale = Math.sqrt(padding / 100); - const paddingPx = 12 * scale; - const contentPx = 24 * scale; // h-6 uses --spacing-6 which also scales with --padding-scale - const borderPx = 1; - return Math.ceil(paddingPx + contentPx + borderPx); -}; - -const getPanelHeightForItems = (itemCount: number, padding: number) => { - const itemHeight = getEffectiveItemHeight(padding); - return Math.max( - itemHeight * TODO_PANEL_MIN_ITEMS, - Math.min(itemHeight * TODO_PANEL_MAX_ITEMS, itemHeight * itemCount) - ); -}; - -interface ProjectNotesTodoPanelProps { - projectRef: ProjectRef | null; - projectLabel?: string | null; - canCreateWorktree?: boolean; - onActionComplete?: () => void; - /** When provided, opening a plan calls this instead of the desktop context - panel tab — hosts without ContextPanel (mobile) render their own viewer. */ - onOpenPlan?: (plan: { path: string; title: string }) => void; - className?: string; -} - -type PendingSendTarget = { - kind: 'session' | 'worktree'; - todoId: string; - todoText: string; -}; - -type ProjectPlanListItem = OpenChamberProjectPlanFileLink & { - title: string; -}; - -const toPlanListItem = async ( - plan: OpenChamberProjectPlanFileLink, - fallbackTitle: string, -): Promise => { - const file = await readProjectPlanFile(plan.path); - return { - ...plan, - title: file?.title || plan.path.split('/').pop() || fallbackTitle, - }; -}; - -const createTodoId = (): string => { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID(); - } - return `todo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; -}; - -const sortTodosWithCompletedLast = (items: OpenChamberProjectTodoItem[]): OpenChamberProjectTodoItem[] => [ - ...items.filter((todo) => !todo.completed), - ...items.filter((todo) => todo.completed), -]; - -const insertTodoBeforeCompleted = (items: OpenChamberProjectTodoItem[], item: OpenChamberProjectTodoItem): OpenChamberProjectTodoItem[] => { - const firstCompletedIndex = items.findIndex((todo) => todo.completed); - if (firstCompletedIndex === -1) { - return [...items, item]; - } - return [...items.slice(0, firstCompletedIndex), item, ...items.slice(firstCompletedIndex)]; -}; - -type SortableTodoHandleProps = { - attributes: ReturnType['attributes']; - listeners: ReturnType['listeners']; - setActivatorNodeRef: ReturnType['setActivatorNodeRef']; - isDragging: boolean; -}; - -const SortableTodoItem: React.FC<{ - id: string; - children: (dragHandleProps: SortableTodoHandleProps) => React.ReactNode; -}> = ({ id, children }) => { - const { - attributes, - listeners, - setNodeRef, - setActivatorNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }); - - return ( -
  • - {children({ attributes, listeners, setActivatorNodeRef, isDragging })} -
  • - ); -}; - -export const ProjectNotesTodoPanel: React.FC = ({ - projectRef, - projectLabel, - canCreateWorktree = false, - onActionComplete, - onOpenPlan, - className, -}) => { - const { t } = useI18n(); - const [isLoading, setIsLoading] = React.useState(false); - const [notes, setNotes] = React.useState(''); - const [todos, setTodos] = React.useState([]); - const [newTodoText, setNewTodoText] = React.useState(''); - const [sendingTodoId, setSendingTodoId] = React.useState(null); - const [expandedTodoIds, setExpandedTodoIds] = React.useState>(() => new Set()); - const [plans, setPlans] = React.useState([]); - const [pendingSendTarget, setPendingSendTarget] = React.useState(null); - const [isSendDialogSubmitting, setIsSendDialogSubmitting] = React.useState(false); - const [contextReloadTick, setContextReloadTick] = React.useState(0); - const notesHydratedRef = React.useRef(false); - const lastSavedNotesRef = React.useRef(''); - const notesDebounceTimerRef = React.useRef(null); - const todoPanelHeight = useUIStore((state) => state.todoPanelHeight); - const setTodoPanelHeight = useUIStore((state) => state.setTodoPanelHeight); - const notesPanelHeight = useUIStore((state) => state.notesPanelHeight); - const setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight); - const [isTodoPanelResizing, setIsTodoPanelResizing] = React.useState(false); - const todoPanelStartYRef = React.useRef(0); - const todoPanelStartHeightRef = React.useRef(todoPanelHeight); - - const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const createSession = useSessionUIStore((state) => state.createSession); - const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession); - const sendMessage = useSessionUIStore((state) => state.sendMessage); - const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); - const setPendingInputText = useInputStore((state) => state.setPendingInputText); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory); - const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); - const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); - const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); - const padding = useUIStore((state) => state.padding); - - const persistProjectData = React.useCallback( - async (nextNotes: string, nextTodos: OpenChamberProjectTodoItem[]) => { - if (!projectRef) { - return false; - } - const key = projectRef.id; - // Serialize concurrent saves per project: a fast toggle/strike while the - // debounce-driven notes save is in flight no longer races the network. - const previous = projectSaveChainByProject.get(key) ?? Promise.resolve(); - const next = previous.catch(() => undefined).then(() => - saveProjectNotesAndTodos(projectRef, { - notes: nextNotes, - todos: nextTodos, - }) - ); - projectSaveChainByProject.set(key, next); - try { - const saved = await next; - if (!saved) { - toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed')); - } - return saved; - } finally { - if (projectSaveChainByProject.get(key) === next) { - projectSaveChainByProject.delete(key); - } - } - }, - [projectRef, t] - ); - - React.useEffect(() => { - if (!projectRef) { - setNotes(''); - setTodos([]); - setPlans([]); - setNewTodoText(''); - setExpandedTodoIds(new Set()); - return; - } - - let cancelled = false; - setIsLoading(true); - - (async () => { - try { - const data = await getProjectContextData(projectRef); - const nextPlans = await Promise.all( - data.plans.map((plan) => toPlanListItem(plan, t('rightSidebar.contextNotesTodo.plan.defaultTitle'))) - ); - if (cancelled) { - return; - } - setNotes(data.notes); - setTodos(sortTodosWithCompletedLast(data.todos)); - setPlans(nextPlans); - lastSavedNotesRef.current = data.notes; - notesHydratedRef.current = true; - setNewTodoText(''); - setExpandedTodoIds(new Set()); - } catch { - if (!cancelled) { - toast.error(t('rightSidebar.contextNotesTodo.toast.loadNotesFailed')); - setNotes(''); - setTodos([]); - setPlans([]); - lastSavedNotesRef.current = ''; - notesHydratedRef.current = true; - } - } finally { - if (!cancelled) { - setIsLoading(false); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [contextReloadTick, projectRef, t]); - - React.useEffect(() => { - if (!projectRef) { - return; - } - - const handleProjectContextRefresh = (event: Event) => { - const detail = (event as CustomEvent<{ projectId?: string }>).detail; - if (detail?.projectId && detail.projectId !== projectRef.id) { - return; - } - setContextReloadTick((previous) => previous + 1); - }; - - window.addEventListener('openchamber:project-plan-saved', handleProjectContextRefresh); - window.addEventListener('openchamber:project-notes-updated', handleProjectContextRefresh); - return () => { - window.removeEventListener('openchamber:project-plan-saved', handleProjectContextRefresh); - window.removeEventListener('openchamber:project-notes-updated', handleProjectContextRefresh); - }; - }, [projectRef]); - - React.useEffect(() => { - if (todos.length < 7) { - return; - } - const targetHeight = getPanelHeightForItems(todos.length, padding); - const minHeight = getEffectiveItemHeight(padding) * TODO_PANEL_MIN_ITEMS; - if ( - todoPanelHeight !== targetHeight - && (todoPanelHeight < minHeight || todoPanelHeight > targetHeight) - ) { - setTodoPanelHeight(targetHeight); - } - }, [todos.length, padding, todoPanelHeight, setTodoPanelHeight]); - - React.useEffect(() => { - if (!isTodoPanelResizing) { - return; - } - - const handlePointerMove = (event: PointerEvent) => { - const delta = event.clientY - todoPanelStartYRef.current; - const nextHeight = Math.min( - getEffectiveItemHeight(padding) * TODO_PANEL_MAX_ITEMS, - Math.max(getEffectiveItemHeight(padding) * TODO_PANEL_MIN_ITEMS, todoPanelStartHeightRef.current + delta) - ); - setTodoPanelHeight(nextHeight); - }; - - const handlePointerEnd = () => { - setIsTodoPanelResizing(false); - }; - - window.addEventListener('pointermove', handlePointerMove); - window.addEventListener('pointerup', handlePointerEnd, { once: true }); - window.addEventListener('pointercancel', handlePointerEnd, { once: true }); - - return () => { - window.removeEventListener('pointermove', handlePointerMove); - window.removeEventListener('pointerup', handlePointerEnd); - window.removeEventListener('pointercancel', handlePointerEnd); - }; - }, [isTodoPanelResizing, padding, setTodoPanelHeight]); - - const handleTodoPanelResizeStart = React.useCallback((event: React.PointerEvent) => { - setIsTodoPanelResizing(true); - todoPanelStartYRef.current = event.clientY; - todoPanelStartHeightRef.current = todoPanelHeight; - event.preventDefault(); - }, [todoPanelHeight]); - - const cancelNotesDebounce = React.useCallback(() => { - if (notesDebounceTimerRef.current !== null) { - window.clearTimeout(notesDebounceTimerRef.current); - notesDebounceTimerRef.current = null; - } - }, []); - - const handleNotesBlur = React.useCallback(() => { - cancelNotesDebounce(); - lastSavedNotesRef.current = notes; - void persistProjectData(notes, todos); - }, [cancelNotesDebounce, notes, persistProjectData, todos]); - - React.useEffect(() => { - if (!projectRef || !notesHydratedRef.current) { - return; - } - - if (notes === lastSavedNotesRef.current) { - return; - } - - notesDebounceTimerRef.current = window.setTimeout(() => { - notesDebounceTimerRef.current = null; - lastSavedNotesRef.current = notes; - void persistProjectData(notes, todos); - }, 400); - - return () => { - cancelNotesDebounce(); - }; - }, [cancelNotesDebounce, notes, persistProjectData, projectRef, todos]); - - React.useEffect(() => () => cancelNotesDebounce(), [cancelNotesDebounce]); - - const handleAddTodo = React.useCallback(() => { - const trimmed = newTodoText.trim(); - if (!trimmed) { - return; - } - - const nextTodos = insertTodoBeforeCompleted(todos, { - id: createTodoId(), - text: trimmed.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH), - completed: false, - createdAt: Date.now(), - }); - setTodos(nextTodos); - setNewTodoText(''); - void persistProjectData(notes, nextTodos); - }, [newTodoText, notes, persistProjectData, todos]); - - const handleToggleTodoExpanded = React.useCallback((id: string) => { - setExpandedTodoIds((previous) => { - const next = new Set(previous); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }, []); - - const handleToggleTodo = React.useCallback( - (id: string, completed: boolean) => { - const todo = todos.find((item) => item.id === id); - if (!todo || todo.completed === completed) { - return; - } - const remainingTodos = todos.filter((item) => item.id !== id); - const updatedTodo = { ...todo, completed }; - const nextTodos = completed - ? [...remainingTodos, updatedTodo] - : insertTodoBeforeCompleted(remainingTodos, updatedTodo); - setTodos(nextTodos); - void persistProjectData(notes, nextTodos); - }, - [notes, persistProjectData, todos] - ); - - const handleDeleteTodo = React.useCallback( - (id: string) => { - const nextTodos = todos.filter((todo) => todo.id !== id); - setTodos(nextTodos); - void persistProjectData(notes, nextTodos); - }, - [notes, persistProjectData, todos] - ); - - const handleClearCompletedTodos = React.useCallback(() => { - const nextTodos = todos.filter((todo) => !todo.completed); - if (nextTodos.length === todos.length) { - return; - } - setTodos(nextTodos); - void persistProjectData(notes, nextTodos); - }, [notes, persistProjectData, todos]); - - const handleTodoReorder = React.useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) { - return; - } - const oldIndex = todos.findIndex((todo) => todo.id === active.id); - const newIndex = todos.findIndex((todo) => todo.id === over.id); - if (oldIndex === -1 || newIndex === -1) { - return; - } - const nextTodos = sortTodosWithCompletedLast(arrayMove(todos, oldIndex, newIndex)); - setTodos(nextTodos); - void persistProjectData(notes, nextTodos); - }, - [notes, persistProjectData, todos] - ); - - const todoSensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) - ); - - const todoInputValue = newTodoText.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH); - const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0); - - const routeToChat = React.useCallback(() => { - setActiveMainTab('chat'); - setSessionSwitcherOpen(false); - }, [setActiveMainTab, setSessionSwitcherOpen]); - - const handleSendToNewSession = React.useCallback( - (todoId: string, todoText: string) => { - if (!projectRef || sendingTodoId) { - return; - } - setPendingSendTarget({ kind: 'session', todoId, todoText }); - }, - [projectRef, sendingTodoId] - ); - - const handleSendToCurrentSession = React.useCallback( - (todoText: string) => { - if (!currentSessionId) { - toast.error(t('rightSidebar.contextNotesTodo.toast.noActiveSession')); - return; - } - routeToChat(); - const fenced = `\`\`\`md\n${todoText}\n\`\`\``; - setPendingInputText(fenced, 'append'); - toast.success(t('rightSidebar.contextNotesTodo.toast.sentToCurrentSession')); - onActionComplete?.(); - }, - [currentSessionId, onActionComplete, routeToChat, setPendingInputText, t] - ); - - const handleSendToNewWorktreeSession = React.useCallback( - (todoId: string, todoText: string) => { - if (!projectRef || sendingTodoId) { - return; - } - if (!canCreateWorktree) { - toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo')); - return; - } - setPendingSendTarget({ kind: 'worktree', todoId, todoText }); - }, - [canCreateWorktree, projectRef, sendingTodoId, t] - ); - - const handleConfirmSend = React.useCallback( - async (execution: TodoSendExecution) => { - if (!projectRef || !pendingSendTarget) { - return; - } - - const visiblePrompt = await renderMagicPrompt('plan.todo.visible', { - todo_text: pendingSendTarget.todoText, - }); - const instructionsText = await renderMagicPrompt('plan.todo.instructions', { - todo_text: pendingSendTarget.todoText, - }); - const syntheticParts = [{ synthetic: true as const, text: instructionsText }]; - - setIsSendDialogSubmitting(true); - setSendingTodoId(pendingSendTarget.todoId); - - try { - routeToChat(); - - let sessionId: string | null = null; - let directoryHint: string | null = projectRef.path; - - if (pendingSendTarget.kind === 'worktree') { - if (!canCreateWorktree) { - toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo')); - return; - } - const created = await createWorktreeSessionForNewBranch(projectRef.path, generateBranchName()); - if (!created?.id) { - return; - } - sessionId = created.id; - directoryHint = created.path; - } else { - const session = await createSession(undefined, projectRef.path, null); - if (!session?.id) { - toast.error(t('rightSidebar.contextNotesTodo.toast.createSessionFailed')); - return; - } - sessionId = session.id; - directoryHint = session.directory ?? projectRef.path; - initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents ?? []); - } - - if (!sessionId) { - return; - } - - const selectionState = useSelectionStore.getState(); - selectionState.saveSessionModelSelection(sessionId, execution.providerID, execution.modelID); - if (execution.agent.trim()) { - selectionState.saveSessionAgentSelection(sessionId, execution.agent); - selectionState.saveAgentModelForSession(sessionId, execution.agent, execution.providerID, execution.modelID); - selectionState.saveAgentModelVariantForSession( - sessionId, - execution.agent, - execution.providerID, - execution.modelID, - execution.variant || undefined, - ); - } - - setCurrentSession(sessionId, directoryHint); - await sendMessage( - visiblePrompt, - execution.providerID, - execution.modelID, - execution.agent.trim() || undefined, - undefined, - undefined, - syntheticParts, - execution.variant || undefined, - ); - - toast.success( - pendingSendTarget.kind === 'worktree' - ? t('rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession') - : t('rightSidebar.contextNotesTodo.toast.sentToNewSession') - ); - setPendingSendTarget(null); - onActionComplete?.(); - } catch (error) { - const description = error instanceof Error ? error.message : undefined; - toast.error(t('rightSidebar.contextNotesTodo.toast.sendTodoFailed'), description ? { description } : undefined); - } finally { - setIsSendDialogSubmitting(false); - setSendingTodoId(null); - } - }, - [canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession, t] - ); - - const planFileInputRef = React.useRef(null); - const [isImportingPlan, setIsImportingPlan] = React.useState(false); - const [deletingPlanId, setDeletingPlanId] = React.useState(null); - - const handleDeletePlan = React.useCallback( - async (planId: string) => { - if (!projectRef || deletingPlanId) { - return; - } - setDeletingPlanId(planId); - try { - const ok = await deleteProjectPlanFile(projectRef, planId); - if (!ok) { - toast.error(t('rightSidebar.contextNotesTodo.toast.deletePlanFailed')); - return; - } - setPlans((previous) => previous.filter((entry) => entry.id !== planId)); - window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { - detail: { projectId: projectRef.id }, - })); - } finally { - setDeletingPlanId(null); - } - }, - [deletingPlanId, projectRef, t] - ); - - const handleTriggerUploadPlan = React.useCallback(async () => { - if (!projectRef || isImportingPlan) { - return; - } - const result = await requestFileAccess({ - defaultPath: projectRef.path, - filters: [ - { name: 'Plan files', extensions: ['md', 'markdown', 'txt'] }, - { name: 'All files', extensions: ['*'] }, - ], - }); - if (result.success && result.path) { - setIsImportingPlan(true); - try { - const params = new URLSearchParams({ - path: result.path, - allowOutsideWorkspace: 'true', - }); - if (result.outsideFileGrant) { - params.set('outsideFileGrant', result.outsideFileGrant); - } - const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' }); - if (!response.ok) { - toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed')); - return; - } - const text = await response.text(); - if (!text.trim()) { - toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty')); - return; - } - const fallbackTitle = result.path.split('/').pop()?.replace(/\.(md|markdown|txt)$/i, '').trim() || ''; - const created = await importProjectPlanFileFromContent(projectRef, text, fallbackTitle); - if (!created) { - toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed')); - return; - } - window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { - detail: { projectId: projectRef.id }, - })); - toast.success(t('rightSidebar.contextNotesTodo.toast.planImported')); - } catch (error) { - const description = error instanceof Error ? error.message : undefined; - toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined); - } finally { - setIsImportingPlan(false); - } - } else if (result.error === 'Native file picker not available') { - // Fall back to HTML file input for web/non-desktop runtimes - planFileInputRef.current?.click(); - } - }, [isImportingPlan, projectRef, t]); - - const handleUploadPlanFile = React.useCallback( - async (file: File | null) => { - if (!projectRef || !file) { - return; - } - setIsImportingPlan(true); - try { - const text = await file.text(); - if (!text.trim()) { - toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty')); - return; - } - const fallbackTitle = file.name.replace(/\.(md|markdown|txt)$/i, '').trim(); - const created = await importProjectPlanFileFromContent(projectRef, text, fallbackTitle); - if (!created) { - toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed')); - return; - } - window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { - detail: { projectId: projectRef.id }, - })); - toast.success(t('rightSidebar.contextNotesTodo.toast.planImported')); - } catch (error) { - const description = error instanceof Error ? error.message : undefined; - toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined); - } finally { - setIsImportingPlan(false); - } - }, - [projectRef, t] - ); - - const handleOpenPlan = React.useCallback( - (plan: ProjectPlanListItem) => { - if (onOpenPlan) { - onOpenPlan({ path: plan.path, title: plan.title }); - return; - } - const projectPath = projectRef?.path?.trim(); - const panelDirectory = currentDirectory?.trim() || projectPath; - if (!panelDirectory) { - return; - } - openContextPanelTab(panelDirectory, { - mode: 'plan', - targetPath: plan.path, - dedupeKey: plan.path, - label: plan.title, - }); - }, - [currentDirectory, onOpenPlan, openContextPanelTab, projectRef] - ); - - if (!projectRef) { - return ( -
    -

    - {t('rightSidebar.contextNotesTodo.empty.selectProject')} -

    -
    - ); - } - - return ( -
    -
    -
    -

    - {t('rightSidebar.contextNotesTodo.notes.title', { - project: projectLabel?.trim() || projectRef.path.split('/').filter(Boolean).pop() || projectRef.path, - })} -

    - {notes.length}/{OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH} -
    -