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 @@
-