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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions src/chrome/src/ui/sidepanel-window-scope.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
function sameTabId(a, b) {
if (a == null || b == null) return false;
return String(a) === String(b);
}

/**
* Keep one side-panel document scoped to the browser window that currently
* contains it. Tab activation events are extension-wide, and a browser can keep
* the same panel document alive while its tab/group is dragged to a different
* window, so a window ID captured once at startup eventually becomes stale.
*/
export function createSidePanelWindowScope({
browserApi,
initialWindowId = null,
getCurrentTabId,
getRenderedTabId,
switchToTab,
settleWindowTransfer = () => new Promise(resolve => setTimeout(resolve, 0)),
}) {
let ownWindowId = initialWindowId;
let tabTransfer = null;
let syncGeneration = 0;

async function refreshOwnWindowId() {
try {
const ownWindow = await browserApi.windows.getCurrent();
if (ownWindow?.id != null) ownWindowId = ownWindow.id;
} catch {
// Keep the last confirmed ID. A transient windows API failure should
// not make an unrelated window eligible to control this panel.
}
return ownWindowId;
}

async function syncActiveTab({ expectedWindowId = null, expectedTabId = null } = {}) {
const windowId = await refreshOwnWindowId();
if (windowId == null) return null;
if (expectedWindowId != null && expectedWindowId !== windowId) return null;
// Only a confirmed event from this panel's window supersedes an older
// in-flight sync. Noise from another browser window must not cancel it.
const generation = ++syncGeneration;

let activeTab = null;
try {
[activeTab] = await browserApi.tabs.query({ active: true, windowId });
} catch {
return null;
}
if (generation !== syncGeneration || !activeTab?.id) return null;
if (activeTab.windowId != null && activeTab.windowId !== windowId) return null;
if (expectedTabId != null && !sameTabId(activeTab.id, expectedTabId)) return null;

await switchToTab(activeTab.id);
return activeTab;
}

function handleDetached(tabId, detachInfo = {}) {
if (!sameTabId(tabId, getCurrentTabId()) && !sameTabId(tabId, getRenderedTabId())) {
return false;
}
tabTransfer = {
tabId,
oldWindowId: detachInfo.oldWindowId ?? ownWindowId,
};
// The cached owner is intentionally invalid during transfer. If the live
// windows lookup fails after attach, fail closed instead of falling back
// to the window the panel just left.
ownWindowId = null;
// Invalidate an activation lookup that may have started just before the
// detach event. Its result belongs to the window the panel is leaving.
syncGeneration += 1;
return true;
}

async function handleAttached(tabId) {
if (!tabTransfer || !sameTabId(tabId, tabTransfer.tabId)) return null;
const transfer = tabTransfer;
// Let the browser finish reparenting the side-panel document before
// asking getCurrent() where it lives. During this turn, old-window
// activation events remain suppressed by tabTransfer.
await settleWindowTransfer();
if (tabTransfer !== transfer) return null;
tabTransfer = null;
// getCurrent() tells us whether the browser moved this panel document with
// the tab or kept it in the old window. Follow the actual panel location.
return await syncActiveTab();
}

async function handleActivated(info = {}) {
if (tabTransfer && info.windowId === tabTransfer.oldWindowId) return null;
return await syncActiveTab({
expectedWindowId: info.windowId,
expectedTabId: info.tabId,
});
}

async function handleFocusChanged(windowId, windowIdNone) {
if (windowId === windowIdNone) return null;
if (tabTransfer && windowId === tabTransfer.oldWindowId) return null;
return await syncActiveTab({ expectedWindowId: windowId });
}

return {
syncActiveTab,
handleDetached,
handleAttached,
handleActivated,
handleFocusChanged,
};
}
38 changes: 24 additions & 14 deletions src/chrome/src/ui/sidepanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from './store-review-prompt.js';
import { providerIconUrl } from './provider-icons.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 <head> bootstrap
// only sees localStorage; if the user changes the theme on another device
Expand Down Expand Up @@ -3346,29 +3347,41 @@ function clearScratchpad(tabId = currentTabId) {
// --- Initialization ---

async function init() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const initialWindow = await chrome.windows.getCurrent().catch(() => null);
const initialWindowId = initialWindow?.id ?? null;
const [tab] = await chrome.tabs.query(initialWindowId != null
? { active: true, windowId: initialWindowId }
: { active: true, currentWindow: true });
currentTabId = tab?.id;
renderedTabId = currentTabId;

// Tab-activation and window-focus events are extension-wide — every
// browser window fires them, and each window has its own side panel
// instance. Without scoping, activity in window B would silently
// retarget window A's panel to B's tab.
const ownWindowId = tab?.windowId ?? (await chrome.windows.getCurrent()).id;
const windowScope = createSidePanelWindowScope({
browserApi: chrome,
initialWindowId: initialWindowId ?? tab?.windowId ?? null,
getCurrentTabId: () => currentTabId,
getRenderedTabId: () => renderedTabId,
switchToTab,
});

chrome.tabs.onActivated.addListener(async (info) => {
if (info.windowId !== ownWindowId) return;
switchToTab(info.tabId);
await windowScope.handleActivated(info);
});

chrome.tabs.onDetached.addListener((tabId, detachInfo) => {
windowScope.handleDetached(tabId, detachInfo);
});

chrome.tabs.onAttached.addListener(async (tabId, attachInfo) => {
await windowScope.handleAttached(tabId, attachInfo);
});

// Also handle window focus changes
chrome.windows.onFocusChanged.addListener(async (windowId) => {
if (windowId === chrome.windows.WINDOW_ID_NONE) return;
if (windowId !== ownWindowId) return;
const [tab] = await chrome.tabs.query({ active: true, windowId });
if (tab?.id && tab.id !== currentTabId) {
switchToTab(tab.id);
}
await windowScope.handleFocusChanged(windowId, chrome.windows.WINDOW_ID_NONE);
});

chrome.tabs.onUpdated?.addListener?.((tabId, changeInfo) => {
Expand Down Expand Up @@ -3403,10 +3416,7 @@ async function init() {

await loadProviders();
await testConnection({ skipWebBrainCloud: true });
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (activeTab?.id && activeTab.id !== currentTabId) {
await switchToTab(activeTab.id);
}
await windowScope.syncActiveTab();
refreshScheduledJobs({ tabId: currentTabId });
refreshRecommendedActions();
await consumePendingContextMenuPrompt();
Expand Down
110 changes: 110 additions & 0 deletions src/firefox/src/ui/sidepanel-window-scope.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
function sameTabId(a, b) {
if (a == null || b == null) return false;
return String(a) === String(b);
}

/**
* Keep one side-panel document scoped to the browser window that currently
* contains it. Tab activation events are extension-wide, and a browser can keep
* the same panel document alive while its tab/group is dragged to a different
* window, so a window ID captured once at startup eventually becomes stale.
*/
export function createSidePanelWindowScope({
browserApi,
initialWindowId = null,
getCurrentTabId,
getRenderedTabId,
switchToTab,
settleWindowTransfer = () => new Promise(resolve => setTimeout(resolve, 0)),
}) {
let ownWindowId = initialWindowId;
let tabTransfer = null;
let syncGeneration = 0;

async function refreshOwnWindowId() {
try {
const ownWindow = await browserApi.windows.getCurrent();
if (ownWindow?.id != null) ownWindowId = ownWindow.id;
} catch {
// Keep the last confirmed ID. A transient windows API failure should
// not make an unrelated window eligible to control this panel.
}
return ownWindowId;
}

async function syncActiveTab({ expectedWindowId = null, expectedTabId = null } = {}) {
const windowId = await refreshOwnWindowId();
if (windowId == null) return null;
if (expectedWindowId != null && expectedWindowId !== windowId) return null;
// Only a confirmed event from this panel's window supersedes an older
// in-flight sync. Noise from another browser window must not cancel it.
const generation = ++syncGeneration;

let activeTab = null;
try {
[activeTab] = await browserApi.tabs.query({ active: true, windowId });
} catch {
return null;
}
if (generation !== syncGeneration || !activeTab?.id) return null;
if (activeTab.windowId != null && activeTab.windowId !== windowId) return null;
if (expectedTabId != null && !sameTabId(activeTab.id, expectedTabId)) return null;

await switchToTab(activeTab.id);
return activeTab;
}

function handleDetached(tabId, detachInfo = {}) {
if (!sameTabId(tabId, getCurrentTabId()) && !sameTabId(tabId, getRenderedTabId())) {
return false;
}
tabTransfer = {
tabId,
oldWindowId: detachInfo.oldWindowId ?? ownWindowId,
};
// The cached owner is intentionally invalid during transfer. If the live
// windows lookup fails after attach, fail closed instead of falling back
// to the window the panel just left.
ownWindowId = null;
// Invalidate an activation lookup that may have started just before the
// detach event. Its result belongs to the window the panel is leaving.
syncGeneration += 1;
return true;
}

async function handleAttached(tabId) {
if (!tabTransfer || !sameTabId(tabId, tabTransfer.tabId)) return null;
const transfer = tabTransfer;
// Let the browser finish reparenting the side-panel document before
// asking getCurrent() where it lives. During this turn, old-window
// activation events remain suppressed by tabTransfer.
await settleWindowTransfer();
if (tabTransfer !== transfer) return null;
tabTransfer = null;
// getCurrent() tells us whether the browser moved this panel document with
// the tab or kept it in the old window. Follow the actual panel location.
return await syncActiveTab();
}

async function handleActivated(info = {}) {
if (tabTransfer && info.windowId === tabTransfer.oldWindowId) return null;
return await syncActiveTab({
expectedWindowId: info.windowId,
expectedTabId: info.tabId,
});
}

async function handleFocusChanged(windowId, windowIdNone) {
if (windowId === windowIdNone) return null;
if (tabTransfer && windowId === tabTransfer.oldWindowId) return null;
return await syncActiveTab({ expectedWindowId: windowId });
}

return {
syncActiveTab,
handleDetached,
handleAttached,
handleActivated,
handleFocusChanged,
};
}
31 changes: 23 additions & 8 deletions src/firefox/src/ui/sidepanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from './store-review-prompt.js';
import { providerIconUrl } from './provider-icons.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 <head> bootstrap
// only sees localStorage; if the user changes the theme on another device
Expand Down Expand Up @@ -3203,19 +3204,36 @@ function clearScratchpad(tabId = currentTabId) {
// --- Initialization ---

async function init() {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
const initialWindow = await browser.windows.getCurrent().catch(() => null);
const initialWindowId = initialWindow?.id ?? null;
const [tab] = await browser.tabs.query(initialWindowId != null
? { active: true, windowId: initialWindowId }
: { active: true, currentWindow: true });
currentTabId = tab?.id;
renderedTabId = currentTabId;

// Tab-activation events are extension-wide — every browser window fires
// them, and each window has its own side panel instance. Without
// scoping, activity in window B would silently retarget window A's
// panel to B's tab.
const ownWindowId = tab?.windowId ?? (await browser.windows.getCurrent()).id;
const windowScope = createSidePanelWindowScope({
browserApi: browser,
initialWindowId: initialWindowId ?? tab?.windowId ?? null,
getCurrentTabId: () => currentTabId,
getRenderedTabId: () => renderedTabId,
switchToTab,
});

browser.tabs.onActivated.addListener(async (info) => {
if (info.windowId !== ownWindowId) return;
await switchToTab(info.tabId);
await windowScope.handleActivated(info);
});

browser.tabs.onDetached.addListener((tabId, detachInfo) => {
windowScope.handleDetached(tabId, detachInfo);
});

browser.tabs.onAttached.addListener(async (tabId, attachInfo) => {
await windowScope.handleAttached(tabId, attachInfo);
});

browser.tabs.onUpdated?.addListener?.((tabId, changeInfo) => {
Expand Down Expand Up @@ -3250,10 +3268,7 @@ async function init() {

await loadProviders();
await testConnection({ skipWebBrainCloud: true });
const [activeTab] = await browser.tabs.query({ active: true, currentWindow: true });
if (activeTab?.id && activeTab.id !== currentTabId) {
await switchToTab(activeTab.id);
}
await windowScope.syncActiveTab();
refreshScheduledJobs({ tabId: currentTabId });
refreshRecommendedActions();
await consumePendingContextMenuPrompt();
Expand Down
Loading
Loading