mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
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.
This commit is contained in:
parent
9bfa3b5c81
commit
d018177927
9 changed files with 350 additions and 28 deletions
|
|
@ -22,6 +22,7 @@
|
|||
- Paint live screencast frames through the Browser panel canvas/ImageBitmap path when available; keep the `<img>`/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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<div class="browser-meta">
|
||||
<div class="browser-meta-top">
|
||||
<div class="browser-session-tabs" role="tablist" aria-label="Browser sessions">
|
||||
<template x-for="browser in $store.browserPage.browsers" :key="$store.browserPage.browserTabKey(browser)">
|
||||
<template x-for="browser in $store.browserPage.visibleBrowsers()" :key="$store.browserPage.browserTabKey(browser)">
|
||||
<div class="browser-tab-shell" :class="{ 'is-active': $store.browserPage.isActiveBrowser(browser) }">
|
||||
<button type="button" class="browser-tab" role="tab"
|
||||
:aria-selected="$store.browserPage.isActiveBrowser(browser).toString()"
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ const model = {
|
|||
frameCanvasReady: false,
|
||||
frameState: null,
|
||||
viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST,
|
||||
tabScope: "per_context",
|
||||
liveScreencastEnabled: true,
|
||||
annotating: false,
|
||||
annotationComments: [],
|
||||
|
|
@ -339,8 +340,10 @@ const model = {
|
|||
{ timeoutMs: 10000 },
|
||||
);
|
||||
const data = firstOk(response);
|
||||
this.applyTabScope(data);
|
||||
this.applyBrowserListing(data.browsers || [], data.context_id || "", {
|
||||
replaceAll: Boolean(data.all_browsers),
|
||||
replaceContext: !data.all_browsers,
|
||||
});
|
||||
})();
|
||||
try {
|
||||
|
|
@ -959,6 +962,18 @@ const model = {
|
|||
return BROWSER_VIEWER_TRANSPORT_SNAPSHOT;
|
||||
},
|
||||
|
||||
normalizeTabScope(value = "") {
|
||||
return String(value || "").trim().toLowerCase().replace("-", "_") === "shared"
|
||||
? "shared"
|
||||
: "per_context";
|
||||
},
|
||||
|
||||
applyTabScope(data = {}) {
|
||||
if (!data || typeof data !== "object") return;
|
||||
if (!Object.prototype.hasOwnProperty.call(data, "tab_scope")) return;
|
||||
this.tabScope = this.normalizeTabScope(data.tab_scope);
|
||||
},
|
||||
|
||||
usesScreencastTransport() {
|
||||
return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_SCREENCAST;
|
||||
},
|
||||
|
|
@ -1077,7 +1092,11 @@ const model = {
|
|||
return;
|
||||
}
|
||||
const data = firstOk(response);
|
||||
this.applyBrowserListing(data.browsers || [], contextId, { replaceAll: Boolean(data.all_browsers) });
|
||||
this.applyTabScope(data);
|
||||
this.applyBrowserListing(data.browsers || [], contextId, {
|
||||
replaceAll: Boolean(data.all_browsers),
|
||||
replaceContext: !data.all_browsers,
|
||||
});
|
||||
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
||||
this.setActiveBrowserId(
|
||||
data.active_browser_id || requestedBrowserId || this.activeBrowserId || null,
|
||||
|
|
@ -1096,10 +1115,14 @@ const model = {
|
|||
if (data?.viewer_transport) {
|
||||
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
||||
}
|
||||
this.applyTabScope(data);
|
||||
const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
|
||||
const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
|
||||
if (Array.isArray(data.browsers)) {
|
||||
this.applyBrowserListing(data.browsers, incomingContextId, { replaceContext: true });
|
||||
this.applyBrowserListing(data.browsers, incomingContextId, {
|
||||
replaceAll: Boolean(data.all_browsers),
|
||||
replaceContext: !data.all_browsers,
|
||||
});
|
||||
}
|
||||
if (incomingBrowserId && !this.activeBrowserId) {
|
||||
this.setActiveBrowserId(incomingBrowserId, incomingContextId);
|
||||
|
|
@ -1164,6 +1187,7 @@ const model = {
|
|||
if (data?.viewer_transport) {
|
||||
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
||||
}
|
||||
this.applyTabScope(data);
|
||||
const commandContextId = this.normalizeContextId(data.active_browser_context_id || data.context_id || this.contextId);
|
||||
if (Array.isArray(data.browsers)) {
|
||||
this.applyBrowserListing(data.browsers, commandContextId, {
|
||||
|
|
@ -1502,7 +1526,11 @@ const model = {
|
|||
{ timeoutMs: 20000 },
|
||||
);
|
||||
const data = firstOk(response);
|
||||
this.applyBrowserListing(data.browsers || [], targetContextId, { replaceAll: Boolean(data.all_browsers) });
|
||||
this.applyTabScope(data);
|
||||
this.applyBrowserListing(data.browsers || [], targetContextId, {
|
||||
replaceAll: Boolean(data.all_browsers),
|
||||
replaceContext: !data.all_browsers,
|
||||
});
|
||||
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
||||
const result = data.result || {};
|
||||
const resultContextId = this.normalizeContextId(
|
||||
|
|
@ -1747,6 +1775,15 @@ const model = {
|
|||
return browsers[0] || null;
|
||||
},
|
||||
|
||||
visibleBrowsers() {
|
||||
const browsers = Array.isArray(this.browsers) ? this.browsers : [];
|
||||
if (this.tabScope === "shared") return browsers;
|
||||
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId || this.resolveContextId());
|
||||
return contextId
|
||||
? browsers.filter((browser) => this.normalizeContextId(browser?.context_id) === contextId)
|
||||
: browsers;
|
||||
},
|
||||
|
||||
firstBrowserInContext(contextId = "") {
|
||||
const normalizedContextId = this.normalizeContextId(contextId);
|
||||
if (!normalizedContextId || !Array.isArray(this.browsers)) return null;
|
||||
|
|
@ -2766,6 +2803,7 @@ const model = {
|
|||
this._viewerToken = "";
|
||||
this.switchingBrowserId = null;
|
||||
this.viewerTransport = this.requestedViewerTransport();
|
||||
this.tabScope = "per_context";
|
||||
this._surfaceMounted = false;
|
||||
this._surfaceSwitching = false;
|
||||
this.commandInFlight = false;
|
||||
|
|
|
|||
|
|
@ -112,6 +112,44 @@
|
|||
/>
|
||||
</label>
|
||||
|
||||
<label class="browser-config-field">
|
||||
<span class="browser-config-field-label">Browser tabs</span>
|
||||
<select
|
||||
x-model="$store.browserConfig.config.browser_tab_scope"
|
||||
@change="$store.browserConfig.setBrowserTabScope($event.target.value)"
|
||||
>
|
||||
<option value="per_context">Separate per chat</option>
|
||||
<option value="shared">Shared across chats</option>
|
||||
</select>
|
||||
<span
|
||||
class="browser-config-field-help"
|
||||
x-show="$store.browserConfig.config.browser_tab_scope !== 'shared'"
|
||||
>
|
||||
Each chat shows only its own Browser tabs.
|
||||
</span>
|
||||
<span
|
||||
class="browser-config-field-help"
|
||||
x-show="$store.browserConfig.config.browser_tab_scope === 'shared'"
|
||||
>
|
||||
The Browser tab strip shows tabs from every active chat.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="browser-config-field">
|
||||
<span class="browser-config-field-label">Maximum tabs per chat</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
step="1"
|
||||
x-model.number="$store.browserConfig.config.max_open_tabs"
|
||||
@change="$store.browserConfig.normalizeMaxOpenTabs()"
|
||||
/>
|
||||
<span class="browser-config-field-help">
|
||||
New Browser tabs stop opening in a chat after this limit.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="browser-config-switch-row">
|
||||
<span class="browser-config-switch-copy">
|
||||
<span class="browser-config-field-label">Autofocus active page</span>
|
||||
|
|
@ -242,6 +280,7 @@
|
|||
}
|
||||
|
||||
.browser-config-field input[type="text"],
|
||||
.browser-config-field input[type="number"],
|
||||
.browser-config-field select {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ def test_browser_config_normalizes_extension_paths(tmp_path):
|
|||
"extension_paths": [str(extension_dir)],
|
||||
"default_homepage": "about:blank",
|
||||
"autofocus_active_page": True,
|
||||
"browser_tab_scope": "per_context",
|
||||
"max_open_tabs": 32,
|
||||
"runtime_backend": "container",
|
||||
"host_browser_privacy_policy": "allow",
|
||||
|
|
@ -195,6 +196,13 @@ def test_browser_config_normalizes_max_open_tabs():
|
|||
assert normalize_browser_config({"max_open_tabs": "oops"})["max_open_tabs"] == 32
|
||||
|
||||
|
||||
def test_browser_config_normalizes_tab_scope():
|
||||
assert normalize_browser_config({})["browser_tab_scope"] == "per_context"
|
||||
assert normalize_browser_config({"browser_tab_scope": "shared"})["browser_tab_scope"] == "shared"
|
||||
assert normalize_browser_config({"browser_tab_scope": "per-context"})["browser_tab_scope"] == "per_context"
|
||||
assert normalize_browser_config({"browser_tab_scope": "everything"})["browser_tab_scope"] == "per_context"
|
||||
|
||||
|
||||
def test_browser_model_selection_uses_presets(monkeypatch):
|
||||
import plugins._browser.helpers.config as browser_config_module
|
||||
from plugins._model_config.helpers import model_config
|
||||
|
|
@ -1065,6 +1073,15 @@ def test_browser_extension_settings_stay_user_facing():
|
|||
|
||||
assert "Choose which installed Chrome extensions Browser loads." in config_html
|
||||
assert "Installed extensions" in config_html
|
||||
assert "Browser tabs" in config_html
|
||||
assert "Separate per chat" in config_html
|
||||
assert "Shared across chats" in config_html
|
||||
assert "Maximum tabs per chat" in config_html
|
||||
assert 'x-model="$store.browserConfig.config.browser_tab_scope"' in config_html
|
||||
assert 'x-model.number="$store.browserConfig.config.max_open_tabs"' in config_html
|
||||
assert "normalizeMaxOpenTabs()" in config_html
|
||||
assert "BROWSER_TAB_SCOPES" in config_store
|
||||
assert "HARD_MAX_OPEN_TABS = 50" in config_store
|
||||
assert "extensionDeleteTitle(extension)" in config_html
|
||||
assert "deleteExtension(extension)" in config_html
|
||||
assert "Delete extension" in config_store
|
||||
|
|
@ -1085,6 +1102,7 @@ def test_browser_viewer_uses_tabs_for_session_switching():
|
|||
assert 'class="browser-session-tabs" role="tablist"' in main_html
|
||||
assert 'class="browser-tab"' in main_html
|
||||
assert 'class="browser-new-tab"' in main_html
|
||||
assert 'browser in $store.browserPage.visibleBrowsers()' in main_html
|
||||
assert ':key="$store.browserPage.browserTabKey(browser)"' in main_html
|
||||
assert "browser.context_id" in main_html
|
||||
assert ':title="$store.browserPage.browserTabTooltip(browser)"' in main_html
|
||||
|
|
@ -1097,6 +1115,10 @@ def test_browser_viewer_uses_tabs_for_session_switching():
|
|||
assert "async syncViewerToSelectedContext" in browser_store
|
||||
assert "isVisibleBrowserSurface()" in browser_store
|
||||
assert "firstBrowserInContext(selectedContextId)" in browser_store
|
||||
assert "visibleBrowsers()" in browser_store
|
||||
assert 'tabScope: "per_context"' in browser_store
|
||||
assert "applyTabScope(data)" in browser_store
|
||||
assert 'if (this.tabScope === "shared") return browsers;' in browser_store
|
||||
assert "requestedContextId && requestedContextId !== inFlightContextId" in browser_store
|
||||
assert "create_browser: Boolean(options.createBrowser || options.create_browser)" in browser_store
|
||||
assert "browserTabTooltip(browser)" in browser_store
|
||||
|
|
@ -2036,6 +2058,39 @@ def test_browser_save_plugin_config_does_not_restart_runtimes_for_preset_only(mo
|
|||
assert restarted == []
|
||||
|
||||
|
||||
def test_browser_save_plugin_config_does_not_restart_runtimes_for_viewer_settings(monkeypatch):
|
||||
restarted = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
browser_hooks_module,
|
||||
"_load_saved_browser_config",
|
||||
lambda project_name="", agent_profile="": {
|
||||
"extension_paths": [],
|
||||
"browser_tab_scope": "per_context",
|
||||
"max_open_tabs": 32,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
browser_hooks_module,
|
||||
"close_all_runtimes_sync",
|
||||
lambda: restarted.append(True),
|
||||
)
|
||||
|
||||
result = browser_hooks_module.save_plugin_config(
|
||||
{
|
||||
"extension_paths": [],
|
||||
"browser_tab_scope": "shared",
|
||||
"max_open_tabs": 12,
|
||||
},
|
||||
project_name="",
|
||||
agent_profile="",
|
||||
)
|
||||
|
||||
assert result["browser_tab_scope"] == "shared"
|
||||
assert result["max_open_tabs"] == 12
|
||||
assert restarted == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_browser_tool_dispatches_direct_actions(monkeypatch):
|
||||
calls = []
|
||||
|
|
@ -2411,6 +2466,7 @@ async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch):
|
|||
return fake_runtime
|
||||
|
||||
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
|
||||
monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
|
||||
monkeypatch.setattr(
|
||||
ws_browser_module.AgentContext,
|
||||
"get",
|
||||
|
|
@ -2466,6 +2522,7 @@ async def test_browser_viewer_subscribe_can_create_blank_tab_when_requested(monk
|
|||
return fake_runtime
|
||||
|
||||
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
|
||||
monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
|
||||
monkeypatch.setattr(
|
||||
ws_browser_module.AgentContext,
|
||||
"get",
|
||||
|
|
@ -2518,11 +2575,8 @@ async def test_browser_viewer_subscribe_returns_initial_snapshot(monkeypatch):
|
|||
assert create is False
|
||||
return FakeRuntime()
|
||||
|
||||
async def fake_all_browser_tabs():
|
||||
return [{"id": 1, "context_id": "ctx", "currentUrl": "https://example.com/"}]
|
||||
|
||||
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
|
||||
monkeypatch.setattr(ws_browser_module, "list_runtime_sessions", fake_all_browser_tabs)
|
||||
monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
|
||||
monkeypatch.setattr(
|
||||
ws_browser_module.AgentContext,
|
||||
"get",
|
||||
|
|
@ -2543,6 +2597,9 @@ async def test_browser_viewer_subscribe_returns_initial_snapshot(monkeypatch):
|
|||
|
||||
assert result["active_browser_id"] == 1
|
||||
assert result["snapshot"]["image"] == "jpeg-data"
|
||||
assert result["browsers"] == [{"id": 1, "context_id": "ctx", "currentUrl": "https://example.com/"}]
|
||||
assert result["all_browsers"] is False
|
||||
assert result["tab_scope"] == "per_context"
|
||||
assert ("screenshot", (1,), {"quality": ws_browser_module.SCREENSHOT_QUALITY}) in calls
|
||||
|
||||
await handler.on_disconnect("sid-snapshot")
|
||||
|
|
@ -2556,6 +2613,7 @@ async def test_browser_viewer_subscribe_without_runtime_does_not_create_runtime(
|
|||
return None
|
||||
|
||||
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
|
||||
monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
|
||||
monkeypatch.setattr(
|
||||
ws_browser_module.AgentContext,
|
||||
"get",
|
||||
|
|
@ -2632,7 +2690,43 @@ async def test_browser_runtime_refuses_new_tabs_when_context_limit_is_reached(mo
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_browser_viewer_command_returns_tabs_from_all_contexts(monkeypatch):
|
||||
async def test_browser_viewer_command_returns_only_requested_context_tabs(monkeypatch):
|
||||
class FakeRuntime:
|
||||
async def call(self, method, *args, **kwargs):
|
||||
if method == "list":
|
||||
return {
|
||||
"browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
|
||||
"last_interacted_browser_id": 1,
|
||||
}
|
||||
raise AssertionError(method)
|
||||
|
||||
async def fake_get_runtime(context_id, create=True):
|
||||
assert context_id == "ctx-a"
|
||||
return FakeRuntime()
|
||||
|
||||
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
|
||||
monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
|
||||
|
||||
handler = ws_browser_module.WsBrowser(
|
||||
SimpleNamespace(),
|
||||
threading.RLock(),
|
||||
manager=None,
|
||||
)
|
||||
|
||||
result = await handler.process(
|
||||
"browser_viewer_command",
|
||||
{"context_id": "ctx-a", "command": "list"},
|
||||
"sid-1",
|
||||
)
|
||||
|
||||
assert result["all_browsers"] is False
|
||||
assert result["tab_scope"] == "per_context"
|
||||
assert result["active_browser_context_id"] == "ctx-a"
|
||||
assert result["browsers"] == [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_browser_viewer_command_can_return_shared_context_tabs(monkeypatch):
|
||||
class FakeRuntime:
|
||||
async def call(self, method, *args, **kwargs):
|
||||
if method == "list":
|
||||
|
|
@ -2660,6 +2754,7 @@ async def test_browser_viewer_command_returns_tabs_from_all_contexts(monkeypatch
|
|||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "shared"})
|
||||
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
|
||||
monkeypatch.setattr(ws_browser_module, "list_runtime_sessions", fake_list_runtime_sessions)
|
||||
|
||||
|
|
@ -2676,24 +2771,69 @@ async def test_browser_viewer_command_returns_tabs_from_all_contexts(monkeypatch
|
|||
)
|
||||
|
||||
assert result["all_browsers"] is True
|
||||
assert result["tab_scope"] == "shared"
|
||||
assert result["active_browser_context_id"] == "ctx-a"
|
||||
assert [browser["context_id"] for browser in result["browsers"]] == ["ctx-a", "ctx-b"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_browser_viewer_sessions_lists_without_creating_runtime(monkeypatch):
|
||||
async def test_browser_viewer_sessions_lists_only_requested_context_without_creating(monkeypatch):
|
||||
class FakeRuntime:
|
||||
async def call(self, method, *args, **kwargs):
|
||||
assert method == "list"
|
||||
return {
|
||||
"browsers": [{"id": 1, "context_id": "ctx-b", "currentUrl": "about:blank"}],
|
||||
"last_interacted_browser_id": 1,
|
||||
}
|
||||
|
||||
async def fake_get_runtime(context_id, create=True):
|
||||
assert context_id == "ctx-b"
|
||||
assert create is False
|
||||
return FakeRuntime()
|
||||
|
||||
monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
|
||||
monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
|
||||
|
||||
handler = ws_browser_module.WsBrowser(
|
||||
SimpleNamespace(),
|
||||
threading.RLock(),
|
||||
manager=None,
|
||||
)
|
||||
|
||||
result = await handler.process(
|
||||
"browser_viewer_sessions",
|
||||
{"context_id": "ctx-b"},
|
||||
"sid-1",
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"context_id": "ctx-b",
|
||||
"browsers": [{"id": 1, "context_id": "ctx-b", "currentUrl": "about:blank"}],
|
||||
"all_browsers": False,
|
||||
"tab_scope": "per_context",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_browser_viewer_sessions_can_list_shared_context_tabs(monkeypatch):
|
||||
async def fake_list_runtime_sessions():
|
||||
return [
|
||||
{
|
||||
"context_id": "ctx-a",
|
||||
"browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
|
||||
"last_interacted_browser_id": 1,
|
||||
}
|
||||
},
|
||||
{
|
||||
"context_id": "ctx-b",
|
||||
"browsers": [{"id": 1, "context_id": "ctx-b", "currentUrl": "https://example.org/"}],
|
||||
"last_interacted_browser_id": 1,
|
||||
},
|
||||
]
|
||||
|
||||
async def fail_get_runtime(*args, **kwargs):
|
||||
raise AssertionError("sessions refresh must not create or fetch one runtime")
|
||||
raise AssertionError("shared sessions refresh must not fetch one runtime")
|
||||
|
||||
monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "shared"})
|
||||
monkeypatch.setattr(ws_browser_module, "list_runtime_sessions", fake_list_runtime_sessions)
|
||||
monkeypatch.setattr(ws_browser_module, "get_runtime", fail_get_runtime)
|
||||
|
||||
|
|
@ -2711,8 +2851,12 @@ async def test_browser_viewer_sessions_lists_without_creating_runtime(monkeypatc
|
|||
|
||||
assert result == {
|
||||
"context_id": "ctx-b",
|
||||
"browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
|
||||
"browsers": [
|
||||
{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"},
|
||||
{"id": 1, "context_id": "ctx-b", "currentUrl": "https://example.org/"},
|
||||
],
|
||||
"all_browsers": True,
|
||||
"tab_scope": "shared",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue