From d018177927723633434d79c2e37ed9fd4b0893f4 Mon Sep 17 00:00:00 2001
From: Alessandro <155005371+3clyp50@users.noreply.github.com>
Date: Sat, 4 Jul 2026 16:52:39 +0200
Subject: [PATCH] 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.
---
plugins/_browser/AGENTS.md | 1 +
plugins/_browser/api/ws_browser.py | 84 +++++++--
plugins/_browser/default_config.yaml | 5 +
plugins/_browser/helpers/config.py | 8 +
.../_browser/webui/browser-config-store.js | 33 ++++
plugins/_browser/webui/browser-panel.html | 2 +-
plugins/_browser/webui/browser-store.js | 44 ++++-
plugins/_browser/webui/config.html | 39 +++++
tests/test_browser_agent_regressions.py | 162 +++++++++++++++++-
9 files changed, 350 insertions(+), 28 deletions(-)
diff --git a/plugins/_browser/AGENTS.md b/plugins/_browser/AGENTS.md
index 6a434bbd5..075bdede5 100644
--- a/plugins/_browser/AGENTS.md
+++ b/plugins/_browser/AGENTS.md
@@ -22,6 +22,7 @@
- Paint live screencast frames through the Browser panel canvas/ImageBitmap path when available; keep the ``/data URL path for snapshots and fallback rendering.
- 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.
- 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.
+- 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`.
- Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
- Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces.
- Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback.
diff --git a/plugins/_browser/api/ws_browser.py b/plugins/_browser/api/ws_browser.py
index f1c28c42d..be7092826 100644
--- a/plugins/_browser/api/ws_browser.py
+++ b/plugins/_browser/api/ws_browser.py
@@ -9,6 +9,11 @@ from typing import Any, ClassVar
from agent import AgentContext
from helpers.ws import WsHandler
from helpers.ws_manager import WsResult
+from plugins._browser.helpers.config import (
+ DEFAULT_BROWSER_TAB_SCOPE,
+ TAB_SCOPE_KEY,
+ get_browser_config,
+)
from plugins._browser.helpers.runtime import get_runtime, list_runtime_sessions
@@ -119,13 +124,16 @@ class WsBrowser(WsHandler):
self._streams[stream_key] = asyncio.create_task(stream_task)
snapshot = await self._snapshot_for_browser(runtime, active_id)
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers)
+
return {
"context_id": context_id,
"active_browser_context_id": context_id,
"active_browser_id": active_id,
"snapshot": snapshot,
- "browsers": await self._all_browser_tabs(),
- "all_browsers": True,
+ "browsers": browsers,
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
"viewer_id": viewer_id,
"viewer_transport": viewer_transport,
"binary_frames": binary_frames,
@@ -142,10 +150,23 @@ class WsBrowser(WsHandler):
return {"context_id": context_id, "unsubscribed": True}
async def _sessions(self, data: dict[str, Any]) -> dict[str, Any]:
+ context_id = self._context_id(data)
+ tab_scope = self._tab_scope()
+ if tab_scope == "shared":
+ return {
+ "context_id": context_id,
+ "browsers": await self._all_browser_tabs(),
+ "all_browsers": True,
+ "tab_scope": tab_scope,
+ }
+
+ runtime = await get_runtime(context_id, create=False) if context_id else None
+ listing = await runtime.call("list") if runtime else {}
return {
- "context_id": self._context_id(data),
- "browsers": await self._all_browser_tabs(),
- "all_browsers": True,
+ "context_id": context_id,
+ "browsers": listing.get("browsers") or [],
+ "all_browsers": False,
+ "tab_scope": tab_scope,
}
async def _snapshot(self, data: dict[str, Any]) -> dict[str, Any] | WsResult:
@@ -157,13 +178,15 @@ class WsBrowser(WsHandler):
runtime = await get_runtime(context_id, create=False)
if not runtime:
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, [])
return {
"context_id": context_id,
"active_browser_context_id": context_id,
"active_browser_id": None,
"snapshot": None,
- "browsers": await self._all_browser_tabs(),
- "all_browsers": True,
+ "browsers": browsers,
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
}
listing = await runtime.call("list")
@@ -182,13 +205,16 @@ class WsBrowser(WsHandler):
quality=quality,
)
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers)
+
return {
"context_id": context_id,
"active_browser_context_id": context_id,
"active_browser_id": active_id,
"snapshot": snapshot,
- "browsers": await self._all_browser_tabs(),
- "all_browsers": True,
+ "browsers": browsers,
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
}
async def _command(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
@@ -223,7 +249,10 @@ class WsBrowser(WsHandler):
listing = await runtime.call("list")
last_interacted_browser_id = listing.get("last_interacted_browser_id")
snapshot = await self._snapshot_for_result(runtime, result)
- all_browsers = await self._all_browser_tabs()
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(
+ context_id,
+ listing.get("browsers") or [],
+ )
await self.emit_to(
sid,
"browser_viewer_state",
@@ -235,8 +264,9 @@ class WsBrowser(WsHandler):
"browser_id": browser_id,
"result": result,
"snapshot": snapshot,
- "browsers": all_browsers,
- "all_browsers": True,
+ "browsers": browsers,
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
"last_interacted_browser_id": last_interacted_browser_id,
"viewer_transport": self._viewer_transport(data),
},
@@ -245,8 +275,9 @@ class WsBrowser(WsHandler):
return {
"result": result,
"snapshot": snapshot,
- "browsers": all_browsers,
- "all_browsers": True,
+ "browsers": browsers,
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
"active_browser_context_id": context_id,
"last_interacted_browser_id": last_interacted_browser_id,
"command": command,
@@ -376,6 +407,24 @@ class WsBrowser(WsHandler):
return await runtime.call("screenshot", browser_id, quality=SCREENSHOT_QUALITY)
return None
+ @staticmethod
+ def _tab_scope() -> str:
+ scope = str(
+ (get_browser_config() or {}).get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE)
+ or DEFAULT_BROWSER_TAB_SCOPE
+ ).strip().lower().replace("-", "_")
+ return "shared" if scope == "shared" else DEFAULT_BROWSER_TAB_SCOPE
+
+ async def _tabs_for_scope(
+ self,
+ context_id: str,
+ browsers: list[dict[str, Any]] | None,
+ ) -> tuple[list[dict[str, Any]], bool, str]:
+ tab_scope = self._tab_scope()
+ if tab_scope == "shared":
+ return await self._all_browser_tabs(), True, tab_scope
+ return browsers or [], False, tab_scope
+
async def _all_browser_tabs(self) -> list[dict[str, Any]]:
browsers: list[dict[str, Any]] = []
for session in await list_runtime_sessions():
@@ -693,6 +742,7 @@ class WsBrowser(WsHandler):
state: dict[str, Any] | None = None,
viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT,
) -> None:
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers or [])
await self._emit_to_connected_viewer(
sid,
"browser_viewer_state",
@@ -704,7 +754,8 @@ class WsBrowser(WsHandler):
"active_browser_id": browser_id,
"browsers": browsers or [],
"state": state,
- "all_browsers": False,
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
"viewer_transport": viewer_transport,
},
)
@@ -718,6 +769,7 @@ class WsBrowser(WsHandler):
viewer_id: str = "",
frame_source: str = "",
) -> None:
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers or [])
await self._emit_to_connected_viewer(
sid,
"browser_viewer_frame",
@@ -726,6 +778,8 @@ class WsBrowser(WsHandler):
"viewer_id": viewer_id,
"browser_id": None,
"browsers": browsers or [],
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
"image": "",
"mime": "",
"state": None,
diff --git a/plugins/_browser/default_config.yaml b/plugins/_browser/default_config.yaml
index b87c040e1..e4416d0fa 100644
--- a/plugins/_browser/default_config.yaml
+++ b/plugins/_browser/default_config.yaml
@@ -8,6 +8,11 @@ default_homepage: "about:blank"
# When the Browser surface is already open, keep it synced to agent Browser tool results.
autofocus_active_page: true
+# Browser tab visibility in the WebUI:
+# - per_context: each chat shows only its own Browser tabs.
+# - shared: show Browser tabs from all active chats.
+browser_tab_scope: "per_context"
+
# Maximum number of Browser tabs/pages a single chat context may keep open.
# Raise this only for deliberate parallel browsing workflows.
max_open_tabs: 32
diff --git a/plugins/_browser/helpers/config.py b/plugins/_browser/helpers/config.py
index f39e52e51..a4a052aed 100644
--- a/plugins/_browser/helpers/config.py
+++ b/plugins/_browser/helpers/config.py
@@ -11,13 +11,16 @@ PLUGIN_NAME = "_browser"
MODEL_PRESET_KEY = "model_preset"
DEFAULT_HOMEPAGE_KEY = "default_homepage"
AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page"
+TAB_SCOPE_KEY = "browser_tab_scope"
MAX_OPEN_TABS_KEY = "max_open_tabs"
RUNTIME_BACKEND_KEY = "runtime_backend"
HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy"
HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode"
RUNTIME_BACKENDS = {"container", "host_required"}
+BROWSER_TAB_SCOPES = {"per_context", "shared"}
HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"}
HOST_BROWSER_PROFILE_MODES = {"existing", "agent"}
+DEFAULT_BROWSER_TAB_SCOPE = "per_context"
DEFAULT_MAX_OPEN_TABS = 32
MIN_MAX_OPEN_TABS = 1
HARD_MAX_OPEN_TABS = 50
@@ -117,6 +120,11 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
raw.get(AUTOFOCUS_ACTIVE_PAGE_KEY, True),
default=True,
),
+ TAB_SCOPE_KEY: _normalize_choice(
+ raw.get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE),
+ allowed=BROWSER_TAB_SCOPES,
+ default=DEFAULT_BROWSER_TAB_SCOPE,
+ ),
MAX_OPEN_TABS_KEY: _normalize_int(
raw.get(MAX_OPEN_TABS_KEY, DEFAULT_MAX_OPEN_TABS),
default=DEFAULT_MAX_OPEN_TABS,
diff --git a/plugins/_browser/webui/browser-config-store.js b/plugins/_browser/webui/browser-config-store.js
index 81fee2e85..aea9fac14 100644
--- a/plugins/_browser/webui/browser-config-store.js
+++ b/plugins/_browser/webui/browser-config-store.js
@@ -4,8 +4,12 @@ import { callJsonApi } from "/js/api.js";
const BROWSER_EXTENSIONS_API = "/plugins/_browser/extensions";
const BROWSER_STATUS_API = "/plugins/_browser/status";
const RUNTIME_BACKENDS = new Set(["container", "host_required"]);
+const BROWSER_TAB_SCOPES = new Set(["per_context", "shared"]);
const HOST_PRIVACY_POLICIES = new Set(["enforce_local", "warn", "allow"]);
const HOST_PROFILE_MODES = new Set(["existing", "agent"]);
+const DEFAULT_MAX_OPEN_TABS = 32;
+const MIN_MAX_OPEN_TABS = 1;
+const HARD_MAX_OPEN_TABS = 50;
function normalizePathList(value) {
const source = Array.isArray(value)
@@ -27,6 +31,8 @@ function ensureConfig(config) {
config.extension_paths = normalizePathList(config.extension_paths);
config.default_homepage = String(config.default_homepage || "about:blank").trim() || "about:blank";
config.autofocus_active_page = normalizeBoolean(config.autofocus_active_page, true);
+ config.browser_tab_scope = normalizeChoice(config.browser_tab_scope, BROWSER_TAB_SCOPES, "per_context");
+ config.max_open_tabs = normalizeInt(config.max_open_tabs, DEFAULT_MAX_OPEN_TABS, MIN_MAX_OPEN_TABS, HARD_MAX_OPEN_TABS);
config.runtime_backend = normalizeRuntimeBackend(config.runtime_backend);
config.host_browser_privacy_policy = normalizeChoice(
config.host_browser_privacy_policy,
@@ -48,6 +54,12 @@ function normalizeChoice(value, allowed, fallback) {
return allowed.has(normalized) ? normalized : fallback;
}
+function normalizeInt(value, fallback, minimum, maximum) {
+ const number = Number.parseInt(value, 10);
+ if (!Number.isFinite(number)) return fallback;
+ return Math.max(minimum, Math.min(maximum, number));
+}
+
function normalizeRuntimeBackend(value) {
const normalized = String(value || "").trim().toLowerCase().replace(/-/g, "_");
if (normalized === "host_when_available") return "host_required";
@@ -132,6 +144,27 @@ export const store = createStore("browserConfig", {
return this.config?.autofocus_active_page === false ? "Off" : "On";
},
+ setBrowserTabScope(value) {
+ const safeConfig = ensureConfig(this.config);
+ if (!safeConfig) return;
+ safeConfig.browser_tab_scope = normalizeChoice(value, BROWSER_TAB_SCOPES, "per_context");
+ },
+
+ browserTabScopeLabel() {
+ return this.config?.browser_tab_scope === "shared" ? "Shared" : "Per chat";
+ },
+
+ normalizeMaxOpenTabs() {
+ const safeConfig = ensureConfig(this.config);
+ if (!safeConfig) return;
+ safeConfig.max_open_tabs = normalizeInt(
+ safeConfig.max_open_tabs,
+ DEFAULT_MAX_OPEN_TABS,
+ MIN_MAX_OPEN_TABS,
+ HARD_MAX_OPEN_TABS,
+ );
+ },
+
runtimeBackendLabel() {
const value = this.config?.runtime_backend || "container";
if (value === "host_required") return "Bring Your Own Browser";
diff --git a/plugins/_browser/webui/browser-panel.html b/plugins/_browser/webui/browser-panel.html
index 1abcde86f..c92a1acec 100644
--- a/plugins/_browser/webui/browser-panel.html
+++ b/plugins/_browser/webui/browser-panel.html
@@ -16,7 +16,7 @@