Make Browser dockable and stabilize canvas interaction

Extend Browser into a reusable panel that can run in either the Universal Canvas or the floating modal. Add canvas registration, dock/undock behavior, and keep the existing modal path working as a fallback. Stabilize tab switching with viewer tokens and stale-frame rejection, prevent command snapshots from crossing active tabs, and keep tab changes responsive. Improve canvas navigation and scrolling by making screencast polling non-blocking and removing page-settle waits from wheel input, so the visible frame updates promptly without stretch/catch-up artifacts. Polish Browser busy feedback with a spinner-only status affordance to avoid misleading “updating browser” copy.

Alessandro committed Apr 26, 2026 at 17:09 UTC 370ac9b87897f1bff51ae15a6071823e92ce9dc2
9 files changed +1387 -1017
plugins/_browser/api/ws_browser.py
+72 -11
@@ -11,9 +11,10 @@ from helpers.ws_manager import WsResult
11 from plugins._browser.helpers.runtime import get_runtime
12
13
14 -FRAME_IDLE_TIMEOUT_SECONDS = 0.35
14 +FRAME_IDLE_POLL_SECONDS = 0.05
15 FRAME_RETRY_DELAY_SECONDS = 0.5
16 FRAME_STATE_REFRESH_SECONDS = 0.75
17 +FRAME_FALLBACK_SCREENSHOT_SECONDS = 1.2
18 SCREENCAST_QUALITY = 92
19
20
@@ -81,14 +82,16 @@ class WsBrowser(WsHandler):
82 existing = self._streams.pop(stream_key, None)
83 if existing:
84 existing.cancel()
85 + viewer_id = str(data.get("viewer_id") or "")
86 self._streams[stream_key] = asyncio.create_task(
85 - self._stream_frames(sid, context_id, active_id)
87 + self._stream_frames(sid, context_id, active_id, viewer_id)
88 )
89
90 return {
91 "context_id": context_id,
92 "active_browser_id": active_id,
93 "browsers": browsers,
94 + "viewer_id": viewer_id,
95 }
96
97 def _unsubscribe(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
@@ -107,6 +110,7 @@ class WsBrowser(WsHandler):
110 runtime = await get_runtime(context_id)
111 command = str(data.get("command") or "").strip().lower().replace("-", "_")
112 browser_id = data.get("browser_id")
113 + viewer_id = str(data.get("viewer_id") or "")
114
115 try:
116 if command == "open":
@@ -130,12 +134,17 @@ class WsBrowser(WsHandler):
134
135 listing = await runtime.call("list")
136 last_interacted_browser_id = listing.get("last_interacted_browser_id")
137 + snapshot = await self._snapshot_for_result(runtime, result)
138 await self.emit_to(
139 sid,
140 "browser_viewer_state",
141 {
142 "context_id": context_id,
143 + "viewer_id": viewer_id,
144 + "command": command,
145 + "browser_id": browser_id,
146 "result": result,
147 + "snapshot": snapshot,
148 "browsers": listing.get("browsers") or [],
149 "last_interacted_browser_id": last_interacted_browser_id,
150 },
@@ -143,8 +152,12 @@ class WsBrowser(WsHandler):
152 )
153 return {
154 "result": result,
155 + "snapshot": snapshot,
156 "browsers": listing.get("browsers") or [],
157 "last_interacted_browser_id": last_interacted_browser_id,
158 + "command": command,
159 + "browser_id": browser_id,
160 + "viewer_id": viewer_id,
161 }
162
163 async def _input(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
@@ -195,13 +208,34 @@ class WsBrowser(WsHandler):
208 except Exception as exc:
209 return self._error("INPUT_FAILED", str(exc), data)
210
198 - return {"state": result}
211 + return {
212 + "state": result,
213 + "snapshot": await self._snapshot_for_result(runtime, result)
214 + if input_type == "mouse"
215 + else None,
216 + }
217 +
218 + async def _snapshot_for_result(
219 + self,
220 + runtime: Any,
221 + result: dict[str, Any] | None,
222 + ) -> dict[str, Any] | None:
223 + if not isinstance(result, dict):
224 + return None
225 + state = result.get("state") if isinstance(result.get("state"), dict) else result
226 + browser_id = state.get("id") if isinstance(state, dict) else result.get("id")
227 + if not browser_id:
228 + return None
229 + with contextlib.suppress(Exception):
230 + return await runtime.call("screenshot", browser_id, quality=SCREENCAST_QUALITY)
231 + return None
232
233 async def _stream_frames(
234 self,
235 sid: str,
236 context_id: str,
237 browser_id: int | str | None,
238 + viewer_id: str = "",
239 ) -> None:
240 runtime = None
241 stream_id = None
@@ -209,7 +243,7 @@ class WsBrowser(WsHandler):
243 try:
244 runtime = await get_runtime(context_id, create=False)
245 if not runtime:
212 - await self._emit_empty_frame(sid, context_id)
246 + await self._emit_empty_frame(sid, context_id, viewer_id=viewer_id)
247 await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
248 continue
249
@@ -217,7 +251,7 @@ class WsBrowser(WsHandler):
251 browsers = listing.get("browsers") or []
252 active_id = self._active_browser_id(listing, browser_id)
253 if not active_id:
220 - await self._emit_empty_frame(sid, context_id, browsers=browsers)
254 + await self._emit_empty_frame(sid, context_id, browsers=browsers, viewer_id=viewer_id)
255 await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
256 continue
257
@@ -235,6 +269,7 @@ class WsBrowser(WsHandler):
269 "browser_viewer_frame",
270 {
271 "context_id": context_id,
272 + "viewer_id": viewer_id,
273 "browser_id": active_id,
274 "browsers": browsers,
275 "image": "",
@@ -244,6 +279,8 @@ class WsBrowser(WsHandler):
279 )
280
281 last_state_refresh = 0.0
282 + last_image_at = 0.0
283 + stream_started_at = time.monotonic()
284 while True:
285 now = time.monotonic()
286 if now - last_state_refresh >= FRAME_STATE_REFRESH_SECONDS:
@@ -256,19 +293,41 @@ class WsBrowser(WsHandler):
293 last_state_refresh = now
294
295 try:
259 - frame = await runtime.call(
260 - "read_screencast_frame",
261 - stream_id,
262 - timeout=FRAME_IDLE_TIMEOUT_SECONDS,
263 - )
264 - except TimeoutError:
296 + frame = await runtime.call("pop_screencast_frame", stream_id)
297 + except KeyError:
298 + break
299 + if frame is None:
300 + if (
301 + now - stream_started_at >= FRAME_FALLBACK_SCREENSHOT_SECONDS
302 + and (
303 + last_image_at == 0.0
304 + or now - last_image_at >= FRAME_FALLBACK_SCREENSHOT_SECONDS
305 + )
306 + ):
307 + with contextlib.suppress(Exception):
308 + frame = await runtime.call(
309 + "screenshot",
310 + active_id,
311 + quality=SCREENCAST_QUALITY,
312 + )
313 + frame["context_id"] = context_id
314 + frame["viewer_id"] = viewer_id
315 + frame["browser_id"] = active_id
316 + frame["browsers"] = browsers
317 + frame["state"] = frame.get("state") or state
318 + await self.emit_to(sid, "browser_viewer_frame", frame)
319 + last_image_at = time.monotonic()
320 + await asyncio.sleep(FRAME_IDLE_POLL_SECONDS)
321 continue
322
323 frame["context_id"] = context_id
324 + frame["viewer_id"] = viewer_id
325 frame["browser_id"] = active_id
326 frame["browsers"] = browsers
327 frame["state"] = state
328 await self.emit_to(sid, "browser_viewer_frame", frame)
329 + if frame.get("image"):
330 + last_image_at = time.monotonic()
331 except asyncio.CancelledError:
332 raise
333 except Exception:
@@ -315,12 +374,14 @@ class WsBrowser(WsHandler):
374 context_id: str,
375 *,
376 browsers: list[dict[str, Any]] | None = None,
377 + viewer_id: str = "",
378 ) -> None:
379 await self.emit_to(
380 sid,
381 "browser_viewer_frame",
382 {
383 "context_id": context_id,
384 + "viewer_id": viewer_id,
385 "browser_id": None,
386 "browsers": browsers or [],
387 "image": "",
plugins/_browser/extensions/webui/chat-input-bottom-actions-start/browser-button.html
+1 -1
@@ -5,7 +5,7 @@
5 aria-label="Show or hide Browser"
6 data-bs-placement="top"
7 data-bs-trigger="hover"
8 - @click="window.toggleModal ? window.toggleModal('/plugins/_browser/webui/main.html') : window.openModal('/plugins/_browser/webui/main.html')"
8 + @click="$store.rightCanvas ? $store.rightCanvas.toggle('browser') : (window.toggleModal ? window.toggleModal('/plugins/_browser/webui/main.html') : window.openModal('/plugins/_browser/webui/main.html'))"
9 >
10 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true">
11 <rect x="3" y="4" width="18" height="16" rx="2"></rect>
plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js
+5
@@ -38,6 +38,11 @@ function drawBrowserTool({
38 "visibility",
39 "Browser",
40 () => {
41 + const canvas = globalThis.Alpine?.store?.("rightCanvas");
42 + if (canvas) {
43 + void canvas.open("browser");
44 + return;
45 + }
46 if (window.ensureModalOpen) {
47 void window.ensureModalOpen(BROWSER_MODAL);
48 return;
plugins/_browser/extensions/webui/right-canvas-panels/browser-panel.html new
+8
@@ -0,0 +1,8 @@
1 +<div
2 + class="right-canvas-surface-panel browser-canvas-surface"
3 + data-surface-id="browser"
4 + x-show="$store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'browser'"
5 + style="display: none;"
6 +>
7 + <x-component path="/plugins/_browser/webui/browser-panel.html" mode="canvas"></x-component>
8 +</div>
plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js new
+39
@@ -0,0 +1,39 @@
1 +function waitForElement(selector, timeoutMs = 3000) {
2 + const found = document.querySelector(selector);
3 + if (found) return Promise.resolve(found);
4 + return new Promise((resolve) => {
5 + const timeout = globalThis.setTimeout(() => {
6 + observer.disconnect();
7 + resolve(null);
8 + }, timeoutMs);
9 + const observer = new MutationObserver(() => {
10 + const element = document.querySelector(selector);
11 + if (!element) return;
12 + globalThis.clearTimeout(timeout);
13 + observer.disconnect();
14 + resolve(element);
15 + });
16 + observer.observe(document.body, { childList: true, subtree: true });
17 + });
18 +}
19 +
20 +export default async function registerBrowserSurface(canvas) {
21 + canvas.registerSurface({
22 + id: "browser",
23 + title: "Browser",
24 + icon: "language",
25 + order: 10,
26 + modalPath: "/plugins/_browser/webui/main.html",
27 + async open() {
28 + const panel = await waitForElement('[data-surface-id="browser"] .browser-panel');
29 + const browser = globalThis.Alpine?.store?.("browserPage");
30 + if (panel && browser?.onOpen) {
31 + await browser.onOpen(panel, { mode: "canvas" });
32 + }
33 + },
34 + async close() {
35 + const browser = globalThis.Alpine?.store?.("browserPage");
36 + await browser?.cleanup?.();
37 + },
38 + });
39 +}
plugins/_browser/helpers/runtime.py
+16 -1
@@ -158,6 +158,15 @@ class _BrowserScreencast:
158 raise RuntimeError("Browser screencast stopped.")
159 return frame
160
161 + async def pop_frame(self) -> dict[str, Any] | None:
162 + try:
163 + frame = self.queue.get_nowait()
164 + except asyncio.QueueEmpty:
165 + return None
166 + if frame is None:
167 + raise RuntimeError("Browser screencast stopped.")
168 + return frame
169 +
170 async def stop(self) -> None:
171 if self.stopped:
172 return
@@ -656,6 +665,12 @@ class _BrowserRuntimeCore:
665 raise KeyError("Browser screencast is not active.")
666 return await screencast.next_frame(timeout=timeout)
667
668 + async def pop_screencast_frame(self, stream_id: str) -> dict[str, Any] | None:
669 + screencast = self.screencasts.get(str(stream_id or ""))
670 + if not screencast:
671 + raise KeyError("Browser screencast is not active.")
672 + return await screencast.pop_frame()
673 +
674 async def stop_screencast(self, stream_id: str) -> None:
675 screencast = self.screencasts.pop(str(stream_id or ""), None)
676 if screencast:
@@ -682,6 +697,7 @@ class _BrowserRuntimeCore:
697 if changed:
698 await page.set_viewport_size(viewport)
699 await self._stop_screencasts_for_browser(resolved_id)
700 + await self._settle(page, short=True)
701 self.last_interacted_browser_id = resolved_id
702 return {"state": await self._state(resolved_id), "viewport": viewport}
703
@@ -722,7 +738,6 @@ class _BrowserRuntimeCore:
738 page = self._page(resolved_id)
739 await page.mouse.move(float(x), float(y))
740 await page.mouse.wheel(float(delta_x), float(delta_y))
725 - await self._settle(page, short=True)
741 self.last_interacted_browser_id = resolved_id
742 return await self._state(resolved_id)
743
plugins/_browser/webui/browser-panel.html new
+923
@@ -0,0 +1,923 @@
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">
13 + <div class="browser-panel" x-create="$store.browserPage.onOpen($el, xAttrs($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 + :disabled="$store.browserPage.isBusy()"
31 + @click.stop="$confirmClick($event, () => $store.browserPage.command('close', { browser_id: browser.id }))">
32 + <span class="material-symbols-outlined">close</span>
33 + </button>
34 + </div>
35 + </template>
36 + <button type="button" class="browser-new-tab" title="New Browser" aria-label="New Browser"
37 + :disabled="$store.browserPage.isBusy()"
38 + @click="$store.browserPage.openNewBrowser()">
39 + <span class="material-symbols-outlined">add</span>
40 + </button>
41 + </div>
42 +
43 + <div class="browser-session-controls">
44 + <div class="browser-extension-menu" @click.outside="$store.browserPage.closeExtensionsMenu()"
45 + @keydown.escape.window="$store.browserPage.closeExtensionsMenu()">
46 + <button type="button" class="btn btn-icon-action browser-extensions" title="Browser extensions"
47 + aria-label="Browser extensions" @click.stop="$store.browserPage.toggleExtensionsMenu()"
48 + :aria-expanded="$store.browserPage.extensionMenuOpen.toString()"
49 + :class="{ 'is-active': $store.browserPage.status?.extensions?.active }">
50 + <span class="material-symbols-outlined">extension</span>
51 + </button>
52 + <div class="browser-extension-dropdown" x-show="$store.browserPage.extensionMenuOpen" x-transition
53 + style="display: none;">
54 + <div class="browser-extension-preset">
55 + <label for="browser-model-preset">Browser LLM Preset</label>
56 + <select id="browser-model-preset" x-model="$store.browserPage.modelPreset"
57 + @change="$store.browserPage.setBrowserModelPreset($event.target.value)"
58 + :disabled="$store.browserPage.modelPresetSaving">
59 + <option value="">Default Main Model</option>
60 + <template x-for="preset in $store.browserPage.modelPresetOptions" :key="preset.name">
61 + <option :value="preset.name" x-text="preset.label"></option>
62 + </template>
63 + </select>
64 + <div class="browser-extension-preset-summary" x-text="$store.browserPage.modelPresetSummary()">
65 + </div>
66 + </div>
67 + <div class="browser-extension-section-title">Chrome Extensions</div>
68 + <button type="button" class="dropdown-item" @click="$store.browserPage.createExtensionWithAgent()">
69 + <span class="material-symbols-outlined">add_circle</span>
70 + <span>Create New Extension with A0</span>
71 + </button>
72 + <div class="browser-extension-url">
73 + <label for="browser-extension-url">Input a Chrome Web Store URL</label>
74 + <input id="browser-extension-url" type="url" x-model="$store.browserPage.extensionInstallUrl"
75 + @keydown.enter.prevent="$store.browserPage.installExtensionFromUrl()"
76 + placeholder="https://chromewebstore.google.com/detail/..." />
77 + <div class="browser-extension-url-actions">
78 + <button type="button" class="btn btn-ok" @click="$store.browserPage.installExtensionFromUrl()"
79 + :disabled="$store.browserPage.extensionActionLoading">
80 + <span class="material-symbols-outlined"
81 + :class="{ spinning: $store.browserPage.extensionActionLoading }"
82 + x-text="$store.browserPage.extensionActionLoading ? 'progress_activity' : 'download'"></span>
83 + <span>Install URL</span>
84 + </button>
85 + <button type="button" class="btn btn-field"
86 + @click="$store.browserPage.askAgentInstallExtension()">
87 + <span class="material-symbols-outlined">psychology_alt</span>
88 + <span x-text="$store.browserPage.extensionAssistantActionLabel()">Scan with A0</span>
89 + </button>
90 + </div>
91 + <div class="browser-extension-warning" x-show="$store.browserPage.hasExtensionInstallUrl()"
92 + x-transition style="display: none;">
93 + <span class="material-symbols-outlined">warning</span>
94 + <span>
95 + Extensions run inside the Docker browser sandbox, but malicious or buggy extensions can still
96 + damage that environment. Review what you install.
97 + </span>
98 + </div>
99 + </div>
100 + <div class="browser-extension-list"
101 + x-show="$store.browserPage.extensionsListLoading || $store.browserPage.extensionsList.length"
102 + style="display: none;">
103 + <div class="browser-extension-list-header">
104 + <span>Installed extensions</span>
105 + <span class="material-symbols-outlined spinning"
106 + x-show="$store.browserPage.extensionsListLoading">progress_activity</span>
107 + </div>
108 + <template x-for="extension in $store.browserPage.extensionsList" :key="extension.path">
109 + <label class="browser-extension-row" :title="extension.path">
110 + <span class="browser-extension-row-text">
111 + <span class="browser-extension-name" x-text="extension.name || 'Unnamed extension'"></span>
112 + <span class="browser-extension-meta"
113 + x-text="$store.browserPage.extensionVersionLabel(extension)"></span>
114 + </span>
115 + <span class="browser-extension-toggle">
116 + <input type="checkbox" :checked="extension.enabled"
117 + :disabled="$store.browserPage.extensionToggleLoadingPath === extension.path"
118 + @change="$store.browserPage.setExtensionEnabled(extension, $event.target.checked, $event.target)" />
119 + <span class="browser-extension-switch"></span>
120 + </span>
121 + </label>
122 + </template>
123 + </div>
124 + <button type="button" class="dropdown-item" @click="$store.browserPage.openExtensionsSettings()">
125 + <span class="material-symbols-outlined">tune</span>
126 + <span>Settings</span>
127 + </button>
128 + <div class="browser-extension-message" x-show="$store.browserPage.extensionActionMessage"
129 + x-text="$store.browserPage.extensionActionMessage"></div>
130 + <div class="browser-extension-error" x-show="$store.browserPage.extensionActionError"
131 + x-text="$store.browserPage.extensionActionError"></div>
132 + </div>
133 + </div>
134 + </div>
135 + </div>
136 + </div>
137 +
138 + <div class="browser-toolbar">
139 + <div class="browser-navigation">
140 + <button class="btn btn-icon-action" title="Back" @click="$store.browserPage.command('back')"
141 + :disabled="!$store.browserPage.activeBrowserId || $store.browserPage.isBusy()">
142 + <span class="material-symbols-outlined">arrow_back</span>
143 + </button>
144 + <button class="btn btn-icon-action" title="Forward" @click="$store.browserPage.command('forward')"
145 + :disabled="!$store.browserPage.activeBrowserId || $store.browserPage.isBusy()">
146 + <span class="material-symbols-outlined">arrow_forward</span>
147 + </button>
148 + <button class="btn btn-icon-action" title="Reload" @click="$store.browserPage.command('reload')"
149 + :disabled="!$store.browserPage.activeBrowserId || $store.browserPage.isBusy()">
150 + <span class="material-symbols-outlined">refresh</span>
151 + </button>
152 + </div>
153 +
154 + <form class="browser-address-form" @submit.prevent="$store.browserPage.go()">
155 + <span class="material-symbols-outlined browser-address-icon">language</span>
156 + <input class="browser-address" x-model="$store.browserPage.address"
157 + @focus="$store.browserPage.onAddressFocus()" @blur="$store.browserPage.onAddressBlur()"
158 + :disabled="$store.browserPage.isBusy()"
159 + placeholder="https://example.com" autocomplete="off" />
160 + </form>
161 + </div>
162 +
163 + <div class="browser-error" x-show="$store.browserPage.error" x-text="$store.browserPage.error"></div>
164 +
165 + <div class="browser-stage" tabindex="0" @click="$el.focus()"
166 + @wheel.prevent="$store.browserPage.sendWheel($event)">
167 + <div class="browser-status" x-show="$store.browserPage.isBusy()">
168 + <span class="material-symbols-outlined spinning">progress_activity</span>
169 + <span x-text="$store.browserPage.loadingMessage()">Connecting browser...</span>
170 + </div>
171 + <template x-if="$store.browserPage.frameSrc">
172 + <img class="browser-frame" :src="$store.browserPage.frameSrc"
173 + @click="$store.browserPage.sendMouse('click', $event)"
174 + @mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)" draggable="false" />
175 + </template>
176 + <template x-if="!$store.browserPage.frameSrc && !$store.browserPage.isBusy()">
177 + <div class="browser-empty">
178 + <span class="material-symbols-outlined">captive_portal</span>
179 + <button class="btn btn-field" @click="$store.browserPage.command('open', { url: 'about:blank' })">Open
180 + Browser</button>
181 + </div>
182 + </template>
183 + </div>
184 + </div>
185 + </template>
186 + </div>
187 +
188 + <style>
189 + .modal-inner.browser-modal {
190 + box-sizing: border-box;
191 + container-type: inline-size;
192 + width: min(78vw, 1120px);
193 + height: min(88vh, 900px);
194 + min-width: min(320px, calc(100vw - 16px));
195 + min-height: min(480px, calc(100vh - 16px));
196 + max-width: calc(100vw - 16px);
197 + max-height: calc(100vh - 16px);
198 + resize: both;
199 + border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
200 + border-radius: 7px;
201 + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
202 + background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
203 + }
204 +
205 + .modal.modal-floating {
206 + pointer-events: none;
207 + }
208 +
209 + .modal.modal-floating .modal-inner {
210 + pointer-events: auto;
211 + }
212 +
213 + .modal-inner.browser-modal .modal-header {
214 + min-height: 34px;
215 + padding: 0.35rem 0.75rem 0.35rem 1rem;
216 + cursor: move;
217 + user-select: none;
218 + background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
219 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
220 + }
221 +
222 + .modal-inner.browser-modal .modal-title {
223 + font-size: 0.95rem;
224 + letter-spacing: 0;
225 + }
226 +
227 + .modal-inner.browser-modal .modal-close {
228 + font-size: 1.35rem;
229 + line-height: 1;
230 + }
231 +
232 + .modal-inner.browser-modal .modal-scroll {
233 + flex: 1 1 auto;
234 + min-height: 0;
235 + overflow: hidden;
236 + padding: 0;
237 + }
238 +
239 + .modal-inner.browser-modal .modal-bd.browser-modal-body {
240 + box-sizing: border-box;
241 + display: flex;
242 + flex-direction: column;
243 + height: 100%;
244 + padding: 0;
245 + min-height: 0;
246 + }
247 +
248 + .modal-inner.browser-modal .modal-bd.browser-modal-body>x-component,
249 + .modal-inner.browser-modal .modal-bd.browser-modal-body>x-component>div,
250 + .modal-inner.browser-modal .modal-bd.browser-modal-body>div {
251 + display: flex;
252 + flex: 1 1 auto;
253 + width: 100%;
254 + height: 100%;
255 + min-width: 0;
256 + min-height: 0;
257 + }
258 +
259 + .modal-inner.browser-modal .browser-panel {
260 + height: 100%;
261 + max-height: 100%;
262 + overflow: hidden;
263 + }
264 +
265 + .browser-panel {
266 + --browser-chrome-surface: color-mix(in srgb, var(--color-background) 92%, #000 8%);
267 + --browser-chrome-border: color-mix(in srgb, var(--color-border) 58%, transparent);
268 + --browser-tab-hover-border: color-mix(in srgb, var(--color-border) 78%, transparent);
269 + --browser-control-size: 34px;
270 + --browser-address-height: 34px;
271 + --browser-tab-height: 34px;
272 + --browser-tab-close-size: 28px;
273 + --browser-control-radius: 0.55rem;
274 + box-sizing: border-box;
275 + display: flex;
276 + flex: 1 1 auto;
277 + flex-direction: column;
278 + gap: 0;
279 + height: 100%;
280 + min-height: 0;
281 + position: relative;
282 + }
283 +
284 + .browser-toolbar {
285 + display: grid;
286 + grid-template-columns: auto minmax(0, 1fr);
287 + grid-template-areas: "nav address";
288 + gap: 8px;
289 + align-items: center;
290 + padding: 8px 10px 9px;
291 + border-bottom: 0;
292 + background: transparent;
293 + }
294 +
295 + .browser-navigation {
296 + grid-area: nav;
297 + display: flex;
298 + gap: 6px;
299 + }
300 +
301 + .browser-panel .btn-icon-action,
302 + .browser-new-tab {
303 + width: var(--browser-control-size);
304 + min-width: var(--browser-control-size);
305 + height: var(--browser-control-size);
306 + min-height: var(--browser-control-size);
307 + border-radius: var(--browser-control-radius);
308 + }
309 +
310 + .browser-panel .btn-icon-action {
311 + color: color-mix(in srgb, var(--color-text) 72%, var(--color-primary) 28%);
312 + background: color-mix(in srgb, var(--color-background) 26%, transparent);
313 + border-color: var(--browser-chrome-border);
314 + }
315 +
316 + .browser-panel .btn-icon-action:hover:not(:disabled) {
317 + color: var(--color-text);
318 + border-color: color-mix(in srgb, var(--color-primary) 34%, var(--browser-chrome-border));
319 + background: color-mix(in srgb, var(--color-background-hover) 62%, transparent);
320 + box-shadow: none;
321 + }
322 +
323 + .browser-panel .btn-icon-action .material-symbols-outlined {
324 + font-size: 1.12rem;
325 + }
326 +
327 + .browser-address-form {
328 + grid-area: address;
329 + min-width: 0;
330 + position: relative;
331 + margin: 0;
332 + }
333 +
334 + .browser-address-icon {
335 + position: absolute;
336 + left: 10px;
337 + top: 50%;
338 + transform: translateY(-50%);
339 + font-size: 18px;
340 + opacity: 0.58;
341 + pointer-events: none;
342 + }
343 +
344 + .browser-address {
345 + width: 100%;
346 + min-height: var(--browser-address-height);
347 + padding: 5px 10px 5px 34px;
348 + border-radius: var(--browser-control-radius);
349 + border: 1px solid var(--browser-chrome-border);
350 + background: var(--color-input);
351 + color: var(--color-text);
352 + font: inherit;
353 + }
354 +
355 + .browser-meta {
356 + display: grid;
357 + grid-template-columns: minmax(0, 1fr);
358 + gap: 0;
359 + padding: 7px 10px 0;
360 + border-bottom: 1px solid var(--browser-chrome-border);
361 + background: var(--browser-chrome-surface);
362 + }
363 +
364 + .browser-meta-top {
365 + display: grid;
366 + grid-template-columns: minmax(0, 1fr) auto;
367 + gap: 10px;
368 + align-items: end;
369 + }
370 +
371 + .browser-session-tabs {
372 + display: flex;
373 + align-items: end;
374 + gap: 4px;
375 + min-width: 0;
376 + overflow-x: auto;
377 + padding: 1px 0 0;
378 + scrollbar-width: thin;
379 + overflow-y: hidden;
380 + }
381 +
382 + .browser-session-tabs::-webkit-scrollbar {
383 + height: 4px;
384 + }
385 +
386 + .browser-session-tabs::-webkit-scrollbar-track {
387 + background: transparent;
388 + }
389 +
390 + .browser-session-tabs::-webkit-scrollbar-thumb {
391 + background: color-mix(in srgb, var(--color-border) 78%, transparent);
392 + border-radius: 999px;
393 + }
394 +
395 + .browser-new-tab {
396 + display: inline-flex;
397 + align-items: center;
398 + border: 1px solid transparent;
399 + color: var(--color-text);
400 + cursor: pointer;
401 + transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
402 + border-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
403 + color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
404 + opacity 0.18s cubic-bezier(0.4, 0, 0.2, 1);
405 + }
406 +
407 + .browser-tab-shell {
408 + flex: 0 1 210px;
409 + position: relative;
410 + display: grid;
411 + grid-template-columns: minmax(0, 1fr) var(--browser-tab-close-size);
412 + align-items: center;
413 + gap: 3px;
414 + min-width: 128px;
415 + max-width: 250px;
416 + height: var(--browser-tab-height);
417 + padding: 0 7px 0 10px;
418 + border: 1px solid transparent;
419 + border-radius: var(--browser-control-radius) var(--browser-control-radius) 0 0;
420 + background: transparent;
421 + opacity: 0.72;
422 + transition: border-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
423 + color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
424 + opacity 0.18s cubic-bezier(0.4, 0, 0.2, 1);
425 + }
426 +
427 + .browser-tab-shell:hover,
428 + .browser-tab-shell:focus-within {
429 + border-color: var(--browser-tab-hover-border);
430 + opacity: 0.94;
431 + }
432 +
433 + .browser-tab-shell.is-active {
434 + z-index: 2;
435 + margin-bottom: -1px;
436 + border-color: var(--browser-chrome-border);
437 + background: transparent;
438 + opacity: 1;
439 + box-shadow: none;
440 + }
441 +
442 + .browser-tab {
443 + display: inline-flex;
444 + align-items: center;
445 + justify-content: flex-start;
446 + gap: 8px;
447 + min-width: 0;
448 + width: 100%;
449 + height: 100%;
450 + padding: 0;
451 + border: 0;
452 + border-radius: 0;
453 + background: transparent;
454 + color: inherit;
455 + cursor: pointer;
456 + font: inherit;
457 + text-align: left;
458 + }
459 +
460 + .browser-tab:hover {
461 + background: transparent;
462 + }
463 +
464 + .browser-tab:focus-visible,
465 + .browser-tab-close:focus-visible {
466 + outline: 1px solid color-mix(in srgb, var(--color-primary) 70%, transparent);
467 + outline-offset: 1px;
468 + }
469 +
470 + .browser-tab-icon {
471 + flex: 0 0 auto;
472 + color: color-mix(in srgb, var(--color-text) 72%, var(--color-primary) 28%);
473 + font-size: 1.04rem;
474 + line-height: 1;
475 + }
476 +
477 + .browser-tab-title {
478 + min-width: 0;
479 + overflow: hidden;
480 + text-overflow: ellipsis;
481 + white-space: nowrap;
482 + font-size: 0.88rem;
483 + font-weight: 600;
484 + }
485 +
486 + .browser-tab-close {
487 + display: inline-flex;
488 + align-items: center;
489 + justify-content: center;
490 + width: var(--browser-tab-close-size);
491 + min-width: var(--browser-tab-close-size);
492 + height: var(--browser-tab-close-size);
493 + min-height: var(--browser-tab-close-size);
494 + padding: 0;
495 + border: 0;
496 + border-radius: 6px;
497 + background: transparent;
498 + color: color-mix(in srgb, var(--color-text) 52%, var(--color-primary) 48%);
499 + cursor: pointer;
500 + opacity: 0.72;
501 + transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
502 + color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
503 + opacity 0.18s cubic-bezier(0.4, 0, 0.2, 1);
504 + }
505 +
506 + .browser-tab-close:hover,
507 + .browser-tab-close.confirming {
508 + background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
509 + color: var(--color-text);
510 + opacity: 1;
511 + }
512 +
513 + .browser-tab-close .material-symbols-outlined {
514 + font-size: 0.82rem;
515 + line-height: 1;
516 + }
517 +
518 + .browser-new-tab {
519 + flex: 0 0 var(--browser-control-size);
520 + justify-content: center;
521 + padding: 0;
522 + border-color: transparent;
523 + border-radius: var(--browser-control-radius);
524 + background: transparent;
525 + color: color-mix(in srgb, var(--color-text) 62%, var(--color-primary) 38%);
526 + }
527 +
528 + .browser-new-tab:hover {
529 + background: color-mix(in srgb, var(--color-background-hover) 58%, transparent);
530 + color: var(--color-text);
531 + }
532 +
533 + .browser-new-tab .material-symbols-outlined {
534 + font-size: 1.18rem;
535 + }
536 +
537 + .browser-session-controls {
538 + display: flex;
539 + align-items: end;
540 + gap: 6px;
541 + min-width: 0;
542 + padding-bottom: 1px;
543 + }
544 +
545 + .browser-session-controls .browser-extensions.is-active {
546 + color: #2e7d32;
547 + }
548 +
549 + .browser-extension-menu {
550 + position: relative;
551 + display: flex;
552 + flex: 0 0 auto;
553 + }
554 +
555 + .browser-extension-dropdown {
556 + position: absolute;
557 + top: calc(100% + 6px);
558 + right: 0;
559 + z-index: 40;
560 + display: flex;
561 + flex-direction: column;
562 + gap: 7px;
563 + width: min(360px, calc(100vw - 24px));
564 + max-height: min(72vh, 560px);
565 + overflow-y: auto;
566 + padding: 10px;
567 + border: 1px solid color-mix(in srgb, var(--color-border) 78%, transparent);
568 + border-radius: 7px;
569 + background: var(--color-background);
570 + box-shadow: 0 16px 38px rgba(0, 0, 0, 0.28);
571 + }
572 +
573 + .browser-extension-dropdown .dropdown-item {
574 + display: flex;
575 + align-items: center;
576 + gap: 8px;
577 + width: 100%;
578 + min-height: 34px;
579 + padding: 7px 9px;
580 + border: 0;
581 + border-radius: 6px;
582 + background: transparent;
583 + color: var(--color-text);
584 + font-weight: 600;
585 + text-align: left;
586 + cursor: pointer;
587 + }
588 +
589 + .browser-extension-dropdown .dropdown-item:hover {
590 + background: color-mix(in srgb, var(--color-panel) 82%, transparent);
591 + }
592 +
593 + .browser-extension-warning,
594 + .browser-extension-message,
595 + .browser-extension-error {
596 + display: flex;
597 + align-items: flex-start;
598 + gap: 8px;
599 + padding: 9px 10px;
600 + border-radius: 7px;
601 + font-size: 0.8rem;
602 + line-height: 1.35;
603 + }
604 +
605 + .browser-extension-warning {
606 + border: 1px solid color-mix(in srgb, #d97706 42%, var(--color-border));
607 + background: color-mix(in srgb, #d97706 14%, var(--color-background));
608 + color: color-mix(in srgb, var(--color-text) 86%, #92400e);
609 + }
610 +
611 + .browser-extension-warning .material-symbols-outlined {
612 + color: #b45309;
613 + font-size: 19px;
614 + }
615 +
616 + .browser-extension-url {
617 + display: flex;
618 + flex-direction: column;
619 + gap: 7px;
620 + padding: 8px;
621 + border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
622 + border-radius: 7px;
623 + background: var(--color-panel);
624 + }
625 +
626 + .browser-extension-url label {
627 + font-size: 0.76rem;
628 + color: var(--color-text-secondary);
629 + }
630 +
631 + .browser-extension-url input {
632 + min-width: 0;
633 + min-height: 32px;
634 + padding: 6px 8px;
635 + border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
636 + border-radius: 6px;
637 + background: var(--color-input);
638 + color: var(--color-text);
639 + }
640 +
641 + .browser-extension-url-actions {
642 + display: grid;
643 + grid-template-columns: repeat(2, minmax(0, 1fr));
644 + gap: 7px;
645 + }
646 +
647 + .browser-extension-url-actions .btn {
648 + display: inline-flex;
649 + align-items: center;
650 + justify-content: center;
651 + gap: 6px;
652 + width: 100%;
653 + min-width: 0;
654 + min-height: 30px;
655 + }
656 +
657 + .browser-extension-preset,
658 + .browser-extension-list {
659 + display: flex;
660 + flex-direction: column;
661 + gap: 7px;
662 + }
663 +
664 + .browser-extension-section-title {
665 + padding: 6px 2px 0;
666 + color: var(--color-text-secondary);
667 + font-size: 0.76rem;
668 + font-weight: 700;
669 + }
670 +
671 + .browser-extension-preset label,
672 + .browser-extension-list-header {
673 + font-size: 0.76rem;
674 + font-weight: 650;
675 + color: var(--color-text-secondary);
676 + }
677 +
678 + .browser-extension-preset select {
679 + width: 100%;
680 + min-height: 32px;
681 + padding: 5px 8px;
682 + border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
683 + border-radius: 6px;
684 + background: var(--color-input);
685 + color: var(--color-text);
686 + }
687 +
688 + .browser-extension-preset-summary,
689 + .browser-extension-meta {
690 + color: var(--color-text-secondary);
691 + font-size: 0.75rem;
692 + padding-left: var(--spacing-xxs);
693 + }
694 +
695 + .browser-extension-list-header {
696 + display: flex;
697 + align-items: center;
698 + justify-content: space-between;
699 + gap: 8px;
700 + }
701 +
702 + .browser-extension-list-header .material-symbols-outlined {
703 + font-size: 16px;
704 + }
705 +
706 + .browser-extension-row {
707 + display: grid;
708 + grid-template-columns: minmax(0, 1fr) auto;
709 + align-items: center;
710 + gap: 10px;
711 + min-height: 34px;
712 + padding: 5px 0;
713 + }
714 +
715 + .browser-extension-row+.browser-extension-row {
716 + border-top: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
717 + }
718 +
719 + .browser-extension-row-text {
720 + display: flex;
721 + min-width: 0;
722 + flex-direction: column;
723 + gap: 2px;
724 + }
725 +
726 + .browser-extension-name {
727 + overflow: hidden;
728 + text-overflow: ellipsis;
729 + white-space: nowrap;
730 + font-size: 0.82rem;
731 + font-weight: 650;
732 + color: var(--color-text);
733 + }
734 +
735 + .browser-extension-toggle {
736 + position: relative;
737 + display: inline-flex;
738 + flex: 0 0 auto;
739 + align-items: center;
740 + width: 38px;
741 + height: 22px;
742 + }
743 +
744 + .browser-extension-toggle input {
745 + position: absolute;
746 + inset: 0;
747 + margin: 0;
748 + opacity: 0;
749 + cursor: pointer;
750 + }
751 +
752 + .browser-extension-toggle input:disabled {
753 + cursor: wait;
754 + }
755 +
756 + .browser-extension-switch {
757 + width: 100%;
758 + height: 100%;
759 + border-radius: 999px;
760 + background: color-mix(in srgb, var(--color-border) 78%, transparent);
761 + transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
762 + opacity 0.18s cubic-bezier(0.4, 0, 0.2, 1);
763 + }
764 +
765 + .browser-extension-switch::after {
766 + content: "";
767 + position: absolute;
768 + top: 3px;
769 + left: 3px;
770 + width: 16px;
771 + height: 16px;
772 + border-radius: 50%;
773 + background: var(--color-background);
774 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.24);
775 + transition: transform 0.18s cubic-bezier(0.4, 0, 0.2, 1);
776 + }
777 +
778 + .browser-extension-toggle input:checked+.browser-extension-switch {
779 + background: color-mix(in srgb, var(--color-primary) 76%, #16a34a);
780 + }
781 +
782 + .browser-extension-toggle input:checked+.browser-extension-switch::after {
783 + transform: translateX(16px);
784 + }
785 +
786 + .browser-extension-toggle input:disabled+.browser-extension-switch {
787 + opacity: 0.58;
788 + }
789 +
790 + .browser-extension-message {
791 + background: color-mix(in srgb, #15803d 12%, var(--color-background));
792 + color: color-mix(in srgb, var(--color-text) 88%, #166534);
793 + }
794 +
795 + .browser-extension-error {
796 + background: color-mix(in srgb, #be123c 12%, var(--color-background));
797 + color: #9f1239;
798 + }
799 +
800 + .browser-stage {
801 + flex: 1 1 auto;
802 + display: flex;
803 + flex-direction: column;
804 + min-height: 0;
805 + overflow: hidden;
806 + position: relative;
807 + background: #fff;
808 + outline: none;
809 + }
810 +
811 + .browser-frame {
812 + flex: 1 1 auto;
813 + display: block;
814 + width: 100%;
815 + height: 100%;
816 + min-width: 0;
817 + min-height: 0;
818 + object-fit: fill;
819 + image-rendering: auto;
820 + user-select: none;
821 + background: #fff;
822 + }
823 +
824 + .browser-status,
825 + .browser-error,
826 + .browser-empty {
827 + display: flex;
828 + align-items: center;
829 + gap: 8px;
830 + min-height: 42px;
831 + font-size: 0.88rem;
832 + }
833 +
834 + .browser-status,
835 + .browser-error {
836 + padding: 0 12px;
837 + }
838 +
839 + .browser-status {
840 + position: absolute;
841 + top: 10px;
842 + left: 10px;
843 + z-index: 5;
844 + width: max-content;
845 + max-width: calc(100% - 20px);
846 + min-height: 32px;
847 + padding: 7px 10px;
848 + border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
849 + border-radius: 7px;
850 + background: color-mix(in srgb, var(--color-background) 92%, transparent);
851 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
852 + pointer-events: none;
853 + }
854 +
855 + .browser-modal .spinning,
856 + .browser-panel .spinning {
857 + display: inline-block;
858 + transform-origin: center;
859 + animation: browser-spin 0.8s linear infinite;
860 + }
861 +
862 + @keyframes browser-spin {
863 + to {
864 + transform: rotate(360deg);
865 + }
866 + }
867 +
868 + .browser-error {
869 + color: #9f1239;
870 + }
871 +
872 + .browser-empty {
873 + display: grid;
874 + flex: 1 1 auto;
875 + width: 100%;
876 + min-height: 0;
877 + justify-items: center;
878 + align-content: center;
879 + text-align: center;
880 + padding: 24px;
881 + color: var(--color-text);
882 + background: var(--color-background);
883 + }
884 +
885 + @container (max-width: 460px) {
886 + .browser-meta-top {
887 + grid-template-columns: minmax(0, 1fr);
888 + }
889 +
890 + .browser-session-controls {
891 + width: 100%;
892 + justify-content: flex-end;
893 + }
894 +
895 + .browser-session-tabs {
896 + width: 100%;
897 + }
898 +
899 + .browser-tab-shell {
900 + flex-basis: 170px;
901 + min-width: 142px;
902 + }
903 +
904 + .browser-new-tab,
905 + .browser-session-controls .btn-icon-action {
906 + width: var(--browser-control-size);
907 + height: var(--browser-control-size);
908 + }
909 +
910 + .browser-extension-dropdown {
911 + right: 0;
912 + left: auto;
913 + width: min(296px, calc(100vw - 72px));
914 + }
915 +
916 + .browser-address {
917 + min-height: var(--browser-address-height);
918 + }
919 + }
920 + </style>
921 +</body>
922 +
923 +</html>
plugins/_browser/webui/browser-store.js
+315 -121
@@ -11,6 +11,11 @@ const EXTENSIONS_ROOT = "/a0/usr/plugins/_browser/extensions";
11 const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
12 const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
13
14 +function makeViewerToken() {
15 + return globalThis.crypto?.randomUUID?.()
16 + || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
17 +}
18 +
19 function firstOk(response) {
20 const result = response?.results?.find((item) => item?.ok);
21 if (result) {
@@ -36,6 +41,8 @@ const model = {
41 frameSrc: "",
42 frameState: null,
43 connected: false,
44 + switchingBrowserId: null,
45 + commandInFlight: false,
46 addressFocused: false,
47 _frameOff: null,
48 _stateOff: null,
@@ -48,6 +55,9 @@ const model = {
55 _stageResizeObserver: null,
56 _viewportSyncTimer: null,
57 _lastViewportKey: "",
58 + _mode: "canvas",
59 + _connectSequence: 0,
60 + _viewerToken: "",
61 extensionMenuOpen: false,
62 extensionInstallUrl: "",
63 extensionActionLoading: false,
@@ -286,10 +296,15 @@ const model = {
296 this.closeExtensionsMenu();
297 },
298
289 - async onOpen(element = null) {
299 + async onOpen(element = null, options = {}) {
300 this.loading = true;
301 this.error = "";
292 - this.setupFloatingModal(element);
302 + this._mode = options?.mode === "modal" ? "modal" : "canvas";
303 + if (this._mode === "modal") {
304 + this.setupFloatingModal(element);
305 + } else {
306 + this.setupCanvasSurface(element);
307 + }
308 this.contextId = this.resolveContextId();
309 try {
310 await this.refreshStatus();
@@ -301,115 +316,172 @@ const model = {
316 }
317 },
318
304 - async connectViewer() {
305 - if (!this.contextId) {
306 - this.connected = false;
307 - this.error = "No active chat context is selected.";
308 - return;
309 - }
310 - this.error = "";
311 - await this._bindSocketEvents();
312 - const initialViewport = this.currentViewportSize();
313 - const response = await websocket.request(
319 + async connectViewer(options = {}) {
320 + if (!this.contextId) {
321 + this.connected = false;
322 + this.error = "No active chat context is selected.";
323 + this.switchingBrowserId = null;
324 + return;
325 + }
326 + const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? this.activeBrowserId);
327 + const sequence = this._connectSequence + 1;
328 + const viewerToken = makeViewerToken();
329 + this._connectSequence = sequence;
330 + this._viewerToken = viewerToken;
331 + this.error = "";
332 + await this._bindSocketEvents();
333 + if (sequence !== this._connectSequence || viewerToken !== this._viewerToken) {
334 + return;
335 + }
336 + const initialViewport = this.currentViewportSize();
337 + let response;
338 + try {
339 + response = await websocket.request(
340 "browser_viewer_subscribe",
341 {
342 context_id: this.contextId,
317 - browser_id: this.activeBrowserId,
343 + browser_id: requestedBrowserId,
344 + viewer_id: viewerToken,
345 viewport_width: initialViewport?.width,
346 viewport_height: initialViewport?.height,
347 },
348 {
322 - timeoutMs: this.browserInstallExpected
323 - ? BROWSER_FIRST_INSTALL_TIMEOUT_MS
324 - : BROWSER_SUBSCRIBE_TIMEOUT_MS,
325 - },
326 - );
327 - const data = firstOk(response);
328 - this.browsers = data.browsers || [];
329 - this.setActiveBrowserId(data.active_browser_id || this.activeBrowserId || null);
330 - this.connected = true;
331 - this.browserInstallExpected = false;
332 - this.queueViewportSync(true);
333 - },
349 + timeoutMs: this.browserInstallExpected
350 + ? BROWSER_FIRST_INSTALL_TIMEOUT_MS
351 + : BROWSER_SUBSCRIBE_TIMEOUT_MS,
352 + },
353 + );
354 + } catch (error) {
355 + if (sequence === this._connectSequence && viewerToken === this._viewerToken) {
356 + this.switchingBrowserId = null;
357 + throw error;
358 + }
359 + return;
360 + }
361 + if (sequence !== this._connectSequence || viewerToken !== this._viewerToken) {
362 + return;
363 + }
364 + const data = firstOk(response);
365 + this.browsers = data.browsers || [];
366 + this.setActiveBrowserId(data.active_browser_id || requestedBrowserId || this.activeBrowserId || null);
367 + this.connected = true;
368 + this.browserInstallExpected = false;
369 + },
370
371 async _bindSocketEvents() {
372 if (!this._frameOff) {
373 const frameHandler = ({ data }) => {
338 - if (data?.context_id !== this.contextId) return;
339 - this.browsers = data.browsers || this.browsers;
340 - this.setActiveBrowserId(data.browser_id || data.state?.id || this.activeBrowserId);
341 - if (data.state) {
342 - this.frameState = data.state;
343 - }
344 - if (!this.addressFocused && data.state?.currentUrl) {
345 - this.address = data.state.currentUrl;
374 + if (data?.context_id !== this.contextId) return;
375 + if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
376 + const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
377 + this.browsers = data.browsers || this.browsers;
378 + if (incomingBrowserId && !this.activeBrowserId) {
379 + this.setActiveBrowserId(incomingBrowserId);
380 + }
381 + if (incomingBrowserId && this.activeBrowserId && !this.sameBrowserId(incomingBrowserId, this.activeBrowserId)) {
382 + return;
383 + }
384 + if (data.state) {
385 + this.frameState = data.state;
386 + }
387 + if (!this.addressFocused && data.state?.currentUrl) {
388 + this.address = data.state.currentUrl;
389 + }
390 + if (data.image) {
391 + this.queueFrameRender(`data:${data.mime || "image/jpeg"};base64,${data.image}`);
392 + if (this.sameBrowserId(this.switchingBrowserId, incomingBrowserId || this.activeBrowserId)) {
393 + this.switchingBrowserId = null;
394 }
347 - if (data.image) {
348 - this.queueFrameRender(`data:${data.mime || "image/jpeg"};base64,${data.image}`);
349 - } else {
350 - this.cancelFrameRender();
395 + } else {
396 + this.cancelFrameRender();
397 + if (!data.state) {
398 this.frameSrc = "";
399 }
353 - if (!data.image && !data.state) {
400 + }
401 + if (!data.image && !data.state) {
402 + if (!this.activeBrowserId) {
403 this.setActiveBrowserId(null);
404 this.frameState = null;
405 this.frameSrc = "";
406 + }
407 }
408 this._lastFrameAt = Date.now();
409 };
410 await websocket.on("browser_viewer_frame", frameHandler);
411 this._frameOff = () => websocket.off("browser_viewer_frame", frameHandler);
412 }
363 - if (!this._stateOff) {
364 - const stateHandler = ({ data }) => {
365 - if (data?.context_id !== this.contextId) return;
366 - this.browsers = data.browsers || [];
367 - this.setActiveBrowserId(data.last_interacted_browser_id || this.firstBrowserId());
368 - this.queueViewportSync(true);
369 - };
413 + if (!this._stateOff) {
414 + const stateHandler = ({ data }) => {
415 + if (data?.context_id !== this.contextId) return;
416 + if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
417 + this.browsers = data.browsers || [];
418 + const command = String(data.command || "").toLowerCase();
419 + const commandBrowserId = this.normalizeBrowserId(data.browser_id);
420 + const result = data.result || {};
421 + const resultState = this.stateFromCommandResult(result);
422 + const preferredBrowserId = this.normalizeBrowserId(
423 + result.id
424 + || result.state?.id
425 + || data.last_interacted_browser_id
426 + || this.activeBrowserId
427 + || this.firstBrowserId()
428 + );
429 + if (
430 + !this.activeBrowserId
431 + || command === "open"
432 + || command === "close"
433 + || this.sameBrowserId(commandBrowserId, this.activeBrowserId)
434 + ) {
435 + this.setActiveBrowserId(preferredBrowserId);
436 + }
437 + this.applyActiveFrameState(resultState || this.browserById(this.activeBrowserId));
438 + this.applySnapshot(data.snapshot);
439 + };
440 await websocket.on("browser_viewer_state", stateHandler);
441 this._stateOff = () => websocket.off("browser_viewer_state", stateHandler);
372 - }
373 - },
374 -
375 - queueFrameRender(frameSrc) {
376 - this._pendingFrameSrc = frameSrc;
377 - if (this._frameRenderHandle) return;
378 - const schedule = globalThis.requestAnimationFrame?.bind(globalThis);
379 - if (schedule) {
380 - this._frameRenderCancel = globalThis.cancelAnimationFrame?.bind(globalThis) || null;
381 - this._frameRenderHandle = schedule(() => this.flushFrameRender());
382 - return;
383 - }
384 - this._frameRenderCancel = globalThis.clearTimeout?.bind(globalThis) || null;
385 - this._frameRenderHandle = globalThis.setTimeout(() => this.flushFrameRender(), 16);
386 - },
387 -
388 - flushFrameRender() {
389 - this._frameRenderHandle = null;
390 - this._frameRenderCancel = null;
391 - this.frameSrc = this._pendingFrameSrc || "";
392 - this._pendingFrameSrc = "";
393 - },
394 -
395 - cancelFrameRender() {
396 - if (this._frameRenderHandle && this._frameRenderCancel) {
397 - this._frameRenderCancel(this._frameRenderHandle);
398 - }
399 - this._frameRenderHandle = null;
400 - this._frameRenderCancel = null;
401 - this._pendingFrameSrc = "";
402 - },
403 -
404 - async command(command, extra = {}) {
405 - this.error = "";
406 - const previousActiveBrowserId = this.activeBrowserId;
442 + }
443 + },
444 +
445 + queueFrameRender(frameSrc) {
446 + this._pendingFrameSrc = frameSrc;
447 + if (this._frameRenderHandle) return;
448 + const schedule = globalThis.requestAnimationFrame?.bind(globalThis);
449 + if (schedule) {
450 + this._frameRenderCancel = globalThis.cancelAnimationFrame?.bind(globalThis) || null;
451 + this._frameRenderHandle = schedule(() => this.flushFrameRender());
452 + return;
453 + }
454 + this._frameRenderCancel = globalThis.clearTimeout?.bind(globalThis) || null;
455 + this._frameRenderHandle = globalThis.setTimeout(() => this.flushFrameRender(), 16);
456 + },
457 +
458 + flushFrameRender() {
459 + this._frameRenderHandle = null;
460 + this._frameRenderCancel = null;
461 + this.frameSrc = this._pendingFrameSrc || "";
462 + this._pendingFrameSrc = "";
463 + },
464 +
465 + cancelFrameRender() {
466 + if (this._frameRenderHandle && this._frameRenderCancel) {
467 + this._frameRenderCancel(this._frameRenderHandle);
468 + }
469 + this._frameRenderHandle = null;
470 + this._frameRenderCancel = null;
471 + this._pendingFrameSrc = "";
472 + },
473 +
474 + async command(command, extra = {}) {
475 + this.error = "";
476 + this.commandInFlight = true;
477 + const previousActiveBrowserId = this.activeBrowserId;
478 try {
479 const response = await websocket.request(
480 "browser_viewer_command",
481 {
482 context_id: this.contextId,
483 browser_id: this.activeBrowserId,
484 + viewer_id: this._viewerToken,
485 command,
486 ...extra,
487 },
@@ -425,20 +497,23 @@ const model = {
497 || data.last_interacted_browser_id
498 || this.firstBrowserId()
499 );
500 + this.applyActiveFrameState(this.stateFromCommandResult(result) || this.browserById(this.activeBrowserId));
501 if (!this.activeBrowserId) {
502 this.frameState = null;
503 this.frameSrc = "";
504 }
432 - if (result.state?.currentUrl || result.currentUrl) {
433 - this.address = result.state?.currentUrl || result.currentUrl;
434 - }
435 - const activeChanged = this.activeBrowserId && this.activeBrowserId !== previousActiveBrowserId;
436 - if ((command === "open" || command === "close" || activeChanged) && this.contextId && this.activeBrowserId) {
437 - await this.connectViewer();
438 - }
439 - this.queueViewportSync(true);
440 - } catch (error) {
505 + if (result.state?.currentUrl || result.currentUrl) {
506 + this.address = result.state?.currentUrl || result.currentUrl;
507 + }
508 + this.applySnapshot(data.snapshot);
509 + const activeChanged = this.activeBrowserId && this.activeBrowserId !== previousActiveBrowserId;
510 + if ((command === "open" || command === "close" || activeChanged) && this.contextId && this.activeBrowserId) {
511 + await this.connectViewer({ browserId: this.activeBrowserId });
512 + }
513 + } catch (error) {
514 this.error = error instanceof Error ? error.message : String(error);
515 + } finally {
516 + this.commandInFlight = false;
517 }
518 },
519
@@ -466,13 +541,33 @@ const model = {
541 },
542
543 async selectBrowser(id) {
469 - if (String(id || "").trim() === "") {
544 + const targetId = this.normalizeBrowserId(id);
545 + if (!targetId) {
546 await this.openNewBrowser();
547 return;
548 }
473 - this.setActiveBrowserId(id);
549 + if (this.sameBrowserId(targetId, this.activeBrowserId) && this.connected && !this.isSwitchingBrowser()) {
550 + return;
551 + }
552 + const browser = this.browserById(targetId);
553 + this.error = "";
554 + this.switchingBrowserId = targetId;
555 + this.cancelFrameRender();
556 + this.frameSrc = "";
557 + this.frameState = browser || null;
558 + if (!this.addressFocused && browser?.currentUrl) {
559 + this.address = browser.currentUrl;
560 + }
561 + this.setActiveBrowserId(targetId);
562 if (this.contextId) {
475 - await this.connectViewer();
563 + try {
564 + await this.connectViewer({ browserId: targetId });
565 + } catch (error) {
566 + if (this.sameBrowserId(this.switchingBrowserId, targetId)) {
567 + this.switchingBrowserId = null;
568 + }
569 + this.error = error instanceof Error ? error.message : String(error);
570 + }
571 }
572 },
573
@@ -500,9 +595,70 @@ const model = {
595 return first?.id || null;
596 },
597
598 + normalizeBrowserId(id) {
599 + return Number(id) || null;
600 + },
601 +
602 + sameBrowserId(left, right) {
603 + const leftId = this.normalizeBrowserId(left);
604 + const rightId = this.normalizeBrowserId(right);
605 + return Boolean(leftId && rightId && leftId === rightId);
606 + },
607 +
608 + browserById(id) {
609 + const numeric = this.normalizeBrowserId(id);
610 + if (!numeric || !Array.isArray(this.browsers)) return null;
611 + return this.browsers.find((browser) => Number(browser?.id) === numeric) || null;
612 + },
613 +
614 + stateFromCommandResult(result = {}) {
615 + if (result?.state?.id || result?.state?.currentUrl || result?.state?.title) {
616 + return result.state;
617 + }
618 + if (result?.id || result?.currentUrl || result?.title) {
619 + return result;
620 + }
621 + return null;
622 + },
623 +
624 + applyActiveFrameState(nextState = null) {
625 + if (!nextState) return;
626 + const stateId = this.normalizeBrowserId(nextState.id);
627 + if (stateId && this.activeBrowserId && !this.sameBrowserId(stateId, this.activeBrowserId)) {
628 + return;
629 + }
630 + this.frameState = nextState;
631 + if (!this.addressFocused && nextState.currentUrl) {
632 + this.address = nextState.currentUrl;
633 + }
634 + },
635 +
636 + applySnapshot(snapshot = null) {
637 + if (!snapshot?.image) return;
638 + const snapshotId = this.normalizeBrowserId(snapshot.browser_id || snapshot.state?.id);
639 + if (snapshotId && this.activeBrowserId && !this.sameBrowserId(snapshotId, this.activeBrowserId)) {
640 + return;
641 + }
642 + if (snapshot.state) {
643 + this.applyActiveFrameState(snapshot.state);
644 + }
645 + this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`);
646 + if (this.sameBrowserId(this.switchingBrowserId, snapshotId || this.activeBrowserId)) {
647 + this.switchingBrowserId = null;
648 + }
649 + },
650 +
651 + isSwitchingBrowser() {
652 + return Boolean(this.switchingBrowserId && this.sameBrowserId(this.switchingBrowserId, this.activeBrowserId));
653 + },
654 +
655 + isBusy() {
656 + return Boolean(this.loading || this.commandInFlight || this.isSwitchingBrowser());
657 + },
658 +
659 setActiveBrowserId(id) {
660 const previous = this.activeBrowserId;
505 - const numeric = Number(id) || null;
661 + const numeric = this.normalizeBrowserId(id);
662 const exists = !numeric || !Array.isArray(this.browsers) || this.browsers.some((browser) => Number(browser.id) === numeric);
663 this.activeBrowserId = exists ? numeric : null;
664 if (this.activeBrowserId !== previous) {
@@ -544,22 +700,23 @@ const model = {
700 }, force ? 0 : 80);
701 },
702
547 - async syncViewport(force = false) {
548 - if (!this.contextId || !this.activeBrowserId) return;
549 - const viewport = this.currentViewportSize();
550 - if (!viewport) return;
551 - const key = `${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
552 - if (!force && this._lastViewportKey === key) return;
553 - try {
554 - await websocket.emit("browser_viewer_input", {
555 - context_id: this.contextId,
556 - browser_id: this.activeBrowserId,
557 - input_type: "viewport",
558 - width: viewport.width,
559 - height: viewport.height,
560 - });
561 - this._lastViewportKey = key;
562 - } catch (error) {
703 + async syncViewport(force = false) {
704 + if (!this.contextId || !this.activeBrowserId) return;
705 + const viewport = this.currentViewportSize();
706 + if (!viewport) return;
707 + const key = `${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
708 + if (this._lastViewportKey === key) return;
709 + try {
710 + await websocket.emit("browser_viewer_input", {
711 + context_id: this.contextId,
712 + browser_id: this.activeBrowserId,
713 + viewer_id: this._viewerToken,
714 + input_type: "viewport",
715 + width: viewport.width,
716 + height: viewport.height,
717 + });
718 + this._lastViewportKey = key;
719 + } catch (error) {
720 this._lastViewportKey = "";
721 console.warn("Browser viewport sync failed", error);
722 }
@@ -569,32 +726,51 @@ const model = {
726 if (!this.activeBrowserId || !event?.currentTarget) return;
727 const pointer = this.pointerCoordinatesFor(event);
728 if (!pointer) return;
572 - await websocket.emit("browser_viewer_input", {
729 + const payload = {
730 context_id: this.contextId,
731 browser_id: this.activeBrowserId,
732 + viewer_id: this._viewerToken,
733 input_type: "mouse",
734 event_type: eventType,
735 x: pointer.x,
736 y: pointer.y,
737 button: "left",
580 - });
738 + };
739 + if (eventType === "click") {
740 + try {
741 + const response = await websocket.request("browser_viewer_input", payload, { timeoutMs: 10000 });
742 + const data = firstOk(response);
743 + this.applyActiveFrameState(data.state);
744 + this.applySnapshot(data.snapshot);
745 + } catch (error) {
746 + this.error = error instanceof Error ? error.message : String(error);
747 + }
748 + return;
749 + }
750 + await websocket.emit("browser_viewer_input", payload);
751 },
752
583 - async sendWheel(event) {
753 + async sendWheel(event) {
754 if (!this.activeBrowserId || !event) return;
755 const image = event.currentTarget?.querySelector?.(".browser-frame") || event.target?.closest?.(".browser-frame");
756 const pointer = this.pointerCoordinatesFor(event, image);
757 if (!pointer) return;
588 - await websocket.emit("browser_viewer_input", {
758 + const payload = {
759 context_id: this.contextId,
760 browser_id: this.activeBrowserId,
761 + viewer_id: this._viewerToken,
762 input_type: "wheel",
763 x: pointer.x,
764 y: pointer.y,
765 delta_x: Number(event.deltaX || 0),
766 delta_y: Number(event.deltaY || 0),
596 - });
597 - },
767 + };
768 + try {
769 + await websocket.emit("browser_viewer_input", payload);
770 + } catch (error) {
771 + this.error = error instanceof Error ? error.message : String(error);
772 + }
773 + },
774
775 async sendKey(event) {
776 if (!this.activeBrowserId) return;
@@ -613,17 +789,21 @@ const model = {
789 },
790
791 async cleanup() {
792 + this._connectSequence += 1;
793 + this._viewerToken = "";
794 + this.switchingBrowserId = null;
795 + this.commandInFlight = false;
796 if (this.contextId) {
797 try {
798 await websocket.emit("browser_viewer_unsubscribe", { context_id: this.contextId });
799 } catch {}
800 }
801 this._frameOff?.();
622 - this._stateOff?.();
623 - this._frameOff = null;
624 - this._stateOff = null;
625 - this.cancelFrameRender();
626 - this._floatingCleanup?.();
802 + this._stateOff?.();
803 + this._frameOff = null;
804 + this._stateOff = null;
805 + this.cancelFrameRender();
806 + this._floatingCleanup?.();
807 this._floatingCleanup = null;
808 this._stageResizeObserver?.disconnect?.();
809 this._stageResizeObserver = null;
@@ -751,6 +931,20 @@ const model = {
931 };
932 },
933
934 + setupCanvasSurface(element = null) {
935 + this._floatingCleanup?.();
936 + this._floatingCleanup = null;
937 + this._stageResizeObserver?.disconnect?.();
938 + const root = element || globalThis.document?.querySelector(".browser-panel");
939 + const stage = root?.querySelector?.(".browser-stage");
940 + this._stageElement = stage || null;
941 + if (stage && globalThis.ResizeObserver) {
942 + this._stageResizeObserver = new ResizeObserver(() => this.queueViewportSync());
943 + this._stageResizeObserver.observe(stage);
944 + }
945 + globalThis.requestAnimationFrame?.(() => this.queueViewportSync(true));
946 + },
947 +
948 get activeTitle() {
949 return this.frameState?.title || "Browser";
950 },
@@ -764,7 +958,7 @@ const model = {
958 const cacheDir = this.status?.playwright?.cache_dir || "/a0/usr/plugins/_browser/playwright";
959 return `Installing Chromium for the first Browser run. This can take a few minutes; future starts reuse ${cacheDir}.`;
960 }
767 - return "Connecting browser...";
961 + return "Loading";
962 },
963 };
964
plugins/_browser/webui/main.html
+8 -883
@@ -1,889 +1,14 @@
1 -<html class="browser-modal">
2 -
1 +<html
2 + class="browser-modal"
3 + data-canvas-surface="browser"
4 + data-canvas-modal-path="/plugins/_browser/webui/main.html"
5 + data-canvas-dock-title="Open Browser in canvas"
6 + data-canvas-dock-icon="dock_to_right"
7 +>
8 <head>
9 <title>Browser</title>
5 - <script type="module">
6 - import { store } from "/plugins/_browser/webui/browser-store.js";
7 - </script>
10 </head>
9 -
11 <body class="browser-modal-body">
11 - <div x-data>
12 - <template x-if="$store.browserPage">
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>
162 - <span x-text="$store.browserPage.loadingMessage()">Connecting browser...</span>
163 - </div>
164 - <div class="browser-error" x-show="$store.browserPage.error" x-text="$store.browserPage.error"></div>
165 -
166 - <div class="browser-stage" tabindex="0" @click="$el.focus()"
167 - @wheel.prevent="$store.browserPage.sendWheel($event)">
168 - <template x-if="$store.browserPage.frameSrc">
169 - <img class="browser-frame" :src="$store.browserPage.frameSrc"
170 - @click="$store.browserPage.sendMouse('click', $event)"
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>
176 - <button class="btn btn-field" @click="$store.browserPage.command('open', { url: 'about:blank' })">Open
177 - Browser</button>
178 - </div>
179 - </template>
180 - </div>
181 - </div>
182 - </template>
183 - </div>
184 -
185 - <style>
186 - .modal-inner.browser-modal {
187 - box-sizing: border-box;
188 - container-type: inline-size;
189 - width: min(78vw, 1120px);
190 - height: min(88vh, 900px);
191 - min-width: min(320px, calc(100vw - 16px));
192 - min-height: min(480px, calc(100vh - 16px));
193 - max-width: calc(100vw - 16px);
194 - max-height: calc(100vh - 16px);
195 - resize: both;
196 - border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
197 - border-radius: 7px;
198 - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
199 - background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
200 - }
201 -
202 - .modal.modal-floating {
203 - pointer-events: none;
204 - }
205 -
206 - .modal.modal-floating .modal-inner {
207 - pointer-events: auto;
208 - }
209 -
210 - .modal-inner.browser-modal .modal-header {
211 - min-height: 34px;
212 - padding: 0.35rem 0.75rem 0.35rem 1rem;
213 - cursor: move;
214 - user-select: none;
215 - background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
216 - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
217 - }
218 -
219 - .modal-inner.browser-modal .modal-title {
220 - font-size: 0.95rem;
221 - letter-spacing: 0;
222 - }
223 -
224 - .modal-inner.browser-modal .modal-close {
225 - font-size: 1.35rem;
226 - line-height: 1;
227 - }
228 -
229 - .modal-inner.browser-modal .modal-scroll {
230 - flex: 1 1 auto;
231 - min-height: 0;
232 - overflow: hidden;
233 - padding: 0;
234 - }
235 -
236 - .modal-inner.browser-modal .modal-bd.browser-modal-body {
237 - box-sizing: border-box;
238 - display: flex;
239 - flex-direction: column;
240 - height: 100%;
241 - padding: 0;
242 - min-height: 0;
243 - }
244 -
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;
263 - flex-direction: column;
264 - gap: 0;
265 - height: 100%;
266 - min-height: 0;
267 - }
268 -
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;
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 {
313 - grid-area: address;
314 - min-width: 0;
315 - position: relative;
316 - margin: 0;
317 - }
318 -
319 - .browser-address-icon {
320 - position: absolute;
321 - left: 10px;
322 - top: 50%;
323 - transform: translateY(-50%);
324 - font-size: 18px;
325 - opacity: 0.58;
326 - pointer-events: none;
327 - }
328 -
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;
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);
671 - }
672 -
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 -
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 -
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 -
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 -
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;
787 - display: flex;
788 - flex-direction: column;
789 - min-height: 0;
790 - overflow: hidden;
791 - background: #fff;
792 - outline: none;
793 - }
794 -
795 - .browser-frame {
796 - flex: 0 0 auto;
797 - display: block;
798 - width: 100%;
799 - height: auto;
800 - min-width: 0;
801 - image-rendering: auto;
802 - user-select: none;
803 - background: #fff;
804 - }
805 -
806 - .browser-status,
807 - .browser-error,
808 - .browser-empty {
809 - display: flex;
810 - align-items: center;
811 - gap: 8px;
812 - min-height: 42px;
813 - font-size: 0.88rem;
814 - }
815 -
816 - .browser-status,
817 - .browser-error {
818 - padding: 0 12px;
819 - }
820 -
821 - .browser-modal .spinning,
822 - .browser-panel .spinning {
823 - display: inline-block;
824 - transform-origin: center;
825 - animation: browser-spin 0.8s linear infinite;
826 - }
827 -
828 - @keyframes browser-spin {
829 - to {
830 - transform: rotate(360deg);
831 - }
832 - }
833 -
834 - .browser-error {
835 - color: #9f1239;
836 - }
837 -
838 - .browser-empty {
839 - display: grid;
840 - flex: 1 1 auto;
841 - width: 100%;
842 - min-height: 0;
843 - justify-items: center;
844 - align-content: center;
845 - text-align: center;
846 - padding: 24px;
847 - color: var(--color-text);
848 - background: var(--color-background);
849 - }
850 -
851 - @container (max-width: 460px) {
852 - .browser-meta-top {
853 - grid-template-columns: minmax(0, 1fr);
854 - }
855 -
856 - .browser-session-controls {
857 - width: 100%;
858 - justify-content: flex-end;
859 - }
860 -
861 - .browser-session-tabs {
862 - width: 100%;
863 - }
864 -
865 - .browser-tab-shell {
866 - flex-basis: 170px;
867 - min-width: 142px;
868 - }
869 -
870 - .browser-new-tab,
871 - .browser-session-controls .btn-icon-action {
872 - width: var(--browser-control-size);
873 - height: var(--browser-control-size);
874 - }
875 -
876 - .browser-extension-dropdown {
877 - right: 0;
878 - left: auto;
879 - width: min(296px, calc(100vw - 72px));
880 - }
881 -
882 - .browser-address {
883 - min-height: var(--browser-address-height);
884 - }
885 - }
886 - </style>
12 + <x-component path="/plugins/_browser/webui/browser-panel.html" mode="modal"></x-component>
13 </body>
888 -
14 </html>