diff --git a/extension/README.md b/extension/README.md index 4073eba11..c3977237f 100644 --- a/extension/README.md +++ b/extension/README.md @@ -24,3 +24,15 @@ Suggested Chrome Web Store justification for `downloads`: > so agents can wait for downloads triggered during an automation workflow. The > command filters by a user-provided filename or URL pattern and timeout. We do > not modify, redirect, or persist user download history. + +## Browser Tab Groups + +OpenCLI groups browser-session tabs under `OpenCLI Browser` by default. To keep +those tabs ungrouped, open the extension popup and turn off **Group browser +tabs**. The preference is stored in the Chrome profile and takes effect for +existing live OpenCLI groups as well as future browser sessions. + +Chrome does not expose saved tab groups through the extension API. Turning the +setting off prevents new saved `OpenCLI Browser` entries, but previously saved +`OpenCLI Browser` entries and legacy `OpenCLI Adapter` entries must be removed +once from Chrome's saved tab groups bar. diff --git a/extension/dist/background.js b/extension/dist/background.js index c6306aca8..dca4dca5b 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -961,6 +961,7 @@ const IDLE_TIMEOUT_DEFAULT = 3e4; const IDLE_TIMEOUT_INTERACTIVE = 6e5; const IDLE_TIMEOUT_NONE = -1; const REGISTRY_KEY = "opencli_target_lease_registry_v2"; +const BROWSER_TAB_GROUPS_ENABLED_KEY = "opencli_browser_tab_groups_enabled"; const LEASE_IDLE_ALARM_PREFIX = "opencli:lease-idle:"; const CONTAINER_TAB_GROUP_TITLE = { interactive: "OpenCLI Browser", @@ -975,6 +976,17 @@ const ownedContainers = { automation: { windowId: null, groupId: null, promise: null, groupPromise: null } }; const interactiveGroupLedger = /* @__PURE__ */ new Set(); +let browserTabGroupingEnabled = true; +async function loadBrowserTabGroupingPreference() { + try { + const local = chrome.storage?.local; + if (!local) return; + const raw = await local.get(BROWSER_TAB_GROUPS_ENABLED_KEY); + browserTabGroupingEnabled = raw[BROWSER_TAB_GROUPS_ENABLED_KEY] !== false; + } catch { + browserTabGroupingEnabled = true; + } +} class CommandFailure extends Error { constructor(code, message, hint) { super(message); @@ -1356,7 +1368,7 @@ async function createOwnedGroup(role, windowId, ids) { return { id: group.id, windowId: group.windowId, title: group.title }; } async function ensureOwnedContainerGroup(role, fallbackWindowId, tabIds) { - if (role === "automation") return null; + if (role === "automation" || !browserTabGroupingEnabled) return null; const ids = [...new Set(tabIds.filter((id) => id !== void 0))]; const container = ownedContainers[role]; const previousGroupPromise = container.groupPromise ?? Promise.resolve(null); @@ -1367,6 +1379,26 @@ async function ensureOwnedContainerGroup(role, fallbackWindowId, tabIds) { container.groupPromise = trackedGroupPromise; return trackedGroupPromise; } +async function removeOwnedBrowserTabGroups() { + const container = ownedContainers.interactive; + const pendingGroup = container.groupPromise; + if (pendingGroup) await pendingGroup.catch(() => null); + const groups = await chrome.tabGroups.query({ title: CONTAINER_TAB_GROUP_TITLE.interactive }).catch(() => []); + for (const group of groups) { + const tabs = await chrome.tabs.query({ groupId: group.id }).catch(() => []); + const tabIds = tabs.map((tab) => tab.id).filter((id) => id !== void 0); + if (tabIds.length > 0) await chrome.tabs.ungroup(tabIds).catch(() => { + }); + } + container.groupId = null; + interactiveGroupLedger.clear(); + await persistRuntimeState(); +} +async function setBrowserTabGroupingEnabled(enabled) { + await chrome.storage?.local?.set?.({ [BROWSER_TAB_GROUPS_ENABLED_KEY]: enabled }); + browserTabGroupingEnabled = enabled; + if (!enabled) await withLeaseMutation(removeOwnedBrowserTabGroups); +} async function ensureOwnedContainerGroupUnlocked(role, fallbackWindowId, ids) { try { const candidates = await collectOwnedGroupCandidates(role); @@ -1610,7 +1642,9 @@ function initialize() { workerRecovered = false; workerReady = (async () => { await getCurrentContextId(); + await loadBrowserTabGroupingPreference(); await reconcileTargetLeaseRegistry(); + if (!browserTabGroupingEnabled) await removeOwnedBrowserTabGroups(); })().catch((err) => { console.warn(`[opencli] Startup recovery failed: ${err instanceof Error ? err.message : String(err)}`); }).finally(() => { @@ -1640,6 +1674,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === "getStatus") { void (async () => { + await workerReady; const contextId = await getCurrentContextId(); const connected = ws?.readyState === WebSocket.OPEN; const extensionVersion = chrome.runtime.getManifest().version; @@ -1649,11 +1684,28 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { reconnecting: reconnectTimer !== null, contextId, extensionVersion, - daemonVersion + daemonVersion, + browserTabGroupingEnabled }); })(); return true; } + if (msg?.type === "setBrowserTabGrouping" && typeof msg.enabled === "boolean") { + void (async () => { + await workerReady; + try { + await setBrowserTabGroupingEnabled(msg.enabled); + sendResponse({ ok: true, browserTabGroupingEnabled }); + } catch (err) { + sendResponse({ + ok: false, + error: err instanceof Error ? err.message : String(err), + browserTabGroupingEnabled + }); + } + })(); + return true; + } return false; }); async function fetchDaemonVersion() { diff --git a/extension/popup.html b/extension/popup.html index 36527991b..9f7fa1fd4 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -64,6 +64,25 @@ border-top: 1px solid #ececec; background: #fff; } + .setting-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-top: 1px solid #ececec; + background: #fff; + } + .setting-label { + flex: 1; + font-size: 12px; + color: #1d1d1f; + } + .setting-toggle { + width: 16px; + height: 16px; + accent-color: #007aff; + cursor: pointer; + } .profile-label { font-size: 11px; color: #86868b; @@ -134,6 +153,10 @@

OpenCLI

+ diff --git a/extension/popup.js b/extension/popup.js index 323e95cf7..8d42ccaca 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -9,6 +9,22 @@ chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => { const copyBtn = document.getElementById('copyBtn'); const hint = document.getElementById('hint'); const extVersion = document.getElementById('extVersion'); + const browserTabGrouping = document.getElementById('browserTabGrouping'); + + browserTabGrouping.checked = resp?.browserTabGroupingEnabled !== false; + browserTabGrouping.disabled = false; + browserTabGrouping.addEventListener('change', () => { + const enabled = browserTabGrouping.checked; + browserTabGrouping.disabled = true; + chrome.runtime.sendMessage({ type: 'setBrowserTabGrouping', enabled }, (result) => { + if (chrome.runtime.lastError || !result?.ok) { + browserTabGrouping.checked = !enabled; + } else { + browserTabGrouping.checked = result.browserTabGroupingEnabled; + } + browserTabGrouping.disabled = false; + }); + }); if (resp && typeof resp.extensionVersion === 'string') { extVersion.textContent = `v${resp.extensionVersion}`; diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index e45614843..8c5d4867f 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -750,7 +750,10 @@ describe('background tab isolation', () => { it('returns the persisted profile contextId from popup status', async () => { const { chrome } = createChromeMock(); - await chrome.storage.local.set({ opencli_context_id_v1: 'abc123xy' }); + await chrome.storage.local.set({ + opencli_context_id_v1: 'abc123xy', + opencli_browser_tab_groups_enabled: false, + }); vi.stubGlobal('chrome', chrome); await import('./background'); @@ -763,6 +766,7 @@ describe('background tab isolation', () => { await vi.waitFor(() => { expect(sendResponse).toHaveBeenCalledWith(expect.objectContaining({ contextId: 'abc123xy', + browserTabGroupingEnabled: false, })); }); }); @@ -1248,6 +1252,57 @@ describe('background tab isolation', () => { expect(chrome.tabGroups.update).not.toHaveBeenCalled(); }); + it('keeps browser tabs ungrouped when browser tab grouping is disabled', async () => { + const { chrome, tabs, groups } = createChromeMock(); + await chrome.storage.local.set({ opencli_browser_tab_groups_enabled: false }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const tabId = await mod.__test__.resolveTabId(undefined, browserKey('default')); + + expect(tabId).toBe(1); + expect(tabs.find((tab) => tab.id === tabId)?.groupId).toBe(-1); + expect(groups).toEqual([]); + expect(chrome.tabs.group).not.toHaveBeenCalled(); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + }); + + it('ungroups live OpenCLI Browser tabs and prevents new groups when the setting is disabled', async () => { + const { chrome, tabs, groups } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const firstTabId = await mod.__test__.resolveTabId(undefined, browserKey('first')); + expect(tabs.find((tab) => tab.id === firstTabId)?.groupId).toBe(100); + expect(groups).toHaveLength(1); + + const onMessageListener = chrome.runtime.onMessage.addListener.mock.calls[0][0]; + const response = await new Promise((resolve) => { + const keepAlive = onMessageListener( + { type: 'setBrowserTabGrouping', enabled: false }, + {}, + resolve, + ); + expect(keepAlive).toBe(true); + }); + + expect(response).toEqual({ ok: true, browserTabGroupingEnabled: false }); + expect(tabs.find((tab) => tab.id === firstTabId)?.groupId).toBe(-1); + expect(groups).toEqual([]); + expect(mod.__test__.getInteractiveContainer()).toEqual(expect.objectContaining({ + groupId: null, + groupIds: [], + })); + + chrome.tabs.group.mockClear(); + chrome.tabGroups.update.mockClear(); + const secondTabId = await mod.__test__.resolveTabId(undefined, browserKey('second')); + + expect(tabs.find((tab) => tab.id === secondTabId)?.groupId).toBe(-1); + expect(chrome.tabs.group).not.toHaveBeenCalled(); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + }); + it('keeps browser groups while adapter sessions stay ungrouped in separate owned windows', async () => { const { chrome, tabs, groups } = createChromeMock(); let nextWindowId = 20; diff --git a/extension/src/background.ts b/extension/src/background.ts index 4918894c2..a7654d2be 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -299,6 +299,7 @@ const IDLE_TIMEOUT_DEFAULT = 30_000; // 30s — adapter-driven automation const IDLE_TIMEOUT_INTERACTIVE = 600_000; // 10min — human-paced browser:* / operate:* const IDLE_TIMEOUT_NONE = -1; // borrowed bound tabs stay bound until unbound/closed const REGISTRY_KEY = 'opencli_target_lease_registry_v2'; +const BROWSER_TAB_GROUPS_ENABLED_KEY = 'opencli_browser_tab_groups_enabled'; const LEASE_IDLE_ALARM_PREFIX = 'opencli:lease-idle:'; const CONTAINER_TAB_GROUP_TITLE: Record = { interactive: 'OpenCLI Browser', @@ -326,6 +327,18 @@ const ownedContainers: Record(); +let browserTabGroupingEnabled = true; + +async function loadBrowserTabGroupingPreference(): Promise { + try { + const local = chrome.storage?.local; + if (!local) return; + const raw = await local.get(BROWSER_TAB_GROUPS_ENABLED_KEY) as Record; + browserTabGroupingEnabled = raw[BROWSER_TAB_GROUPS_ENABLED_KEY] !== false; + } catch { + browserTabGroupingEnabled = true; + } +} type StoredLease = Omit & { idleDeadlineAt: number; @@ -863,7 +876,7 @@ async function ensureOwnedContainerGroup( // Adapter automation runs in an owned background window but no longer creates // a visible "OpenCLI Adapter" tab group. Its ownership anchors are the // persisted container windowId and per-lease preferredTabId. - if (role === 'automation') return null; + if (role === 'automation' || !browserTabGroupingEnabled) return null; const ids = [...new Set(tabIds.filter((id): id is number => id !== undefined))]; @@ -879,6 +892,29 @@ async function ensureOwnedContainerGroup( return trackedGroupPromise; } +async function removeOwnedBrowserTabGroups(): Promise { + const container = ownedContainers.interactive; + const pendingGroup = container.groupPromise; + if (pendingGroup) await pendingGroup.catch(() => null); + + const groups = await chrome.tabGroups.query({ title: CONTAINER_TAB_GROUP_TITLE.interactive }).catch(() => []); + for (const group of groups) { + const tabs = await chrome.tabs.query({ groupId: group.id }).catch(() => []); + const tabIds = tabs.map((tab) => tab.id).filter((id): id is number => id !== undefined); + if (tabIds.length > 0) await chrome.tabs.ungroup(tabIds).catch(() => {}); + } + + container.groupId = null; + interactiveGroupLedger.clear(); + await persistRuntimeState(); +} + +async function setBrowserTabGroupingEnabled(enabled: boolean): Promise { + await chrome.storage?.local?.set?.({ [BROWSER_TAB_GROUPS_ENABLED_KEY]: enabled }); + browserTabGroupingEnabled = enabled; + if (!enabled) await withLeaseMutation(removeOwnedBrowserTabGroups); +} + async function ensureOwnedContainerGroupUnlocked( role: OwnedWindowRole, fallbackWindowId: number | null, @@ -1212,7 +1248,9 @@ function initialize(): void { workerRecovered = false; workerReady = (async () => { await getCurrentContextId(); + await loadBrowserTabGroupingPreference(); await reconcileTargetLeaseRegistry(); + if (!browserTabGroupingEnabled) await removeOwnedBrowserTabGroups(); })().catch((err) => { // Never leave workerReady rejected/pending: a wedged gate would freeze // every gated handler for the life of the worker. @@ -1258,6 +1296,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === 'getStatus') { void (async () => { + await workerReady; const contextId = await getCurrentContextId(); const connected = ws?.readyState === WebSocket.OPEN; const extensionVersion = chrome.runtime.getManifest().version; @@ -1268,10 +1307,27 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { contextId, extensionVersion, daemonVersion, + browserTabGroupingEnabled, }); })(); return true; } + if (msg?.type === 'setBrowserTabGrouping' && typeof msg.enabled === 'boolean') { + void (async () => { + await workerReady; + try { + await setBrowserTabGroupingEnabled(msg.enabled); + sendResponse({ ok: true, browserTabGroupingEnabled }); + } catch (err) { + sendResponse({ + ok: false, + error: err instanceof Error ? err.message : String(err), + browserTabGroupingEnabled, + }); + } + })(); + return true; + } return false; });