Polish Browser settings and viewport handling
Add Browser settings for the default starting page and tool-result autofocus, and wire them through config, APIs, runtime opens, and the settings UI. Resolve Chrome extension __MSG_* manifest labels from locale metadata so installed extensions show readable names. Stabilize Browser viewport negotiation across canvas and modal surfaces by clearing stale frames, waiting for stable surface dimensions, and forcing sync after dock transitions. Move Browser loading/error state into a thin bottom status bar so it no longer overlays the page viewport.
Alessandro committed
Apr 26, 2026 at 21:47 UTC
c32e32828745f2e6f4debe85a6bda21d379841b4
13 files changed
+561
-68
plugins/_browser/api/extensions.py
+4
@@ -3,6 +3,8 @@ from types import SimpleNamespace
3
from helpers import plugins
4
from helpers.api import ApiHandler, Request
5
from plugins._browser.helpers.config import (
6
+ AUTOFOCUS_ACTIVE_PAGE_KEY,
7
+ DEFAULT_HOMEPAGE_KEY,
8
MODEL_PRESET_KEY,
9
PLUGIN_NAME,
10
get_browser_config,
@@ -81,6 +83,8 @@ class Extensions(ApiHandler):
83
"ok": True,
84
"root": str(get_extensions_root()),
85
"extensions": list_browser_extensions(),
86
+ DEFAULT_HOMEPAGE_KEY: config[DEFAULT_HOMEPAGE_KEY],
87
+ AUTOFOCUS_ACTIVE_PAGE_KEY: config[AUTOFOCUS_ACTIVE_PAGE_KEY],
88
MODEL_PRESET_KEY: config[MODEL_PRESET_KEY],
89
"main_model_summary": get_browser_main_model_summary(agent=agent),
90
"model_preset_options": get_browser_model_preset_options(agent=agent, settings=config),
plugins/_browser/api/ws_browser.py
+2
-2
@@ -61,7 +61,7 @@ class WsBrowser(WsHandler):
61
listing = await runtime.call("list")
62
browsers = listing.get("browsers") or []
63
if not browsers:
64
- opened = await runtime.call("open", "about:blank")
64
+ opened = await runtime.call("open", "")
65
listing = await runtime.call("list")
66
browsers = listing.get("browsers") or []
67
if opened.get("id"):
@@ -114,7 +114,7 @@ class WsBrowser(WsHandler):
114
115
try:
116
if command == "open":
117
- result = await runtime.call("open", data.get("url") or "about:blank")
117
+ result = await runtime.call("open", data.get("url") or "")
118
elif command == "navigate":
119
result = await runtime.call("navigate", browser_id, data.get("url") or "")
120
elif command == "back":
plugins/_browser/default_config.yaml
+6
@@ -2,6 +2,12 @@
2
# Paths must be readable from the Agent Zero runtime itself.
3
extension_paths: []
4
5
+# Page opened by new Browser sessions when no URL is provided.
6
+default_homepage: "about:blank"
7
+
8
+# Focus Browser canvas pages automatically when agent Browser tool results arrive.
9
+autofocus_active_page: true
10
+
11
# Optional _model_config preset used by Browser-owned model helpers.
12
# Empty uses the effective Main Model.
13
model_preset: ""
plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js
+15
-1
@@ -34,6 +34,19 @@ async function openBrowserCanvas(payload = {}) {
34
await window.openModal?.(BROWSER_MODAL);
35
}
36
37
+async function browserAllowsToolAutofocus() {
38
+ try {
39
+ const browser = globalThis.Alpine?.store?.("browserPage")
40
+ || (await import("/plugins/_browser/webui/browser-store.js")).store;
41
+ if (browser?.allowsToolAutofocus) {
42
+ return await browser.allowsToolAutofocus();
43
+ }
44
+ } catch (error) {
45
+ console.warn("Browser autofocus setting could not be checked", error);
46
+ }
47
+ return true;
48
+}
49
+
50
function parseBrowserResult(content) {
51
if (!content || typeof content !== "string") return {};
52
try {
@@ -78,7 +91,8 @@ function autoOpenBrowserCanvas(args, result) {
91
if (autoOpenedBrowsers.has(key) || sessionStorage.getItem(persistedKey)) return;
92
autoOpenedBrowsers.add(key);
93
sessionStorage.setItem(persistedKey, "1");
81
- requestAnimationFrame(() => {
94
+ requestAnimationFrame(async () => {
95
+ if (!(await browserAllowsToolAutofocus())) return;
96
void openBrowserCanvas({ browserId, source: "tool" });
97
});
98
}
plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js
+15
-1
@@ -21,7 +21,8 @@ export default async function autoOpenBrowserResults(context) {
21
const persistedKey = `a0.browser.autoOpened.${key}`;
22
if (hasOpened(key, persistedKey)) continue;
23
24
- requestAnimationFrame(() => {
24
+ requestAnimationFrame(async () => {
25
+ if (!(await browserAllowsToolAutofocus())) return;
26
void openBrowserCanvas({ browserId, source: "tool-result" });
27
});
28
}
@@ -147,3 +148,16 @@ async function openBrowserCanvas(payload = {}) {
148
}
149
await window.openModal?.(BROWSER_MODAL);
150
}
151
+
152
+async function browserAllowsToolAutofocus() {
153
+ try {
154
+ const browser = globalThis.Alpine?.store?.("browserPage")
155
+ || (await import("/plugins/_browser/webui/browser-store.js")).store;
156
+ if (browser?.allowsToolAutofocus) {
157
+ return await browser.allowsToolAutofocus();
158
+ }
159
+ } catch (error) {
160
+ console.warn("Browser autofocus setting could not be checked", error);
161
+ }
162
+ return true;
163
+}
plugins/_browser/helpers/config.py
+29
@@ -9,6 +9,8 @@ if TYPE_CHECKING:
9
10
PLUGIN_NAME = "_browser"
11
MODEL_PRESET_KEY = "model_preset"
12
+DEFAULT_HOMEPAGE_KEY = "default_homepage"
13
+AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page"
14
BASE_BROWSER_ARGS = [
15
"--no-sandbox",
16
"--disable-dev-shm-usage",
@@ -42,6 +44,26 @@ def _normalize_model_preset(value: Any) -> str:
44
return str(value or "").strip()
45
46
47
+def _normalize_default_homepage(value: Any) -> str:
48
+ homepage = str(value or "").strip()
49
+ return homepage or "about:blank"
50
+
51
+
52
+def _normalize_bool(value: Any, default: bool = True) -> bool:
53
+ if value is None:
54
+ return default
55
+ if isinstance(value, bool):
56
+ return value
57
+ if isinstance(value, (int, float)):
58
+ return bool(value)
59
+ normalized = str(value).strip().lower()
60
+ if normalized in {"1", "true", "yes", "on", "enabled"}:
61
+ return True
62
+ if normalized in {"0", "false", "no", "off", "disabled"}:
63
+ return False
64
+ return default
65
+
66
+
67
def _model_config_summary(config: dict[str, Any] | None) -> str:
68
if not isinstance(config, dict):
69
return ""
@@ -55,6 +77,13 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
77
extension_paths = _normalize_extension_paths(raw.get("extension_paths", []))
78
return {
79
"extension_paths": extension_paths,
80
+ DEFAULT_HOMEPAGE_KEY: _normalize_default_homepage(
81
+ raw.get(DEFAULT_HOMEPAGE_KEY, raw.get("starting_page", "about:blank"))
82
+ ),
83
+ AUTOFOCUS_ACTIVE_PAGE_KEY: _normalize_bool(
84
+ raw.get(AUTOFOCUS_ACTIVE_PAGE_KEY, True),
85
+ default=True,
86
+ ),
87
MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")),
88
}
89
plugins/_browser/helpers/extension_manager.py
+88
-2
@@ -20,6 +20,7 @@ EXTENSIONS_ROOT_DIR = ("usr", "plugins", PLUGIN_NAME, "extensions")
20
EXTENSION_ID_RE = re.compile(r"^[a-p]{32}$")
21
WEB_STORE_ID_RE = re.compile(r"(?<![a-p])([a-p]{32})(?![a-p])")
22
CHROME_VERSION_RE = re.compile(r"(\d+(?:\.\d+){0,3})")
23
+CHROME_I18N_MESSAGE_RE = re.compile(r"__MSG_([A-Za-z0-9_@.-]+)__")
24
DEFAULT_CHROME_PRODVERSION = "140.0.0.0"
25
CHROME_VERSION_COMMANDS = (
26
("google-chrome", "--version"),
@@ -103,7 +104,7 @@ def install_chrome_web_store_extension(source: str) -> dict[str, Any]:
104
return {
105
"ok": True,
106
"id": extension_id,
106
- "name": manifest.get("name") or extension_id,
107
+ "name": _manifest_label(target, manifest, "name") or extension_id,
108
"version": manifest.get("version") or "",
109
"path": str(target),
110
"extension_paths": config["extension_paths"],
@@ -260,8 +261,15 @@ def _enable_extension_path(extension_path: Path) -> dict[str, Any]:
261
def _extension_entry(extension_dir: Path, enabled_paths: set[str]) -> dict[str, Any]:
262
manifest = _read_manifest(extension_dir)
263
extension_path = str(extension_dir)
264
+ name = (
265
+ _manifest_label(extension_dir, manifest, "name")
266
+ or _manifest_label(extension_dir, manifest, "short_name")
267
+ or extension_dir.name
268
+ )
269
return {
264
- "name": manifest.get("name") or extension_dir.name,
270
+ "name": name,
271
+ "raw_name": manifest.get("name") or "",
272
+ "description": _manifest_label(extension_dir, manifest, "description"),
273
"version": manifest.get("version") or "",
274
"path": extension_path,
275
"enabled": extension_path in enabled_paths,
@@ -274,3 +282,81 @@ def _read_manifest(extension_path: Path) -> dict[str, Any]:
282
return json.loads(manifest_path.read_text(encoding="utf-8"))
283
except Exception:
284
return {}
285
+
286
+
287
+def _manifest_label(extension_dir: Path, manifest: dict[str, Any], key: str) -> str:
288
+ value = str(manifest.get(key) or "").strip()
289
+ if not value:
290
+ return ""
291
+
292
+ messages = _load_locale_messages(extension_dir, str(manifest.get("default_locale") or ""))
293
+ if not messages:
294
+ return "" if CHROME_I18N_MESSAGE_RE.fullmatch(value) else value
295
+
296
+ def replace_message(match: re.Match[str]) -> str:
297
+ message_key = match.group(1)
298
+ message = _resolve_locale_message(messages, message_key)
299
+ return message if message is not None else match.group(0)
300
+
301
+ resolved = CHROME_I18N_MESSAGE_RE.sub(replace_message, value).strip()
302
+ if CHROME_I18N_MESSAGE_RE.fullmatch(resolved):
303
+ return ""
304
+ return resolved
305
+
306
+
307
+def _load_locale_messages(extension_dir: Path, default_locale: str) -> dict[str, Any]:
308
+ locale_root = extension_dir / "_locales"
309
+ if not locale_root.is_dir():
310
+ return {}
311
+
312
+ preferred_locales = [
313
+ default_locale,
314
+ default_locale.split("_", 1)[0] if default_locale else "",
315
+ "en_US",
316
+ "en",
317
+ ]
318
+ for locale in [item for item in preferred_locales if item]:
319
+ messages = _read_locale_file(locale_root / locale / "messages.json")
320
+ if messages:
321
+ return messages
322
+
323
+ for messages_path in sorted(locale_root.glob("*/messages.json")):
324
+ messages = _read_locale_file(messages_path)
325
+ if messages:
326
+ return messages
327
+ return {}
328
+
329
+
330
+def _read_locale_file(messages_path: Path) -> dict[str, Any]:
331
+ if not messages_path.is_file():
332
+ return {}
333
+ try:
334
+ data = json.loads(messages_path.read_text(encoding="utf-8"))
335
+ except Exception:
336
+ return {}
337
+ return data if isinstance(data, dict) else {}
338
+
339
+
340
+def _resolve_locale_message(messages: dict[str, Any], key: str) -> str | None:
341
+ entry = messages.get(key)
342
+ if not isinstance(entry, dict):
343
+ return None
344
+ message = str(entry.get("message") or "")
345
+ if not message:
346
+ return None
347
+
348
+ placeholders = entry.get("placeholders")
349
+ if isinstance(placeholders, dict):
350
+ for name, placeholder in placeholders.items():
351
+ if not isinstance(placeholder, dict):
352
+ continue
353
+ content = str(placeholder.get("content") or "")
354
+ if not content:
355
+ continue
356
+ message = re.sub(
357
+ rf"\${re.escape(str(name))}\$",
358
+ content,
359
+ message,
360
+ flags=re.IGNORECASE,
361
+ )
362
+ return message
plugins/_browser/helpers/runtime.py
+20
-6
@@ -20,7 +20,11 @@ from helpers import files
20
from helpers.defer import DeferredTask
21
from helpers.print_style import PrintStyle
22
23
-from plugins._browser.helpers.config import build_browser_launch_config, get_browser_config
23
+from plugins._browser.helpers.config import (
24
+ DEFAULT_HOMEPAGE_KEY,
25
+ build_browser_launch_config,
26
+ get_browser_config,
27
+)
28
from plugins._browser.helpers.playwright import configure_playwright_env, ensure_playwright_binary
29
30
@@ -31,6 +35,7 @@ DEFAULT_VIEWPORT = {"width": 1024, "height": 768}
35
CHROME_SINGLETON_FILES = ("SingletonLock", "SingletonCookie", "SingletonSocket")
36
SCREENCAST_MAX_WIDTH = 4096
37
SCREENCAST_MAX_HEIGHT = 4096
38
+VIEWPORT_SIZE_TOLERANCE = 4
39
40
_SPECIAL_SCHEME_RE = re.compile(r"^(?:about|blob|data|file|mailto|tel):", re.I)
41
_URL_SCHEME_RE = re.compile(r"^[a-z][a-z\d+\-.]*://", re.I)
@@ -460,17 +465,24 @@ class _BrowserRuntimeCore:
465
except OSError as exc:
466
PrintStyle.warning(f"Could not force-stop orphaned Chromium process {pid}: {exc}")
467
463
- async def open(self, url: str = "about:blank") -> dict[str, Any]:
468
+ async def open(self, url: str = "") -> dict[str, Any]:
469
await self.ensure_started()
470
page = await self.context.new_page()
471
browser_page = self._register_page(page)
472
self.last_interacted_browser_id = browser_page.id
468
- if url and url != "about:blank":
469
- await self._goto(page, normalize_url(url))
473
+ target_url = self._initial_url(url)
474
+ if target_url and target_url != "about:blank":
475
+ await self._goto(page, normalize_url(target_url))
476
else:
477
await self._settle(page)
478
return {"id": browser_page.id, "state": await self._state(browser_page.id)}
479
480
+ def _initial_url(self, url: str = "") -> str:
481
+ raw_url = str(url or "").strip()
482
+ if raw_url:
483
+ return raw_url
484
+ return str(get_browser_config().get(DEFAULT_HOMEPAGE_KEY) or "about:blank").strip() or "about:blank"
485
+
486
async def list(self) -> dict[str, Any]:
487
await self.ensure_started()
488
return {
@@ -691,8 +703,10 @@ class _BrowserRuntimeCore:
703
}
704
current_viewport = page.viewport_size or {}
705
changed = (
694
- int(current_viewport.get("width") or 0) != viewport["width"]
695
- or int(current_viewport.get("height") or 0) != viewport["height"]
706
+ abs(int(current_viewport.get("width") or 0) - viewport["width"])
707
+ > VIEWPORT_SIZE_TOLERANCE
708
+ or abs(int(current_viewport.get("height") or 0) - viewport["height"])
709
+ > VIEWPORT_SIZE_TOLERANCE
710
)
711
if changed:
712
await page.set_viewport_size(viewport)
plugins/_browser/tools/browser.py
+1
-1
@@ -25,7 +25,7 @@ class Browser(Tool):
25
26
try:
27
if action == "open":
28
- result = await runtime.call("open", url or "about:blank")
28
+ result = await runtime.call("open", url or "")
29
elif action == "list":
30
result = await runtime.call("list")
31
elif action == "state":
plugins/_browser/webui/browser-config-store.js
+22
@@ -21,11 +21,23 @@ function normalizePathList(value) {
21
function ensureConfig(config) {
22
if (!config || typeof config !== "object") return null;
23
config.extension_paths = normalizePathList(config.extension_paths);
24
+ config.default_homepage = String(config.default_homepage || "about:blank").trim() || "about:blank";
25
+ config.autofocus_active_page = normalizeBoolean(config.autofocus_active_page, true);
26
config.model_preset = String(config.model_preset || "").trim();
27
delete config.model;
28
return config;
29
}
30
31
+function normalizeBoolean(value, fallback = true) {
32
+ if (value === undefined || value === null || value === "") return fallback;
33
+ if (typeof value === "boolean") return value;
34
+ if (typeof value === "number") return Boolean(value);
35
+ const normalized = String(value).trim().toLowerCase();
36
+ if (["1", "true", "yes", "on", "enabled"].includes(normalized)) return true;
37
+ if (["0", "false", "no", "off", "disabled"].includes(normalized)) return false;
38
+ return fallback;
39
+}
40
+
41
export const store = createStore("browserConfig", {
42
config: null,
43
extensionsList: [],
@@ -50,6 +62,16 @@ export const store = createStore("browserConfig", {
62
this.config = safeConfig;
63
},
64
65
+ setAutofocusActivePage(enabled) {
66
+ const safeConfig = ensureConfig(this.config);
67
+ if (!safeConfig) return;
68
+ safeConfig.autofocus_active_page = Boolean(enabled);
69
+ },
70
+
71
+ autofocusLabel() {
72
+ return this.config?.autofocus_active_page === false ? "Off" : "On";
73
+ },
74
+
75
hasPaths() {
76
return this.pathCount() > 0;
77
},
plugins/_browser/webui/browser-panel.html
+61
-40
@@ -43,11 +43,11 @@
43
<div class="browser-session-controls">
44
<div class="browser-extension-menu" @click.outside="$store.browserPage.closeExtensionsMenu()"
45
@keydown.escape.window="$store.browserPage.closeExtensionsMenu()">
46
- <button type="button" class="btn btn-icon-action browser-extensions" title="Browser extensions"
47
- aria-label="Browser extensions" @click.stop="$store.browserPage.toggleExtensionsMenu()"
46
+ <button type="button" class="btn btn-icon-action browser-extensions" title="Browser settings"
47
+ aria-label="Browser settings" @click.stop="$store.browserPage.toggleExtensionsMenu()"
48
:aria-expanded="$store.browserPage.extensionMenuOpen.toString()"
49
:class="{ 'is-active': $store.browserPage.status?.extensions?.active }">
50
- <span class="material-symbols-outlined">extension</span>
50
+ <span class="material-symbols-outlined">tune</span>
51
</button>
52
<div class="browser-extension-dropdown" x-show="$store.browserPage.extensionMenuOpen" x-transition
53
style="display: none;">
@@ -160,14 +160,8 @@
160
</form>
161
</div>
162
163
- <div class="browser-error" x-show="$store.browserPage.error" x-text="$store.browserPage.error"></div>
164
-
163
<div class="browser-stage" tabindex="0" @click="$el.focus()"
164
@wheel.prevent="$store.browserPage.sendWheel($event)">
167
- <div class="browser-status" x-show="$store.browserPage.isBusy()">
168
- <span class="material-symbols-outlined spinning">progress_activity</span>
169
- <span x-text="$store.browserPage.loadingMessage()">Connecting browser...</span>
170
- </div>
165
<template x-if="$store.browserPage.frameSrc">
166
<img class="browser-frame" :src="$store.browserPage.frameSrc"
167
@click="$store.browserPage.sendMouse('click', $event)"
@@ -176,11 +170,24 @@
170
<template x-if="!$store.browserPage.frameSrc && !$store.browserPage.isBusy()">
171
<div class="browser-empty">
172
<span class="material-symbols-outlined">captive_portal</span>
179
- <button class="btn btn-field" @click="$store.browserPage.command('open', { url: 'about:blank' })">Open
173
+ <button class="btn btn-field" @click="$store.browserPage.command('open')">Open
174
Browser</button>
175
</div>
176
</template>
177
</div>
178
+
179
+ <div class="browser-bottom-status"
180
+ :class="{ 'is-active': $store.browserPage.isBusy() || $store.browserPage.error, 'has-error': $store.browserPage.error && !$store.browserPage.isBusy() }">
181
+ <template x-if="$store.browserPage.isBusy()">
182
+ <div class="browser-status" :title="$store.browserPage.loadingMessage()">
183
+ <span class="material-symbols-outlined spinning">progress_activity</span>
184
+ <span x-text="$store.browserPage.loadingMessage()">Loading</span>
185
+ </div>
186
+ </template>
187
+ <template x-if="!$store.browserPage.isBusy() && $store.browserPage.error">
188
+ <div class="browser-error" x-text="$store.browserPage.error" :title="$store.browserPage.error"></div>
189
+ </template>
190
+ </div>
191
</div>
192
</template>
193
</div>
@@ -799,18 +806,17 @@
806
807
.browser-stage {
808
flex: 1 1 auto;
802
- display: flex;
803
- flex-direction: column;
809
min-height: 0;
810
overflow: hidden;
811
position: relative;
807
- background: #fff;
812
+ contain: size layout paint;
813
outline: none;
814
}
815
816
.browser-frame {
812
- flex: 1 1 auto;
817
display: block;
818
+ position: absolute;
819
+ inset: 0;
820
width: 100%;
821
height: 100%;
822
min-width: 0;
@@ -821,8 +827,6 @@
827
background: #fff;
828
}
829
824
- .browser-status,
825
- .browser-error,
830
.browser-empty {
831
display: flex;
832
align-items: center;
@@ -831,26 +835,48 @@
835
font-size: 0.88rem;
836
}
837
834
- .browser-status,
835
- .browser-error {
836
- padding: 0 12px;
837
- }
838
+ .browser-bottom-status {
839
+ display: flex;
840
+ align-items: center;
841
+ flex: 0 0 24px;
842
+ min-height: 24px;
843
+ max-height: 24px;
844
+ min-width: 0;
845
+ padding: 0 10px;
846
+ overflow: hidden;
847
+ border-top: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
848
+ background: color-mix(in srgb, var(--color-background) 95%, #000 5%);
849
+ color: color-mix(in srgb, var(--color-text) 74%, transparent);
850
+ font-size: 0.76rem;
851
+ line-height: 1;
852
+ }
853
839
- .browser-status {
840
- position: absolute;
841
- top: 10px;
842
- left: 10px;
843
- z-index: 5;
844
- width: max-content;
845
- max-width: calc(100% - 20px);
846
- min-height: 32px;
847
- padding: 7px 10px;
848
- border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
849
- border-radius: 7px;
850
- background: color-mix(in srgb, var(--color-background) 92%, transparent);
851
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
852
- pointer-events: none;
853
- }
854
+ .browser-bottom-status.has-error {
855
+ color: #fca5a5;
856
+ background: color-mix(in srgb, #7f1d1d 22%, var(--color-background));
857
+ }
858
+
859
+ .browser-status,
860
+ .browser-error {
861
+ display: inline-flex;
862
+ align-items: center;
863
+ gap: 6px;
864
+ min-width: 0;
865
+ max-width: 100%;
866
+ overflow: hidden;
867
+ white-space: nowrap;
868
+ }
869
+
870
+ .browser-status span:not(.material-symbols-outlined),
871
+ .browser-error {
872
+ min-width: 0;
873
+ overflow: hidden;
874
+ text-overflow: ellipsis;
875
+ }
876
+
877
+ .browser-status .material-symbols-outlined {
878
+ font-size: 15px;
879
+ }
880
881
.browser-modal .spinning,
882
.browser-panel .spinning {
@@ -865,10 +891,6 @@
891
}
892
}
893
868
- .browser-error {
869
- color: #9f1239;
870
- }
871
-
894
.browser-empty {
895
display: grid;
896
flex: 1 1 auto;
@@ -879,7 +901,6 @@
901
text-align: center;
902
padding: 24px;
903
color: var(--color-text);
882
- background: var(--color-background);
904
}
905
906
@container (max-width: 460px) {
plugins/_browser/webui/browser-store.js
+203
-15
@@ -10,6 +10,9 @@ websocket.addHandlers(["ws_webui"]);
10
const EXTENSIONS_ROOT = "/a0/usr/plugins/_browser/extensions";
11
const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
12
const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
13
+const BROWSER_CONFIG_REFRESH_MS = 15000;
14
+const VIEWPORT_SYNC_DEBOUNCE_MS = 220;
15
+const VIEWPORT_SYNC_SIZE_TOLERANCE = 4;
16
17
function makeViewerToken() {
18
return globalThis.crypto?.randomUUID?.()
@@ -30,6 +33,23 @@ function firstOk(response) {
33
return {};
34
}
35
36
+function normalizeBool(value, fallback = true) {
37
+ if (value === undefined || value === null || value === "") return fallback;
38
+ if (typeof value === "boolean") return value;
39
+ if (typeof value === "number") return Boolean(value);
40
+ const normalized = String(value).trim().toLowerCase();
41
+ if (["1", "true", "yes", "on", "enabled"].includes(normalized)) return true;
42
+ if (["0", "false", "no", "off", "disabled"].includes(normalized)) return false;
43
+ return fallback;
44
+}
45
+
46
+function nextAnimationFrame() {
47
+ return new Promise((resolve) => {
48
+ const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16));
49
+ schedule(() => resolve());
50
+ });
51
+}
52
+
53
const model = {
54
loading: true,
55
error: "",
@@ -55,7 +75,10 @@ const model = {
75
_stageResizeObserver: null,
76
_viewportSyncTimer: null,
77
_lastViewportKey: "",
58
- _mode: "canvas",
78
+ _lastViewport: null,
79
+ _mode: "",
80
+ _surfaceMounted: false,
81
+ _surfaceSwitching: false,
82
_connectSequence: 0,
83
_viewerToken: "",
84
extensionMenuOpen: false,
@@ -72,6 +95,10 @@ const model = {
95
mainModelSummary: "",
96
modelPresetSaving: false,
97
browserInstallExpected: false,
98
+ defaultHomepage: "about:blank",
99
+ autofocusActivePage: true,
100
+ _configLoadedAt: 0,
101
+ _configRefreshPromise: null,
102
103
async refreshStatus() {
104
this.status = await callJsonApi("/plugins/_browser/status", {});
@@ -99,11 +126,48 @@ const model = {
126
applyExtensionPayload(response = {}) {
127
this.extensionsRoot = response.root || EXTENSIONS_ROOT;
128
this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
129
+ this.defaultHomepage = String(response.default_homepage || "about:blank").trim() || "about:blank";
130
+ this.autofocusActivePage = normalizeBool(response.autofocus_active_page, true);
131
this.modelPreset = String(response.model_preset || "");
132
this.mainModelSummary = String(response.main_model_summary || "");
133
this.modelPresetOptions = Array.isArray(response.model_preset_options)
134
? response.model_preset_options
135
: [];
136
+ this._configLoadedAt = Date.now();
137
+ },
138
+
139
+ async ensureBrowserConfigLoaded(force = false) {
140
+ if (!force && this._configLoadedAt && Date.now() - this._configLoadedAt < BROWSER_CONFIG_REFRESH_MS) {
141
+ return;
142
+ }
143
+ if (this._configRefreshPromise) {
144
+ await this._configRefreshPromise;
145
+ return;
146
+ }
147
+ this._configRefreshPromise = (async () => {
148
+ const response = await callJsonApi("/plugins/_browser/extensions", {
149
+ action: "list",
150
+ context_id: this.contextId || this.resolveContextId(),
151
+ });
152
+ if (!response?.ok) {
153
+ throw new Error(response?.error || "Could not load browser settings.");
154
+ }
155
+ this.applyExtensionPayload(response);
156
+ })();
157
+ try {
158
+ await this._configRefreshPromise;
159
+ } finally {
160
+ this._configRefreshPromise = null;
161
+ }
162
+ },
163
+
164
+ async allowsToolAutofocus() {
165
+ try {
166
+ await this.ensureBrowserConfigLoaded();
167
+ } catch (error) {
168
+ console.warn("Browser autofocus setting could not be loaded", error);
169
+ }
170
+ return this.autofocusActivePage !== false;
171
},
172
173
toggleExtensionsMenu() {
@@ -300,8 +364,9 @@ const model = {
364
this.loading = true;
365
this.error = "";
366
const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? options.browser_id);
303
- this._mode = options?.mode === "modal" ? "modal" : "canvas";
304
- if (this._mode === "modal") {
367
+ const nextMode = options?.mode === "modal" ? "modal" : "canvas";
368
+ this.prepareSurfaceOpen(nextMode, requestedBrowserId);
369
+ if (nextMode === "modal") {
370
this.setupFloatingModal(element);
371
} else {
372
this.setupCanvasSurface(element);
@@ -309,7 +374,10 @@ const model = {
374
this.contextId = this.resolveContextId();
375
try {
376
await this.refreshStatus();
312
- await this.connectViewer({ browserId: requestedBrowserId });
377
+ const viewport = await this.waitForSurfaceViewport();
378
+ this.resetRenderedFrameIfViewportChanged(viewport, requestedBrowserId);
379
+ await this.connectViewer({ browserId: requestedBrowserId, initialViewport: viewport });
380
+ await this.syncViewportAfterSurfaceOpen();
381
} catch (error) {
382
this.error = error instanceof Error ? error.message : String(error);
383
} finally {
@@ -317,11 +385,80 @@ const model = {
385
}
386
},
387
388
+ prepareSurfaceOpen(nextMode, requestedBrowserId = null) {
389
+ const previousMode = this._mode;
390
+ const modeChanged = this._surfaceMounted && previousMode && previousMode !== nextMode;
391
+ const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId();
392
+ this._mode = nextMode;
393
+ this._surfaceMounted = true;
394
+ this._lastViewportKey = "";
395
+ if (!modeChanged && (this.frameSrc || !targetBrowserId)) return;
396
+
397
+ this.resetRenderedFrame();
398
+ this.resetViewportTracking();
399
+ this._surfaceSwitching = Boolean(targetBrowserId);
400
+ this.switchingBrowserId = targetBrowserId;
401
+ },
402
+
403
+ resetViewportTracking() {
404
+ this._lastViewportKey = "";
405
+ this._lastViewport = null;
406
+ },
407
+
408
+ resetRenderedFrame() {
409
+ this.cancelFrameRender();
410
+ this.frameSrc = "";
411
+ this._lastFrameAt = 0;
412
+ },
413
+
414
+ resetRenderedFrameIfViewportChanged(viewport = null, requestedBrowserId = null) {
415
+ if (!viewport || !this.frameSrc || !this._lastViewport) return;
416
+ const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId();
417
+ if (!this.sameBrowserId(this._lastViewport.browserId, targetBrowserId)) return;
418
+ const changed = Math.abs(this._lastViewport.width - viewport.width) > VIEWPORT_SYNC_SIZE_TOLERANCE
419
+ || Math.abs(this._lastViewport.height - viewport.height) > VIEWPORT_SYNC_SIZE_TOLERANCE;
420
+ if (!changed) return;
421
+
422
+ this.resetRenderedFrame();
423
+ this.resetViewportTracking();
424
+ this._surfaceSwitching = true;
425
+ this.switchingBrowserId = targetBrowserId;
426
+ },
427
+
428
+ async waitForSurfaceViewport() {
429
+ let lastKey = "";
430
+ let stableCount = 0;
431
+ for (let index = 0; index < 24; index += 1) {
432
+ await nextAnimationFrame();
433
+ const viewport = this.currentViewportSize();
434
+ if (!viewport) continue;
435
+ const key = `${viewport.width}x${viewport.height}`;
436
+ if (key === lastKey) {
437
+ stableCount += 1;
438
+ if (stableCount >= 2) return viewport;
439
+ } else {
440
+ stableCount = 0;
441
+ lastKey = key;
442
+ }
443
+ }
444
+ return this.currentViewportSize();
445
+ },
446
+
447
+ async syncViewportAfterSurfaceOpen() {
448
+ if (!this.connected || !this.activeBrowserId) return;
449
+ await this.waitForSurfaceViewport();
450
+ await this.syncViewport(true);
451
+ if (this._mode !== "canvas") return;
452
+ globalThis.setTimeout?.(() => this.queueViewportSync(true), 240);
453
+ globalThis.setTimeout?.(() => this.queueViewportSync(true), 420);
454
+ },
455
+
456
async connectViewer(options = {}) {
457
if (!this.contextId) {
458
this.connected = false;
459
this.error = "No active chat context is selected.";
460
this.switchingBrowserId = null;
461
+ this._surfaceSwitching = false;
462
return;
463
}
464
const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? this.activeBrowserId);
@@ -334,7 +471,7 @@ const model = {
471
if (sequence !== this._connectSequence || viewerToken !== this._viewerToken) {
472
return;
473
}
337
- const initialViewport = this.currentViewportSize();
474
+ const initialViewport = options.initialViewport || this.currentViewportSize();
475
let response;
476
try {
477
response = await websocket.request(
@@ -355,6 +492,7 @@ const model = {
492
} catch (error) {
493
if (sequence === this._connectSequence && viewerToken === this._viewerToken) {
494
this.switchingBrowserId = null;
495
+ this._surfaceSwitching = false;
496
throw error;
497
}
498
return;
@@ -393,6 +531,7 @@ const model = {
531
if (this.sameBrowserId(this.switchingBrowserId, incomingBrowserId || this.activeBrowserId)) {
532
this.switchingBrowserId = null;
533
}
534
+ this._surfaceSwitching = false;
535
} else {
536
this.cancelFrameRender();
537
if (!data.state) {
@@ -573,7 +712,7 @@ const model = {
712
},
713
714
async openNewBrowser() {
576
- await this.command("open", { url: "about:blank" });
715
+ await this.command("open");
716
},
717
718
isActiveBrowser(browser) {
@@ -647,6 +786,7 @@ const model = {
786
if (this.sameBrowserId(this.switchingBrowserId, snapshotId || this.activeBrowserId)) {
787
this.switchingBrowserId = null;
788
}
789
+ this._surfaceSwitching = false;
790
},
791
792
isSwitchingBrowser() {
@@ -654,7 +794,7 @@ const model = {
794
},
795
796
isBusy() {
657
- return Boolean(this.loading || this.commandInFlight || this.isSwitchingBrowser());
797
+ return Boolean(this.loading || this.commandInFlight || this._surfaceSwitching || this.isSwitchingBrowser());
798
},
799
800
setActiveBrowserId(id) {
@@ -664,6 +804,7 @@ const model = {
804
this.activeBrowserId = exists ? numeric : null;
805
if (this.activeBrowserId !== previous) {
806
this._lastViewportKey = "";
807
+ this._lastViewport = null;
808
}
809
},
810
@@ -673,17 +814,47 @@ const model = {
814
const rect = target.getBoundingClientRect();
815
const naturalWidth = target.naturalWidth || rect.width;
816
const naturalHeight = target.naturalHeight || rect.height;
817
+ let contentLeft = rect.left;
818
+ let contentTop = rect.top;
819
+ let contentWidth = rect.width;
820
+ let contentHeight = rect.height;
821
+
822
+ const objectFit = globalThis.getComputedStyle?.(target)?.objectFit || "";
823
+ if (
824
+ target.matches?.(".browser-frame")
825
+ && ["contain", "scale-down"].includes(objectFit)
826
+ && naturalWidth > 0
827
+ && naturalHeight > 0
828
+ && rect.width > 0
829
+ && rect.height > 0
830
+ ) {
831
+ const naturalRatio = naturalWidth / naturalHeight;
832
+ const rectRatio = rect.width / rect.height;
833
+ if (naturalRatio > rectRatio) {
834
+ contentWidth = rect.width;
835
+ contentHeight = rect.width / naturalRatio;
836
+ contentTop = rect.top + (rect.height - contentHeight) / 2;
837
+ } else {
838
+ contentHeight = rect.height;
839
+ contentWidth = rect.height * naturalRatio;
840
+ contentLeft = rect.left + (rect.width - contentWidth) / 2;
841
+ }
842
+ }
843
+
844
+ const relativeX = (event.clientX - contentLeft) / Math.max(1, contentWidth);
845
+ const relativeY = (event.clientY - contentTop) / Math.max(1, contentHeight);
846
return {
677
- x: ((event.clientX - rect.left) / Math.max(1, rect.width)) * naturalWidth,
678
- y: ((event.clientY - rect.top) / Math.max(1, rect.height)) * naturalHeight,
847
+ x: Math.max(0, Math.min(naturalWidth, relativeX * naturalWidth)),
848
+ y: Math.max(0, Math.min(naturalHeight, relativeY * naturalHeight)),
849
};
850
},
851
852
currentViewportSize() {
853
const stage = this._stageElement;
854
if (!stage) return null;
685
- const width = Math.floor(stage.clientWidth || 0);
686
- const height = Math.floor(stage.clientHeight || 0);
855
+ const rect = stage.getBoundingClientRect?.();
856
+ const width = Math.round(rect?.width || stage.clientWidth || 0);
857
+ const height = Math.round(rect?.height || stage.clientHeight || 0);
858
if (width < 80 || height < 80) return null;
859
return {
860
width: Math.max(320, width),
@@ -698,7 +869,7 @@ const model = {
869
this._viewportSyncTimer = globalThis.setTimeout(() => {
870
this._viewportSyncTimer = null;
871
void this.syncViewport(force);
701
- }, force ? 0 : 80);
872
+ }, force ? 0 : VIEWPORT_SYNC_DEBOUNCE_MS);
873
},
874
875
async syncViewport(force = false) {
@@ -706,7 +877,16 @@ const model = {
877
const viewport = this.currentViewportSize();
878
if (!viewport) return;
879
const key = `${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
709
- if (this._lastViewportKey === key) return;
880
+ if (
881
+ this._lastViewportKey === key
882
+ || (
883
+ !force
884
+ && this._lastViewport
885
+ && this.sameBrowserId(this._lastViewport.browserId, this.activeBrowserId)
886
+ && Math.abs(this._lastViewport.width - viewport.width) <= VIEWPORT_SYNC_SIZE_TOLERANCE
887
+ && Math.abs(this._lastViewport.height - viewport.height) <= VIEWPORT_SYNC_SIZE_TOLERANCE
888
+ )
889
+ ) return;
890
try {
891
await websocket.emit("browser_viewer_input", {
892
context_id: this.contextId,
@@ -717,8 +897,14 @@ const model = {
897
height: viewport.height,
898
});
899
this._lastViewportKey = key;
900
+ this._lastViewport = {
901
+ browserId: this.activeBrowserId,
902
+ width: viewport.width,
903
+ height: viewport.height,
904
+ };
905
} catch (error) {
906
this._lastViewportKey = "";
907
+ this._lastViewport = null;
908
console.warn("Browser viewport sync failed", error);
909
}
910
},
@@ -793,6 +979,8 @@ const model = {
979
this._connectSequence += 1;
980
this._viewerToken = "";
981
this.switchingBrowserId = null;
982
+ this._surfaceMounted = false;
983
+ this._surfaceSwitching = false;
984
this.commandInFlight = false;
985
if (this.contextId) {
986
try {
@@ -803,7 +991,7 @@ const model = {
991
this._stateOff?.();
992
this._frameOff = null;
993
this._stateOff = null;
806
- this.cancelFrameRender();
994
+ this.resetRenderedFrame();
995
this._floatingCleanup?.();
996
this._floatingCleanup = null;
997
this._stageResizeObserver?.disconnect?.();
@@ -813,7 +1001,7 @@ const model = {
1001
globalThis.clearTimeout(this._viewportSyncTimer);
1002
this._viewportSyncTimer = null;
1003
}
816
- this._lastViewportKey = "";
1004
+ this.resetViewportTracking();
1005
this.extensionMenuOpen = false;
1006
this.extensionActionLoading = false;
1007
this.extensionsListLoading = false;
plugins/_browser/webui/config.html
+95
@@ -15,6 +15,41 @@
15
x-effect="$store.browserConfig.bindConfig(config)"
16
x-destroy="$store.browserConfig.cleanup()"
17
>
18
+ <div class="browser-config-card">
19
+ <div class="section-title">Browsing</div>
20
+ <div class="section-description">
21
+ Set how new Browser sessions start and whether agent activity should pull focus.
22
+ </div>
23
+
24
+ <label class="browser-config-field">
25
+ <span class="browser-config-field-label">Starting page</span>
26
+ <input
27
+ type="text"
28
+ x-model="$store.browserConfig.config.default_homepage"
29
+ placeholder="about:blank or https://example.com"
30
+ autocomplete="off"
31
+ />
32
+ </label>
33
+
34
+ <label class="browser-config-switch-row">
35
+ <span class="browser-config-switch-copy">
36
+ <span class="browser-config-field-label">Autofocus active page</span>
37
+ <span class="browser-config-field-help">Focus pages opened or changed by Browser tool results.</span>
38
+ </span>
39
+ <span class="browser-config-toggle-with-label">
40
+ <span class="browser-config-toggle-label" x-text="$store.browserConfig.autofocusLabel()"></span>
41
+ <span class="browser-config-toggle">
42
+ <input
43
+ type="checkbox"
44
+ :checked="$store.browserConfig.config.autofocus_active_page !== false"
45
+ @change="$store.browserConfig.setAutofocusActivePage($event.target.checked)"
46
+ />
47
+ <span class="browser-config-switch"></span>
48
+ </span>
49
+ </span>
50
+ </label>
51
+ </div>
52
+
53
<div class="browser-config-card">
54
<div class="section-title">Extensions</div>
55
<div class="section-description">
@@ -86,6 +121,65 @@
121
border-radius: 8px;
122
}
123
124
+ .browser-config-field {
125
+ display: flex;
126
+ flex-direction: column;
127
+ gap: 6px;
128
+ }
129
+
130
+ .browser-config-field-label {
131
+ color: var(--color-text);
132
+ font-size: 0.84rem;
133
+ font-weight: 650;
134
+ }
135
+
136
+ .browser-config-field-help {
137
+ color: var(--color-text-secondary);
138
+ font-size: 0.78rem;
139
+ line-height: 1.35;
140
+ }
141
+
142
+ .browser-config-field input[type="text"] {
143
+ width: 100%;
144
+ min-height: 36px;
145
+ padding: 7px 10px;
146
+ border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
147
+ border-radius: 8px;
148
+ background: var(--color-input);
149
+ color: var(--color-text);
150
+ font: inherit;
151
+ }
152
+
153
+ .browser-config-switch-row {
154
+ display: grid;
155
+ grid-template-columns: minmax(0, 1fr) auto;
156
+ align-items: center;
157
+ gap: 16px;
158
+ min-height: 46px;
159
+ padding-top: 2px;
160
+ }
161
+
162
+ .browser-config-switch-copy {
163
+ display: flex;
164
+ min-width: 0;
165
+ flex-direction: column;
166
+ gap: 3px;
167
+ }
168
+
169
+ .browser-config-toggle-with-label {
170
+ display: inline-flex;
171
+ align-items: center;
172
+ gap: 8px;
173
+ }
174
+
175
+ .browser-config-toggle-label {
176
+ min-width: 24px;
177
+ color: var(--color-text-secondary);
178
+ font-size: 0.78rem;
179
+ font-weight: 650;
180
+ text-align: right;
181
+ }
182
+
183
.browser-config-extension-list {
184
display: flex;
185
flex-direction: column;
@@ -166,6 +260,7 @@
260
height: 100%;
261
border-radius: 999px;
262
background: color-mix(in srgb, var(--color-border) 78%, transparent);
263
+ pointer-events: none;
264
transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1);
265
}
266