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
},