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