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