From 08c86e10d6c73f8a707a74dc15c760ebd4efea0c Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Wed, 29 Jul 2026 23:11:54 +0200 Subject: [PATCH 01/18] Fix context-menu run ownership races --- src/chrome/src/background.js | 49 ++- src/chrome/src/context-menu-storage.js | 141 ++++++++- src/chrome/src/run-reconnect.js | 8 +- src/chrome/src/ui/context-menu-prompts.js | 81 ++++- src/chrome/src/ui/sidepanel.js | 130 +++++++- src/firefox/src/background.js | 49 ++- src/firefox/src/context-menu-storage.js | 141 ++++++++- src/firefox/src/run-reconnect.js | 8 +- src/firefox/src/ui/context-menu-prompts.js | 81 ++++- src/firefox/src/ui/sidepanel.js | 129 +++++++- test/run.js | 328 +++++++++++++++++++-- 11 files changed, 1094 insertions(+), 51 deletions(-) diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 3a03ec72a..c6686f819 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -2151,8 +2151,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,6 +2619,18 @@ 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, + Date.now(), + () => agent.activeRunState(tabId)?.running || detachedRunStarts.has(tabId), + ); + } + case 'clear_context_menu_prompt': { const tabId = msg.tabId || sender.tab?.id; return await contextMenuStorage.clear(tabId, msg.promptId); diff --git a/src/chrome/src/context-menu-storage.js b/src/chrome/src/context-menu-storage.js index be08d3490..d8742dc1f 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,143 @@ export function createContextMenuStorage(getStore) { return { ok: true, prompt: prompt?.text ? prompt : null }; } + async function claim(tabId, promptId, claimantId, now = Date.now(), isRunActive = () => false) { + 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 nowMs = Number.isFinite(Number(now)) ? Number(now) : Date.now(); + 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) { + 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; + if (!samePrompt || !sameClaimant) { + return { + ok: true, + reserved: false, + reason: activeClaim ? 'leased' : 'claim-lost', + leaseExpiresAt: Number(activeClaim?.expiresAt || 0) || 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 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 +344,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, 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..bd340b62a 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,27 @@ export function createContextMenuPromptHandler({ const currentTabId = getCurrentTabId(); const clearPayload = { tabId: payload.tabId ?? currentTabId, promptId: payload.id }; + 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()) { + 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; + } if (getAgentMode() !== 'ask') setMode('ask'); getInputEl().value = payload.text; @@ -110,19 +172,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 +224,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..0a2f34c20 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -1143,6 +1143,7 @@ const { autoResizeInput, sendMessage, sendToBackground, + getIsDocumentVisible: () => document.visibilityState !== 'hidden', }); // Completion notification + success celebration. Default on; togglable via Settings. let notifySoundEnabled = true; @@ -1576,8 +1577,10 @@ async function loadTabChat(tabId) { 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 }); + } return enqueueTabChatOperation(tabId, async (numericTabId) => { // Keep the live transcript lossless. persistTabChatToSession may compact // only the storage.session copy when the shared quota requires it. @@ -1587,7 +1590,7 @@ function persistTabChat(tabId, html) { }); } -async function flushRenderedTabChat() { +async function flushRenderedTabChat({ allowHidden = false } = {}) { const tabId = renderedTabId; if (tabId == null) return; if (persistTimer && persistTimerTabId === tabId) { @@ -1596,7 +1599,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) { @@ -2065,10 +2072,18 @@ async function renderClearedConversationForTab(tabId) { // to thrash storage on every keystroke / streamed token. let persistTimer = null; let persistTimerTabId = null; +let visibilityHandoffPromise = Promise.resolve(); +let visibleStateRefreshPending = false; +let lastVisibleTabChatSnapshot = null; 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 +2219,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); @@ -3688,6 +3705,46 @@ async function switchToTab(newTabId) { } 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); + 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); + if (document.visibilityState === 'hidden' || !sameTabId(currentTabId, tabId)) return; + await consumePendingContextMenuPrompt(); + drainQueuedContextMenuPrompts(); +} + +function requestVisibleSidePanelStateRefresh() { + if (document.visibilityState === 'hidden') return; + visibleStateRefreshPending = true; + Promise.resolve(visibilityHandoffPromise).catch(() => {}).then(() => { + if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return; + visibleStateRefreshPending = false; + refreshVisibleSidePanelState().catch(() => {}); + }); } async function restoreActiveRunState(tabId = currentTabId) { @@ -6890,9 +6947,14 @@ 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; const requestedSourceGrounding = retryOptions?.sourceGrounding ?? chatExtraParams.sourceGrounding; const sourceGrounding = requestedSourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING ? SELECTION_ONLY_SOURCE_GROUNDING @@ -7010,6 +7072,29 @@ async function sendMessage(extraChatParams = {}) { 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 }; + } + if (!renewedClaim?.claimed) { + onContextMenuClaimRejected?.(renewedClaim || { + reason: 'claim-lost', + retryAfterMs: 1_000, + }); + setTabProcessing(tabId, false); + setTabAbortRequested(tabId, false); + if (sameTabId(currentTabId, tabId)) syncSendButtonState(); + return false; + } + } + let userEl = null; let assistantEl = null; // A selection-only shortcut must not inherit unrelated attachment chips @@ -7058,6 +7143,7 @@ async function sendMessage(extraChatParams = {}) { let accepted = false; let captureStartFailed = false; + let contextMenuReservationRejected = false; let completedSuccessfully = false; let promptEligibleCompletion = false; try { @@ -7159,7 +7245,17 @@ 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) { + onContextMenuClaimRejected?.({ + reason: e.reason || 'claim-lost', + leaseExpiresAt: e.leaseExpiresAt, + retryAfterMs: e.retryAfterMs || 1_000, + }); + 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 +7300,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 +7425,26 @@ chrome.runtime.onMessage.addListener((msg) => { acceptContextMenuPrompt(msg.prompt || msg); }); +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; + } + visibilityHandoffPromise = snapshot + ? persistTabChat(snapshot.tabId, snapshot.html, { allowHidden: true }).catch(() => {}) + : Promise.resolve(); + 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/firefox/src/background.js b/src/firefox/src/background.js index 60e593d99..e40d1574b 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -1876,8 +1876,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); @@ -2271,6 +2304,18 @@ 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, + Date.now(), + () => agent.activeRunState(tabId)?.running || detachedRunStarts.has(tabId), + ); + } + case 'clear_context_menu_prompt': { const tabId = msg.tabId || sender.tab?.id; return await contextMenuStorage.clear(tabId, msg.promptId); diff --git a/src/firefox/src/context-menu-storage.js b/src/firefox/src/context-menu-storage.js index 4bbefd4a1..f74b9da47 100644 --- a/src/firefox/src/context-menu-storage.js +++ b/src/firefox/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 {() => (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,143 @@ export function createContextMenuStorage(getStore) { return { ok: true, prompt: prompt?.text ? prompt : null }; } + async function claim(tabId, promptId, claimantId, now = Date.now(), isRunActive = () => false) { + 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 nowMs = Number.isFinite(Number(now)) ? Number(now) : Date.now(); + 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) { + 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; + if (!samePrompt || !sameClaimant) { + return { + ok: true, + reserved: false, + reason: activeClaim ? 'leased' : 'claim-lost', + leaseExpiresAt: Number(activeClaim?.expiresAt || 0) || 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 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 +344,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, clear, cleanup }; } diff --git a/src/firefox/src/run-reconnect.js b/src/firefox/src/run-reconnect.js index 526ac4f01..a7bd5f11e 100644 --- a/src/firefox/src/run-reconnect.js +++ b/src/firefox/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/firefox/src/ui/context-menu-prompts.js b/src/firefox/src/ui/context-menu-prompts.js index 8cf2a03fa..bd340b62a 100644 --- a/src/firefox/src/ui/context-menu-prompts.js +++ b/src/firefox/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,27 @@ export function createContextMenuPromptHandler({ const currentTabId = getCurrentTabId(); const clearPayload = { tabId: payload.tabId ?? currentTabId, promptId: payload.id }; + 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()) { + 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; + } if (getAgentMode() !== 'ask') setMode('ask'); getInputEl().value = payload.text; @@ -110,19 +172,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 +224,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/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 6e22b8c03..60dff6d47 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -1001,6 +1001,7 @@ const { autoResizeInput, sendMessage, sendToBackground, + getIsDocumentVisible: () => document.visibilityState !== 'hidden', }); // Completion notification + success celebration. Default on; togglable via Settings. let notifySoundEnabled = true; @@ -1441,8 +1442,10 @@ async function loadTabChat(tabId) { 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 }); + } return enqueueTabChatOperation(tabId, async (numericTabId) => { // Keep the live transcript lossless. persistTabChatToSession may compact // only the storage.session copy when the shared quota requires it. @@ -1452,7 +1455,7 @@ function persistTabChat(tabId, html) { }); } -async function flushRenderedTabChat() { +async function flushRenderedTabChat({ allowHidden = false } = {}) { const tabId = renderedTabId; if (tabId == null) return; if (persistTimer && persistTimerTabId === tabId) { @@ -1461,7 +1464,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) { @@ -1485,10 +1492,18 @@ function clearCachedTabChat(tabId) { // to thrash storage on every keystroke / streamed token. let persistTimer = null; let persistTimerTabId = null; +let visibilityHandoffPromise = Promise.resolve(); +let visibleStateRefreshPending = false; +let lastVisibleTabChatSnapshot = null; 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; @@ -1624,7 +1639,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); @@ -3537,6 +3554,45 @@ async function switchToTab(newTabId) { } 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; + + // A browser can preserve more than one sidebar document across tab/window + // changes. Reload shared session state before a previously hidden document + // becomes eligible to persist this tab again. + tabChats.delete(tabId); + const html = await loadTabChat(tabId); + 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); + if (document.visibilityState === 'hidden' || !sameTabId(currentTabId, tabId)) return; + await consumePendingContextMenuPrompt(); + drainQueuedContextMenuPrompts(); +} + +function requestVisibleSidePanelStateRefresh() { + if (document.visibilityState === 'hidden') return; + visibleStateRefreshPending = true; + Promise.resolve(visibilityHandoffPromise).catch(() => {}).then(() => { + if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return; + visibleStateRefreshPending = false; + refreshVisibleSidePanelState().catch(() => {}); + }); } async function restoreActiveRunState(tabId = currentTabId) { @@ -6627,9 +6683,14 @@ 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; const requestedSourceGrounding = retryOptions?.sourceGrounding ?? chatExtraParams.sourceGrounding; const sourceGrounding = requestedSourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING ? SELECTION_ONLY_SOURCE_GROUNDING @@ -6742,6 +6803,29 @@ async function sendMessage(extraChatParams = {}) { 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 }; + } + if (!renewedClaim?.claimed) { + onContextMenuClaimRejected?.(renewedClaim || { + reason: 'claim-lost', + retryAfterMs: 1_000, + }); + setTabProcessing(tabId, false); + setTabAbortRequested(tabId, false); + if (sameTabId(currentTabId, tabId)) syncSendButtonState(); + return false; + } + } + let userEl = null; let assistantEl = null; // A selection-only shortcut must not inherit unrelated attachment chips @@ -6790,6 +6874,7 @@ async function sendMessage(extraChatParams = {}) { let accepted = false; let captureStartFailed = false; + let contextMenuReservationRejected = false; let completedSuccessfully = false; let promptEligibleCompletion = false; try { @@ -6891,7 +6976,17 @@ 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) { + onContextMenuClaimRejected?.({ + reason: e.reason || 'claim-lost', + leaseExpiresAt: e.leaseExpiresAt, + retryAfterMs: e.retryAfterMs || 1_000, + }); + 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); @@ -6935,7 +7030,7 @@ async function sendMessage(extraChatParams = {}) { } 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, @@ -6953,6 +7048,26 @@ browser.runtime.onMessage.addListener((msg) => { acceptContextMenuPrompt(msg.prompt || msg); }); +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; + } + visibilityHandoffPromise = snapshot + ? persistTabChat(snapshot.tabId, snapshot.html, { allowHidden: true }).catch(() => {}) + : Promise.resolve(); + return; + } + requestVisibleSidePanelStateRefresh(); +}); + browser.runtime.onMessage.addListener((msg) => { if (msg?.target !== 'sidepanel' || msg.action !== 'context_menu_tab_navigated') return; clearQueuedForTab(msg.tabId); diff --git a/test/run.js b/test/run.js index f64cf51aa..05893c5ea 100644 --- a/test/run.js +++ b/test/run.js @@ -415,6 +415,7 @@ const { renderSkillMarkdown: renderSkillMarkdownFx } = await import( 'file://' + path.join(ROOT, 'src/firefox/src/ui/skill-markdown.js').replace(/\\/g, '/') ); const { + CONTEXT_MENU_CLAIM_LEASE_MS: CONTEXT_MENU_CLAIM_LEASE_MS_CH, SELECTION_ONLY_SOURCE_GROUNDING: SELECTION_ONLY_SOURCE_GROUNDING_CH, buildContextMenuPrompt: buildContextMenuPromptCh, buildSelectionPrompt: buildSelectionPromptCh, @@ -424,6 +425,7 @@ const { 'file://' + path.join(ROOT, 'src/chrome/src/context-menu-storage.js').replace(/\\/g, '/') ); const { + CONTEXT_MENU_CLAIM_LEASE_MS: CONTEXT_MENU_CLAIM_LEASE_MS_FX, SELECTION_ONLY_SOURCE_GROUNDING: SELECTION_ONLY_SOURCE_GROUNDING_FX, buildContextMenuPrompt: buildContextMenuPromptFx, buildSelectionPrompt: buildSelectionPromptFx, @@ -16988,7 +16990,7 @@ test('sidepanel suppresses streamed raw tool-call text before rendering tool ste assert.match(panel, /streamedAssistantTextByEl\.set\(textEl, nextText\);\s*textEl\.dataset\.streamedAssistantActive = 'true';\s*scheduleStreamedAssistantMarkdownRender\(textEl\);/, `${label}: text_delta should retain raw Markdown, persist only an active-stream marker, and schedule incremental rendering`); assert.match(panel, /const streamedAssistantRenderFrameByEl = new WeakMap\(\);[\s\S]*?function renderStreamedAssistantMarkdownNow\(textEl\)[\s\S]*?textEl\.innerHTML = formatMarkdown\(streamedText, \{ enhance: false \}\);[\s\S]*?scrollToBottom\(\);[\s\S]*?function scheduleStreamedAssistantMarkdownRender\(textEl\)[\s\S]*?requestAnimationFrame\([\s\S]*?renderStreamedAssistantMarkdownNow\(textEl\);/, `${label}: live Markdown should render at most once per animation frame before following output`); assert.match(panel, /function clearStreamedAssistantText\(textEl\)[\s\S]*?cancelAnimationFrame\(frame\);[\s\S]*?streamedAssistantRenderFrameByEl\.delete\(textEl\);/, `${label}: terminal and tool transitions should cancel pending stream renders`); - assert.match(panel, /async function flushRenderedTabChat\(\)[\s\S]*?flushPendingStreamedAssistantMarkdownRenders\(\);\s*await persistTabChat\(tabId, messagesEl\.innerHTML\);/, `${label}: tab switches should render a queued frame before serializing its last acknowledged stream chunk`); + assert.match(panel, /async function flushRenderedTabChat\(\{ allowHidden = false \} = \{\}\)[\s\S]*?flushPendingStreamedAssistantMarkdownRenders\(\);[\s\S]*?const html = messagesEl\.innerHTML;[\s\S]*?await persistTabChat\(tabId, html, \{ allowHidden \}\);/, `${label}: tab switches and visibility handoffs should render a queued frame before serializing its last acknowledged stream chunk`); assert.doesNotMatch(panel, /const nextText = textEl\.textContent \+ data\.content;/, `${label}: rendered Markdown must never become the source for later deltas`); assert.match(panel, /function formatMarkdown\(text, options = \{\}\)[\s\S]*?const enhance = options\.enhance !== false;[\s\S]*?const highlighted = enhance \? highlightCode\(block\.code, block\.lang\) : escapeHtml\(block\.code\);[\s\S]*?if \(enhance\) scheduleMathRender\(\);[\s\S]*?if \(enhance && codeBlocks\.length > 0\)/, `${label}: syntax highlighting and interactive Markdown enhancements should wait for the terminal render`); assert.match(panel, /case 'run_complete':[\s\S]*?const streamedText = getStreamedAssistantText\(textEl\);[\s\S]*?const hasStreamedText = hasStreamedAssistantText\(textEl\);[\s\S]*?const visibleStreamedText = streamedText[\s\S]*?\|\| \(hasStreamedText \? textEl\?\.innerText \|\| textEl\?\.textContent \|\| '' : ''\);[\s\S]*?else if \(textEl && hasStreamedText\)[\s\S]*?const terminalContent = data\.status === 'stopped' \|\| data\.status === 'cancelled'[\s\S]*?\? visibleStreamedText[\s\S]*?renderAssistantTextUpdate\(currentAssistantEl, terminalContent, \{[\s\S]*?replace: terminalContent !== streamedText,[\s\S]*?else if \(textEl && !textEl\.textContent\.trim\(\)\)/, `${label}: restored/background streams should receive one enhanced authoritative terminal render while stopped runs preserve visible partial text`); @@ -17941,7 +17943,7 @@ test('sidepanel flushes run chat before queue settlement after immediate tab swi if (label === 'chrome' || label === 'firefox') { assert.match( panel, - /async function flushRenderedTabChat\(\) \{[\s\S]*?const tabId = renderedTabId;[\s\S]*?if \(persistTimer && persistTimerTabId === tabId\) \{[\s\S]*?clearTimeout\(persistTimer\);[\s\S]*?\}[\s\S]*?await persistTabChat\(tabId, messagesEl\.innerHTML\);[\s\S]*?\}/, + /async function flushRenderedTabChat\(\{ allowHidden = false \} = \{\}\) \{[\s\S]*?const tabId = renderedTabId;[\s\S]*?if \(persistTimer && persistTimerTabId === tabId\) \{[\s\S]*?clearTimeout\(persistTimer\);[\s\S]*?\}[\s\S]*?const html = messagesEl\.innerHTML;[\s\S]*?await persistTabChat\(tabId, html, \{ allowHidden \}\);[\s\S]*?\}/, `${label}: final flush should cancel stale debounced writes and persist the rendered tab immediately`, ); } @@ -21397,7 +21399,10 @@ function createDeferredRemoveStore() { remove(k) { const gate = deferred(); removes.push({ key: k, gate }); - return gate.promise.then(() => { data.delete(k); }); + return gate.promise.then(() => { + const keys = Array.isArray(k) ? k : [k]; + keys.forEach(key => data.delete(key)); + }); }, }; return { store, data, removes }; @@ -21407,11 +21412,13 @@ async function waitMicrotasks(count = 2) { for (let i = 0; i < count; i += 1) await Promise.resolve(); } -function createContextMenuPromptHarness(createHandler, prompt, sendMessage) { +function createContextMenuPromptHarness(createHandler, prompt, sendMessage, options = {}) { let currentTabId = prompt.tabId; let isProcessing = false; + let isVisible = options.isVisible !== false; let mode = 'act'; const sends = []; + const claims = []; const input = { value: '', events: [], @@ -21431,17 +21438,27 @@ function createContextMenuPromptHarness(createHandler, prompt, sendMessage) { return sendMessage(extra, sends.length); }, sendToBackground: async (action, params) => { + if (action === 'claim_context_menu_prompt') { + claims.push(params); + if (typeof options.claimPrompt === 'function') { + return await options.claimPrompt(params, claims.length); + } + return { ok: true, claimed: true }; + } assert.equal(action, 'consume_context_menu_prompt'); assert.deepEqual(params, { tabId: currentTabId }); return { prompt }; }, + getIsDocumentVisible: () => isVisible, }); return { handler, input, sends, + claims, setProcessing(value) { isProcessing = value; }, setTabId(value) { currentTabId = value; }, + setVisible(value) { isVisible = value; }, }; } @@ -21459,14 +21476,18 @@ test('context-menu prompt transport preserves only allowlisted selection groundi const h = createContextMenuPromptHarness(createHandler, prompt, async () => true); h.handler.acceptContextMenuPrompt(prompt); await waitMicrotasks(3); - assert.deepEqual( - h.sends[0].extra, - { - contextMenuClear: { tabId: prompt.tabId, promptId: prompt.id }, - sourceGrounding, - }, - `${label}: selection-only provenance should survive sidepanel transport`, - ); + const { + contextMenuClaim, + __onContextMenuClaimRejected, + ...groundedExtra + } = h.sends[0].extra; + assert.deepEqual(groundedExtra, { + contextMenuClear: { tabId: prompt.tabId, promptId: prompt.id }, + sourceGrounding, + }, `${label}: selection-only provenance should survive sidepanel transport`); + assert.equal(contextMenuClaim.promptId, prompt.id, `${label}: run-start ownership should stay prompt-scoped`); + assert.equal(typeof contextMenuClaim.claimantId, 'string', `${label}: run-start ownership should include the panel claimant`); + assert.equal(typeof __onContextMenuClaimRejected, 'function', `${label}: reservation loss should remain locally retryable`); const invalidPrompt = { id: `${label}-invalid-grounding`, @@ -21477,11 +21498,16 @@ test('context-menu prompt transport preserves only allowlisted selection groundi const invalid = createContextMenuPromptHarness(createHandler, invalidPrompt, async () => true); invalid.handler.acceptContextMenuPrompt(invalidPrompt); await waitMicrotasks(3); - assert.deepEqual( - invalid.sends[0].extra, - { contextMenuClear: { tabId: invalidPrompt.tabId, promptId: invalidPrompt.id } }, - `${label}: unknown grounding values must be dropped`, - ); + const { + contextMenuClaim: invalidClaim, + __onContextMenuClaimRejected: invalidRejection, + ...invalidExtra + } = invalid.sends[0].extra; + assert.deepEqual(invalidExtra, { + contextMenuClear: { tabId: invalidPrompt.tabId, promptId: invalidPrompt.id }, + }, `${label}: unknown grounding values must be dropped`); + assert.equal(invalidClaim.promptId, invalidPrompt.id, `${label}: invalid grounding must not remove run ownership`); + assert.equal(typeof invalidRejection, 'function', `${label}: invalid grounding must not remove retry handling`); } }); @@ -21501,11 +21527,16 @@ test('context-menu prompt recovery retries after an unaccepted send', async () = await h.handler.consumePendingContextMenuPrompt(); await waitMicrotasks(3); assert.equal(h.sends.length, 2, `${label}: stored prompt should retry after the first send was not accepted`); - assert.deepEqual( - h.sends[1].extra, - { contextMenuClear: { tabId: prompt.tabId, promptId: prompt.id } }, - `${label}: retry should still clear the stored prompt when accepted`, - ); + const { + contextMenuClaim, + __onContextMenuClaimRejected, + ...retryExtra + } = h.sends[1].extra; + assert.deepEqual(retryExtra, { + contextMenuClear: { tabId: prompt.tabId, promptId: prompt.id }, + }, `${label}: retry should still clear the stored prompt when accepted`); + assert.equal(contextMenuClaim.promptId, prompt.id, `${label}: recovered sends should retain prompt ownership`); + assert.equal(typeof __onContextMenuClaimRejected, 'function', `${label}: recovered sends should remain retryable until reservation`); } }); @@ -21535,6 +21566,227 @@ test('context-menu prompt recovery does not duplicate an in-flight send', async } }); +test('context-menu prompt leases deduplicate sends across sidepanel instances', async () => { + for (const [label, createHandler] of [ + ['chrome', createContextMenuPromptHandlerCh], + ['firefox', createContextMenuPromptHandlerFx], + ]) { + const prompt = { id: `${label}-cross-instance`, tabId: 10, text: 'Translate this selection' }; + let leaseOwner = ''; + const claimPrompt = async ({ claimantId }) => { + if (!leaseOwner) leaseOwner = claimantId; + return { ok: true, claimed: claimantId === leaseOwner }; + }; + const first = createContextMenuPromptHarness(createHandler, prompt, async () => true, { claimPrompt }); + const second = createContextMenuPromptHarness(createHandler, prompt, async () => true, { claimPrompt }); + + first.handler.acceptContextMenuPrompt(prompt); + second.handler.acceptContextMenuPrompt(prompt); + await waitMicrotasks(8); + + assert.equal(first.claims.length + second.claims.length, 2, `${label}: both panel instances should contend through the background lease`); + assert.equal(first.sends.length + second.sends.length, 1, `${label}: only the lease owner may submit the prompt`); + assert.notEqual(first.claims[0].claimantId, second.claims[0].claimantId, `${label}: panel instances need distinct lease identities`); + } +}); + +test('context-menu prompt lease recovery retries after an abandoned owner expires', async () => { + for (const [label, createHandler] of [ + ['chrome', createContextMenuPromptHandlerCh], + ['firefox', createContextMenuPromptHandlerFx], + ]) { + const prompt = { id: `${label}-lease-recovery`, tabId: 10, text: 'Proofread this selection' }; + let claimAttempt = 0; + const h = createContextMenuPromptHarness(createHandler, prompt, async () => true, { + claimPrompt: async () => { + claimAttempt += 1; + if (claimAttempt === 1) { + return { ok: true, claimed: false, reason: 'leased', leaseExpiresAt: Date.now() + 5 }; + } + return { ok: true, claimed: true }; + }, + }); + + h.handler.acceptContextMenuPrompt(prompt); + await new Promise(resolve => setTimeout(resolve, 90)); + + assert.equal(h.claims.length, 2, `${label}: the surviving panel should reclaim after the abandoned lease expires`); + assert.equal(h.sends.length, 1, `${label}: lease recovery should submit the prompt exactly once`); + } +}); + +test('context-menu prompts retry after the background reports an active run', async () => { + for (const [label, createHandler] of [ + ['chrome', createContextMenuPromptHandlerCh], + ['firefox', createContextMenuPromptHandlerFx], + ]) { + const prompt = { id: `${label}-active-run-retry`, tabId: 10, text: 'Translate this selection' }; + let claimAttempt = 0; + const h = createContextMenuPromptHarness(createHandler, prompt, async () => true, { + claimPrompt: async () => { + claimAttempt += 1; + return claimAttempt === 1 + ? { ok: true, claimed: false, reason: 'run-active', retryAfterMs: 5 } + : { ok: true, claimed: true }; + }, + }); + + h.handler.acceptContextMenuPrompt(prompt); + await new Promise(resolve => setTimeout(resolve, 90)); + + assert.equal(h.claims.length, 2, `${label}: an active background run should trigger a later claim attempt`); + assert.equal(h.sends.length, 1, `${label}: the prompt should submit once the background run releases the tab`); + } +}); + +test('hidden sidepanel instances neither claim nor consume context-menu prompts', async () => { + for (const [label, createHandler] of [ + ['chrome', createContextMenuPromptHandlerCh], + ['firefox', createContextMenuPromptHandlerFx], + ]) { + const prompt = { id: `${label}-hidden`, tabId: 11, text: 'Revise this message' }; + const h = createContextMenuPromptHarness(createHandler, prompt, async () => true, { isVisible: false }); + + h.handler.acceptContextMenuPrompt(prompt); + await h.handler.consumePendingContextMenuPrompt(); + await waitMicrotasks(3); + assert.equal(h.claims.length, 0, `${label}: hidden panels must not claim broadcast or stored prompts`); + assert.equal(h.sends.length, 0, `${label}: hidden panels must not submit prompts`); + + h.setVisible(true); + h.handler.acceptContextMenuPrompt(prompt); + await waitMicrotasks(5); + assert.equal(h.claims.length, 1, `${label}: the panel may claim after becoming visible`); + assert.equal(h.sends.length, 1, `${label}: the visible panel should submit after claiming`); + } +}); + +test('context-menu ownership and stale-panel persistence guards are wired in both builds', () => { + for (const [label, prefix] of [ + ['chrome', 'src/chrome'], + ['firefox', 'src/firefox'], + ]) { + const background = fs.readFileSync(path.join(ROOT, prefix, 'src/background.js'), 'utf8'); + const handler = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/context-menu-prompts.js'), 'utf8'); + const panel = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/sidepanel.js'), 'utf8'); + + assert.match( + background, + /case 'chat_start':[\s\S]*?contextMenuStorage\.reserve\([\s\S]*?launchDetachedRun\('chat', msg, sender\)/, + `${label}: background should validate prompt ownership while atomically reserving the detached run`, + ); + assert.match( + background, + /case 'claim_context_menu_prompt':[\s\S]*?contextMenuStorage\.claim\([\s\S]*?agent\.activeRunState\(tabId\)\?\.running \|\| detachedRunStarts\.has\(tabId\)/, + `${label}: background should reject queued claim operations after another run reserves the tab`, + ); + assert.match( + handler, + /sendToBackground\('claim_context_menu_prompt', \{[\s\S]*?promptId: payload\.id,[\s\S]*?claimantId,[\s\S]*?\}\)/, + `${label}: panel handlers should claim before submitting`, + ); + assert.match( + handler, + /contextMenuClaim: \{[\s\S]*?promptId: payload\.id,[\s\S]*?claimantId,[\s\S]*?\}[\s\S]*?__onContextMenuClaimRejected/, + `${label}: the claimed identity should follow the prompt into the run-start path`, + ); + assert.match( + panel, + /getIsDocumentVisible: \(\) => document\.visibilityState !== 'hidden'/, + `${label}: context-menu handling should be limited to visible panel documents`, + ); + assert.match( + panel, + /function persistTabChat\(tabId, html, \{ allowHidden = false \} = \{\}\) \{[\s\S]*?document\.visibilityState === 'hidden'[\s\S]*?!allowHidden[\s\S]*?skipped: true/, + `${label}: hidden panel documents must not persist transcript snapshots`, + ); + assert.match( + panel, + /async function refreshVisibleSidePanelState\(\) \{[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?await loadTabChat\(tabId\);[\s\S]*?await consumePendingContextMenuPrompt\(\);/, + `${label}: a returning panel should reload shared state before consuming prompts`, + ); + assert.match( + panel, + /document\.addEventListener\('visibilitychange',[\s\S]*?requestVisibleSidePanelStateRefresh\(\)/, + `${label}: visibility restoration should trigger the stale-panel refresh`, + ); + assert.match( + panel, + /const snapshot = lastVisibleTabChatSnapshot;[\s\S]*?visibilityHandoffPromise = snapshot[\s\S]*?persistTabChat\(snapshot\.tabId, snapshot\.html, \{ allowHidden: true \}\)/, + `${label}: the last visible transcript should flush before hidden-writer protection takes over`, + ); + assert.match( + panel, + /await prepareChatHistoryForTurn\(tabId, modeForSend\);[\s\S]*?sendToBackground\('claim_context_menu_prompt',[\s\S]*?let userEl = null;/, + `${label}: context-menu ownership should be renewed after async preflight and before rendering the submitted turn`, + ); + } +}); + +test('context-menu prompt storage enforces a durable expiring lease', async () => { + for (const [label, createStorage, leaseMs] of [ + ['chrome', createContextMenuStorageCh, CONTEXT_MENU_CLAIM_LEASE_MS_CH], + ['firefox', createContextMenuStorageFx, CONTEXT_MENU_CLAIM_LEASE_MS_FX], + ]) { + const data = new Map(); + const store = { + async set(values) { + Object.entries(values || {}).forEach(([key, value]) => data.set(key, value)); + }, + async get(key) { + return data.has(key) ? { [key]: data.get(key) } : {}; + }, + async remove(keys) { + (Array.isArray(keys) ? keys : [keys]).forEach(key => data.delete(key)); + }, + }; + const storage = createStorage(() => store); + const prompt = { id: `${label}-lease`, tabId: 12, text: 'Explain this selection' }; + await storage.save(prompt.tabId, prompt); + + const first = await storage.claim(prompt.tabId, prompt.id, 'panel-a', 1_000); + const duplicateOwner = await storage.claim(prompt.tabId, prompt.id, 'panel-a', 1_001); + const contender = await storage.claim(prompt.tabId, prompt.id, 'panel-b', 1_002); + const takeover = await storage.claim(prompt.tabId, prompt.id, 'panel-b', duplicateOwner.leaseExpiresAt); + + assert.equal(first.claimed, true, `${label}: first panel should acquire the lease`); + assert.equal(duplicateOwner.claimed, true, `${label}: lease acquisition should be idempotent for its owner`); + assert.equal(contender.claimed, false, `${label}: a second panel must be rejected while the lease is active`); + assert.equal(contender.reason, 'leased', `${label}: active lease rejection should be explicit`); + assert.equal(takeover.claimed, true, `${label}: another panel may recover the prompt after lease expiry`); + assert.equal(data.get(storage.claimKey(prompt.tabId))?.claimantId, 'panel-b', `${label}: the durable claim should record the current owner`); + + let reservations = 0; + const staleOwner = await storage.reserve(prompt.tabId, prompt.id, 'panel-a', () => { + reservations += 1; + return { accepted: true }; + }); + const currentOwner = await storage.reserve(prompt.tabId, prompt.id, 'panel-b', () => { + reservations += 1; + return { accepted: true, requestId: 'reserved-run' }; + }); + assert.equal(staleOwner.reserved, false, `${label}: an expired prior owner must not reserve the run after takeover`); + assert.equal(currentOwner.reserved, true, `${label}: the current claimant should reserve the run`); + assert.equal(currentOwner.requestId, 'reserved-run', `${label}: reservation should return the detached-run acknowledgement`); + assert.equal(reservations, 1, `${label}: ownership validation and the run reservation callback should be exactly once`); + + const blockedByRun = await storage.claim( + prompt.tabId, + prompt.id, + 'panel-c', + takeover.leaseExpiresAt, + () => true, + ); + assert.equal(blockedByRun.claimed, false, `${label}: a queued claimant must be rejected once a run is reserved`); + assert.equal(blockedByRun.reason, 'run-active', `${label}: run reservation rejection should remain retryable`); + assert.equal(blockedByRun.retryAfterMs, 1_000, `${label}: active-run rejection should include a retry interval`); + + await storage.clear(prompt.tabId, prompt.id); + assert.equal(data.has(storage.key(prompt.tabId)), false, `${label}: accepting the run should clear the durable prompt`); + assert.equal(data.has(storage.claimKey(prompt.tabId)), false, `${label}: accepting the run should clear its lease`); + } +}); + test('sidepanel auto-follow bypasses smooth-scroll lag while messages grow', () => { for (const [label, prefix] of [ ['chrome', 'src/chrome'], @@ -32798,7 +33050,7 @@ test('detached chat lifecycle owns the default-on Ask streaming kill switch', () const continueBody = background.match(/case 'continue': \{([\s\S]*?)\n\s+case 'abort'/)?.[1] || ''; assert.match(panel, /sendRunWithReconnect\('chat_start'/, `${label}: sidepanel must retain detached chat_start`); - assert.match(background, /case 'chat_start':\s*return launchDetachedRun\('chat'/, `${label}: background must retain detached launch/reconnect ownership`); + assert.match(background, /case 'chat_start':\s*\{[\s\S]*?launchDetachedRun\('chat'/, `${label}: background must retain detached launch/reconnect ownership`); assert.match(chatBody, new RegExp(`await ${storageName}\\.storage\\.local\\.get\\('openaiAskStreamingEnabled'\\)`), `${label}: each detached chat should read the current kill switch`); assert.match(chatBody, /interactiveChat: true/, `${label}: only interactive chat should receive streaming capability`); assert.match(chatBody, /askStreamingEnabled: askStreamingSettings\.openaiAskStreamingEnabled !== false/, `${label}: streaming should default on`); @@ -52345,6 +52597,36 @@ test('detached terminal journals win over duplicate task rejection records', asy } }); +test('detached run rejections preserve structured reservation metadata', async () => { + for (const [label, runDetachedWithReconnect] of [ + ['chrome', runDetachedWithReconnectCh], + ['firefox', runDetachedWithReconnectFx], + ]) { + const requestId = `${label}-structured-rejection`; + await assert.rejects( + runDetachedWithReconnect({ + initialAction: 'chat_start', + payload: { tabId: 64, requestId, mode: 'ask', text: 'translate this' }, + start: async () => ({ + accepted: false, + code: 'context-menu-reservation-rejected', + reason: 'run-active', + retryAfterMs: 1_000, + }), + probe: async () => { + throw new Error('a rejected reservation must not enter the probe loop'); + }, + isConnectionError: () => false, + wait: async () => {}, + }), + error => error?.code === 'context-menu-reservation-rejected' + && error?.reason === 'run-active' + && error?.retryAfterMs === 1_000, + `${label}: the sidepanel should receive enough rejection metadata to retry without rendering an error`, + ); + } +}); + test('run-state probes only expose the snapshot requested by reconnect recovery', () => { const previousSnapshot = { requestId: 'previous-terminal-request', From fd303c4403a422a17f8264e6cc2f45d8fd946e8c Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Wed, 29 Jul 2026 23:46:40 +0200 Subject: [PATCH 02/18] Fix side panel handoff review issues --- src/chrome/src/background.js | 36 ++++++- src/chrome/src/context-menu-storage.js | 49 +++++++++- src/chrome/src/ui/sidepanel.js | 83 ++++++++++++---- src/chrome/src/ui/tab-chat-persistence.js | 84 +++++++++++++++++ src/firefox/src/background.js | 50 ++++++++-- src/firefox/src/context-menu-storage.js | 49 +++++++++- src/firefox/src/ui/sidepanel.js | 83 ++++++++++++---- src/firefox/src/ui/tab-chat-persistence.js | 84 +++++++++++++++++ test/run.js | 105 ++++++++++++++++----- 9 files changed, 550 insertions(+), 73 deletions(-) diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index c6686f819..923675183 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,7 @@ function getContextMenuPromptStore() { } const contextMenuStorage = createContextMenuStorage(getContextMenuPromptStore); +const tabChatHandoff = createTabChatHandoffCoordinator(chrome.storage.session); function createContextMenus() { if (!chrome.contextMenus?.create) return; @@ -1689,8 +1691,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 +1930,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) { @@ -2631,11 +2639,35 @@ async function handleMessage(msg, sender) { ); } + 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); + + case 'load_tab_chat': + return await tabChatHandoff.load(msg.tabId || sender.tab?.id, { + waitForHandoff: msg.waitForHandoff === true, + }); + + case 'clear_tab_chat': + return await tabChatHandoff.clear(msg.tabId || sender.tab?.id); + 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 d8742dc1f..a917f3491 100644 --- a/src/chrome/src/context-menu-storage.js +++ b/src/chrome/src/context-menu-storage.js @@ -309,6 +309,53 @@ export function createContextMenuStorage(getStore) { }); } + 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); @@ -355,5 +402,5 @@ export function createContextMenuStorage(getStore) { }); } - return { key, claimKey, save, consume, claim, reserve, clear, cleanup }; + return { key, claimKey, save, consume, claim, reserve, release, clear, cleanup }; } diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 0a2f34c20..75b544600 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 @@ -1557,20 +1556,25 @@ 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, + }); + 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 */ } @@ -1581,12 +1585,20 @@ 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' }); + 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', { tabId: numericTabId, html }); + } 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', { tabId: numericTabId, html }); }); } @@ -1617,7 +1629,7 @@ function clearCachedTabChat(tabId) { return enqueueTabChatOperation(tabId, async (numericTabId) => { tabChats.delete(numericTabId); try { - await chrome.storage.session?.remove(TAB_CHAT_PREFIX + numericTabId).catch(() => {}); + await sendToBackground('clear_tab_chat', { tabId: numericTabId }); } catch (e) { /* ignore */ } return { ok: true }; }); @@ -2072,7 +2084,6 @@ async function renderClearedConversationForTab(tabId) { // to thrash storage on every keystroke / streamed token. let persistTimer = null; let persistTimerTabId = null; -let visibilityHandoffPromise = Promise.resolve(); let visibleStateRefreshPending = false; let lastVisibleTabChatSnapshot = null; function schedulePersist() { @@ -3718,7 +3729,7 @@ async function refreshVisibleSidePanelState() { // 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); + const html = await loadTabChat(tabId, { waitForHandoff: true }); if (document.visibilityState === 'hidden' || !sameTabId(currentTabId, tabId)) return; renderedTabId = tabId; if (html && html !== messagesEl.innerHTML) { @@ -3740,7 +3751,7 @@ async function refreshVisibleSidePanelState() { function requestVisibleSidePanelStateRefresh() { if (document.visibilityState === 'hidden') return; visibleStateRefreshPending = true; - Promise.resolve(visibilityHandoffPromise).catch(() => {}).then(() => { + Promise.resolve().then(() => { if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return; visibleStateRefreshPending = false; refreshVisibleSidePanelState().catch(() => {}); @@ -7036,7 +7047,9 @@ 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) { if (text) saveInputDraftForTab(tabId, text); return false; @@ -7063,7 +7076,9 @@ async function sendMessage(extraChatParams = {}) { syncSendButtonState(); return false; } - renderToCurrentTab = sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); + renderToCurrentTab = document.visibilityState !== 'hidden' + && sameTabId(currentTabId, tabId) + && sameTabId(renderedTabId, tabId); if (!renderToCurrentTab) { if (text) saveInputDraftForTab(tabId, text); setTabProcessing(tabId, false); @@ -7083,6 +7098,23 @@ async function sendMessage(extraChatParams = {}) { } catch { renewedClaim = { claimed: false, reason: 'connection', retryAfterMs: 1_000 }; } + const claimStillVisible = document.visibilityState !== 'hidden' + && sameTabId(currentTabId, tabId) + && sameTabId(renderedTabId, tabId); + if (renewedClaim?.claimed && !claimStillVisible) { + try { + await sendToBackground('release_context_menu_prompt_claim', { + tabId, + promptId: contextMenuClaim.promptId, + claimantId: contextMenuClaim.claimantId, + }); + } catch { /* the durable lease still expires if release fails */ } + onContextMenuClaimRejected?.({ reason: 'panel-hidden', retryAfterMs: 250 }); + setTabProcessing(tabId, false); + setTabAbortRequested(tabId, false); + if (sameTabId(currentTabId, tabId)) syncSendButtonState(); + return false; + } if (!renewedClaim?.claimed) { onContextMenuClaimRejected?.(renewedClaim || { reason: 'claim-lost', @@ -7095,6 +7127,17 @@ async function sendMessage(extraChatParams = {}) { } } + renderToCurrentTab = document.visibilityState !== 'hidden' + && sameTabId(currentTabId, tabId) + && sameTabId(renderedTabId, tabId); + if (!renderToCurrentTab) { + if (text) saveInputDraftForTab(tabId, text); + setTabProcessing(tabId, false); + setTabAbortRequested(tabId, false); + syncSendButtonState(); + return false; + } + let userEl = null; let assistantEl = null; // A selection-only shortcut must not inherit unrelated attachment chips @@ -7437,9 +7480,9 @@ document.addEventListener('visibilitychange', () => { persistTimer = null; persistTimerTabId = null; } - visibilityHandoffPromise = snapshot - ? persistTabChat(snapshot.tabId, snapshot.html, { allowHidden: true }).catch(() => {}) - : Promise.resolve(); + if (snapshot) { + persistTabChat(snapshot.tabId, snapshot.html, { allowHidden: true }).catch(() => {}); + } return; } requestVisibleSidePanelStateRefresh(); diff --git a/src/chrome/src/ui/tab-chat-persistence.js b/src/chrome/src/ui/tab-chat-persistence.js index efe592be8..ebdf9a4ce 100644 --- a/src/chrome/src/ui/tab-chat-persistence.js +++ b/src/chrome/src/ui/tab-chat-persistence.js @@ -191,3 +191,87 @@ export async function persistTabChatToSession(storageArea, key, html, warn = con } } } + +export const TAB_CHAT_HANDOFF_SETTLE_MS = 25; + +/** + * Serialize tab-chat reads and writes in the background's shared JavaScript + * realm. The short settle window lets the outgoing panel enqueue its final + * visibility snapshot even if the newly visible panel's message is delivered + * first. + * + * @param {chrome.storage.StorageArea | browser.storage.StorageArea} storageArea + * @param {{ + * persist?: typeof persistTabChatToSession, + * settleHandoff?: () => Promise, + * }} options + */ +export function createTabChatHandoffCoordinator(storageArea, { + persist = persistTabChatToSession, + settleHandoff = () => new Promise(resolve => setTimeout(resolve, TAB_CHAT_HANDOFF_SETTLE_MS)), +} = {}) { + const operations = new Map(); + const latestHtml = new Map(); + + function normalizeTabId(tabId) { + const numericTabId = Number(tabId); + return Number.isFinite(numericTabId) ? numericTabId : null; + } + + function enqueue(tabId, fn) { + const numericTabId = normalizeTabId(tabId); + if (numericTabId == null) { + return Promise.resolve({ ok: false, error: 'No tab ID' }); + } + const previous = operations.get(numericTabId) || Promise.resolve(); + const operation = previous.catch(() => {}).then(() => fn(numericTabId)); + operations.set(numericTabId, operation); + operation.finally(() => { + if (operations.get(numericTabId) === operation) operations.delete(numericTabId); + }).catch(() => {}); + return operation; + } + + function save(tabId, html) { + const numericTabId = normalizeTabId(tabId); + if (numericTabId == null) return Promise.resolve({ ok: false, error: 'No tab ID' }); + const source = String(html || ''); + return enqueue(numericTabId, async (queuedTabId) => { + // Retain the lossless copy even if persistence has to compact the + // storage.session value for quota recovery. + latestHtml.set(queuedTabId, source); + return persist(storageArea, TAB_CHAT_PREFIX + queuedTabId, source); + }); + } + + async function load(tabId, { waitForHandoff = false } = {}) { + const numericTabId = normalizeTabId(tabId); + if (numericTabId == null) return { ok: false, found: false, error: 'No tab ID' }; + if (waitForHandoff) await settleHandoff(); + return enqueue(numericTabId, async (queuedTabId) => { + if (latestHtml.has(queuedTabId)) { + return { ok: true, found: true, html: latestHtml.get(queuedTabId) }; + } + const key = TAB_CHAT_PREFIX + queuedTabId; + const stored = await storageArea.get(key); + const html = stored?.[key]; + if (typeof html === 'string') { + latestHtml.set(queuedTabId, html); + return { ok: true, found: true, html }; + } + return { ok: true, found: false, html: null }; + }); + } + + function clear(tabId) { + const numericTabId = normalizeTabId(tabId); + if (numericTabId == null) return Promise.resolve({ ok: false, error: 'No tab ID' }); + return enqueue(numericTabId, async (queuedTabId) => { + latestHtml.delete(queuedTabId); + await storageArea.remove(TAB_CHAT_PREFIX + queuedTabId); + return { ok: true }; + }); + } + + return { save, load, clear }; +} diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index e40d1574b..61acef296 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -35,6 +35,7 @@ import { buildSelectionPrompt, createContextMenuStorage, } from './context-menu-storage.js'; +import { createTabChatHandoffCoordinator } from './ui/tab-chat-persistence.js'; import { normalizeOllamaLaunchHandoff } from './ollama-handoff.js'; import { RunUiJournal, RunUiPersistenceScheduler, runUiSnapshotForRequest } from './run-ui-journal.js'; import { @@ -125,6 +126,7 @@ function getContextMenuPromptStore() { } const contextMenuStorage = createContextMenuStorage(getContextMenuPromptStore); +const tabChatHandoff = createTabChatHandoffCoordinator(browser.storage.session); function createContextMenus() { const api = getContextMenuApi(); @@ -1137,7 +1139,7 @@ browser.tabs.onRemoved.addListener((tabId) => { clearTimeout(pendingContextMenuNotifications.get(tabId)); pendingContextMenuNotifications.delete(tabId); contextMenuStorage.cleanup(tabId); - browser.storage.session?.remove(`tabChat:${tabId}`).catch(() => {}); + tabChatHandoff.clear(tabId).catch(() => {}); scheduler.cancelForTab(tabId).catch(() => {}); try { agent._cleanupTab(tabId); } catch { /* ignore */ } }); @@ -1724,14 +1726,22 @@ browser.runtime.onMessage.addListener((msg, sender) => { }); async function handleMessage(msg, sender) { - if (providerManager.providers.size === 0) { - await providerManager.load(); + const lightweightAction = [ + 'persist_tab_chat', + 'load_tab_chat', + 'clear_tab_chat', + 'release_context_menu_prompt_claim', + ].includes(msg.action); + if (!lightweightAction) { + if (providerManager.providers.size === 0) { + await providerManager.load(); + } + // Hydrate agent toggles and prompt add-ons once at boot (not per message); + // onChanged keeps them in sync afterward. + await Promise.all([planBeforeActReady, planReviewReady, customSkillsReady, userMemoryReady]); + await screenshotRedactionReady; + await imageBudgetReady; } - // Hydrate agent toggles and prompt add-ons once at boot (not per message); - // onChanged keeps them in sync afterward. - await Promise.all([planBeforeActReady, planReviewReady, customSkillsReady, userMemoryReady]); - await screenshotRedactionReady; - await imageBudgetReady; switch (msg.action) { case 'profile_sync_state': return { ok: true, ...(await profileSync.state()) }; @@ -2316,11 +2326,35 @@ async function handleMessage(msg, sender) { ); } + 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); + + case 'load_tab_chat': + return await tabChatHandoff.load(msg.tabId || sender.tab?.id, { + waitForHandoff: msg.waitForHandoff === true, + }); + + case 'clear_tab_chat': + return await tabChatHandoff.clear(msg.tabId || sender.tab?.id); + 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/firefox/src/context-menu-storage.js b/src/firefox/src/context-menu-storage.js index f74b9da47..c76f89f96 100644 --- a/src/firefox/src/context-menu-storage.js +++ b/src/firefox/src/context-menu-storage.js @@ -309,6 +309,53 @@ export function createContextMenuStorage(getStore) { }); } + 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); @@ -355,5 +402,5 @@ export function createContextMenuStorage(getStore) { }); } - return { key, claimKey, save, consume, claim, reserve, clear, cleanup }; + return { key, claimKey, save, consume, claim, reserve, release, clear, cleanup }; } diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 60dff6d47..e6ec48fba 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/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 browser.storage.local (the inline bootstrap @@ -1422,20 +1421,25 @@ 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 browser.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, + }); + 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 */ } @@ -1446,12 +1450,20 @@ 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' }); + 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', { tabId: numericTabId, html }); + } 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(browser.storage.session, key, html); + return sendToBackground('persist_tab_chat', { tabId: numericTabId, html }); }); } @@ -1482,7 +1494,7 @@ function clearCachedTabChat(tabId) { return enqueueTabChatOperation(tabId, async (numericTabId) => { tabChats.delete(numericTabId); try { - await browser.storage.session?.remove(TAB_CHAT_PREFIX + numericTabId).catch(() => {}); + await sendToBackground('clear_tab_chat', { tabId: numericTabId }); } catch (e) { /* ignore */ } return { ok: true }; }); @@ -1492,7 +1504,6 @@ function clearCachedTabChat(tabId) { // to thrash storage on every keystroke / streamed token. let persistTimer = null; let persistTimerTabId = null; -let visibilityHandoffPromise = Promise.resolve(); let visibleStateRefreshPending = false; let lastVisibleTabChatSnapshot = null; function schedulePersist() { @@ -3566,7 +3577,7 @@ async function refreshVisibleSidePanelState() { // changes. Reload shared session state before a previously hidden document // becomes eligible to persist this tab again. tabChats.delete(tabId); - const html = await loadTabChat(tabId); + const html = await loadTabChat(tabId, { waitForHandoff: true }); if (document.visibilityState === 'hidden' || !sameTabId(currentTabId, tabId)) return; renderedTabId = tabId; if (html && html !== messagesEl.innerHTML) { @@ -3588,7 +3599,7 @@ async function refreshVisibleSidePanelState() { function requestVisibleSidePanelStateRefresh() { if (document.visibilityState === 'hidden') return; visibleStateRefreshPending = true; - Promise.resolve(visibilityHandoffPromise).catch(() => {}).then(() => { + Promise.resolve().then(() => { if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return; visibleStateRefreshPending = false; refreshVisibleSidePanelState().catch(() => {}); @@ -6769,7 +6780,9 @@ async function sendMessage(extraChatParams = {}) { } 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) { if (text) saveInputDraftForTab(tabId, text); return false; @@ -6794,7 +6807,9 @@ async function sendMessage(extraChatParams = {}) { syncSendButtonState(); return false; } - renderToCurrentTab = sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); + renderToCurrentTab = document.visibilityState !== 'hidden' + && sameTabId(currentTabId, tabId) + && sameTabId(renderedTabId, tabId); if (!renderToCurrentTab) { if (text) saveInputDraftForTab(tabId, text); setTabProcessing(tabId, false); @@ -6814,6 +6829,23 @@ async function sendMessage(extraChatParams = {}) { } catch { renewedClaim = { claimed: false, reason: 'connection', retryAfterMs: 1_000 }; } + const claimStillVisible = document.visibilityState !== 'hidden' + && sameTabId(currentTabId, tabId) + && sameTabId(renderedTabId, tabId); + if (renewedClaim?.claimed && !claimStillVisible) { + try { + await sendToBackground('release_context_menu_prompt_claim', { + tabId, + promptId: contextMenuClaim.promptId, + claimantId: contextMenuClaim.claimantId, + }); + } catch { /* the durable lease still expires if release fails */ } + onContextMenuClaimRejected?.({ reason: 'panel-hidden', retryAfterMs: 250 }); + setTabProcessing(tabId, false); + setTabAbortRequested(tabId, false); + if (sameTabId(currentTabId, tabId)) syncSendButtonState(); + return false; + } if (!renewedClaim?.claimed) { onContextMenuClaimRejected?.(renewedClaim || { reason: 'claim-lost', @@ -6826,6 +6858,17 @@ async function sendMessage(extraChatParams = {}) { } } + renderToCurrentTab = document.visibilityState !== 'hidden' + && sameTabId(currentTabId, tabId) + && sameTabId(renderedTabId, tabId); + if (!renderToCurrentTab) { + if (text) saveInputDraftForTab(tabId, text); + setTabProcessing(tabId, false); + setTabAbortRequested(tabId, false); + syncSendButtonState(); + return false; + } + let userEl = null; let assistantEl = null; // A selection-only shortcut must not inherit unrelated attachment chips @@ -7060,9 +7103,9 @@ document.addEventListener('visibilitychange', () => { persistTimer = null; persistTimerTabId = null; } - visibilityHandoffPromise = snapshot - ? persistTabChat(snapshot.tabId, snapshot.html, { allowHidden: true }).catch(() => {}) - : Promise.resolve(); + if (snapshot) { + persistTabChat(snapshot.tabId, snapshot.html, { allowHidden: true }).catch(() => {}); + } return; } requestVisibleSidePanelStateRefresh(); diff --git a/src/firefox/src/ui/tab-chat-persistence.js b/src/firefox/src/ui/tab-chat-persistence.js index efe592be8..2c362f8ac 100644 --- a/src/firefox/src/ui/tab-chat-persistence.js +++ b/src/firefox/src/ui/tab-chat-persistence.js @@ -191,3 +191,87 @@ export async function persistTabChatToSession(storageArea, key, html, warn = con } } } + +export const TAB_CHAT_HANDOFF_SETTLE_MS = 25; + +/** + * Serialize tab-chat reads and writes in the background's shared JavaScript + * realm. The short settle window lets the outgoing panel enqueue its final + * visibility snapshot even if the newly visible panel's message is delivered + * first. + * + * @param {browser.storage.StorageArea} storageArea + * @param {{ + * persist?: typeof persistTabChatToSession, + * settleHandoff?: () => Promise, + * }} options + */ +export function createTabChatHandoffCoordinator(storageArea, { + persist = persistTabChatToSession, + settleHandoff = () => new Promise(resolve => setTimeout(resolve, TAB_CHAT_HANDOFF_SETTLE_MS)), +} = {}) { + const operations = new Map(); + const latestHtml = new Map(); + + function normalizeTabId(tabId) { + const numericTabId = Number(tabId); + return Number.isFinite(numericTabId) ? numericTabId : null; + } + + function enqueue(tabId, fn) { + const numericTabId = normalizeTabId(tabId); + if (numericTabId == null) { + return Promise.resolve({ ok: false, error: 'No tab ID' }); + } + const previous = operations.get(numericTabId) || Promise.resolve(); + const operation = previous.catch(() => {}).then(() => fn(numericTabId)); + operations.set(numericTabId, operation); + operation.finally(() => { + if (operations.get(numericTabId) === operation) operations.delete(numericTabId); + }).catch(() => {}); + return operation; + } + + function save(tabId, html) { + const numericTabId = normalizeTabId(tabId); + if (numericTabId == null) return Promise.resolve({ ok: false, error: 'No tab ID' }); + const source = String(html || ''); + return enqueue(numericTabId, async (queuedTabId) => { + // Retain the lossless copy even if persistence has to compact the + // storage.session value for quota recovery. + latestHtml.set(queuedTabId, source); + return persist(storageArea, TAB_CHAT_PREFIX + queuedTabId, source); + }); + } + + async function load(tabId, { waitForHandoff = false } = {}) { + const numericTabId = normalizeTabId(tabId); + if (numericTabId == null) return { ok: false, found: false, error: 'No tab ID' }; + if (waitForHandoff) await settleHandoff(); + return enqueue(numericTabId, async (queuedTabId) => { + if (latestHtml.has(queuedTabId)) { + return { ok: true, found: true, html: latestHtml.get(queuedTabId) }; + } + const key = TAB_CHAT_PREFIX + queuedTabId; + const stored = await storageArea.get(key); + const html = stored?.[key]; + if (typeof html === 'string') { + latestHtml.set(queuedTabId, html); + return { ok: true, found: true, html }; + } + return { ok: true, found: false, html: null }; + }); + } + + function clear(tabId) { + const numericTabId = normalizeTabId(tabId); + if (numericTabId == null) return Promise.resolve({ ok: false, error: 'No tab ID' }); + return enqueue(numericTabId, async (queuedTabId) => { + latestHtml.delete(queuedTabId); + await storageArea.remove(TAB_CHAT_PREFIX + queuedTabId); + return { ok: true }; + }); + } + + return { save, load, clear }; +} diff --git a/test/run.js b/test/run.js index 05893c5ea..41d54d5ea 100644 --- a/test/run.js +++ b/test/run.js @@ -18096,42 +18096,87 @@ test('tab-chat persistence evicts an existing chat when older keys saturate the } }); +test('tab-chat handoff coordinator orders a returning-panel read behind the outgoing snapshot', async () => { + for (const [label, persistence] of [ + ['chrome', TabChatPersistenceCh], + ['firefox', TabChatPersistenceFx], + ]) { + const values = { [`${persistence.TAB_CHAT_PREFIX}7`]: '
stale
' }; + const writes = []; + let releaseHandoff; + const handoffSettled = new Promise(resolve => { releaseHandoff = resolve; }); + const storageArea = { + async get(key) { + return { [key]: values[key] }; + }, + async set(patch) { + writes.push(Object.values(patch)[0]); + Object.assign(values, patch); + }, + async remove(key) { + delete values[key]; + }, + }; + const coordinator = persistence.createTabChatHandoffCoordinator(storageArea, { + settleHandoff: () => handoffSettled, + }); + + // Model the cross-document delivery race: the returning panel asks first, + // then the hidden document publishes its final snapshot during the shared + // handoff window. + const restore = coordinator.load(7, { waitForHandoff: true }); + await waitMicrotasks(2); + const save = coordinator.save(7, '
fresh
'); + releaseHandoff(); + + assert.equal((await save).ok, true, `${label}: outgoing handoff should persist`); + assert.deepEqual(writes, ['
fresh
'], `${label}: final snapshot should be the coordinated write`); + assert.deepEqual( + await restore, + { ok: true, found: true, html: '
fresh
' }, + `${label}: returning panel must restore the final outgoing snapshot`, + ); + } +}); + test('chrome sidepanel serializes tab-chat storage writes with clears and reads', () => { const panel = fs.readFileSync(path.join(ROOT, 'src/chrome/src/ui/sidepanel.js'), 'utf8'); assert.match(panel, /const tabChatOperations = new Map\(\);/, 'chrome: tab-chat operations should be queued per tab'); assert.match(panel, /function enqueueTabChatOperation\(tabId, fn\) \{[\s\S]*?const previous = tabChatOperations\.get\(numericTabId\) \|\| Promise\.resolve\(\);[\s\S]*?tabChatOperations\.set\(numericTabId, operation\);[\s\S]*?\}/, 'chrome: tab-chat writes should be serialized behind prior operations'); - const loadStart = panel.indexOf('async function loadTabChat(tabId) {'); + const loadStart = panel.indexOf('async function loadTabChat(tabId, { waitForHandoff = false } = {}) {'); assert.notEqual(loadStart, -1, 'chrome: loadTabChat missing'); const loadBody = panel.slice(loadStart, panel.indexOf('\n}\n\nfunction persistTabChat', loadStart) + 2); assert.match(loadBody, /const numericTabId = Number\(tabId\);[\s\S]*?if \(!Number\.isFinite\(numericTabId\)\) return null;/, 'chrome: tab-chat restore should normalize tab ids before checking the cache'); - assert.match(loadBody, /if \(!tabChatOperations\.has\(numericTabId\) && tabChats\.has\(numericTabId\)\) return tabChats\.get\(numericTabId\);/, 'chrome: tab-chat restore should only trust cached HTML when no queued operation can update it'); - assert.match(loadBody, /return await enqueueTabChatOperation\(numericTabId, async \(queuedTabId\) => \{[\s\S]*?if \(tabChats\.has\(queuedTabId\)\) return tabChats\.get\(queuedTabId\);[\s\S]*?const stored = await chrome\.storage\.session\.get\(key\);/, 'chrome: tab-chat restore should wait behind pending per-tab operations before reading cache or storage'); - assert.match(panel, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?return persistTabChatToSession\(chrome\.storage\.session, key, html\);/, 'chrome: tab-chat persistence should be serialized through the queue and quota recovery helper'); + assert.match(loadBody, /if \(!waitForHandoff && !tabChatOperations\.has\(numericTabId\) && tabChats\.has\(numericTabId\)\)/, 'chrome: coordinated handoff restores should bypass stale document-local HTML'); + assert.match(loadBody, /return await enqueueTabChatOperation\(numericTabId, async \(queuedTabId\) => \{[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff,[\s\S]*?\}\);/, 'chrome: tab-chat restore should read through the shared background queue'); + assert.match(panel, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?sendToBackground\('persist_tab_chat', \{ tabId: numericTabId, html \}\);/, 'chrome: visible tab-chat persistence should enter the shared background queue'); + assert.match(panel, /document\.visibilityState === 'hidden' && allowHidden[\s\S]*?sendToBackground\('persist_tab_chat', \{ tabId: numericTabId, html \}\);/, 'chrome: hidden handoff must bypass the document-local queue and enter the shared queue immediately'); const clearStart = panel.indexOf('function clearCachedTabChat(tabId) {'); assert.notEqual(clearStart, -1, 'chrome: clearCachedTabChat missing'); const clearBody = panel.slice(clearStart, panel.indexOf('\n}\n\nasync function renderClearedConversationForTab', clearStart) + 2); assert.match(clearBody, /tabChats\.delete\(tabId\);/, 'chrome: clearing tab chat should delete the cached HTML before queuing storage removal'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{/, 'chrome: clearing tab chat should be serialized through the queue'); - assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?chrome\.storage\.session\?\.remove\(TAB_CHAT_PREFIX \+ numericTabId\)/, 'chrome: queued clears should delete stale HTML re-cached by older queued writes before removing storage'); + assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?sendToBackground\('clear_tab_chat', \{ tabId: numericTabId \}\)/, 'chrome: queued clears should delete stale HTML before clearing the shared background state'); }); test('firefox sidepanel serializes tab-chat storage writes with clears and reads', () => { const panel = fs.readFileSync(path.join(ROOT, 'src/firefox/src/ui/sidepanel.js'), 'utf8'); assert.match(panel, /const tabChatOperations = new Map\(\);/, 'firefox: tab-chat operations should be queued per tab'); assert.match(panel, /function enqueueTabChatOperation\(tabId, fn\) \{[\s\S]*?const previous = tabChatOperations\.get\(numericTabId\) \|\| Promise\.resolve\(\);[\s\S]*?tabChatOperations\.set\(numericTabId, operation\);[\s\S]*?\}/, 'firefox: tab-chat writes should be serialized behind prior operations'); - const loadStart = panel.indexOf('async function loadTabChat(tabId) {'); + const loadStart = panel.indexOf('async function loadTabChat(tabId, { waitForHandoff = false } = {}) {'); assert.notEqual(loadStart, -1, 'firefox: loadTabChat missing'); const loadBody = panel.slice(loadStart, panel.indexOf('\n}\n\nfunction persistTabChat', loadStart) + 2); assert.match(loadBody, /const numericTabId = Number\(tabId\);[\s\S]*?if \(!Number\.isFinite\(numericTabId\)\) return null;/, 'firefox: tab-chat restore should normalize tab ids before checking the cache'); - assert.match(loadBody, /if \(!tabChatOperations\.has\(numericTabId\) && tabChats\.has\(numericTabId\)\) return tabChats\.get\(numericTabId\);/, 'firefox: tab-chat restore should only trust cached HTML when no queued operation can update it'); - assert.match(loadBody, /return await enqueueTabChatOperation\(numericTabId, async \(queuedTabId\) => \{[\s\S]*?if \(tabChats\.has\(queuedTabId\)\) return tabChats\.get\(queuedTabId\);[\s\S]*?const stored = await browser\.storage\.session\.get\(key\);/, 'firefox: tab-chat restore should wait behind pending per-tab operations before reading cache or storage'); - assert.match(panel, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?return persistTabChatToSession\(browser\.storage\.session, key, html\);/, 'firefox: tab-chat persistence should be serialized through the queue and quota recovery helper'); + assert.match(loadBody, /if \(!waitForHandoff && !tabChatOperations\.has\(numericTabId\) && tabChats\.has\(numericTabId\)\)/, 'firefox: coordinated handoff restores should bypass stale document-local HTML'); + assert.match(loadBody, /return await enqueueTabChatOperation\(numericTabId, async \(queuedTabId\) => \{[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff,[\s\S]*?\}\);/, 'firefox: tab-chat restore should read through the shared background queue'); + assert.match(panel, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?sendToBackground\('persist_tab_chat', \{ tabId: numericTabId, html \}\);/, 'firefox: visible tab-chat persistence should enter the shared background queue'); + assert.match(panel, /document\.visibilityState === 'hidden' && allowHidden[\s\S]*?sendToBackground\('persist_tab_chat', \{ tabId: numericTabId, html \}\);/, 'firefox: hidden handoff must bypass the document-local queue and enter the shared queue immediately'); const clearStart = panel.indexOf('function clearCachedTabChat(tabId) {'); assert.notEqual(clearStart, -1, 'firefox: clearCachedTabChat missing'); const clearBody = panel.slice(clearStart, panel.indexOf('\n}\n\n// Save current tab', clearStart) + 2); assert.match(clearBody, /tabChats\.delete\(tabId\);/, 'firefox: clearing tab chat should delete the cached HTML before queuing storage removal'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{/, 'firefox: clearing tab chat should be serialized through the queue'); - assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?browser\.storage\.session\?\.remove\(TAB_CHAT_PREFIX \+ numericTabId\)/, 'firefox: queued clears should delete stale HTML re-cached by older queued writes before removing storage'); + assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?sendToBackground\('clear_tab_chat', \{ tabId: numericTabId \}\)/, 'firefox: queued clears should delete stale HTML before clearing the shared background state'); }); test('chrome sidepanel cancels stale tab-chat persistence when clearing a tab', () => { @@ -18152,7 +18197,7 @@ test('chrome sidepanel cancels stale tab-chat persistence when clearing a tab', const clearBody = panel.slice(clearStart, panel.indexOf('\n}\n\nasync function renderClearedConversationForTab', clearStart) + 2); assert.match( clearBody, - /if \(persistTimer && persistTimerTabId === tabId\) \{[\s\S]*?clearTimeout\(persistTimer\);[\s\S]*?persistTimer = null;[\s\S]*?persistTimerTabId = null;[\s\S]*?\}[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?chrome\.storage\.session\?\.remove\(TAB_CHAT_PREFIX \+ numericTabId\)/, + /if \(persistTimer && persistTimerTabId === tabId\) \{[\s\S]*?clearTimeout\(persistTimer\);[\s\S]*?persistTimer = null;[\s\S]*?persistTimerTabId = null;[\s\S]*?\}[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?sendToBackground\('clear_tab_chat', \{ tabId: numericTabId \}\)/, 'chrome: clearing a tab should cancel any pending stale write before removing cached chat', ); }); @@ -20054,7 +20099,7 @@ test('sidepanel preserves stale residual slash-command prompts without hidden ru assert.equal(modeCaptureIdx < parseIdx && apiCaptureIdx < parseIdx, true, `${label}: stale-tab residual sends should not read visible-tab options after slash parsing`); assert.match( sendBody, - /const tabId = currentTabId;[\s\S]*?text = await parseSlashCommands\(text, tabId, \{ permissionSkipContext \}\);[\s\S]*?renderToCurrentTab = sameTabId\(currentTabId, tabId\) && sameTabId\(renderedTabId, tabId\);[\s\S]*?if \(!renderToCurrentTab\) \{[\s\S]*?if \(text\) saveInputDraftForTab\(tabId, text\);[\s\S]*?return false;[\s\S]*?\}/, + /const tabId = currentTabId;[\s\S]*?text = await parseSlashCommands\(text, tabId, \{ permissionSkipContext \}\);[\s\S]*?renderToCurrentTab = document\.visibilityState !== 'hidden'[\s\S]*?sameTabId\(currentTabId, tabId\)[\s\S]*?sameTabId\(renderedTabId, tabId\);[\s\S]*?if \(!renderToCurrentTab\) \{[\s\S]*?if \(text\) saveInputDraftForTab\(tabId, text\);[\s\S]*?return false;[\s\S]*?\}/, `${label}: stale residual slash-command prompts should be preserved as drafts instead of hidden runs`, ); assert.match( @@ -20064,7 +20109,7 @@ test('sidepanel preserves stale residual slash-command prompts without hidden ru ); assert.match( sendBody, - /await prepareChatHistoryForTurn\(tabId, modeForSend\);\s*if \(isTabAbortRequested\(tabId\)\) \{[\s\S]*?setTabProcessing\(tabId, false\);[\s\S]*?setTabAbortRequested\(tabId, false\);[\s\S]*?syncSendButtonState\(\);[\s\S]*?return false;[\s\S]*?\}[\s\S]*?renderToCurrentTab = sameTabId\(currentTabId, tabId\) && sameTabId\(renderedTabId, tabId\);/, + /await prepareChatHistoryForTurn\(tabId, modeForSend\);\s*if \(isTabAbortRequested\(tabId\)\) \{[\s\S]*?setTabProcessing\(tabId, false\);[\s\S]*?setTabAbortRequested\(tabId, false\);[\s\S]*?syncSendButtonState\(\);[\s\S]*?return false;[\s\S]*?\}[\s\S]*?renderToCurrentTab = document\.visibilityState !== 'hidden'[\s\S]*?sameTabId\(currentTabId, tabId\)[\s\S]*?sameTabId\(renderedTabId, tabId\);/, `${label}: aborted sends during history hydration should stop before chat dispatch`, ); const staleReturnIdx = sendBody.indexOf('if (!renderToCurrentTab) {'); @@ -21702,8 +21747,8 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot ); assert.match( panel, - /async function refreshVisibleSidePanelState\(\) \{[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?await loadTabChat\(tabId\);[\s\S]*?await consumePendingContextMenuPrompt\(\);/, - `${label}: a returning panel should reload shared state before consuming prompts`, + /async function refreshVisibleSidePanelState\(\) \{[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?await loadTabChat\(tabId, \{ waitForHandoff: true \}\);[\s\S]*?await consumePendingContextMenuPrompt\(\);/, + `${label}: a returning panel should wait for the shared handoff before consuming prompts`, ); assert.match( panel, @@ -21712,13 +21757,23 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot ); assert.match( panel, - /const snapshot = lastVisibleTabChatSnapshot;[\s\S]*?visibilityHandoffPromise = snapshot[\s\S]*?persistTabChat\(snapshot\.tabId, snapshot\.html, \{ allowHidden: true \}\)/, - `${label}: the last visible transcript should flush before hidden-writer protection takes over`, + /const snapshot = lastVisibleTabChatSnapshot;[\s\S]*?persistTabChat\(snapshot\.tabId, snapshot\.html, \{ allowHidden: true \}\)/, + `${label}: the last visible transcript should enter the shared handoff before hidden-writer protection takes over`, + ); + assert.doesNotMatch( + panel, + /visibilityHandoffPromise/, + `${label}: a document-local promise cannot coordinate distinct side-panel documents`, + ); + assert.match( + background, + /createTabChatHandoffCoordinator\([\s\S]*?case 'persist_tab_chat':[\s\S]*?tabChatHandoff\.save[\s\S]*?case 'load_tab_chat':[\s\S]*?tabChatHandoff\.load/, + `${label}: transcript handoff reads and writes should share a background coordinator`, ); assert.match( panel, - /await prepareChatHistoryForTurn\(tabId, modeForSend\);[\s\S]*?sendToBackground\('claim_context_menu_prompt',[\s\S]*?let userEl = null;/, - `${label}: context-menu ownership should be renewed after async preflight and before rendering the submitted turn`, + /await prepareChatHistoryForTurn\(tabId, modeForSend\);[\s\S]*?renderToCurrentTab = document\.visibilityState !== 'hidden'[\s\S]*?sendToBackground\('claim_context_menu_prompt',[\s\S]*?const claimStillVisible = document\.visibilityState !== 'hidden'[\s\S]*?release_context_menu_prompt_claim[\s\S]*?let userEl = null;/, + `${label}: context-menu ownership should require visibility around renewal and be released before a hidden panel can start`, ); } }); @@ -21770,11 +21825,19 @@ test('context-menu prompt storage enforces a durable expiring lease', async () = assert.equal(currentOwner.requestId, 'reserved-run', `${label}: reservation should return the detached-run acknowledgement`); assert.equal(reservations, 1, `${label}: ownership validation and the run reservation callback should be exactly once`); + const released = await storage.release(prompt.tabId, prompt.id, 'panel-b'); + assert.equal(released.released, true, `${label}: a panel that becomes hidden should relinquish its lease`); + assert.equal(released.prompt?.id, prompt.id, `${label}: releasing ownership must retain the durable prompt`); + assert.equal(data.has(storage.claimKey(prompt.tabId)), false, `${label}: released ownership should clear only the lease`); + assert.equal(data.has(storage.key(prompt.tabId)), true, `${label}: released ownership must not consume the prompt`); + const reclaimed = await storage.claim(prompt.tabId, prompt.id, 'panel-c', takeover.leaseExpiresAt + 1); + assert.equal(reclaimed.claimed, true, `${label}: a newly visible panel should reclaim immediately after release`); + const blockedByRun = await storage.claim( prompt.tabId, prompt.id, - 'panel-c', - takeover.leaseExpiresAt, + 'panel-d', + reclaimed.leaseExpiresAt, () => true, ); assert.equal(blockedByRun.claimed, false, `${label}: a queued claimant must be rejected once a run is reserved`); From 7f5aa1575e28f1d6be66d73784ce6d16fa772779 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Wed, 29 Jul 2026 23:53:41 +0200 Subject: [PATCH 03/18] Close side panel handoff race gaps --- src/chrome/src/ui/context-menu-prompts.js | 10 +++++ src/chrome/src/ui/sidepanel.js | 2 +- src/firefox/src/ui/context-menu-prompts.js | 10 +++++ src/firefox/src/ui/sidepanel.js | 2 +- test/run.js | 45 +++++++++++++++++++++- 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/chrome/src/ui/context-menu-prompts.js b/src/chrome/src/ui/context-menu-prompts.js index bd340b62a..e6dd260da 100644 --- a/src/chrome/src/ui/context-menu-prompts.js +++ b/src/chrome/src/ui/context-menu-prompts.js @@ -147,6 +147,16 @@ export function createContextMenuPromptHandler({ // after this background connection or lease attempt recovers. claimResult = { claimed: false, reason: 'connection', retryAfterMs: 1_000 }; } + if (claimResult?.claimed && !getIsDocumentVisible()) { + try { + await sendToBackground('release_context_menu_prompt_claim', { + tabId: clearPayload.tabId, + promptId: payload.id, + claimantId, + }); + } catch { /* the durable lease still expires if release fails */ } + claimResult = { claimed: false, reason: 'panel-hidden', retryAfterMs: 250 }; + } if (!claimResult?.claimed || !getIsDocumentVisible()) { runningContextMenuPromptId = null; trackedContextMenuPromptIds.delete(payload.id); diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 75b544600..148b9f53a 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -3568,7 +3568,7 @@ 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); + const html = await loadTabChat(restoreTabId, { waitForHandoff: true }); if (currentTabId === restoreTabId && html) { await hydrateRestoredChatHistory(restoreTabId, html); if (currentTabId === restoreTabId) { diff --git a/src/firefox/src/ui/context-menu-prompts.js b/src/firefox/src/ui/context-menu-prompts.js index bd340b62a..e6dd260da 100644 --- a/src/firefox/src/ui/context-menu-prompts.js +++ b/src/firefox/src/ui/context-menu-prompts.js @@ -147,6 +147,16 @@ export function createContextMenuPromptHandler({ // after this background connection or lease attempt recovers. claimResult = { claimed: false, reason: 'connection', retryAfterMs: 1_000 }; } + if (claimResult?.claimed && !getIsDocumentVisible()) { + try { + await sendToBackground('release_context_menu_prompt_claim', { + tabId: clearPayload.tabId, + promptId: payload.id, + claimantId, + }); + } catch { /* the durable lease still expires if release fails */ } + claimResult = { claimed: false, reason: 'panel-hidden', retryAfterMs: 250 }; + } if (!claimResult?.claimed || !getIsDocumentVisible()) { runningContextMenuPromptId = null; trackedContextMenuPromptIds.delete(payload.id); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index e6ec48fba..fdc247da3 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -3420,7 +3420,7 @@ 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); + const html = await loadTabChat(restoreTabId, { waitForHandoff: true }); if (currentTabId === restoreTabId && html) { await hydrateRestoredChatHistory(restoreTabId, html); if (currentTabId === restoreTabId) { diff --git a/test/run.js b/test/run.js index 41d54d5ea..138d1ead5 100644 --- a/test/run.js +++ b/test/run.js @@ -17814,7 +17814,7 @@ test('sidepanel hydrates restored history ids before fallback records', () => { const initMatch = panel.match(/async function init\(\) \{([\s\S]*?)\n \/\/ Start observing the messages container/); assert.ok(initMatch, `${label}: init restore block missing`); const initBody = initMatch[1]; - const initLoadIdx = initBody.indexOf('const html = await loadTabChat(restoreTabId);'); + const initLoadIdx = initBody.indexOf('const html = await loadTabChat(restoreTabId, { waitForHandoff: true });'); const initHydrateIdx = initBody.indexOf('await hydrateRestoredChatHistory(restoreTabId, html);'); const initGuardIdx = initBody.indexOf('if (currentTabId === restoreTabId) {', initHydrateIdx); const initDomIdx = initBody.indexOf('messagesEl.innerHTML = html;', initGuardIdx); @@ -18238,7 +18238,7 @@ test('sidepanel does not miss startup tab switches before consuming tab-scoped s if (label === 'chrome') { const restoreCaptureIdx = body.indexOf('const restoreTabId = currentTabId;'); - const restoreLoadIdx = body.indexOf('const html = await loadTabChat(restoreTabId);'); + const restoreLoadIdx = body.indexOf('const html = await loadTabChat(restoreTabId, { waitForHandoff: true });'); const restoreGuardIdx = body.indexOf('if (currentTabId === restoreTabId && html)'); assert.notEqual(restoreCaptureIdx, -1, 'chrome: initial tab-chat restore should capture the target tab'); assert.notEqual(restoreLoadIdx, -1, 'chrome: initial tab-chat restore should load the captured tab'); @@ -21464,6 +21464,7 @@ function createContextMenuPromptHarness(createHandler, prompt, sendMessage, opti let mode = 'act'; const sends = []; const claims = []; + const releases = []; const input = { value: '', events: [], @@ -21490,6 +21491,10 @@ function createContextMenuPromptHarness(createHandler, prompt, sendMessage, opti } return { ok: true, claimed: true }; } + if (action === 'release_context_menu_prompt_claim') { + releases.push(params); + return { ok: true, released: true }; + } assert.equal(action, 'consume_context_menu_prompt'); assert.deepEqual(params, { tabId: currentTabId }); return { prompt }; @@ -21501,6 +21506,7 @@ function createContextMenuPromptHarness(createHandler, prompt, sendMessage, opti input, sends, claims, + releases, setProcessing(value) { isProcessing = value; }, setTabId(value) { currentTabId = value; }, setVisible(value) { isVisible = value; }, @@ -21635,6 +21641,31 @@ test('context-menu prompt leases deduplicate sends across sidepanel instances', } }); +test('context-menu prompts release an initial claim acquired after the panel becomes hidden', async () => { + for (const [label, createHandler] of [ + ['chrome', createContextMenuPromptHandlerCh], + ['firefox', createContextMenuPromptHandlerFx], + ]) { + const prompt = { id: `${label}-hidden-during-claim`, tabId: 10, text: 'Explain this selection' }; + const claimGate = deferred(); + const h = createContextMenuPromptHarness(createHandler, prompt, async () => true, { + claimPrompt: async () => claimGate.promise, + }); + + h.handler.acceptContextMenuPrompt(prompt); + await waitMicrotasks(3); + h.setVisible(false); + claimGate.resolve({ ok: true, claimed: true }); + await waitMicrotasks(8); + + assert.equal(h.sends.length, 0, `${label}: a hidden panel must not submit after its initial claim resolves`); + assert.equal(h.releases.length, 1, `${label}: the successful hidden claim should be released`); + assert.equal(h.releases[0].tabId, prompt.tabId, `${label}: release should retain the prompt tab`); + assert.equal(h.releases[0].promptId, prompt.id, `${label}: release should target the claimed prompt`); + assert.equal(h.releases[0].claimantId, h.claims[0].claimantId, `${label}: only the claim owner may release it`); + } +}); + test('context-menu prompt lease recovery retries after an abandoned owner expires', async () => { for (const [label, createHandler] of [ ['chrome', createContextMenuPromptHandlerCh], @@ -21735,6 +21766,11 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot /contextMenuClaim: \{[\s\S]*?promptId: payload\.id,[\s\S]*?claimantId,[\s\S]*?\}[\s\S]*?__onContextMenuClaimRejected/, `${label}: the claimed identity should follow the prompt into the run-start path`, ); + assert.match( + handler, + /claimResult\?\.claimed && !getIsDocumentVisible\(\)[\s\S]*?release_context_menu_prompt_claim[\s\S]*?reason: 'panel-hidden'/, + `${label}: an initial claim that resolves after hiding should be released for the visible panel`, + ); assert.match( panel, /getIsDocumentVisible: \(\) => document\.visibilityState !== 'hidden'/, @@ -21750,6 +21786,11 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot /async function refreshVisibleSidePanelState\(\) \{[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?await loadTabChat\(tabId, \{ waitForHandoff: true \}\);[\s\S]*?await consumePendingContextMenuPrompt\(\);/, `${label}: a returning panel should wait for the shared handoff before consuming prompts`, ); + assert.match( + panel, + /const restoreTabId = currentTabId;[\s\S]*?await loadTabChat\(restoreTabId, \{ waitForHandoff: true \}\);/, + `${label}: an initially visible panel should also wait for an outgoing document handoff`, + ); assert.match( panel, /document\.addEventListener\('visibilitychange',[\s\S]*?requestVisibleSidePanelStateRefresh\(\)/, From 9d398ed386efcc799b55a2277a6a6f5fd191dd40 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 00:17:28 +0200 Subject: [PATCH 04/18] Fix transcript handoff barrier race --- src/chrome/src/background.js | 22 +++- src/chrome/src/ui/sidepanel.js | 36 +++++- src/chrome/src/ui/tab-chat-persistence.js | 121 +++++++++++++++++---- src/firefox/src/background.js | 22 +++- src/firefox/src/ui/sidepanel.js | 36 +++++- src/firefox/src/ui/tab-chat-persistence.js | 121 +++++++++++++++++---- test/run.js | 96 ++++++++++++---- 7 files changed, 384 insertions(+), 70 deletions(-) diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 923675183..651b674d6 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -165,7 +165,21 @@ function getContextMenuPromptStore() { } const contextMenuStorage = createContextMenuStorage(getContextMenuPromptStore); -const tabChatHandoff = createTabChatHandoffCoordinator(chrome.storage.session); +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; @@ -2658,11 +2672,15 @@ async function handleMessage(msg, sender) { } case 'persist_tab_chat': - return await tabChatHandoff.save(msg.tabId || sender.tab?.id, msg.html); + 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': diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 148b9f53a..fe465d8e8 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -1538,6 +1538,10 @@ function isSuccessfulAskCompletion(mode, response) { // conversation survives the side panel being closed and reopened. const tabChats = new Map(); 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(); @@ -1568,7 +1572,12 @@ async function loadTabChat(tabId, { waitForHandoff = false } = {}) { 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); @@ -1587,18 +1596,25 @@ function persistTabChat(tabId, html, { allowHidden = false } = {}) { } 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', { tabId: numericTabId, html }); + return sendToBackground('persist_tab_chat', payload); } return enqueueTabChatOperation(tabId, async (numericTabId) => { // Keep the live transcript lossless. The background may compact only the // storage.session copy when the shared quota requires it. tabChats.set(numericTabId, html); - return sendToBackground('persist_tab_chat', { tabId: numericTabId, html }); + return sendToBackground('persist_tab_chat', payload); }); } @@ -1626,6 +1642,7 @@ function clearCachedTabChat(tabId) { persistTimerTabId = null; } tabChats.delete(tabId); + tabChatHandoffGenerations.delete(Number(tabId)); return enqueueTabChatOperation(tabId, async (numericTabId) => { tabChats.delete(numericTabId); try { @@ -7468,6 +7485,21 @@ 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, + }); +}); + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { visibleStateRefreshPending = false; diff --git a/src/chrome/src/ui/tab-chat-persistence.js b/src/chrome/src/ui/tab-chat-persistence.js index ebdf9a4ce..2e4a0a99c 100644 --- a/src/chrome/src/ui/tab-chat-persistence.js +++ b/src/chrome/src/ui/tab-chat-persistence.js @@ -192,25 +192,25 @@ export async function persistTabChatToSession(storageArea, key, html, warn = con } } -export const TAB_CHAT_HANDOFF_SETTLE_MS = 25; +export const TAB_CHAT_HANDOFF_PREFIX = 'tabChatHandoff:'; /** * Serialize tab-chat reads and writes in the background's shared JavaScript - * realm. The short settle window lets the outgoing panel enqueue its final - * visibility snapshot even if the newly visible panel's message is delivered - * first. + * 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, - * settleHandoff?: () => Promise, + * requestHandoff?: (tabId: number, handoff: { ownerId: string, generation: number }) => Promise, * }} options */ export function createTabChatHandoffCoordinator(storageArea, { persist = persistTabChatToSession, - settleHandoff = () => new Promise(resolve => setTimeout(resolve, TAB_CHAT_HANDOFF_SETTLE_MS)), + requestHandoff = async () => null, } = {}) { const operations = new Map(); + const handoffOperations = new Map(); const latestHtml = new Map(); function normalizeTabId(tabId) { @@ -232,11 +232,56 @@ export function createTabChatHandoffCoordinator(storageArea, { return operation; } - function save(tabId, html) { + function enqueueHandoff(tabId, fn) { + const previous = handoffOperations.get(tabId) || Promise.resolve(); + const operation = previous.catch(() => {}).then(fn); + handoffOperations.set(tabId, operation); + operation.finally(() => { + if (handoffOperations.get(tabId) === operation) handoffOperations.delete(tabId); + }).catch(() => {}); + return operation; + } + + async function readHandoffState(tabId) { + const key = TAB_CHAT_HANDOFF_PREFIX + tabId; + const stored = await storageArea.get(key); + const state = stored?.[key]; + const ownerId = String(state?.ownerId || ''); + const generation = Number(state?.generation); + return ownerId && Number.isFinite(generation) && generation > 0 + ? { ownerId, generation } + : null; + } + + async function readLatest(tabId) { + if (latestHtml.has(tabId)) { + return { ok: true, found: true, html: latestHtml.get(tabId) }; + } + const key = TAB_CHAT_PREFIX + tabId; + const stored = await storageArea.get(key); + const html = stored?.[key]; + if (typeof html === 'string') { + latestHtml.set(tabId, html); + return { ok: true, found: true, html }; + } + return { ok: true, found: false, html: null }; + } + + function save(tabId, html, { ownerId = '', handoffGeneration = null } = {}) { const numericTabId = normalizeTabId(tabId); if (numericTabId == null) return Promise.resolve({ ok: false, error: 'No tab ID' }); const source = String(html || ''); return enqueue(numericTabId, async (queuedTabId) => { + const normalizedOwnerId = String(ownerId || ''); + const normalizedGeneration = Number(handoffGeneration); + if (normalizedOwnerId && Number.isFinite(normalizedGeneration) && normalizedGeneration > 0) { + const state = await readHandoffState(queuedTabId); + if (!state + || state.ownerId !== normalizedOwnerId + || state.generation !== normalizedGeneration) { + return { ok: true, skipped: true, reason: 'stale-handoff' }; + } + } // Retain the lossless copy even if persistence has to compact the // storage.session value for quota recovery. latestHtml.set(queuedTabId, source); @@ -244,22 +289,53 @@ export function createTabChatHandoffCoordinator(storageArea, { }); } - async function load(tabId, { waitForHandoff = false } = {}) { + async function load(tabId, { waitForHandoff = false, claimantId = '' } = {}) { const numericTabId = normalizeTabId(tabId); if (numericTabId == null) return { ok: false, found: false, error: 'No tab ID' }; - if (waitForHandoff) await settleHandoff(); - return enqueue(numericTabId, async (queuedTabId) => { - if (latestHtml.has(queuedTabId)) { - return { ok: true, found: true, html: latestHtml.get(queuedTabId) }; - } - const key = TAB_CHAT_PREFIX + queuedTabId; - const stored = await storageArea.get(key); - const html = stored?.[key]; - if (typeof html === 'string') { - latestHtml.set(queuedTabId, html); - return { ok: true, found: true, html }; + if (!waitForHandoff) { + return enqueue(numericTabId, queuedTabId => readLatest(queuedTabId)); + } + + return enqueueHandoff(numericTabId, async () => { + const outgoing = await enqueue(numericTabId, queuedTabId => readHandoffState(queuedTabId)); + if (outgoing) { + let acknowledgement = null; + try { + acknowledgement = await requestHandoff(numericTabId, outgoing); + } catch { /* an unavailable outgoing document has no snapshot to acknowledge */ } + if (acknowledgement?.ok + && String(acknowledgement.ownerId || '') === outgoing.ownerId + && Number(acknowledgement.generation) === outgoing.generation + && typeof acknowledgement.html === 'string') { + await save(numericTabId, acknowledgement.html, { + ownerId: outgoing.ownerId, + handoffGeneration: outgoing.generation, + }); + } } - return { ok: true, found: false, html: null }; + + return enqueue(numericTabId, async (queuedTabId) => { + const current = await readHandoffState(queuedTabId); + const generation = Math.max( + Number(outgoing?.generation || 0), + Number(current?.generation || 0), + ) + 1; + const normalizedClaimantId = String(claimantId || ''); + if (normalizedClaimantId) { + await storageArea.set({ + [TAB_CHAT_HANDOFF_PREFIX + queuedTabId]: { + ownerId: normalizedClaimantId, + generation, + }, + }); + } + const result = await readLatest(queuedTabId); + return { + ...result, + handoffOwnerId: normalizedClaimantId || null, + handoffGeneration: normalizedClaimantId ? generation : null, + }; + }); }); } @@ -268,7 +344,10 @@ export function createTabChatHandoffCoordinator(storageArea, { if (numericTabId == null) return Promise.resolve({ ok: false, error: 'No tab ID' }); return enqueue(numericTabId, async (queuedTabId) => { latestHtml.delete(queuedTabId); - await storageArea.remove(TAB_CHAT_PREFIX + queuedTabId); + await storageArea.remove([ + TAB_CHAT_PREFIX + queuedTabId, + TAB_CHAT_HANDOFF_PREFIX + queuedTabId, + ]); return { ok: true }; }); } diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index 61acef296..b0150a283 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -126,7 +126,21 @@ function getContextMenuPromptStore() { } const contextMenuStorage = createContextMenuStorage(getContextMenuPromptStore); -const tabChatHandoff = createTabChatHandoffCoordinator(browser.storage.session); +const tabChatHandoff = createTabChatHandoffCoordinator(browser.storage.session, { + requestHandoff: async (tabId, { ownerId, generation }) => { + try { + return await browser.runtime.sendMessage({ + target: 'sidepanel', + action: 'tab_chat_handoff_request', + tabId, + ownerId, + generation, + }); + } catch { + return null; + } + }, +}); function createContextMenus() { const api = getContextMenuApi(); @@ -2345,11 +2359,15 @@ async function handleMessage(msg, sender) { } case 'persist_tab_chat': - return await tabChatHandoff.save(msg.tabId || sender.tab?.id, msg.html); + 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': diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index fdc247da3..650598390 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -1403,6 +1403,10 @@ function updateActWarning() { // conversation survives the sidebar being closed and reopened. const tabChats = new Map(); 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(); @@ -1433,7 +1437,12 @@ async function loadTabChat(tabId, { waitForHandoff = false } = {}) { 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); @@ -1452,18 +1461,25 @@ function persistTabChat(tabId, html, { allowHidden = false } = {}) { } 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', { tabId: numericTabId, html }); + return sendToBackground('persist_tab_chat', payload); } return enqueueTabChatOperation(tabId, async (numericTabId) => { // Keep the live transcript lossless. The background may compact only the // storage.session copy when the shared quota requires it. tabChats.set(numericTabId, html); - return sendToBackground('persist_tab_chat', { tabId: numericTabId, html }); + return sendToBackground('persist_tab_chat', payload); }); } @@ -1491,6 +1507,7 @@ function clearCachedTabChat(tabId) { persistTimerTabId = null; } tabChats.delete(tabId); + tabChatHandoffGenerations.delete(Number(tabId)); return enqueueTabChatOperation(tabId, async (numericTabId) => { tabChats.delete(numericTabId); try { @@ -7091,6 +7108,21 @@ browser.runtime.onMessage.addListener((msg) => { acceptContextMenuPrompt(msg.prompt || msg); }); +browser.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, + }); +}); + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { visibleStateRefreshPending = false; diff --git a/src/firefox/src/ui/tab-chat-persistence.js b/src/firefox/src/ui/tab-chat-persistence.js index 2c362f8ac..b271a57db 100644 --- a/src/firefox/src/ui/tab-chat-persistence.js +++ b/src/firefox/src/ui/tab-chat-persistence.js @@ -192,25 +192,25 @@ export async function persistTabChatToSession(storageArea, key, html, warn = con } } -export const TAB_CHAT_HANDOFF_SETTLE_MS = 25; +export const TAB_CHAT_HANDOFF_PREFIX = 'tabChatHandoff:'; /** * Serialize tab-chat reads and writes in the background's shared JavaScript - * realm. The short settle window lets the outgoing panel enqueue its final - * visibility snapshot even if the newly visible panel's message is delivered - * first. + * realm. Coordinated loads explicitly request and acknowledge the outgoing + * document's final snapshot before assigning a new handoff generation. * * @param {browser.storage.StorageArea} storageArea * @param {{ * persist?: typeof persistTabChatToSession, - * settleHandoff?: () => Promise, + * requestHandoff?: (tabId: number, handoff: { ownerId: string, generation: number }) => Promise, * }} options */ export function createTabChatHandoffCoordinator(storageArea, { persist = persistTabChatToSession, - settleHandoff = () => new Promise(resolve => setTimeout(resolve, TAB_CHAT_HANDOFF_SETTLE_MS)), + requestHandoff = async () => null, } = {}) { const operations = new Map(); + const handoffOperations = new Map(); const latestHtml = new Map(); function normalizeTabId(tabId) { @@ -232,11 +232,56 @@ export function createTabChatHandoffCoordinator(storageArea, { return operation; } - function save(tabId, html) { + function enqueueHandoff(tabId, fn) { + const previous = handoffOperations.get(tabId) || Promise.resolve(); + const operation = previous.catch(() => {}).then(fn); + handoffOperations.set(tabId, operation); + operation.finally(() => { + if (handoffOperations.get(tabId) === operation) handoffOperations.delete(tabId); + }).catch(() => {}); + return operation; + } + + async function readHandoffState(tabId) { + const key = TAB_CHAT_HANDOFF_PREFIX + tabId; + const stored = await storageArea.get(key); + const state = stored?.[key]; + const ownerId = String(state?.ownerId || ''); + const generation = Number(state?.generation); + return ownerId && Number.isFinite(generation) && generation > 0 + ? { ownerId, generation } + : null; + } + + async function readLatest(tabId) { + if (latestHtml.has(tabId)) { + return { ok: true, found: true, html: latestHtml.get(tabId) }; + } + const key = TAB_CHAT_PREFIX + tabId; + const stored = await storageArea.get(key); + const html = stored?.[key]; + if (typeof html === 'string') { + latestHtml.set(tabId, html); + return { ok: true, found: true, html }; + } + return { ok: true, found: false, html: null }; + } + + function save(tabId, html, { ownerId = '', handoffGeneration = null } = {}) { const numericTabId = normalizeTabId(tabId); if (numericTabId == null) return Promise.resolve({ ok: false, error: 'No tab ID' }); const source = String(html || ''); return enqueue(numericTabId, async (queuedTabId) => { + const normalizedOwnerId = String(ownerId || ''); + const normalizedGeneration = Number(handoffGeneration); + if (normalizedOwnerId && Number.isFinite(normalizedGeneration) && normalizedGeneration > 0) { + const state = await readHandoffState(queuedTabId); + if (!state + || state.ownerId !== normalizedOwnerId + || state.generation !== normalizedGeneration) { + return { ok: true, skipped: true, reason: 'stale-handoff' }; + } + } // Retain the lossless copy even if persistence has to compact the // storage.session value for quota recovery. latestHtml.set(queuedTabId, source); @@ -244,22 +289,53 @@ export function createTabChatHandoffCoordinator(storageArea, { }); } - async function load(tabId, { waitForHandoff = false } = {}) { + async function load(tabId, { waitForHandoff = false, claimantId = '' } = {}) { const numericTabId = normalizeTabId(tabId); if (numericTabId == null) return { ok: false, found: false, error: 'No tab ID' }; - if (waitForHandoff) await settleHandoff(); - return enqueue(numericTabId, async (queuedTabId) => { - if (latestHtml.has(queuedTabId)) { - return { ok: true, found: true, html: latestHtml.get(queuedTabId) }; - } - const key = TAB_CHAT_PREFIX + queuedTabId; - const stored = await storageArea.get(key); - const html = stored?.[key]; - if (typeof html === 'string') { - latestHtml.set(queuedTabId, html); - return { ok: true, found: true, html }; + if (!waitForHandoff) { + return enqueue(numericTabId, queuedTabId => readLatest(queuedTabId)); + } + + return enqueueHandoff(numericTabId, async () => { + const outgoing = await enqueue(numericTabId, queuedTabId => readHandoffState(queuedTabId)); + if (outgoing) { + let acknowledgement = null; + try { + acknowledgement = await requestHandoff(numericTabId, outgoing); + } catch { /* an unavailable outgoing document has no snapshot to acknowledge */ } + if (acknowledgement?.ok + && String(acknowledgement.ownerId || '') === outgoing.ownerId + && Number(acknowledgement.generation) === outgoing.generation + && typeof acknowledgement.html === 'string') { + await save(numericTabId, acknowledgement.html, { + ownerId: outgoing.ownerId, + handoffGeneration: outgoing.generation, + }); + } } - return { ok: true, found: false, html: null }; + + return enqueue(numericTabId, async (queuedTabId) => { + const current = await readHandoffState(queuedTabId); + const generation = Math.max( + Number(outgoing?.generation || 0), + Number(current?.generation || 0), + ) + 1; + const normalizedClaimantId = String(claimantId || ''); + if (normalizedClaimantId) { + await storageArea.set({ + [TAB_CHAT_HANDOFF_PREFIX + queuedTabId]: { + ownerId: normalizedClaimantId, + generation, + }, + }); + } + const result = await readLatest(queuedTabId); + return { + ...result, + handoffOwnerId: normalizedClaimantId || null, + handoffGeneration: normalizedClaimantId ? generation : null, + }; + }); }); } @@ -268,7 +344,10 @@ export function createTabChatHandoffCoordinator(storageArea, { if (numericTabId == null) return Promise.resolve({ ok: false, error: 'No tab ID' }); return enqueue(numericTabId, async (queuedTabId) => { latestHtml.delete(queuedTabId); - await storageArea.remove(TAB_CHAT_PREFIX + queuedTabId); + await storageArea.remove([ + TAB_CHAT_PREFIX + queuedTabId, + TAB_CHAT_HANDOFF_PREFIX + queuedTabId, + ]); return { ok: true }; }); } diff --git a/test/run.js b/test/run.js index 138d1ead5..89e8963b6 100644 --- a/test/run.js +++ b/test/run.js @@ -18103,39 +18103,80 @@ test('tab-chat handoff coordinator orders a returning-panel read behind the outg ]) { const values = { [`${persistence.TAB_CHAT_PREFIX}7`]: '
stale
' }; const writes = []; - let releaseHandoff; - const handoffSettled = new Promise(resolve => { releaseHandoff = resolve; }); + let acknowledgeHandoff; + let requestedHandoff = null; + let signalHandoffRequest; + const handoffAcknowledged = new Promise(resolve => { acknowledgeHandoff = resolve; }); + const handoffRequested = new Promise(resolve => { signalHandoffRequest = resolve; }); const storageArea = { async get(key) { return { [key]: values[key] }; }, async set(patch) { - writes.push(Object.values(patch)[0]); + if (Object.hasOwn(patch, `${persistence.TAB_CHAT_PREFIX}7`)) { + writes.push(patch[`${persistence.TAB_CHAT_PREFIX}7`]); + } Object.assign(values, patch); }, - async remove(key) { - delete values[key]; + async remove(keys) { + for (const key of Array.isArray(keys) ? keys : [keys]) delete values[key]; }, }; const coordinator = persistence.createTabChatHandoffCoordinator(storageArea, { - settleHandoff: () => handoffSettled, + requestHandoff: async (tabId, handoff) => { + requestedHandoff = { tabId, ...handoff }; + signalHandoffRequest(); + return handoffAcknowledged; + }, }); - // Model the cross-document delivery race: the returning panel asks first, - // then the hidden document publishes its final snapshot during the shared - // handoff window. - const restore = coordinator.load(7, { waitForHandoff: true }); - await waitMicrotasks(2); - const save = coordinator.save(7, '
fresh
'); - releaseHandoff(); + const outgoing = await coordinator.load(7, { + waitForHandoff: true, + claimantId: 'outgoing-panel', + }); + assert.equal(outgoing.handoffGeneration, 1, `${label}: the first visible panel should own generation one`); - assert.equal((await save).ok, true, `${label}: outgoing handoff should persist`); + // Model a service-worker/message delay longer than the removed settling + // timer: the restore must remain pending until the exact outgoing + // generation acknowledges its final lossless snapshot. + let restoreSettled = false; + const restore = coordinator.load(7, { + waitForHandoff: true, + claimantId: 'returning-panel', + }).finally(() => { restoreSettled = true; }); + await handoffRequested; + assert.equal(restoreSettled, false, `${label}: restore must wait for an explicit handoff acknowledgement`); + assert.deepEqual( + requestedHandoff, + { tabId: 7, ownerId: 'outgoing-panel', generation: 1 }, + `${label}: the barrier should target the current owner generation`, + ); + acknowledgeHandoff({ + ok: true, + ownerId: requestedHandoff.ownerId, + generation: requestedHandoff.generation, + html: '
fresh
', + }); + + const restored = await restore; assert.deepEqual(writes, ['
fresh
'], `${label}: final snapshot should be the coordinated write`); assert.deepEqual( - await restore, - { ok: true, found: true, html: '
fresh
' }, + restored, + { + ok: true, + found: true, + html: '
fresh
', + handoffOwnerId: 'returning-panel', + handoffGeneration: 2, + }, `${label}: returning panel must restore the final outgoing snapshot`, ); + const staleWrite = await coordinator.save(7, '
late stale
', { + ownerId: 'outgoing-panel', + handoffGeneration: 1, + }); + assert.equal(staleWrite.skipped, true, `${label}: a late write from the old generation must be rejected`); + assert.equal(values[`${persistence.TAB_CHAT_PREFIX}7`], '
fresh
', `${label}: stale delivery must not overwrite the acknowledged snapshot`); } }); @@ -18149,8 +18190,8 @@ test('chrome sidepanel serializes tab-chat storage writes with clears and reads' assert.match(loadBody, /const numericTabId = Number\(tabId\);[\s\S]*?if \(!Number\.isFinite\(numericTabId\)\) return null;/, 'chrome: tab-chat restore should normalize tab ids before checking the cache'); assert.match(loadBody, /if \(!waitForHandoff && !tabChatOperations\.has\(numericTabId\) && tabChats\.has\(numericTabId\)\)/, 'chrome: coordinated handoff restores should bypass stale document-local HTML'); assert.match(loadBody, /return await enqueueTabChatOperation\(numericTabId, async \(queuedTabId\) => \{[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff,[\s\S]*?\}\);/, 'chrome: tab-chat restore should read through the shared background queue'); - assert.match(panel, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?sendToBackground\('persist_tab_chat', \{ tabId: numericTabId, html \}\);/, 'chrome: visible tab-chat persistence should enter the shared background queue'); - assert.match(panel, /document\.visibilityState === 'hidden' && allowHidden[\s\S]*?sendToBackground\('persist_tab_chat', \{ tabId: numericTabId, html \}\);/, 'chrome: hidden handoff must bypass the document-local queue and enter the shared queue immediately'); + assert.match(panel, /const payload = \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration[\s\S]*?return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?sendToBackground\('persist_tab_chat', payload\);/, 'chrome: visible tab-chat persistence should carry its owner generation through the shared background queue'); + assert.match(panel, /document\.visibilityState === 'hidden' && allowHidden[\s\S]*?sendToBackground\('persist_tab_chat', payload\);/, 'chrome: hidden handoff must bypass the document-local queue and enter the shared queue immediately'); const clearStart = panel.indexOf('function clearCachedTabChat(tabId) {'); assert.notEqual(clearStart, -1, 'chrome: clearCachedTabChat missing'); const clearBody = panel.slice(clearStart, panel.indexOf('\n}\n\nasync function renderClearedConversationForTab', clearStart) + 2); @@ -18169,8 +18210,8 @@ test('firefox sidepanel serializes tab-chat storage writes with clears and reads assert.match(loadBody, /const numericTabId = Number\(tabId\);[\s\S]*?if \(!Number\.isFinite\(numericTabId\)\) return null;/, 'firefox: tab-chat restore should normalize tab ids before checking the cache'); assert.match(loadBody, /if \(!waitForHandoff && !tabChatOperations\.has\(numericTabId\) && tabChats\.has\(numericTabId\)\)/, 'firefox: coordinated handoff restores should bypass stale document-local HTML'); assert.match(loadBody, /return await enqueueTabChatOperation\(numericTabId, async \(queuedTabId\) => \{[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff,[\s\S]*?\}\);/, 'firefox: tab-chat restore should read through the shared background queue'); - assert.match(panel, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?sendToBackground\('persist_tab_chat', \{ tabId: numericTabId, html \}\);/, 'firefox: visible tab-chat persistence should enter the shared background queue'); - assert.match(panel, /document\.visibilityState === 'hidden' && allowHidden[\s\S]*?sendToBackground\('persist_tab_chat', \{ tabId: numericTabId, html \}\);/, 'firefox: hidden handoff must bypass the document-local queue and enter the shared queue immediately'); + assert.match(panel, /const payload = \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration[\s\S]*?return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?sendToBackground\('persist_tab_chat', payload\);/, 'firefox: visible tab-chat persistence should carry its owner generation through the shared background queue'); + assert.match(panel, /document\.visibilityState === 'hidden' && allowHidden[\s\S]*?sendToBackground\('persist_tab_chat', payload\);/, 'firefox: hidden handoff must bypass the document-local queue and enter the shared queue immediately'); const clearStart = panel.indexOf('function clearCachedTabChat(tabId) {'); assert.notEqual(clearStart, -1, 'firefox: clearCachedTabChat missing'); const clearBody = panel.slice(clearStart, panel.indexOf('\n}\n\n// Save current tab', clearStart) + 2); @@ -21811,6 +21852,21 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot /createTabChatHandoffCoordinator\([\s\S]*?case 'persist_tab_chat':[\s\S]*?tabChatHandoff\.save[\s\S]*?case 'load_tab_chat':[\s\S]*?tabChatHandoff\.load/, `${label}: transcript handoff reads and writes should share a background coordinator`, ); + assert.match( + background, + /requestHandoff:[\s\S]*?tab_chat_handoff_request[\s\S]*?ownerId,[\s\S]*?generation,/, + `${label}: a returning panel should request an explicit acknowledgement from the outgoing owner generation`, + ); + assert.match( + panel, + /action !== 'tab_chat_handoff_request'[\s\S]*?msg\.ownerId[\s\S]*?tabChatHandoffOwnerId[\s\S]*?sendResponse\(\{[\s\S]*?generation: Number\(msg\.generation\),[\s\S]*?html: snapshot\.html/, + `${label}: only the targeted owner should acknowledge with its final snapshot`, + ); + assert.doesNotMatch( + fs.readFileSync(path.join(ROOT, prefix, 'src/ui/tab-chat-persistence.js'), 'utf8'), + /TAB_CHAT_HANDOFF_SETTLE_MS|settleHandoff|setTimeout/, + `${label}: transcript handoff correctness must not depend on a settling timer`, + ); assert.match( panel, /await prepareChatHistoryForTurn\(tabId, modeForSend\);[\s\S]*?renderToCurrentTab = document\.visibilityState !== 'hidden'[\s\S]*?sendToBackground\('claim_context_menu_prompt',[\s\S]*?const claimStillVisible = document\.visibilityState !== 'hidden'[\s\S]*?release_context_menu_prompt_claim[\s\S]*?let userEl = null;/, From c8c5bfc4af315c340fe432bd7c0d4fe3ed7a8c37 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 00:39:12 +0200 Subject: [PATCH 05/18] Release claim after final visibility loss --- src/chrome/src/ui/sidepanel.js | 27 ++++++++++++++++++--------- src/firefox/src/ui/sidepanel.js | 27 ++++++++++++++++++--------- test/run.js | 21 +++++++++++++++++++-- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index fe465d8e8..d66b4fdd8 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -7104,6 +7104,20 @@ async function sendMessage(extraChatParams = {}) { return false; } + let renewedContextMenuClaim = false; + const releaseRenewedContextMenuClaim = async () => { + if (!renewedContextMenuClaim) return; + renewedContextMenuClaim = false; + try { + await sendToBackground('release_context_menu_prompt_claim', { + tabId, + promptId: contextMenuClaim.promptId, + claimantId: contextMenuClaim.claimantId, + }); + } catch { /* the durable lease still expires if release fails */ } + onContextMenuClaimRejected?.({ reason: 'panel-hidden', retryAfterMs: 250 }); + }; + if (contextMenuClaim?.promptId && contextMenuClaim?.claimantId) { let renewedClaim = null; try { @@ -7115,18 +7129,12 @@ async function sendMessage(extraChatParams = {}) { } catch { renewedClaim = { claimed: false, reason: 'connection', retryAfterMs: 1_000 }; } + renewedContextMenuClaim = renewedClaim?.claimed === true; const claimStillVisible = document.visibilityState !== 'hidden' && sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); - if (renewedClaim?.claimed && !claimStillVisible) { - try { - await sendToBackground('release_context_menu_prompt_claim', { - tabId, - promptId: contextMenuClaim.promptId, - claimantId: contextMenuClaim.claimantId, - }); - } catch { /* the durable lease still expires if release fails */ } - onContextMenuClaimRejected?.({ reason: 'panel-hidden', retryAfterMs: 250 }); + if (renewedContextMenuClaim && !claimStillVisible) { + await releaseRenewedContextMenuClaim(); setTabProcessing(tabId, false); setTabAbortRequested(tabId, false); if (sameTabId(currentTabId, tabId)) syncSendButtonState(); @@ -7148,6 +7156,7 @@ async function sendMessage(extraChatParams = {}) { && sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); if (!renderToCurrentTab) { + await releaseRenewedContextMenuClaim(); if (text) saveInputDraftForTab(tabId, text); setTabProcessing(tabId, false); setTabAbortRequested(tabId, false); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 650598390..dcb0c5d3c 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -6835,6 +6835,20 @@ async function sendMessage(extraChatParams = {}) { return false; } + let renewedContextMenuClaim = false; + const releaseRenewedContextMenuClaim = async () => { + if (!renewedContextMenuClaim) return; + renewedContextMenuClaim = false; + try { + await sendToBackground('release_context_menu_prompt_claim', { + tabId, + promptId: contextMenuClaim.promptId, + claimantId: contextMenuClaim.claimantId, + }); + } catch { /* the durable lease still expires if release fails */ } + onContextMenuClaimRejected?.({ reason: 'panel-hidden', retryAfterMs: 250 }); + }; + if (contextMenuClaim?.promptId && contextMenuClaim?.claimantId) { let renewedClaim = null; try { @@ -6846,18 +6860,12 @@ async function sendMessage(extraChatParams = {}) { } catch { renewedClaim = { claimed: false, reason: 'connection', retryAfterMs: 1_000 }; } + renewedContextMenuClaim = renewedClaim?.claimed === true; const claimStillVisible = document.visibilityState !== 'hidden' && sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); - if (renewedClaim?.claimed && !claimStillVisible) { - try { - await sendToBackground('release_context_menu_prompt_claim', { - tabId, - promptId: contextMenuClaim.promptId, - claimantId: contextMenuClaim.claimantId, - }); - } catch { /* the durable lease still expires if release fails */ } - onContextMenuClaimRejected?.({ reason: 'panel-hidden', retryAfterMs: 250 }); + if (renewedContextMenuClaim && !claimStillVisible) { + await releaseRenewedContextMenuClaim(); setTabProcessing(tabId, false); setTabAbortRequested(tabId, false); if (sameTabId(currentTabId, tabId)) syncSendButtonState(); @@ -6879,6 +6887,7 @@ async function sendMessage(extraChatParams = {}) { && sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); if (!renderToCurrentTab) { + await releaseRenewedContextMenuClaim(); if (text) saveInputDraftForTab(tabId, text); setTabProcessing(tabId, false); setTabAbortRequested(tabId, false); diff --git a/test/run.js b/test/run.js index 89e8963b6..10fbce5de 100644 --- a/test/run.js +++ b/test/run.js @@ -21869,8 +21869,25 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot ); assert.match( panel, - /await prepareChatHistoryForTurn\(tabId, modeForSend\);[\s\S]*?renderToCurrentTab = document\.visibilityState !== 'hidden'[\s\S]*?sendToBackground\('claim_context_menu_prompt',[\s\S]*?const claimStillVisible = document\.visibilityState !== 'hidden'[\s\S]*?release_context_menu_prompt_claim[\s\S]*?let userEl = null;/, - `${label}: context-menu ownership should require visibility around renewal and be released before a hidden panel can start`, + /const releaseRenewedContextMenuClaim = async \(\) => \{[\s\S]*?renewedContextMenuClaim = false;[\s\S]*?release_context_menu_prompt_claim[\s\S]*?onContextMenuClaimRejected\?\.\(\{ reason: 'panel-hidden', retryAfterMs: 250 \}\);[\s\S]*?renewedContextMenuClaim = renewedClaim\?\.claimed === true;/, + `${label}: renewed ownership should have one idempotent release-and-retry path`, + ); + const sendMessageStart = panel.indexOf('async function sendMessage(extraChatParams = {}) {'); + const sendMessageEnd = panel.indexOf( + label === 'chrome' ? '\nfunction formatRecordTimer(' : '\nfunction ensureCurrentRunAssistant(', + sendMessageStart, + ); + assert.notEqual(sendMessageEnd, -1, `${label}: sendMessage boundary should remain inspectable`); + const sendMessageBody = panel.slice(sendMessageStart, sendMessageEnd); + assert.equal( + (sendMessageBody.match(/await releaseRenewedContextMenuClaim\(\);/g) || []).length, + 2, + `${label}: both visibility checks after renewal should release the claim and schedule retry`, + ); + assert.match( + sendMessageBody, + /renewedContextMenuClaim = renewedClaim\?\.claimed === true;[\s\S]*?const claimStillVisible[\s\S]*?if \(renewedContextMenuClaim && !claimStillVisible\) \{[\s\S]*?await releaseRenewedContextMenuClaim\(\);[\s\S]*?renderToCurrentTab = document\.visibilityState !== 'hidden'[\s\S]*?if \(!renderToCurrentTab\) \{\s*await releaseRenewedContextMenuClaim\(\);/, + `${label}: the final post-renewal visibility failure should relinquish ownership before returning`, ); } }); From d01615ed44b01dc32ba671237b7beb0c78483690 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 00:57:34 +0200 Subject: [PATCH 06/18] Reject expired context-menu reservations --- src/chrome/src/context-menu-storage.js | 10 ++++++---- src/firefox/src/context-menu-storage.js | 10 ++++++---- test/run.js | 14 ++++++++++++-- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/chrome/src/context-menu-storage.js b/src/chrome/src/context-menu-storage.js index a917f3491..a0500d8fb 100644 --- a/src/chrome/src/context-menu-storage.js +++ b/src/chrome/src/context-menu-storage.js @@ -261,7 +261,7 @@ export function createContextMenuStorage(getStore) { }); } - async function reserve(tabId, promptId, claimantId, onReserve) { + async function reserve(tabId, promptId, claimantId, onReserve, now = Date.now()) { const normalizedPromptId = String(promptId || ''); const normalizedClaimantId = String(claimantId || ''); if (!normalizedPromptId || !normalizedClaimantId || typeof onReserve !== 'function') { @@ -291,12 +291,14 @@ export function createContextMenuStorage(getStore) { } const samePrompt = String(activeClaim?.promptId || '') === normalizedPromptId; const sameClaimant = String(activeClaim?.claimantId || '') === normalizedClaimantId; - if (!samePrompt || !sameClaimant) { + const leaseExpiresAt = Number(activeClaim?.expiresAt || 0); + const activeLease = leaseExpiresAt > Number(now); + if (!samePrompt || !sameClaimant || !activeLease) { return { ok: true, reserved: false, - reason: activeClaim ? 'leased' : 'claim-lost', - leaseExpiresAt: Number(activeClaim?.expiresAt || 0) || undefined, + reason: activeClaim && activeLease ? 'leased' : 'claim-lost', + leaseExpiresAt: leaseExpiresAt || undefined, }; } diff --git a/src/firefox/src/context-menu-storage.js b/src/firefox/src/context-menu-storage.js index c76f89f96..fa077305b 100644 --- a/src/firefox/src/context-menu-storage.js +++ b/src/firefox/src/context-menu-storage.js @@ -261,7 +261,7 @@ export function createContextMenuStorage(getStore) { }); } - async function reserve(tabId, promptId, claimantId, onReserve) { + async function reserve(tabId, promptId, claimantId, onReserve, now = Date.now()) { const normalizedPromptId = String(promptId || ''); const normalizedClaimantId = String(claimantId || ''); if (!normalizedPromptId || !normalizedClaimantId || typeof onReserve !== 'function') { @@ -291,12 +291,14 @@ export function createContextMenuStorage(getStore) { } const samePrompt = String(activeClaim?.promptId || '') === normalizedPromptId; const sameClaimant = String(activeClaim?.claimantId || '') === normalizedClaimantId; - if (!samePrompt || !sameClaimant) { + const leaseExpiresAt = Number(activeClaim?.expiresAt || 0); + const activeLease = leaseExpiresAt > Number(now); + if (!samePrompt || !sameClaimant || !activeLease) { return { ok: true, reserved: false, - reason: activeClaim ? 'leased' : 'claim-lost', - leaseExpiresAt: Number(activeClaim?.expiresAt || 0) || undefined, + reason: activeClaim && activeLease ? 'leased' : 'claim-lost', + leaseExpiresAt: leaseExpiresAt || undefined, }; } diff --git a/test/run.js b/test/run.js index 10fbce5de..bfa97c267 100644 --- a/test/run.js +++ b/test/run.js @@ -21926,19 +21926,29 @@ test('context-menu prompt storage enforces a durable expiring lease', async () = assert.equal(data.get(storage.claimKey(prompt.tabId))?.claimantId, 'panel-b', `${label}: the durable claim should record the current owner`); let reservations = 0; + const reservationTime = takeover.leaseExpiresAt - 1; const staleOwner = await storage.reserve(prompt.tabId, prompt.id, 'panel-a', () => { reservations += 1; return { accepted: true }; - }); + }, reservationTime); const currentOwner = await storage.reserve(prompt.tabId, prompt.id, 'panel-b', () => { reservations += 1; return { accepted: true, requestId: 'reserved-run' }; - }); + }, reservationTime); assert.equal(staleOwner.reserved, false, `${label}: an expired prior owner must not reserve the run after takeover`); assert.equal(currentOwner.reserved, true, `${label}: the current claimant should reserve the run`); assert.equal(currentOwner.requestId, 'reserved-run', `${label}: reservation should return the detached-run acknowledgement`); assert.equal(reservations, 1, `${label}: ownership validation and the run reservation callback should be exactly once`); + const expiredOwner = await storage.reserve(prompt.tabId, prompt.id, 'panel-b', () => { + reservations += 1; + return { accepted: true }; + }, takeover.leaseExpiresAt); + assert.equal(expiredOwner.reserved, false, `${label}: a claimant must not reserve at or after lease expiry`); + assert.equal(expiredOwner.reason, 'claim-lost', `${label}: expired ownership should require a fresh claim`); + assert.equal(expiredOwner.leaseExpiresAt, takeover.leaseExpiresAt, `${label}: expiry rejection should retain retry timing`); + assert.equal(reservations, 1, `${label}: an expired claimant must not invoke the run reservation callback`); + const released = await storage.release(prompt.tabId, prompt.id, 'panel-b'); assert.equal(released.released, true, `${label}: a panel that becomes hidden should relinquish its lease`); assert.equal(released.prompt?.id, prompt.id, `${label}: releasing ownership must retain the durable prompt`); From 37e243a6ab46b3313f570d781164185785f73c8c Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 05:05:00 +0200 Subject: [PATCH 07/18] Fix sidepanel refresh and claim races --- src/chrome/src/ui/sidepanel.js | 76 ++++++++++++++++++++++----------- src/firefox/src/ui/sidepanel.js | 76 ++++++++++++++++++++++----------- test/run.js | 33 ++++++++++---- 3 files changed, 128 insertions(+), 57 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index d66b4fdd8..688a893fd 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -2102,7 +2102,19 @@ async function renderClearedConversationForTab(tabId) { let persistTimer = null; let persistTimerTabId = null; let visibleStateRefreshPending = false; +let visibleStateRefreshPromise = Promise.resolve(); +let visibleStateRefreshInProgress = false; +let visibleStateRefreshGeneration = 0; let lastVisibleTabChatSnapshot = null; + +async function waitForVisibleSidePanelStateRefresh() { + let pendingRefresh; + do { + pendingRefresh = visibleStateRefreshPromise; + await pendingRefresh.catch(() => {}); + } while (pendingRefresh !== visibleStateRefreshPromise); +} + function schedulePersist() { if (document.visibilityState === 'hidden') return; if (persistTimer) clearTimeout(persistTimer); @@ -3760,19 +3772,30 @@ async function refreshVisibleSidePanelState() { addMessage('system', t('sp.help_message')); } await restoreActiveRunState(tabId); - if (document.visibilityState === 'hidden' || !sameTabId(currentTabId, tabId)) return; - await consumePendingContextMenuPrompt(); - drainQueuedContextMenuPrompts(); + return document.visibilityState !== 'hidden' && sameTabId(currentTabId, tabId); } function requestVisibleSidePanelStateRefresh() { if (document.visibilityState === 'hidden') return; visibleStateRefreshPending = true; - Promise.resolve().then(() => { - if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return; + visibleStateRefreshInProgress = true; + const refreshGeneration = ++visibleStateRefreshGeneration; + syncSendButtonState(); + visibleStateRefreshPromise = visibleStateRefreshPromise.catch(() => {}).then(async () => { + if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return false; visibleStateRefreshPending = false; - refreshVisibleSidePanelState().catch(() => {}); + return refreshVisibleSidePanelState(); }); + 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) { @@ -6253,6 +6276,10 @@ function isOutOfBandSlashDraft(value) { function syncSendButtonState() { if (!sendBtn) return; const draft = normalizeScreenshotCommandText(inputEl?.value || '').trim(); + if (visibleStateRefreshPending || visibleStateRefreshInProgress) { + sendBtn.disabled = true; + return; + } if (isAwaitingPlanReviewForTab()) { sendBtn.disabled = true; return; @@ -6989,11 +7016,25 @@ async function sendMessage(extraChatParams = {}) { : null; delete chatExtraParams.sourceGrounding; if (sourceGrounding) chatExtraParams.sourceGrounding = sourceGrounding; + await waitForVisibleSidePanelStateRefresh(); stopListening(); let text = inputEl.value.trim(); if (!text) return; const submittedText = text; const tabId = currentTabId; + let contextMenuClaimOwned = Boolean(contextMenuClaim?.promptId && contextMenuClaim?.claimantId); + const releaseOwnedContextMenuClaim = async () => { + if (!contextMenuClaimOwned) return; + contextMenuClaimOwned = false; + try { + await sendToBackground('release_context_menu_prompt_claim', { + tabId, + promptId: contextMenuClaim.promptId, + claimantId: contextMenuClaim.claimantId, + }); + } catch { /* the durable lease still expires if release fails */ } + onContextMenuClaimRejected?.({ reason: 'panel-hidden', retryAfterMs: 250 }); + }; if (isConversationClearInProgress(tabId)) return false; const permissionSkipContext = permissionSkipCommandContextForDraft(tabId, text); const requestId = createRunRequestId(tabId); @@ -7097,6 +7138,7 @@ async function sendMessage(extraChatParams = {}) { && sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); if (!renderToCurrentTab) { + await releaseOwnedContextMenuClaim(); if (text) saveInputDraftForTab(tabId, text); setTabProcessing(tabId, false); setTabAbortRequested(tabId, false); @@ -7104,20 +7146,6 @@ async function sendMessage(extraChatParams = {}) { return false; } - let renewedContextMenuClaim = false; - const releaseRenewedContextMenuClaim = async () => { - if (!renewedContextMenuClaim) return; - renewedContextMenuClaim = false; - try { - await sendToBackground('release_context_menu_prompt_claim', { - tabId, - promptId: contextMenuClaim.promptId, - claimantId: contextMenuClaim.claimantId, - }); - } catch { /* the durable lease still expires if release fails */ } - onContextMenuClaimRejected?.({ reason: 'panel-hidden', retryAfterMs: 250 }); - }; - if (contextMenuClaim?.promptId && contextMenuClaim?.claimantId) { let renewedClaim = null; try { @@ -7129,12 +7157,12 @@ async function sendMessage(extraChatParams = {}) { } catch { renewedClaim = { claimed: false, reason: 'connection', retryAfterMs: 1_000 }; } - renewedContextMenuClaim = renewedClaim?.claimed === true; + contextMenuClaimOwned = renewedClaim?.claimed === true; const claimStillVisible = document.visibilityState !== 'hidden' && sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); - if (renewedContextMenuClaim && !claimStillVisible) { - await releaseRenewedContextMenuClaim(); + if (contextMenuClaimOwned && !claimStillVisible) { + await releaseOwnedContextMenuClaim(); setTabProcessing(tabId, false); setTabAbortRequested(tabId, false); if (sameTabId(currentTabId, tabId)) syncSendButtonState(); @@ -7156,7 +7184,7 @@ async function sendMessage(extraChatParams = {}) { && sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); if (!renderToCurrentTab) { - await releaseRenewedContextMenuClaim(); + await releaseOwnedContextMenuClaim(); if (text) saveInputDraftForTab(tabId, text); setTabProcessing(tabId, false); setTabAbortRequested(tabId, false); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index dcb0c5d3c..66edddbf8 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -1522,7 +1522,19 @@ function clearCachedTabChat(tabId) { let persistTimer = null; let persistTimerTabId = null; let visibleStateRefreshPending = false; +let visibleStateRefreshPromise = Promise.resolve(); +let visibleStateRefreshInProgress = false; +let visibleStateRefreshGeneration = 0; let lastVisibleTabChatSnapshot = null; + +async function waitForVisibleSidePanelStateRefresh() { + let pendingRefresh; + do { + pendingRefresh = visibleStateRefreshPromise; + await pendingRefresh.catch(() => {}); + } while (pendingRefresh !== visibleStateRefreshPromise); +} + function schedulePersist() { if (document.visibilityState === 'hidden') return; if (persistTimer) clearTimeout(persistTimer); @@ -3608,19 +3620,30 @@ async function refreshVisibleSidePanelState() { addMessage('system', t('sp.help_message')); } await restoreActiveRunState(tabId); - if (document.visibilityState === 'hidden' || !sameTabId(currentTabId, tabId)) return; - await consumePendingContextMenuPrompt(); - drainQueuedContextMenuPrompts(); + return document.visibilityState !== 'hidden' && sameTabId(currentTabId, tabId); } function requestVisibleSidePanelStateRefresh() { if (document.visibilityState === 'hidden') return; visibleStateRefreshPending = true; - Promise.resolve().then(() => { - if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return; + visibleStateRefreshInProgress = true; + const refreshGeneration = ++visibleStateRefreshGeneration; + syncSendButtonState(); + visibleStateRefreshPromise = visibleStateRefreshPromise.catch(() => {}).then(async () => { + if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return false; visibleStateRefreshPending = false; - refreshVisibleSidePanelState().catch(() => {}); + return refreshVisibleSidePanelState(); }); + 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) { @@ -6087,6 +6110,10 @@ function isOutOfBandSlashDraft(value) { function syncSendButtonState() { if (!sendBtn) return; const draft = normalizeScreenshotCommandText(inputEl?.value || '').trim(); + if (visibleStateRefreshPending || visibleStateRefreshInProgress) { + sendBtn.disabled = true; + return; + } if (isAwaitingPlanReviewForTab()) { sendBtn.disabled = true; return; @@ -6725,11 +6752,25 @@ async function sendMessage(extraChatParams = {}) { : null; delete chatExtraParams.sourceGrounding; if (sourceGrounding) chatExtraParams.sourceGrounding = sourceGrounding; + await waitForVisibleSidePanelStateRefresh(); stopListening(); let text = inputEl.value.trim(); if (!text) return; const submittedText = text; const tabId = currentTabId; + let contextMenuClaimOwned = Boolean(contextMenuClaim?.promptId && contextMenuClaim?.claimantId); + const releaseOwnedContextMenuClaim = async () => { + if (!contextMenuClaimOwned) return; + contextMenuClaimOwned = false; + try { + await sendToBackground('release_context_menu_prompt_claim', { + tabId, + promptId: contextMenuClaim.promptId, + claimantId: contextMenuClaim.claimantId, + }); + } catch { /* the durable lease still expires if release fails */ } + onContextMenuClaimRejected?.({ reason: 'panel-hidden', retryAfterMs: 250 }); + }; if (isConversationClearInProgress(tabId)) return false; const permissionSkipContext = permissionSkipCommandContextForDraft(tabId, text); const requestId = createRunRequestId(tabId); @@ -6828,6 +6869,7 @@ async function sendMessage(extraChatParams = {}) { && sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); if (!renderToCurrentTab) { + await releaseOwnedContextMenuClaim(); if (text) saveInputDraftForTab(tabId, text); setTabProcessing(tabId, false); setTabAbortRequested(tabId, false); @@ -6835,20 +6877,6 @@ async function sendMessage(extraChatParams = {}) { return false; } - let renewedContextMenuClaim = false; - const releaseRenewedContextMenuClaim = async () => { - if (!renewedContextMenuClaim) return; - renewedContextMenuClaim = false; - try { - await sendToBackground('release_context_menu_prompt_claim', { - tabId, - promptId: contextMenuClaim.promptId, - claimantId: contextMenuClaim.claimantId, - }); - } catch { /* the durable lease still expires if release fails */ } - onContextMenuClaimRejected?.({ reason: 'panel-hidden', retryAfterMs: 250 }); - }; - if (contextMenuClaim?.promptId && contextMenuClaim?.claimantId) { let renewedClaim = null; try { @@ -6860,12 +6888,12 @@ async function sendMessage(extraChatParams = {}) { } catch { renewedClaim = { claimed: false, reason: 'connection', retryAfterMs: 1_000 }; } - renewedContextMenuClaim = renewedClaim?.claimed === true; + contextMenuClaimOwned = renewedClaim?.claimed === true; const claimStillVisible = document.visibilityState !== 'hidden' && sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); - if (renewedContextMenuClaim && !claimStillVisible) { - await releaseRenewedContextMenuClaim(); + if (contextMenuClaimOwned && !claimStillVisible) { + await releaseOwnedContextMenuClaim(); setTabProcessing(tabId, false); setTabAbortRequested(tabId, false); if (sameTabId(currentTabId, tabId)) syncSendButtonState(); @@ -6887,7 +6915,7 @@ async function sendMessage(extraChatParams = {}) { && sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); if (!renderToCurrentTab) { - await releaseRenewedContextMenuClaim(); + await releaseOwnedContextMenuClaim(); if (text) saveInputDraftForTab(tabId, text); setTabProcessing(tabId, false); setTabAbortRequested(tabId, false); diff --git a/test/run.js b/test/run.js index bfa97c267..1d82d55b9 100644 --- a/test/run.js +++ b/test/run.js @@ -21824,8 +21824,23 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot ); assert.match( panel, - /async function refreshVisibleSidePanelState\(\) \{[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?await loadTabChat\(tabId, \{ waitForHandoff: true \}\);[\s\S]*?await consumePendingContextMenuPrompt\(\);/, - `${label}: a returning panel should wait for the shared handoff before consuming prompts`, + /let visibleStateRefreshPromise = Promise\.resolve\(\);[\s\S]*?async function waitForVisibleSidePanelStateRefresh\(\) \{[\s\S]*?pendingRefresh = visibleStateRefreshPromise;[\s\S]*?await pendingRefresh\.catch\(\(\) => \{\}\);[\s\S]*?pendingRefresh !== visibleStateRefreshPromise/, + `${label}: sends should wait until the complete serialized visibility refresh queue settles`, + ); + assert.match( + panel, + /async function refreshVisibleSidePanelState\(\) \{[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?await loadTabChat\(tabId, \{ waitForHandoff: true \}\);[\s\S]*?await restoreActiveRunState\(tabId\);[\s\S]*?function requestVisibleSidePanelStateRefresh\(\) \{[\s\S]*?visibleStateRefreshPromise = visibleStateRefreshPromise\.catch\(\(\) => \{\}\)\.then\(async \(\) => \{[\s\S]*?return refreshVisibleSidePanelState\(\);[\s\S]*?refreshPromise\.then\(\(refreshed\) => \{[\s\S]*?refreshPromise !== visibleStateRefreshPromise[\s\S]*?consumePendingContextMenuPrompt\(\)/, + `${label}: a returning panel should serialize the shared handoff before consuming prompts`, + ); + assert.match( + panel, + /function syncSendButtonState\(\) \{[\s\S]*?if \(visibleStateRefreshPending \|\| visibleStateRefreshInProgress\) \{\s*sendBtn\.disabled = true;\s*return;/, + `${label}: the composer should stay disabled throughout visibility reconciliation`, + ); + assert.match( + panel, + /async function sendMessage\(extraChatParams = \{\}\) \{[\s\S]*?await waitForVisibleSidePanelStateRefresh\(\);\s*stopListening\(\);\s*let text = inputEl\.value\.trim\(\);/, + `${label}: user sends should not capture or append a turn until visibility reconciliation finishes`, ); assert.match( panel, @@ -21869,8 +21884,8 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot ); assert.match( panel, - /const releaseRenewedContextMenuClaim = async \(\) => \{[\s\S]*?renewedContextMenuClaim = false;[\s\S]*?release_context_menu_prompt_claim[\s\S]*?onContextMenuClaimRejected\?\.\(\{ reason: 'panel-hidden', retryAfterMs: 250 \}\);[\s\S]*?renewedContextMenuClaim = renewedClaim\?\.claimed === true;/, - `${label}: renewed ownership should have one idempotent release-and-retry path`, + /let contextMenuClaimOwned = Boolean\(contextMenuClaim\?\.promptId && contextMenuClaim\?\.claimantId\);[\s\S]*?const releaseOwnedContextMenuClaim = async \(\) => \{[\s\S]*?contextMenuClaimOwned = false;[\s\S]*?release_context_menu_prompt_claim[\s\S]*?onContextMenuClaimRejected\?\.\(\{ reason: 'panel-hidden', retryAfterMs: 250 \}\);[\s\S]*?contextMenuClaimOwned = renewedClaim\?\.claimed === true;/, + `${label}: initial and renewed ownership should share one idempotent release-and-retry path`, ); const sendMessageStart = panel.indexOf('async function sendMessage(extraChatParams = {}) {'); const sendMessageEnd = panel.indexOf( @@ -21880,14 +21895,14 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot assert.notEqual(sendMessageEnd, -1, `${label}: sendMessage boundary should remain inspectable`); const sendMessageBody = panel.slice(sendMessageStart, sendMessageEnd); assert.equal( - (sendMessageBody.match(/await releaseRenewedContextMenuClaim\(\);/g) || []).length, - 2, - `${label}: both visibility checks after renewal should release the claim and schedule retry`, + (sendMessageBody.match(/await releaseOwnedContextMenuClaim\(\);/g) || []).length, + 3, + `${label}: visibility exits after preflight and renewal should release the claim and schedule retry`, ); assert.match( sendMessageBody, - /renewedContextMenuClaim = renewedClaim\?\.claimed === true;[\s\S]*?const claimStillVisible[\s\S]*?if \(renewedContextMenuClaim && !claimStillVisible\) \{[\s\S]*?await releaseRenewedContextMenuClaim\(\);[\s\S]*?renderToCurrentTab = document\.visibilityState !== 'hidden'[\s\S]*?if \(!renderToCurrentTab\) \{\s*await releaseRenewedContextMenuClaim\(\);/, - `${label}: the final post-renewal visibility failure should relinquish ownership before returning`, + /await prepareChatHistoryForTurn\(tabId, modeForSend\);[\s\S]*?if \(!renderToCurrentTab\) \{\s*await releaseOwnedContextMenuClaim\(\);[\s\S]*?contextMenuClaimOwned = renewedClaim\?\.claimed === true;[\s\S]*?const claimStillVisible[\s\S]*?if \(contextMenuClaimOwned && !claimStillVisible\) \{[\s\S]*?await releaseOwnedContextMenuClaim\(\);[\s\S]*?renderToCurrentTab = document\.visibilityState !== 'hidden'[\s\S]*?if \(!renderToCurrentTab\) \{\s*await releaseOwnedContextMenuClaim\(\);/, + `${label}: every post-preflight visibility failure should relinquish initial or renewed ownership before returning`, ); } }); From d27bcb363d5a5c25b2e3341bf466b6d864d30cad Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 10:05:28 +0200 Subject: [PATCH 08/18] Handle handoff and claim retry races --- src/chrome/src/ui/context-menu-prompts.js | 26 ++++++++--- src/chrome/src/ui/sidepanel.js | 18 ++++++-- src/firefox/src/ui/context-menu-prompts.js | 26 ++++++++--- src/firefox/src/ui/sidepanel.js | 18 ++++++-- test/run.js | 51 ++++++++++++++++++++-- 5 files changed, 113 insertions(+), 26 deletions(-) diff --git a/src/chrome/src/ui/context-menu-prompts.js b/src/chrome/src/ui/context-menu-prompts.js index e6dd260da..ee6c42ff7 100644 --- a/src/chrome/src/ui/context-menu-prompts.js +++ b/src/chrome/src/ui/context-menu-prompts.js @@ -135,6 +135,15 @@ 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', { @@ -148,13 +157,7 @@ export function createContextMenuPromptHandler({ claimResult = { claimed: false, reason: 'connection', retryAfterMs: 1_000 }; } if (claimResult?.claimed && !getIsDocumentVisible()) { - try { - await sendToBackground('release_context_menu_prompt_claim', { - tabId: clearPayload.tabId, - promptId: payload.id, - claimantId, - }); - } catch { /* the durable lease still expires if release fails */ } + await releasePromptClaim(); claimResult = { claimed: false, reason: 'panel-hidden', retryAfterMs: 250 }; } if (!claimResult?.claimed || !getIsDocumentVisible()) { @@ -166,6 +169,15 @@ export function createContextMenuPromptHandler({ 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; diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 688a893fd..00981966b 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -1537,6 +1537,7 @@ 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-${ @@ -1586,7 +1587,9 @@ async function loadTabChat(tabId, { waitForHandoff = false } = {}) { tabChats.delete(queuedTabId); return null; }); - } catch (e) { /* ignore */ } + } catch (e) { + if (waitForHandoff) return TAB_CHAT_LOAD_FAILED; + } return null; } @@ -3598,7 +3601,7 @@ async function init() { const restoreTabId = currentTabId; if (restoreTabId != null) { const html = await loadTabChat(restoreTabId, { waitForHandoff: true }); - if (currentTabId === restoreTabId && html) { + if (html !== TAB_CHAT_LOAD_FAILED && currentTabId === restoreTabId && html) { await hydrateRestoredChatHistory(restoreTabId, html); if (currentTabId === restoreTabId) { messagesEl.innerHTML = html; @@ -3759,6 +3762,7 @@ async function refreshVisibleSidePanelState() { // 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) { @@ -7023,7 +7027,9 @@ async function sendMessage(extraChatParams = {}) { const submittedText = text; const tabId = currentTabId; let contextMenuClaimOwned = Boolean(contextMenuClaim?.promptId && contextMenuClaim?.claimantId); - const releaseOwnedContextMenuClaim = async () => { + const releaseOwnedContextMenuClaim = async ( + rejection = { reason: 'panel-hidden', retryAfterMs: 250 }, + ) => { if (!contextMenuClaimOwned) return; contextMenuClaimOwned = false; try { @@ -7033,7 +7039,7 @@ async function sendMessage(extraChatParams = {}) { claimantId: contextMenuClaim.claimantId, }); } catch { /* the durable lease still expires if release fails */ } - onContextMenuClaimRejected?.({ reason: 'panel-hidden', retryAfterMs: 250 }); + onContextMenuClaimRejected?.(rejection); }; if (isConversationClearInProgress(tabId)) return false; const permissionSkipContext = permissionSkipCommandContextForDraft(tabId, text); @@ -7049,6 +7055,10 @@ async function sendMessage(extraChatParams = {}) { return false; } if (isProcessing) { + if (contextMenuClaimOwned) { + await releaseOwnedContextMenuClaim({ reason: 'run-active', retryAfterMs: 1_000 }); + return false; + } if (isOutOfBandSlashDraft(text)) { resetComposerHistoryNavigation(tabId); saveInputDraftForTab(tabId, ''); diff --git a/src/firefox/src/ui/context-menu-prompts.js b/src/firefox/src/ui/context-menu-prompts.js index e6dd260da..ee6c42ff7 100644 --- a/src/firefox/src/ui/context-menu-prompts.js +++ b/src/firefox/src/ui/context-menu-prompts.js @@ -135,6 +135,15 @@ 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', { @@ -148,13 +157,7 @@ export function createContextMenuPromptHandler({ claimResult = { claimed: false, reason: 'connection', retryAfterMs: 1_000 }; } if (claimResult?.claimed && !getIsDocumentVisible()) { - try { - await sendToBackground('release_context_menu_prompt_claim', { - tabId: clearPayload.tabId, - promptId: payload.id, - claimantId, - }); - } catch { /* the durable lease still expires if release fails */ } + await releasePromptClaim(); claimResult = { claimed: false, reason: 'panel-hidden', retryAfterMs: 250 }; } if (!claimResult?.claimed || !getIsDocumentVisible()) { @@ -166,6 +169,15 @@ export function createContextMenuPromptHandler({ 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; diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 66edddbf8..fd53d818e 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -1402,6 +1402,7 @@ function updateActWarning() { // Also mirrored to browser.storage.session keyed `tabChat:` so the // conversation survives the sidebar 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-${ @@ -1451,7 +1452,9 @@ async function loadTabChat(tabId, { waitForHandoff = false } = {}) { tabChats.delete(queuedTabId); return null; }); - } catch (e) { /* ignore */ } + } catch (e) { + if (waitForHandoff) return TAB_CHAT_LOAD_FAILED; + } return null; } @@ -3450,7 +3453,7 @@ async function init() { const restoreTabId = currentTabId; if (restoreTabId != null) { const html = await loadTabChat(restoreTabId, { waitForHandoff: true }); - if (currentTabId === restoreTabId && html) { + if (html !== TAB_CHAT_LOAD_FAILED && currentTabId === restoreTabId && html) { await hydrateRestoredChatHistory(restoreTabId, html); if (currentTabId === restoreTabId) { messagesEl.innerHTML = html; @@ -3607,6 +3610,7 @@ async function refreshVisibleSidePanelState() { // becomes eligible to persist this tab again. 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) { @@ -6759,7 +6763,9 @@ async function sendMessage(extraChatParams = {}) { const submittedText = text; const tabId = currentTabId; let contextMenuClaimOwned = Boolean(contextMenuClaim?.promptId && contextMenuClaim?.claimantId); - const releaseOwnedContextMenuClaim = async () => { + const releaseOwnedContextMenuClaim = async ( + rejection = { reason: 'panel-hidden', retryAfterMs: 250 }, + ) => { if (!contextMenuClaimOwned) return; contextMenuClaimOwned = false; try { @@ -6769,7 +6775,7 @@ async function sendMessage(extraChatParams = {}) { claimantId: contextMenuClaim.claimantId, }); } catch { /* the durable lease still expires if release fails */ } - onContextMenuClaimRejected?.({ reason: 'panel-hidden', retryAfterMs: 250 }); + onContextMenuClaimRejected?.(rejection); }; if (isConversationClearInProgress(tabId)) return false; const permissionSkipContext = permissionSkipCommandContextForDraft(tabId, text); @@ -6785,6 +6791,10 @@ async function sendMessage(extraChatParams = {}) { return false; } if (isProcessing) { + if (contextMenuClaimOwned) { + await releaseOwnedContextMenuClaim({ reason: 'run-active', retryAfterMs: 1_000 }); + return false; + } if (isOutOfBandSlashDraft(text)) { resetComposerHistoryNavigation(tabId); saveInputDraftForTab(tabId, ''); diff --git a/test/run.js b/test/run.js index 1d82d55b9..c73c97a6d 100644 --- a/test/run.js +++ b/test/run.js @@ -18190,6 +18190,8 @@ test('chrome sidepanel serializes tab-chat storage writes with clears and reads' assert.match(loadBody, /const numericTabId = Number\(tabId\);[\s\S]*?if \(!Number\.isFinite\(numericTabId\)\) return null;/, 'chrome: tab-chat restore should normalize tab ids before checking the cache'); assert.match(loadBody, /if \(!waitForHandoff && !tabChatOperations\.has\(numericTabId\) && tabChats\.has\(numericTabId\)\)/, 'chrome: coordinated handoff restores should bypass stale document-local HTML'); assert.match(loadBody, /return await enqueueTabChatOperation\(numericTabId, async \(queuedTabId\) => \{[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff,[\s\S]*?\}\);/, 'chrome: tab-chat restore should read through the shared background queue'); + assert.match(loadBody, /catch \(e\) \{\s*if \(waitForHandoff\) return TAB_CHAT_LOAD_FAILED;\s*\}[\s\S]*?return null;/, 'chrome: coordinated load failures should remain distinct from successful empty restores'); + assert.match(panel, /const html = await loadTabChat\(tabId, \{ waitForHandoff: true \}\);\s*if \(html === TAB_CHAT_LOAD_FAILED\) return false;[\s\S]*?messagesEl\.innerHTML = '';/, 'chrome: a failed visibility handoff must preserve the current transcript DOM'); assert.match(panel, /const payload = \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration[\s\S]*?return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?sendToBackground\('persist_tab_chat', payload\);/, 'chrome: visible tab-chat persistence should carry its owner generation through the shared background queue'); assert.match(panel, /document\.visibilityState === 'hidden' && allowHidden[\s\S]*?sendToBackground\('persist_tab_chat', payload\);/, 'chrome: hidden handoff must bypass the document-local queue and enter the shared queue immediately'); const clearStart = panel.indexOf('function clearCachedTabChat(tabId) {'); @@ -18210,6 +18212,8 @@ test('firefox sidepanel serializes tab-chat storage writes with clears and reads assert.match(loadBody, /const numericTabId = Number\(tabId\);[\s\S]*?if \(!Number\.isFinite\(numericTabId\)\) return null;/, 'firefox: tab-chat restore should normalize tab ids before checking the cache'); assert.match(loadBody, /if \(!waitForHandoff && !tabChatOperations\.has\(numericTabId\) && tabChats\.has\(numericTabId\)\)/, 'firefox: coordinated handoff restores should bypass stale document-local HTML'); assert.match(loadBody, /return await enqueueTabChatOperation\(numericTabId, async \(queuedTabId\) => \{[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff,[\s\S]*?\}\);/, 'firefox: tab-chat restore should read through the shared background queue'); + assert.match(loadBody, /catch \(e\) \{\s*if \(waitForHandoff\) return TAB_CHAT_LOAD_FAILED;\s*\}[\s\S]*?return null;/, 'firefox: coordinated load failures should remain distinct from successful empty restores'); + assert.match(panel, /const html = await loadTabChat\(tabId, \{ waitForHandoff: true \}\);\s*if \(html === TAB_CHAT_LOAD_FAILED\) return false;[\s\S]*?messagesEl\.innerHTML = '';/, 'firefox: a failed visibility handoff must preserve the current transcript DOM'); assert.match(panel, /const payload = \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration[\s\S]*?return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?sendToBackground\('persist_tab_chat', payload\);/, 'firefox: visible tab-chat persistence should carry its owner generation through the shared background queue'); assert.match(panel, /document\.visibilityState === 'hidden' && allowHidden[\s\S]*?sendToBackground\('persist_tab_chat', payload\);/, 'firefox: hidden handoff must bypass the document-local queue and enter the shared queue immediately'); const clearStart = panel.indexOf('function clearCachedTabChat(tabId) {'); @@ -18280,10 +18284,10 @@ test('sidepanel does not miss startup tab switches before consuming tab-scoped s if (label === 'chrome') { const restoreCaptureIdx = body.indexOf('const restoreTabId = currentTabId;'); const restoreLoadIdx = body.indexOf('const html = await loadTabChat(restoreTabId, { waitForHandoff: true });'); - const restoreGuardIdx = body.indexOf('if (currentTabId === restoreTabId && html)'); + const restoreGuardIdx = body.indexOf('if (html !== TAB_CHAT_LOAD_FAILED && currentTabId === restoreTabId && html)'); assert.notEqual(restoreCaptureIdx, -1, 'chrome: initial tab-chat restore should capture the target tab'); assert.notEqual(restoreLoadIdx, -1, 'chrome: initial tab-chat restore should load the captured tab'); - assert.notEqual(restoreGuardIdx, -1, 'chrome: initial tab-chat restore should drop stale async results'); + assert.notEqual(restoreGuardIdx, -1, 'chrome: initial tab-chat restore should drop failed and stale async results'); assert.equal(listenerIdx < restoreCaptureIdx && restoreCaptureIdx < restoreLoadIdx && restoreLoadIdx < restoreGuardIdx, true, 'chrome: initial restore must be guarded after listener-driven tab changes'); } } @@ -21707,6 +21711,40 @@ test('context-menu prompts release an initial claim acquired after the panel bec } }); +test('context-menu prompts release and requeue when another run starts during claim acquisition', async () => { + for (const [label, createHandler] of [ + ['chrome', createContextMenuPromptHandlerCh], + ['firefox', createContextMenuPromptHandlerFx], + ]) { + const prompt = { id: `${label}-busy-during-claim`, tabId: 10, text: 'Explain this selection' }; + const claimGate = deferred(); + const h = createContextMenuPromptHarness(createHandler, prompt, async () => true, { + claimPrompt: async (_params, attempt) => ( + attempt === 1 ? claimGate.promise : { ok: true, claimed: true } + ), + }); + + h.handler.acceptContextMenuPrompt(prompt); + await waitMicrotasks(3); + h.setProcessing(true); + claimGate.resolve({ ok: true, claimed: true }); + await waitMicrotasks(8); + + assert.equal(h.sends.length, 0, `${label}: a prompt must not enter the ordinary composer queue after a run starts`); + assert.equal(h.releases.length, 1, `${label}: the prompt lease should be released while the active run owns the tab`); + assert.equal(h.input.value, '', `${label}: the claimed prompt should not replace the user's busy composer draft`); + + h.setProcessing(false); + h.handler.drainQueuedContextMenuPrompts(); + await waitMicrotasks(8); + + assert.equal(h.claims.length, 2, `${label}: the context-menu queue should reclaim after the active run settles`); + assert.equal(h.sends.length, 1, `${label}: the full prompt payload should submit exactly once after reclaim`); + assert.equal(h.sends[0].extra.contextMenuClaim.promptId, prompt.id, `${label}: the deferred send must preserve ownership metadata`); + assert.equal(h.sends[0].extra.contextMenuClear.promptId, prompt.id, `${label}: the deferred send must still clear durable prompt storage`); + } +}); + test('context-menu prompt lease recovery retries after an abandoned owner expires', async () => { for (const [label, createHandler] of [ ['chrome', createContextMenuPromptHandlerCh], @@ -21809,7 +21847,7 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot ); assert.match( handler, - /claimResult\?\.claimed && !getIsDocumentVisible\(\)[\s\S]*?release_context_menu_prompt_claim[\s\S]*?reason: 'panel-hidden'/, + /const releasePromptClaim = async \(\) => \{[\s\S]*?release_context_menu_prompt_claim[\s\S]*?claimResult\?\.claimed && !getIsDocumentVisible\(\)[\s\S]*?await releasePromptClaim\(\);[\s\S]*?reason: 'panel-hidden'/, `${label}: an initial claim that resolves after hiding should be released for the visible panel`, ); assert.match( @@ -21884,9 +21922,14 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot ); assert.match( panel, - /let contextMenuClaimOwned = Boolean\(contextMenuClaim\?\.promptId && contextMenuClaim\?\.claimantId\);[\s\S]*?const releaseOwnedContextMenuClaim = async \(\) => \{[\s\S]*?contextMenuClaimOwned = false;[\s\S]*?release_context_menu_prompt_claim[\s\S]*?onContextMenuClaimRejected\?\.\(\{ reason: 'panel-hidden', retryAfterMs: 250 \}\);[\s\S]*?contextMenuClaimOwned = renewedClaim\?\.claimed === true;/, + /let contextMenuClaimOwned = Boolean\(contextMenuClaim\?\.promptId && contextMenuClaim\?\.claimantId\);[\s\S]*?const releaseOwnedContextMenuClaim = async \([\s\S]*?reason: 'panel-hidden', retryAfterMs: 250[\s\S]*?contextMenuClaimOwned = false;[\s\S]*?release_context_menu_prompt_claim[\s\S]*?onContextMenuClaimRejected\?\.\(rejection\);[\s\S]*?contextMenuClaimOwned = renewedClaim\?\.claimed === true;/, `${label}: initial and renewed ownership should share one idempotent release-and-retry path`, ); + assert.match( + panel, + /if \(isProcessing\) \{\s*if \(contextMenuClaimOwned\) \{\s*await releaseOwnedContextMenuClaim\(\{ reason: 'run-active', retryAfterMs: 1_000 \}\);\s*return false;/, + `${label}: a context-menu claim must never fall through to the ordinary busy composer queue`, + ); const sendMessageStart = panel.indexOf('async function sendMessage(extraChatParams = {}) {'); const sendMessageEnd = panel.indexOf( label === 'chrome' ? '\nfunction formatRecordTimer(' : '\nfunction ensureCurrentRunAssistant(', From aaa38087d91791ef7eab7cb80e04645d453032a2 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 10:14:15 +0200 Subject: [PATCH 09/18] Release claims on initial visibility exit --- src/chrome/src/ui/sidepanel.js | 1 + src/firefox/src/ui/sidepanel.js | 1 + test/run.js | 9 +++++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 00981966b..b3d2e5482 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -7119,6 +7119,7 @@ async function sendMessage(extraChatParams = {}) { && sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); if (!renderToCurrentTab) { + await releaseOwnedContextMenuClaim(); if (text) saveInputDraftForTab(tabId, text); return false; } diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index fd53d818e..6ddd4ef8b 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -6852,6 +6852,7 @@ async function sendMessage(extraChatParams = {}) { && sameTabId(currentTabId, tabId) && sameTabId(renderedTabId, tabId); if (!renderToCurrentTab) { + await releaseOwnedContextMenuClaim(); if (text) saveInputDraftForTab(tabId, text); return false; } diff --git a/test/run.js b/test/run.js index c73c97a6d..c564ae7e2 100644 --- a/test/run.js +++ b/test/run.js @@ -21939,8 +21939,13 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot const sendMessageBody = panel.slice(sendMessageStart, sendMessageEnd); assert.equal( (sendMessageBody.match(/await releaseOwnedContextMenuClaim\(\);/g) || []).length, - 3, - `${label}: visibility exits after preflight and renewal should release the claim and schedule retry`, + 4, + `${label}: every visibility exit should release the claim and schedule retry`, + ); + assert.match( + sendMessageBody, + /if \(!retryOptions\) text = await parseSlashCommands\(text, tabId, \{ permissionSkipContext \}\);[\s\S]*?if \(!renderToCurrentTab\) \{\s*await releaseOwnedContextMenuClaim\(\);/, + `${label}: the first visibility failure after refresh and command parsing should relinquish initial ownership`, ); assert.match( sendMessageBody, From 2889b7f1d9dd0e904a174d368351b3132648e8c4 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 10:20:56 +0200 Subject: [PATCH 10/18] Release context claims on preflight abort --- src/chrome/src/ui/sidepanel.js | 1 + src/firefox/src/ui/sidepanel.js | 1 + test/run.js | 9 +++++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index b3d2e5482..74a424473 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -7140,6 +7140,7 @@ async function sendMessage(extraChatParams = {}) { await prepareChatHistoryForTurn(tabId, modeForSend); if (isTabAbortRequested(tabId)) { + await releaseOwnedContextMenuClaim(); setTabProcessing(tabId, false); setTabAbortRequested(tabId, false); syncSendButtonState(); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 6ddd4ef8b..eb57e06ef 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -6871,6 +6871,7 @@ async function sendMessage(extraChatParams = {}) { await prepareChatHistoryForTurn(tabId, modeForSend); if (isTabAbortRequested(tabId)) { + await releaseOwnedContextMenuClaim(); setTabProcessing(tabId, false); setTabAbortRequested(tabId, false); syncSendButtonState(); diff --git a/test/run.js b/test/run.js index c564ae7e2..da82ea6ca 100644 --- a/test/run.js +++ b/test/run.js @@ -21939,14 +21939,19 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot const sendMessageBody = panel.slice(sendMessageStart, sendMessageEnd); assert.equal( (sendMessageBody.match(/await releaseOwnedContextMenuClaim\(\);/g) || []).length, - 4, - `${label}: every visibility exit should release the claim and schedule retry`, + 5, + `${label}: every visibility or preflight-abort exit should release the claim and schedule retry`, ); assert.match( sendMessageBody, /if \(!retryOptions\) text = await parseSlashCommands\(text, tabId, \{ permissionSkipContext \}\);[\s\S]*?if \(!renderToCurrentTab\) \{\s*await releaseOwnedContextMenuClaim\(\);/, `${label}: the first visibility failure after refresh and command parsing should relinquish initial ownership`, ); + assert.match( + sendMessageBody, + /await prepareChatHistoryForTurn\(tabId, modeForSend\);\s*if \(isTabAbortRequested\(tabId\)\) \{\s*await releaseOwnedContextMenuClaim\(\);/, + `${label}: aborting preflight should relinquish initial ownership before resetting run state`, + ); assert.match( sendMessageBody, /await prepareChatHistoryForTurn\(tabId, modeForSend\);[\s\S]*?if \(!renderToCurrentTab\) \{\s*await releaseOwnedContextMenuClaim\(\);[\s\S]*?contextMenuClaimOwned = renewedClaim\?\.claimed === true;[\s\S]*?const claimStillVisible[\s\S]*?if \(contextMenuClaimOwned && !claimStillVisible\) \{[\s\S]*?await releaseOwnedContextMenuClaim\(\);[\s\S]*?renderToCurrentTab = document\.visibilityState !== 'hidden'[\s\S]*?if \(!renderToCurrentTab\) \{\s*await releaseOwnedContextMenuClaim\(\);/, From 051792f726f2d02b7f9478685bfc5c210848092c Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 10:33:52 +0200 Subject: [PATCH 11/18] Preserve handoff ownership across clears --- src/chrome/src/background.js | 5 +- src/chrome/src/context-menu-storage.js | 8 ++- src/chrome/src/ui/sidepanel.js | 20 +++++- src/chrome/src/ui/tab-chat-persistence.js | 33 +++++++--- src/firefox/src/background.js | 5 +- src/firefox/src/context-menu-storage.js | 8 ++- src/firefox/src/ui/sidepanel.js | 20 +++++- src/firefox/src/ui/tab-chat-persistence.js | 33 +++++++--- test/run.js | 72 +++++++++++++++++++++- 9 files changed, 177 insertions(+), 27 deletions(-) diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 651b674d6..0af5140fc 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -2684,7 +2684,10 @@ async function handleMessage(msg, sender) { }); case 'clear_tab_chat': - return await tabChatHandoff.clear(msg.tabId || sender.tab?.id); + return await tabChatHandoff.clear(msg.tabId || sender.tab?.id, { + ownerId: msg.handoffOwnerId, + handoffGeneration: msg.handoffGeneration, + }); case 'list_scheduled_jobs': { const tabId = msg.tabId || sender.tab?.id || null; diff --git a/src/chrome/src/context-menu-storage.js b/src/chrome/src/context-menu-storage.js index a0500d8fb..ea63316f1 100644 --- a/src/chrome/src/context-menu-storage.js +++ b/src/chrome/src/context-menu-storage.js @@ -261,7 +261,7 @@ export function createContextMenuStorage(getStore) { }); } - async function reserve(tabId, promptId, claimantId, onReserve, now = Date.now()) { + async function reserve(tabId, promptId, claimantId, onReserve, now) { const normalizedPromptId = String(promptId || ''); const normalizedClaimantId = String(claimantId || ''); if (!normalizedPromptId || !normalizedClaimantId || typeof onReserve !== 'function') { @@ -292,7 +292,11 @@ export function createContextMenuStorage(getStore) { const samePrompt = String(activeClaim?.promptId || '') === normalizedPromptId; const sameClaimant = String(activeClaim?.claimantId || '') === normalizedClaimantId; const leaseExpiresAt = Number(activeClaim?.expiresAt || 0); - const activeLease = leaseExpiresAt > Number(now); + 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, diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 74a424473..46a423628 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -1645,11 +1645,27 @@ function clearCachedTabChat(tabId) { persistTimerTabId = null; } tabChats.delete(tabId); - tabChatHandoffGenerations.delete(Number(tabId)); return enqueueTabChatOperation(tabId, async (numericTabId) => { tabChats.delete(numericTabId); try { - await sendToBackground('clear_tab_chat', { tabId: numericTabId }); + let handoffGeneration = tabChatHandoffGenerations.get(numericTabId); + if (!Number.isFinite(handoffGeneration)) { + 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); + } + } + await sendToBackground('clear_tab_chat', { + tabId: numericTabId, + handoffOwnerId: tabChatHandoffOwnerId, + handoffGeneration, + }); } catch (e) { /* ignore */ } return { ok: true }; }); diff --git a/src/chrome/src/ui/tab-chat-persistence.js b/src/chrome/src/ui/tab-chat-persistence.js index 2e4a0a99c..74e99bfc7 100644 --- a/src/chrome/src/ui/tab-chat-persistence.js +++ b/src/chrome/src/ui/tab-chat-persistence.js @@ -274,7 +274,10 @@ export function createTabChatHandoffCoordinator(storageArea, { return enqueue(numericTabId, async (queuedTabId) => { const normalizedOwnerId = String(ownerId || ''); const normalizedGeneration = Number(handoffGeneration); - if (normalizedOwnerId && Number.isFinite(normalizedGeneration) && normalizedGeneration > 0) { + if (normalizedOwnerId) { + if (!Number.isFinite(normalizedGeneration) || normalizedGeneration <= 0) { + return { ok: true, skipped: true, reason: 'stale-handoff' }; + } const state = await readHandoffState(queuedTabId); if (!state || state.ownerId !== normalizedOwnerId @@ -339,16 +342,32 @@ export function createTabChatHandoffCoordinator(storageArea, { }); } - function clear(tabId) { + function clear(tabId, { ownerId = '', handoffGeneration = null } = {}) { const numericTabId = normalizeTabId(tabId); if (numericTabId == null) return Promise.resolve({ ok: false, error: 'No tab ID' }); return enqueue(numericTabId, async (queuedTabId) => { + const normalizedOwnerId = String(ownerId || ''); + const normalizedGeneration = Number(handoffGeneration); + if (normalizedOwnerId) { + if (!Number.isFinite(normalizedGeneration) || normalizedGeneration <= 0) { + return { ok: true, skipped: true, reason: 'stale-handoff' }; + } + const state = await readHandoffState(queuedTabId); + if (!state + || state.ownerId !== normalizedOwnerId + || state.generation !== normalizedGeneration) { + return { ok: true, skipped: true, reason: 'stale-handoff' }; + } + } latestHtml.delete(queuedTabId); - await storageArea.remove([ - TAB_CHAT_PREFIX + queuedTabId, - TAB_CHAT_HANDOFF_PREFIX + queuedTabId, - ]); - return { ok: true }; + const keys = [TAB_CHAT_PREFIX + queuedTabId]; + if (!normalizedOwnerId) keys.push(TAB_CHAT_HANDOFF_PREFIX + queuedTabId); + await storageArea.remove(keys); + return { + ok: true, + handoffOwnerId: normalizedOwnerId || null, + handoffGeneration: normalizedOwnerId ? normalizedGeneration : null, + }; }); } diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index b0150a283..41f41586a 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -2371,7 +2371,10 @@ async function handleMessage(msg, sender) { }); case 'clear_tab_chat': - return await tabChatHandoff.clear(msg.tabId || sender.tab?.id); + return await tabChatHandoff.clear(msg.tabId || sender.tab?.id, { + ownerId: msg.handoffOwnerId, + handoffGeneration: msg.handoffGeneration, + }); case 'list_scheduled_jobs': { const tabId = msg.tabId || sender.tab?.id || null; diff --git a/src/firefox/src/context-menu-storage.js b/src/firefox/src/context-menu-storage.js index fa077305b..8a0cf2236 100644 --- a/src/firefox/src/context-menu-storage.js +++ b/src/firefox/src/context-menu-storage.js @@ -261,7 +261,7 @@ export function createContextMenuStorage(getStore) { }); } - async function reserve(tabId, promptId, claimantId, onReserve, now = Date.now()) { + async function reserve(tabId, promptId, claimantId, onReserve, now) { const normalizedPromptId = String(promptId || ''); const normalizedClaimantId = String(claimantId || ''); if (!normalizedPromptId || !normalizedClaimantId || typeof onReserve !== 'function') { @@ -292,7 +292,11 @@ export function createContextMenuStorage(getStore) { const samePrompt = String(activeClaim?.promptId || '') === normalizedPromptId; const sameClaimant = String(activeClaim?.claimantId || '') === normalizedClaimantId; const leaseExpiresAt = Number(activeClaim?.expiresAt || 0); - const activeLease = leaseExpiresAt > Number(now); + 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, diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index eb57e06ef..789c75622 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -1510,11 +1510,27 @@ function clearCachedTabChat(tabId) { persistTimerTabId = null; } tabChats.delete(tabId); - tabChatHandoffGenerations.delete(Number(tabId)); return enqueueTabChatOperation(tabId, async (numericTabId) => { tabChats.delete(numericTabId); try { - await sendToBackground('clear_tab_chat', { tabId: numericTabId }); + let handoffGeneration = tabChatHandoffGenerations.get(numericTabId); + if (!Number.isFinite(handoffGeneration)) { + 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); + } + } + await sendToBackground('clear_tab_chat', { + tabId: numericTabId, + handoffOwnerId: tabChatHandoffOwnerId, + handoffGeneration, + }); } catch (e) { /* ignore */ } return { ok: true }; }); diff --git a/src/firefox/src/ui/tab-chat-persistence.js b/src/firefox/src/ui/tab-chat-persistence.js index b271a57db..7177411bf 100644 --- a/src/firefox/src/ui/tab-chat-persistence.js +++ b/src/firefox/src/ui/tab-chat-persistence.js @@ -274,7 +274,10 @@ export function createTabChatHandoffCoordinator(storageArea, { return enqueue(numericTabId, async (queuedTabId) => { const normalizedOwnerId = String(ownerId || ''); const normalizedGeneration = Number(handoffGeneration); - if (normalizedOwnerId && Number.isFinite(normalizedGeneration) && normalizedGeneration > 0) { + if (normalizedOwnerId) { + if (!Number.isFinite(normalizedGeneration) || normalizedGeneration <= 0) { + return { ok: true, skipped: true, reason: 'stale-handoff' }; + } const state = await readHandoffState(queuedTabId); if (!state || state.ownerId !== normalizedOwnerId @@ -339,16 +342,32 @@ export function createTabChatHandoffCoordinator(storageArea, { }); } - function clear(tabId) { + function clear(tabId, { ownerId = '', handoffGeneration = null } = {}) { const numericTabId = normalizeTabId(tabId); if (numericTabId == null) return Promise.resolve({ ok: false, error: 'No tab ID' }); return enqueue(numericTabId, async (queuedTabId) => { + const normalizedOwnerId = String(ownerId || ''); + const normalizedGeneration = Number(handoffGeneration); + if (normalizedOwnerId) { + if (!Number.isFinite(normalizedGeneration) || normalizedGeneration <= 0) { + return { ok: true, skipped: true, reason: 'stale-handoff' }; + } + const state = await readHandoffState(queuedTabId); + if (!state + || state.ownerId !== normalizedOwnerId + || state.generation !== normalizedGeneration) { + return { ok: true, skipped: true, reason: 'stale-handoff' }; + } + } latestHtml.delete(queuedTabId); - await storageArea.remove([ - TAB_CHAT_PREFIX + queuedTabId, - TAB_CHAT_HANDOFF_PREFIX + queuedTabId, - ]); - return { ok: true }; + const keys = [TAB_CHAT_PREFIX + queuedTabId]; + if (!normalizedOwnerId) keys.push(TAB_CHAT_HANDOFF_PREFIX + queuedTabId); + await storageArea.remove(keys); + return { + ok: true, + handoffOwnerId: normalizedOwnerId || null, + handoffGeneration: normalizedOwnerId ? normalizedGeneration : null, + }; }); } diff --git a/test/run.js b/test/run.js index da82ea6ca..21fa9ced5 100644 --- a/test/run.js +++ b/test/run.js @@ -18177,6 +18177,28 @@ test('tab-chat handoff coordinator orders a returning-panel read behind the outg }); assert.equal(staleWrite.skipped, true, `${label}: a late write from the old generation must be rejected`); assert.equal(values[`${persistence.TAB_CHAT_PREFIX}7`], '
fresh
', `${label}: stale delivery must not overwrite the acknowledged snapshot`); + + const cleared = await coordinator.clear(7, { + ownerId: 'returning-panel', + handoffGeneration: 2, + }); + assert.equal(cleared.ok, true, `${label}: the visible owner should clear its transcript`); + assert.equal(values[`${persistence.TAB_CHAT_PREFIX}7`], undefined, `${label}: clear should remove transcript content`); + assert.deepEqual( + values[`${persistence.TAB_CHAT_HANDOFF_PREFIX}7`], + { ownerId: 'returning-panel', generation: 2 }, + `${label}: clear should preserve the visible document's validated handoff ownership`, + ); + const unversionedWrite = await coordinator.save(7, '
unversioned stale
', { + ownerId: 'returning-panel', + }); + assert.equal(unversionedWrite.skipped, true, `${label}: owner-tagged writes without a generation must fail closed`); + const postClearWrite = await coordinator.save(7, '
new conversation
', { + ownerId: 'returning-panel', + handoffGeneration: 2, + }); + assert.equal(postClearWrite.ok, true, `${label}: the preserved owner should persist the new conversation`); + assert.equal(values[`${persistence.TAB_CHAT_PREFIX}7`], '
new conversation
', `${label}: the post-clear transcript should remain current`); } }); @@ -18199,7 +18221,8 @@ test('chrome sidepanel serializes tab-chat storage writes with clears and reads' const clearBody = panel.slice(clearStart, panel.indexOf('\n}\n\nasync function renderClearedConversationForTab', clearStart) + 2); assert.match(clearBody, /tabChats\.delete\(tabId\);/, 'chrome: clearing tab chat should delete the cached HTML before queuing storage removal'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{/, 'chrome: clearing tab chat should be serialized through the queue'); - assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?sendToBackground\('clear_tab_chat', \{ tabId: numericTabId \}\)/, 'chrome: queued clears should delete stale HTML before clearing the shared background state'); + assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?handoffGeneration = tabChatHandoffGenerations\.get\(numericTabId\);[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff: true,[\s\S]*?sendToBackground\('clear_tab_chat', \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration,/, 'chrome: queued clears should retain or reacquire ownership before clearing shared state'); + assert.doesNotMatch(clearBody, /tabChatHandoffGenerations\.delete/, 'chrome: clearing content must not discard the visible document handoff generation'); }); test('firefox sidepanel serializes tab-chat storage writes with clears and reads', () => { @@ -18221,7 +18244,8 @@ test('firefox sidepanel serializes tab-chat storage writes with clears and reads const clearBody = panel.slice(clearStart, panel.indexOf('\n}\n\n// Save current tab', clearStart) + 2); assert.match(clearBody, /tabChats\.delete\(tabId\);/, 'firefox: clearing tab chat should delete the cached HTML before queuing storage removal'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{/, 'firefox: clearing tab chat should be serialized through the queue'); - assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?sendToBackground\('clear_tab_chat', \{ tabId: numericTabId \}\)/, 'firefox: queued clears should delete stale HTML before clearing the shared background state'); + assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?handoffGeneration = tabChatHandoffGenerations\.get\(numericTabId\);[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff: true,[\s\S]*?sendToBackground\('clear_tab_chat', \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration,/, 'firefox: queued clears should retain or reacquire ownership before clearing shared state'); + assert.doesNotMatch(clearBody, /tabChatHandoffGenerations\.delete/, 'firefox: clearing content must not discard the visible document handoff generation'); }); test('chrome sidepanel cancels stale tab-chat persistence when clearing a tab', () => { @@ -18242,7 +18266,7 @@ test('chrome sidepanel cancels stale tab-chat persistence when clearing a tab', const clearBody = panel.slice(clearStart, panel.indexOf('\n}\n\nasync function renderClearedConversationForTab', clearStart) + 2); assert.match( clearBody, - /if \(persistTimer && persistTimerTabId === tabId\) \{[\s\S]*?clearTimeout\(persistTimer\);[\s\S]*?persistTimer = null;[\s\S]*?persistTimerTabId = null;[\s\S]*?\}[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?sendToBackground\('clear_tab_chat', \{ tabId: numericTabId \}\)/, + /if \(persistTimer && persistTimerTabId === tabId\) \{[\s\S]*?clearTimeout\(persistTimer\);[\s\S]*?persistTimer = null;[\s\S]*?persistTimerTabId = null;[\s\S]*?\}[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?sendToBackground\('clear_tab_chat', \{[\s\S]*?handoffGeneration,/, 'chrome: clearing a tab should cancel any pending stale write before removing cached chat', ); }); @@ -21905,6 +21929,11 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot /createTabChatHandoffCoordinator\([\s\S]*?case 'persist_tab_chat':[\s\S]*?tabChatHandoff\.save[\s\S]*?case 'load_tab_chat':[\s\S]*?tabChatHandoff\.load/, `${label}: transcript handoff reads and writes should share a background coordinator`, ); + assert.match( + background, + /case 'clear_tab_chat':[\s\S]*?tabChatHandoff\.clear\([\s\S]*?ownerId: msg\.handoffOwnerId,[\s\S]*?handoffGeneration: msg\.handoffGeneration/, + `${label}: New Chat should clear content through the visible document's validated handoff ownership`, + ); assert.match( background, /requestHandoff:[\s\S]*?tab_chat_handoff_request[\s\S]*?ownerId,[\s\S]*?generation,/, @@ -21966,8 +21995,12 @@ test('context-menu prompt storage enforces a durable expiring lease', async () = ['firefox', createContextMenuStorageFx, CONTEXT_MENU_CLAIM_LEASE_MS_FX], ]) { const data = new Map(); + let nextSetGate = null; const store = { async set(values) { + const gate = nextSetGate; + nextSetGate = null; + if (gate) await gate.promise; Object.entries(values || {}).forEach(([key, value]) => data.set(key, value)); }, async get(key) { @@ -22039,6 +22072,39 @@ test('context-menu prompt storage enforces a durable expiring lease', async () = await storage.clear(prompt.tabId, prompt.id); assert.equal(data.has(storage.key(prompt.tabId)), false, `${label}: accepting the run should clear the durable prompt`); assert.equal(data.has(storage.claimKey(prompt.tabId)), false, `${label}: accepting the run should clear its lease`); + + const queuedPrompt = { + id: `${label}-queued-expiry`, + tabId: 13, + text: 'Summarize this selection', + }; + await storage.save(queuedPrompt.tabId, queuedPrompt); + const queuedClaim = await storage.claim(queuedPrompt.tabId, queuedPrompt.id, 'queued-panel', 1_000); + const setGate = deferred(); + nextSetGate = setGate; + const blockingSave = storage.save(queuedPrompt.tabId, queuedPrompt); + await waitMicrotasks(3); + + const originalNow = Date.now; + let queuedReservation = null; + try { + Date.now = () => 1_000; + const reservation = storage.reserve( + queuedPrompt.tabId, + queuedPrompt.id, + 'queued-panel', + () => ({ accepted: true }), + ); + Date.now = () => queuedClaim.leaseExpiresAt; + setGate.resolve(); + await blockingSave; + queuedReservation = await reservation; + } finally { + Date.now = originalNow; + setGate.resolve(); + } + assert.equal(queuedReservation?.reserved, false, `${label}: queued validation must reject a lease that expired while waiting`); + assert.equal(queuedReservation?.reason, 'claim-lost', `${label}: queued expiry should require a fresh claim`); } }); From 94284702cd4002a46b73921593a96c06508e2a89 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 10:44:38 +0200 Subject: [PATCH 12/18] Retry tab chat ownership reconciliation --- src/chrome/src/ui/sidepanel.js | 29 ++++++++++++++++++++++---- src/firefox/src/ui/sidepanel.js | 37 ++++++++++++++++++++++++++++----- test/run.js | 21 ++++++++++++------- 3 files changed, 71 insertions(+), 16 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 46a423628..d2e1c15d5 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -2125,13 +2125,19 @@ 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(() => {}); - } while (pendingRefresh !== visibleStateRefreshPromise); + if (tabSwitchTransitionId != null) await waitForTabChatHandoffRetry(); + } while (pendingRefresh !== visibleStateRefreshPromise || tabSwitchTransitionId != null); } function schedulePersist() { @@ -3711,6 +3717,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 @@ -3735,7 +3742,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) { @@ -3761,6 +3770,7 @@ async function switchToTab(newTabId) { refreshRecommendedActions(); } finally { if (switchGeneration === tabSwitchGeneration && tabSwitchTransitionId === newTabId) tabSwitchTransitionId = null; + syncSendButtonState(); } drainQueuedAgentUpdatesForTab(newTabId); consumePendingContextMenuPrompt().then(() => drainQueuedContextMenuPrompts()).catch(() => {}); @@ -3804,7 +3814,18 @@ function requestVisibleSidePanelStateRefresh() { visibleStateRefreshPromise = visibleStateRefreshPromise.catch(() => {}).then(async () => { if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return false; visibleStateRefreshPending = false; - return refreshVisibleSidePanelState(); + 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(() => { @@ -6296,7 +6317,7 @@ function isOutOfBandSlashDraft(value) { function syncSendButtonState() { if (!sendBtn) return; const draft = normalizeScreenshotCommandText(inputEl?.value || '').trim(); - if (visibleStateRefreshPending || visibleStateRefreshInProgress) { + if (tabSwitchTransitionId != null || visibleStateRefreshPending || visibleStateRefreshInProgress) { sendBtn.disabled = true; return; } diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 789c75622..9863bd3f0 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -1545,13 +1545,19 @@ 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(() => {}); - } while (pendingRefresh !== visibleStateRefreshPromise); + if (tabSwitchTransitionId != null) await waitForTabChatHandoffRetry(); + } while (pendingRefresh !== visibleStateRefreshPromise || tabSwitchTransitionId != null); } function schedulePersist() { @@ -3560,6 +3566,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 @@ -3584,8 +3591,16 @@ async function switchToTab(newTabId) { syncCurrentTabRunFlags(); syncApiMutationsAllowedForCurrentTab(); - // Restore new tab's chat from memory or storage. - const html = await loadTabChat(newTabId); + // Acquire shared handoff ownership for the destination before its DOM can + // render or persist. Retry transient background failures while this remains + // the current tab-switch generation. + let html = TAB_CHAT_LOAD_FAILED; + while (html === TAB_CHAT_LOAD_FAILED) { + html = await loadTabChat(newTabId, { waitForHandoff: true }); + if (html !== TAB_CHAT_LOAD_FAILED) break; + if (switchGeneration !== tabSwitchGeneration || currentTabId !== newTabId) return; + await waitForTabChatHandoffRetry(); + } if (switchGeneration !== tabSwitchGeneration || currentTabId !== newTabId) return; if (html) { await hydrateRestoredChatHistory(newTabId, html); @@ -3610,6 +3625,7 @@ async function switchToTab(newTabId) { refreshRecommendedActions(); } finally { if (switchGeneration === tabSwitchGeneration && tabSwitchTransitionId === newTabId) tabSwitchTransitionId = null; + syncSendButtonState(); } drainQueuedAgentUpdatesForTab(newTabId); consumePendingContextMenuPrompt().then(() => drainQueuedContextMenuPrompts()).catch(() => {}); @@ -3652,7 +3668,18 @@ function requestVisibleSidePanelStateRefresh() { visibleStateRefreshPromise = visibleStateRefreshPromise.catch(() => {}).then(async () => { if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return false; visibleStateRefreshPending = false; - return refreshVisibleSidePanelState(); + 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(() => { @@ -6130,7 +6157,7 @@ function isOutOfBandSlashDraft(value) { function syncSendButtonState() { if (!sendBtn) return; const draft = normalizeScreenshotCommandText(inputEl?.value || '').trim(); - if (visibleStateRefreshPending || visibleStateRefreshInProgress) { + if (tabSwitchTransitionId != null || visibleStateRefreshPending || visibleStateRefreshInProgress) { sendBtn.disabled = true; return; } diff --git a/test/run.js b/test/run.js index 21fa9ced5..a7047bbc2 100644 --- a/test/run.js +++ b/test/run.js @@ -17704,7 +17704,7 @@ test('sidepanel queues target-tab updates and suppresses non-target updates duri const markIdx = body.indexOf('tabSwitchTransitionId = newTabId;'); const flushIdx = body.indexOf('await flushRenderedTabChat();'); const historyFlushIdx = body.indexOf('await flushChatHistorySnapshot(outgoingTabId);'); - const loadIdx = body.indexOf('const html = await loadTabChat(newTabId);'); + const loadIdx = body.indexOf('loadTabChat(newTabId'); const clearTransitionIdx = body.indexOf('if (switchGeneration === tabSwitchGeneration && tabSwitchTransitionId === newTabId) tabSwitchTransitionId = null;'); const replayIdx = body.indexOf('drainQueuedAgentUpdatesForTab(newTabId);'); const consumeIdx = body.indexOf('consumePendingContextMenuPrompt()'); @@ -17799,7 +17799,7 @@ test('sidepanel hydrates restored history ids before fallback records', () => { const switchMatch = panel.match(/async function switchToTab\(newTabId\) \{([\s\S]*?)\n\}/); assert.ok(switchMatch, `${label}: switchToTab body missing`); const switchBody = switchMatch[1]; - const loadIdx = switchBody.indexOf('const html = await loadTabChat(newTabId);'); + const loadIdx = switchBody.indexOf('loadTabChat(newTabId'); const restoredHasUserIdx = switchBody.indexOf('if (html) {'); const hydrateRestoredIdx = switchBody.indexOf('await hydrateRestoredChatHistory(newTabId, html);'); const postHydrateGuardIdx = switchBody.indexOf('if (switchGeneration !== tabSwitchGeneration || currentTabId !== newTabId) return;', hydrateRestoredIdx); @@ -21886,19 +21886,26 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot ); assert.match( panel, - /let visibleStateRefreshPromise = Promise\.resolve\(\);[\s\S]*?async function waitForVisibleSidePanelStateRefresh\(\) \{[\s\S]*?pendingRefresh = visibleStateRefreshPromise;[\s\S]*?await pendingRefresh\.catch\(\(\) => \{\}\);[\s\S]*?pendingRefresh !== visibleStateRefreshPromise/, - `${label}: sends should wait until the complete serialized visibility refresh queue settles`, + /let visibleStateRefreshPromise = Promise\.resolve\(\);[\s\S]*?async function waitForVisibleSidePanelStateRefresh\(\) \{[\s\S]*?pendingRefresh = visibleStateRefreshPromise;[\s\S]*?await pendingRefresh\.catch\(\(\) => \{\}\);[\s\S]*?tabSwitchTransitionId != null[\s\S]*?pendingRefresh !== visibleStateRefreshPromise \|\| tabSwitchTransitionId != null/, + `${label}: sends should wait until visibility refreshes and coordinated tab switches settle`, ); assert.match( panel, - /async function refreshVisibleSidePanelState\(\) \{[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?await loadTabChat\(tabId, \{ waitForHandoff: true \}\);[\s\S]*?await restoreActiveRunState\(tabId\);[\s\S]*?function requestVisibleSidePanelStateRefresh\(\) \{[\s\S]*?visibleStateRefreshPromise = visibleStateRefreshPromise\.catch\(\(\) => \{\}\)\.then\(async \(\) => \{[\s\S]*?return refreshVisibleSidePanelState\(\);[\s\S]*?refreshPromise\.then\(\(refreshed\) => \{[\s\S]*?refreshPromise !== visibleStateRefreshPromise[\s\S]*?consumePendingContextMenuPrompt\(\)/, + /async function refreshVisibleSidePanelState\(\) \{[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?await loadTabChat\(tabId, \{ waitForHandoff: true \}\);[\s\S]*?await restoreActiveRunState\(tabId\);[\s\S]*?function requestVisibleSidePanelStateRefresh\(\) \{[\s\S]*?visibleStateRefreshPromise = visibleStateRefreshPromise\.catch\(\(\) => \{\}\)\.then\(async \(\) => \{[\s\S]*?let refreshed = await refreshVisibleSidePanelState\(\);[\s\S]*?while \(refreshed === false[\s\S]*?visibleStateRefreshPending = true;[\s\S]*?await waitForTabChatHandoffRetry\(\);[\s\S]*?refreshed = await refreshVisibleSidePanelState\(\);[\s\S]*?refreshPromise\.then\(\(refreshed\) => \{[\s\S]*?refreshPromise !== visibleStateRefreshPromise[\s\S]*?consumePendingContextMenuPrompt\(\)/, `${label}: a returning panel should serialize the shared handoff before consuming prompts`, ); assert.match( panel, - /function syncSendButtonState\(\) \{[\s\S]*?if \(visibleStateRefreshPending \|\| visibleStateRefreshInProgress\) \{\s*sendBtn\.disabled = true;\s*return;/, - `${label}: the composer should stay disabled throughout visibility reconciliation`, + /function syncSendButtonState\(\) \{[\s\S]*?if \(tabSwitchTransitionId != null \|\| visibleStateRefreshPending \|\| visibleStateRefreshInProgress\) \{\s*sendBtn\.disabled = true;\s*return;/, + `${label}: the composer should stay disabled throughout tab-switch and visibility reconciliation`, ); + if (label === 'firefox') { + assert.match( + panel, + /async function switchToTab\(newTabId\) \{[\s\S]*?tabSwitchTransitionId = newTabId;[\s\S]*?let html = TAB_CHAT_LOAD_FAILED;[\s\S]*?while \(html === TAB_CHAT_LOAD_FAILED\) \{[\s\S]*?loadTabChat\(newTabId, \{ waitForHandoff: true \}\);[\s\S]*?await waitForTabChatHandoffRetry\(\);/, + 'firefox: switching tabs should acquire destination handoff ownership and retry transient failures before rendering', + ); + } assert.match( panel, /async function sendMessage\(extraChatParams = \{\}\) \{[\s\S]*?await waitForVisibleSidePanelStateRefresh\(\);\s*stopListening\(\);\s*let text = inputEl\.value\.trim\(\);/, From 4ffb9726f922512cadca3de124b6792053a96c38 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 10:55:09 +0200 Subject: [PATCH 13/18] Retry initial handoff ownership --- src/chrome/src/background.js | 1 - src/chrome/src/context-menu-storage.js | 7 ++- src/chrome/src/ui/sidepanel.js | 35 +++++++++---- src/firefox/src/background.js | 1 - src/firefox/src/context-menu-storage.js | 7 ++- src/firefox/src/ui/sidepanel.js | 35 +++++++++---- test/run.js | 70 ++++++++++++++++++++----- 7 files changed, 119 insertions(+), 37 deletions(-) diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 0af5140fc..9364fabc1 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -2648,7 +2648,6 @@ async function handleMessage(msg, sender) { tabId, msg.promptId, msg.claimantId, - Date.now(), () => agent.activeRunState(tabId)?.running || detachedRunStarts.has(tabId), ); } diff --git a/src/chrome/src/context-menu-storage.js b/src/chrome/src/context-menu-storage.js index ea63316f1..9fa6a2a38 100644 --- a/src/chrome/src/context-menu-storage.js +++ b/src/chrome/src/context-menu-storage.js @@ -196,7 +196,7 @@ export function createContextMenuStorage(getStore) { return { ok: true, prompt: prompt?.text ? prompt : null }; } - async function claim(tabId, promptId, claimantId, now = Date.now(), isRunActive = () => false) { + async function claim(tabId, promptId, claimantId, isRunActive = () => false, now) { const normalizedPromptId = String(promptId || ''); const normalizedClaimantId = String(claimantId || ''); if (!normalizedPromptId || !normalizedClaimantId) { @@ -232,7 +232,10 @@ export function createContextMenuStorage(getStore) { retryAfterMs: 1_000, }; } - const nowMs = Number.isFinite(Number(now)) ? Number(now) : Date.now(); + 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) { diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index d2e1c15d5..d741bafcf 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -2136,8 +2136,12 @@ async function waitForVisibleSidePanelStateRefresh() { do { pendingRefresh = visibleStateRefreshPromise; await pendingRefresh.catch(() => {}); - if (tabSwitchTransitionId != null) await waitForTabChatHandoffRetry(); - } while (pendingRefresh !== visibleStateRefreshPromise || tabSwitchTransitionId != null); + if (tabSwitchTransitionId != null || visibleStateRefreshInProgress) { + await waitForTabChatHandoffRetry(); + } + } while (pendingRefresh !== visibleStateRefreshPromise + || tabSwitchTransitionId != null + || visibleStateRefreshInProgress); } function schedulePersist() { @@ -3622,14 +3626,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, { waitForHandoff: true }); - if (html !== TAB_CHAT_LOAD_FAILED && 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(); } } diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index 41f41586a..29becc37f 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -2335,7 +2335,6 @@ async function handleMessage(msg, sender) { tabId, msg.promptId, msg.claimantId, - Date.now(), () => agent.activeRunState(tabId)?.running || detachedRunStarts.has(tabId), ); } diff --git a/src/firefox/src/context-menu-storage.js b/src/firefox/src/context-menu-storage.js index 8a0cf2236..a2112c262 100644 --- a/src/firefox/src/context-menu-storage.js +++ b/src/firefox/src/context-menu-storage.js @@ -196,7 +196,7 @@ export function createContextMenuStorage(getStore) { return { ok: true, prompt: prompt?.text ? prompt : null }; } - async function claim(tabId, promptId, claimantId, now = Date.now(), isRunActive = () => false) { + async function claim(tabId, promptId, claimantId, isRunActive = () => false, now) { const normalizedPromptId = String(promptId || ''); const normalizedClaimantId = String(claimantId || ''); if (!normalizedPromptId || !normalizedClaimantId) { @@ -232,7 +232,10 @@ export function createContextMenuStorage(getStore) { retryAfterMs: 1_000, }; } - const nowMs = Number.isFinite(Number(now)) ? Number(now) : Date.now(); + 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) { diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 9863bd3f0..23214615f 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -1556,8 +1556,12 @@ async function waitForVisibleSidePanelStateRefresh() { do { pendingRefresh = visibleStateRefreshPromise; await pendingRefresh.catch(() => {}); - if (tabSwitchTransitionId != null) await waitForTabChatHandoffRetry(); - } while (pendingRefresh !== visibleStateRefreshPromise || tabSwitchTransitionId != null); + if (tabSwitchTransitionId != null || visibleStateRefreshInProgress) { + await waitForTabChatHandoffRetry(); + } + } while (pendingRefresh !== visibleStateRefreshPromise + || tabSwitchTransitionId != null + || visibleStateRefreshInProgress); } function schedulePersist() { @@ -3474,14 +3478,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, { waitForHandoff: true }); - if (html !== TAB_CHAT_LOAD_FAILED && 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(); } } diff --git a/test/run.js b/test/run.js index a7047bbc2..75f094a26 100644 --- a/test/run.js +++ b/test/run.js @@ -17814,7 +17814,7 @@ test('sidepanel hydrates restored history ids before fallback records', () => { const initMatch = panel.match(/async function init\(\) \{([\s\S]*?)\n \/\/ Start observing the messages container/); assert.ok(initMatch, `${label}: init restore block missing`); const initBody = initMatch[1]; - const initLoadIdx = initBody.indexOf('const html = await loadTabChat(restoreTabId, { waitForHandoff: true });'); + const initLoadIdx = initBody.indexOf('loadTabChat(restoreTabId, { waitForHandoff: true })'); const initHydrateIdx = initBody.indexOf('await hydrateRestoredChatHistory(restoreTabId, html);'); const initGuardIdx = initBody.indexOf('if (currentTabId === restoreTabId) {', initHydrateIdx); const initDomIdx = initBody.indexOf('messagesEl.innerHTML = html;', initGuardIdx); @@ -18307,8 +18307,8 @@ test('sidepanel does not miss startup tab switches before consuming tab-scoped s if (label === 'chrome') { const restoreCaptureIdx = body.indexOf('const restoreTabId = currentTabId;'); - const restoreLoadIdx = body.indexOf('const html = await loadTabChat(restoreTabId, { waitForHandoff: true });'); - const restoreGuardIdx = body.indexOf('if (html !== TAB_CHAT_LOAD_FAILED && currentTabId === restoreTabId && html)'); + const restoreLoadIdx = body.indexOf('loadTabChat(restoreTabId, { waitForHandoff: true })'); + const restoreGuardIdx = body.indexOf('if (currentTabId === restoreTabId && html && html !== TAB_CHAT_LOAD_FAILED)'); assert.notEqual(restoreCaptureIdx, -1, 'chrome: initial tab-chat restore should capture the target tab'); assert.notEqual(restoreLoadIdx, -1, 'chrome: initial tab-chat restore should load the captured tab'); assert.notEqual(restoreGuardIdx, -1, 'chrome: initial tab-chat restore should drop failed and stale async results'); @@ -21859,6 +21859,11 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot /case 'claim_context_menu_prompt':[\s\S]*?contextMenuStorage\.claim\([\s\S]*?agent\.activeRunState\(tabId\)\?\.running \|\| detachedRunStarts\.has\(tabId\)/, `${label}: background should reject queued claim operations after another run reserves the tab`, ); + assert.doesNotMatch( + background, + /case 'claim_context_menu_prompt':[\s\S]*?contextMenuStorage\.claim\([\s\S]*?msg\.claimantId,\s*Date\.now\(\),/, + `${label}: claim timestamps must be sampled inside the serialized storage operation`, + ); assert.match( handler, /sendToBackground\('claim_context_menu_prompt', \{[\s\S]*?promptId: payload\.id,[\s\S]*?claimantId,[\s\S]*?\}\)/, @@ -21886,7 +21891,7 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot ); assert.match( panel, - /let visibleStateRefreshPromise = Promise\.resolve\(\);[\s\S]*?async function waitForVisibleSidePanelStateRefresh\(\) \{[\s\S]*?pendingRefresh = visibleStateRefreshPromise;[\s\S]*?await pendingRefresh\.catch\(\(\) => \{\}\);[\s\S]*?tabSwitchTransitionId != null[\s\S]*?pendingRefresh !== visibleStateRefreshPromise \|\| tabSwitchTransitionId != null/, + /let visibleStateRefreshPromise = Promise\.resolve\(\);[\s\S]*?async function waitForVisibleSidePanelStateRefresh\(\) \{[\s\S]*?pendingRefresh = visibleStateRefreshPromise;[\s\S]*?await pendingRefresh\.catch\(\(\) => \{\}\);[\s\S]*?tabSwitchTransitionId != null \|\| visibleStateRefreshInProgress[\s\S]*?pendingRefresh !== visibleStateRefreshPromise[\s\S]*?\|\| tabSwitchTransitionId != null[\s\S]*?\|\| visibleStateRefreshInProgress/, `${label}: sends should wait until visibility refreshes and coordinated tab switches settle`, ); assert.match( @@ -21913,8 +21918,8 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot ); assert.match( panel, - /const restoreTabId = currentTabId;[\s\S]*?await loadTabChat\(restoreTabId, \{ waitForHandoff: true \}\);/, - `${label}: an initially visible panel should also wait for an outgoing document handoff`, + /const restoreTabId = currentTabId;[\s\S]*?visibleStateRefreshInProgress = true;[\s\S]*?let html = TAB_CHAT_LOAD_FAILED;[\s\S]*?while \(html === TAB_CHAT_LOAD_FAILED && currentTabId === restoreTabId\) \{[\s\S]*?await loadTabChat\(restoreTabId, \{ waitForHandoff: true \}\);[\s\S]*?await waitForTabChatHandoffRetry\(\);[\s\S]*?finally \{[\s\S]*?visibleStateRefreshInProgress = false;/, + `${label}: an initially visible panel should stay blocked and retry until it acquires handoff ownership`, ); assert.match( panel, @@ -22021,10 +22026,10 @@ test('context-menu prompt storage enforces a durable expiring lease', async () = const prompt = { id: `${label}-lease`, tabId: 12, text: 'Explain this selection' }; await storage.save(prompt.tabId, prompt); - const first = await storage.claim(prompt.tabId, prompt.id, 'panel-a', 1_000); - const duplicateOwner = await storage.claim(prompt.tabId, prompt.id, 'panel-a', 1_001); - const contender = await storage.claim(prompt.tabId, prompt.id, 'panel-b', 1_002); - const takeover = await storage.claim(prompt.tabId, prompt.id, 'panel-b', duplicateOwner.leaseExpiresAt); + const first = await storage.claim(prompt.tabId, prompt.id, 'panel-a', () => false, 1_000); + const duplicateOwner = await storage.claim(prompt.tabId, prompt.id, 'panel-a', () => false, 1_001); + const contender = await storage.claim(prompt.tabId, prompt.id, 'panel-b', () => false, 1_002); + const takeover = await storage.claim(prompt.tabId, prompt.id, 'panel-b', () => false, duplicateOwner.leaseExpiresAt); assert.equal(first.claimed, true, `${label}: first panel should acquire the lease`); assert.equal(duplicateOwner.claimed, true, `${label}: lease acquisition should be idempotent for its owner`); @@ -22062,15 +22067,15 @@ test('context-menu prompt storage enforces a durable expiring lease', async () = assert.equal(released.prompt?.id, prompt.id, `${label}: releasing ownership must retain the durable prompt`); assert.equal(data.has(storage.claimKey(prompt.tabId)), false, `${label}: released ownership should clear only the lease`); assert.equal(data.has(storage.key(prompt.tabId)), true, `${label}: released ownership must not consume the prompt`); - const reclaimed = await storage.claim(prompt.tabId, prompt.id, 'panel-c', takeover.leaseExpiresAt + 1); + const reclaimed = await storage.claim(prompt.tabId, prompt.id, 'panel-c', () => false, takeover.leaseExpiresAt + 1); assert.equal(reclaimed.claimed, true, `${label}: a newly visible panel should reclaim immediately after release`); const blockedByRun = await storage.claim( prompt.tabId, prompt.id, 'panel-d', - reclaimed.leaseExpiresAt, () => true, + reclaimed.leaseExpiresAt, ); assert.equal(blockedByRun.claimed, false, `${label}: a queued claimant must be rejected once a run is reserved`); assert.equal(blockedByRun.reason, 'run-active', `${label}: run reservation rejection should remain retryable`); @@ -22080,13 +22085,52 @@ test('context-menu prompt storage enforces a durable expiring lease', async () = assert.equal(data.has(storage.key(prompt.tabId)), false, `${label}: accepting the run should clear the durable prompt`); assert.equal(data.has(storage.claimKey(prompt.tabId)), false, `${label}: accepting the run should clear its lease`); + const delayedClaimPrompt = { + id: `${label}-queued-claim-clock`, + tabId: 14, + text: 'Explain this page', + }; + await storage.save(delayedClaimPrompt.tabId, delayedClaimPrompt); + const incumbentClaim = await storage.claim( + delayedClaimPrompt.tabId, + delayedClaimPrompt.id, + 'incumbent-panel', + () => false, + 2_000, + ); + const claimSetGate = deferred(); + nextSetGate = claimSetGate; + const claimBlockingSave = storage.save(delayedClaimPrompt.tabId, delayedClaimPrompt); + await waitMicrotasks(3); + + const claimOriginalNow = Date.now; + let delayedClaim = null; + try { + Date.now = () => 2_000; + const queuedTakeover = storage.claim( + delayedClaimPrompt.tabId, + delayedClaimPrompt.id, + 'next-panel', + () => false, + ); + Date.now = () => incumbentClaim.leaseExpiresAt; + claimSetGate.resolve(); + await claimBlockingSave; + delayedClaim = await queuedTakeover; + } finally { + Date.now = claimOriginalNow; + claimSetGate.resolve(); + } + assert.equal(delayedClaim?.claimed, true, `${label}: queued claims should re-sample time and take over an expired lease`); + assert.equal(delayedClaim?.leaseExpiresAt, incumbentClaim.leaseExpiresAt + leaseMs, `${label}: a delayed claim should receive a fresh full lease`); + const queuedPrompt = { id: `${label}-queued-expiry`, tabId: 13, text: 'Summarize this selection', }; await storage.save(queuedPrompt.tabId, queuedPrompt); - const queuedClaim = await storage.claim(queuedPrompt.tabId, queuedPrompt.id, 'queued-panel', 1_000); + const queuedClaim = await storage.claim(queuedPrompt.tabId, queuedPrompt.id, 'queued-panel', () => false, 1_000); const setGate = deferred(); nextSetGate = setGate; const blockingSave = storage.save(queuedPrompt.tabId, queuedPrompt); From 2fa4ce0ef882cd98f927d6f2d7fc749ff6c5aa17 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 11:04:02 +0200 Subject: [PATCH 14/18] Invalidate cleared chat handoff snapshots --- src/chrome/src/ui/sidepanel.js | 10 +++++++++- src/firefox/src/ui/sidepanel.js | 10 +++++++++- test/run.js | 5 ++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index d741bafcf..257269477 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -1644,6 +1644,10 @@ 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); @@ -2095,7 +2099,7 @@ function drainQueuedComposerMessageForCurrentTab() { } async function renderClearedConversationForTab(tabId) { - clearCachedTabChat(tabId); + await clearCachedTabChat(tabId); resetComposerHistoryNavigation(tabId); saveInputDraftForTab(tabId, ''); clearPendingAttachmentsForTab(tabId); @@ -2112,6 +2116,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(); } diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 23214615f..00c0f24c4 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -1509,6 +1509,10 @@ 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); @@ -2216,7 +2220,7 @@ function drainQueuedComposerMessageForCurrentTab() { } async function renderClearedConversationForTab(tabId) { - clearCachedTabChat(tabId); + await clearCachedTabChat(tabId); resetComposerHistoryNavigation(tabId); saveInputDraftForTab(tabId, ''); clearPendingAttachmentsForTab(tabId); @@ -2233,6 +2237,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(); } diff --git a/test/run.js b/test/run.js index 75f094a26..82360653e 100644 --- a/test/run.js +++ b/test/run.js @@ -17865,7 +17865,8 @@ test('sidepanel deletes durable history when clearing conversations', () => { const helperStart = panel.indexOf('async function renderClearedConversationForTab(tabId)'); assert.notEqual(helperStart, -1, `${label}: clear helper should be async`); const helperBody = panel.slice(helperStart, panel.indexOf('refreshScheduledJobs({', helperStart)); - assert.match(helperBody, /await resetChatHistoryStateForTab\(tabId\);[\s\S]*?if \(currentTabId !== tabId\) return;/, `${label}: clear helper should delete durable history before visible-tab guards`); + assert.match(helperBody, /await clearCachedTabChat\(tabId\);[\s\S]*?await resetChatHistoryStateForTab\(tabId\);[\s\S]*?if \(currentTabId !== tabId\) return;/, `${label}: clear helper should durably clear tab chat before deleting history and checking visibility`); + assert.match(helperBody, /addMessage\('system', t\('sp\.cleared_message'\)\);[\s\S]*?lastVisibleTabChatSnapshot = \{ tabId: Number\(tabId\), html: clearedHtml \};[\s\S]*?await persistTabChat\(tabId, clearedHtml, \{ allowHidden: true \}\)/, `${label}: a cleared handoff snapshot should replace the invalidated transcript only after the durable clear`); const resetIdx = panel.indexOf("if (command.value === '/reset')"); const resetSlashBody = panel.slice(resetIdx, panel.indexOf("if (command.value === '/screenshot'", resetIdx)); @@ -18221,6 +18222,7 @@ test('chrome sidepanel serializes tab-chat storage writes with clears and reads' const clearBody = panel.slice(clearStart, panel.indexOf('\n}\n\nasync function renderClearedConversationForTab', clearStart) + 2); assert.match(clearBody, /tabChats\.delete\(tabId\);/, 'chrome: clearing tab chat should delete the cached HTML before queuing storage removal'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{/, 'chrome: clearing tab chat should be serialized through the queue'); + assert.match(clearBody, /lastVisibleTabChatSnapshot[\s\S]*?sameTabId\(lastVisibleTabChatSnapshot\.tabId, tabId\)[\s\S]*?lastVisibleTabChatSnapshot = null;[\s\S]*?return enqueueTabChatOperation/, 'chrome: clearing should invalidate the pre-clear handoff snapshot before yielding'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?handoffGeneration = tabChatHandoffGenerations\.get\(numericTabId\);[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff: true,[\s\S]*?sendToBackground\('clear_tab_chat', \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration,/, 'chrome: queued clears should retain or reacquire ownership before clearing shared state'); assert.doesNotMatch(clearBody, /tabChatHandoffGenerations\.delete/, 'chrome: clearing content must not discard the visible document handoff generation'); }); @@ -18244,6 +18246,7 @@ test('firefox sidepanel serializes tab-chat storage writes with clears and reads const clearBody = panel.slice(clearStart, panel.indexOf('\n}\n\n// Save current tab', clearStart) + 2); assert.match(clearBody, /tabChats\.delete\(tabId\);/, 'firefox: clearing tab chat should delete the cached HTML before queuing storage removal'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{/, 'firefox: clearing tab chat should be serialized through the queue'); + assert.match(clearBody, /lastVisibleTabChatSnapshot[\s\S]*?sameTabId\(lastVisibleTabChatSnapshot\.tabId, tabId\)[\s\S]*?lastVisibleTabChatSnapshot = null;[\s\S]*?return enqueueTabChatOperation/, 'firefox: clearing should invalidate the pre-clear handoff snapshot before yielding'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?handoffGeneration = tabChatHandoffGenerations\.get\(numericTabId\);[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff: true,[\s\S]*?sendToBackground\('clear_tab_chat', \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration,/, 'firefox: queued clears should retain or reacquire ownership before clearing shared state'); assert.doesNotMatch(clearBody, /tabChatHandoffGenerations\.delete/, 'firefox: clearing content must not discard the visible document handoff generation'); }); From 2ea8ceb97c52de81b27c54f7454b472204511e50 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 11:14:15 +0200 Subject: [PATCH 15/18] Release claims on preflight exits --- src/chrome/src/ui/sidepanel.js | 8 +++++++- src/firefox/src/ui/sidepanel.js | 8 +++++++- test/run.js | 6 +++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 257269477..3fcd156ba 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -7103,16 +7103,21 @@ async function sendMessage(extraChatParams = {}) { } catch { /* the durable lease still expires if release fails */ } onContextMenuClaimRejected?.(rejection); }; - 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; } @@ -7188,6 +7193,7 @@ async function sendMessage(extraChatParams = {}) { // 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(); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 00c0f24c4..77dc731e6 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -6845,16 +6845,21 @@ async function sendMessage(extraChatParams = {}) { } catch { /* the durable lease still expires if release fails */ } onContextMenuClaimRejected?.(rejection); }; - 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; } @@ -6925,6 +6930,7 @@ async function sendMessage(extraChatParams = {}) { return false; } if (!text) { + await releaseOwnedContextMenuClaim({ reason: 'preflight-empty', retryAfterMs: 1_000 }); scrollToBottom({ force: true }); inputEl.value = ''; autoResizeInput(); diff --git a/test/run.js b/test/run.js index 82360653e..c6f98fc74 100644 --- a/test/run.js +++ b/test/run.js @@ -15117,7 +15117,7 @@ test('sidepanel New conversation uses a Vivaldi-safe in-panel confirmation dialo `${label}: confirmed New conversation should discard queued prompts before stopping and clearing`, ); assert.match(panel, /function syncSendButtonState\(\) \{[\s\S]*?isConversationClearInProgress\(\)[\s\S]*?sendBtn\.disabled = true;/, `${label}: the composer should stay disabled for the full clear transaction`); - assert.match(panel, /async function sendMessage\(extraChatParams = \{\}\) \{[\s\S]*?const tabId = currentTabId;[\s\S]*?if \(isConversationClearInProgress\(tabId\)\) return false;/, `${label}: Enter and programmatic sends should not bypass the pending-clear interlock`); + assert.match(panel, /async function sendMessage\(extraChatParams = \{\}\) \{[\s\S]*?const tabId = currentTabId;[\s\S]*?if \(isConversationClearInProgress\(tabId\)\) \{[\s\S]*?releaseOwnedContextMenuClaim\(\{ reason: 'conversation-clear', retryAfterMs: 1_000 \}\);[\s\S]*?return false;/, `${label}: Enter and programmatic sends should not bypass the pending-clear interlock`); assert.match(panel, /function suppressRunUpdatesForClearedConversation\(tabId\) \{[\s\S]*?localRunRequestIds\.get\(Number\(tabId\)\)[\s\S]*?clearedConversationRunRequestIds\.add\(requestId\)[\s\S]*?clearedConversationRunRequestIds\.size > 100/, `${label}: conversation clear should retain a bounded set of invalidated run requests`); assert.match(panel, /function handleAgentUpdateMessage\(msg\) \{[\s\S]*?if \(msg\.requestId && clearedConversationRunRequestIds\.has\(String\(msg\.requestId\)\)\) return;[\s\S]*?const eventAssistantEl = ensureCurrentRunAssistant\(msg\);/, `${label}: cleared-run updates should be rejected before they can recreate an assistant bubble`); assert.match(panel, /async function abortRun\(tabId = currentTabId\) \{[\s\S]*?sendToBackground\('abort', \{ tabId \}\)[\s\S]*?stopBtn\.addEventListener\('click', \(\) => abortRun\(\)\);/, `${label}: Stop should support a captured tab target without treating click events as tab ids`); @@ -22329,7 +22329,7 @@ test('sidepanel long replies use reading-first turn navigation', () => { ); assert.match( panel, - /if \(!text\) \{\s*scrollToBottom\(\{ force: true \}\);\s*inputEl\.value = '';/, + /if \(!text\) \{[\s\S]*?scrollToBottom\(\{ force: true \}\);\s*inputEl\.value = '';/, `${label}: fully consumed slash commands should reveal synchronous UI output`, ); assert.match( @@ -52480,7 +52480,7 @@ test('sidepanel: restored plan review cards rebind approve and cancel actions', assert.match(source, /sendToBackground\('agent_run_state'/, `${file} should ask background for active-run state`); assert.match(source, /const awaitingPlanReviewTabs = new Set\(\);/, `${file} should track tabs awaiting plan approval`); assert.match(source, /function isAwaitingPlanReviewForTab\(/, `${file} should detect awaiting plan-review tabs`); - assert.match(source, /if \(isAwaitingPlanReviewForTab\(tabId\)\) \{[\s\S]*?sp\.plan\.awaiting_review[\s\S]*?return false;/, `${file} should block normal sends while a plan awaits approval`); + assert.match(source, /if \(isAwaitingPlanReviewForTab\(tabId\)\) \{[\s\S]*?releaseOwnedContextMenuClaim\(\{ reason: 'plan-review-pending', retryAfterMs: 1_000 \}\);[\s\S]*?sp\.plan\.awaiting_review[\s\S]*?return false;/, `${file} should release claimed context-menu prompts before blocking sends for plan approval`); assert.match(source, /rebindPlanReviewCards\(\);/, `${file} should call the rebinder after chat restore`); assert.match(source, /plan-review-approve[\s\S]*submitPlanReview\(card, tabId, planId, 'approve'/, `${file} should rebind approve`); assert.match(source, /plan-review-change[\s\S]*setPlanReviewRawEditing\(card, enableRaw/, `${file} should toggle raw markdown via Edit as text`); From ce5e8412e33c08742088c8f91511ad865592f393 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 11:23:27 +0200 Subject: [PATCH 16/18] Retry stale tab chat clears --- src/chrome/src/background.js | 16 ++++++++++++++-- src/chrome/src/ui/sidepanel.js | 32 +++++++++++++++++++++++++------- src/firefox/src/background.js | 16 ++++++++++++++-- src/firefox/src/ui/sidepanel.js | 32 +++++++++++++++++++++++++------- test/run.js | 12 ++++++++++++ 5 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 9364fabc1..d6d540010 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -2682,11 +2682,23 @@ async function handleMessage(msg, sender) { claimantId: msg.handoffOwnerId, }); - case 'clear_tab_chat': - return await tabChatHandoff.clear(msg.tabId || sender.tab?.id, { + 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; diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 3fcd156ba..13f3b4df7 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -1651,9 +1651,10 @@ function clearCachedTabChat(tabId) { tabChats.delete(tabId); return enqueueTabChatOperation(tabId, async (numericTabId) => { tabChats.delete(numericTabId); - try { - let handoffGeneration = tabChatHandoffGenerations.get(numericTabId); - if (!Number.isFinite(handoffGeneration)) { + 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, @@ -1665,13 +1666,13 @@ function clearCachedTabChat(tabId) { tabChatHandoffGenerations.set(numericTabId, handoffGeneration); } } - await sendToBackground('clear_tab_chat', { + clearResult = await sendToBackground('clear_tab_chat', { tabId: numericTabId, handoffOwnerId: tabChatHandoffOwnerId, handoffGeneration, }); - } catch (e) { /* ignore */ } - return { ok: true }; + if (!clearResult?.skipped || clearResult.reason !== 'stale-handoff') return clearResult; + } }); } @@ -2099,7 +2100,10 @@ function drainQueuedComposerMessageForCurrentTab() { } async function renderClearedConversationForTab(tabId) { - await 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); @@ -7617,6 +7621,20 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { }); }); +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; diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index 29becc37f..39c1d258b 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -2369,11 +2369,23 @@ async function handleMessage(msg, sender) { claimantId: msg.handoffOwnerId, }); - case 'clear_tab_chat': - return await tabChatHandoff.clear(msg.tabId || sender.tab?.id, { + 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) { + browser.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; diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 77dc731e6..059b00296 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -1516,9 +1516,10 @@ function clearCachedTabChat(tabId) { tabChats.delete(tabId); return enqueueTabChatOperation(tabId, async (numericTabId) => { tabChats.delete(numericTabId); - try { - let handoffGeneration = tabChatHandoffGenerations.get(numericTabId); - if (!Number.isFinite(handoffGeneration)) { + 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, @@ -1530,13 +1531,13 @@ function clearCachedTabChat(tabId) { tabChatHandoffGenerations.set(numericTabId, handoffGeneration); } } - await sendToBackground('clear_tab_chat', { + clearResult = await sendToBackground('clear_tab_chat', { tabId: numericTabId, handoffOwnerId: tabChatHandoffOwnerId, handoffGeneration, }); - } catch (e) { /* ignore */ } - return { ok: true }; + if (!clearResult?.skipped || clearResult.reason !== 'stale-handoff') return clearResult; + } }); } @@ -2220,7 +2221,10 @@ function drainQueuedComposerMessageForCurrentTab() { } async function renderClearedConversationForTab(tabId) { - await 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); @@ -7246,6 +7250,20 @@ browser.runtime.onMessage.addListener((msg, _sender, sendResponse) => { }); }); +browser.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; diff --git a/test/run.js b/test/run.js index c6f98fc74..c9154f8df 100644 --- a/test/run.js +++ b/test/run.js @@ -18223,6 +18223,7 @@ test('chrome sidepanel serializes tab-chat storage writes with clears and reads' assert.match(clearBody, /tabChats\.delete\(tabId\);/, 'chrome: clearing tab chat should delete the cached HTML before queuing storage removal'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{/, 'chrome: clearing tab chat should be serialized through the queue'); assert.match(clearBody, /lastVisibleTabChatSnapshot[\s\S]*?sameTabId\(lastVisibleTabChatSnapshot\.tabId, tabId\)[\s\S]*?lastVisibleTabChatSnapshot = null;[\s\S]*?return enqueueTabChatOperation/, 'chrome: clearing should invalidate the pre-clear handoff snapshot before yielding'); + assert.match(clearBody, /while \(true\)[\s\S]*?clearResult\?\.reason === 'stale-handoff'[\s\S]*?sendToBackground\('load_tab_chat'[\s\S]*?clearResult = await sendToBackground\('clear_tab_chat'[\s\S]*?clearResult\.reason !== 'stale-handoff'/, 'chrome: a stale clear should reacquire ownership and retry before rendering the cleared conversation'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?handoffGeneration = tabChatHandoffGenerations\.get\(numericTabId\);[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff: true,[\s\S]*?sendToBackground\('clear_tab_chat', \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration,/, 'chrome: queued clears should retain or reacquire ownership before clearing shared state'); assert.doesNotMatch(clearBody, /tabChatHandoffGenerations\.delete/, 'chrome: clearing content must not discard the visible document handoff generation'); }); @@ -18247,6 +18248,7 @@ test('firefox sidepanel serializes tab-chat storage writes with clears and reads assert.match(clearBody, /tabChats\.delete\(tabId\);/, 'firefox: clearing tab chat should delete the cached HTML before queuing storage removal'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{/, 'firefox: clearing tab chat should be serialized through the queue'); assert.match(clearBody, /lastVisibleTabChatSnapshot[\s\S]*?sameTabId\(lastVisibleTabChatSnapshot\.tabId, tabId\)[\s\S]*?lastVisibleTabChatSnapshot = null;[\s\S]*?return enqueueTabChatOperation/, 'firefox: clearing should invalidate the pre-clear handoff snapshot before yielding'); + assert.match(clearBody, /while \(true\)[\s\S]*?clearResult\?\.reason === 'stale-handoff'[\s\S]*?sendToBackground\('load_tab_chat'[\s\S]*?clearResult = await sendToBackground\('clear_tab_chat'[\s\S]*?clearResult\.reason !== 'stale-handoff'/, 'firefox: a stale clear should reacquire ownership and retry before rendering the cleared conversation'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?handoffGeneration = tabChatHandoffGenerations\.get\(numericTabId\);[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff: true,[\s\S]*?sendToBackground\('clear_tab_chat', \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration,/, 'firefox: queued clears should retain or reacquire ownership before clearing shared state'); assert.doesNotMatch(clearBody, /tabChatHandoffGenerations\.delete/, 'firefox: clearing content must not discard the visible document handoff generation'); }); @@ -21949,6 +21951,16 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot /case 'clear_tab_chat':[\s\S]*?tabChatHandoff\.clear\([\s\S]*?ownerId: msg\.handoffOwnerId,[\s\S]*?handoffGeneration: msg\.handoffGeneration/, `${label}: New Chat should clear content through the visible document's validated handoff ownership`, ); + assert.match( + background, + /case 'clear_tab_chat':[\s\S]*?result\?\.ok && !result\.skipped[\s\S]*?action: 'tab_chat_cleared'[\s\S]*?handoffOwnerId: result\.handoffOwnerId/, + `${label}: a successful clear should notify other panel documents to refresh the cleared transcript`, + ); + assert.match( + panel, + /action !== 'tab_chat_cleared'[\s\S]*?msg\.handoffOwnerId === tabChatHandoffOwnerId[\s\S]*?lastVisibleTabChatSnapshot = null;[\s\S]*?requestVisibleSidePanelStateRefresh\(\);/, + `${label}: a different visible panel owner should reconcile immediately after a successful clear`, + ); assert.match( background, /requestHandoff:[\s\S]*?tab_chat_handoff_request[\s\S]*?ownerId,[\s\S]*?generation,/, From 5bbe75e0a06091a096edc1b9fe6b64b384d6959b Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 11:33:47 +0200 Subject: [PATCH 17/18] Invalidate pre-clear handoff generations --- src/chrome/src/ui/sidepanel.js | 14 +++++++++++--- src/chrome/src/ui/tab-chat-persistence.js | 15 +++++++++++++-- src/firefox/src/ui/sidepanel.js | 14 +++++++++++--- src/firefox/src/ui/tab-chat-persistence.js | 15 +++++++++++++-- test/run.js | 22 ++++++++++++++++++---- 5 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 13f3b4df7..2bb732a6c 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -1671,7 +1671,13 @@ function clearCachedTabChat(tabId) { handoffOwnerId: tabChatHandoffOwnerId, handoffGeneration, }); - if (!clearResult?.skipped || clearResult.reason !== 'stale-handoff') return clearResult; + if (!clearResult?.skipped || clearResult.reason !== 'stale-handoff') { + if (clearResult?.handoffOwnerId === tabChatHandoffOwnerId + && Number.isFinite(Number(clearResult?.handoffGeneration))) { + tabChatHandoffGenerations.set(numericTabId, Number(clearResult.handoffGeneration)); + } + return clearResult; + } } }); } @@ -7428,11 +7434,13 @@ async function sendMessage(extraChatParams = {}) { && String(e?.message || '').startsWith(RUN_CAPTURE_START_ERROR_PREFIX); contextMenuReservationRejected = e?.code === 'context-menu-reservation-rejected'; if (contextMenuReservationRejected) { - onContextMenuClaimRejected?.({ + 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; diff --git a/src/chrome/src/ui/tab-chat-persistence.js b/src/chrome/src/ui/tab-chat-persistence.js index 74e99bfc7..236eecf48 100644 --- a/src/chrome/src/ui/tab-chat-persistence.js +++ b/src/chrome/src/ui/tab-chat-persistence.js @@ -361,12 +361,23 @@ export function createTabChatHandoffCoordinator(storageArea, { } latestHtml.delete(queuedTabId); const keys = [TAB_CHAT_PREFIX + queuedTabId]; - if (!normalizedOwnerId) keys.push(TAB_CHAT_HANDOFF_PREFIX + queuedTabId); + let nextGeneration = null; + if (normalizedOwnerId) { + nextGeneration = normalizedGeneration + 1; + await storageArea.set({ + [TAB_CHAT_HANDOFF_PREFIX + queuedTabId]: { + ownerId: normalizedOwnerId, + generation: nextGeneration, + }, + }); + } else { + keys.push(TAB_CHAT_HANDOFF_PREFIX + queuedTabId); + } await storageArea.remove(keys); return { ok: true, handoffOwnerId: normalizedOwnerId || null, - handoffGeneration: normalizedOwnerId ? normalizedGeneration : null, + handoffGeneration: nextGeneration, }; }); } diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 059b00296..7f6786061 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -1536,7 +1536,13 @@ function clearCachedTabChat(tabId) { handoffOwnerId: tabChatHandoffOwnerId, handoffGeneration, }); - if (!clearResult?.skipped || clearResult.reason !== 'stale-handoff') return clearResult; + if (!clearResult?.skipped || clearResult.reason !== 'stale-handoff') { + if (clearResult?.handoffOwnerId === tabChatHandoffOwnerId + && Number.isFinite(Number(clearResult?.handoffGeneration))) { + tabChatHandoffGenerations.set(numericTabId, Number(clearResult.handoffGeneration)); + } + return clearResult; + } } }); } @@ -7165,11 +7171,13 @@ async function sendMessage(extraChatParams = {}) { && String(e?.message || '').startsWith(RUN_CAPTURE_START_ERROR_PREFIX); contextMenuReservationRejected = e?.code === 'context-menu-reservation-rejected'; if (contextMenuReservationRejected) { - onContextMenuClaimRejected?.({ + 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; diff --git a/src/firefox/src/ui/tab-chat-persistence.js b/src/firefox/src/ui/tab-chat-persistence.js index 7177411bf..a3c866284 100644 --- a/src/firefox/src/ui/tab-chat-persistence.js +++ b/src/firefox/src/ui/tab-chat-persistence.js @@ -361,12 +361,23 @@ export function createTabChatHandoffCoordinator(storageArea, { } latestHtml.delete(queuedTabId); const keys = [TAB_CHAT_PREFIX + queuedTabId]; - if (!normalizedOwnerId) keys.push(TAB_CHAT_HANDOFF_PREFIX + queuedTabId); + let nextGeneration = null; + if (normalizedOwnerId) { + nextGeneration = normalizedGeneration + 1; + await storageArea.set({ + [TAB_CHAT_HANDOFF_PREFIX + queuedTabId]: { + ownerId: normalizedOwnerId, + generation: nextGeneration, + }, + }); + } else { + keys.push(TAB_CHAT_HANDOFF_PREFIX + queuedTabId); + } await storageArea.remove(keys); return { ok: true, handoffOwnerId: normalizedOwnerId || null, - handoffGeneration: normalizedOwnerId ? normalizedGeneration : null, + handoffGeneration: nextGeneration, }; }); } diff --git a/test/run.js b/test/run.js index c9154f8df..92368c7dd 100644 --- a/test/run.js +++ b/test/run.js @@ -18184,21 +18184,28 @@ test('tab-chat handoff coordinator orders a returning-panel read behind the outg handoffGeneration: 2, }); assert.equal(cleared.ok, true, `${label}: the visible owner should clear its transcript`); + assert.equal(cleared.handoffGeneration, 3, `${label}: clear should rotate the visible owner's generation`); assert.equal(values[`${persistence.TAB_CHAT_PREFIX}7`], undefined, `${label}: clear should remove transcript content`); assert.deepEqual( values[`${persistence.TAB_CHAT_HANDOFF_PREFIX}7`], - { ownerId: 'returning-panel', generation: 2 }, - `${label}: clear should preserve the visible document's validated handoff ownership`, + { ownerId: 'returning-panel', generation: 3 }, + `${label}: clear should preserve the visible document's ownership under a fresh generation`, ); + const capturedAcknowledgementWrite = await coordinator.save(7, '
captured before clear
', { + ownerId: 'returning-panel', + handoffGeneration: 2, + }); + assert.equal(capturedAcknowledgementWrite.skipped, true, `${label}: a pre-clear acknowledgement delivered after clear must be rejected`); + assert.equal(values[`${persistence.TAB_CHAT_PREFIX}7`], undefined, `${label}: a delayed pre-clear acknowledgement must not restore cleared content`); const unversionedWrite = await coordinator.save(7, '
unversioned stale
', { ownerId: 'returning-panel', }); assert.equal(unversionedWrite.skipped, true, `${label}: owner-tagged writes without a generation must fail closed`); const postClearWrite = await coordinator.save(7, '
new conversation
', { ownerId: 'returning-panel', - handoffGeneration: 2, + handoffGeneration: 3, }); - assert.equal(postClearWrite.ok, true, `${label}: the preserved owner should persist the new conversation`); + assert.equal(postClearWrite.ok, true, `${label}: the owner should persist the new conversation under the rotated generation`); assert.equal(values[`${persistence.TAB_CHAT_PREFIX}7`], '
new conversation
', `${label}: the post-clear transcript should remain current`); } }); @@ -18225,6 +18232,7 @@ test('chrome sidepanel serializes tab-chat storage writes with clears and reads' assert.match(clearBody, /lastVisibleTabChatSnapshot[\s\S]*?sameTabId\(lastVisibleTabChatSnapshot\.tabId, tabId\)[\s\S]*?lastVisibleTabChatSnapshot = null;[\s\S]*?return enqueueTabChatOperation/, 'chrome: clearing should invalidate the pre-clear handoff snapshot before yielding'); assert.match(clearBody, /while \(true\)[\s\S]*?clearResult\?\.reason === 'stale-handoff'[\s\S]*?sendToBackground\('load_tab_chat'[\s\S]*?clearResult = await sendToBackground\('clear_tab_chat'[\s\S]*?clearResult\.reason !== 'stale-handoff'/, 'chrome: a stale clear should reacquire ownership and retry before rendering the cleared conversation'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?handoffGeneration = tabChatHandoffGenerations\.get\(numericTabId\);[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff: true,[\s\S]*?sendToBackground\('clear_tab_chat', \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration,/, 'chrome: queued clears should retain or reacquire ownership before clearing shared state'); + assert.match(clearBody, /clearResult\?\.handoffOwnerId === tabChatHandoffOwnerId[\s\S]*?tabChatHandoffGenerations\.set\(numericTabId, Number\(clearResult\.handoffGeneration\)\)/, 'chrome: the clearing panel should adopt the generation rotated by the coordinator'); assert.doesNotMatch(clearBody, /tabChatHandoffGenerations\.delete/, 'chrome: clearing content must not discard the visible document handoff generation'); }); @@ -18250,6 +18258,7 @@ test('firefox sidepanel serializes tab-chat storage writes with clears and reads assert.match(clearBody, /lastVisibleTabChatSnapshot[\s\S]*?sameTabId\(lastVisibleTabChatSnapshot\.tabId, tabId\)[\s\S]*?lastVisibleTabChatSnapshot = null;[\s\S]*?return enqueueTabChatOperation/, 'firefox: clearing should invalidate the pre-clear handoff snapshot before yielding'); assert.match(clearBody, /while \(true\)[\s\S]*?clearResult\?\.reason === 'stale-handoff'[\s\S]*?sendToBackground\('load_tab_chat'[\s\S]*?clearResult = await sendToBackground\('clear_tab_chat'[\s\S]*?clearResult\.reason !== 'stale-handoff'/, 'firefox: a stale clear should reacquire ownership and retry before rendering the cleared conversation'); assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?handoffGeneration = tabChatHandoffGenerations\.get\(numericTabId\);[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff: true,[\s\S]*?sendToBackground\('clear_tab_chat', \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration,/, 'firefox: queued clears should retain or reacquire ownership before clearing shared state'); + assert.match(clearBody, /clearResult\?\.handoffOwnerId === tabChatHandoffOwnerId[\s\S]*?tabChatHandoffGenerations\.set\(numericTabId, Number\(clearResult\.handoffGeneration\)\)/, 'firefox: the clearing panel should adopt the generation rotated by the coordinator'); assert.doesNotMatch(clearBody, /tabChatHandoffGenerations\.delete/, 'firefox: clearing content must not discard the visible document handoff generation'); }); @@ -21998,6 +22007,11 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot 5, `${label}: every visibility or preflight-abort exit should release the claim and schedule retry`, ); + assert.match( + sendMessageBody, + /contextMenuReservationRejected = e\?\.code === 'context-menu-reservation-rejected';[\s\S]*?const rejection = \{[\s\S]*?if \(contextMenuClaimOwned\) await releaseOwnedContextMenuClaim\(rejection\);[\s\S]*?else onContextMenuClaimRejected\?\.\(rejection\);/, + `${label}: a run-active reservation rejection should release the renewed prompt lease before retrying`, + ); assert.match( sendMessageBody, /if \(!retryOptions\) text = await parseSlashCommands\(text, tabId, \{ permissionSkipContext \}\);[\s\S]*?if \(!renderToCurrentTab\) \{\s*await releaseOwnedContextMenuClaim\(\);/, From f8080ee56d70b5dae838600bcec77ad1154932c0 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 30 Jul 2026 11:46:26 +0200 Subject: [PATCH 18/18] Bind claimed prompts to source tabs --- src/chrome/src/ui/sidepanel.js | 42 +++++++++++++++++++++++---------- src/firefox/src/ui/sidepanel.js | 42 +++++++++++++++++++++++---------- test/run.js | 16 ++++++++++--- 3 files changed, 71 insertions(+), 29 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 2bb732a6c..c4483d12a 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -7086,19 +7086,10 @@ async function sendMessage(extraChatParams = {}) { delete chatExtraParams.__mode; delete chatExtraParams.__onContextMenuClaimRejected; const contextMenuClaim = chatExtraParams.contextMenuClaim; - 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(); - stopListening(); - let text = inputEl.value.trim(); - if (!text) return; - const submittedText = text; - const tabId = currentTabId; 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 }, ) => { @@ -7106,13 +7097,38 @@ async function sendMessage(extraChatParams = {}) { contextMenuClaimOwned = false; try { await sendToBackground('release_context_menu_prompt_claim', { - tabId, + 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) { + if (contextMenuClaimOwned) { + await releaseOwnedContextMenuClaim({ reason: 'preflight-empty', retryAfterMs: 1_000 }); + return false; + } + return; + } + const submittedText = text; + const tabId = currentTabId; if (isConversationClearInProgress(tabId)) { await releaseOwnedContextMenuClaim({ reason: 'conversation-clear', retryAfterMs: 1_000 }); return false; diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 7f6786061..353adb31b 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -6828,19 +6828,10 @@ async function sendMessage(extraChatParams = {}) { delete chatExtraParams.__mode; delete chatExtraParams.__onContextMenuClaimRejected; const contextMenuClaim = chatExtraParams.contextMenuClaim; - 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(); - stopListening(); - let text = inputEl.value.trim(); - if (!text) return; - const submittedText = text; - const tabId = currentTabId; 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 }, ) => { @@ -6848,13 +6839,38 @@ async function sendMessage(extraChatParams = {}) { contextMenuClaimOwned = false; try { await sendToBackground('release_context_menu_prompt_claim', { - tabId, + 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) { + if (contextMenuClaimOwned) { + await releaseOwnedContextMenuClaim({ reason: 'preflight-empty', retryAfterMs: 1_000 }); + return false; + } + return; + } + const submittedText = text; + const tabId = currentTabId; if (isConversationClearInProgress(tabId)) { await releaseOwnedContextMenuClaim({ reason: 'conversation-clear', retryAfterMs: 1_000 }); return false; diff --git a/test/run.js b/test/run.js index 92368c7dd..93c244a4d 100644 --- a/test/run.js +++ b/test/run.js @@ -21927,7 +21927,7 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot } assert.match( panel, - /async function sendMessage\(extraChatParams = \{\}\) \{[\s\S]*?await waitForVisibleSidePanelStateRefresh\(\);\s*stopListening\(\);\s*let text = inputEl\.value\.trim\(\);/, + /async function sendMessage\(extraChatParams = \{\}\) \{[\s\S]*?await waitForVisibleSidePanelStateRefresh\(\);[\s\S]*?stopListening\(\);\s*let text = inputEl\.value\.trim\(\);/, `${label}: user sends should not capture or append a turn until visibility reconciliation finishes`, ); assert.match( @@ -21987,9 +21987,19 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot ); assert.match( panel, - /let contextMenuClaimOwned = Boolean\(contextMenuClaim\?\.promptId && contextMenuClaim\?\.claimantId\);[\s\S]*?const releaseOwnedContextMenuClaim = async \([\s\S]*?reason: 'panel-hidden', retryAfterMs: 250[\s\S]*?contextMenuClaimOwned = false;[\s\S]*?release_context_menu_prompt_claim[\s\S]*?onContextMenuClaimRejected\?\.\(rejection\);[\s\S]*?contextMenuClaimOwned = renewedClaim\?\.claimed === true;/, + /let contextMenuClaimOwned = Boolean\(contextMenuClaim\?\.promptId && contextMenuClaim\?\.claimantId\);[\s\S]*?const claimedContextMenuTabId = contextMenuClaimOwned[\s\S]*?chatExtraParams\.contextMenuClear\?\.tabId[\s\S]*?const releaseOwnedContextMenuClaim = async \([\s\S]*?reason: 'panel-hidden', retryAfterMs: 250[\s\S]*?contextMenuClaimOwned = false;[\s\S]*?release_context_menu_prompt_claim[\s\S]*?tabId: claimedContextMenuTabId,[\s\S]*?onContextMenuClaimRejected\?\.\(rejection\);[\s\S]*?contextMenuClaimOwned = renewedClaim\?\.claimed === true;/, `${label}: initial and renewed ownership should share one idempotent release-and-retry path`, ); + assert.match( + panel, + /const claimedContextMenuTabId = contextMenuClaimOwned[\s\S]*?await waitForVisibleSidePanelStateRefresh\(\);[\s\S]*?if \(contextMenuClaimOwned[\s\S]*?!sameTabId\(currentTabId, claimedContextMenuTabId\)[\s\S]*?!sameTabId\(renderedTabId, claimedContextMenuTabId\)[\s\S]*?await releaseOwnedContextMenuClaim\(\);[\s\S]*?return false;[\s\S]*?let text = inputEl\.value\.trim\(\);/, + `${label}: claimed prompts should remain bound to their source tab before reading the refreshed composer`, + ); + assert.match( + panel, + /let text = inputEl\.value\.trim\(\);\s*if \(!text\) \{\s*if \(contextMenuClaimOwned\) \{\s*await releaseOwnedContextMenuClaim\(\{ reason: 'preflight-empty', retryAfterMs: 1_000 \}\);\s*return false;/, + `${label}: an empty refreshed composer should release and retry an owned prompt`, + ); assert.match( panel, /if \(isProcessing\) \{\s*if \(contextMenuClaimOwned\) \{\s*await releaseOwnedContextMenuClaim\(\{ reason: 'run-active', retryAfterMs: 1_000 \}\);\s*return false;/, @@ -22004,7 +22014,7 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot const sendMessageBody = panel.slice(sendMessageStart, sendMessageEnd); assert.equal( (sendMessageBody.match(/await releaseOwnedContextMenuClaim\(\);/g) || []).length, - 5, + 6, `${label}: every visibility or preflight-abort exit should release the claim and schedule retry`, ); assert.match(