From 43460059b0c371d8124a8ee5e4863af19c9bb118 Mon Sep 17 00:00:00 2001 From: limerc Date: Mon, 9 Feb 2026 01:15:23 +0300 Subject: [PATCH 1/2] fix(content): don't suppress non-extension unhandledrejection Only call preventDefault() for unhandledrejection events attributable to LightSession; keep site errors visible in DevTools. Adds a small filter helper + unit tests. --- extension/src/content/content.ts | 22 +++++++++- extension/src/content/rejection-filter.ts | 49 +++++++++++++++++++++++ tests/unit/rejection-filter.test.ts | 35 ++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 extension/src/content/rejection-filter.ts create mode 100644 tests/unit/rejection-filter.test.ts diff --git a/extension/src/content/content.ts b/extension/src/content/content.ts index f19f92b..9d395ac 100644 --- a/extension/src/content/content.ts +++ b/extension/src/content/content.ts @@ -22,6 +22,7 @@ import { } from './status-bar'; import { isEmptyChatView } from './chat-view'; import { installUserCollapse, type UserCollapseController } from './user-collapse'; +import { isLightSessionRejection } from './rejection-filter'; // ============================================================================ @@ -412,6 +413,23 @@ window.addEventListener('error', (event) => { }); window.addEventListener('unhandledrejection', (event) => { - logError('Unhandled promise rejection:', event.reason); - event.preventDefault(); + let extensionUrlPrefix: string | undefined; + try { + extensionUrlPrefix = browser?.runtime?.getURL?.(''); + } catch { + extensionUrlPrefix = undefined; + } + + // Do not suppress site (ChatGPT) errors. Only suppress if it clearly originates from LightSession. + const strictIsOurs = isLightSessionRejection(event.reason, extensionUrlPrefix); + const looseIsOurs = isLightSessionRejection(event.reason); + + if (strictIsOurs || looseIsOurs) { + logError('Unhandled promise rejection:', event.reason); + + // Only suppress default reporting if we are confident this originates from our extension. + if (strictIsOurs) { + event.preventDefault(); + } + } }); diff --git a/extension/src/content/rejection-filter.ts b/extension/src/content/rejection-filter.ts new file mode 100644 index 0000000..d853d43 --- /dev/null +++ b/extension/src/content/rejection-filter.ts @@ -0,0 +1,49 @@ +/** + * Helpers for filtering global error events so we don't suppress site errors. + * + * The content script can observe `window` errors/rejections originating from the page. + * Calling `preventDefault()` on these events suppresses the browser's default reporting, + * so we must only suppress errors that are clearly caused by LightSession itself. + */ + +export function isLightSessionRejection( + reason: unknown, + extensionUrlPrefix?: string, +): boolean { + const parts: string[] = []; + + if (typeof reason === 'string') { + parts.push(reason); + } else if (reason instanceof Error) { + if (typeof reason.message === 'string') parts.push(reason.message); + if (typeof reason.stack === 'string') parts.push(reason.stack); + if (typeof reason.name === 'string') parts.push(reason.name); + } else if (typeof reason === 'object' && reason !== null) { + const r = reason as Record; + if (typeof r.message === 'string') parts.push(r.message); + if (typeof r.stack === 'string') parts.push(r.stack); + if (typeof r.name === 'string') parts.push(r.name); + // Some browsers use different keys on Error-like objects. + if (typeof r.filename === 'string') parts.push(r.filename); + if (typeof r.fileName === 'string') parts.push(r.fileName); + } + + if (parts.length === 0) return false; + + const haystack = parts.join('\n'); + + // The most reliable signal: our own extension base URL. + // If we have it, prefer it exclusively to avoid suppressing unrelated site errors. + if (extensionUrlPrefix) { + return haystack.includes(extensionUrlPrefix); + } + + // Fallback heuristics (only used if runtime URL isn't available). + // Our logger prefix sometimes appears in thrown messages. + if (haystack.includes('LS:')) return true; + + // Useful in dev builds / source maps. + if (haystack.includes('light-session')) return true; + + return false; +} diff --git a/tests/unit/rejection-filter.test.ts b/tests/unit/rejection-filter.test.ts new file mode 100644 index 0000000..d1d3812 --- /dev/null +++ b/tests/unit/rejection-filter.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { isLightSessionRejection } from '../../extension/src/content/rejection-filter'; + +describe('isLightSessionRejection', () => { + it('returns false for empty/unknown reasons', () => { + expect(isLightSessionRejection(undefined)).toBe(false); + expect(isLightSessionRejection(null)).toBe(false); + expect(isLightSessionRejection(123)).toBe(false); + expect(isLightSessionRejection({})).toBe(false); + }); + + it('matches when reason string contains LS:', () => { + expect(isLightSessionRejection('LS: boom')).toBe(true); + }); + + it('does not match LS: in message when extension URL is provided but not present', () => { + const prefix = 'chrome-extension://abc123/'; + expect(isLightSessionRejection('LS: boom', prefix)).toBe(false); + }); + + it('matches when Error.stack contains the extension base URL', () => { + const prefix = 'chrome-extension://abc123/'; + const err = new Error('nope'); + // Simulate a stack that points at our bundled content script URL. + err.stack = `Error: nope\n at doThing (${prefix}dist/content.js:1:1)`; + expect(isLightSessionRejection(err, prefix)).toBe(true); + }); + + it('does not match non-LightSession errors by default', () => { + const err = new Error('Some site error'); + err.stack = `Error: Some site error\n at foo (https://chatgpt.com/app.js:1:1)`; + expect(isLightSessionRejection(err)).toBe(false); + }); +}); From 1e306c1ab7a87c6ca1d9ae8cdcc671420c2cfdf7 Mon Sep 17 00:00:00 2001 From: limerc Date: Mon, 9 Feb 2026 01:23:44 +0300 Subject: [PATCH 2/2] fix(status-bar): don't overcount trimmed messages Page script reports an absolute removed/hidden count per event, so accumulating across repeated lightsession-status events inflates the pill number. Display the current value directly and add a regression test. --- extension/src/content/status-bar.ts | 23 ++++------------------- tests/unit/status-bar.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/extension/src/content/status-bar.ts b/extension/src/content/status-bar.ts index b61e108..7281a2f 100644 --- a/extension/src/content/status-bar.ts +++ b/extension/src/content/status-bar.ts @@ -19,7 +19,6 @@ type StatusBarState = 'active' | 'waiting' | 'all-visible' | 'unrecognized'; let currentStats: StatusBarStats | null = null; let isVisible = true; -let accumulatedTrimmed = 0; // Throttle status bar updates to reduce DOM writes during active chat let lastUpdateTime = 0; @@ -174,21 +173,9 @@ function renderWaitingStatusBar(bar: HTMLElement): void { * Update the status bar with new stats (throttled, with change detection) */ export function updateStatusBar(stats: StatusBarStats): void { - // Reset accumulated count when entering a new/empty chat - if (stats.totalMessages === 0) { - accumulatedTrimmed = 0; - } - - // Accumulate trimmed count - if (stats.trimmedMessages > 0) { - accumulatedTrimmed += stats.trimmedMessages; - } - - // Use accumulated count for display - const displayStats: StatusBarStats = { - ...stats, - trimmedMessages: accumulatedTrimmed, - }; + // Page-script reports an absolute "currently hidden" count (not a delta), + // so the status bar must not accumulate across repeated status events. + const displayStats: StatusBarStats = stats; // Change detection: skip if stats haven't changed if (statsEqual(displayStats, currentStats)) { @@ -298,15 +285,13 @@ export function removeStatusBar(): void { } currentStats = null; isVisible = false; - accumulatedTrimmed = 0; lastUpdateTime = 0; } /** - * Reset accumulated trimmed counter (call on chat navigation) + * Reset status bar state (call on chat navigation / empty chat) */ export function resetAccumulatedTrimmed(): void { - accumulatedTrimmed = 0; currentStats = null; pendingStats = null; if (pendingUpdateTimer !== null) { diff --git a/tests/unit/status-bar.test.ts b/tests/unit/status-bar.test.ts index 2ce490a..da4626e 100644 --- a/tests/unit/status-bar.test.ts +++ b/tests/unit/status-bar.test.ts @@ -56,6 +56,34 @@ describe('status bar behavior', () => { expect(resetBar?.textContent).toBe(WAITING_TEXT); }); + it('does not accumulate trimmed messages across repeated status events', () => { + vi.useFakeTimers(); + showStatusBar(); + + updateStatusBar({ + totalMessages: 8, + visibleMessages: 3, + trimmedMessages: 5, + keepLastN: 3, + }); + vi.advanceTimersByTime(TIMING.STATUS_BAR_THROTTLE_MS); + + const bar = document.getElementById('lightsession-status-bar'); + expect(bar?.textContent).toBe('LightSession · last 3 · 5 trimmed'); + + // Repeated status event with the same absolute "currently trimmed" count + updateStatusBar({ + totalMessages: 8, + visibleMessages: 3, + trimmedMessages: 5, + keepLastN: 3, + }); + vi.advanceTimersByTime(TIMING.STATUS_BAR_THROTTLE_MS); + + const bar2 = document.getElementById('lightsession-status-bar'); + expect(bar2?.textContent).toBe('LightSession · last 3 · 5 trimmed'); + }); + it('refreshes the status bar if the DOM node is removed', () => { vi.useFakeTimers(); showStatusBar();