Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2bb4b8e
feat(plugins): add ChatGPT conversation export
TanChuping Aug 9, 2026
655ea91
fix(plugins): address ChatGPT export review
TanChuping Aug 9, 2026
1f0e159
fix(plugins): align PDF naming and language preference
TanChuping Aug 9, 2026
096a5f8
fix(plugins): verify ChatGPT rich export handoffs
TanChuping Aug 9, 2026
37bb145
fix(plugins): harden ChatGPT handoff navigation
TanChuping Aug 9, 2026
daab570
fix(plugins): preserve ChatGPT rich export content
TanChuping Aug 9, 2026
a5c6960
fix(plugins): serialize ChatGPT export transitions
TanChuping Aug 9, 2026
f9ba16e
fix(plugins): finish ChatGPT export delivery
TanChuping Aug 10, 2026
97618a7
fix(plugins): preserve complete ChatGPT snapshots
TanChuping Aug 10, 2026
4ffb9db
fix(plugins): close ChatGPT export branch gaps
TanChuping Aug 10, 2026
72ad036
fix(plugins): address final export review nits
TanChuping Aug 10, 2026
ea9f8af
fix(plugins): preserve export recovery state
TanChuping Aug 10, 2026
fa6165d
fix(plugins): avoid ambiguous selection remounts
TanChuping Aug 10, 2026
7003e4d
fix(plugins): stabilize ChatGPT export operations
TanChuping Aug 10, 2026
e00eb0a
fix(plugins): preserve ChatGPT handoff and prompt content
TanChuping Aug 10, 2026
7f68e9b
feat(plugins): add ChatGPT temporary chat handoff
Nagi-ovo Aug 12, 2026
64955bd
chore(plugins): align #921 with merged exporter
Nagi-ovo Aug 12, 2026
6a234a3
fix(plugins): harden ChatGPT temporary handoff recovery
Nagi-ovo Aug 13, 2026
f6c80bb
fix(plugins): clean up orphaned ChatGPT handoffs
Nagi-ovo Aug 13, 2026
188c944
fix(plugins): harden temporary chat handoff
TanChuping Aug 14, 2026
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
64 changes: 64 additions & 0 deletions .github/docs/REGRESSION_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,70 @@ Commit:

`fix(export): address ChatGPT export review findings`

## Temporary-chat handoff state must stay private and tab-scoped

Symptom:

Leaving temporary mode could fail when ChatGPT reused its composer node, write
the transcript into another page editor, or replay a cancelled handoff after the
plugin was re-enabled. Multiline insertion could also be misreported as failed.

Root cause:

The handoff relied on composer node replacement and an unconstrained textbox
fallback, compared multiline text against `textContent`, and stored the complete
transcript in page-owned `sessionStorage` without invalidating resume state on
plugin disposal. Moving the payload to extension storage without sweeping its
key also left orphaned transcripts behind when a tab closed and lost its token.

Fix:

Resolve only ChatGPT composer candidates in selector-priority order, accept a
usable same-node composer after temporary mode ends, compare normalized rendered
text, and keep the payload in extension storage behind a tab-scoped token that
is removed on success, cancellation, mismatch, or expiry. Sweep expired payload
keys on later handoff activity so a closed tab cannot retain its transcript
indefinitely.

Regression test:

`src/features/plugins/builtin/chatgptTemporaryHandoff/handoff.test.ts` (`accepts
a reused composer node and verifies multiline content by rendered text`,
`ignores another page editor before the real ChatGPT composer`, and `clears
pending state when plugin disposal cancels a resume`, and `sweeps an expired
orphan after its tab-scoped token is lost`).

Commit:

`fix(plugins): harden ChatGPT temporary handoff recovery`

## Temporary-chat handoff attachments need unique names

Symptom:

A second long temporary-chat handoff could reuse the first attachment preview
and insert only the new instruction, silently handing the old transcript to the
new chat.

Root cause:

Attachment recovery treats a visible matching filename as proof that the file
was already delivered, while the original filename contained only the date.

Fix:

Give every handoff a timestamp plus nonce and reuse that identity for both the
downloaded backup and the composer attachment.

Regression test:

`src/features/plugins/builtin/chatgptTemporaryHandoff/handoff.test.ts`
(`gives separate handoffs unique filenames even at the same instant`).

Commit:

`feat(plugins): add ChatGPT temporary chat handoff`

## Export fetch limits must not delete rendered content

Symptom:
Expand Down
13 changes: 13 additions & 0 deletions src/features/plugins/builtin/builtin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ describe('BUILTIN_PLUGINS', () => {
expect(exportPlugin?.i18n?.zh?.name).toBe('ChatGPT · 对话导出');
});

it('keeps temporary-chat handoff separate from conversation export', () => {
const handoff = BUILTIN_PLUGINS.find(
(plugin) => plugin.id === 'voyager.chatgpt-temporary-handoff',
);
expect(handoff).toBeDefined();
expect(handoff?.matches).toEqual(['https://chatgpt.com/*', 'https://chat.openai.com/*']);
expect(handoff?.contributes.styles ?? []).toEqual([]);
expect(handoff?.contributes.domOps ?? []).toEqual([]);
expect(handoff?.i18n?.zh?.name).toBe('ChatGPT · 临时对话反悔');
expect(handoff?.i18n?.zh_TW?.name).toBe('ChatGPT · 暫時對話反悔');
expect(handoff?.id).not.toBe('voyager.chatgpt-export');
});

it('includes the Claude timeline native function plugin', () => {
const timeline = BUILTIN_PLUGINS.find((m) => m.id === 'voyager.claude-timeline');
expect(timeline).toBeDefined();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';

import {
handleChatGptHandoffExpiryMessage,
reconcileChatGptHandoffExpiryAlarms,
startChatGptTemporaryHandoffBackgroundService,
} from './background';
import {
CHATGPT_HANDOFF_CANCEL_EXPIRY_MESSAGE,
CHATGPT_HANDOFF_SCHEDULE_EXPIRY_MESSAGE,
PENDING_HANDOFF_KEY,
PENDING_HANDOFF_TTL_MS,
pendingHandoffAlarmName,
} from './storage';

const NOW = Date.parse('2026-08-14T12:00:00Z');
const STORAGE_KEY = `${PENDING_HANDOFF_KEY}:test-tab-token`;

let stored: Record<string, unknown>;
let alarmListener: ((alarm: chrome.alarms.Alarm) => void) | undefined;

beforeEach(() => {
vi.clearAllMocks();
stored = {};
alarmListener = undefined;
(chrome.storage.local.get as unknown as Mock).mockImplementation(async () => ({ ...stored }));
(chrome.storage.local.remove as unknown as Mock).mockImplementation(
async (keys: string | string[]) => {
for (const key of Array.isArray(keys) ? keys : [keys]) delete stored[key];
},
);
(chrome.alarms.create as unknown as Mock).mockResolvedValue(undefined);
(chrome.alarms.clear as unknown as Mock).mockResolvedValue(true);
(chrome.alarms.onAlarm.addListener as unknown as Mock).mockImplementation(
(listener: (alarm: chrome.alarms.Alarm) => void) => {
alarmListener = listener;
},
);
});

describe('ChatGPT temporary handoff expiry service', () => {
it('schedules and cancels a one-time expiry alarm for a validated storage key', async () => {
const expiresAt = NOW + PENDING_HANDOFF_TTL_MS;

await expect(
handleChatGptHandoffExpiryMessage(
{
type: CHATGPT_HANDOFF_SCHEDULE_EXPIRY_MESSAGE,
payload: { storageKey: STORAGE_KEY, expiresAt },
},
NOW,
),
).resolves.toEqual({ ok: true });
expect(chrome.alarms.create).toHaveBeenCalledWith(pendingHandoffAlarmName(STORAGE_KEY), {
when: expiresAt,
});

await expect(
handleChatGptHandoffExpiryMessage({
type: CHATGPT_HANDOFF_CANCEL_EXPIRY_MESSAGE,
payload: { storageKey: STORAGE_KEY },
}),
).resolves.toEqual({ ok: true });
expect(chrome.alarms.clear).toHaveBeenCalledWith(pendingHandoffAlarmName(STORAGE_KEY));
});

it('rejects malformed keys without touching alarms or storage', async () => {
await expect(
handleChatGptHandoffExpiryMessage({
type: CHATGPT_HANDOFF_SCHEDULE_EXPIRY_MESSAGE,
payload: { storageKey: 'other-feature:key', expiresAt: NOW + 1_000 },
}),
).resolves.toEqual({ ok: false });

expect(chrome.alarms.create).not.toHaveBeenCalled();
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
});

it('reconciles fresh records and removes expired or malformed orphan records', async () => {
const freshKey = STORAGE_KEY;
const expiredKey = `${PENDING_HANDOFF_KEY}:expired-token`;
const malformedKey = `${PENDING_HANDOFF_KEY}:malformed-token`;
stored = {
[freshKey]: { storedAt: NOW - 1_000 },
[expiredKey]: { storedAt: NOW - PENDING_HANDOFF_TTL_MS - 1 },
[malformedKey]: { storedAt: 'not-a-number' },
unrelated: { storedAt: 0 },
};

await reconcileChatGptHandoffExpiryAlarms(NOW);

expect(chrome.alarms.create).toHaveBeenCalledWith(pendingHandoffAlarmName(freshKey), {
when: NOW - 1_000 + PENDING_HANDOFF_TTL_MS,
});
expect(stored).not.toHaveProperty(expiredKey);
expect(stored).not.toHaveProperty(malformedKey);
expect(stored).toHaveProperty('unrelated');
});

it('removes the matching pending record when its alarm fires', async () => {
stored[STORAGE_KEY] = { storedAt: NOW };
startChatGptTemporaryHandoffBackgroundService();

expect(alarmListener).toBeTypeOf('function');
alarmListener!({ name: pendingHandoffAlarmName(STORAGE_KEY)!, scheduledTime: NOW });

await vi.waitFor(() => expect(stored).not.toHaveProperty(STORAGE_KEY));
expect(chrome.storage.local.remove).toHaveBeenCalledWith(STORAGE_KEY);
});

it('retries the expiry alarm when storage deletion fails', async () => {
startChatGptTemporaryHandoffBackgroundService();
(chrome.storage.local.remove as unknown as Mock).mockRejectedValueOnce(
new Error('storage busy'),
);

alarmListener!({ name: pendingHandoffAlarmName(STORAGE_KEY)!, scheduledTime: NOW });

await vi.waitFor(() =>
expect(chrome.alarms.create).toHaveBeenCalledWith(pendingHandoffAlarmName(STORAGE_KEY), {
delayInMinutes: 1,
}),
);
});
});
104 changes: 104 additions & 0 deletions src/features/plugins/builtin/chatgptTemporaryHandoff/background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { logger } from '@/core/services/LoggerService';

import {
CHATGPT_HANDOFF_CANCEL_EXPIRY_MESSAGE,
CHATGPT_HANDOFF_SCHEDULE_EXPIRY_MESSAGE,
PENDING_HANDOFF_STORAGE_PREFIX,
PENDING_HANDOFF_TTL_MS,
isPendingHandoffStorageKey,
pendingHandoffAlarmName,
pendingHandoffStorageKeyFromAlarm,
} from './storage';

interface HandoffExpiryMessage {
readonly type:
| typeof CHATGPT_HANDOFF_SCHEDULE_EXPIRY_MESSAGE
| typeof CHATGPT_HANDOFF_CANCEL_EXPIRY_MESSAGE;
readonly payload?: {
readonly storageKey?: unknown;
readonly expiresAt?: unknown;
};
}

export function isChatGptHandoffExpiryMessage(message: unknown): message is HandoffExpiryMessage {
if (!message || typeof message !== 'object') return false;
const type = (message as { type?: unknown }).type;
return (
type === CHATGPT_HANDOFF_SCHEDULE_EXPIRY_MESSAGE ||
type === CHATGPT_HANDOFF_CANCEL_EXPIRY_MESSAGE
);
}

async function removePendingStorageKey(storageKey: string): Promise<void> {
await chrome.storage.local.remove(storageKey);
}

export async function handleChatGptHandoffExpiryMessage(
message: HandoffExpiryMessage,
now = Date.now(),
): Promise<{ ok: boolean }> {
const storageKey = message.payload?.storageKey;
if (!isPendingHandoffStorageKey(storageKey)) return { ok: false };
const alarmName = pendingHandoffAlarmName(storageKey);
if (!alarmName || !chrome.alarms?.clear) return { ok: false };

if (message.type === CHATGPT_HANDOFF_CANCEL_EXPIRY_MESSAGE) {
await chrome.alarms.clear(alarmName);
return { ok: true };
}

const expiresAt = message.payload?.expiresAt;
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return { ok: false };
if (expiresAt <= now) {
await removePendingStorageKey(storageKey);
await chrome.alarms.clear(alarmName);
return { ok: true };
}
if (!chrome.alarms.create) return { ok: false };
await chrome.alarms.create(alarmName, { when: expiresAt });
return { ok: true };
}

export async function reconcileChatGptHandoffExpiryAlarms(now = Date.now()): Promise<void> {
if (!chrome.alarms?.create) return;
const stored = await chrome.storage.local.get(null);
for (const [storageKey, value] of Object.entries(stored)) {
if (!storageKey.startsWith(PENDING_HANDOFF_STORAGE_PREFIX)) continue;
if (!isPendingHandoffStorageKey(storageKey) || !value || typeof value !== 'object') {
await removePendingStorageKey(storageKey);
continue;
}
const storedAt = (value as { storedAt?: unknown }).storedAt;
if (typeof storedAt !== 'number' || !Number.isFinite(storedAt)) {
await removePendingStorageKey(storageKey);
continue;
}
const expiresAt = storedAt + PENDING_HANDOFF_TTL_MS;
if (expiresAt <= now || storedAt > now + 5_000) {
await removePendingStorageKey(storageKey);
continue;
}
const alarmName = pendingHandoffAlarmName(storageKey);
if (alarmName) await chrome.alarms.create(alarmName, { when: expiresAt });
}
}

export function startChatGptTemporaryHandoffBackgroundService(): void {
void reconcileChatGptHandoffExpiryAlarms().catch((error) => {
logger.warn('ChatGPT handoff expiry reconciliation failed', { error: String(error) });
});
chrome.alarms?.onAlarm?.addListener((alarm) => {
const storageKey = pendingHandoffStorageKeyFromAlarm(alarm.name);
if (!storageKey) return;
void removePendingStorageKey(storageKey).catch(async (error) => {
logger.warn('ChatGPT handoff expiry cleanup failed', { error: String(error) });
try {
await chrome.alarms.create(alarm.name, { delayInMinutes: 1 });
} catch (retryError) {
logger.warn('ChatGPT handoff expiry retry scheduling failed', {
error: String(retryError),
});
}
});
});
}
Loading
Loading