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>