Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions extension/src/content/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


// ============================================================================
Expand Down Expand Up @@ -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();
}
}
});
49 changes: 49 additions & 0 deletions extension/src/content/rejection-filter.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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;
}
23 changes: 4 additions & 19 deletions extension/src/content/status-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/rejection-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
28 changes: 28 additions & 0 deletions tests/unit/status-bar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down