From 3df29fccb7a2053ae7c99d4354ae848dbda3631b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 30 Mar 2026 00:20:02 -0700 Subject: [PATCH] fix: prevent repeat chat message rendering on reconnect/replay Root cause: server persists chat to disk (chat.jsonl) and replays on restart. Client had no dedup, so every reconnect re-rendered the entire history. Messages from an old HN session would repeat endlessly on the SF Chronicle tab. Fix: renderedEntryIds Set tracks which entry IDs have been rendered. addChatEntry skips entries already in the set. Entries without an id (local notifications) bypass the check. Clear chat resets the set. Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/test/sidebar-ux.test.ts | 34 ++++++++++++++++++++++++++++++++++ extension/sidepanel.js | 11 +++++++++++ 2 files changed, 45 insertions(+) diff --git a/browse/test/sidebar-ux.test.ts b/browse/test/sidebar-ux.test.ts index c03b18cd..a96f57a2 100644 --- a/browse/test/sidebar-ux.test.ts +++ b/browse/test/sidebar-ux.test.ts @@ -940,6 +940,40 @@ describe('chat toolbar buttons disabled state', () => { }); }); +// ─── Chat message dedup ───────────────────────────────────────── + +describe('chat message dedup (prevents repeat rendering)', () => { + const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); + + test('renderedEntryIds Set exists for dedup tracking', () => { + expect(js).toContain('const renderedEntryIds = new Set()'); + }); + + test('addChatEntry checks entry.id against renderedEntryIds', () => { + const addFn = js.slice( + js.indexOf('function addChatEntry(entry)'), + js.indexOf('\n // User messages', js.indexOf('function addChatEntry(entry)')), + ); + expect(addFn).toContain('renderedEntryIds.has(entry.id)'); + expect(addFn).toContain('renderedEntryIds.add(entry.id)'); + // Should return early (skip) if already rendered + expect(addFn).toContain('return'); + }); + + test('addChatEntry skips dedup for entries without id (local notifications)', () => { + const addFn = js.slice( + js.indexOf('function addChatEntry(entry)'), + js.indexOf('\n // User messages', js.indexOf('function addChatEntry(entry)')), + ); + // Should only check dedup when entry.id is defined + expect(addFn).toContain('entry.id !== undefined'); + }); + + test('clear chat resets renderedEntryIds', () => { + expect(js).toContain('renderedEntryIds.clear()'); + }); +}); + // ─── LLM-based cleanup architecture ───────────────────────────── describe('LLM-based cleanup (smart agent cleanup)', () => { diff --git a/extension/sidepanel.js b/extension/sidepanel.js index bfc28e88..9e6626fc 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -102,7 +102,17 @@ let agentContainer = null; // The container for the current agent response let agentTextEl = null; // The text accumulator element let agentText = ''; // Accumulated text +// Dedup: track which entry IDs have already been rendered to prevent +// repeat rendering on reconnect or tab switch (server replays from disk) +const renderedEntryIds = new Set(); + function addChatEntry(entry) { + // Dedup by entry ID — prevent repeat rendering on reconnect/replay + if (entry.id !== undefined) { + if (renderedEntryIds.has(entry.id)) return; + renderedEntryIds.add(entry.id); + } + // Remove welcome message on first real message const welcome = chatMessages.querySelector('.chat-welcome'); if (welcome) welcome.remove(); @@ -577,6 +587,7 @@ document.getElementById('clear-chat').addEventListener('click', async () => { } catch {} // Reset local state chatLineCount = 0; + renderedEntryIds.clear(); agentContainer = null; agentTextEl = null; agentText = '';