diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js
index 3a03ec72a..d6d540010 100644
--- a/src/chrome/src/background.js
+++ b/src/chrome/src/background.js
@@ -37,6 +37,7 @@ import {
buildSelectionPrompt,
createContextMenuStorage,
} from './context-menu-storage.js';
+import { createTabChatHandoffCoordinator } from './ui/tab-chat-persistence.js';
import {
prepareRecordingHost,
startTabRecording,
@@ -164,6 +165,21 @@ function getContextMenuPromptStore() {
}
const contextMenuStorage = createContextMenuStorage(getContextMenuPromptStore);
+const tabChatHandoff = createTabChatHandoffCoordinator(chrome.storage.session, {
+ requestHandoff: async (tabId, { ownerId, generation }) => {
+ try {
+ return await chrome.runtime.sendMessage({
+ target: 'sidepanel',
+ action: 'tab_chat_handoff_request',
+ tabId,
+ ownerId,
+ generation,
+ });
+ } catch {
+ return null;
+ }
+ },
+});
function createContextMenus() {
if (!chrome.contextMenus?.create) return;
@@ -1689,8 +1705,8 @@ chrome.tabs.onRemoved.addListener((tabId) => {
clearTimeout(pendingContextMenuNotifications.get(tabId));
pendingContextMenuNotifications.delete(tabId);
contextMenuStorage.cleanup(tabId);
+ tabChatHandoff.clear(tabId).catch(() => {});
savePanelTabs();
- chrome.storage.session?.remove(`tabChat:${tabId}`).catch(() => {});
scheduler.cancelForTab(tabId).catch(() => {});
agent.clearDevCssPatchesForTab(tabId).catch(() => {});
try { agent._cleanupTab(tabId); } catch { /* ignore */ }
@@ -1928,7 +1944,13 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
});
async function handleMessage(msg, sender) {
- const lightweightAction = msg.action === 'get_recording_state';
+ const lightweightAction = [
+ 'get_recording_state',
+ 'persist_tab_chat',
+ 'load_tab_chat',
+ 'clear_tab_chat',
+ 'release_context_menu_prompt_claim',
+ ].includes(msg.action);
if (!lightweightAction) {
// Ensure providers are loaded
if (providerManager.providers.size === 0) {
@@ -2151,8 +2173,41 @@ async function handleMessage(msg, sender) {
};
}
- case 'chat_start':
- return launchDetachedRun('chat', msg, sender);
+ case 'chat_start': {
+ const claim = msg.contextMenuClaim;
+ if (!claim?.promptId || !claim?.claimantId) {
+ return launchDetachedRun('chat', msg, sender);
+ }
+ const tabId = msg.tabId || sender.tab?.id;
+ try {
+ const reservation = await contextMenuStorage.reserve(
+ tabId,
+ claim.promptId,
+ claim.claimantId,
+ () => launchDetachedRun('chat', msg, sender),
+ );
+ if (reservation?.reserved) return reservation;
+ return {
+ ok: false,
+ accepted: false,
+ code: 'context-menu-reservation-rejected',
+ reason: reservation?.reason || 'claim-lost',
+ leaseExpiresAt: reservation?.leaseExpiresAt,
+ retryAfterMs: reservation?.reason === 'run-active' ? 1_000 : undefined,
+ };
+ } catch (error) {
+ if (/run is already active/i.test(String(error?.message || ''))) {
+ return {
+ ok: false,
+ accepted: false,
+ code: 'context-menu-reservation-rejected',
+ reason: 'run-active',
+ retryAfterMs: 1_000,
+ };
+ }
+ throw error;
+ }
+ }
case 'continue_start':
return launchDetachedRun('continue', msg, sender);
@@ -2586,11 +2641,65 @@ async function handleMessage(msg, sender) {
return await contextMenuStorage.consume(tabId);
}
+ case 'claim_context_menu_prompt': {
+ const tabId = msg.tabId || sender.tab?.id;
+ if (!tabId) return { ok: false, claimed: false, error: 'No tab ID' };
+ return await contextMenuStorage.claim(
+ tabId,
+ msg.promptId,
+ msg.claimantId,
+ () => agent.activeRunState(tabId)?.running || detachedRunStarts.has(tabId),
+ );
+ }
+
+ case 'release_context_menu_prompt_claim': {
+ const tabId = msg.tabId || sender.tab?.id;
+ const result = await contextMenuStorage.release(
+ tabId,
+ msg.promptId,
+ msg.claimantId,
+ );
+ if (result?.released && result.prompt?.text) {
+ notifySidePanelOfContextMenuPrompt(result.prompt);
+ }
+ return result;
+ }
+
case 'clear_context_menu_prompt': {
const tabId = msg.tabId || sender.tab?.id;
return await contextMenuStorage.clear(tabId, msg.promptId);
}
+ case 'persist_tab_chat':
+ return await tabChatHandoff.save(msg.tabId || sender.tab?.id, msg.html, {
+ ownerId: msg.handoffOwnerId,
+ handoffGeneration: msg.handoffGeneration,
+ });
+
+ case 'load_tab_chat':
+ return await tabChatHandoff.load(msg.tabId || sender.tab?.id, {
+ waitForHandoff: msg.waitForHandoff === true,
+ claimantId: msg.handoffOwnerId,
+ });
+
+ case 'clear_tab_chat': {
+ const tabId = msg.tabId || sender.tab?.id;
+ const result = await tabChatHandoff.clear(tabId, {
+ ownerId: msg.handoffOwnerId,
+ handoffGeneration: msg.handoffGeneration,
+ });
+ if (result?.ok && !result.skipped) {
+ chrome.runtime.sendMessage({
+ target: 'sidepanel',
+ action: 'tab_chat_cleared',
+ tabId,
+ handoffOwnerId: result.handoffOwnerId,
+ handoffGeneration: result.handoffGeneration,
+ }).catch(() => {});
+ }
+ return result;
+ }
+
case 'list_scheduled_jobs': {
const tabId = msg.tabId || sender.tab?.id || null;
return { ok: true, jobs: await scheduler.listJobs({ tabId: msg.all ? null : tabId }) };
diff --git a/src/chrome/src/context-menu-storage.js b/src/chrome/src/context-menu-storage.js
index be08d3490..9fa6a2a38 100644
--- a/src/chrome/src/context-menu-storage.js
+++ b/src/chrome/src/context-menu-storage.js
@@ -125,18 +125,25 @@ export function buildContextMenuPrompt(selectionText) {
}
const CONTEXT_MENU_PENDING_PREFIX = 'contextMenuPrompt:';
+const CONTEXT_MENU_CLAIM_PREFIX = 'contextMenuPromptClaim:';
+export const CONTEXT_MENU_CLAIM_LEASE_MS = 15_000;
/**
* @param {() => (chrome.storage.StorageArea | browser.storage.StorageArea | null)} getStore
*/
export function createContextMenuStorage(getStore) {
const pending = new Map();
+ const claims = new Map();
const operations = new Map();
function key(tabId) {
return `${CONTEXT_MENU_PENDING_PREFIX}${tabId}`;
}
+ function claimKey(tabId) {
+ return `${CONTEXT_MENU_CLAIM_PREFIX}${tabId}`;
+ }
+
function enqueue(tabId, fn) {
const numericTabId = Number(tabId);
if (!Number.isFinite(numericTabId)) return Promise.resolve({ ok: true });
@@ -189,18 +196,199 @@ export function createContextMenuStorage(getStore) {
return { ok: true, prompt: prompt?.text ? prompt : null };
}
+ async function claim(tabId, promptId, claimantId, isRunActive = () => false, now) {
+ const normalizedPromptId = String(promptId || '');
+ const normalizedClaimantId = String(claimantId || '');
+ if (!normalizedPromptId || !normalizedClaimantId) {
+ return { ok: false, claimed: false, error: 'Prompt ID and claimant ID are required.' };
+ }
+ return enqueue(tabId, async (numericTabId) => {
+ const k = key(numericTabId);
+ const ck = claimKey(numericTabId);
+ const store = getStore();
+ let prompt = pending.get(numericTabId) || null;
+ if (!prompt && store) {
+ try {
+ const stored = await store.get(k);
+ prompt = stored?.[k] || null;
+ } catch { /* best effort */ }
+ }
+ if (!prompt?.text || String(prompt.id || '') !== normalizedPromptId) {
+ return { ok: true, claimed: false, reason: 'missing' };
+ }
+
+ let activeClaim = claims.get(numericTabId) || null;
+ if (!activeClaim && store) {
+ try {
+ const stored = await store.get(ck);
+ activeClaim = stored?.[ck] || null;
+ } catch { /* best effort */ }
+ }
+ if (isRunActive()) {
+ return {
+ ok: true,
+ claimed: false,
+ reason: 'run-active',
+ retryAfterMs: 1_000,
+ };
+ }
+ const suppliedNow = Number(now);
+ const nowMs = now == null || !Number.isFinite(suppliedNow)
+ ? Date.now()
+ : suppliedNow;
+ const samePrompt = String(activeClaim?.promptId || '') === normalizedPromptId;
+ const activeLease = samePrompt && Number(activeClaim?.expiresAt || 0) > nowMs;
+ if (activeLease && String(activeClaim?.claimantId || '') !== normalizedClaimantId) {
+ return {
+ ok: true,
+ claimed: false,
+ reason: 'leased',
+ leaseExpiresAt: Number(activeClaim.expiresAt),
+ };
+ }
+
+ const nextClaim = {
+ promptId: normalizedPromptId,
+ claimantId: normalizedClaimantId,
+ expiresAt: nowMs + CONTEXT_MENU_CLAIM_LEASE_MS,
+ };
+ claims.set(numericTabId, nextClaim);
+ if (store) {
+ try { await store.set({ [ck]: nextClaim }); } catch { /* best effort */ }
+ }
+ return {
+ ok: true,
+ claimed: true,
+ leaseExpiresAt: nextClaim.expiresAt,
+ };
+ });
+ }
+
+ async function reserve(tabId, promptId, claimantId, onReserve, now) {
+ const normalizedPromptId = String(promptId || '');
+ const normalizedClaimantId = String(claimantId || '');
+ if (!normalizedPromptId || !normalizedClaimantId || typeof onReserve !== 'function') {
+ return { ok: false, reserved: false, reason: 'invalid' };
+ }
+ return enqueue(tabId, async (numericTabId) => {
+ const k = key(numericTabId);
+ const ck = claimKey(numericTabId);
+ const store = getStore();
+ let prompt = pending.get(numericTabId) || null;
+ if (!prompt && store) {
+ try {
+ const stored = await store.get(k);
+ prompt = stored?.[k] || null;
+ } catch { /* best effort */ }
+ }
+ if (!prompt?.text || String(prompt.id || '') !== normalizedPromptId) {
+ return { ok: true, reserved: false, reason: 'missing' };
+ }
+
+ let activeClaim = claims.get(numericTabId) || null;
+ if (!activeClaim && store) {
+ try {
+ const stored = await store.get(ck);
+ activeClaim = stored?.[ck] || null;
+ } catch { /* best effort */ }
+ }
+ const samePrompt = String(activeClaim?.promptId || '') === normalizedPromptId;
+ const sameClaimant = String(activeClaim?.claimantId || '') === normalizedClaimantId;
+ const leaseExpiresAt = Number(activeClaim?.expiresAt || 0);
+ const suppliedNow = Number(now);
+ const validationNow = now == null || !Number.isFinite(suppliedNow)
+ ? Date.now()
+ : suppliedNow;
+ const activeLease = leaseExpiresAt > validationNow;
+ if (!samePrompt || !sameClaimant || !activeLease) {
+ return {
+ ok: true,
+ reserved: false,
+ reason: activeClaim && activeLease ? 'leased' : 'claim-lost',
+ leaseExpiresAt: leaseExpiresAt || undefined,
+ };
+ }
+
+ // Invoke the reservation callback while this tab's storage operation is
+ // still exclusive. The callback synchronously installs the background
+ // run guard, so a queued claimant cannot slip between ownership
+ // validation and detached-run reservation.
+ const result = onReserve(numericTabId);
+ return { ...result, reserved: true };
+ });
+ }
+
+ async function release(tabId, promptId, claimantId) {
+ const normalizedPromptId = String(promptId || '');
+ const normalizedClaimantId = String(claimantId || '');
+ if (!normalizedPromptId || !normalizedClaimantId) {
+ return { ok: false, released: false, error: 'Prompt ID and claimant ID are required.' };
+ }
+ return enqueue(tabId, async (numericTabId) => {
+ const k = key(numericTabId);
+ const ck = claimKey(numericTabId);
+ const store = getStore();
+ let activeClaim = claims.get(numericTabId) || null;
+ if (!activeClaim && store) {
+ try {
+ const stored = await store.get(ck);
+ activeClaim = stored?.[ck] || null;
+ } catch { /* best effort */ }
+ }
+ const samePrompt = String(activeClaim?.promptId || '') === normalizedPromptId;
+ const sameClaimant = String(activeClaim?.claimantId || '') === normalizedClaimantId;
+ if (!samePrompt || !sameClaimant) {
+ return { ok: true, released: false, reason: activeClaim ? 'claim-lost' : 'missing' };
+ }
+
+ if (store) {
+ try {
+ await store.remove(ck);
+ } catch {
+ return { ok: false, released: false, reason: 'storage' };
+ }
+ }
+ claims.delete(numericTabId);
+
+ let prompt = pending.get(numericTabId) || null;
+ if (!prompt && store) {
+ try {
+ const stored = await store.get(k);
+ prompt = stored?.[k] || null;
+ } catch { /* best effort */ }
+ }
+ return {
+ ok: true,
+ released: true,
+ prompt: prompt?.text ? prompt : null,
+ };
+ });
+ }
+
async function clear(tabId, promptId) {
return enqueue(tabId, async (numericTabId) => {
const k = key(numericTabId);
+ const ck = claimKey(numericTabId);
const store = getStore();
const p = pending.get(numericTabId);
if (!promptId || p?.id === promptId) pending.delete(numericTabId);
+ const inMemoryClaim = claims.get(numericTabId);
+ if (!promptId || inMemoryClaim?.promptId === promptId) claims.delete(numericTabId);
if (store) {
+ const keysToRemove = [];
try {
const stored = await store.get(k);
const storedPrompt = stored?.[k] || null;
- if (!promptId || storedPrompt?.id === promptId) await store.remove(k);
+ if (!promptId || storedPrompt?.id === promptId) keysToRemove.push(k);
} catch { /* best effort */ }
+ try {
+ const stored = await store.get(ck);
+ const storedClaim = stored?.[ck] || null;
+ if (!promptId || storedClaim?.promptId === promptId) keysToRemove.push(ck);
+ } catch { /* best effort */ }
+ if (keysToRemove.length) {
+ try { await store.remove(keysToRemove); } catch { /* best effort */ }
+ }
}
return { ok: true };
});
@@ -212,13 +400,16 @@ export function createContextMenuStorage(getStore) {
async function cleanup(tabId) {
return enqueue(tabId, async (numericTabId) => {
pending.delete(numericTabId);
+ claims.delete(numericTabId);
const store = getStore();
if (store) {
- try { await store.remove(key(numericTabId)); } catch { /* best effort */ }
+ try {
+ await store.remove([key(numericTabId), claimKey(numericTabId)]);
+ } catch { /* best effort */ }
}
return { ok: true };
});
}
- return { key, save, consume, clear, cleanup };
+ return { key, claimKey, save, consume, claim, reserve, release, clear, cleanup };
}
diff --git a/src/chrome/src/run-reconnect.js b/src/chrome/src/run-reconnect.js
index 526ac4f01..a7bd5f11e 100644
--- a/src/chrome/src/run-reconnect.js
+++ b/src/chrome/src/run-reconnect.js
@@ -114,7 +114,13 @@ export async function runDetachedWithReconnect({
} else {
try {
const ack = await start(action, actionPayload);
- if (ack?.accepted === false) throw new Error(ack.error || 'The background rejected the run.');
+ if (ack?.accepted === false) {
+ const rejection = new Error(ack.error || 'The background rejected the run.');
+ for (const field of ['code', 'reason', 'leaseExpiresAt', 'retryAfterMs']) {
+ if (ack?.[field] != null) rejection[field] = ack[field];
+ }
+ throw rejection;
+ }
if (ack?.requestId && !requestMatches(ack.requestId, requestId)) {
throw new Error('The background acknowledged a different run request.');
}
diff --git a/src/chrome/src/ui/context-menu-prompts.js b/src/chrome/src/ui/context-menu-prompts.js
index 8cf2a03fa..ee6c42ff7 100644
--- a/src/chrome/src/ui/context-menu-prompts.js
+++ b/src/chrome/src/ui/context-menu-prompts.js
@@ -14,11 +14,14 @@ export function createContextMenuPromptHandler({
autoResizeInput,
sendMessage,
sendToBackground,
+ getIsDocumentVisible = () => true,
}) {
const acceptedContextMenuPromptIds = new Set();
const trackedContextMenuPromptIds = new Set();
const deferredContextMenuPrompts = [];
const queuedContextMenuPrompts = [];
+ const claimRetryTimers = new Map();
+ const claimantId = `sidepanel-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
let runningContextMenuPromptId = null;
function normalizeContextMenuPromptPayload(raw) {
@@ -41,7 +44,39 @@ export function createContextMenuPromptHandler({
return payload?.tabId == null || currentTabId == null || Number(payload.tabId) === Number(currentTabId);
}
+ function clearClaimRetry(promptId) {
+ const retry = claimRetryTimers.get(promptId);
+ if (retry?.timerId) clearTimeout(retry.timerId);
+ claimRetryTimers.delete(promptId);
+ }
+
+ function scheduleClaimRetry(payload, leaseExpiresAt, retryAfterMs) {
+ const expiry = Number(leaseExpiresAt);
+ const retryDelay = Number(retryAfterMs);
+ const retryAt = Number.isFinite(expiry) && expiry > 0
+ ? expiry
+ : Number.isFinite(retryDelay) && retryDelay > 0
+ ? Date.now() + retryDelay
+ : NaN;
+ if (!payload?.id || !Number.isFinite(retryAt)) return;
+ clearClaimRetry(payload.id);
+ const delay = Math.max(50, retryAt - Date.now() + 25);
+ const timerId = setTimeout(() => {
+ claimRetryTimers.delete(payload.id);
+ if (!getIsDocumentVisible()
+ || acceptedContextMenuPromptIds.has(payload.id)
+ || trackedContextMenuPromptIds.has(payload.id)
+ || runningContextMenuPromptId === payload.id) return;
+ acceptContextMenuPrompt(payload);
+ }, delay);
+ claimRetryTimers.set(payload.id, { tabId: payload.tabId, timerId });
+ }
+
function routeTrackedContextMenuPrompt(payload) {
+ if (!getIsDocumentVisible()) {
+ trackedContextMenuPromptIds.delete(payload.id);
+ return;
+ }
if (getCurrentTabId() == null) {
deferredContextMenuPrompts.push(payload);
return;
@@ -59,6 +94,7 @@ export function createContextMenuPromptHandler({
function acceptContextMenuPrompt(rawPayload) {
const payload = normalizeContextMenuPromptPayload(rawPayload);
if (!payload) return;
+ if (!getIsDocumentVisible()) return;
if (acceptedContextMenuPromptIds.has(payload.id) || trackedContextMenuPromptIds.has(payload.id)) return;
trackedContextMenuPromptIds.add(payload.id);
routeTrackedContextMenuPrompt(payload);
@@ -86,6 +122,11 @@ export function createContextMenuPromptHandler({
async function runContextMenuPrompt(payload) {
if (!payload?.text) return;
+ clearClaimRetry(payload.id);
+ if (!getIsDocumentVisible()) {
+ trackedContextMenuPromptIds.delete(payload.id);
+ return;
+ }
if (runningContextMenuPromptId || getIsProcessing()) {
queuedContextMenuPrompts.push(payload);
return;
@@ -94,6 +135,49 @@ export function createContextMenuPromptHandler({
const currentTabId = getCurrentTabId();
const clearPayload = { tabId: payload.tabId ?? currentTabId, promptId: payload.id };
+ const releasePromptClaim = async () => {
+ try {
+ await sendToBackground('release_context_menu_prompt_claim', {
+ tabId: clearPayload.tabId,
+ promptId: payload.id,
+ claimantId,
+ });
+ } catch { /* the durable lease still expires if release fails */ }
+ };
+ let claimResult = null;
+ try {
+ claimResult = await sendToBackground('claim_context_menu_prompt', {
+ tabId: clearPayload.tabId,
+ promptId: payload.id,
+ claimantId,
+ });
+ } catch {
+ // Leave the durable prompt untouched. A visible panel can reclaim it
+ // after this background connection or lease attempt recovers.
+ claimResult = { claimed: false, reason: 'connection', retryAfterMs: 1_000 };
+ }
+ if (claimResult?.claimed && !getIsDocumentVisible()) {
+ await releasePromptClaim();
+ claimResult = { claimed: false, reason: 'panel-hidden', retryAfterMs: 250 };
+ }
+ if (!claimResult?.claimed || !getIsDocumentVisible()) {
+ runningContextMenuPromptId = null;
+ trackedContextMenuPromptIds.delete(payload.id);
+ // A different panel instance owns an active lease. A repeated delivery
+ // may re-check the lease, but it cannot submit until the lease expires.
+ scheduleClaimRetry(payload, claimResult?.leaseExpiresAt, claimResult?.retryAfterMs);
+ drainQueuedContextMenuPrompts();
+ return;
+ }
+ const promptTabStillActive = getCurrentTabId() != null
+ && contextMenuPromptMatchesCurrentTab(payload);
+ if (getIsProcessing() || !promptTabStillActive) {
+ await releasePromptClaim();
+ runningContextMenuPromptId = null;
+ queuedContextMenuPrompts.push(payload);
+ drainQueuedContextMenuPrompts();
+ return;
+ }
if (getAgentMode() !== 'ask') setMode('ask');
getInputEl().value = payload.text;
@@ -110,19 +194,33 @@ export function createContextMenuPromptHandler({
// drain. On a pre-receipt SW crash, storage is still intact and
// consumePendingContextMenuPrompt() recovers the prompt on the next panel load.
let accepted = false;
+ let rejectedClaim = null;
try {
accepted = await sendMessage({
contextMenuClear: clearPayload,
+ contextMenuClaim: {
+ promptId: payload.id,
+ claimantId,
+ },
+ __onContextMenuClaimRejected: (result) => {
+ rejectedClaim = result || { reason: 'claim-lost' };
+ },
...(payload.sourceGrounding ? { sourceGrounding: payload.sourceGrounding } : {}),
});
} catch { /* storage recovery can retry the prompt later */ }
runningContextMenuPromptId = null;
trackedContextMenuPromptIds.delete(payload.id);
- if (accepted) acceptedContextMenuPromptIds.add(payload.id);
+ if (accepted) {
+ acceptedContextMenuPromptIds.add(payload.id);
+ clearClaimRetry(payload.id);
+ } else if (rejectedClaim) {
+ scheduleClaimRetry(payload, rejectedClaim.leaseExpiresAt, rejectedClaim.retryAfterMs);
+ }
drainQueuedContextMenuPrompts();
}
async function consumePendingContextMenuPrompt() {
+ if (!getIsDocumentVisible()) return;
const currentTabId = getCurrentTabId();
if (currentTabId == null) return;
try {
@@ -148,6 +246,9 @@ export function createContextMenuPromptHandler({
...queuedContextMenuPrompts.filter(keep));
deferredContextMenuPrompts.splice(0, deferredContextMenuPrompts.length,
...deferredContextMenuPrompts.filter(keep));
+ for (const [promptId, retry] of claimRetryTimers) {
+ if (Number(retry?.tabId) === numericTabId) clearClaimRetry(promptId);
+ }
}
return {
diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js
index a7e9e36e1..c4483d12a 100644
--- a/src/chrome/src/ui/sidepanel.js
+++ b/src/chrome/src/ui/sidepanel.js
@@ -38,7 +38,6 @@ import {
} from './store-review-prompt.js';
import { providerIconUrl } from './provider-icons.js';
import { parseWatchSlashCommand, WATCH_COMMAND_USAGE } from './watch-command.js';
-import { TAB_CHAT_PREFIX, persistTabChatToSession } from './tab-chat-persistence.js';
import { createSidePanelWindowScope } from './sidepanel-window-scope.js';
// Hydrate the theme from chrome.storage.local (the inline
bootstrap
@@ -1143,6 +1142,7 @@ const {
autoResizeInput,
sendMessage,
sendToBackground,
+ getIsDocumentVisible: () => document.visibilityState !== 'hidden',
});
// Completion notification + success celebration. Default on; togglable via Settings.
let notifySoundEnabled = true;
@@ -1537,7 +1537,12 @@ function isSuccessfulAskCompletion(mode, response) {
// Also mirrored to chrome.storage.session keyed `tabChat:` so the
// conversation survives the side panel being closed and reopened.
const tabChats = new Map();
+const TAB_CHAT_LOAD_FAILED = Symbol('tab-chat-load-failed');
const tabChatOperations = new Map();
+const tabChatHandoffGenerations = new Map();
+const tabChatHandoffOwnerId = `sidepanel-chat-${
+ globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`
+}`;
const tabInputDrafts = new Map();
const permissionSkipCommandContextsByTab = new Map();
const queuedComposerMessagesByTab = new Map();
@@ -1556,38 +1561,67 @@ function enqueueTabChatOperation(tabId, fn) {
return operation;
}
-async function loadTabChat(tabId) {
+async function loadTabChat(tabId, { waitForHandoff = false } = {}) {
const numericTabId = Number(tabId);
if (!Number.isFinite(numericTabId)) return null;
- if (!tabChatOperations.has(numericTabId) && tabChats.has(numericTabId)) return tabChats.get(numericTabId);
+ if (!waitForHandoff && !tabChatOperations.has(numericTabId) && tabChats.has(numericTabId)) {
+ return tabChats.get(numericTabId);
+ }
try {
return await enqueueTabChatOperation(numericTabId, async (queuedTabId) => {
- if (tabChats.has(queuedTabId)) return tabChats.get(queuedTabId);
- const key = TAB_CHAT_PREFIX + queuedTabId;
- const stored = await chrome.storage.session.get(key);
- const html = stored?.[key];
+ if (!waitForHandoff && tabChats.has(queuedTabId)) return tabChats.get(queuedTabId);
+ const stored = await sendToBackground('load_tab_chat', {
+ tabId: queuedTabId,
+ waitForHandoff,
+ handoffOwnerId: tabChatHandoffOwnerId,
+ });
+ if (stored?.handoffOwnerId === tabChatHandoffOwnerId
+ && Number.isFinite(Number(stored?.handoffGeneration))) {
+ tabChatHandoffGenerations.set(queuedTabId, Number(stored.handoffGeneration));
+ }
+ const html = stored?.found ? stored.html : null;
if (typeof html === 'string') {
tabChats.set(queuedTabId, html);
return html;
}
+ tabChats.delete(queuedTabId);
return null;
});
- } catch (e) { /* ignore */ }
+ } catch (e) {
+ if (waitForHandoff) return TAB_CHAT_LOAD_FAILED;
+ }
return null;
}
-function persistTabChat(tabId, html) {
- if (tabId == null) return;
+function persistTabChat(tabId, html, { allowHidden = false } = {}) {
+ if (tabId == null || (document.visibilityState === 'hidden' && !allowHidden)) {
+ return Promise.resolve({ ok: false, skipped: true });
+ }
+ const numericTabId = Number(tabId);
+ if (!Number.isFinite(numericTabId)) return Promise.resolve({ ok: false, error: 'No tab ID' });
+ const handoffGeneration = tabChatHandoffGenerations.get(numericTabId);
+ const payload = {
+ tabId: numericTabId,
+ html,
+ handoffOwnerId: tabChatHandoffOwnerId,
+ };
+ if (Number.isFinite(handoffGeneration)) payload.handoffGeneration = handoffGeneration;
+ if (document.visibilityState === 'hidden' && allowHidden) {
+ // Do not wait behind this document's local queue. The shared background
+ // coordinator orders this authoritative handoff behind any earlier write
+ // and ahead of the newly visible document's restore.
+ tabChats.set(numericTabId, html);
+ return sendToBackground('persist_tab_chat', payload);
+ }
return enqueueTabChatOperation(tabId, async (numericTabId) => {
- // Keep the live transcript lossless. persistTabChatToSession may compact
- // only the storage.session copy when the shared quota requires it.
+ // Keep the live transcript lossless. The background may compact only the
+ // storage.session copy when the shared quota requires it.
tabChats.set(numericTabId, html);
- const key = TAB_CHAT_PREFIX + numericTabId;
- return persistTabChatToSession(chrome.storage.session, key, html);
+ return sendToBackground('persist_tab_chat', payload);
});
}
-async function flushRenderedTabChat() {
+async function flushRenderedTabChat({ allowHidden = false } = {}) {
const tabId = renderedTabId;
if (tabId == null) return;
if (persistTimer && persistTimerTabId === tabId) {
@@ -1596,7 +1630,11 @@ async function flushRenderedTabChat() {
persistTimerTabId = null;
}
flushPendingStreamedAssistantMarkdownRenders();
- await persistTabChat(tabId, messagesEl.innerHTML);
+ const html = messagesEl.innerHTML;
+ if (document.visibilityState !== 'hidden') {
+ lastVisibleTabChatSnapshot = { tabId: Number(tabId), html };
+ }
+ await persistTabChat(tabId, html, { allowHidden });
}
function clearCachedTabChat(tabId) {
@@ -1606,13 +1644,41 @@ function clearCachedTabChat(tabId) {
persistTimer = null;
persistTimerTabId = null;
}
+ if (lastVisibleTabChatSnapshot
+ && sameTabId(lastVisibleTabChatSnapshot.tabId, tabId)) {
+ lastVisibleTabChatSnapshot = null;
+ }
tabChats.delete(tabId);
return enqueueTabChatOperation(tabId, async (numericTabId) => {
tabChats.delete(numericTabId);
- try {
- await chrome.storage.session?.remove(TAB_CHAT_PREFIX + numericTabId).catch(() => {});
- } catch (e) { /* ignore */ }
- return { ok: true };
+ let handoffGeneration = tabChatHandoffGenerations.get(numericTabId);
+ let clearResult = null;
+ while (true) {
+ if (!Number.isFinite(handoffGeneration) || clearResult?.reason === 'stale-handoff') {
+ const ownership = await sendToBackground('load_tab_chat', {
+ tabId: numericTabId,
+ waitForHandoff: true,
+ handoffOwnerId: tabChatHandoffOwnerId,
+ });
+ if (ownership?.handoffOwnerId === tabChatHandoffOwnerId
+ && Number.isFinite(Number(ownership?.handoffGeneration))) {
+ handoffGeneration = Number(ownership.handoffGeneration);
+ tabChatHandoffGenerations.set(numericTabId, handoffGeneration);
+ }
+ }
+ clearResult = await sendToBackground('clear_tab_chat', {
+ tabId: numericTabId,
+ handoffOwnerId: tabChatHandoffOwnerId,
+ handoffGeneration,
+ });
+ if (!clearResult?.skipped || clearResult.reason !== 'stale-handoff') {
+ if (clearResult?.handoffOwnerId === tabChatHandoffOwnerId
+ && Number.isFinite(Number(clearResult?.handoffGeneration))) {
+ tabChatHandoffGenerations.set(numericTabId, Number(clearResult.handoffGeneration));
+ }
+ return clearResult;
+ }
+ }
});
}
@@ -2040,7 +2106,10 @@ function drainQueuedComposerMessageForCurrentTab() {
}
async function renderClearedConversationForTab(tabId) {
- clearCachedTabChat(tabId);
+ const clearResult = await clearCachedTabChat(tabId);
+ if (!clearResult?.ok || clearResult?.skipped) {
+ throw new Error(clearResult?.error || 'Unable to clear tab chat.');
+ }
resetComposerHistoryNavigation(tabId);
saveInputDraftForTab(tabId, '');
clearPendingAttachmentsForTab(tabId);
@@ -2057,6 +2126,10 @@ async function renderClearedConversationForTab(tabId) {
autoResizeInput();
syncSendButtonState();
addMessage('system', t('sp.cleared_message'));
+ const clearedHtml = messagesEl.innerHTML;
+ lastVisibleTabChatSnapshot = { tabId: Number(tabId), html: clearedHtml };
+ tabChats.set(Number(tabId), clearedHtml);
+ await persistTabChat(tabId, clearedHtml, { allowHidden: true }).catch(() => {});
refreshScheduledJobs({ tabId });
refreshRecommendedActions();
}
@@ -2065,10 +2138,39 @@ async function renderClearedConversationForTab(tabId) {
// to thrash storage on every keystroke / streamed token.
let persistTimer = null;
let persistTimerTabId = null;
+let visibleStateRefreshPending = false;
+let visibleStateRefreshPromise = Promise.resolve();
+let visibleStateRefreshInProgress = false;
+let visibleStateRefreshGeneration = 0;
+let lastVisibleTabChatSnapshot = null;
+const TAB_CHAT_HANDOFF_RETRY_MS = 250;
+
+function waitForTabChatHandoffRetry() {
+ return new Promise(resolve => setTimeout(resolve, TAB_CHAT_HANDOFF_RETRY_MS));
+}
+
+async function waitForVisibleSidePanelStateRefresh() {
+ let pendingRefresh;
+ do {
+ pendingRefresh = visibleStateRefreshPromise;
+ await pendingRefresh.catch(() => {});
+ if (tabSwitchTransitionId != null || visibleStateRefreshInProgress) {
+ await waitForTabChatHandoffRetry();
+ }
+ } while (pendingRefresh !== visibleStateRefreshPromise
+ || tabSwitchTransitionId != null
+ || visibleStateRefreshInProgress);
+}
+
function schedulePersist() {
+ if (document.visibilityState === 'hidden') return;
if (persistTimer) clearTimeout(persistTimer);
const tabId = renderedTabId;
const html = messagesEl.innerHTML;
+ if (tabId != null) {
+ lastVisibleTabChatSnapshot = { tabId: Number(tabId), html };
+ tabChats.set(Number(tabId), html);
+ }
persistTimerTabId = tabId;
persistTimer = setTimeout(() => {
persistTimer = null;
@@ -2204,7 +2306,9 @@ async function prepareChatHistoryForTurn(tabId, mode) {
async function persistChatHistorySnapshot(tabId, { refreshTabInfo = false } = {}) {
const numericTabId = Number(tabId);
- if (!Number.isFinite(numericTabId) || renderedTabId !== numericTabId) return;
+ if (document.visibilityState === 'hidden'
+ || !Number.isFinite(numericTabId)
+ || renderedTabId !== numericTabId) return;
const messages = extractChatHistoryMessages(messagesEl);
if (!messages.some((message) => message.role === 'user')) return;
const saveSeq = nextChatHistorySaveSeqForTab(numericTabId);
@@ -3540,14 +3644,27 @@ async function init() {
// Restore prior conversation for this tab (if any) — survives close/reopen.
const restoreTabId = currentTabId;
if (restoreTabId != null) {
- const html = await loadTabChat(restoreTabId);
- if (currentTabId === restoreTabId && html) {
- await hydrateRestoredChatHistory(restoreTabId, html);
- if (currentTabId === restoreTabId) {
- messagesEl.innerHTML = html;
- messagesEl.querySelectorAll('[data-bound]').forEach(el => delete el.dataset.bound);
- rebindRestoredMessageControls();
+ visibleStateRefreshInProgress = true;
+ syncSendButtonState();
+ try {
+ let html = TAB_CHAT_LOAD_FAILED;
+ while (html === TAB_CHAT_LOAD_FAILED && currentTabId === restoreTabId) {
+ html = await loadTabChat(restoreTabId, { waitForHandoff: true });
+ if (html === TAB_CHAT_LOAD_FAILED && currentTabId === restoreTabId) {
+ await waitForTabChatHandoffRetry();
+ }
+ }
+ if (currentTabId === restoreTabId && html && html !== TAB_CHAT_LOAD_FAILED) {
+ await hydrateRestoredChatHistory(restoreTabId, html);
+ if (currentTabId === restoreTabId) {
+ messagesEl.innerHTML = html;
+ messagesEl.querySelectorAll('[data-bound]').forEach(el => delete el.dataset.bound);
+ rebindRestoredMessageControls();
+ }
}
+ } finally {
+ visibleStateRefreshInProgress = false;
+ syncSendButtonState();
}
}
@@ -3635,6 +3752,7 @@ async function switchToTab(newTabId) {
const switchGeneration = ++tabSwitchGeneration;
tabSwitchTransitionId = newTabId;
queuedTabSwitchMessages = [];
+ syncSendButtonState();
// The activity strip is a single panel-wide DOM node, unlike the tab-scoped
// chat and run journals. Clear the outgoing tab's transient status before
// any async restore work can yield; restoreActiveRunState (or a queued target
@@ -3659,7 +3777,9 @@ async function switchToTab(newTabId) {
syncCurrentTabRunFlags();
syncApiMutationsAllowedForCurrentTab();
- // Restore new tab's chat from memory or storage.
+ // Chrome gives each tab-specific side panel its own document. The
+ // visibility refresh below coordinates ownership when the destination
+ // document becomes active, so this in-document switch only restores cache.
const html = await loadTabChat(newTabId);
if (switchGeneration !== tabSwitchGeneration || currentTabId !== newTabId) return;
if (html) {
@@ -3685,9 +3805,73 @@ async function switchToTab(newTabId) {
refreshRecommendedActions();
} finally {
if (switchGeneration === tabSwitchGeneration && tabSwitchTransitionId === newTabId) tabSwitchTransitionId = null;
+ syncSendButtonState();
}
drainQueuedAgentUpdatesForTab(newTabId);
consumePendingContextMenuPrompt().then(() => drainQueuedContextMenuPrompts()).catch(() => {});
+ if (visibleStateRefreshPending) requestVisibleSidePanelStateRefresh();
+}
+
+async function refreshVisibleSidePanelState() {
+ if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return;
+ const tabId = Number(currentTabId);
+ if (!Number.isFinite(tabId)) return;
+
+ // Every tab-specific Chrome side panel is a separate extension document.
+ // When an older document becomes visible again, its in-memory chat cache can
+ // predate writes made by the panel that was visible in the meantime. Reload
+ // from the shared session copy before that document is allowed to persist.
+ tabChats.delete(tabId);
+ const html = await loadTabChat(tabId, { waitForHandoff: true });
+ if (html === TAB_CHAT_LOAD_FAILED) return false;
+ if (document.visibilityState === 'hidden' || !sameTabId(currentTabId, tabId)) return;
+ renderedTabId = tabId;
+ if (html && html !== messagesEl.innerHTML) {
+ await hydrateRestoredChatHistory(tabId, html);
+ if (document.visibilityState === 'hidden' || !sameTabId(currentTabId, tabId)) return;
+ messagesEl.innerHTML = html;
+ messagesEl.querySelectorAll('[data-bound]').forEach(el => delete el.dataset.bound);
+ rebindRestoredMessageControls();
+ } else if (!html) {
+ messagesEl.innerHTML = '';
+ addMessage('system', t('sp.help_message'));
+ }
+ await restoreActiveRunState(tabId);
+ return document.visibilityState !== 'hidden' && sameTabId(currentTabId, tabId);
+}
+
+function requestVisibleSidePanelStateRefresh() {
+ if (document.visibilityState === 'hidden') return;
+ visibleStateRefreshPending = true;
+ visibleStateRefreshInProgress = true;
+ const refreshGeneration = ++visibleStateRefreshGeneration;
+ syncSendButtonState();
+ visibleStateRefreshPromise = visibleStateRefreshPromise.catch(() => {}).then(async () => {
+ if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return false;
+ visibleStateRefreshPending = false;
+ let refreshed = await refreshVisibleSidePanelState();
+ while (refreshed === false
+ && document.visibilityState !== 'hidden'
+ && tabSwitchTransitionId == null) {
+ visibleStateRefreshPending = true;
+ syncSendButtonState();
+ await waitForTabChatHandoffRetry();
+ if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return false;
+ visibleStateRefreshPending = false;
+ refreshed = await refreshVisibleSidePanelState();
+ }
+ return refreshed;
+ });
+ const refreshPromise = visibleStateRefreshPromise;
+ refreshPromise.finally(() => {
+ if (refreshGeneration !== visibleStateRefreshGeneration) return;
+ visibleStateRefreshInProgress = false;
+ syncSendButtonState();
+ }).catch(() => {});
+ refreshPromise.then((refreshed) => {
+ if (!refreshed || refreshPromise !== visibleStateRefreshPromise) return;
+ consumePendingContextMenuPrompt().then(() => drainQueuedContextMenuPrompts()).catch(() => {});
+ }).catch(() => {});
}
async function restoreActiveRunState(tabId = currentTabId) {
@@ -6168,6 +6352,10 @@ function isOutOfBandSlashDraft(value) {
function syncSendButtonState() {
if (!sendBtn) return;
const draft = normalizeScreenshotCommandText(inputEl?.value || '').trim();
+ if (tabSwitchTransitionId != null || visibleStateRefreshPending || visibleStateRefreshInProgress) {
+ sendBtn.disabled = true;
+ return;
+ }
if (isAwaitingPlanReviewForTab()) {
sendBtn.disabled = true;
return;
@@ -6890,34 +7078,80 @@ function updateApiBadge() {
async function sendMessage(extraChatParams = {}) {
const retryOptions = extraChatParams?.__retry || null;
const modeOverride = ['ask', 'act', 'dev'].includes(extraChatParams?.__mode) ? extraChatParams.__mode : null;
+ const onContextMenuClaimRejected = typeof extraChatParams?.__onContextMenuClaimRejected === 'function'
+ ? extraChatParams.__onContextMenuClaimRejected
+ : null;
const chatExtraParams = { ...(extraChatParams || {}) };
delete chatExtraParams.__retry;
delete chatExtraParams.__mode;
+ delete chatExtraParams.__onContextMenuClaimRejected;
+ const contextMenuClaim = chatExtraParams.contextMenuClaim;
+ let contextMenuClaimOwned = Boolean(contextMenuClaim?.promptId && contextMenuClaim?.claimantId);
+ const claimedContextMenuTabId = contextMenuClaimOwned
+ ? Number(chatExtraParams.contextMenuClear?.tabId ?? currentTabId)
+ : null;
+ const releaseOwnedContextMenuClaim = async (
+ rejection = { reason: 'panel-hidden', retryAfterMs: 250 },
+ ) => {
+ if (!contextMenuClaimOwned) return;
+ contextMenuClaimOwned = false;
+ try {
+ await sendToBackground('release_context_menu_prompt_claim', {
+ tabId: claimedContextMenuTabId,
+ promptId: contextMenuClaim.promptId,
+ claimantId: contextMenuClaim.claimantId,
+ });
+ } catch { /* the durable lease still expires if release fails */ }
+ onContextMenuClaimRejected?.(rejection);
+ };
const requestedSourceGrounding = retryOptions?.sourceGrounding ?? chatExtraParams.sourceGrounding;
const sourceGrounding = requestedSourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING
? SELECTION_ONLY_SOURCE_GROUNDING
: null;
delete chatExtraParams.sourceGrounding;
if (sourceGrounding) chatExtraParams.sourceGrounding = sourceGrounding;
+ await waitForVisibleSidePanelStateRefresh();
+ if (contextMenuClaimOwned
+ && (document.visibilityState === 'hidden'
+ || !sameTabId(currentTabId, claimedContextMenuTabId)
+ || !sameTabId(renderedTabId, claimedContextMenuTabId))) {
+ await releaseOwnedContextMenuClaim();
+ return false;
+ }
stopListening();
let text = inputEl.value.trim();
- if (!text) return;
+ if (!text) {
+ if (contextMenuClaimOwned) {
+ await releaseOwnedContextMenuClaim({ reason: 'preflight-empty', retryAfterMs: 1_000 });
+ return false;
+ }
+ return;
+ }
const submittedText = text;
const tabId = currentTabId;
- if (isConversationClearInProgress(tabId)) return false;
+ if (isConversationClearInProgress(tabId)) {
+ await releaseOwnedContextMenuClaim({ reason: 'conversation-clear', retryAfterMs: 1_000 });
+ return false;
+ }
const permissionSkipContext = permissionSkipCommandContextForDraft(tabId, text);
const requestId = createRunRequestId(tabId);
text = normalizeScreenshotCommandText(text);
if (isAwaitingPlanReviewForTab(tabId)) {
+ await releaseOwnedContextMenuClaim({ reason: 'plan-review-pending', retryAfterMs: 1_000 });
showComposerToast(t('sp.plan.awaiting_review'), { duration: 5000 });
syncSendButtonState();
return false;
}
if (!retryOptions && !sourceGrounding && !isProcessing && isAttachmentReadPendingForTab(tabId)) {
+ await releaseOwnedContextMenuClaim({ reason: 'attachment-read-pending', retryAfterMs: 1_000 });
syncSendButtonState();
return false;
}
if (isProcessing) {
+ if (contextMenuClaimOwned) {
+ await releaseOwnedContextMenuClaim({ reason: 'run-active', retryAfterMs: 1_000 });
+ return false;
+ }
if (isOutOfBandSlashDraft(text)) {
resetComposerHistoryNavigation(tabId);
saveInputDraftForTab(tabId, '');
@@ -6974,14 +7208,18 @@ async function sendMessage(extraChatParams = {}) {
// Parse any leading slash command. parseSlashCommands may strip the
// command from `text` and toggle apiMutationsAllowed as a side effect.
if (!retryOptions) text = await parseSlashCommands(text, tabId, { permissionSkipContext });
- let renderToCurrentTab = sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId);
+ let renderToCurrentTab = document.visibilityState !== 'hidden'
+ && sameTabId(currentTabId, tabId)
+ && sameTabId(renderedTabId, tabId);
if (!renderToCurrentTab) {
+ await releaseOwnedContextMenuClaim();
if (text) saveInputDraftForTab(tabId, text);
return false;
}
// If the entire message was just the slash command, there's nothing
// left to send to the agent — bail out after the side effect.
if (!text) {
+ await releaseOwnedContextMenuClaim({ reason: 'preflight-empty', retryAfterMs: 1_000 });
scrollToBottom({ force: true });
inputEl.value = '';
autoResizeInput();
@@ -6996,13 +7234,63 @@ async function sendMessage(extraChatParams = {}) {
await prepareChatHistoryForTurn(tabId, modeForSend);
if (isTabAbortRequested(tabId)) {
+ await releaseOwnedContextMenuClaim();
setTabProcessing(tabId, false);
setTabAbortRequested(tabId, false);
syncSendButtonState();
return false;
}
- renderToCurrentTab = sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId);
+ renderToCurrentTab = document.visibilityState !== 'hidden'
+ && sameTabId(currentTabId, tabId)
+ && sameTabId(renderedTabId, tabId);
+ if (!renderToCurrentTab) {
+ await releaseOwnedContextMenuClaim();
+ if (text) saveInputDraftForTab(tabId, text);
+ setTabProcessing(tabId, false);
+ setTabAbortRequested(tabId, false);
+ syncSendButtonState();
+ return false;
+ }
+
+ if (contextMenuClaim?.promptId && contextMenuClaim?.claimantId) {
+ let renewedClaim = null;
+ try {
+ renewedClaim = await sendToBackground('claim_context_menu_prompt', {
+ tabId,
+ promptId: contextMenuClaim.promptId,
+ claimantId: contextMenuClaim.claimantId,
+ });
+ } catch {
+ renewedClaim = { claimed: false, reason: 'connection', retryAfterMs: 1_000 };
+ }
+ contextMenuClaimOwned = renewedClaim?.claimed === true;
+ const claimStillVisible = document.visibilityState !== 'hidden'
+ && sameTabId(currentTabId, tabId)
+ && sameTabId(renderedTabId, tabId);
+ if (contextMenuClaimOwned && !claimStillVisible) {
+ await releaseOwnedContextMenuClaim();
+ setTabProcessing(tabId, false);
+ setTabAbortRequested(tabId, false);
+ if (sameTabId(currentTabId, tabId)) syncSendButtonState();
+ return false;
+ }
+ if (!renewedClaim?.claimed) {
+ onContextMenuClaimRejected?.(renewedClaim || {
+ reason: 'claim-lost',
+ retryAfterMs: 1_000,
+ });
+ setTabProcessing(tabId, false);
+ setTabAbortRequested(tabId, false);
+ if (sameTabId(currentTabId, tabId)) syncSendButtonState();
+ return false;
+ }
+ }
+
+ renderToCurrentTab = document.visibilityState !== 'hidden'
+ && sameTabId(currentTabId, tabId)
+ && sameTabId(renderedTabId, tabId);
if (!renderToCurrentTab) {
+ await releaseOwnedContextMenuClaim();
if (text) saveInputDraftForTab(tabId, text);
setTabProcessing(tabId, false);
setTabAbortRequested(tabId, false);
@@ -7058,6 +7346,7 @@ async function sendMessage(extraChatParams = {}) {
let accepted = false;
let captureStartFailed = false;
+ let contextMenuReservationRejected = false;
let completedSuccessfully = false;
let promptEligibleCompletion = false;
try {
@@ -7159,7 +7448,19 @@ async function sendMessage(extraChatParams = {}) {
} catch (e) {
captureStartFailed = !!runCaptureDirective
&& String(e?.message || '').startsWith(RUN_CAPTURE_START_ERROR_PREFIX);
- if (captureStartFailed) {
+ contextMenuReservationRejected = e?.code === 'context-menu-reservation-rejected';
+ if (contextMenuReservationRejected) {
+ const rejection = {
+ reason: e.reason || 'claim-lost',
+ leaseExpiresAt: e.leaseExpiresAt,
+ retryAfterMs: e.retryAfterMs || 1_000,
+ };
+ if (contextMenuClaimOwned) await releaseOwnedContextMenuClaim(rejection);
+ else onContextMenuClaimRejected?.(rejection);
+ userEl?.remove();
+ assistantEl?.remove();
+ if (currentAssistantEl === assistantEl) currentAssistantEl = null;
+ } else if (captureStartFailed) {
const message = String(e?.message || '').slice(RUN_CAPTURE_START_ERROR_PREFIX.length);
reportTrailingRunCaptureError(runCaptureDirective, new Error(message), tabId);
restorePendingAttachmentsForTab(tabId, attachmentsForSend);
@@ -7204,7 +7505,7 @@ async function sendMessage(extraChatParams = {}) {
if (renderToCurrentTab && currentTabId === tabId) scrollToBottom();
if (renderToCurrentTab && renderedTabId === tabId) await flushRenderedTabChat();
if (renderToCurrentTab && renderedTabId === tabId) await flushChatHistorySnapshot(tabId, { refreshTabInfo: true });
- if (renderToCurrentTab && !wasAborted && !captureStartFailed) {
+ if (renderToCurrentTab && !wasAborted && !captureStartFailed && !contextMenuReservationRejected) {
notifyCompletion({
success: currentTabId === tabId && completedSuccessfully,
storeReviewSuccess: currentTabId === tabId && promptEligibleCompletion,
@@ -7329,6 +7630,55 @@ chrome.runtime.onMessage.addListener((msg) => {
acceptContextMenuPrompt(msg.prompt || msg);
});
+chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
+ if (msg?.target !== 'sidepanel'
+ || msg.action !== 'tab_chat_handoff_request'
+ || String(msg.ownerId || '') !== tabChatHandoffOwnerId) return;
+ const snapshot = lastVisibleTabChatSnapshot;
+ if (!snapshot || !sameTabId(snapshot.tabId, msg.tabId)) return;
+ sendResponse({
+ ok: true,
+ tabId: Number(snapshot.tabId),
+ ownerId: tabChatHandoffOwnerId,
+ generation: Number(msg.generation),
+ html: snapshot.html,
+ });
+});
+
+chrome.runtime.onMessage.addListener((msg) => {
+ if (msg?.target !== 'sidepanel'
+ || msg.action !== 'tab_chat_cleared'
+ || msg.handoffOwnerId === tabChatHandoffOwnerId
+ || document.visibilityState === 'hidden'
+ || !sameTabId(currentTabId, msg.tabId)) return;
+ tabChats.delete(Number(msg.tabId));
+ if (lastVisibleTabChatSnapshot
+ && sameTabId(lastVisibleTabChatSnapshot.tabId, msg.tabId)) {
+ lastVisibleTabChatSnapshot = null;
+ }
+ requestVisibleSidePanelStateRefresh();
+});
+
+document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'hidden') {
+ visibleStateRefreshPending = false;
+ // This document was authoritative immediately before it became hidden.
+ // Bypass the hidden-writer guard exactly once so the next visible panel
+ // cannot restore the older pre-debounce storage snapshot.
+ const snapshot = lastVisibleTabChatSnapshot;
+ if (snapshot && persistTimer && sameTabId(persistTimerTabId, snapshot.tabId)) {
+ clearTimeout(persistTimer);
+ persistTimer = null;
+ persistTimerTabId = null;
+ }
+ if (snapshot) {
+ persistTabChat(snapshot.tabId, snapshot.html, { allowHidden: true }).catch(() => {});
+ }
+ return;
+ }
+ requestVisibleSidePanelStateRefresh();
+});
+
chrome.runtime.onMessage.addListener((msg) => {
if (msg?.target !== 'sidepanel' || msg.action !== 'context_menu_tab_navigated') return;
clearQueuedForTab(msg.tabId);
diff --git a/src/chrome/src/ui/tab-chat-persistence.js b/src/chrome/src/ui/tab-chat-persistence.js
index efe592be8..236eecf48 100644
--- a/src/chrome/src/ui/tab-chat-persistence.js
+++ b/src/chrome/src/ui/tab-chat-persistence.js
@@ -191,3 +191,196 @@ export async function persistTabChatToSession(storageArea, key, html, warn = con
}
}
}
+
+export const TAB_CHAT_HANDOFF_PREFIX = 'tabChatHandoff:';
+
+/**
+ * Serialize tab-chat reads and writes in the background's shared JavaScript
+ * realm. Coordinated loads explicitly request and acknowledge the outgoing
+ * document's final snapshot before assigning a new handoff generation.
+ *
+ * @param {chrome.storage.StorageArea | browser.storage.StorageArea} storageArea
+ * @param {{
+ * persist?: typeof persistTabChatToSession,
+ * requestHandoff?: (tabId: number, handoff: { ownerId: string, generation: number }) => Promise