aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-06-22 19:55:46 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-06-22 20:05:59 -0400
commitc1ed934dd20931b177e233ae0327a290a8001631 (patch)
tree384d7747031249f2f09fcfbbd5062ed175d435d0
parentb8165967408be47e93b6a566270988177f8f6866 (diff)
Keep Pi-created tabs in one session group
-rw-r--r--CHANGELOG.md1
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/service_worker.js32
-rw-r--r--extensions/chrome-profile-bridge/index.ts27
-rw-r--r--test-suite/unit/automation-target.test.mjs47
4 files changed, 88 insertions, 19 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0341dfd..eae1975 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ All notable user-facing changes to `pi-chrome`.
- **Survives `/reload` and Chrome service-worker restarts.** Ownership is tracked by id and mirrored to `chrome.storage.session`, so the window is reused rather than orphaned.
- **Guarded tab management.** `chrome_tab` `activate`/`close`/`group`/`ungroup` with no target now act on the session's automation tab if present, otherwise error — they no longer fall back to (or spawn a throwaway tab to touch) your active tab.
- **Safe, non-blocking cleanup.** The owned target is closed on `/chrome revoke` and on real session end (never on `/reload`); cleanup only ever closes the calling session's own window/tab — never user tabs/windows or another session's target — and is fire-and-forget so it never blocks `/quit`, `/reload`, or session end.
+- **One session, one tab group.** `chrome_tab new` now reuses this session's existing Pi tab group even when another Chrome window is focused, creating the tab in that group's window because Chrome tab groups cannot span windows. The old `group:false` / `groupTitle:""` opt-out is ignored; Pi-created tabs are never intentionally left ungrouped, and a grouping failure closes the just-created tab before returning an error.
## 0.15.38 — 2026-06-07
diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
index c393090..2b5f96f 100644
--- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js
+++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
@@ -1073,8 +1073,10 @@ async function groupRecord(groupId) {
};
}
-// Find an existing tab group in `windowId` whose title matches `title` (case-insensitive).
-// Used so all Pi-opened tabs collect into one group per window instead of spawning new ones.
+// Find existing tab groups whose title matches `title` (case-insensitive).
+// Same-window lookup is used when grouping an already-created tab. Any-window lookup is used before
+// creating a new Pi tab so one Pi session keeps one tab group and new tabs are created in that
+// group's window (Chrome tab groups cannot span windows).
async function findGroupByTitle(windowId, title) {
if (!chrome.tabGroups) return null;
const wanted = cleanGroupTitle(title).toLowerCase();
@@ -1083,6 +1085,13 @@ async function findGroupByTitle(windowId, title) {
return match ? match.id : null;
}
+async function findGroupRecordByTitle(title) {
+ if (!chrome.tabGroups) return null;
+ const wanted = cleanGroupTitle(title).toLowerCase();
+ const groups = await chrome.tabGroups.query({}).catch(() => []);
+ return groups.find((g) => (g.title || "").trim().toLowerCase() === wanted) || null;
+}
+
// Add `tab` to a tab group, then set title/color. If the tab is ungrouped, reuse an
// existing same-title group in its window when present, otherwise create a new group.
async function groupTab(tab, title, color) {
@@ -1115,11 +1124,20 @@ async function dispatch(action, params) {
return Promise.all(tabs.map(formatTab));
}
case "tab.new": {
- const tab = await chrome.tabs.create({ url: params.url || "about:blank", active: true });
- // Every Pi-opened tab joins a group by default. Pass groupTitle:"" (or group:false) to opt out.
- const optOut = params.groupTitle === "" || params.group === false;
- if (optOut && !params.groupColor) return formatTab(tab);
- return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
+ // Every Pi-opened tab must join a tab group. There is intentionally no opt-out: an ungrouped
+ // Pi-created tab is easy to lose among user tabs. If grouping fails after creation, close the
+ // tab best-effort before surfacing the error so tab.new never leaves an ungrouped Pi tab.
+ const groupTitle = params.groupTitle || "Pi";
+ const existingGroup = await findGroupRecordByTitle(groupTitle);
+ const createParams = { url: params.url || "about:blank", active: true };
+ if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
+ const tab = await chrome.tabs.create(createParams);
+ try {
+ return await groupTab(tab, groupTitle, params.groupColor);
+ } catch (error) {
+ if (typeof tab.id === "number") await chrome.tabs.remove(tab.id).catch(() => {});
+ throw error;
+ }
}
case "tab.activate": {
// Management actions never auto-create an automation target (createOwnedTarget:false): with
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index 7690f25..e626bef 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -844,13 +844,20 @@ export default function (pi: ExtensionAPI): void {
let wireParams: Record<string, unknown> = sessionKey !== undefined && params.sessionKey === undefined
? { ...params, sessionKey }
: params;
+ const sessionTitle = sessionCtx !== undefined ? sessionGroupTitle(sessionCtx) : undefined;
+ // Any tab Pi opens through tab.new/tab.group must use THIS session's group, even if a caller
+ // passes group:false or a custom groupTitle. This central guard covers chrome_tab plus internal
+ // callers such as chrome_launch(url).
+ if ((action === "tab.new" || action === "tab.group") && sessionTitle !== undefined) {
+ wireParams = { ...wireParams, groupTitle: sessionTitle };
+ }
// Any tab Pi *uses* (page.* interactions) should join this session's group, mirroring the
// auto-grouping that tab.new already does. Tagging the wire params lets getTabByParams pull
// the resolved tab into the session group on the service-worker side. We skip tab.* actions:
- // tab.new/group group explicitly, and activate/close/ungroup/list must not.
- const shouldJoinGroup = action.startsWith("page.") && sessionCtx !== undefined && params.sessionGroupTitle === undefined;
+ // tab.new/group are forced above, and activate/close/ungroup/list must not group tabs.
+ const shouldJoinGroup = action.startsWith("page.") && sessionTitle !== undefined && params.sessionGroupTitle === undefined;
if (shouldJoinGroup) {
- wireParams = { ...wireParams, sessionGroupTitle: sessionGroupTitle(sessionCtx as ExtensionContext), joinSessionGroup: true };
+ wireParams = { ...wireParams, sessionGroupTitle: sessionTitle, joinSessionGroup: true };
}
return bridge.send(action, wireParams, timeoutMs, signal);
};
@@ -1279,7 +1286,7 @@ Usage rules:
pi.registerTool({
name: "chrome_tab",
label: "Chrome Tab",
- description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension. activate/close/group/ungroup require a target (targetId/urlIncludes/titleIncludes); with no target they act on this session's pi-chrome automation tab if one exists, and otherwise error rather than touching the user's active tab.",
+ description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension. New/grouped tabs always use this session's Pi tab group. activate/close/group/ungroup require a target (targetId/urlIncludes/titleIncludes); with no target they act on this session's pi-chrome automation tab if one exists, and otherwise error rather than touching the user's active tab.",
promptSnippet: "List/open/activate/close/group existing Chrome tabs through the companion extension.",
parameters: Type.Object({
action: StringEnum(tabActionValues),
@@ -1287,18 +1294,18 @@ Usage rules:
targetId: Type.Optional(Type.String({ description: "Chrome tab id for activate/close/group/ungroup." })),
urlIncludes: Type.Optional(Type.String({ description: "Match the target tab by URL substring for activate/close/group/ungroup." })),
titleIncludes: Type.Optional(Type.String({ description: "Match the target tab by title substring for activate/close/group/ungroup." })),
- group: Type.Optional(Type.Boolean({ description: "action=new only: pass false to open an ungrouped tab. By default every Pi-opened tab joins this session's own tab group." })),
- groupTitle: Type.Optional(Type.String({ description: "Tab group title for action=group/new. Defaults to this Pi session's group ('Pi Session: <name-or-id>'). Pass an empty string on action=new to opt out of grouping." })),
+ group: Type.Optional(Type.Boolean({ description: "Deprecated; ignored. Pi-created tabs always join this session's own tab group." })),
+ groupTitle: Type.Optional(Type.String({ description: "Deprecated for action=new/group; ignored so one Pi session uses one tab group ('Pi Session: <name-or-id>')." })),
groupColor: Type.Optional(Type.String({ description: "Tab group color for action=group/new: grey, blue, red, yellow, green, pink, purple, cyan, or orange. Defaults to blue." })),
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
const forwarded = { ...params } as typeof params & { groupTitle?: string };
- // Default every Pi-opened/explicitly-grouped tab into this session's own group,
- // named after the session display name (falling back to the session id), unless
- // the caller specified a group title or opted out with group:false.
- if ((params.action === "new" || params.action === "group") && params.groupTitle === undefined && params.group !== false) {
+ // Force every Pi-opened/explicitly-grouped tab into this session's own group,
+ // named after the session display name (falling back to the session id). There is
+ // intentionally no opt-out: one Pi session should create/use one tab group.
+ if (params.action === "new" || params.action === "group") {
forwarded.groupTitle = sessionGroupTitle(ctx);
}
const result = await authorizedBridgeSend(`tab.${params.action}`, forwarded, DEFAULT_TIMEOUT_MS, signal);
diff --git a/test-suite/unit/automation-target.test.mjs b/test-suite/unit/automation-target.test.mjs
index 541e08a..9c8618c 100644
--- a/test-suite/unit/automation-target.test.mjs
+++ b/test-suite/unit/automation-target.test.mjs
@@ -75,8 +75,8 @@ function makeChrome(state, { withWindows = true, withStorage = true, withTabGrou
return list.map((t) => ({ ...t }));
},
get: async (id) => { const t = tabs.get(id); if (!t) throw new Error(`No tab with id ${id}`); return { ...t }; },
- create: async ({ url = "about:blank", active = false } = {}) => {
- const tab = { id: alloc.tab(), windowId: userWindowId, url, active, groupId: -1 };
+ create: async ({ url = "about:blank", active = false, windowId = userWindowId } = {}) => {
+ const tab = { id: alloc.tab(), windowId, url, active, groupId: -1 };
tabs.set(tab.id, tab);
return { ...tab };
},
@@ -207,6 +207,49 @@ async function run() {
ok(state.groups.size === groupsBefore, "group: reusing the automation tab does not create a second group");
}
+ // ===== tab.new joins the existing session group instead of creating one group per window. =====
+ {
+ const state = makeChromeState();
+ const w = loadWorker(makeChrome(state, { withTabGroups: true }));
+ const groupTitle = "Pi Session: alpha";
+ const nav = await w.dispatch("page.navigate", {
+ url: "https://pi.test/group-owner", waitUntilLoad: false,
+ sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle,
+ });
+ const navTab = state.tabs.get(nav.id);
+ const groupId = navTab.groupId;
+ const groupsBefore = state.groups.size;
+
+ const opened = await w.dispatch("tab.new", { url: "https://pi.test/new-tab", groupTitle, sessionKey: SK });
+ ok(state.groups.size === groupsBefore, "tab.new-group: did not create another same-session group");
+ ok(opened.tab.groupId === groupId, "tab.new-group: opened tab joined the existing session group");
+ ok(opened.tab.windowId === nav.windowId, "tab.new-group: opened tab was created in the existing group's window");
+
+ const forced = await w.dispatch("tab.new", { url: "https://pi.test/no-opt-out", groupTitle, group: false, sessionKey: SK });
+ ok(forced.tab.groupId === groupId, "tab.new-group: group:false is ignored; tab still joins the session group");
+ ok(state.groups.size === groupsBefore, "tab.new-group: group:false does not create another group");
+
+ const blankTitle = await w.dispatch("tab.new", { url: "https://pi.test/blank-title", groupTitle: "", group: false, sessionKey: SK });
+ ok(typeof blankTitle.tab.groupId === "number" && blankTitle.tab.groupId >= 0, "tab.new-group: groupTitle:'' still creates a grouped tab");
+ ok(blankTitle.group.title === "Pi", "tab.new-group: blank groupTitle falls back to a group instead of opting out");
+ }
+
+ // ===== tab.new never leaves an ungrouped tab behind when grouping fails. =====
+ {
+ const state = makeChromeState();
+ const chrome = makeChrome(state, { withTabGroups: true });
+ const w = loadWorker(chrome);
+ const tabsBefore = state.tabs.size;
+ chrome.tabs.group = async () => { throw new Error("group blew up"); };
+
+ await throwsWith(
+ () => w.dispatch("tab.new", { url: "https://pi.test/group-fail", groupTitle: "Pi Session: alpha", sessionKey: SK }),
+ /group blew up/,
+ "tab.new-group-fail: surfaces grouping error",
+ );
+ ok(state.tabs.size === tabsBefore, "tab.new-group-fail: closes the created tab instead of leaving it ungrouped");
+ }
+
// ===== Grouping is best-effort: a tabGroups failure must not break navigation. =====
{
const state = makeChromeState();