Polish Browser chrome and extension management UX

Refine the Browser modal UI with more native-feeling tabs, consistent chrome controls, right-side tab close buttons, and a cleaner extension dropdown. Move the Browser LLM preset into the dropdown with the active Main Model summary, simplify extension settings, remove the global extension enable switch and legacy extension root behavior, and add per-extension enable toggles. Also updates the Chrome extension install/review flow with contextual warning copy, “Scan with A0”, cleaner labels, hidden empty extension state, and regression coverage for the new Browser UX.

Alessandro committed Apr 26, 2026 at 00:09 UTC cf67047ad3e4161e194a00bbf329ce28c266af8d
9 files changed +1179 -652
plugins/_browser/api/extensions.py
+66 -6
@@ -1,27 +1,87 @@
1 +from types import SimpleNamespace
2 +
3 +from helpers import plugins
4 from helpers.api import ApiHandler, Request
5 +from plugins._browser.helpers.config import (
6 + MODEL_PRESET_KEY,
7 + PLUGIN_NAME,
8 + get_browser_config,
9 + get_browser_main_model_summary,
10 + get_browser_model_preset_options,
11 +)
12 from plugins._browser.helpers.extension_manager import (
13 get_extensions_root,
14 install_chrome_web_store_extension,
15 list_browser_extensions,
16 + set_browser_extension_enabled,
17 )
18
19
20 class Extensions(ApiHandler):
21 async def process(self, input: dict, request: Request) -> dict:
22 action = input.get("action", "list")
23 + agent = self._agent_from_input(input)
24
25 if action == "list":
14 - return {
15 - "ok": True,
16 - "root": str(get_extensions_root()),
17 - "extensions": list_browser_extensions(),
18 - }
26 + return self._browser_extension_payload(agent=agent)
27
28 if action == "install_web_store":
29 try:
30 result = install_chrome_web_store_extension(str(input.get("url", "")))
31 except ValueError as exc:
32 return {"ok": False, "error": str(exc)}
25 - return result
33 + return {
34 + **self._browser_extension_payload(agent=agent),
35 + **result,
36 + }
37 +
38 + if action == "set_extension_enabled":
39 + try:
40 + set_browser_extension_enabled(
41 + str(input.get("path", "")),
42 + bool(input.get("enabled", False)),
43 + )
44 + except ValueError as exc:
45 + return {"ok": False, "error": str(exc)}
46 + return self._browser_extension_payload(agent=agent)
47 +
48 + if action == "set_model_preset":
49 + preset_name = str(input.get(MODEL_PRESET_KEY, "") or "").strip()
50 + available_presets = {
51 + option["name"]
52 + for option in get_browser_model_preset_options(agent=agent)
53 + if option.get("name") and not option.get("missing")
54 + }
55 + if preset_name and preset_name not in available_presets:
56 + return {"ok": False, "error": "Choose an available browser model preset."}
57 +
58 + config = get_browser_config()
59 + config[MODEL_PRESET_KEY] = preset_name
60 + plugins.save_plugin_config(PLUGIN_NAME, "", "", config)
61 + return self._browser_extension_payload(agent=agent)
62
63 return {"ok": False, "error": f"Unknown action: {action}"}
64 +
65 + def _agent_from_input(self, input: dict):
66 + context_id = str(input.get("context_id", "") or "").strip()
67 + if not context_id:
68 + return None
69 + try:
70 + context = self.use_context(context_id, create_if_not_exists=False)
71 + except Exception:
72 + return None
73 + config = getattr(context, "config", None)
74 + if config is None:
75 + return None
76 + return SimpleNamespace(context=context, config=config)
77 +
78 + def _browser_extension_payload(self, agent=None) -> dict:
79 + config = get_browser_config()
80 + return {
81 + "ok": True,
82 + "root": str(get_extensions_root()),
83 + "extensions": list_browser_extensions(),
84 + MODEL_PRESET_KEY: config[MODEL_PRESET_KEY],
85 + "main_model_summary": get_browser_main_model_summary(agent=agent),
86 + "model_preset_options": get_browser_model_preset_options(agent=agent, settings=config),
87 + }
plugins/_browser/default_config.yaml
+1 -4
@@ -1,8 +1,5 @@
1 -# Load unpacked Chromium extension directories into the Browser tool.
1 +# Enabled unpacked Chromium extension directories.
2 # Paths must be readable from the Agent Zero runtime itself.
3 -extensions_enabled: false
4 -
5 -# One unpacked extension directory per item.
3 extension_paths: []
4
5 # Optional _model_config preset used by Browser-owned model helpers.
plugins/_browser/helpers/config.py
+20 -14
@@ -42,11 +42,19 @@ def _normalize_model_preset(value: Any) -> str:
42 return str(value or "").strip()
43
44
45 +def _model_config_summary(config: dict[str, Any] | None) -> str:
46 + if not isinstance(config, dict):
47 + return ""
48 + provider = str(config.get("provider", "") or "").strip()
49 + model_name = str(config.get("name", "") or "").strip()
50 + return " / ".join(part for part in (provider, model_name) if part)
51 +
52 +
53 def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
54 raw = settings if isinstance(settings, dict) else {}
55 + extension_paths = _normalize_extension_paths(raw.get("extension_paths", []))
56 return {
48 - "extensions_enabled": bool(raw.get("extensions_enabled", False)),
49 - "extension_paths": _normalize_extension_paths(raw.get("extension_paths", [])),
57 + "extension_paths": extension_paths,
58 MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")),
59 }
60
@@ -54,7 +62,6 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
62 def browser_runtime_config(settings: dict[str, Any] | None) -> dict[str, Any]:
63 config = normalize_browser_config(settings)
64 return {
57 - "extensions_enabled": config["extensions_enabled"],
65 "extension_paths": config["extension_paths"],
66 }
67
@@ -96,9 +103,7 @@ def get_browser_model_preset_options(
103 chat_cfg = preset.get("chat", {}) if isinstance(preset, dict) else {}
104 if not isinstance(chat_cfg, dict):
105 chat_cfg = {}
99 - provider = str(chat_cfg.get("provider", "") or "").strip()
100 - model_name = str(chat_cfg.get("name", "") or "").strip()
101 - summary = " / ".join(part for part in (provider, model_name) if part)
106 + summary = _model_config_summary(chat_cfg)
107 options.append(
108 {
109 "name": name,
@@ -121,6 +126,12 @@ def get_browser_model_preset_options(
126 return options
127
128
129 +def get_browser_main_model_summary(agent: "Agent | None" = None) -> str:
130 + from plugins._model_config.helpers import model_config
131 +
132 + return _model_config_summary(model_config.get_chat_model_config(agent))
133 +
134 +
135 def resolve_browser_model_selection(
136 agent: "Agent | None" = None,
137 settings: dict[str, Any] | None = None,
@@ -216,16 +227,12 @@ def describe_browser_extensions(settings: dict[str, Any] | None) -> dict[str, An
227
228 active_paths = [item["path"] for item in path_details if item["loadable"]]
229 invalid_paths = [item["path"] for item in path_details if not item["loadable"]]
219 - active = bool(config["extensions_enabled"] and active_paths)
230 + active = bool(active_paths)
231
232 warnings: list[str] = []
222 - if config["extensions_enabled"] and not config["extension_paths"]:
223 - warnings.append(
224 - "Extensions are enabled, but no unpacked extension directories are configured."
225 - )
226 - elif config["extensions_enabled"] and not active_paths:
233 + if config["extension_paths"] and not active_paths:
234 warnings.append(
228 - "Extensions are enabled, but none of the configured extension directories are readable unpacked folders."
235 + "None of the enabled extension directories are readable unpacked folders."
236 )
237 elif invalid_paths:
238 warnings.append(
@@ -233,7 +240,6 @@ def describe_browser_extensions(settings: dict[str, Any] | None) -> dict[str, An
240 )
241
242 return {
236 - "enabled": bool(config["extensions_enabled"]),
243 "active": active,
244 "configured_paths": config["extension_paths"],
245 "active_paths": active_paths,
plugins/_browser/helpers/extension_manager.py
+48 -36
@@ -17,7 +17,6 @@ from plugins._browser.helpers.config import PLUGIN_NAME, get_browser_config
17
18
19 EXTENSIONS_ROOT_DIR = ("usr", "plugins", PLUGIN_NAME, "extensions")
20 -LEGACY_EXTENSIONS_ROOT_DIR = ("usr", "browser-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})")
@@ -43,22 +42,6 @@ def get_extensions_root() -> Path:
42 return root
43
44
46 -def get_extension_roots() -> list[Path]:
47 - roots = [
48 - get_extensions_root(),
49 - Path(files.get_abs_path(*LEGACY_EXTENSIONS_ROOT_DIR)),
50 - ]
51 - unique: list[Path] = []
52 - seen: set[str] = set()
53 - for root in roots:
54 - key = str(root)
55 - if key in seen:
56 - continue
57 - seen.add(key)
58 - unique.append(root)
59 - return unique
60 -
61 -
45 def parse_chrome_web_store_extension_id(value: str) -> str:
46 source = str(value or "").strip()
47 if EXTENSION_ID_RE.fullmatch(source):
@@ -75,25 +58,22 @@ def list_browser_extensions() -> list[dict[str, Any]]:
58 config = get_browser_config()
59 enabled_paths = {str(Path(path).expanduser()) for path in config["extension_paths"]}
60 entries: list[dict[str, Any]] = []
61 + seen: set[str] = set()
62
79 - for root in get_extension_roots():
80 - if not root.exists():
81 - continue
63 + root = get_extensions_root()
64 + if root.exists():
65 for manifest_path in sorted(root.glob("**/manifest.json")):
83 - extension_dir = manifest_path.parent
84 - try:
85 - manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
86 - except Exception:
87 - manifest = {}
88 - extension_path = str(extension_dir)
89 - entries.append(
90 - {
91 - "name": manifest.get("name") or extension_dir.name,
92 - "version": manifest.get("version") or "",
93 - "path": extension_path,
94 - "enabled": extension_path in enabled_paths,
95 - }
96 - )
66 + entry = _extension_entry(manifest_path.parent, enabled_paths)
67 + seen.add(entry["path"])
68 + entries.append(entry)
69 +
70 + for configured_path in config["extension_paths"]:
71 + extension_dir = Path(configured_path).expanduser()
72 + extension_path = str(extension_dir)
73 + if extension_path in seen or not (extension_dir / "manifest.json").is_file():
74 + continue
75 + entries.append(_extension_entry(extension_dir, enabled_paths))
76 + seen.add(extension_path)
77
78 return entries
79
@@ -126,11 +106,33 @@ def install_chrome_web_store_extension(source: str) -> dict[str, Any]:
106 "name": manifest.get("name") or extension_id,
107 "version": manifest.get("version") or "",
108 "path": str(target),
129 - "extensions_enabled": config["extensions_enabled"],
109 "extension_paths": config["extension_paths"],
110 }
111
112
113 +def set_browser_extension_enabled(extension_path: str, enabled: bool) -> dict[str, Any]:
114 + raw_path = str(extension_path or "").strip()
115 + if not raw_path:
116 + raise ValueError("Choose an extension first.")
117 +
118 + path = str(Path(raw_path).expanduser())
119 + directory = Path(path)
120 + if enabled and not (directory / "manifest.json").is_file():
121 + raise ValueError("Extension folder must contain a manifest.json file.")
122 +
123 + config = get_browser_config()
124 + paths = list(config["extension_paths"])
125 + if enabled:
126 + if path not in paths:
127 + paths.append(path)
128 + else:
129 + paths = [item for item in paths if str(Path(item).expanduser()) != path]
130 +
131 + config["extension_paths"] = paths
132 + plugins.save_plugin_config(PLUGIN_NAME, "", "", config)
133 + return config
134 +
135 +
136 def _download_crx(extension_id: str, archive_path: Path) -> None:
137 prodversion = _detect_chrome_prodversion()
138 url = _build_web_store_download_url(extension_id, prodversion=prodversion)
@@ -250,12 +252,22 @@ def _enable_extension_path(extension_path: Path) -> dict[str, Any]:
252 paths = list(config["extension_paths"])
253 if path not in paths:
254 paths.append(path)
253 - config["extensions_enabled"] = True
255 config["extension_paths"] = paths
256 plugins.save_plugin_config(PLUGIN_NAME, "", "", config)
257 return config
258
259
260 +def _extension_entry(extension_dir: Path, enabled_paths: set[str]) -> dict[str, Any]:
261 + manifest = _read_manifest(extension_dir)
262 + extension_path = str(extension_dir)
263 + return {
264 + "name": manifest.get("name") or extension_dir.name,
265 + "version": manifest.get("version") or "",
266 + "path": extension_path,
267 + "enabled": extension_path in enabled_paths,
268 + }
269 +
270 +
271 def _read_manifest(extension_path: Path) -> dict[str, Any]:
272 manifest_path = extension_path / "manifest.json"
273 try:
plugins/_browser/webui/browser-config-store.js
+42 -84
@@ -1,7 +1,7 @@
1 import { createStore } from "/js/AlpineStore.js";
2 -import { fetchApi } from "/js/api.js";
2 +import { callJsonApi } from "/js/api.js";
3
4 -const MODEL_CONFIG_API = "/plugins/_model_config/model_presets";
4 +const BROWSER_EXTENSIONS_API = "/plugins/_browser/extensions";
5
6 function normalizePathList(value) {
7 const source = Array.isArray(value)
@@ -20,9 +20,6 @@ function normalizePathList(value) {
20
21 function ensureConfig(config) {
22 if (!config || typeof config !== "object") return null;
23 - if (typeof config.extensions_enabled !== "boolean") {
24 - config.extensions_enabled = Boolean(config.extensions_enabled);
25 - }
23 config.extension_paths = normalizePathList(config.extension_paths);
24 config.model_preset = String(config.model_preset || "").trim();
25 delete config.model;
@@ -31,21 +28,19 @@ function ensureConfig(config) {
28
29 export const store = createStore("browserConfig", {
30 config: null,
34 - extensionPathsText: "",
35 - presets: [],
36 - presetsLoading: false,
37 - presetsError: "",
38 - _presetsLoaded: false,
31 + extensionsList: [],
32 + extensionsLoading: false,
33 + extensionsError: "",
34
35 async init(config) {
36 this.bindConfig(config);
42 - await this.loadPresets();
37 + await this.loadExtensionsList();
38 },
39
40 cleanup() {
41 this.config = null;
47 - this.extensionPathsText = "";
48 - this.presetsError = "";
42 + this.extensionsList = [];
43 + this.extensionsError = "";
44 },
45
46 bindConfig(config) {
@@ -53,18 +48,6 @@ export const store = createStore("browserConfig", {
48 if (!safeConfig) return;
49 if (this.config === safeConfig) return;
50 this.config = safeConfig;
56 - this.extensionPathsText = safeConfig.extension_paths.join("\n");
57 - },
58 -
59 - setExtensionPathsText(value) {
60 - this.extensionPathsText = String(value || "");
61 - this.syncExtensionPaths();
62 - },
63 -
64 - syncExtensionPaths() {
65 - const safeConfig = ensureConfig(this.config);
66 - if (!safeConfig) return;
67 - safeConfig.extension_paths = normalizePathList(this.extensionPathsText);
51 },
52
53 hasPaths() {
@@ -72,84 +55,59 @@ export const store = createStore("browserConfig", {
55 },
56
57 pathCount() {
75 - return normalizePathList(this.extensionPathsText).length;
58 + return normalizePathList(this.config?.extension_paths).length;
59 },
60
61 pathCountLabel() {
62 const count = this.pathCount();
80 - if (!count) return "No extension paths configured";
81 - return `${count} path${count === 1 ? "" : "s"} configured`;
63 + if (!count) return "No extensions enabled";
64 + return `${count} extension${count === 1 ? "" : "s"} enabled`;
65 },
66
67 extensionModeReady() {
85 - const safeConfig = ensureConfig(this.config);
86 - return Boolean(safeConfig?.extensions_enabled && this.pathCount());
68 + return this.pathCount() > 0;
69 },
70
89 - async loadPresets() {
90 - if (this._presetsLoaded || this.presetsLoading) return;
91 - this.presetsLoading = true;
92 - this.presetsError = "";
71 + async loadExtensionsList() {
72 + if (this.extensionsLoading) return;
73 + this.extensionsLoading = true;
74 + this.extensionsError = "";
75 try {
94 - const response = await fetchApi(MODEL_CONFIG_API, {
95 - method: "POST",
96 - headers: { "Content-Type": "application/json" },
97 - body: JSON.stringify({ action: "get" }),
98 - });
99 - const data = await response.json().catch(() => ({}));
100 - this.presets = Array.isArray(data?.presets)
101 - ? data.presets.filter((preset) => String(preset?.name || "").trim())
102 - : [];
103 - this._presetsLoaded = true;
76 + const response = await callJsonApi(BROWSER_EXTENSIONS_API, { action: "list" });
77 + if (!response?.ok) {
78 + throw new Error(response?.error || "Could not load browser extensions.");
79 + }
80 + this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
81 } catch (error) {
105 - this.presets = [];
106 - this.presetsError = error instanceof Error ? error.message : String(error);
82 + this.extensionsList = [];
83 + this.extensionsError = error instanceof Error ? error.message : String(error);
84 } finally {
108 - this.presetsLoading = false;
85 + this.extensionsLoading = false;
86 }
87 },
88
112 - selectedPreset() {
113 - const selected = String(this.config?.model_preset || "").trim();
114 - if (!selected) return null;
115 - return this.presets.find((preset) => preset?.name === selected) || null;
89 + extensionEnabled(extension) {
90 + const path = typeof extension === "string" ? extension : extension?.path;
91 + return normalizePathList(this.config?.extension_paths).includes(String(path || ""));
92 },
93
118 - presetOptions() {
119 - const selected = String(this.config?.model_preset || "").trim();
120 - const options = this.presets.map((preset) => ({
121 - ...preset,
122 - label: preset.name,
123 - missing: false,
124 - }));
125 - if (selected && this._presetsLoaded && !options.some((preset) => preset.name === selected)) {
126 - options.push({
127 - name: selected,
128 - label: `${selected} (missing)`,
129 - missing: true,
130 - });
94 + setExtensionEnabled(extension, enabled) {
95 + const path = String((typeof extension === "string" ? extension : extension?.path) || "").trim();
96 + if (!path) return;
97 + const safeConfig = ensureConfig(this.config);
98 + if (!safeConfig) return;
99 + const paths = normalizePathList(safeConfig.extension_paths);
100 + if (enabled && !paths.includes(path)) {
101 + paths.push(path);
102 + } else if (!enabled) {
103 + const index = paths.indexOf(path);
104 + if (index >= 0) paths.splice(index, 1);
105 }
132 - return options;
133 - },
134 -
135 - selectedPresetSummary() {
136 - const selected = String(this.config?.model_preset || "").trim();
137 - if (!selected) return "Using the effective Main Model.";
138 -
139 - const preset = this.selectedPreset();
140 - if (!preset) return `Preset "${selected}" is not available. Browser will fall back to the Main Model.`;
141 -
142 - const chat = preset.chat || {};
143 - const parts = [chat.provider, chat.name].filter((item) => String(item || "").trim());
144 - return parts.length ? parts.join(" / ") : "This preset has no Main Model; Browser will fall back to the Main Model.";
145 - },
146 -
147 - selectedPresetMissing() {
148 - const selected = String(this.config?.model_preset || "").trim();
149 - return Boolean(selected && this._presetsLoaded && !this.selectedPreset());
106 + safeConfig.extension_paths = paths;
107 },
108
152 - openPresets() {
153 - void globalThis.openModal?.("/plugins/_model_config/webui/main.html");
109 + extensionVersionLabel(extension) {
110 + const version = String(extension?.version || "").trim();
111 + return version ? `v${version}` : "Unpacked extension";
112 },
113 });
plugins/_browser/webui/browser-store.js
+145 -30
@@ -2,13 +2,12 @@ import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { getNamespacedClient } from "/js/websocket.js";
4 import { store as chatInputStore } from "/components/chat/input/input-store.js";
5 -import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
6
7 const websocket = getNamespacedClient("/ws");
8 websocket.addHandlers(["ws_webui"]);
9
11 -const EXTENSIONS_ROOT_FALLBACK = "/a0/usr/plugins/_browser/extensions";
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
@@ -53,6 +52,12 @@ const model = {
52 extensionActionError: "",
53 extensionsRoot: "",
54 extensionsList: [],
55 + extensionsListLoading: false,
56 + extensionToggleLoadingPath: "",
57 + modelPreset: "",
58 + modelPresetOptions: [],
59 + mainModelSummary: "",
60 + modelPresetSaving: false,
61 browserInstallExpected: false,
62
63 async refreshStatus() {
@@ -61,13 +66,33 @@ const model = {
66 },
67
68 async refreshExtensionsList() {
64 - const response = await callJsonApi("/plugins/_browser/extensions", { action: "list" });
65 - if (response?.ok) {
66 - this.extensionsRoot = response.root || EXTENSIONS_ROOT_FALLBACK;
67 - this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
69 + this.extensionsListLoading = true;
70 + try {
71 + const response = await callJsonApi("/plugins/_browser/extensions", {
72 + action: "list",
73 + context_id: this.contextId,
74 + });
75 + if (!response?.ok) {
76 + throw new Error(response?.error || "Could not load browser extensions.");
77 + }
78 + this.applyExtensionPayload(response);
79 + } catch (error) {
80 + this.extensionActionError = error instanceof Error ? error.message : String(error);
81 + } finally {
82 + this.extensionsListLoading = false;
83 }
84 },
85
86 + applyExtensionPayload(response = {}) {
87 + this.extensionsRoot = response.root || EXTENSIONS_ROOT;
88 + this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
89 + this.modelPreset = String(response.model_preset || "");
90 + this.mainModelSummary = String(response.main_model_summary || "");
91 + this.modelPresetOptions = Array.isArray(response.model_preset_options)
92 + ? response.model_preset_options
93 + : [];
94 + },
95 +
96 toggleExtensionsMenu() {
97 this.extensionMenuOpen = !this.extensionMenuOpen;
98 if (this.extensionMenuOpen) {
@@ -121,37 +146,30 @@ const model = {
146 }
147 },
148
124 - async openExtensionsFolder() {
125 - this.closeExtensionsMenu();
126 - try {
127 - if (!this.extensionsRoot) {
128 - await this.refreshExtensionsList();
129 - }
130 - void fileBrowserStore.open(this.extensionsRoot || EXTENSIONS_ROOT_FALLBACK);
131 - } catch (error) {
132 - this.extensionActionError = error instanceof Error ? error.message : String(error);
133 - }
134 - },
135 -
149 createExtensionWithAgent() {
150 this._prefillAgentPrompt(
151 [
152 "Use the a0-browser-ext skill to create a new Chrome extension for Agent Zero's Browser.",
153 "Start by asking me for the extension name, purpose, target websites, and required permissions.",
141 - `Create it under ${this.extensionsRoot || EXTENSIONS_ROOT_FALLBACK}/<extension-slug> and keep permissions minimal.`,
154 + `Create it under ${this.extensionsRoot || EXTENSIONS_ROOT}/<extension-slug> and keep permissions minimal.`,
155 ].join("\n")
156 );
157 },
158
159 askAgentInstallExtension() {
160 const url = String(this.extensionInstallUrl || "").trim();
148 - this._prefillAgentPrompt(
149 - [
150 - "Use the a0-browser-ext skill to install and review a Chrome Web Store extension for Agent Zero's Browser.",
151 - url ? `Chrome Web Store URL or id: ${url}` : "Ask me for the Chrome Web Store URL or extension id first.",
152 - "Explain the permissions and any sandbox risk before enabling it.",
153 - ].join("\n")
154 - );
161 + const prompt = url
162 + ? [
163 + "Use the a0-browser-ext skill to review and optionally install this Chrome Web Store extension for Agent Zero's Browser.",
164 + `Chrome Web Store URL or id: ${url}`,
165 + "Explain the permissions and any sandbox risk before enabling it.",
166 + ].join("\n")
167 + : [
168 + "Use the a0-browser-ext skill to help me install and review a Chrome Web Store extension for Agent Zero's Browser.",
169 + "Ask me for the Chrome Web Store URL or extension id first.",
170 + "Explain the permissions and any sandbox risk before enabling it.",
171 + ].join("\n");
172 + this._prefillAgentPrompt(prompt);
173 },
174
175 async installExtensionFromUrl() {
@@ -167,15 +185,16 @@ const model = {
185 try {
186 const response = await callJsonApi("/plugins/_browser/extensions", {
187 action: "install_web_store",
188 + context_id: this.contextId,
189 url,
190 });
191 if (!response?.ok) {
192 throw new Error(response?.error || "Install failed.");
193 }
194 + this.applyExtensionPayload(response);
195 this.extensionInstallUrl = "";
176 - this.extensionActionMessage = `Installed ${response.name || response.id}. Browser sessions restart when extension settings change.`;
177 - await this.refreshStatus();
178 - await this.refreshExtensionsList();
196 + this.extensionActionMessage = `Installed ${response.name || response.id}.`;
197 + await this.refreshAfterSettingsClose();
198 } catch (error) {
199 this.extensionActionError = error instanceof Error ? error.message : String(error);
200 } finally {
@@ -183,6 +202,80 @@ const model = {
202 }
203 },
204
205 + async setExtensionEnabled(extension, enabled, input = null) {
206 + const path = String(extension?.path || "");
207 + if (!path) return;
208 + const previous = Boolean(extension?.enabled);
209 + this.extensionActionMessage = "";
210 + this.extensionActionError = "";
211 + this.extensionToggleLoadingPath = path;
212 + try {
213 + const response = await callJsonApi("/plugins/_browser/extensions", {
214 + action: "set_extension_enabled",
215 + context_id: this.contextId,
216 + path,
217 + enabled: Boolean(enabled),
218 + });
219 + if (!response?.ok) {
220 + throw new Error(response?.error || "Could not update extension.");
221 + }
222 + this.applyExtensionPayload(response);
223 + this.extensionActionMessage = `${enabled ? "Enabled" : "Disabled"} ${extension.name || "extension"}.`;
224 + await this.refreshAfterSettingsClose();
225 + } catch (error) {
226 + if (input) input.checked = previous;
227 + this.extensionActionError = error instanceof Error ? error.message : String(error);
228 + } finally {
229 + this.extensionToggleLoadingPath = "";
230 + }
231 + },
232 +
233 + async setBrowserModelPreset(value) {
234 + const presetName = String(value || "");
235 + this.modelPreset = presetName;
236 + this.extensionActionMessage = "";
237 + this.extensionActionError = "";
238 + this.modelPresetSaving = true;
239 + try {
240 + const response = await callJsonApi("/plugins/_browser/extensions", {
241 + action: "set_model_preset",
242 + context_id: this.contextId,
243 + model_preset: presetName,
244 + });
245 + if (!response?.ok) {
246 + throw new Error(response?.error || "Could not update browser model preset.");
247 + }
248 + this.applyExtensionPayload(response);
249 + this.extensionActionMessage = "Browser model preset updated.";
250 + } catch (error) {
251 + this.extensionActionError = error instanceof Error ? error.message : String(error);
252 + await this.refreshExtensionsList();
253 + } finally {
254 + this.modelPresetSaving = false;
255 + }
256 + },
257 +
258 + modelPresetSummary() {
259 + if (!this.modelPreset) {
260 + return this.mainModelSummary ? `Using ${this.mainModelSummary}` : "Using Main Model";
261 + }
262 + const option = this.modelPresetOptions.find((preset) => preset?.name === this.modelPreset);
263 + return option?.summary || option?.label || this.modelPreset;
264 + },
265 +
266 + hasExtensionInstallUrl() {
267 + return Boolean(String(this.extensionInstallUrl || "").trim());
268 + },
269 +
270 + extensionAssistantActionLabel() {
271 + return "Scan with A0";
272 + },
273 +
274 + extensionVersionLabel(extension) {
275 + const version = String(extension?.version || "").trim();
276 + return version ? `v${version}` : "Unpacked extension";
277 + },
278 +
279 _prefillAgentPrompt(prompt) {
280 chatInputStore.message = prompt;
281 chatInputStore.adjustTextareaHeight?.();
@@ -332,7 +425,7 @@ const model = {
425
426 async selectBrowser(id) {
427 if (String(id || "").trim() === "") {
335 - await this.command("open", { url: "about:blank" });
428 + await this.openNewBrowser();
429 return;
430 }
431 this.setActiveBrowserId(id);
@@ -341,6 +434,25 @@ const model = {
434 }
435 },
436
437 + async openNewBrowser() {
438 + await this.command("open", { url: "about:blank" });
439 + },
440 +
441 + isActiveBrowser(browser) {
442 + return Number(browser?.id) === Number(this.activeBrowserId);
443 + },
444 +
445 + browserTabTitle(browser) {
446 + const title = String(browser?.title || "").trim();
447 + const url = String(browser?.currentUrl || "").trim();
448 + return title || url || "about:blank";
449 + },
450 +
451 + browserTabLabel(browser) {
452 + const id = browser?.id ? `#${browser.id}` : "Browser";
453 + return `${id} ${this.browserTabTitle(browser)}`;
454 + },
455 +
456 firstBrowserId() {
457 const first = Array.isArray(this.browsers) ? this.browsers[0] : null;
458 return first?.id || null;
@@ -480,6 +592,9 @@ const model = {
592 this._lastViewportKey = "";
593 this.extensionMenuOpen = false;
594 this.extensionActionLoading = false;
595 + this.extensionsListLoading = false;
596 + this.extensionToggleLoadingPath = "";
597 + this.modelPresetSaving = false;
598 this.connected = false;
599 },
600
plugins/_browser/webui/config.html
+128 -104
@@ -16,121 +16,53 @@
16 x-destroy="$store.browserConfig.cleanup()"
17 >
18 <div class="browser-config-card">
19 - <div class="section-title">Browser Model Preset</div>
19 + <div class="section-title">Extensions</div>
20 <div class="section-description">
21 - Choose an optional Model Configuration preset for Browser-owned model helpers. Leave it
22 - on default to follow the effective Main Model.
23 - </div>
24 -
25 - <div class="field">
26 - <div class="field-label">
27 - <div class="field-title">Preset</div>
28 - <div class="field-description" x-text="$store.browserConfig.selectedPresetSummary()"></div>
29 - </div>
30 - <div class="field-control">
31 - <select x-model="config.model_preset" :disabled="$store.browserConfig.presetsLoading">
32 - <option value="">Default Main Model</option>
33 - <template x-for="preset in $store.browserConfig.presetOptions()" :key="preset.name">
34 - <option :value="preset.name" x-text="preset.label"></option>
35 - </template>
36 - </select>
37 - </div>
38 - </div>
39 -
40 - <div class="browser-config-note" x-show="$store.browserConfig.presetsLoading">
41 - <span class="material-symbols-outlined spinning">progress_activity</span>
42 - <span>Loading model presets...</span>
43 - </div>
44 -
45 - <div class="browser-config-warning" x-show="$store.browserConfig.selectedPresetMissing()">
46 - <span class="material-symbols-outlined">warning</span>
47 - <span>The saved preset is missing. Browser will use the effective Main Model until you choose another preset.</span>
48 - </div>
49 -
50 - <div class="browser-config-note" x-show="$store.browserConfig.presetsError">
51 - <span class="material-symbols-outlined">error</span>
52 - <span x-text="$store.browserConfig.presetsError"></span>
53 - </div>
54 -
55 - <div class="browser-config-actions">
56 - <button type="button" class="btn btn-field" @click="$store.browserConfig.openPresets()">
57 - <span class="material-symbols-outlined">tune</span>
58 - <span>Edit Presets</span>
59 - </button>
60 - </div>
61 - </div>
62 -
63 - <div class="browser-config-card">
64 - <div class="section-title">Chrome Extensions</div>
65 - <div class="section-description">
66 - Load unpacked Chromium extensions into the Browser tool. When extensions are active,
67 - Browser loads them into the same persistent full Chromium runtime used for every
68 - Browser session.
21 + Choose which installed Chrome extensions Browser loads.
22 </div>
23
24 <div class="browser-config-warning">
25 <span class="material-symbols-outlined">warning</span>
26 <span>
74 - Browser extensions run inside the Docker browser sandbox, but malicious or buggy
75 - extensions can still damage that sandboxed environment. Install only extensions you
76 - trust and keep permissions as small as possible.
27 + Extensions run inside the Docker browser sandbox, but malicious or buggy extensions can
28 + still damage that environment. Keep only the extensions you trust enabled.
29 </span>
30 </div>
31
80 - <div class="field">
81 - <div class="field-label">
82 - <div class="field-title">Enable extensions</div>
83 - <div class="field-description">
84 - Turn this on only when you have unpacked extension folders ready. Saving changes
85 - restarts active Browser sessions so the new launch mode applies immediately.
86 - </div>
32 + <div class="browser-config-extension-list">
33 + <div class="browser-config-subtitle">
34 + <span>Installed extensions</span>
35 + <span class="material-symbols-outlined spinning" x-show="$store.browserConfig.extensionsLoading">progress_activity</span>
36 </div>
88 - <div class="field-control">
89 - <label class="toggle">
90 - <input type="checkbox" x-model="config.extensions_enabled" />
91 - <span class="toggler"></span>
37 + <template x-if="!$store.browserConfig.extensionsLoading && !$store.browserConfig.extensionsList.length">
38 + <div class="browser-config-empty">No installed extensions found.</div>
39 + </template>
40 + <template x-for="extension in $store.browserConfig.extensionsList" :key="extension.path">
41 + <label class="browser-config-extension-row" :title="extension.path">
42 + <span class="browser-config-extension-text">
43 + <span class="browser-config-extension-name" x-text="extension.name || 'Unnamed extension'"></span>
44 + <span class="browser-config-extension-meta" x-text="$store.browserConfig.extensionVersionLabel(extension)"></span>
45 + </span>
46 + <span class="browser-config-toggle">
47 + <input
48 + type="checkbox"
49 + :checked="$store.browserConfig.extensionEnabled(extension)"
50 + @change="$store.browserConfig.setExtensionEnabled(extension, $event.target.checked)"
51 + />
52 + <span class="browser-config-switch"></span>
53 + </span>
54 </label>
55 + </template>
56 + <div class="browser-config-note" x-show="$store.browserConfig.extensionsError">
57 + <span class="material-symbols-outlined">error</span>
58 + <span x-text="$store.browserConfig.extensionsError"></span>
59 </div>
60 </div>
61
96 - <div class="field">
97 - <div class="field-label">
98 - <div class="field-title">Extension directories</div>
99 - <div class="field-description">
100 - One unpacked extension directory per line. Use paths that are visible inside the
101 - runtime environment itself, especially when Agent Zero is running in Docker.
102 - </div>
103 - </div>
104 - <div class="field-control">
105 - <textarea
106 - :value="$store.browserConfig.extensionPathsText"
107 - @input="$store.browserConfig.setExtensionPathsText($event.target.value)"
108 - rows="6"
109 - placeholder="/a0/usr/plugins/_browser/extensions/my-extension"
110 - ></textarea>
111 - </div>
112 - </div>
113 -
114 - <div class="browser-config-note">
115 - <span class="material-symbols-outlined">info</span>
116 - <span>
117 - Chrome Web Store URL installs are converted into unpacked folders under
118 - /a0/usr/plugins/_browser/extensions/chrome-web-store, then loaded from the directory list above.
119 - </span>
120 - </div>
121 -
122 - <div class="browser-config-note">
123 - <span class="material-symbols-outlined">deployed_code</span>
124 - <span>
125 - Browser caches Playwright Chromium under /a0/usr/plugins/_browser/playwright. The first Browser
126 - run can take a few minutes while Chromium is installed; later starts reuse that cache.
127 - </span>
128 - </div>
129 -
62 <div class="browser-config-pill-row">
63 <span class="browser-config-pill" x-text="$store.browserConfig.pathCountLabel()"></span>
64 <span class="browser-config-pill tone-active" x-show="$store.browserConfig.extensionModeReady()">
133 - Extension mode ready
65 + Ready
66 </span>
67 </div>
68 </div>
@@ -154,16 +86,108 @@
86 border-radius: 8px;
87 }
88
157 - .browser-config-card textarea {
158 - min-height: 132px;
159 - resize: vertical;
160 - font-family: var(--font-family-monospace, monospace);
89 + .browser-config-extension-list {
90 + display: flex;
91 + flex-direction: column;
92 + gap: 8px;
93 + padding: 12px;
94 + border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
95 + border-radius: 8px;
96 + background: color-mix(in srgb, var(--color-panel) 78%, transparent);
97 }
98
163 - .browser-config-actions {
99 + .browser-config-subtitle {
100 display: flex;
165 - flex-wrap: wrap;
101 + align-items: center;
102 + justify-content: space-between;
103 gap: 8px;
104 + color: var(--color-text-secondary);
105 + font-size: 0.82rem;
106 + font-weight: 650;
107 + }
108 +
109 + .browser-config-subtitle .material-symbols-outlined {
110 + font-size: 18px;
111 + }
112 +
113 + .browser-config-empty,
114 + .browser-config-extension-meta {
115 + color: var(--color-text-secondary);
116 + font-size: 0.78rem;
117 + }
118 +
119 + .browser-config-extension-row {
120 + display: grid;
121 + grid-template-columns: minmax(0, 1fr) auto;
122 + align-items: center;
123 + gap: 14px;
124 + min-height: 42px;
125 + padding: 8px 0;
126 + }
127 +
128 + .browser-config-extension-row + .browser-config-extension-row {
129 + border-top: 1px solid color-mix(in srgb, var(--color-border) 52%, transparent);
130 + }
131 +
132 + .browser-config-extension-text {
133 + display: flex;
134 + min-width: 0;
135 + flex-direction: column;
136 + gap: 2px;
137 + }
138 +
139 + .browser-config-extension-name {
140 + overflow: hidden;
141 + text-overflow: ellipsis;
142 + white-space: nowrap;
143 + color: var(--color-text);
144 + font-weight: 650;
145 + }
146 +
147 + .browser-config-toggle {
148 + position: relative;
149 + display: inline-flex;
150 + flex: 0 0 auto;
151 + align-items: center;
152 + width: 42px;
153 + height: 24px;
154 + }
155 +
156 + .browser-config-toggle input {
157 + position: absolute;
158 + inset: 0;
159 + margin: 0;
160 + opacity: 0;
161 + cursor: pointer;
162 + }
163 +
164 + .browser-config-switch {
165 + width: 100%;
166 + height: 100%;
167 + border-radius: 999px;
168 + background: color-mix(in srgb, var(--color-border) 78%, transparent);
169 + transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1);
170 + }
171 +
172 + .browser-config-switch::after {
173 + content: "";
174 + position: absolute;
175 + top: 3px;
176 + left: 3px;
177 + width: 18px;
178 + height: 18px;
179 + border-radius: 50%;
180 + background: var(--color-background);
181 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.24);
182 + transition: transform 0.18s cubic-bezier(0.4, 0, 0.2, 1);
183 + }
184 +
185 + .browser-config-toggle input:checked + .browser-config-switch {
186 + background: color-mix(in srgb, var(--color-primary) 76%, #16a34a);
187 + }
188 +
189 + .browser-config-toggle input:checked + .browser-config-switch::after {
190 + transform: translateX(18px);
191 }
192
193 .browser-config-note {
plugins/_browser/webui/main.html
+670 -356
@@ -1,141 +1,161 @@
1 <html class="browser-modal">
2 +
3 <head>
4 <title>Browser</title>
5 <script type="module">
6 import { store } from "/plugins/_browser/webui/browser-store.js";
7 </script>
8 </head>
9 +
10 <body class="browser-modal-body">
11 <div x-data>
12 <template x-if="$store.browserPage">
11 - <div
12 - class="browser-panel"
13 - x-create="$store.browserPage.onOpen($el)"
14 - x-destroy="$store.browserPage.cleanup()"
15 - @keydown.window="$store.browserPage.sendKey($event)"
16 - >
17 - <div class="browser-meta">
18 - <div class="browser-meta-top">
19 - <div class="browser-titleline">
20 - <span
21 - class="browser-live-dot"
22 - :class="{ active: $store.browserPage.connected && $store.browserPage.frameSrc }"
23 - ></span>
24 - <span class="browser-title">Browser</span>
25 - <span class="browser-id" x-show="$store.browserPage.activeBrowserId" x-text="'#' + $store.browserPage.activeBrowserId"></span>
26 - </div>
27 - <div class="browser-session-controls">
28 - <select class="browser-select" x-model="$store.browserPage.activeBrowserId" @change="$store.browserPage.selectBrowser($event.target.value)">
29 - <option value="">New Browser</option>
30 - <template x-for="browser in $store.browserPage.browsers" :key="browser.id">
31 - <option :value="browser.id" x-text="'#' + browser.id + ' ' + (browser.title || browser.currentUrl || 'about:blank')"></option>
32 - </template>
33 - </select>
34 - <div
35 - class="browser-extension-menu"
36 - @click.outside="$store.browserPage.closeExtensionsMenu()"
37 - @keydown.escape.window="$store.browserPage.closeExtensionsMenu()"
38 - >
39 - <button
40 - type="button"
41 - class="btn btn-icon-action browser-extensions"
42 - title="Browser extensions"
43 - aria-label="Browser extensions"
44 - @click.stop="$store.browserPage.toggleExtensionsMenu()"
45 - :aria-expanded="$store.browserPage.extensionMenuOpen.toString()"
46 - :class="{ 'is-active': $store.browserPage.status?.extensions?.active }"
47 - >
48 - <span class="material-symbols-outlined">extension</span>
49 - </button>
50 - <div
51 - class="browser-extension-dropdown"
52 - x-show="$store.browserPage.extensionMenuOpen"
53 - x-transition
54 - style="display: none;"
55 - >
56 - <div class="browser-extension-warning">
57 - <span class="material-symbols-outlined">warning</span>
58 - <span>
59 - Extensions run inside the Docker browser sandbox, but malicious or buggy extensions can still damage that environment. Review what you install.
60 - </span>
61 - </div>
62 - <button type="button" class="dropdown-item" @click="$store.browserPage.createExtensionWithAgent()">
63 - <span class="material-symbols-outlined">add_circle</span>
64 - <span>+ Create New with A0</span>
65 - </button>
66 - <div class="browser-extension-url">
67 - <label for="browser-extension-url">Chrome Web Store URL</label>
68 - <input
69 - id="browser-extension-url"
70 - type="url"
71 - x-model="$store.browserPage.extensionInstallUrl"
72 - @keydown.enter.prevent="$store.browserPage.installExtensionFromUrl()"
73 - placeholder="https://chromewebstore.google.com/detail/..."
74 - />
75 - <div class="browser-extension-url-actions">
76 - <button
77 - type="button"
78 - class="btn btn-ok"
79 - @click="$store.browserPage.installExtensionFromUrl()"
80 - :disabled="$store.browserPage.extensionActionLoading"
81 - >
82 - <span
83 - class="material-symbols-outlined"
84 - :class="{ spinning: $store.browserPage.extensionActionLoading }"
85 - x-text="$store.browserPage.extensionActionLoading ? 'progress_activity' : 'download'"
86 - ></span>
87 - <span>Install URL</span>
88 - </button>
89 - <button type="button" class="btn btn-field" @click="$store.browserPage.askAgentInstallExtension()">
90 - <span class="material-symbols-outlined">psychology_alt</span>
91 - <span>Ask A0</span>
92 - </button>
93 - </div>
94 - </div>
95 - <button type="button" class="dropdown-item" @click="$store.browserPage.openExtensionsFolder()">
96 - <span class="material-symbols-outlined">folder_open</span>
97 - <span>My Browser Extensions</span>
98 - </button>
99 - <button type="button" class="dropdown-item" @click="$store.browserPage.openExtensionsSettings()">
100 - <span class="material-symbols-outlined">tune</span>
101 - <span>Browser Extension Settings</span>
102 - </button>
103 - <div class="browser-extension-message" x-show="$store.browserPage.extensionActionMessage" x-text="$store.browserPage.extensionActionMessage"></div>
104 - <div class="browser-extension-error" x-show="$store.browserPage.extensionActionError" x-text="$store.browserPage.extensionActionError"></div>
105 - </div>
106 - </div>
107 - <button class="btn btn-icon-action browser-close" title="Close Browser" @click="$confirmClick($event, () => $store.browserPage.command('close'))" :disabled="!$store.browserPage.activeBrowserId">
108 - <span class="material-symbols-outlined">close</span>
109 - </button>
110 - </div>
111 - </div>
112 - </div>
113 -
114 - <div class="browser-toolbar">
115 - <div class="browser-navigation">
116 - <button class="btn btn-icon-action" title="Back" @click="$store.browserPage.command('back')" :disabled="!$store.browserPage.activeBrowserId">
117 - <span class="material-symbols-outlined">arrow_back</span>
118 - </button>
119 - <button class="btn btn-icon-action" title="Forward" @click="$store.browserPage.command('forward')" :disabled="!$store.browserPage.activeBrowserId">
120 - <span class="material-symbols-outlined">arrow_forward</span>
121 - </button>
122 - <button class="btn btn-icon-action" title="Reload" @click="$store.browserPage.command('reload')" :disabled="!$store.browserPage.activeBrowserId">
123 - <span class="material-symbols-outlined">refresh</span>
124 - </button>
125 - </div>
126 -
127 - <form class="browser-address-form" @submit.prevent="$store.browserPage.go()">
128 - <span class="material-symbols-outlined browser-address-icon">language</span>
129 - <input
130 - class="browser-address"
131 - x-model="$store.browserPage.address"
132 - @focus="$store.browserPage.onAddressFocus()"
133 - @blur="$store.browserPage.onAddressBlur()"
134 - placeholder="https://example.com"
135 - autocomplete="off"
136 - />
137 - </form>
138 - </div>
13 + <div class="browser-panel" x-create="$store.browserPage.onOpen($el)" x-destroy="$store.browserPage.cleanup()"
14 + @keydown.window="$store.browserPage.sendKey($event)">
15 + <div class="browser-meta">
16 + <div class="browser-meta-top">
17 + <div class="browser-session-tabs" role="tablist" aria-label="Browser sessions">
18 + <template x-for="browser in $store.browserPage.browsers" :key="browser.id">
19 + <div class="browser-tab-shell" :class="{ 'is-active': $store.browserPage.isActiveBrowser(browser) }">
20 + <button type="button" class="browser-tab" role="tab"
21 + :aria-selected="$store.browserPage.isActiveBrowser(browser).toString()"
22 + :title="$store.browserPage.browserTabLabel(browser)"
23 + @click="$store.browserPage.selectBrowser(browser.id)">
24 + <span class="material-symbols-outlined browser-tab-icon" aria-hidden="true">language</span>
25 + <span class="browser-tab-title" x-text="$store.browserPage.browserTabTitle(browser)"></span>
26 + </button>
27 + <button type="button" class="browser-tab-close"
28 + :title="'Close ' + $store.browserPage.browserTabLabel(browser)"
29 + :aria-label="'Close ' + $store.browserPage.browserTabLabel(browser)"
30 + @click.stop="$confirmClick($event, () => $store.browserPage.command('close', { browser_id: browser.id }))">
31 + <span class="material-symbols-outlined">close</span>
32 + </button>
33 + </div>
34 + </template>
35 + <button type="button" class="browser-new-tab" title="New Browser" aria-label="New Browser"
36 + @click="$store.browserPage.openNewBrowser()">
37 + <span class="material-symbols-outlined">add</span>
38 + </button>
39 + </div>
40 +
41 + <div class="browser-session-controls">
42 + <div class="browser-extension-menu" @click.outside="$store.browserPage.closeExtensionsMenu()"
43 + @keydown.escape.window="$store.browserPage.closeExtensionsMenu()">
44 + <button type="button" class="btn btn-icon-action browser-extensions" title="Browser extensions"
45 + aria-label="Browser extensions" @click.stop="$store.browserPage.toggleExtensionsMenu()"
46 + :aria-expanded="$store.browserPage.extensionMenuOpen.toString()"
47 + :class="{ 'is-active': $store.browserPage.status?.extensions?.active }">
48 + <span class="material-symbols-outlined">extension</span>
49 + </button>
50 + <div class="browser-extension-dropdown" x-show="$store.browserPage.extensionMenuOpen" x-transition
51 + style="display: none;">
52 + <div class="browser-extension-preset">
53 + <label for="browser-model-preset">Browser LLM Preset</label>
54 + <select id="browser-model-preset" x-model="$store.browserPage.modelPreset"
55 + @change="$store.browserPage.setBrowserModelPreset($event.target.value)"
56 + :disabled="$store.browserPage.modelPresetSaving">
57 + <option value="">Default Main Model</option>
58 + <template x-for="preset in $store.browserPage.modelPresetOptions" :key="preset.name">
59 + <option :value="preset.name" x-text="preset.label"></option>
60 + </template>
61 + </select>
62 + <div class="browser-extension-preset-summary" x-text="$store.browserPage.modelPresetSummary()">
63 + </div>
64 + </div>
65 + <div class="browser-extension-section-title">Chrome Extensions</div>
66 + <button type="button" class="dropdown-item" @click="$store.browserPage.createExtensionWithAgent()">
67 + <span class="material-symbols-outlined">add_circle</span>
68 + <span>Create New Extension with A0</span>
69 + </button>
70 + <div class="browser-extension-url">
71 + <label for="browser-extension-url">Input a Chrome Web Store URL</label>
72 + <input id="browser-extension-url" type="url" x-model="$store.browserPage.extensionInstallUrl"
73 + @keydown.enter.prevent="$store.browserPage.installExtensionFromUrl()"
74 + placeholder="https://chromewebstore.google.com/detail/..." />
75 + <div class="browser-extension-url-actions">
76 + <button type="button" class="btn btn-ok" @click="$store.browserPage.installExtensionFromUrl()"
77 + :disabled="$store.browserPage.extensionActionLoading">
78 + <span class="material-symbols-outlined"
79 + :class="{ spinning: $store.browserPage.extensionActionLoading }"
80 + x-text="$store.browserPage.extensionActionLoading ? 'progress_activity' : 'download'"></span>
81 + <span>Install URL</span>
82 + </button>
83 + <button type="button" class="btn btn-field"
84 + @click="$store.browserPage.askAgentInstallExtension()">
85 + <span class="material-symbols-outlined">psychology_alt</span>
86 + <span x-text="$store.browserPage.extensionAssistantActionLabel()">Scan with A0</span>
87 + </button>
88 + </div>
89 + <div class="browser-extension-warning" x-show="$store.browserPage.hasExtensionInstallUrl()"
90 + x-transition style="display: none;">
91 + <span class="material-symbols-outlined">warning</span>
92 + <span>
93 + Extensions run inside the Docker browser sandbox, but malicious or buggy extensions can still
94 + damage that environment. Review what you install.
95 + </span>
96 + </div>
97 + </div>
98 + <div class="browser-extension-list"
99 + x-show="$store.browserPage.extensionsListLoading || $store.browserPage.extensionsList.length"
100 + style="display: none;">
101 + <div class="browser-extension-list-header">
102 + <span>Installed extensions</span>
103 + <span class="material-symbols-outlined spinning"
104 + x-show="$store.browserPage.extensionsListLoading">progress_activity</span>
105 + </div>
106 + <template x-for="extension in $store.browserPage.extensionsList" :key="extension.path">
107 + <label class="browser-extension-row" :title="extension.path">
108 + <span class="browser-extension-row-text">
109 + <span class="browser-extension-name" x-text="extension.name || 'Unnamed extension'"></span>
110 + <span class="browser-extension-meta"
111 + x-text="$store.browserPage.extensionVersionLabel(extension)"></span>
112 + </span>
113 + <span class="browser-extension-toggle">
114 + <input type="checkbox" :checked="extension.enabled"
115 + :disabled="$store.browserPage.extensionToggleLoadingPath === extension.path"
116 + @change="$store.browserPage.setExtensionEnabled(extension, $event.target.checked, $event.target)" />
117 + <span class="browser-extension-switch"></span>
118 + </span>
119 + </label>
120 + </template>
121 + </div>
122 + <button type="button" class="dropdown-item" @click="$store.browserPage.openExtensionsSettings()">
123 + <span class="material-symbols-outlined">tune</span>
124 + <span>Settings</span>
125 + </button>
126 + <div class="browser-extension-message" x-show="$store.browserPage.extensionActionMessage"
127 + x-text="$store.browserPage.extensionActionMessage"></div>
128 + <div class="browser-extension-error" x-show="$store.browserPage.extensionActionError"
129 + x-text="$store.browserPage.extensionActionError"></div>
130 + </div>
131 + </div>
132 + </div>
133 + </div>
134 + </div>
135 +
136 + <div class="browser-toolbar">
137 + <div class="browser-navigation">
138 + <button class="btn btn-icon-action" title="Back" @click="$store.browserPage.command('back')"
139 + :disabled="!$store.browserPage.activeBrowserId">
140 + <span class="material-symbols-outlined">arrow_back</span>
141 + </button>
142 + <button class="btn btn-icon-action" title="Forward" @click="$store.browserPage.command('forward')"
143 + :disabled="!$store.browserPage.activeBrowserId">
144 + <span class="material-symbols-outlined">arrow_forward</span>
145 + </button>
146 + <button class="btn btn-icon-action" title="Reload" @click="$store.browserPage.command('reload')"
147 + :disabled="!$store.browserPage.activeBrowserId">
148 + <span class="material-symbols-outlined">refresh</span>
149 + </button>
150 + </div>
151 +
152 + <form class="browser-address-form" @submit.prevent="$store.browserPage.go()">
153 + <span class="material-symbols-outlined browser-address-icon">language</span>
154 + <input class="browser-address" x-model="$store.browserPage.address"
155 + @focus="$store.browserPage.onAddressFocus()" @blur="$store.browserPage.onAddressBlur()"
156 + placeholder="https://example.com" autocomplete="off" />
157 + </form>
158 + </div>
159
160 <div class="browser-status" x-show="$store.browserPage.loading">
161 <span class="material-symbols-outlined spinning">progress_activity</span>
@@ -143,25 +163,18 @@
163 </div>
164 <div class="browser-error" x-show="$store.browserPage.error" x-text="$store.browserPage.error"></div>
165
146 - <div
147 - class="browser-stage"
148 - tabindex="0"
149 - @click="$el.focus()"
150 - @wheel.prevent="$store.browserPage.sendWheel($event)"
151 - >
166 + <div class="browser-stage" tabindex="0" @click="$el.focus()"
167 + @wheel.prevent="$store.browserPage.sendWheel($event)">
168 <template x-if="$store.browserPage.frameSrc">
153 - <img
154 - class="browser-frame"
155 - :src="$store.browserPage.frameSrc"
169 + <img class="browser-frame" :src="$store.browserPage.frameSrc"
170 @click="$store.browserPage.sendMouse('click', $event)"
157 - @mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)"
158 - draggable="false"
159 - />
171 + @mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)" draggable="false" />
172 </template>
173 <template x-if="!$store.browserPage.frameSrc && !$store.browserPage.loading">
174 <div class="browser-empty">
175 <span class="material-symbols-outlined">captive_portal</span>
164 - <button class="btn btn-field" @click="$store.browserPage.command('open', { url: 'about:blank' })">Open Browser</button>
176 + <button class="btn btn-field" @click="$store.browserPage.command('open', { url: 'about:blank' })">Open
177 + Browser</button>
178 </div>
179 </template>
180 </div>
@@ -229,13 +242,21 @@
242 min-height: 0;
243 }
244
232 - .modal-inner.browser-modal .modal-bd.browser-modal-body > div[x-data] {
245 + .modal-inner.browser-modal .modal-bd.browser-modal-body>div[x-data] {
246 display: flex;
247 flex: 1 1 auto;
248 min-height: 0;
249 }
250
251 .browser-panel {
252 + --browser-chrome-surface: color-mix(in srgb, var(--color-background) 92%, #000 8%);
253 + --browser-chrome-border: color-mix(in srgb, var(--color-border) 58%, transparent);
254 + --browser-tab-hover-border: color-mix(in srgb, var(--color-border) 78%, transparent);
255 + --browser-control-size: 34px;
256 + --browser-address-height: 34px;
257 + --browser-tab-height: 34px;
258 + --browser-tab-close-size: 28px;
259 + --browser-control-radius: 0.55rem;
260 box-sizing: border-box;
261 display: flex;
262 flex: 1 1 auto;
@@ -245,21 +266,47 @@
266 min-height: 0;
267 }
268
248 - .browser-toolbar {
249 - display: grid;
250 - grid-template-columns: auto minmax(0, 1fr);
251 - grid-template-areas: "nav address";
252 - gap: 6px;
253 - align-items: center;
254 - padding: 7px 8px;
255 - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
256 - background: color-mix(in srgb, var(--color-panel) 90%, transparent);
269 + .browser-toolbar {
270 + display: grid;
271 + grid-template-columns: auto minmax(0, 1fr);
272 + grid-template-areas: "nav address";
273 + gap: 8px;
274 + align-items: center;
275 + padding: 8px 10px 9px;
276 + border-bottom: 0;
277 + background: transparent;
278 }
279
280 .browser-navigation {
281 grid-area: nav;
282 display: flex;
262 - gap: 4px;
283 + gap: 6px;
284 + }
285 +
286 + .browser-panel .btn-icon-action,
287 + .browser-new-tab {
288 + width: var(--browser-control-size);
289 + min-width: var(--browser-control-size);
290 + height: var(--browser-control-size);
291 + min-height: var(--browser-control-size);
292 + border-radius: var(--browser-control-radius);
293 + }
294 +
295 + .browser-panel .btn-icon-action {
296 + color: color-mix(in srgb, var(--color-text) 72%, var(--color-primary) 28%);
297 + background: color-mix(in srgb, var(--color-background) 26%, transparent);
298 + border-color: var(--browser-chrome-border);
299 + }
300 +
301 + .browser-panel .btn-icon-action:hover:not(:disabled) {
302 + color: var(--color-text);
303 + border-color: color-mix(in srgb, var(--color-primary) 34%, var(--browser-chrome-border));
304 + background: color-mix(in srgb, var(--color-background-hover) 62%, transparent);
305 + box-shadow: none;
306 + }
307 +
308 + .browser-panel .btn-icon-action .material-symbols-outlined {
309 + font-size: 1.12rem;
310 }
311
312 .browser-address-form {
@@ -279,205 +326,461 @@
326 pointer-events: none;
327 }
328
282 - .browser-address,
283 - .browser-select {
329 + .browser-address {
330 + width: 100%;
331 + min-height: var(--browser-address-height);
332 + padding: 5px 10px 5px 34px;
333 + border-radius: var(--browser-control-radius);
334 + border: 1px solid var(--browser-chrome-border);
335 + background: var(--color-input);
336 + color: var(--color-text);
337 + font: inherit;
338 + }
339 +
340 + .browser-meta {
341 + display: grid;
342 + grid-template-columns: minmax(0, 1fr);
343 + gap: 0;
344 + padding: 7px 10px 0;
345 + border-bottom: 1px solid var(--browser-chrome-border);
346 + background: var(--browser-chrome-surface);
347 + }
348 +
349 + .browser-meta-top {
350 + display: grid;
351 + grid-template-columns: minmax(0, 1fr) auto;
352 + gap: 10px;
353 + align-items: end;
354 + }
355 +
356 + .browser-session-tabs {
357 + display: flex;
358 + align-items: end;
359 + gap: 4px;
360 + min-width: 0;
361 + overflow-x: auto;
362 + padding: 1px 0 0;
363 + scrollbar-width: thin;
364 + overflow-y: hidden;
365 + }
366 +
367 + .browser-session-tabs::-webkit-scrollbar {
368 + height: 4px;
369 + }
370 +
371 + .browser-session-tabs::-webkit-scrollbar-track {
372 + background: transparent;
373 + }
374 +
375 + .browser-session-tabs::-webkit-scrollbar-thumb {
376 + background: color-mix(in srgb, var(--color-border) 78%, transparent);
377 + border-radius: 999px;
378 + }
379 +
380 + .browser-new-tab {
381 + display: inline-flex;
382 + align-items: center;
383 + border: 1px solid transparent;
384 + color: var(--color-text);
385 + cursor: pointer;
386 + transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
387 + border-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
388 + color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
389 + opacity 0.18s cubic-bezier(0.4, 0, 0.2, 1);
390 + }
391 +
392 + .browser-tab-shell {
393 + flex: 0 1 210px;
394 + position: relative;
395 + display: grid;
396 + grid-template-columns: minmax(0, 1fr) var(--browser-tab-close-size);
397 + align-items: center;
398 + gap: 3px;
399 + min-width: 128px;
400 + max-width: 250px;
401 + height: var(--browser-tab-height);
402 + padding: 0 7px 0 10px;
403 + border: 1px solid transparent;
404 + border-radius: var(--browser-control-radius) var(--browser-control-radius) 0 0;
405 + background: transparent;
406 + opacity: 0.72;
407 + transition: border-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
408 + color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
409 + opacity 0.18s cubic-bezier(0.4, 0, 0.2, 1);
410 + }
411 +
412 + .browser-tab-shell:hover,
413 + .browser-tab-shell:focus-within {
414 + border-color: var(--browser-tab-hover-border);
415 + opacity: 0.94;
416 + }
417 +
418 + .browser-tab-shell.is-active {
419 + z-index: 2;
420 + margin-bottom: -1px;
421 + border-color: var(--browser-chrome-border);
422 + background: transparent;
423 + opacity: 1;
424 + box-shadow: none;
425 + }
426 +
427 + .browser-tab {
428 + display: inline-flex;
429 + align-items: center;
430 + justify-content: flex-start;
431 + gap: 8px;
432 + min-width: 0;
433 width: 100%;
434 + height: 100%;
435 + padding: 0;
436 + border: 0;
437 + border-radius: 0;
438 + background: transparent;
439 + color: inherit;
440 + cursor: pointer;
441 + font: inherit;
442 + text-align: left;
443 + }
444 +
445 + .browser-tab:hover {
446 + background: transparent;
447 + }
448 +
449 + .browser-tab:focus-visible,
450 + .browser-tab-close:focus-visible {
451 + outline: 1px solid color-mix(in srgb, var(--color-primary) 70%, transparent);
452 + outline-offset: 1px;
453 + }
454 +
455 + .browser-tab-icon {
456 + flex: 0 0 auto;
457 + color: color-mix(in srgb, var(--color-text) 72%, var(--color-primary) 28%);
458 + font-size: 1.04rem;
459 + line-height: 1;
460 + }
461 +
462 + .browser-tab-title {
463 + min-width: 0;
464 + overflow: hidden;
465 + text-overflow: ellipsis;
466 + white-space: nowrap;
467 + font-size: 0.88rem;
468 + font-weight: 600;
469 + }
470 +
471 + .browser-tab-close {
472 + display: inline-flex;
473 + align-items: center;
474 + justify-content: center;
475 + width: var(--browser-tab-close-size);
476 + min-width: var(--browser-tab-close-size);
477 + height: var(--browser-tab-close-size);
478 + min-height: var(--browser-tab-close-size);
479 + padding: 0;
480 + border: 0;
481 + border-radius: 50%;
482 + background: transparent;
483 + color: color-mix(in srgb, var(--color-text) 52%, var(--color-primary) 48%);
484 + cursor: pointer;
485 + opacity: 0.72;
486 + transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
487 + color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
488 + opacity 0.18s cubic-bezier(0.4, 0, 0.2, 1);
489 + }
490 +
491 + .browser-tab-close:hover,
492 + .browser-tab-close.confirming {
493 + background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
494 + color: var(--color-text);
495 + opacity: 1;
496 + }
497 +
498 + .browser-tab-close .material-symbols-outlined {
499 + font-size: 0.82rem;
500 + line-height: 1;
501 + }
502 +
503 + .browser-new-tab {
504 + flex: 0 0 var(--browser-control-size);
505 + justify-content: center;
506 + padding: 0;
507 + border-color: transparent;
508 + border-radius: var(--browser-control-radius);
509 + background: transparent;
510 + color: color-mix(in srgb, var(--color-text) 62%, var(--color-primary) 38%);
511 + }
512 +
513 + .browser-new-tab:hover {
514 + background: color-mix(in srgb, var(--color-background-hover) 58%, transparent);
515 + color: var(--color-text);
516 + }
517 +
518 + .browser-new-tab .material-symbols-outlined {
519 + font-size: 1.18rem;
520 + }
521 +
522 + .browser-session-controls {
523 + display: flex;
524 + align-items: end;
525 + gap: 6px;
526 + min-width: 0;
527 + padding-bottom: 1px;
528 + }
529 +
530 + .browser-session-controls .browser-extensions.is-active {
531 + color: #2e7d32;
532 + }
533 +
534 + .browser-extension-menu {
535 + position: relative;
536 + display: flex;
537 + flex: 0 0 auto;
538 + }
539 +
540 + .browser-extension-dropdown {
541 + position: absolute;
542 + top: calc(100% + 6px);
543 + right: 0;
544 + z-index: 40;
545 + display: flex;
546 + flex-direction: column;
547 + gap: 7px;
548 + width: min(360px, calc(100vw - 24px));
549 + max-height: min(72vh, 560px);
550 + overflow-y: auto;
551 + padding: 10px;
552 + border: 1px solid color-mix(in srgb, var(--color-border) 78%, transparent);
553 + border-radius: 7px;
554 + background: var(--color-background);
555 + box-shadow: 0 16px 38px rgba(0, 0, 0, 0.28);
556 + }
557 +
558 + .browser-extension-dropdown .dropdown-item {
559 + display: flex;
560 + align-items: center;
561 + gap: 8px;
562 + width: 100%;
563 + min-height: 34px;
564 + padding: 7px 9px;
565 + border: 0;
566 + border-radius: 6px;
567 + background: transparent;
568 + color: var(--color-text);
569 + font-weight: 600;
570 + text-align: left;
571 + cursor: pointer;
572 + }
573 +
574 + .browser-extension-dropdown .dropdown-item:hover {
575 + background: color-mix(in srgb, var(--color-panel) 82%, transparent);
576 + }
577 +
578 + .browser-extension-warning,
579 + .browser-extension-message,
580 + .browser-extension-error {
581 + display: flex;
582 + align-items: flex-start;
583 + gap: 8px;
584 + padding: 9px 10px;
585 + border-radius: 7px;
586 + font-size: 0.8rem;
587 + line-height: 1.35;
588 + }
589 +
590 + .browser-extension-warning {
591 + border: 1px solid color-mix(in srgb, #d97706 42%, var(--color-border));
592 + background: color-mix(in srgb, #d97706 14%, var(--color-background));
593 + color: color-mix(in srgb, var(--color-text) 86%, #92400e);
594 + }
595 +
596 + .browser-extension-warning .material-symbols-outlined {
597 + color: #b45309;
598 + font-size: 19px;
599 + }
600 +
601 + .browser-extension-url {
602 + display: flex;
603 + flex-direction: column;
604 + gap: 7px;
605 + padding: 8px;
606 + border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
607 + border-radius: 7px;
608 + background: var(--color-panel);
609 + }
610 +
611 + .browser-extension-url label {
612 + font-size: 0.76rem;
613 + color: var(--color-text-secondary);
614 + }
615 +
616 + .browser-extension-url input {
617 + min-width: 0;
618 min-height: 32px;
286 - padding: 5px 9px;
619 + padding: 6px 8px;
620 + border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
621 border-radius: 6px;
622 + background: var(--color-input);
623 + color: var(--color-text);
624 + }
625 +
626 + .browser-extension-url-actions {
627 + display: grid;
628 + grid-template-columns: repeat(2, minmax(0, 1fr));
629 + gap: 7px;
630 + }
631 +
632 + .browser-extension-url-actions .btn {
633 + display: inline-flex;
634 + align-items: center;
635 + justify-content: center;
636 + gap: 6px;
637 + width: 100%;
638 + min-width: 0;
639 + min-height: 30px;
640 + }
641 +
642 + .browser-extension-preset,
643 + .browser-extension-list {
644 + display: flex;
645 + flex-direction: column;
646 + gap: 7px;
647 + }
648 +
649 + .browser-extension-section-title {
650 + padding: 6px 2px 0;
651 + color: var(--color-text-secondary);
652 + font-size: 0.76rem;
653 + font-weight: 700;
654 + }
655 +
656 + .browser-extension-preset label,
657 + .browser-extension-list-header {
658 + font-size: 0.76rem;
659 + font-weight: 650;
660 + color: var(--color-text-secondary);
661 + }
662 +
663 + .browser-extension-preset select {
664 + width: 100%;
665 + min-height: 32px;
666 + padding: 5px 8px;
667 border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
668 + border-radius: 6px;
669 background: var(--color-input);
670 color: var(--color-text);
291 - font: inherit;
671 }
672
294 - .browser-address {
295 - padding-left: 34px;
296 - }
297 -
298 - .browser-select {
299 - min-width: 0;
300 - }
301 -
302 - .browser-meta {
303 - display: grid;
304 - grid-template-columns: minmax(0, 1fr);
305 - gap: 6px;
306 - padding: 6px 10px;
307 - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
308 - background: color-mix(in srgb, var(--color-panel) 82%, transparent);
309 - }
310 -
311 - .browser-meta-top {
312 - display: grid;
313 - grid-template-columns: minmax(0, 1fr) auto;
314 - gap: 10px;
315 - align-items: center;
316 - }
317 -
318 - .browser-titleline {
319 - display: flex;
320 - align-items: center;
321 - gap: 8px;
322 - min-width: 0;
323 - }
324 -
325 - .browser-session-controls {
326 - display: flex;
327 - align-items: center;
328 - gap: 6px;
329 - min-width: 0;
330 - }
331 -
332 - .browser-session-controls .browser-select {
333 - width: min(320px, 52cqw);
334 - }
335 -
336 - .browser-session-controls .browser-extensions.is-active {
337 - color: #2e7d32;
338 - }
339 -
340 - .browser-extension-menu {
341 - position: relative;
342 - display: flex;
343 - flex: 0 0 auto;
344 - }
345 -
346 - .browser-extension-dropdown {
347 - position: absolute;
348 - top: calc(100% + 6px);
349 - right: 0;
350 - z-index: 40;
351 - display: flex;
352 - flex-direction: column;
353 - gap: 7px;
354 - width: min(360px, calc(100vw - 24px));
355 - padding: 10px;
356 - border: 1px solid color-mix(in srgb, var(--color-border) 78%, transparent);
357 - border-radius: 7px;
358 - background: var(--color-background);
359 - box-shadow: 0 16px 38px rgba(0, 0, 0, 0.28);
360 - }
361 -
362 - .browser-extension-dropdown .dropdown-item {
363 - display: flex;
364 - align-items: center;
365 - gap: 8px;
366 - width: 100%;
367 - min-height: 34px;
368 - padding: 7px 9px;
369 - border: 0;
370 - border-radius: 6px;
371 - background: transparent;
372 - color: var(--color-text);
373 - font-weight: 600;
374 - text-align: left;
375 - cursor: pointer;
376 - }
377 -
378 - .browser-extension-dropdown .dropdown-item:hover {
379 - background: color-mix(in srgb, var(--color-panel) 82%, transparent);
380 - }
381 -
382 - .browser-extension-warning,
383 - .browser-extension-message,
384 - .browser-extension-error {
385 - display: flex;
386 - align-items: flex-start;
387 - gap: 8px;
388 - padding: 9px 10px;
389 - border-radius: 7px;
390 - font-size: 0.8rem;
391 - line-height: 1.35;
392 - }
393 -
394 - .browser-extension-warning {
395 - border: 1px solid color-mix(in srgb, #d97706 42%, var(--color-border));
396 - background: color-mix(in srgb, #d97706 14%, var(--color-background));
397 - color: color-mix(in srgb, var(--color-text) 86%, #92400e);
398 - }
399 -
400 - .browser-extension-warning .material-symbols-outlined {
401 - color: #b45309;
402 - font-size: 19px;
403 - }
404 -
405 - .browser-extension-url {
406 - display: flex;
407 - flex-direction: column;
408 - gap: 7px;
409 - padding: 8px;
410 - border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
411 - border-radius: 7px;
412 - background: var(--color-panel);
413 - }
414 -
415 - .browser-extension-url label {
416 - font-size: 0.76rem;
417 - color: var(--color-text-secondary);
418 - }
419 -
420 - .browser-extension-url input {
421 - min-width: 0;
422 - min-height: 32px;
423 - padding: 6px 8px;
424 - border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
425 - border-radius: 6px;
426 - background: var(--color-input);
427 - color: var(--color-text);
428 - }
429 -
430 - .browser-extension-url-actions {
431 - display: flex;
432 - flex-wrap: wrap;
433 - gap: 7px;
434 - }
435 -
436 - .browser-extension-url-actions .btn {
437 - display: inline-flex;
438 - align-items: center;
439 - gap: 6px;
440 - min-height: 30px;
441 - }
442 -
443 - .browser-extension-message {
444 - background: color-mix(in srgb, #15803d 12%, var(--color-background));
445 - color: color-mix(in srgb, var(--color-text) 88%, #166534);
446 - }
447 -
448 - .browser-extension-error {
449 - background: color-mix(in srgb, #be123c 12%, var(--color-background));
450 - color: #9f1239;
451 - }
452 -
453 - .browser-live-dot {
454 - width: 8px;
455 - height: 8px;
456 - border-radius: 50%;
457 - background: #777;
458 - flex: 0 0 auto;
673 + .browser-extension-preset-summary,
674 + .browser-extension-meta {
675 + color: var(--color-text-secondary);
676 + font-size: 0.75rem;
677 + padding-left: var(--spacing-xxs);
678 }
679
461 - .browser-live-dot.active {
462 - background: #2e7d32;
463 - box-shadow: 0 0 0 4px rgba(46, 125, 50, 0.13);
680 + .browser-extension-list-header {
681 + display: flex;
682 + align-items: center;
683 + justify-content: space-between;
684 + gap: 8px;
685 + }
686 +
687 + .browser-extension-list-header .material-symbols-outlined {
688 + font-size: 16px;
689 }
690
466 - .browser-title {
467 - overflow: hidden;
468 - text-overflow: ellipsis;
469 - white-space: nowrap;
470 - }
691 + .browser-extension-row {
692 + display: grid;
693 + grid-template-columns: minmax(0, 1fr) auto;
694 + align-items: center;
695 + gap: 10px;
696 + min-height: 34px;
697 + padding: 5px 0;
698 + }
699 +
700 + .browser-extension-row+.browser-extension-row {
701 + border-top: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
702 + }
703 +
704 + .browser-extension-row-text {
705 + display: flex;
706 + min-width: 0;
707 + flex-direction: column;
708 + gap: 2px;
709 + }
710
472 - .browser-title {
473 - font-size: 0.9rem;
711 + .browser-extension-name {
712 + overflow: hidden;
713 + text-overflow: ellipsis;
714 + white-space: nowrap;
715 + font-size: 0.82rem;
716 font-weight: 650;
717 + color: var(--color-text);
718 + }
719 +
720 + .browser-extension-toggle {
721 + position: relative;
722 + display: inline-flex;
723 + flex: 0 0 auto;
724 + align-items: center;
725 + width: 38px;
726 + height: 22px;
727 }
728
477 - .browser-id {
478 - font-size: 0.78rem;
479 - opacity: 0.68;
480 - }
729 + .browser-extension-toggle input {
730 + position: absolute;
731 + inset: 0;
732 + margin: 0;
733 + opacity: 0;
734 + cursor: pointer;
735 + }
736 +
737 + .browser-extension-toggle input:disabled {
738 + cursor: wait;
739 + }
740 +
741 + .browser-extension-switch {
742 + width: 100%;
743 + height: 100%;
744 + border-radius: 999px;
745 + background: color-mix(in srgb, var(--color-border) 78%, transparent);
746 + transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
747 + opacity 0.18s cubic-bezier(0.4, 0, 0.2, 1);
748 + }
749 +
750 + .browser-extension-switch::after {
751 + content: "";
752 + position: absolute;
753 + top: 3px;
754 + left: 3px;
755 + width: 16px;
756 + height: 16px;
757 + border-radius: 50%;
758 + background: var(--color-background);
759 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.24);
760 + transition: transform 0.18s cubic-bezier(0.4, 0, 0.2, 1);
761 + }
762 +
763 + .browser-extension-toggle input:checked+.browser-extension-switch {
764 + background: color-mix(in srgb, var(--color-primary) 76%, #16a34a);
765 + }
766 +
767 + .browser-extension-toggle input:checked+.browser-extension-switch::after {
768 + transform: translateX(16px);
769 + }
770 +
771 + .browser-extension-toggle input:disabled+.browser-extension-switch {
772 + opacity: 0.58;
773 + }
774 +
775 + .browser-extension-message {
776 + background: color-mix(in srgb, #15803d 12%, var(--color-background));
777 + color: color-mix(in srgb, var(--color-text) 88%, #166534);
778 + }
779 +
780 + .browser-extension-error {
781 + background: color-mix(in srgb, #be123c 12%, var(--color-background));
782 + color: #9f1239;
783 + }
784
785 .browser-stage {
786 flex: 1 1 auto;
@@ -543,31 +846,42 @@
846 background: var(--color-background);
847 }
848
546 - @container (max-width: 460px) {
547 - .browser-meta-top {
548 - grid-template-columns: minmax(0, 1fr);
549 - }
849 + @container (max-width: 460px) {
850 + .browser-meta-top {
851 + grid-template-columns: minmax(0, 1fr);
852 + }
853 +
854 + .browser-session-controls {
855 + width: 100%;
856 + justify-content: flex-end;
857 + }
858 +
859 + .browser-session-tabs {
860 + width: 100%;
861 + }
862
551 - .browser-session-controls {
552 - width: 100%;
553 - }
863 + .browser-tab-shell {
864 + flex-basis: 170px;
865 + min-width: 142px;
866 + }
867
555 - .browser-session-controls .browser-select {
556 - flex: 1 1 auto;
557 - width: auto;
558 - }
868 + .browser-new-tab,
869 + .browser-session-controls .btn-icon-action {
870 + width: var(--browser-control-size);
871 + height: var(--browser-control-size);
872 + }
873
560 - .browser-extension-dropdown {
561 - right: 0;
562 - left: auto;
563 - width: min(296px, calc(100vw - 72px));
564 - }
874 + .browser-extension-dropdown {
875 + right: 0;
876 + left: auto;
877 + width: min(296px, calc(100vw - 72px));
878 + }
879
566 - .browser-address,
567 - .browser-select {
568 - min-height: 34px;
569 - }
880 + .browser-address {
881 + min-height: var(--browser-address-height);
882 + }
883 }
884 </style>
885 </body>
573 -</html>
886 +
887 +</html>
\ No newline at end of file
tests/test_browser_agent_regressions.py
+59 -18
@@ -12,6 +12,7 @@ if str(PROJECT_ROOT) not in sys.path:
12
13 from plugins._browser.helpers.config import (
14 build_browser_launch_config,
15 + get_browser_main_model_summary,
16 get_browser_model_preset_options,
17 normalize_browser_config,
18 resolve_browser_model_selection,
@@ -21,7 +22,6 @@ from plugins._browser.helpers.extension_manager import (
22 _crx_zip_payload,
23 _detect_chrome_prodversion,
24 _normalize_chrome_prodversion,
24 - get_extension_roots,
25 get_extensions_root,
26 parse_chrome_web_store_extension_id,
27 )
@@ -52,13 +52,11 @@ def test_browser_config_normalizes_extension_paths(tmp_path):
52
53 config = normalize_browser_config(
54 {
55 - "extensions_enabled": 1,
55 "extension_paths": [str(extension_dir), "", " ", str(extension_dir)],
56 }
57 )
58
59 assert config == {
61 - "extensions_enabled": True,
60 "extension_paths": [str(extension_dir)],
61 "model_preset": "",
62 }
@@ -76,7 +74,7 @@ def test_browser_model_selection_uses_presets(monkeypatch):
74 monkeypatch.setattr(
75 browser_config_module,
76 "get_browser_config",
79 - lambda agent=None: {"model_preset": "Research", "extensions_enabled": False, "extension_paths": []},
77 + lambda agent=None: {"model_preset": "Research", "extension_paths": []},
78 )
79 monkeypatch.setattr(
80 model_config,
@@ -125,10 +123,21 @@ def test_browser_model_preset_options_include_missing_selected(monkeypatch):
123 assert options[-1]["missing"] is True
124
125
126 +def test_browser_main_model_summary_shows_current_model(monkeypatch):
127 + from plugins._model_config.helpers import model_config
128 +
129 + monkeypatch.setattr(
130 + model_config,
131 + "get_chat_model_config",
132 + lambda agent=None: {"provider": "openrouter", "name": "example/main"},
133 + )
134 +
135 + assert get_browser_main_model_summary() == "openrouter / example/main"
136 +
137 +
138 def test_browser_launch_config_uses_full_chromium_for_all_sessions(tmp_path):
139 default_launch = build_browser_launch_config(
140 {
131 - "extensions_enabled": False,
141 "extension_paths": [],
142 }
143 )
@@ -144,7 +153,6 @@ def test_browser_launch_config_uses_full_chromium_for_all_sessions(tmp_path):
153
154 launch = build_browser_launch_config(
155 {
147 - "extensions_enabled": True,
156 "extension_paths": [str(extension_dir)],
157 }
158 )
@@ -189,10 +197,6 @@ def test_browser_extension_storage_uses_plugin_user_path(monkeypatch, tmp_path):
197 )
198
199 assert get_extensions_root() == tmp_path / "usr" / "plugins" / "_browser" / "extensions"
192 - assert get_extension_roots() == [
193 - tmp_path / "usr" / "plugins" / "_browser" / "extensions",
194 - tmp_path / "usr" / "browser-extensions",
195 - ]
200
201
202 def test_browser_extension_manager_parses_web_store_urls():
@@ -242,9 +246,17 @@ def test_browser_extension_menu_exposes_agent_and_url_paths():
246 )
247 skill = PROJECT_ROOT / "skills" / "a0-browser-ext" / "SKILL.md"
248
245 - assert "+ Create New with A0" in html
246 - assert "Chrome Web Store URL" in html
247 - assert "My Browser Extensions" in html
249 + assert "Create New Extension with A0" in html
250 + assert "+ Create New with A0" not in html
251 + assert "Input a Chrome Web Store URL" in html
252 + assert "My Browser Extensions" not in html
253 + assert "Browser LLM Preset" in html
254 + assert "Chrome Extensions" in html
255 + assert "Installed extensions" in html
256 + assert "No extensions installed yet." not in html
257 + assert "Browser Extension Settings" not in html
258 + assert "<span>Settings</span>" in html
259 + assert "hasExtensionInstallUrl()" in html
260 assert "malicious or buggy extensions" in html
261 assert skill.exists()
262
@@ -274,6 +286,40 @@ def test_browser_ui_spinners_have_browser_local_animation():
286 assert "@keyframes browser-config-spin" in config_html
287
288
289 +def test_browser_extension_settings_stay_user_facing():
290 + config_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "config.html").read_text(
291 + encoding="utf-8"
292 + )
293 +
294 + assert "Choose which installed Chrome extensions Browser loads." in config_html
295 + assert "Installed extensions" in config_html
296 + assert "<textarea" not in config_html
297 + assert "Enabled extension directories" not in config_html
298 + assert "Chrome Web Store URL installs" not in config_html
299 + assert "Browser caches Playwright Chromium" not in config_html
300 +
301 +
302 +def test_browser_viewer_uses_tabs_for_session_switching():
303 + main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "main.html").read_text(
304 + encoding="utf-8"
305 + )
306 + browser_store = (
307 + PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
308 + ).read_text(encoding="utf-8")
309 +
310 + assert 'class="browser-session-tabs" role="tablist"' in main_html
311 + assert 'class="browser-tab"' in main_html
312 + assert 'class="browser-new-tab"' in main_html
313 + assert "$store.browserPage.openNewBrowser()" in main_html
314 + assert "browser-select" not in main_html
315 + assert "browser-live-dot" not in main_html
316 + assert "async openNewBrowser()" in browser_store
317 + assert "browserTabTitle(browser)" in browser_store
318 + assert "Scan with A0" in browser_store
319 + assert "Review with A0" not in browser_store
320 + assert "Using ${this.mainModelSummary}" in browser_store
321 +
322 +
323 def test_browser_docker_installs_full_chromium_to_persistent_cache():
324 script = (
325 PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_playwright.sh"
@@ -313,7 +359,6 @@ def test_browser_save_plugin_config_restarts_runtimes_on_change(monkeypatch, tmp
359 browser_hooks_module,
360 "_load_saved_browser_config",
361 lambda project_name="", agent_profile="": {
316 - "extensions_enabled": False,
362 "extension_paths": [],
363 },
364 )
@@ -325,14 +370,12 @@ def test_browser_save_plugin_config_restarts_runtimes_on_change(monkeypatch, tmp
370
371 result = browser_hooks_module.save_plugin_config(
372 {
328 - "extensions_enabled": True,
373 "extension_paths": [str(extension_dir)],
374 },
375 project_name="",
376 agent_profile="",
377 )
378
335 - assert result["extensions_enabled"] is True
379 assert result["extension_paths"] == [str(extension_dir)]
380 assert result["model_preset"] == ""
381 assert restarted == [True]
@@ -345,7 +388,6 @@ def test_browser_save_plugin_config_does_not_restart_runtimes_for_preset_only(mo
388 browser_hooks_module,
389 "_load_saved_browser_config",
390 lambda project_name="", agent_profile="": {
348 - "extensions_enabled": False,
391 "extension_paths": [],
392 "model_preset": "",
393 },
@@ -358,7 +400,6 @@ def test_browser_save_plugin_config_does_not_restart_runtimes_for_preset_only(mo
400
401 result = browser_hooks_module.save_plugin_config(
402 {
361 - "extensions_enabled": False,
403 "extension_paths": [],
404 "model_preset": "Research",
405 },