Add configurable Browser tab scope
Expose Browser settings for separate per-chat tabs versus a shared tab strip, and surface the existing maximum-tabs-per-chat cap. Make viewer WebSocket payloads carry the selected tab scope so the Browser panel can render the right tab list without guessing. Keep the default scoped per chat, preserve the shared mode as an opt-in fallback, and cover both paths with Browser regression tests.
Alessandro committed
Jul 4, 2026 at 16:52 UTC
d018177927723633434d79c2e37ed9fd4b0893f4
9 files changed
+350
-28
plugins/_browser/AGENTS.md
+1
@@ -22,6 +22,7 @@
22
- Paint live screencast frames through the Browser panel canvas/ImageBitmap path when available; keep the `<img>`/data URL path for snapshots and fallback rendering.
23
- Push internal screencast frames from the runtime to the WebSocket consumer after subscription; keep `read/pop_screencast_frame` as fallback/tooling APIs, not the WebUI hot path.
24
- Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback. Do not let the WebUI advertise binary frames unless its Socket.IO client reconstructs attachments as real `Blob`, `ArrayBuffer`, or typed-array values.
25
+- Keep WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other AgentContext runtimes only when the Browser settings tab scope is `shared`.
26
- Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
27
- Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces.
28
- Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback.
plugins/_browser/api/ws_browser.py
+69
-15
@@ -9,6 +9,11 @@ from typing import Any, ClassVar
9
from agent import AgentContext
10
from helpers.ws import WsHandler
11
from helpers.ws_manager import WsResult
12
+from plugins._browser.helpers.config import (
13
+ DEFAULT_BROWSER_TAB_SCOPE,
14
+ TAB_SCOPE_KEY,
15
+ get_browser_config,
16
+)
17
from plugins._browser.helpers.runtime import get_runtime, list_runtime_sessions
18
19
@@ -119,13 +124,16 @@ class WsBrowser(WsHandler):
124
self._streams[stream_key] = asyncio.create_task(stream_task)
125
snapshot = await self._snapshot_for_browser(runtime, active_id)
126
127
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers)
128
+
129
return {
130
"context_id": context_id,
131
"active_browser_context_id": context_id,
132
"active_browser_id": active_id,
133
"snapshot": snapshot,
127
- "browsers": await self._all_browser_tabs(),
128
- "all_browsers": True,
134
+ "browsers": browsers,
135
+ "all_browsers": all_browsers,
136
+ "tab_scope": tab_scope,
137
"viewer_id": viewer_id,
138
"viewer_transport": viewer_transport,
139
"binary_frames": binary_frames,
@@ -142,10 +150,23 @@ class WsBrowser(WsHandler):
150
return {"context_id": context_id, "unsubscribed": True}
151
152
async def _sessions(self, data: dict[str, Any]) -> dict[str, Any]:
153
+ context_id = self._context_id(data)
154
+ tab_scope = self._tab_scope()
155
+ if tab_scope == "shared":
156
+ return {
157
+ "context_id": context_id,
158
+ "browsers": await self._all_browser_tabs(),
159
+ "all_browsers": True,
160
+ "tab_scope": tab_scope,
161
+ }
162
+
163
+ runtime = await get_runtime(context_id, create=False) if context_id else None
164
+ listing = await runtime.call("list") if runtime else {}
165
return {
146
- "context_id": self._context_id(data),
147
- "browsers": await self._all_browser_tabs(),
148
- "all_browsers": True,
166
+ "context_id": context_id,
167
+ "browsers": listing.get("browsers") or [],
168
+ "all_browsers": False,
169
+ "tab_scope": tab_scope,
170
}
171
172
async def _snapshot(self, data: dict[str, Any]) -> dict[str, Any] | WsResult:
@@ -157,13 +178,15 @@ class WsBrowser(WsHandler):
178
179
runtime = await get_runtime(context_id, create=False)
180
if not runtime:
181
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, [])
182
return {
183
"context_id": context_id,
184
"active_browser_context_id": context_id,
185
"active_browser_id": None,
186
"snapshot": None,
165
- "browsers": await self._all_browser_tabs(),
166
- "all_browsers": True,
187
+ "browsers": browsers,
188
+ "all_browsers": all_browsers,
189
+ "tab_scope": tab_scope,
190
}
191
192
listing = await runtime.call("list")
@@ -182,13 +205,16 @@ class WsBrowser(WsHandler):
205
quality=quality,
206
)
207
208
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers)
209
+
210
return {
211
"context_id": context_id,
212
"active_browser_context_id": context_id,
213
"active_browser_id": active_id,
214
"snapshot": snapshot,
190
- "browsers": await self._all_browser_tabs(),
191
- "all_browsers": True,
215
+ "browsers": browsers,
216
+ "all_browsers": all_browsers,
217
+ "tab_scope": tab_scope,
218
}
219
220
async def _command(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
@@ -223,7 +249,10 @@ class WsBrowser(WsHandler):
249
listing = await runtime.call("list")
250
last_interacted_browser_id = listing.get("last_interacted_browser_id")
251
snapshot = await self._snapshot_for_result(runtime, result)
226
- all_browsers = await self._all_browser_tabs()
252
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(
253
+ context_id,
254
+ listing.get("browsers") or [],
255
+ )
256
await self.emit_to(
257
sid,
258
"browser_viewer_state",
@@ -235,8 +264,9 @@ class WsBrowser(WsHandler):
264
"browser_id": browser_id,
265
"result": result,
266
"snapshot": snapshot,
238
- "browsers": all_browsers,
239
- "all_browsers": True,
267
+ "browsers": browsers,
268
+ "all_browsers": all_browsers,
269
+ "tab_scope": tab_scope,
270
"last_interacted_browser_id": last_interacted_browser_id,
271
"viewer_transport": self._viewer_transport(data),
272
},
@@ -245,8 +275,9 @@ class WsBrowser(WsHandler):
275
return {
276
"result": result,
277
"snapshot": snapshot,
248
- "browsers": all_browsers,
249
- "all_browsers": True,
278
+ "browsers": browsers,
279
+ "all_browsers": all_browsers,
280
+ "tab_scope": tab_scope,
281
"active_browser_context_id": context_id,
282
"last_interacted_browser_id": last_interacted_browser_id,
283
"command": command,
@@ -376,6 +407,24 @@ class WsBrowser(WsHandler):
407
return await runtime.call("screenshot", browser_id, quality=SCREENSHOT_QUALITY)
408
return None
409
410
+ @staticmethod
411
+ def _tab_scope() -> str:
412
+ scope = str(
413
+ (get_browser_config() or {}).get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE)
414
+ or DEFAULT_BROWSER_TAB_SCOPE
415
+ ).strip().lower().replace("-", "_")
416
+ return "shared" if scope == "shared" else DEFAULT_BROWSER_TAB_SCOPE
417
+
418
+ async def _tabs_for_scope(
419
+ self,
420
+ context_id: str,
421
+ browsers: list[dict[str, Any]] | None,
422
+ ) -> tuple[list[dict[str, Any]], bool, str]:
423
+ tab_scope = self._tab_scope()
424
+ if tab_scope == "shared":
425
+ return await self._all_browser_tabs(), True, tab_scope
426
+ return browsers or [], False, tab_scope
427
+
428
async def _all_browser_tabs(self) -> list[dict[str, Any]]:
429
browsers: list[dict[str, Any]] = []
430
for session in await list_runtime_sessions():
@@ -693,6 +742,7 @@ class WsBrowser(WsHandler):
742
state: dict[str, Any] | None = None,
743
viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT,
744
) -> None:
745
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers or [])
746
await self._emit_to_connected_viewer(
747
sid,
748
"browser_viewer_state",
@@ -704,7 +754,8 @@ class WsBrowser(WsHandler):
754
"active_browser_id": browser_id,
755
"browsers": browsers or [],
756
"state": state,
707
- "all_browsers": False,
757
+ "all_browsers": all_browsers,
758
+ "tab_scope": tab_scope,
759
"viewer_transport": viewer_transport,
760
},
761
)
@@ -718,6 +769,7 @@ class WsBrowser(WsHandler):
769
viewer_id: str = "",
770
frame_source: str = "",
771
) -> None:
772
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers or [])
773
await self._emit_to_connected_viewer(
774
sid,
775
"browser_viewer_frame",
@@ -726,6 +778,8 @@ class WsBrowser(WsHandler):
778
"viewer_id": viewer_id,
779
"browser_id": None,
780
"browsers": browsers or [],
781
+ "all_browsers": all_browsers,
782
+ "tab_scope": tab_scope,
783
"image": "",
784
"mime": "",
785
"state": None,
plugins/_browser/default_config.yaml
+5
@@ -8,6 +8,11 @@ default_homepage: "about:blank"
8
# When the Browser surface is already open, keep it synced to agent Browser tool results.
9
autofocus_active_page: true
10
11
+# Browser tab visibility in the WebUI:
12
+# - per_context: each chat shows only its own Browser tabs.
13
+# - shared: show Browser tabs from all active chats.
14
+browser_tab_scope: "per_context"
15
+
16
# Maximum number of Browser tabs/pages a single chat context may keep open.
17
# Raise this only for deliberate parallel browsing workflows.
18
max_open_tabs: 32
plugins/_browser/helpers/config.py
+8
@@ -11,13 +11,16 @@ PLUGIN_NAME = "_browser"
11
MODEL_PRESET_KEY = "model_preset"
12
DEFAULT_HOMEPAGE_KEY = "default_homepage"
13
AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page"
14
+TAB_SCOPE_KEY = "browser_tab_scope"
15
MAX_OPEN_TABS_KEY = "max_open_tabs"
16
RUNTIME_BACKEND_KEY = "runtime_backend"
17
HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy"
18
HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode"
19
RUNTIME_BACKENDS = {"container", "host_required"}
20
+BROWSER_TAB_SCOPES = {"per_context", "shared"}
21
HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"}
22
HOST_BROWSER_PROFILE_MODES = {"existing", "agent"}
23
+DEFAULT_BROWSER_TAB_SCOPE = "per_context"
24
DEFAULT_MAX_OPEN_TABS = 32
25
MIN_MAX_OPEN_TABS = 1
26
HARD_MAX_OPEN_TABS = 50
@@ -117,6 +120,11 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
120
raw.get(AUTOFOCUS_ACTIVE_PAGE_KEY, True),
121
default=True,
122
),
123
+ TAB_SCOPE_KEY: _normalize_choice(
124
+ raw.get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE),
125
+ allowed=BROWSER_TAB_SCOPES,
126
+ default=DEFAULT_BROWSER_TAB_SCOPE,
127
+ ),
128
MAX_OPEN_TABS_KEY: _normalize_int(
129
raw.get(MAX_OPEN_TABS_KEY, DEFAULT_MAX_OPEN_TABS),
130
default=DEFAULT_MAX_OPEN_TABS,
plugins/_browser/webui/browser-config-store.js
+33
@@ -4,8 +4,12 @@ import { callJsonApi } from "/js/api.js";
4
const BROWSER_EXTENSIONS_API = "/plugins/_browser/extensions";
5
const BROWSER_STATUS_API = "/plugins/_browser/status";
6
const RUNTIME_BACKENDS = new Set(["container", "host_required"]);
7
+const BROWSER_TAB_SCOPES = new Set(["per_context", "shared"]);
8
const HOST_PRIVACY_POLICIES = new Set(["enforce_local", "warn", "allow"]);
9
const HOST_PROFILE_MODES = new Set(["existing", "agent"]);
10
+const DEFAULT_MAX_OPEN_TABS = 32;
11
+const MIN_MAX_OPEN_TABS = 1;
12
+const HARD_MAX_OPEN_TABS = 50;
13
14
function normalizePathList(value) {
15
const source = Array.isArray(value)
@@ -27,6 +31,8 @@ function ensureConfig(config) {
31
config.extension_paths = normalizePathList(config.extension_paths);
32
config.default_homepage = String(config.default_homepage || "about:blank").trim() || "about:blank";
33
config.autofocus_active_page = normalizeBoolean(config.autofocus_active_page, true);
34
+ config.browser_tab_scope = normalizeChoice(config.browser_tab_scope, BROWSER_TAB_SCOPES, "per_context");
35
+ config.max_open_tabs = normalizeInt(config.max_open_tabs, DEFAULT_MAX_OPEN_TABS, MIN_MAX_OPEN_TABS, HARD_MAX_OPEN_TABS);
36
config.runtime_backend = normalizeRuntimeBackend(config.runtime_backend);
37
config.host_browser_privacy_policy = normalizeChoice(
38
config.host_browser_privacy_policy,
@@ -48,6 +54,12 @@ function normalizeChoice(value, allowed, fallback) {
54
return allowed.has(normalized) ? normalized : fallback;
55
}
56
57
+function normalizeInt(value, fallback, minimum, maximum) {
58
+ const number = Number.parseInt(value, 10);
59
+ if (!Number.isFinite(number)) return fallback;
60
+ return Math.max(minimum, Math.min(maximum, number));
61
+}
62
+
63
function normalizeRuntimeBackend(value) {
64
const normalized = String(value || "").trim().toLowerCase().replace(/-/g, "_");
65
if (normalized === "host_when_available") return "host_required";
@@ -132,6 +144,27 @@ export const store = createStore("browserConfig", {
144
return this.config?.autofocus_active_page === false ? "Off" : "On";
145
},
146
147
+ setBrowserTabScope(value) {
148
+ const safeConfig = ensureConfig(this.config);
149
+ if (!safeConfig) return;
150
+ safeConfig.browser_tab_scope = normalizeChoice(value, BROWSER_TAB_SCOPES, "per_context");
151
+ },
152
+
153
+ browserTabScopeLabel() {
154
+ return this.config?.browser_tab_scope === "shared" ? "Shared" : "Per chat";
155
+ },
156
+
157
+ normalizeMaxOpenTabs() {
158
+ const safeConfig = ensureConfig(this.config);
159
+ if (!safeConfig) return;
160
+ safeConfig.max_open_tabs = normalizeInt(
161
+ safeConfig.max_open_tabs,
162
+ DEFAULT_MAX_OPEN_TABS,
163
+ MIN_MAX_OPEN_TABS,
164
+ HARD_MAX_OPEN_TABS,
165
+ );
166
+ },
167
+
168
runtimeBackendLabel() {
169
const value = this.config?.runtime_backend || "container";
170
if (value === "host_required") return "Bring Your Own Browser";
plugins/_browser/webui/browser-panel.html
+1
-1
@@ -16,7 +16,7 @@
16
<div class="browser-meta">
17
<div class="browser-meta-top">
18
<div class="browser-session-tabs" role="tablist" aria-label="Browser sessions">
19
- <template x-for="browser in $store.browserPage.browsers" :key="$store.browserPage.browserTabKey(browser)">
19
+ <template x-for="browser in $store.browserPage.visibleBrowsers()" :key="$store.browserPage.browserTabKey(browser)">
20
<div class="browser-tab-shell" :class="{ 'is-active': $store.browserPage.isActiveBrowser(browser) }">
21
<button type="button" class="browser-tab" role="tab"
22
:aria-selected="$store.browserPage.isActiveBrowser(browser).toString()"
plugins/_browser/webui/browser-store.js
+41
-3
@@ -168,6 +168,7 @@ const model = {
168
frameCanvasReady: false,
169
frameState: null,
170
viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST,
171
+ tabScope: "per_context",
172
liveScreencastEnabled: true,
173
annotating: false,
174
annotationComments: [],
@@ -339,8 +340,10 @@ const model = {
340
{ timeoutMs: 10000 },
341
);
342
const data = firstOk(response);
343
+ this.applyTabScope(data);
344
this.applyBrowserListing(data.browsers || [], data.context_id || "", {
345
replaceAll: Boolean(data.all_browsers),
346
+ replaceContext: !data.all_browsers,
347
});
348
})();
349
try {
@@ -959,6 +962,18 @@ const model = {
962
return BROWSER_VIEWER_TRANSPORT_SNAPSHOT;
963
},
964
965
+ normalizeTabScope(value = "") {
966
+ return String(value || "").trim().toLowerCase().replace("-", "_") === "shared"
967
+ ? "shared"
968
+ : "per_context";
969
+ },
970
+
971
+ applyTabScope(data = {}) {
972
+ if (!data || typeof data !== "object") return;
973
+ if (!Object.prototype.hasOwnProperty.call(data, "tab_scope")) return;
974
+ this.tabScope = this.normalizeTabScope(data.tab_scope);
975
+ },
976
+
977
usesScreencastTransport() {
978
return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_SCREENCAST;
979
},
@@ -1077,7 +1092,11 @@ const model = {
1092
return;
1093
}
1094
const data = firstOk(response);
1080
- this.applyBrowserListing(data.browsers || [], contextId, { replaceAll: Boolean(data.all_browsers) });
1095
+ this.applyTabScope(data);
1096
+ this.applyBrowserListing(data.browsers || [], contextId, {
1097
+ replaceAll: Boolean(data.all_browsers),
1098
+ replaceContext: !data.all_browsers,
1099
+ });
1100
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
1101
this.setActiveBrowserId(
1102
data.active_browser_id || requestedBrowserId || this.activeBrowserId || null,
@@ -1096,10 +1115,14 @@ const model = {
1115
if (data?.viewer_transport) {
1116
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
1117
}
1118
+ this.applyTabScope(data);
1119
const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
1120
const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
1121
if (Array.isArray(data.browsers)) {
1102
- this.applyBrowserListing(data.browsers, incomingContextId, { replaceContext: true });
1122
+ this.applyBrowserListing(data.browsers, incomingContextId, {
1123
+ replaceAll: Boolean(data.all_browsers),
1124
+ replaceContext: !data.all_browsers,
1125
+ });
1126
}
1127
if (incomingBrowserId && !this.activeBrowserId) {
1128
this.setActiveBrowserId(incomingBrowserId, incomingContextId);
@@ -1164,6 +1187,7 @@ const model = {
1187
if (data?.viewer_transport) {
1188
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
1189
}
1190
+ this.applyTabScope(data);
1191
const commandContextId = this.normalizeContextId(data.active_browser_context_id || data.context_id || this.contextId);
1192
if (Array.isArray(data.browsers)) {
1193
this.applyBrowserListing(data.browsers, commandContextId, {
@@ -1502,7 +1526,11 @@ const model = {
1526
{ timeoutMs: 20000 },
1527
);
1528
const data = firstOk(response);
1505
- this.applyBrowserListing(data.browsers || [], targetContextId, { replaceAll: Boolean(data.all_browsers) });
1529
+ this.applyTabScope(data);
1530
+ this.applyBrowserListing(data.browsers || [], targetContextId, {
1531
+ replaceAll: Boolean(data.all_browsers),
1532
+ replaceContext: !data.all_browsers,
1533
+ });
1534
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
1535
const result = data.result || {};
1536
const resultContextId = this.normalizeContextId(
@@ -1747,6 +1775,15 @@ const model = {
1775
return browsers[0] || null;
1776
},
1777
1778
+ visibleBrowsers() {
1779
+ const browsers = Array.isArray(this.browsers) ? this.browsers : [];
1780
+ if (this.tabScope === "shared") return browsers;
1781
+ const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId || this.resolveContextId());
1782
+ return contextId
1783
+ ? browsers.filter((browser) => this.normalizeContextId(browser?.context_id) === contextId)
1784
+ : browsers;
1785
+ },
1786
+
1787
firstBrowserInContext(contextId = "") {
1788
const normalizedContextId = this.normalizeContextId(contextId);
1789
if (!normalizedContextId || !Array.isArray(this.browsers)) return null;
@@ -2766,6 +2803,7 @@ const model = {
2803
this._viewerToken = "";
2804
this.switchingBrowserId = null;
2805
this.viewerTransport = this.requestedViewerTransport();
2806
+ this.tabScope = "per_context";
2807
this._surfaceMounted = false;
2808
this._surfaceSwitching = false;
2809
this.commandInFlight = false;
plugins/_browser/webui/config.html
+39
@@ -112,6 +112,44 @@
112
/>
113
</label>
114
115
+ <label class="browser-config-field">
116
+ <span class="browser-config-field-label">Browser tabs</span>
117
+ <select
118
+ x-model="$store.browserConfig.config.browser_tab_scope"
119
+ @change="$store.browserConfig.setBrowserTabScope($event.target.value)"
120
+ >
121
+ <option value="per_context">Separate per chat</option>
122
+ <option value="shared">Shared across chats</option>
123
+ </select>
124
+ <span
125
+ class="browser-config-field-help"
126
+ x-show="$store.browserConfig.config.browser_tab_scope !== 'shared'"
127
+ >
128
+ Each chat shows only its own Browser tabs.
129
+ </span>
130
+ <span
131
+ class="browser-config-field-help"
132
+ x-show="$store.browserConfig.config.browser_tab_scope === 'shared'"
133
+ >
134
+ The Browser tab strip shows tabs from every active chat.
135
+ </span>
136
+ </label>
137
+
138
+ <label class="browser-config-field">
139
+ <span class="browser-config-field-label">Maximum tabs per chat</span>
140
+ <input
141
+ type="number"
142
+ min="1"
143
+ max="50"
144
+ step="1"
145
+ x-model.number="$store.browserConfig.config.max_open_tabs"
146
+ @change="$store.browserConfig.normalizeMaxOpenTabs()"
147
+ />
148
+ <span class="browser-config-field-help">
149
+ New Browser tabs stop opening in a chat after this limit.
150
+ </span>
151
+ </label>
152
+
153
<label class="browser-config-switch-row">
154
<span class="browser-config-switch-copy">
155
<span class="browser-config-field-label">Autofocus active page</span>
@@ -242,6 +280,7 @@
280
}
281
282
.browser-config-field input[type="text"],
283
+ .browser-config-field input[type="number"],
284
.browser-config-field select {
285
width: 100%;
286
min-height: 36px;
tests/test_browser_agent_regressions.py
+153
-9
@@ -157,6 +157,7 @@ def test_browser_config_normalizes_extension_paths(tmp_path):
157
"extension_paths": [str(extension_dir)],
158
"default_homepage": "about:blank",
159
"autofocus_active_page": True,
160
+ "browser_tab_scope": "per_context",
161
"max_open_tabs": 32,
162
"runtime_backend": "container",
163
"host_browser_privacy_policy": "allow",
@@ -195,6 +196,13 @@ def test_browser_config_normalizes_max_open_tabs():
196
assert normalize_browser_config({"max_open_tabs": "oops"})["max_open_tabs"] == 32
197
198
199
+def test_browser_config_normalizes_tab_scope():
200
+ assert normalize_browser_config({})["browser_tab_scope"] == "per_context"
201
+ assert normalize_browser_config({"browser_tab_scope": "shared"})["browser_tab_scope"] == "shared"
202
+ assert normalize_browser_config({"browser_tab_scope": "per-context"})["browser_tab_scope"] == "per_context"
203
+ assert normalize_browser_config({"browser_tab_scope": "everything"})["browser_tab_scope"] == "per_context"
204
+
205
+
206
def test_browser_model_selection_uses_presets(monkeypatch):
207
import plugins._browser.helpers.config as browser_config_module
208
from plugins._model_config.helpers import model_config
@@ -1065,6 +1073,15 @@ def test_browser_extension_settings_stay_user_facing():
1073
1074
assert "Choose which installed Chrome extensions Browser loads." in config_html
1075
assert "Installed extensions" in config_html
1076
+ assert "Browser tabs" in config_html
1077
+ assert "Separate per chat" in config_html
1078
+ assert "Shared across chats" in config_html
1079
+ assert "Maximum tabs per chat" in config_html
1080
+ assert 'x-model="$store.browserConfig.config.browser_tab_scope"' in config_html
1081
+ assert 'x-model.number="$store.browserConfig.config.max_open_tabs"' in config_html
1082
+ assert "normalizeMaxOpenTabs()" in config_html
1083
+ assert "BROWSER_TAB_SCOPES" in config_store
1084
+ assert "HARD_MAX_OPEN_TABS = 50" in config_store
1085
assert "extensionDeleteTitle(extension)" in config_html
1086
assert "deleteExtension(extension)" in config_html
1087
assert "Delete extension" in config_store
@@ -1085,6 +1102,7 @@ def test_browser_viewer_uses_tabs_for_session_switching():
1102
assert 'class="browser-session-tabs" role="tablist"' in main_html
1103
assert 'class="browser-tab"' in main_html
1104
assert 'class="browser-new-tab"' in main_html
1105
+ assert 'browser in $store.browserPage.visibleBrowsers()' in main_html
1106
assert ':key="$store.browserPage.browserTabKey(browser)"' in main_html
1107
assert "browser.context_id" in main_html
1108
assert ':title="$store.browserPage.browserTabTooltip(browser)"' in main_html
@@ -1097,6 +1115,10 @@ def test_browser_viewer_uses_tabs_for_session_switching():
1115
assert "async syncViewerToSelectedContext" in browser_store
1116
assert "isVisibleBrowserSurface()" in browser_store
1117
assert "firstBrowserInContext(selectedContextId)" in browser_store
1118
+ assert "visibleBrowsers()" in browser_store
1119
+ assert 'tabScope: "per_context"' in browser_store
1120
+ assert "applyTabScope(data)" in browser_store
1121
+ assert 'if (this.tabScope === "shared") return browsers;' in browser_store
1122
assert "requestedContextId && requestedContextId !== inFlightContextId" in browser_store
1123
assert "create_browser: Boolean(options.createBrowser || options.create_browser)" in browser_store
1124
assert "browserTabTooltip(browser)" in browser_store
@@ -2036,6 +2058,39 @@ def test_browser_save_plugin_config_does_not_restart_runtimes_for_preset_only(mo
2058
assert restarted == []
2059
2060
2061
+def test_browser_save_plugin_config_does_not_restart_runtimes_for_viewer_settings(monkeypatch):
2062
+ restarted = []
2063
+
2064
+ monkeypatch.setattr(
2065
+ browser_hooks_module,
2066
+ "_load_saved_browser_config",
2067
+ lambda project_name="", agent_profile="": {
2068
+ "extension_paths": [],
2069
+ "browser_tab_scope": "per_context",
2070
+ "max_open_tabs": 32,
2071
+ },
2072
+ )
2073
+ monkeypatch.setattr(
2074
+ browser_hooks_module,
2075
+ "close_all_runtimes_sync",
2076
+ lambda: restarted.append(True),
2077
+ )
2078
+
2079
+ result = browser_hooks_module.save_plugin_config(
2080
+ {
2081
+ "extension_paths": [],
2082
+ "browser_tab_scope": "shared",
2083
+ "max_open_tabs": 12,
2084
+ },
2085
+ project_name="",
2086
+ agent_profile="",
2087
+ )
2088
+
2089
+ assert result["browser_tab_scope"] == "shared"
2090
+ assert result["max_open_tabs"] == 12
2091
+ assert restarted == []
2092
+
2093
+
2094
@pytest.mark.anyio
2095
async def test_browser_tool_dispatches_direct_actions(monkeypatch):
2096
calls = []
@@ -2411,6 +2466,7 @@ async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch):
2466
return fake_runtime
2467
2468
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
2469
+ monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
2470
monkeypatch.setattr(
2471
ws_browser_module.AgentContext,
2472
"get",
@@ -2466,6 +2522,7 @@ async def test_browser_viewer_subscribe_can_create_blank_tab_when_requested(monk
2522
return fake_runtime
2523
2524
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
2525
+ monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
2526
monkeypatch.setattr(
2527
ws_browser_module.AgentContext,
2528
"get",
@@ -2518,11 +2575,8 @@ async def test_browser_viewer_subscribe_returns_initial_snapshot(monkeypatch):
2575
assert create is False
2576
return FakeRuntime()
2577
2521
- async def fake_all_browser_tabs():
2522
- return [{"id": 1, "context_id": "ctx", "currentUrl": "https://example.com/"}]
2523
-
2578
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
2525
- monkeypatch.setattr(ws_browser_module, "list_runtime_sessions", fake_all_browser_tabs)
2579
+ monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
2580
monkeypatch.setattr(
2581
ws_browser_module.AgentContext,
2582
"get",
@@ -2543,6 +2597,9 @@ async def test_browser_viewer_subscribe_returns_initial_snapshot(monkeypatch):
2597
2598
assert result["active_browser_id"] == 1
2599
assert result["snapshot"]["image"] == "jpeg-data"
2600
+ assert result["browsers"] == [{"id": 1, "context_id": "ctx", "currentUrl": "https://example.com/"}]
2601
+ assert result["all_browsers"] is False
2602
+ assert result["tab_scope"] == "per_context"
2603
assert ("screenshot", (1,), {"quality": ws_browser_module.SCREENSHOT_QUALITY}) in calls
2604
2605
await handler.on_disconnect("sid-snapshot")
@@ -2556,6 +2613,7 @@ async def test_browser_viewer_subscribe_without_runtime_does_not_create_runtime(
2613
return None
2614
2615
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
2616
+ monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
2617
monkeypatch.setattr(
2618
ws_browser_module.AgentContext,
2619
"get",
@@ -2632,7 +2690,43 @@ async def test_browser_runtime_refuses_new_tabs_when_context_limit_is_reached(mo
2690
2691
2692
@pytest.mark.anyio
2635
-async def test_browser_viewer_command_returns_tabs_from_all_contexts(monkeypatch):
2693
+async def test_browser_viewer_command_returns_only_requested_context_tabs(monkeypatch):
2694
+ class FakeRuntime:
2695
+ async def call(self, method, *args, **kwargs):
2696
+ if method == "list":
2697
+ return {
2698
+ "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
2699
+ "last_interacted_browser_id": 1,
2700
+ }
2701
+ raise AssertionError(method)
2702
+
2703
+ async def fake_get_runtime(context_id, create=True):
2704
+ assert context_id == "ctx-a"
2705
+ return FakeRuntime()
2706
+
2707
+ monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
2708
+ monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
2709
+
2710
+ handler = ws_browser_module.WsBrowser(
2711
+ SimpleNamespace(),
2712
+ threading.RLock(),
2713
+ manager=None,
2714
+ )
2715
+
2716
+ result = await handler.process(
2717
+ "browser_viewer_command",
2718
+ {"context_id": "ctx-a", "command": "list"},
2719
+ "sid-1",
2720
+ )
2721
+
2722
+ assert result["all_browsers"] is False
2723
+ assert result["tab_scope"] == "per_context"
2724
+ assert result["active_browser_context_id"] == "ctx-a"
2725
+ assert result["browsers"] == [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}]
2726
+
2727
+
2728
+@pytest.mark.anyio
2729
+async def test_browser_viewer_command_can_return_shared_context_tabs(monkeypatch):
2730
class FakeRuntime:
2731
async def call(self, method, *args, **kwargs):
2732
if method == "list":
@@ -2660,6 +2754,7 @@ async def test_browser_viewer_command_returns_tabs_from_all_contexts(monkeypatch
2754
},
2755
]
2756
2757
+ monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "shared"})
2758
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
2759
monkeypatch.setattr(ws_browser_module, "list_runtime_sessions", fake_list_runtime_sessions)
2760
@@ -2676,24 +2771,69 @@ async def test_browser_viewer_command_returns_tabs_from_all_contexts(monkeypatch
2771
)
2772
2773
assert result["all_browsers"] is True
2774
+ assert result["tab_scope"] == "shared"
2775
assert result["active_browser_context_id"] == "ctx-a"
2776
assert [browser["context_id"] for browser in result["browsers"]] == ["ctx-a", "ctx-b"]
2777
2778
2779
@pytest.mark.anyio
2684
-async def test_browser_viewer_sessions_lists_without_creating_runtime(monkeypatch):
2780
+async def test_browser_viewer_sessions_lists_only_requested_context_without_creating(monkeypatch):
2781
+ class FakeRuntime:
2782
+ async def call(self, method, *args, **kwargs):
2783
+ assert method == "list"
2784
+ return {
2785
+ "browsers": [{"id": 1, "context_id": "ctx-b", "currentUrl": "about:blank"}],
2786
+ "last_interacted_browser_id": 1,
2787
+ }
2788
+
2789
+ async def fake_get_runtime(context_id, create=True):
2790
+ assert context_id == "ctx-b"
2791
+ assert create is False
2792
+ return FakeRuntime()
2793
+
2794
+ monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
2795
+ monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
2796
+
2797
+ handler = ws_browser_module.WsBrowser(
2798
+ SimpleNamespace(),
2799
+ threading.RLock(),
2800
+ manager=None,
2801
+ )
2802
+
2803
+ result = await handler.process(
2804
+ "browser_viewer_sessions",
2805
+ {"context_id": "ctx-b"},
2806
+ "sid-1",
2807
+ )
2808
+
2809
+ assert result == {
2810
+ "context_id": "ctx-b",
2811
+ "browsers": [{"id": 1, "context_id": "ctx-b", "currentUrl": "about:blank"}],
2812
+ "all_browsers": False,
2813
+ "tab_scope": "per_context",
2814
+ }
2815
+
2816
+
2817
+@pytest.mark.anyio
2818
+async def test_browser_viewer_sessions_can_list_shared_context_tabs(monkeypatch):
2819
async def fake_list_runtime_sessions():
2820
return [
2821
{
2822
"context_id": "ctx-a",
2823
"browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
2824
"last_interacted_browser_id": 1,
2691
- }
2825
+ },
2826
+ {
2827
+ "context_id": "ctx-b",
2828
+ "browsers": [{"id": 1, "context_id": "ctx-b", "currentUrl": "https://example.org/"}],
2829
+ "last_interacted_browser_id": 1,
2830
+ },
2831
]
2832
2833
async def fail_get_runtime(*args, **kwargs):
2695
- raise AssertionError("sessions refresh must not create or fetch one runtime")
2834
+ raise AssertionError("shared sessions refresh must not fetch one runtime")
2835
2836
+ monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "shared"})
2837
monkeypatch.setattr(ws_browser_module, "list_runtime_sessions", fake_list_runtime_sessions)
2838
monkeypatch.setattr(ws_browser_module, "get_runtime", fail_get_runtime)
2839
@@ -2711,8 +2851,12 @@ async def test_browser_viewer_sessions_lists_without_creating_runtime(monkeypatc
2851
2852
assert result == {
2853
"context_id": "ctx-b",
2714
- "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
2854
+ "browsers": [
2855
+ {"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"},
2856
+ {"id": 1, "context_id": "ctx-b", "currentUrl": "https://example.org/"},
2857
+ ],
2858
"all_browsers": True,
2859
+ "tab_scope": "shared",
2860
}
2861
2862