diff --git a/extension/dist/background.js b/extension/dist/background.js index fa085bb6f..cf6eeb49f 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -759,6 +759,7 @@ const CONTAINER_TAB_GROUP_TITLE = { interactive: "OpenCLI Browser", automation: "OpenCLI Adapter" }; +const LEGACY_AUTOMATION_TAB_GROUP_TITLE = "OpenCLI"; const AUTOMATION_TAB_GROUP_COLOR = "orange"; let leaseMutationQueue = Promise.resolve(); const ownedContainers = { @@ -994,11 +995,70 @@ async function getOwnedContainerGroupId(role, windowId) { } container.groupId = null; } - const groups = await chrome.tabGroups.query({ windowId, title: CONTAINER_TAB_GROUP_TITLE[role] }); - const existing = groups[0]; - if (!existing) return null; - container.groupId = existing.id; - return existing.id; + for (const title of getOwnedContainerGroupTitles(role)) { + const groups = await chrome.tabGroups.query({ windowId, title }); + const existing = groups[0]; + if (existing) { + container.groupId = existing.id; + return existing.id; + } + } + return null; +} +function getOwnedContainerGroupTitles(role) { + return role === "automation" ? [CONTAINER_TAB_GROUP_TITLE.automation, LEGACY_AUTOMATION_TAB_GROUP_TITLE] : [CONTAINER_TAB_GROUP_TITLE.interactive]; +} +async function focusOwnedWindowIfRequested(windowId, mode) { + if (mode !== "foreground") return; + const updateWindow = chrome.windows.update; + if (typeof updateWindow === "function") await updateWindow(windowId, { focused: true }).catch(() => { + }); +} +async function toOwnedContainerDiscoveryCandidate(group) { + try { + const chromeWindow = await chrome.windows.get(group.windowId); + const reusableTabId = await findReusableOwnedContainerTab(group.windowId); + return { + windowId: group.windowId, + groupId: group.id, + focused: !!chromeWindow.focused, + hasReusableTab: reusableTabId !== void 0 + }; + } catch { + return null; + } +} +function selectOwnedContainerDiscoveryCandidate(candidates) { + if (candidates.length === 0) return null; + return [...candidates].sort((a, b) => { + if (a.focused !== b.focused) return a.focused ? -1 : 1; + if (a.hasReusableTab !== b.hasReusableTab) return a.hasReusableTab ? -1 : 1; + return a.groupId - b.groupId; + })[0]; +} +async function discoverOwnedContainerFromTabGroup(role) { + const container = ownedContainers[role]; + if (container.groupId !== null) { + try { + const group = await chrome.tabGroups.get(container.groupId); + await chrome.windows.get(group.windowId); + container.windowId = group.windowId; + return { windowId: group.windowId, groupId: group.id }; + } catch { + container.windowId = null; + container.groupId = null; + } + } + for (const title of getOwnedContainerGroupTitles(role)) { + const groups = await chrome.tabGroups.query({ title }); + const candidates = (await Promise.all(groups.map(toOwnedContainerDiscoveryCandidate))).filter((candidate) => candidate !== null); + const selected = selectOwnedContainerDiscoveryCandidate(candidates); + if (!selected) continue; + container.windowId = selected.windowId; + container.groupId = selected.groupId; + return { windowId: selected.windowId, groupId: selected.groupId }; + } + return null; } async function ensureOwnedContainerTabGroup(role, windowId, tabIds) { const ids = [...new Set(tabIds.filter((id) => id !== void 0))]; @@ -1038,11 +1098,7 @@ async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "back if (container.windowId !== null) { try { await chrome.windows.get(container.windowId); - if (mode === "foreground") { - const updateWindow = chrome.windows.update; - if (typeof updateWindow === "function") await updateWindow(container.windowId, { focused: true }).catch(() => { - }); - } + await focusOwnedWindowIfRequested(container.windowId, mode); const initialTabId2 = await findReusableOwnedContainerTab(container.windowId); await ensureOwnedContainerTabGroup(role, container.windowId, [initialTabId2]); return { @@ -1054,6 +1110,17 @@ async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "back container.groupId = null; } } + const discovered = await discoverOwnedContainerFromTabGroup(role); + if (discovered) { + await focusOwnedWindowIfRequested(discovered.windowId, mode); + const initialTabId2 = await findReusableOwnedContainerTab(discovered.windowId); + await ensureOwnedContainerTabGroup(role, discovered.windowId, [initialTabId2]); + await persistRuntimeState(); + return { + windowId: discovered.windowId, + initialTabId: initialTabId2 + }; + } const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; const win = await chrome.windows.create({ url: startUrl, diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index 551db2162..7ab75bb81 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -158,7 +158,7 @@ function createChromeMock() { onEvent: { addListener: vi.fn() } as Listener<(source: any, method: string, params: any) => void>, }, windows: { - get: vi.fn(async (windowId: number) => ({ id: windowId })), + get: vi.fn(async (windowId: number) => ({ id: windowId, focused: windowId === lastFocusedWindowId })), create: vi.fn(async ({ url, focused, width, height, type }: any) => ({ id: 1, url, focused, width, height, type })), remove: vi.fn(async (_windowId: number) => {}), onRemoved: { addListener: vi.fn() } as Listener<(windowId: number) => void>, @@ -187,7 +187,15 @@ function createChromeMock() { }, }; - return { chrome, tabs, groups, query, create, update }; + return { + chrome, + tabs, + groups, + query, + create, + update, + setLastFocusedWindowId: (windowId: number) => { lastFocusedWindowId = windowId; }, + }; } describe('background tab isolation', () => { @@ -908,7 +916,7 @@ describe('background tab isolation', () => { const { chrome } = createChromeMock(); chrome.windows.get = vi.fn(async (windowId: number) => { if (windowId === 90 || windowId === 91) throw new Error(`stale window ${windowId}`); - return { id: windowId }; + return { id: windowId, focused: false }; }); vi.stubGlobal('chrome', chrome); @@ -1050,6 +1058,166 @@ describe('background tab isolation', () => { expect(chrome.tabGroups.update).not.toHaveBeenCalled(); }); + it('discovers and reuses an existing OpenCLI Adapter group in another window before creating one', async () => { + const { chrome, tabs, groups } = createChromeMock(); + tabs.push({ + id: 77, + windowId: 7, + url: 'about:blank', + title: 'blank', + active: true, + status: 'complete', + groupId: -1, + }); + groups.push({ + id: 99, + windowId: 7, + title: 'OpenCLI Adapter', + color: 'orange', + collapsed: true, + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const tabId = await mod.__test__.resolveTabId(undefined, adapterKey('twitter')); + + expect(tabId).toBe(77); + expect(chrome.windows.create).not.toHaveBeenCalled(); + expect(mod.__test__.getAutomationWindowId(adapterKey('twitter'))).toBe(7); + expect(tabs.find((tab) => tab.id === 77)?.groupId).toBe(99); + expect(chrome.tabs.group).toHaveBeenCalledWith({ groupId: 99, tabIds: [77] }); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + }); + + it('prefers a focused OpenCLI Adapter group when multiple matching groups exist', async () => { + const { chrome, tabs, groups, setLastFocusedWindowId } = createChromeMock(); + setLastFocusedWindowId(8); + tabs.push({ + id: 77, + windowId: 7, + url: 'about:blank', + title: 'blank', + active: true, + status: 'complete', + groupId: -1, + }); + tabs.push({ + id: 78, + windowId: 8, + url: 'about:blank', + title: 'blank', + active: true, + status: 'complete', + groupId: -1, + }); + groups.push( + { + id: 99, + windowId: 7, + title: 'OpenCLI Adapter', + color: 'orange', + collapsed: true, + }, + { + id: 98, + windowId: 8, + title: 'OpenCLI Adapter', + color: 'orange', + collapsed: true, + }, + ); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const tabId = await mod.__test__.resolveTabId(undefined, adapterKey('twitter')); + + expect(tabId).toBe(78); + expect(chrome.windows.create).not.toHaveBeenCalled(); + expect(mod.__test__.getAutomationWindowId(adapterKey('twitter'))).toBe(8); + expect(tabs.find((tab) => tab.id === 78)?.groupId).toBe(98); + expect(chrome.tabs.group).toHaveBeenCalledWith({ groupId: 98, tabIds: [78] }); + }); + + it('prefers an OpenCLI Adapter group with a reusable debuggable tab when none are focused', async () => { + const { chrome, tabs, groups, setLastFocusedWindowId } = createChromeMock(); + setLastFocusedWindowId(2); + tabs.push({ + id: 77, + windowId: 7, + url: 'chrome://settings', + title: 'settings', + active: true, + status: 'complete', + groupId: -1, + }); + tabs.push({ + id: 78, + windowId: 8, + url: 'about:blank', + title: 'blank', + active: true, + status: 'complete', + groupId: -1, + }); + groups.push( + { + id: 97, + windowId: 7, + title: 'OpenCLI Adapter', + color: 'orange', + collapsed: true, + }, + { + id: 98, + windowId: 8, + title: 'OpenCLI Adapter', + color: 'orange', + collapsed: true, + }, + ); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const tabId = await mod.__test__.resolveTabId(undefined, adapterKey('twitter')); + + expect(tabId).toBe(78); + expect(chrome.windows.create).not.toHaveBeenCalled(); + expect(mod.__test__.getAutomationWindowId(adapterKey('twitter'))).toBe(8); + expect(tabs.find((tab) => tab.id === 78)?.groupId).toBe(98); + expect(chrome.tabs.group).toHaveBeenCalledWith({ groupId: 98, tabIds: [78] }); + }); + + it('discovers and reuses a legacy OpenCLI automation group before creating a duplicate', async () => { + const { chrome, tabs, groups } = createChromeMock(); + tabs.push({ + id: 78, + windowId: 8, + url: 'about:blank', + title: 'blank', + active: true, + status: 'complete', + groupId: -1, + }); + groups.push({ + id: 98, + windowId: 8, + title: 'OpenCLI', + color: 'orange', + collapsed: true, + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const tabId = await mod.__test__.resolveTabId(undefined, adapterKey('twitter')); + + expect(tabId).toBe(78); + expect(chrome.windows.create).not.toHaveBeenCalled(); + expect(mod.__test__.getAutomationWindowId(adapterKey('twitter'))).toBe(8); + expect(tabs.find((tab) => tab.id === 78)?.groupId).toBe(98); + expect(chrome.tabs.group).toHaveBeenCalledWith({ groupId: 98, tabIds: [78] }); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + }); + it('reuses a persisted automation group id after service worker restart even if the user renamed it', async () => { const { chrome, tabs, groups } = createChromeMock(); groups.push({ diff --git a/extension/src/background.ts b/extension/src/background.ts index 2925c13bc..57f51e4c0 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -204,6 +204,7 @@ const CONTAINER_TAB_GROUP_TITLE: Record = { interactive: 'OpenCLI Browser', automation: 'OpenCLI Adapter', }; +const LEGACY_AUTOMATION_TAB_GROUP_TITLE = 'OpenCLI'; const AUTOMATION_TAB_GROUP_COLOR: chrome.tabGroups.ColorEnum = 'orange'; let leaseMutationQueue: Promise = Promise.resolve(); const ownedContainers: Record { + if (mode !== 'foreground') return; + const updateWindow = (chrome.windows as unknown as { update?: (windowId: number, updateInfo: { focused?: boolean }) => Promise }).update; + if (typeof updateWindow === 'function') await updateWindow(windowId, { focused: true }).catch(() => {}); +} + +async function toOwnedContainerDiscoveryCandidate(group: chrome.tabGroups.TabGroup): Promise { + try { + const chromeWindow = await chrome.windows.get(group.windowId); + const reusableTabId = await findReusableOwnedContainerTab(group.windowId); + return { + windowId: group.windowId, + groupId: group.id, + focused: !!chromeWindow.focused, + hasReusableTab: reusableTabId !== undefined, + }; + } catch { + // Ignore stale browser-session group/window state and keep looking. + return null; + } +} + +function selectOwnedContainerDiscoveryCandidate(candidates: OwnedContainerDiscoveryCandidate[]): OwnedContainerDiscoveryCandidate | null { + if (candidates.length === 0) return null; + return [...candidates].sort((a, b) => { + if (a.focused !== b.focused) return a.focused ? -1 : 1; + if (a.hasReusableTab !== b.hasReusableTab) return a.hasReusableTab ? -1 : 1; + return a.groupId - b.groupId; + })[0]; +} + +async function discoverOwnedContainerFromTabGroup(role: OwnedWindowRole): Promise<{ windowId: number; groupId: number } | null> { + const container = ownedContainers[role]; + if (container.groupId !== null) { + try { + const group = await chrome.tabGroups.get(container.groupId); + await chrome.windows.get(group.windowId); + container.windowId = group.windowId; + return { windowId: group.windowId, groupId: group.id }; + } catch { + container.windowId = null; + container.groupId = null; + } + } + + for (const title of getOwnedContainerGroupTitles(role)) { + const groups = await chrome.tabGroups.query({ title }); + const candidates = (await Promise.all(groups.map(toOwnedContainerDiscoveryCandidate))) + .filter((candidate): candidate is OwnedContainerDiscoveryCandidate => candidate !== null); + const selected = selectOwnedContainerDiscoveryCandidate(candidates); + if (!selected) continue; + container.windowId = selected.windowId; + container.groupId = selected.groupId; + return { windowId: selected.windowId, groupId: selected.groupId }; + } + + return null; } async function ensureOwnedContainerTabGroup(role: OwnedWindowRole, windowId: number, tabIds: Array): Promise { @@ -560,10 +637,7 @@ async function ensureOwnedContainerWindowUnlocked( if (container.windowId !== null) { try { await chrome.windows.get(container.windowId); - if (mode === 'foreground') { - const updateWindow = (chrome.windows as unknown as { update?: (windowId: number, updateInfo: { focused?: boolean }) => Promise }).update; - if (typeof updateWindow === 'function') await updateWindow(container.windowId, { focused: true }).catch(() => {}); - } + await focusOwnedWindowIfRequested(container.windowId, mode); const initialTabId = await findReusableOwnedContainerTab(container.windowId); await ensureOwnedContainerTabGroup(role, container.windowId, [initialTabId]); return { @@ -576,6 +650,18 @@ async function ensureOwnedContainerWindowUnlocked( } } + const discovered = await discoverOwnedContainerFromTabGroup(role); + if (discovered) { + await focusOwnedWindowIfRequested(discovered.windowId, mode); + const initialTabId = await findReusableOwnedContainerTab(discovered.windowId); + await ensureOwnedContainerTabGroup(role, discovered.windowId, [initialTabId]); + await persistRuntimeState(); + return { + windowId: discovered.windowId, + initialTabId, + }; + } + const startUrl = (initialUrl && isSafeNavigationUrl(initialUrl)) ? initialUrl : BLANK_PAGE; // Note: Do NOT set `state` parameter here. Chrome 146+ rejects 'normal' as an invalid