Skip to content
Open
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
12 changes: 12 additions & 0 deletions extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
56 changes: 54 additions & 2 deletions extension/dist/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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;
Expand All @@ -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() {
Expand Down
23 changes: 23 additions & 0 deletions extension/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -134,6 +153,10 @@ <h1>OpenCLI</h1>
<span class="profile-id" id="contextId"></span>
<button type="button" class="copy-btn" id="copyBtn" title="Copy contextId">Copy</button>
</div>
<label class="setting-row" for="browserTabGrouping">
<span class="setting-label">Group browser tabs</span>
<input class="setting-toggle" id="browserTabGrouping" type="checkbox" disabled>
</label>
<div class="hint" id="hint" style="display: none;">
The extension connects automatically when you run any <code>opencli</code> command.
</div>
Expand Down
16 changes: 16 additions & 0 deletions extension/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
57 changes: 56 additions & 1 deletion extension/src/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -763,6 +766,7 @@ describe('background tab isolation', () => {
await vi.waitFor(() => {
expect(sendResponse).toHaveBeenCalledWith(expect.objectContaining({
contextId: 'abc123xy',
browserTabGroupingEnabled: false,
}));
});
});
Expand Down Expand Up @@ -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;
Expand Down
58 changes: 57 additions & 1 deletion extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OwnedWindowRole, string> = {
interactive: 'OpenCLI Browser',
Expand Down Expand Up @@ -326,6 +327,18 @@ const ownedContainers: Record<OwnedWindowRole, {
// creates a visible group. Persisted as part of the session registry (see
// StoredRegistry) and restored by reconcileTargetLeaseRegistry().
const interactiveGroupLedger = new Set<number>();
let browserTabGroupingEnabled = true;

async function loadBrowserTabGroupingPreference(): Promise<void> {
try {
const local = chrome.storage?.local;
if (!local) return;
const raw = await local.get(BROWSER_TAB_GROUPS_ENABLED_KEY) as Record<string, unknown>;
browserTabGroupingEnabled = raw[BROWSER_TAB_GROUPS_ENABLED_KEY] !== false;
} catch {
browserTabGroupingEnabled = true;
}
}

type StoredLease = Omit<TargetLease, 'idleTimer' | 'idleDeadlineAt'> & {
idleDeadlineAt: number;
Expand Down Expand Up @@ -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))];

Expand All @@ -879,6 +892,29 @@ async function ensureOwnedContainerGroup(
return trackedGroupPromise;
}

async function removeOwnedBrowserTabGroups(): Promise<void> {
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<void> {
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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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;
});

Expand Down
Loading