Stabilize browser canvas screencast lifecycle

Restart the canvas screencast after page-changing commands and remount viewport metrics when starting or resizing streams so canvas scrolling stays smooth across first mount, new tabs, and navigation. Move Browser JS off Alpine global store lookups and onto direct store imports, tighten modal/canvas handoff state, and keep annotations aligned with accepted viewport frames. Improve Browser tab close ergonomics, allow Chromium native error pages to render without blocking the UI, include right-canvas tab polish, and expand regression coverage for these paths.

Alessandro committed Apr 28, 2026 at 07:02 UTC 9ec070793df5f14e984a12d7b2b6d2700d5e7f03
12 files changed +993 -191
plugins/_browser/api/ws_browser.py
+3 -25
@@ -14,7 +14,6 @@ from plugins._browser.helpers.runtime import get_runtime
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
17 SCREENCAST_QUALITY = 92
18
19
@@ -195,6 +194,7 @@ class WsBrowser(WsHandler):
194 browser_id,
195 int(data.get("width") or 0),
196 int(data.get("height") or 0),
197 + restart_screencast=bool(data.get("restart_stream")),
198 )
199 elif input_type == "wheel":
200 result = await runtime.call(
@@ -300,12 +300,11 @@ class WsBrowser(WsHandler):
300 "image": "",
301 "mime": "",
302 "state": state,
303 + "frame_source": "state",
304 },
305 )
306
307 last_state_refresh = 0.0
307 - last_image_at = 0.0
308 - stream_started_at = time.monotonic()
308 while True:
309 now = time.monotonic()
310 if now - last_state_refresh >= FRAME_STATE_REFRESH_SECONDS:
@@ -322,26 +321,6 @@ class WsBrowser(WsHandler):
321 except KeyError:
322 break
323 if frame is None:
325 - if (
326 - now - stream_started_at >= FRAME_FALLBACK_SCREENSHOT_SECONDS
327 - and (
328 - last_image_at == 0.0
329 - or now - last_image_at >= FRAME_FALLBACK_SCREENSHOT_SECONDS
330 - )
331 - ):
332 - with contextlib.suppress(Exception):
333 - frame = await runtime.call(
334 - "screenshot",
335 - active_id,
336 - quality=SCREENCAST_QUALITY,
337 - )
338 - frame["context_id"] = context_id
339 - frame["viewer_id"] = viewer_id
340 - frame["browser_id"] = active_id
341 - frame["browsers"] = browsers
342 - frame["state"] = frame.get("state") or state
343 - await self.emit_to(sid, "browser_viewer_frame", frame)
344 - last_image_at = time.monotonic()
324 await asyncio.sleep(FRAME_IDLE_POLL_SECONDS)
325 continue
326
@@ -350,9 +329,8 @@ class WsBrowser(WsHandler):
329 frame["browser_id"] = active_id
330 frame["browsers"] = browsers
331 frame["state"] = state
332 + frame["frame_source"] = "screencast"
333 await self.emit_to(sid, "browser_viewer_frame", frame)
354 - if frame.get("image"):
355 - last_image_at = time.monotonic()
334 except asyncio.CancelledError:
335 raise
336 except Exception:
plugins/_browser/extensions/webui/chat-input-bottom-actions-start/browser-button.html
+3 -3
@@ -1,11 +1,11 @@
1 <button
2 type="button"
3 class="text-button browser-chat-action"
4 - title="Show or hide Browser"
5 - aria-label="Show or hide Browser"
4 + title="Open Browser"
5 + aria-label="Open Browser"
6 data-bs-placement="top"
7 data-bs-trigger="hover"
8 - @click="$store.rightCanvas ? $store.rightCanvas.toggle('browser') : (window.toggleModal ? window.toggleModal('/plugins/_browser/webui/main.html') : window.openModal('/plugins/_browser/webui/main.html'))"
8 + @click="$store.rightCanvas ? $store.rightCanvas.open('browser') : (window.ensureModalOpen ? window.ensureModalOpen('/plugins/_browser/webui/main.html') : (window.openModal && 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
+10 -9
@@ -4,6 +4,8 @@ import {
4 } from "/components/messages/action-buttons/simple-action-buttons.js";
5 import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
6 import { store as speechStore } from "/components/chat/speech/speech-store.js";
7 +import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
8 +import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
9 import {
10 buildDetailPayload,
11 cleanStepTitle,
@@ -21,25 +23,24 @@ export default async function registerBrowserToolHandler(extData) {
23 }
24
25 async function openBrowserCanvas(payload = {}) {
24 - const canvas = globalThis.Alpine?.store?.("rightCanvas")
25 - || (await import("/components/canvas/right-canvas-store.js")).store;
26 - if (canvas) {
27 - await canvas.open("browser", payload);
26 + if (rightCanvasStore?.open) {
27 + await rightCanvasStore.open("browser", payload);
28 return;
29 }
30 +
31 if (window.ensureModalOpen) {
32 await window.ensureModalOpen(BROWSER_MODAL);
33 return;
34 }
34 - await window.openModal?.(BROWSER_MODAL);
35 + if (window.openModal) {
36 + await window.openModal(BROWSER_MODAL);
37 + }
38 }
39
40 async function browserAllowsToolAutofocus() {
41 try {
39 - const browser = globalThis.Alpine?.store?.("browserPage")
40 - || (await import("/plugins/_browser/webui/browser-store.js")).store;
41 - if (browser?.allowsToolAutofocus) {
42 - return await browser.allowsToolAutofocus();
42 + if (browserStore.allowsToolAutofocus) {
43 + return await browserStore.allowsToolAutofocus();
44 }
45 } catch (error) {
46 console.warn("Browser autofocus setting could not be checked", error);
plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js
+65 -4
@@ -1,3 +1,5 @@
1 +import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
2 +
3 function waitForElement(selector, timeoutMs = 3000) {
4 const found = document.querySelector(selector);
5 if (found) return Promise.resolve(found);
@@ -17,6 +19,56 @@ function waitForElement(selector, timeoutMs = 3000) {
19 });
20 }
21
22 +function nextAnimationFrame() {
23 + return new Promise((resolve) => {
24 + const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16));
25 + schedule(() => resolve());
26 + });
27 +}
28 +
29 +function isVisibleCanvasPanel(panel) {
30 + if (!panel?.isConnected) return false;
31 + const surface = panel.closest(".browser-canvas-surface");
32 + const stage = panel.querySelector(".browser-stage") || panel;
33 + const surfaceStyle = surface ? globalThis.getComputedStyle?.(surface) : null;
34 + const panelStyle = globalThis.getComputedStyle?.(panel);
35 + if (surfaceStyle?.display === "none" || surfaceStyle?.visibility === "hidden") return false;
36 + if (panelStyle?.display === "none" || panelStyle?.visibility === "hidden") return false;
37 + const rect = stage.getBoundingClientRect?.();
38 + return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
39 +}
40 +
41 +async function waitForVisibleCanvasPanel(selector, timeoutMs = 3000) {
42 + const deadline = Date.now() + timeoutMs;
43 + let stableKey = "";
44 + let stableCount = 0;
45 +
46 + while (Date.now() <= deadline) {
47 + const panel = document.querySelector(selector);
48 + const visible = isVisibleCanvasPanel(panel);
49 + if (visible) {
50 + const stage = panel.querySelector(".browser-stage") || panel;
51 + const rect = stage.getBoundingClientRect();
52 + const key = `${Math.round(rect.width || 0)}x${Math.round(rect.height || 0)}`;
53 + if (key === stableKey) {
54 + stableCount += 1;
55 + if (stableCount >= 2) {
56 + return panel;
57 + }
58 + } else {
59 + stableKey = key;
60 + stableCount = 0;
61 + }
62 + } else {
63 + stableKey = "";
64 + stableCount = 0;
65 + }
66 + await nextAnimationFrame();
67 + }
68 +
69 + return document.querySelector(selector);
70 +}
71 +
72 export default async function registerBrowserSurface(canvas) {
73 canvas.registerSurface({
74 id: "browser",
@@ -24,9 +76,19 @@ export default async function registerBrowserSurface(canvas) {
76 icon: "language",
77 order: 10,
78 modalPath: "/plugins/_browser/webui/main.html",
79 + beginDockHandoff() {
80 + browserStore.beginSurfaceHandoff?.();
81 + },
82 + finishDockHandoff() {
83 + browserStore.finishSurfaceHandoff?.();
84 + },
85 + cancelDockHandoff() {
86 + browserStore.cancelSurfaceHandoff?.();
87 + },
88 async open(payload = {}) {
28 - const panel = await waitForElement('[data-surface-id="browser"] .browser-panel');
29 - const browser = globalThis.Alpine?.store?.("browserPage");
89 + await waitForElement('[data-surface-id="browser"] .browser-panel');
90 + const panel = await waitForVisibleCanvasPanel('[data-surface-id="browser"] .browser-panel');
91 + const browser = browserStore;
92 if (panel && browser?.onOpen) {
93 await browser.onOpen(panel, {
94 mode: "canvas",
@@ -35,8 +97,7 @@ export default async function registerBrowserSurface(canvas) {
97 }
98 },
99 async close() {
38 - const browser = globalThis.Alpine?.store?.("browserPage");
39 - await browser?.cleanup?.();
100 + await browserStore.cleanup?.();
101 },
102 });
103 }
plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js
+10 -9
@@ -1,3 +1,6 @@
1 +import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
2 +import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
3 +
4 const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
5 const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
6 const autoOpenedBrowsers = new Set();
@@ -135,10 +138,8 @@ function hasOpened(key, persistedKey) {
138 }
139
140 async function openBrowserCanvas(payload = {}) {
138 - const canvas = globalThis.Alpine?.store?.("rightCanvas")
139 - || (await import("/components/canvas/right-canvas-store.js")).store;
140 - if (canvas) {
141 - await canvas.open("browser", payload);
141 + if (rightCanvasStore?.open) {
142 + await rightCanvasStore.open("browser", payload);
143 return;
144 }
145
@@ -146,15 +147,15 @@ async function openBrowserCanvas(payload = {}) {
147 await window.ensureModalOpen(BROWSER_MODAL);
148 return;
149 }
149 - await window.openModal?.(BROWSER_MODAL);
150 + if (window.openModal) {
151 + await window.openModal(BROWSER_MODAL);
152 + }
153 }
154
155 async function browserAllowsToolAutofocus() {
156 try {
154 - const browser = globalThis.Alpine?.store?.("browserPage")
155 - || (await import("/plugins/_browser/webui/browser-store.js")).store;
156 - if (browser?.allowsToolAutofocus) {
157 - return await browser.allowsToolAutofocus();
157 + if (browserStore.allowsToolAutofocus) {
158 + return await browserStore.allowsToolAutofocus();
159 }
160 } catch (error) {
161 console.warn("Browser autofocus setting could not be checked", error);
plugins/_browser/helpers/runtime.py
+72 -29
@@ -36,6 +36,7 @@ CHROME_SINGLETON_FILES = ("SingletonLock", "SingletonCookie", "SingletonSocket")
36 SCREENCAST_MAX_WIDTH = 4096
37 SCREENCAST_MAX_HEIGHT = 4096
38 VIEWPORT_SIZE_TOLERANCE = 4
39 +VIEWPORT_REMOUNT_PAUSE_SECONDS = 0.05
40
41 _SPECIAL_SCHEME_RE = re.compile(r"^(?:about|blob|data|file|mailto|tel):", re.I)
42 _URL_SCHEME_RE = re.compile(r"^[a-z][a-z\d+\-.]*://", re.I)
@@ -51,6 +52,18 @@ _TYPED_HOST_RE = re.compile(
52 _SAFE_CONTEXT_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
53
54
55 +def _nudged_viewport(viewport: dict[str, int]) -> dict[str, int]:
56 + width = int(viewport["width"])
57 + height = int(viewport["height"])
58 + if width < 4096:
59 + return {"width": width + 1, "height": height}
60 + if width > 320:
61 + return {"width": width - 1, "height": height}
62 + if height < 4096:
63 + return {"width": width, "height": height + 1}
64 + return {"width": width, "height": height - 1}
65 +
66 +
67 def normalize_url(value: str) -> str:
68 raw = str(value or "").strip()
69 if not raw:
@@ -126,6 +139,29 @@ class _BrowserScreencast:
139 self._expected_height = height
140 with contextlib.suppress(Exception):
141 await self.session.send("Page.enable")
142 + await self._apply_cdp_viewport_with_remount({"width": width, "height": height})
143 + await self.session.send(
144 + "Page.startScreencast",
145 + {
146 + "format": "jpeg",
147 + "quality": max(20, min(95, int(quality))),
148 + "maxWidth": SCREENCAST_MAX_WIDTH,
149 + "maxHeight": SCREENCAST_MAX_HEIGHT,
150 + "everyNthFrame": max(1, int(every_nth_frame)),
151 + },
152 + )
153 +
154 + async def _apply_cdp_viewport_with_remount(self, viewport: dict[str, int]) -> None:
155 + await self._apply_cdp_viewport(viewport)
156 + await asyncio.sleep(VIEWPORT_REMOUNT_PAUSE_SECONDS)
157 + await self._apply_cdp_viewport(_nudged_viewport(viewport))
158 + await asyncio.sleep(VIEWPORT_REMOUNT_PAUSE_SECONDS)
159 + await self._apply_cdp_viewport(viewport)
160 + await asyncio.sleep(VIEWPORT_REMOUNT_PAUSE_SECONDS)
161 +
162 + async def _apply_cdp_viewport(self, viewport: dict[str, int]) -> None:
163 + width = max(320, min(4096, int(viewport.get("width") or DEFAULT_VIEWPORT["width"])))
164 + height = max(200, min(4096, int(viewport.get("height") or DEFAULT_VIEWPORT["height"])))
165 await self.session.send(
166 "Emulation.setDeviceMetricsOverride",
167 {
@@ -144,16 +180,6 @@ class _BrowserScreencast:
180 "height": height,
181 },
182 )
147 - await self.session.send(
148 - "Page.startScreencast",
149 - {
150 - "format": "jpeg",
151 - "quality": max(20, min(95, int(quality))),
152 - "maxWidth": SCREENCAST_MAX_WIDTH,
153 - "maxHeight": SCREENCAST_MAX_HEIGHT,
154 - "everyNthFrame": max(1, int(every_nth_frame)),
155 - },
156 - )
183
184 async def next_frame(self, timeout: float = 1.0) -> dict[str, Any]:
185 frame = await asyncio.wait_for(self.queue.get(), timeout=max(0.1, float(timeout)))
@@ -197,13 +223,19 @@ class _BrowserScreencast:
223 async def _handle_frame(self, params: dict[str, Any]) -> None:
224 try:
225 data = params.get("data") or ""
200 - if data and self._frame_matches_viewport(data):
226 + if data:
227 + metadata = dict(params.get("metadata") or {})
228 + size = self._jpeg_size(data)
229 + if size:
230 + metadata["jpegWidth"], metadata["jpegHeight"] = size
231 + metadata["expectedWidth"] = self._expected_width
232 + metadata["expectedHeight"] = self._expected_height
233 self._queue_latest(
234 {
235 "browser_id": self.browser_id,
236 "mime": self.mime,
237 "image": data,
206 - "metadata": params.get("metadata") or {},
238 + "metadata": metadata,
239 }
240 )
241 finally:
@@ -220,20 +252,6 @@ class _BrowserScreencast:
252 with contextlib.suppress(asyncio.QueueFull):
253 self.queue.put_nowait(frame)
254
223 - def _frame_matches_viewport(self, data: str) -> bool:
224 - if not self._expected_width or not self._expected_height:
225 - return True
226 - size = self._jpeg_size(data)
227 - if not size:
228 - return True
229 - width, height = size
230 - if (
231 - abs(width - self._expected_width) <= VIEWPORT_SIZE_TOLERANCE
232 - and abs(height - self._expected_height) <= VIEWPORT_SIZE_TOLERANCE
233 - ):
234 - return True
235 - return False
236 -
255 @staticmethod
256 def _jpeg_size(data: str) -> tuple[int, int] | None:
257 try:
@@ -709,6 +727,7 @@ class _BrowserRuntimeCore:
727 browser_id: int | str | None,
728 width: int,
729 height: int,
730 + restart_screencast: bool = False,
731 ) -> dict[str, Any]:
732 await self.ensure_started()
733 resolved_id = self._resolve_browser_id(browser_id)
@@ -724,13 +743,33 @@ class _BrowserRuntimeCore:
743 or abs(int(current_viewport.get("height") or 0) - viewport["height"])
744 > VIEWPORT_SIZE_TOLERANCE
745 )
727 - if changed:
728 - await page.set_viewport_size(viewport)
746 + should_remount_viewport = changed or restart_screencast
747 + if should_remount_viewport:
748 await self._stop_screencasts_for_browser(resolved_id)
749 + if changed:
750 + await self._apply_viewport_with_remount(page, viewport)
751 + elif restart_screencast:
752 + await self._remount_viewport(page, viewport)
753 + if should_remount_viewport:
754 await self._settle(page, short=True)
755 self.last_interacted_browser_id = resolved_id
756 return {"state": await self._state(resolved_id), "viewport": viewport}
757
758 + async def _apply_viewport_with_remount(self, page: Any, viewport: dict[str, int]) -> None:
759 + await page.set_viewport_size(viewport)
760 + await asyncio.sleep(VIEWPORT_REMOUNT_PAUSE_SECONDS)
761 + await self._remount_viewport(page, viewport)
762 +
763 + async def _remount_viewport(self, page: Any, viewport: dict[str, int]) -> None:
764 + nudged_viewport = self._nudged_viewport(viewport)
765 + await page.set_viewport_size(nudged_viewport)
766 + await asyncio.sleep(VIEWPORT_REMOUNT_PAUSE_SECONDS)
767 + await page.set_viewport_size(viewport)
768 +
769 + @staticmethod
770 + def _nudged_viewport(viewport: dict[str, int]) -> dict[str, int]:
771 + return _nudged_viewport(viewport)
772 +
773 async def mouse(
774 self,
775 browser_id: int | str | None,
@@ -838,15 +877,19 @@ class _BrowserRuntimeCore:
877 return {"action": action or {}, "state": await self._state(resolved_id)}
878
879 async def _goto(self, page: Any, url: str) -> None:
880 + from playwright.async_api import Error as PlaywrightError
881 from playwright.async_api import TimeoutError as PlaywrightTimeoutError
882
883 try:
884 await page.goto(url, wait_until="domcontentloaded", timeout=30000)
885 except PlaywrightTimeoutError:
886 PrintStyle.warning(f"Browser navigation timed out after DOM handoff: {url}")
887 + except PlaywrightError as exc:
888 + PrintStyle.warning(f"Browser navigation showed a native error page for {url}: {exc}")
889 await self._settle(page)
890
891 async def _settle(self, page: Any, short: bool = False) -> None:
892 + from playwright.async_api import Error as PlaywrightError
893 from playwright.async_api import TimeoutError as PlaywrightTimeoutError
894
895 try:
@@ -854,7 +897,7 @@ class _BrowserRuntimeCore:
897 "domcontentloaded",
898 timeout=1000 if short else 5000,
899 )
857 - except PlaywrightTimeoutError:
900 + except (PlaywrightError, PlaywrightTimeoutError):
901 pass
902 await asyncio.sleep(0.1 if short else 0.35)
903
plugins/_browser/webui/browser-panel.html
+6 -5
@@ -27,8 +27,9 @@
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 }))">
30 + :disabled="$store.browserPage.isClosingBrowser(browser.id)"
31 + @pointerdown.stop
32 + @click.stop="$store.browserPage.closeBrowser(browser.id)">
33 <span class="material-symbols-outlined">close</span>
34 </button>
35 </div>
@@ -349,8 +350,8 @@
350 --browser-tab-hover-border: color-mix(in srgb, var(--color-border) 78%, transparent);
351 --browser-control-size: 34px;
352 --browser-address-height: 34px;
352 - --browser-tab-height: 34px;
353 - --browser-tab-close-size: 28px;
353 + --browser-tab-height: 36px;
354 + --browser-tab-close-size: 32px;
355 --browser-control-radius: 0.55rem;
356 box-sizing: border-box;
357 display: flex;
@@ -592,7 +593,7 @@
593 }
594
595 .browser-tab-close .material-symbols-outlined {
595 - font-size: 0.82rem;
596 + font-size: 1rem;
597 line-height: 1;
598 }
599
plugins/_browser/webui/browser-store.js
+358 -79
@@ -1,8 +1,11 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { getNamespacedClient } from "/js/websocket.js";
4 +import { getContext, setContext } from "/index.js";
5 import { store as chatInputStore } from "/components/chat/input/input-store.js";
6 import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
7 +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
8 +import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
9
10 const websocket = getNamespacedClient("/ws");
11 websocket.addHandlers(["ws_webui"]);
@@ -15,6 +18,7 @@ const VIEWPORT_SYNC_DEBOUNCE_MS = 220;
18 const VIEWPORT_SYNC_SIZE_TOLERANCE = 4;
19 const CANVAS_VIEWPORT_SETTLE_MS = 520;
20 const SURFACE_VIEWPORT_STABLE_FRAMES = 4;
21 +const SURFACE_VIEWPORT_MAX_WAIT_MS = 1200;
22 const FRAME_REJECT_SYNC_COOLDOWN_MS = 600;
23 const ANNOTATION_DRAG_THRESHOLD = 6;
24 const ANNOTATION_MAX_COMMENTS = 24;
@@ -126,7 +130,15 @@ const model = {
130 _mode: "",
131 _surfaceMounted: false,
132 _surfaceSwitching: false,
133 + _surfaceHandoff: false,
134 + _surfaceHandoffTimer: null,
135 _surfaceOpenedAt: 0,
136 + _surfaceOpenSequence: 0,
137 + _canvasSurfaceReadySequence: 0,
138 + _canvasFirstFrameAcceptedSequence: 0,
139 + _canvasFirstFrameNudgeSequence: 0,
140 + _openPromise: null,
141 + _openSignature: "",
142 _connectSequence: 0,
143 _viewerToken: "",
144 _contextCreatePromise: null,
@@ -146,6 +158,8 @@ const model = {
158 browserInstallExpected: false,
159 defaultHomepage: "about:blank",
160 autofocusActivePage: true,
161 + _commandInFlightCount: 0,
162 + _closingBrowserIds: {},
163 _configLoadedAt: 0,
164 _configRefreshPromise: null,
165
@@ -234,8 +248,7 @@ const model = {
248
249 resolveContextId() {
250 const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid");
237 - const selectedChat = globalThis.Alpine?.store?.("chats")?.selected;
238 - return globalThis.getContext?.() || urlContext || selectedChat || "";
251 + return getContext() || urlContext || chatsStore.selected || "";
252 },
253
254 async ensureContextId() {
@@ -269,12 +282,8 @@ const model = {
282 throw new Error(response?.error || "Could not create a chat for Browser.");
283 }
284
272 - if (typeof globalThis.setContext === "function") {
273 - globalThis.setContext(contextId);
274 - } else {
275 - const chatsStore = globalThis.Alpine?.store?.("chats");
276 - chatsStore?.setSelected?.(contextId);
277 - }
285 + setContext(contextId);
286 + chatsStore.setSelected?.(contextId);
287
288 return contextId;
289 },
@@ -451,10 +460,47 @@ const model = {
460 },
461
462 async onOpen(element = null, options = {}) {
463 + const requestedBrowserId = this.normalizeBrowserId(
464 + options.requestedBrowserId ?? options.browserId ?? options.browser_id,
465 + );
466 + const nextMode = options?.mode === "modal" ? "modal" : "canvas";
467 + if (nextMode === "canvas" && !this.isCanvasSurfaceVisible(element)) {
468 + return;
469 + }
470 + const openSignature = this.surfaceOpenSignature(element, nextMode, requestedBrowserId);
471 + if (this._openPromise && this._openSignature === openSignature) {
472 + return await this._openPromise;
473 + }
474 + const promise = this.openSurface(element, {
475 + ...options,
476 + requestedBrowserId,
477 + nextMode,
478 + });
479 + this._openPromise = promise;
480 + this._openSignature = openSignature;
481 + try {
482 + return await promise;
483 + } finally {
484 + if (this._openPromise === promise) {
485 + this._openPromise = null;
486 + this._openSignature = "";
487 + }
488 + }
489 + },
490 +
491 + async openSurface(element = null, options = {}) {
492 this.loading = true;
493 this.error = "";
456 - const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? options.browser_id);
457 - const nextMode = options?.mode === "modal" ? "modal" : "canvas";
494 + const requestedBrowserId = this.normalizeBrowserId(
495 + options.requestedBrowserId ?? options.browserId ?? options.browser_id,
496 + );
497 + const nextMode = options?.nextMode || (options?.mode === "modal" ? "modal" : "canvas");
498 + if (nextMode === "canvas" && !this.isCanvasSurfaceVisible(element)) {
499 + this.loading = false;
500 + return;
501 + }
502 + const surfaceSequence = this._surfaceOpenSequence + 1;
503 + this._surfaceOpenSequence = surfaceSequence;
504 this.prepareSurfaceOpen(nextMode, requestedBrowserId);
505 if (nextMode === "modal") {
506 this.setupFloatingModal(element);
@@ -463,16 +509,95 @@ const model = {
509 }
510 try {
511 await this.ensureContextId();
512 + if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
513 await this.refreshStatus();
467 - const viewport = await this.waitForSurfaceViewport();
514 + if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
515 + const viewport = await this.waitForSurfaceViewport({ sequence: surfaceSequence });
516 + if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
517 + if (nextMode === "canvas" && !viewport) return;
518 this.resetRenderedFrameIfViewportChanged(viewport, requestedBrowserId);
519 await this.connectViewer({ browserId: requestedBrowserId, initialViewport: viewport });
470 - await this.syncViewportAfterSurfaceOpen();
520 + if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
521 + await this.syncViewportAfterSurfaceOpen(surfaceSequence);
522 } catch (error) {
472 - this.error = error instanceof Error ? error.message : String(error);
523 + if (this.isCurrentSurfaceOpen(surfaceSequence)) {
524 + this.error = error instanceof Error ? error.message : String(error);
525 + }
526 } finally {
474 - this.loading = false;
527 + if (this.isCurrentSurfaceOpen(surfaceSequence)) {
528 + this.loading = false;
529 + if (this._mode === "canvas") {
530 + this._canvasSurfaceReadySequence = surfaceSequence;
531 + this.scheduleCanvasWidthNudgeAfterFirstFrame();
532 + }
533 + }
534 + }
535 + },
536 +
537 + surfaceOpenSignature(element = null, mode = "", browserId = null) {
538 + const root = element || globalThis.document?.querySelector(".browser-panel");
539 + if (root && !root.__browserSurfaceOpenId) {
540 + root.__browserSurfaceOpenId = makeViewerToken();
541 }
542 + return [
543 + mode || "",
544 + this.normalizeBrowserId(browserId) || "",
545 + root?.__browserSurfaceOpenId || "",
546 + ].join(":");
547 + },
548 +
549 + isCurrentSurfaceOpen(sequence) {
550 + return this._surfaceMounted && sequence === this._surfaceOpenSequence;
551 + },
552 +
553 + beginSurfaceHandoff() {
554 + if (this._surfaceHandoffTimer) {
555 + globalThis.clearTimeout(this._surfaceHandoffTimer);
556 + }
557 + this._surfaceHandoff = true;
558 + this._surfaceHandoffTimer = globalThis.setTimeout(() => {
559 + this._surfaceHandoff = false;
560 + this._surfaceHandoffTimer = null;
561 + }, 3000);
562 + },
563 +
564 + finishSurfaceHandoff() {
565 + if (this._surfaceHandoffTimer) {
566 + globalThis.clearTimeout(this._surfaceHandoffTimer);
567 + this._surfaceHandoffTimer = null;
568 + }
569 + this._surfaceHandoff = false;
570 + },
571 +
572 + cancelSurfaceHandoff() {
573 + if (this._surfaceHandoffTimer) {
574 + globalThis.clearTimeout(this._surfaceHandoffTimer);
575 + this._surfaceHandoffTimer = null;
576 + }
577 + this._surfaceHandoff = false;
578 + },
579 +
580 + releaseSurfaceBindings() {
581 + this._floatingCleanup?.();
582 + this._floatingCleanup = null;
583 + this._stageResizeObserver?.disconnect?.();
584 + this._stageResizeObserver = null;
585 + this._stageElement = null;
586 + },
587 +
588 + isCanvasSurfaceVisible(element = null) {
589 + const root = element
590 + || globalThis.document?.querySelector?.(".browser-canvas-surface .browser-panel")
591 + || globalThis.document?.querySelector?.(".browser-panel");
592 + if (!root?.isConnected) return false;
593 + const surface = root.closest?.(".browser-canvas-surface");
594 + const stage = root.querySelector?.(".browser-stage") || root;
595 + const surfaceStyle = surface ? globalThis.getComputedStyle?.(surface) : null;
596 + const rootStyle = globalThis.getComputedStyle?.(root);
597 + if (surfaceStyle?.display === "none" || surfaceStyle?.visibility === "hidden") return false;
598 + if (rootStyle?.display === "none" || rootStyle?.visibility === "hidden") return false;
599 + const rect = stage.getBoundingClientRect?.();
600 + return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
601 },
602
603 prepareSurfaceOpen(nextMode, requestedBrowserId = null) {
@@ -517,11 +642,16 @@ const model = {
642 this.switchingBrowserId = targetBrowserId;
643 },
644
520 - async waitForSurfaceViewport() {
645 + async waitForSurfaceViewport(options = {}) {
646 + const sequence = Number(options.sequence || 0);
647 + const startedAt = Date.now();
648 let lastKey = "";
649 let stableCount = 0;
523 - for (let index = 0; index < 24; index += 1) {
650 + while (Date.now() - startedAt <= SURFACE_VIEWPORT_MAX_WAIT_MS) {
651 await nextAnimationFrame();
652 + if (sequence && !this.isCurrentSurfaceOpen(sequence)) {
653 + return null;
654 + }
655 const viewport = this.surfaceViewportMeasurement();
656 if (!viewport) continue;
657 const key = `${viewport.rawWidth}x${viewport.rawHeight}`;
@@ -538,16 +668,29 @@ const model = {
668 lastKey = key;
669 }
670 }
541 - return this.currentViewportSize();
671 + const fallbackViewport = this.currentViewportSize();
672 + return fallbackViewport;
673 },
674
544 - async syncViewportAfterSurfaceOpen() {
675 + async syncViewportAfterSurfaceOpen(sequence = this._surfaceOpenSequence) {
676 if (!this.connected || !this.activeBrowserId) return;
546 - await this.waitForSurfaceViewport();
547 - await this.syncViewport(true);
677 + await this.waitForSurfaceViewport({ sequence });
678 + if (!this.isCurrentSurfaceOpen(sequence)) {
679 + return;
680 + }
681 + await this.syncViewport(true, { restartStream: this._mode === "canvas" });
682 if (this._mode !== "canvas") return;
549 - globalThis.setTimeout?.(() => this.queueViewportSync(true), 240);
550 - globalThis.setTimeout?.(() => this.queueViewportSync(true), 420);
683 + this.scheduleViewportSyncForSurface(sequence, 240);
684 + this.scheduleViewportSyncForSurface(sequence, 520);
685 + },
686 +
687 + scheduleViewportSyncForSurface(sequence, delayMs = 0) {
688 + globalThis.setTimeout?.(() => {
689 + if (!this.isCurrentSurfaceOpen(sequence) || this._mode !== "canvas") {
690 + return;
691 + }
692 + this.queueViewportSync(true);
693 + }, delayMs);
694 },
695
696 async connectViewer(options = {}) {
@@ -715,11 +858,12 @@ const model = {
858 this._pendingFrameSrc = "";
859 this._pendingFrameOptions = null;
860 const sequence = this._frameRenderSequence + 1;
861 + const surfaceSequence = this._surfaceOpenSequence;
862 this._frameRenderSequence = sequence;
719 - void this.renderDecodedFrame(frameSrc, options, sequence);
863 + void this.renderDecodedFrame(frameSrc, options, sequence, surfaceSequence);
864 },
865
722 - async renderDecodedFrame(frameSrc, options = {}, sequence = 0) {
866 + async renderDecodedFrame(frameSrc, options = {}, sequence = 0, surfaceSequence = this._surfaceOpenSequence) {
867 if (!frameSrc) {
868 if (sequence === this._frameRenderSequence) {
869 this.frameSrc = "";
@@ -727,7 +871,9 @@ const model = {
871 return;
872 }
873 const dimensions = await loadFrameDimensions(frameSrc);
730 - if (sequence !== this._frameRenderSequence) return;
874 + if (sequence !== this._frameRenderSequence || surfaceSequence !== this._surfaceOpenSequence) {
875 + return;
876 + }
877 const viewport = this.currentViewportSize() || this._lastViewport;
878 if (!this.frameMatchesViewport(dimensions, viewport)) {
879 this.requestViewportSyncAfterRejectedFrame();
@@ -737,6 +883,56 @@ const model = {
883 this._lastFrameDimensions = dimensions;
884 this._lastFrameAt = Date.now();
885 options?.onAccepted?.();
886 + this._canvasFirstFrameAcceptedSequence = surfaceSequence;
887 + this.scheduleCanvasWidthNudgeAfterFirstFrame();
888 + },
889 +
890 + scheduleCanvasWidthNudgeAfterFirstFrame() {
891 + const surfaceSequence = this._surfaceOpenSequence;
892 + if (this._mode !== "canvas" || !this.isCurrentSurfaceOpen(surfaceSequence) || !this.activeBrowserId) {
893 + return;
894 + }
895 + if (this._canvasFirstFrameNudgeSequence === surfaceSequence) {
896 + return;
897 + }
898 + if (
899 + this._canvasSurfaceReadySequence !== surfaceSequence
900 + || this._canvasFirstFrameAcceptedSequence !== surfaceSequence
901 + ) {
902 + return;
903 + }
904 + this._canvasFirstFrameNudgeSequence = surfaceSequence;
905 +
906 + void (async () => {
907 + await nextAnimationFrame();
908 + await nextAnimationFrame();
909 + if (!this.isCurrentSurfaceOpen(surfaceSequence) || this._mode !== "canvas") {
910 + return;
911 + }
912 + this.forceRightCanvasWidthNudge();
913 + })();
914 + },
915 +
916 + forceRightCanvasWidthNudge() {
917 + const canvas = rightCanvasStore;
918 + if (!canvas || canvas.isMobileMode || !canvas.isOpen || canvas.activeSurfaceId !== "browser") {
919 + return;
920 + }
921 +
922 + const currentWidth = Number(canvas.width || 0);
923 + if (!Number.isFinite(currentWidth) || currentWidth <= 0) {
924 + return;
925 + }
926 + const maxWidth = Number(canvas.maxWidth?.() || currentWidth);
927 + const minWidth = Number(canvas.minWidth || 420);
928 + const direction = currentWidth < maxWidth ? 1 : -1;
929 + const nudgedWidth = currentWidth + direction;
930 + if (nudgedWidth < minWidth || nudgedWidth > maxWidth || nudgedWidth === currentWidth) {
931 + return;
932 + }
933 +
934 + canvas.setWidth?.(nudgedWidth, { persist: false });
935 + this.queueViewportSync(true);
936 },
937
938 frameMatchesViewport(dimensions = null, viewport = null) {
@@ -749,7 +945,9 @@ const model = {
945
946 requestViewportSyncAfterRejectedFrame() {
947 const now = Date.now();
752 - if (now - this._lastFrameRejectSyncAt < FRAME_REJECT_SYNC_COOLDOWN_MS) return;
948 + if (now - this._lastFrameRejectSyncAt < FRAME_REJECT_SYNC_COOLDOWN_MS) {
949 + return;
950 + }
951 this._lastFrameRejectSyncAt = now;
952 this.queueViewportSync(true);
953 },
@@ -776,11 +974,22 @@ const model = {
974 this._frameRenderSequence += 1;
975 },
976
977 + beginCommand() {
978 + this._commandInFlightCount += 1;
979 + this.commandInFlight = true;
980 + },
981 +
982 + finishCommand() {
983 + this._commandInFlightCount = Math.max(0, this._commandInFlightCount - 1);
984 + this.commandInFlight = this._commandInFlightCount > 0;
985 + },
986 +
987 async command(command, extra = {}) {
988 this.error = "";
989 this.annotationError = "";
782 - this.commandInFlight = true;
990 + this.beginCommand();
991 const previousActiveBrowserId = this.activeBrowserId;
992 + const commandName = String(command || "").toLowerCase();
993 try {
994 await this.ensureContextId();
995 const response = await websocket.request(
@@ -813,19 +1022,33 @@ const model = {
1022 this.address = result.state?.currentUrl || result.currentUrl;
1023 }
1024 this.applySnapshot(data.snapshot);
816 - if (["navigate", "back", "forward", "reload", "close"].includes(String(command || "").toLowerCase())) {
1025 + if (["navigate", "back", "forward", "reload", "close"].includes(commandName)) {
1026 this.clearAnnotationsForBrowser(previousActiveBrowserId);
1027 this.cancelAnnotationDraft();
1028 }
1029 const activeChanged = this.activeBrowserId && this.activeBrowserId !== previousActiveBrowserId;
821 - if ((command === "open" || command === "close" || activeChanged) && this.contextId && this.activeBrowserId) {
1030 + if ((commandName === "open" || commandName === "close" || activeChanged) && this.contextId && this.activeBrowserId) {
1031 await this.connectViewer({ browserId: this.activeBrowserId });
823 - }
824 - } catch (error) {
1032 + } else if (["navigate", "back", "forward", "reload"].includes(commandName)) {
1033 + await this.restartCanvasStreamAfterPageChange();
1034 + }
1035 + } catch (error) {
1036 this.error = error instanceof Error ? error.message : String(error);
1037 } finally {
827 - this.commandInFlight = false;
1038 + this.finishCommand();
1039 + }
1040 + },
1041 +
1042 + async restartCanvasStreamAfterPageChange() {
1043 + const surfaceSequence = this._surfaceOpenSequence;
1044 + if (this._mode !== "canvas" || !this.isCurrentSurfaceOpen(surfaceSequence) || !this.activeBrowserId) {
1045 + return;
1046 }
1047 + await this.waitForSurfaceViewport({ sequence: surfaceSequence });
1048 + if (this._mode !== "canvas" || !this.isCurrentSurfaceOpen(surfaceSequence) || !this.activeBrowserId) {
1049 + return;
1050 + }
1051 + await this.syncViewport(true, { restartStream: true });
1052 },
1053
1054 async go() {
@@ -886,6 +1109,35 @@ const model = {
1109 await this.command("open");
1110 },
1111
1112 + isClosingBrowser(id) {
1113 + const browserId = this.normalizeBrowserId(id);
1114 + return Boolean(browserId && this._closingBrowserIds[String(browserId)]);
1115 + },
1116 +
1117 + markBrowserClosing(id, closing = true) {
1118 + const browserId = this.normalizeBrowserId(id);
1119 + if (!browserId) return;
1120 + const key = String(browserId);
1121 + const nextClosing = { ...this._closingBrowserIds };
1122 + if (closing) {
1123 + nextClosing[key] = true;
1124 + } else {
1125 + delete nextClosing[key];
1126 + }
1127 + this._closingBrowserIds = nextClosing;
1128 + },
1129 +
1130 + async closeBrowser(id) {
1131 + const browserId = this.normalizeBrowserId(id);
1132 + if (!browserId || this.isClosingBrowser(browserId)) return;
1133 + this.markBrowserClosing(browserId, true);
1134 + try {
1135 + await this.command("close", { browser_id: browserId });
1136 + } finally {
1137 + this.markBrowserClosing(browserId, false);
1138 + }
1139 + },
1140 +
1141 isActiveBrowser(browser) {
1142 return Number(browser?.id) === Number(this.activeBrowserId);
1143 },
@@ -1479,37 +1731,46 @@ const model = {
1731 }, force ? 0 : VIEWPORT_SYNC_DEBOUNCE_MS);
1732 },
1733
1482 - async syncViewport(force = false) {
1483 - if (!this.contextId || !this.activeBrowserId) return;
1484 - const viewport = this.currentViewportSize();
1485 - if (!viewport) return;
1486 - const key = `${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
1487 - if (
1488 - this._lastViewportKey === key
1489 - || (
1490 - !force
1491 - && this._lastViewport
1492 - && this.sameBrowserId(this._lastViewport.browserId, this.activeBrowserId)
1493 - && Math.abs(this._lastViewport.width - viewport.width) <= VIEWPORT_SYNC_SIZE_TOLERANCE
1494 - && Math.abs(this._lastViewport.height - viewport.height) <= VIEWPORT_SYNC_SIZE_TOLERANCE
1495 - )
1496 - ) return;
1497 - try {
1498 - await websocket.emit("browser_viewer_input", {
1499 - context_id: this.contextId,
1500 - browser_id: this.activeBrowserId,
1501 - viewer_id: this._viewerToken,
1502 - input_type: "viewport",
1503 - width: viewport.width,
1504 - height: viewport.height,
1505 - });
1506 - this._lastViewportKey = key;
1507 - this._lastViewport = {
1508 - browserId: this.activeBrowserId,
1509 - width: viewport.width,
1510 - height: viewport.height,
1511 - };
1512 - } catch (error) {
1734 + async syncViewport(force = false, options = {}) {
1735 + const restartStream = Boolean(options.restartStream);
1736 + if (!this.contextId || !this.activeBrowserId) {
1737 + return;
1738 + }
1739 + const viewport = this.currentViewportSize();
1740 + if (!viewport) {
1741 + return;
1742 + }
1743 + const key = `${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
1744 + if (
1745 + (!restartStream && this._lastViewportKey === key)
1746 + || (
1747 + !force
1748 + && !restartStream
1749 + && this._lastViewport
1750 + && this.sameBrowserId(this._lastViewport.browserId, this.activeBrowserId)
1751 + && Math.abs(this._lastViewport.width - viewport.width) <= VIEWPORT_SYNC_SIZE_TOLERANCE
1752 + && Math.abs(this._lastViewport.height - viewport.height) <= VIEWPORT_SYNC_SIZE_TOLERANCE
1753 + )
1754 + ) {
1755 + return;
1756 + }
1757 + try {
1758 + await websocket.emit("browser_viewer_input", {
1759 + context_id: this.contextId,
1760 + browser_id: this.activeBrowserId,
1761 + viewer_id: this._viewerToken,
1762 + input_type: "viewport",
1763 + width: viewport.width,
1764 + height: viewport.height,
1765 + restart_stream: restartStream,
1766 + });
1767 + this._lastViewportKey = key;
1768 + this._lastViewport = {
1769 + browserId: this.activeBrowserId,
1770 + width: viewport.width,
1771 + height: viewport.height,
1772 + };
1773 + } catch (error) {
1774 this._lastViewportKey = "";
1775 this._lastViewport = null;
1776 console.warn("Browser viewport sync failed", error);
@@ -1545,7 +1806,7 @@ const model = {
1806 await websocket.emit("browser_viewer_input", payload);
1807 },
1808
1548 - async sendWheel(event) {
1809 + async sendWheel(event) {
1810 if (!this.activeBrowserId || !event) return;
1811 const image = event.currentTarget?.querySelector?.(".browser-frame") || event.target?.closest?.(".browser-frame");
1812 const pointer = this.pointerCoordinatesFor(event, image);
@@ -1560,12 +1821,12 @@ const model = {
1821 delta_x: Number(event.deltaX || 0),
1822 delta_y: Number(event.deltaY || 0),
1823 };
1563 - try {
1564 - await websocket.emit("browser_viewer_input", payload);
1565 - } catch (error) {
1566 - this.error = error instanceof Error ? error.message : String(error);
1567 - }
1568 - },
1824 + try {
1825 + await websocket.emit("browser_viewer_input", payload);
1826 + } catch (error) {
1827 + this.error = error instanceof Error ? error.message : String(error);
1828 + }
1829 + },
1830
1831 async sendKey(event) {
1832 if (this.annotating) return;
@@ -1585,12 +1846,22 @@ const model = {
1846 },
1847
1848 async cleanup() {
1849 + if (this._surfaceHandoff) {
1850 + this.releaseSurfaceBindings();
1851 + this.extensionMenuOpen = false;
1852 + return;
1853 + }
1854 + this._surfaceOpenSequence += 1;
1855 + this._openPromise = null;
1856 + this._openSignature = "";
1857 this._connectSequence += 1;
1858 this._viewerToken = "";
1859 this.switchingBrowserId = null;
1860 this._surfaceMounted = false;
1861 this._surfaceSwitching = false;
1862 this.commandInFlight = false;
1863 + this._commandInFlightCount = 0;
1864 + this._closingBrowserIds = {};
1865 this.annotating = false;
1866 this.annotationBusy = false;
1867 this.annotationError = "";
@@ -1606,11 +1877,7 @@ const model = {
1877 this._frameOff = null;
1878 this._stateOff = null;
1879 this.resetRenderedFrame();
1609 - this._floatingCleanup?.();
1610 - this._floatingCleanup = null;
1611 - this._stageResizeObserver?.disconnect?.();
1612 - this._stageResizeObserver = null;
1613 - this._stageElement = null;
1880 + this.releaseSurfaceBindings();
1881 if (this._viewportSyncTimer) {
1882 globalThis.clearTimeout(this._viewportSyncTimer);
1883 this._viewportSyncTimer = null;
@@ -1681,11 +1948,17 @@ const model = {
1948 resizeObserver.observe(inner);
1949 if (stage) {
1950 this._stageResizeObserver?.disconnect?.();
1684 - this._stageResizeObserver = new ResizeObserver(() => this.queueViewportSync());
1951 + this._stageResizeObserver = new ResizeObserver(() => {
1952 + this.queueViewportSync();
1953 + });
1954 this._stageResizeObserver.observe(stage);
1955 }
1956 }
1688 - globalThis.requestAnimationFrame(() => this.queueViewportSync(true));
1957 + const surfaceSequence = this._surfaceOpenSequence;
1958 + globalThis.requestAnimationFrame(() => {
1959 + if (!this.isCurrentSurfaceOpen(surfaceSequence)) return;
1960 + this.queueViewportSync(true);
1961 + });
1962
1963 const onPointerMove = (event) => {
1964 if (!drag) return;
@@ -1735,6 +2008,7 @@ const model = {
2008 },
2009
2010 setupCanvasSurface(element = null) {
2011 + const surfaceSequence = this._surfaceOpenSequence;
2012 this._floatingCleanup?.();
2013 this._floatingCleanup = null;
2014 this._stageResizeObserver?.disconnect?.();
@@ -1742,10 +2016,15 @@ const model = {
2016 const stage = root?.querySelector?.(".browser-stage");
2017 this._stageElement = stage || null;
2018 if (stage && globalThis.ResizeObserver) {
1745 - this._stageResizeObserver = new ResizeObserver(() => this.queueViewportSync());
2019 + this._stageResizeObserver = new ResizeObserver(() => {
2020 + this.queueViewportSync();
2021 + });
2022 this._stageResizeObserver.observe(stage);
2023 }
1748 - globalThis.requestAnimationFrame?.(() => this.queueViewportSync(true));
2024 + globalThis.requestAnimationFrame?.(() => {
2025 + if (!this.isCurrentSurfaceOpen(surfaceSequence) || this._mode !== "canvas") return;
2026 + this.queueViewportSync(true);
2027 + });
2028 },
2029
2030 get activeTitle() {
tests/test_browser_agent_regressions.py
+401 -12
@@ -370,7 +370,9 @@ def test_browser_viewer_creates_chat_when_no_context_is_selected():
370 assert "async ensureContextId()" in js
371 assert "async createChatContextForBrowser()" in js
372 assert 'callJsonApi("/chat_create"' in js
373 - assert "globalThis.setContext(contextId)" in js
373 + assert 'import { getContext, setContext } from "/index.js";' in js
374 + assert "setContext(contextId)" in js
375 + assert "chatsStore.setSelected?.(contextId)" in js
376 assert "await this.ensureContextId();" in js
377 assert "No active chat context is selected." not in js
378
@@ -382,11 +384,206 @@ def test_browser_canvas_startup_waits_for_raw_viewport_settle():
384
385 assert "const CANVAS_VIEWPORT_SETTLE_MS = 520;" in js
386 assert "const SURFACE_VIEWPORT_STABLE_FRAMES = 4;" in js
387 + assert "const SURFACE_VIEWPORT_MAX_WAIT_MS = 1200;" in js
388 assert "surfaceViewportMeasurement()" in js
389 assert "rawWidth" in js
390 assert "rawHeight" in js
391 assert "const key = `${viewport.rawWidth}x${viewport.rawHeight}`;" in js
392 assert "Date.now() - this._surfaceOpenedAt >= CANVAS_VIEWPORT_SETTLE_MS" in js
393 + assert "while (Date.now() - startedAt <= SURFACE_VIEWPORT_MAX_WAIT_MS)" in js
394 + assert "this._openPromise && this._openSignature === openSignature" in js
395 + assert "isCurrentSurfaceOpen(surfaceSequence)" in js
396 + assert "isCanvasSurfaceVisible(element)" in js
397 + assert "scheduleViewportSyncForSurface" in js
398 +
399 +
400 +def test_browser_canvas_surface_open_waits_for_visible_panel():
401 + js = (
402 + PROJECT_ROOT
403 + / "plugins"
404 + / "_browser"
405 + / "extensions"
406 + / "webui"
407 + / "right_canvas_register_surfaces"
408 + / "register-browser.js"
409 + ).read_text(encoding="utf-8")
410 +
411 + assert "waitForVisibleCanvasPanel" in js
412 + assert "isVisibleCanvasPanel(panel)" in js
413 + assert 'panel.closest(".browser-canvas-surface")' in js
414 + assert 'panel.querySelector(".browser-stage")' in js
415 + assert "getBoundingClientRect" in js
416 + assert "stableCount >= 2" in js
417 + assert "forceCanvasWidthNudgeAfterBrowserMount" not in js
418 +
419 +
420 +def test_browser_canvas_nudges_width_after_first_accepted_frame():
421 + js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
422 + encoding="utf-8"
423 + )
424 +
425 + assert "_canvasSurfaceReadySequence" in js
426 + assert "_canvasFirstFrameAcceptedSequence" in js
427 + assert "_canvasFirstFrameNudgeSequence" in js
428 + assert "scheduleCanvasWidthNudgeAfterFirstFrame()" in js
429 + assert "this._canvasSurfaceReadySequence = surfaceSequence;" in js
430 + assert "const surfaceSequence = this._surfaceOpenSequence;" in js
431 + assert "this._canvasFirstFrameAcceptedSequence = surfaceSequence;" in js
432 + assert "forceRightCanvasWidthNudge()" in js
433 + assert "await nextAnimationFrame();" in js
434 + assert "globalThis.Alpine" not in js
435 + assert 'import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";' in js
436 + assert "const canvas = rightCanvasStore;" in js
437 + assert 'canvas.activeSurfaceId !== "browser"' in js
438 + assert "canvas.setWidth?.(nudgedWidth, { persist: false })" in js
439 + assert "this.queueViewportSync(true)" in js
440 + frame_assignment_index = js.index("this.frameSrc = frameSrc;")
441 + frame_nudge_schedule_index = js.index(
442 + "this.scheduleCanvasWidthNudgeAfterFirstFrame();",
443 + frame_assignment_index,
444 + )
445 + assert frame_assignment_index < frame_nudge_schedule_index
446 +
447 +
448 +def test_browser_canvas_restarts_stream_after_page_navigation():
449 + js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
450 + encoding="utf-8"
451 + )
452 +
453 + assert "async restartCanvasStreamAfterPageChange()" in js
454 + assert '["navigate", "back", "forward", "reload"].includes(commandName)' in js
455 + assert "await this.restartCanvasStreamAfterPageChange();" in js
456 + assert "await this.waitForSurfaceViewport({ sequence: surfaceSequence });" in js
457 + assert "await this.syncViewport(true, { restartStream: true });" in js
458 + reconnect_index = js.index("await this.connectViewer({ browserId: this.activeBrowserId });")
459 + restart_index = js.index("await this.restartCanvasStreamAfterPageChange();")
460 + assert reconnect_index < restart_index
461 +
462 +
463 +def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff():
464 + button_html = (
465 + PROJECT_ROOT
466 + / "plugins"
467 + / "_browser"
468 + / "extensions"
469 + / "webui"
470 + / "chat-input-bottom-actions-start"
471 + / "browser-button.html"
472 + ).read_text(encoding="utf-8")
473 + tool_handler = (
474 + PROJECT_ROOT
475 + / "plugins"
476 + / "_browser"
477 + / "extensions"
478 + / "webui"
479 + / "get_tool_message_handler"
480 + / "browser-tool-handler.js"
481 + ).read_text(encoding="utf-8")
482 + after_loop_handler = (
483 + PROJECT_ROOT
484 + / "plugins"
485 + / "_browser"
486 + / "extensions"
487 + / "webui"
488 + / "set_messages_after_loop"
489 + / "auto-open-browser-results.js"
490 + ).read_text(encoding="utf-8")
491 + register_js = (
492 + PROJECT_ROOT
493 + / "plugins"
494 + / "_browser"
495 + / "extensions"
496 + / "webui"
497 + / "right_canvas_register_surfaces"
498 + / "register-browser.js"
499 + ).read_text(encoding="utf-8")
500 + browser_store = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
501 + encoding="utf-8"
502 + )
503 + canvas_store = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas-store.js").read_text(
504 + encoding="utf-8"
505 + )
506 + modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8")
507 +
508 + assert "Open Browser" in button_html
509 + assert "$store.rightCanvas ? $store.rightCanvas.open('browser')" in button_html
510 + assert "window.ensureModalOpen ? window.ensureModalOpen('/plugins/_browser/webui/main.html')" in button_html
511 + assert "$store.rightCanvas.toggle('browser')" not in button_html
512 + assert 'defaultOpenMode: "modal"' not in register_js
513 + assert "beginDockHandoff()" in register_js
514 + assert "beginSurfaceHandoff" in register_js
515 + assert "finishDockHandoff()" in register_js
516 + assert "cancelDockHandoff()" in register_js
517 + assert "defaultOpenMode" not in canvas_store
518 + assert "await surface.beginDockHandoff?.(payload)" in canvas_store
519 + assert "await this.closeDockSourceModal(payload, modalPath)" in canvas_store
520 + assert "delete openPayload.closeSourceModal" in canvas_store
521 + assert "await surface.finishDockHandoff?.({ ...openPayload, opened })" in canvas_store
522 + assert "await surface.cancelDockHandoff?.(payload)" in canvas_store
523 + assert "async closeDockSourceModal" in canvas_store
524 +
525 + assert "sourceModalPath: modal.path" in modals_js
526 + assert "closeSourceModal: async () =>" in modals_js
527 + assert "const closed = await closeModal(modal.path)" in modals_js
528 + assert "const fallbackClosed = await closeModal()" in modals_js
529 + assert "button.disabled = true" in modals_js
530 +
531 + assert "beginSurfaceHandoff()" in browser_store
532 + assert "finishSurfaceHandoff()" in browser_store
533 + assert "cancelSurfaceHandoff()" in browser_store
534 + assert "releaseSurfaceBindings()" in browser_store
535 + assert "this.releaseSurfaceBindings();" in browser_store
536 +
537 + for js in (tool_handler, after_loop_handler):
538 + assert "async function openBrowserCanvas" in js
539 + assert "openBrowserModal" not in js
540 + assert js.index('await rightCanvasStore.open("browser", payload);') < js.index("if (window.ensureModalOpen)")
541 +
542 + assert "function autoOpenBrowserCanvas" in tool_handler
543 +
544 + for js in (tool_handler, after_loop_handler, register_js, browser_store, modals_js):
545 + assert "globalThis.Alpine" not in js
546 + assert "Alpine?.store" not in js
547 + assert "Alpine.store" not in js
548 +
549 +
550 +def test_browser_canvas_uses_plain_panel_without_debug_probe():
551 + panel_html = (
552 + PROJECT_ROOT
553 + / "plugins"
554 + / "_browser"
555 + / "extensions"
556 + / "webui"
557 + / "right-canvas-panels"
558 + / "browser-panel.html"
559 + ).read_text(encoding="utf-8")
560 + browser_store = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
561 + encoding="utf-8"
562 + )
563 + register_js = (
564 + PROJECT_ROOT
565 + / "plugins"
566 + / "_browser"
567 + / "extensions"
568 + / "webui"
569 + / "right_canvas_register_surfaces"
570 + / "register-browser.js"
571 + ).read_text(encoding="utf-8")
572 + canvas_store = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas-store.js").read_text(
573 + encoding="utf-8"
574 + )
575 + modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8")
576 +
577 + assert 'x-component path="/plugins/_browser/webui/browser-panel.html" mode="canvas"' in panel_html
578 + assert "browserCanvasRemountKey" not in panel_html
579 + assert "remountBrowserCanvasOnce" not in panel_html
580 + assert "data-remount-slot" not in panel_html
581 +
582 + for js in (browser_store, register_js, canvas_store, modals_js):
583 + assert "__a0BrowserDebug" not in js
584 + assert "browserDebug" not in js
585 + assert "emitBrowserDebug" not in js
586 + assert ".debug(" not in js
587
588
589 def test_browser_ui_spinners_have_browser_local_animation():
@@ -436,6 +633,29 @@ def test_browser_viewer_uses_tabs_for_session_switching():
633 assert "Using ${this.mainModelSummary}" in browser_store
634
635
636 +def test_browser_tabs_close_without_confirmation_or_busy_lock():
637 + main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html").read_text(
638 + encoding="utf-8"
639 + )
640 + browser_store = (
641 + PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
642 + ).read_text(encoding="utf-8")
643 + close_start = main_html.index('class="browser-tab-close"')
644 + close_end = main_html.index("</button>", close_start)
645 + close_markup = main_html[close_start:close_end]
646 +
647 + assert '@click.stop="$store.browserPage.closeBrowser(browser.id)"' in main_html
648 + assert "@pointerdown.stop" in close_markup
649 + assert "$confirmClick" not in main_html
650 + assert "isBusy()" not in close_markup
651 + assert ':disabled="$store.browserPage.isClosingBrowser(browser.id)"' in close_markup
652 + assert "--browser-tab-close-size: 32px;" in main_html
653 + assert "async closeBrowser(id)" in browser_store
654 + assert "isClosingBrowser(id)" in browser_store
655 + assert "_closingBrowserIds" in browser_store
656 + assert "_commandInFlightCount" in browser_store
657 +
658 +
659 def test_browser_viewer_uses_cdp_screencast_transport():
660 ws_browser = (PROJECT_ROOT / "plugins" / "_browser" / "api" / "ws_browser.py").read_text(
661 encoding="utf-8"
@@ -469,15 +689,44 @@ def test_browser_viewer_uses_cdp_screencast_transport():
689 assert "requestAnimationFrame" in browser_store
690 assert "viewport_width: initialViewport?.width" in browser_store
691 assert "viewport_height: initialViewport?.height" in browser_store
692 + assert "restart_stream: restartStream" in browser_store
693 + assert 'restart_screencast=bool(data.get("restart_stream"))' in ws_browser
694 + assert "restart_screencast: bool = False" in runtime
695 + assert "should_remount_viewport = changed or restart_screencast" in runtime
696 + assert "VIEWPORT_REMOUNT_PAUSE_SECONDS = 0.05" in runtime
697 + assert "await self._apply_cdp_viewport_with_remount" in runtime
698 + assert "await self._apply_viewport_with_remount(page, viewport)" in runtime
699 + assert "await self._remount_viewport(page, viewport)" in runtime
700 + assert "await asyncio.sleep(VIEWPORT_REMOUNT_PAUSE_SECONDS)" in runtime
701 + assert "def _nudged_viewport(viewport: dict[str, int])" in runtime
702 + assert 'await this.syncViewport(true, { restartStream: this._mode === "canvas" });' in browser_store
703 assert "this.frameState = data.state || null" not in browser_store
704 assert "function loadFrameDimensions(src)" in browser_store
705 assert "frameMatchesViewport(dimensions = null, viewport = null)" in browser_store
706 assert "requestViewportSyncAfterRejectedFrame()" in browser_store
707 + assert "FRAME_FALLBACK_SCREENSHOT_SECONDS" not in ws_browser
708 + assert '"frame_source": "state"' in ws_browser
709 + assert '"frame_source"] = "screencast"' in ws_browser
710 + assert "fallback_screenshot" not in ws_browser
711 + assert "canvas_wheel_screenshot" not in ws_browser
712 + assert "surface_mode: this._mode" not in browser_store
713 assert "overflow: hidden;" in main_html
714 assert "object-fit: fill;" in main_html
715 assert "image-rendering: auto;" in main_html
716
717
718 +def test_browser_navigation_errors_stay_inside_native_browser_page():
719 + runtime = (
720 + PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"
721 + ).read_text(encoding="utf-8")
722 +
723 + assert "Error as PlaywrightError" in runtime
724 + assert "except PlaywrightError as exc:" in runtime
725 + assert "Browser navigation showed a native error page" in runtime
726 + assert "await self._settle(page)" in runtime
727 + assert "except (PlaywrightError, PlaywrightTimeoutError):" in runtime
728 +
729 +
730 def test_browser_annotate_mode_ui_and_prompt_hooks():
731 panel_html = (
732 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html"
@@ -562,14 +811,57 @@ async def test_browser_screencast_acknowledges_and_drops_stale_frames():
811 assert frame["browser_id"] == 7
812 assert frame["image"] == "second"
813 assert frame["metadata"]["deviceWidth"] == 200
565 - assert ("Emulation.setDeviceMetricsOverride", {
566 - "width": 1118,
567 - "height": 662,
568 - "deviceScaleFactor": 1,
569 - "mobile": False,
570 - "dontSetVisibleSize": True,
571 - }) in session.sent
572 - assert ("Emulation.setVisibleSize", {"width": 1118, "height": 662}) in session.sent
814 + assert frame["metadata"]["expectedWidth"] == 1118
815 + assert frame["metadata"]["expectedHeight"] == 662
816 + metrics_calls = [
817 + params
818 + for method, params in session.sent
819 + if method == "Emulation.setDeviceMetricsOverride"
820 + ]
821 + visible_calls = [
822 + params
823 + for method, params in session.sent
824 + if method == "Emulation.setVisibleSize"
825 + ]
826 + assert metrics_calls[:3] == [
827 + {
828 + "width": 1118,
829 + "height": 662,
830 + "deviceScaleFactor": 1,
831 + "mobile": False,
832 + "dontSetVisibleSize": True,
833 + },
834 + {
835 + "width": 1119,
836 + "height": 662,
837 + "deviceScaleFactor": 1,
838 + "mobile": False,
839 + "dontSetVisibleSize": True,
840 + },
841 + {
842 + "width": 1118,
843 + "height": 662,
844 + "deviceScaleFactor": 1,
845 + "mobile": False,
846 + "dontSetVisibleSize": True,
847 + },
848 + ]
849 + assert visible_calls[:3] == [
850 + {"width": 1118, "height": 662},
851 + {"width": 1119, "height": 662},
852 + {"width": 1118, "height": 662},
853 + ]
854 + start_index = next(
855 + index
856 + for index, (method, _params) in enumerate(session.sent)
857 + if method == "Page.startScreencast"
858 + )
859 + cdp_viewport_indices = [
860 + index
861 + for index, (method, _params) in enumerate(session.sent)
862 + if method.startswith("Emulation.")
863 + ]
864 + assert max(cdp_viewport_indices) < start_index
865 assert ("Page.screencastFrameAck", {"sessionId": 1}) in session.sent
866 assert ("Page.screencastFrameAck", {"sessionId": 2}) in session.sent
867
@@ -580,7 +872,7 @@ async def test_browser_screencast_acknowledges_and_drops_stale_frames():
872
873
874 @pytest.mark.anyio
583 -async def test_browser_screencast_keeps_rejecting_wrong_viewport_frames():
875 +async def test_browser_screencast_passes_wrong_viewport_frames_to_frontend_validator():
876 class FakeSession:
877 def __init__(self):
878 self.handlers = {}
@@ -610,7 +902,14 @@ async def test_browser_screencast_keeps_rejecting_wrong_viewport_frames():
902 )
903 await asyncio.sleep(0)
904
613 - assert await screencast.pop_frame() is None
905 + frame = await screencast.pop_frame()
906 +
907 + assert frame is not None
908 + assert frame["image"] == SMALL_JPEG_10X10
909 + assert frame["metadata"]["jpegWidth"] == 10
910 + assert frame["metadata"]["jpegHeight"] == 10
911 + assert frame["metadata"]["expectedWidth"] == 1118
912 + assert frame["metadata"]["expectedHeight"] == 662
913 assert ("Page.screencastFrameAck", {"sessionId": 13}) in session.sent
914
915 await screencast.stop()
@@ -819,6 +1118,7 @@ async def test_browser_viewer_viewport_input_dispatches_resize(monkeypatch):
1118 "input_type": "viewport",
1119 "width": 1280,
1120 "height": 720,
1121 + "restart_stream": True,
1122 },
1123 "sid-1",
1124 )
@@ -827,7 +1127,96 @@ async def test_browser_viewer_viewport_input_dispatches_resize(monkeypatch):
1127 "state": {"ok": True, "method": "set_viewport", "args": (7, 1280, 720)},
1128 "snapshot": None,
1129 }
830 - assert calls == [("set_viewport", (7, 1280, 720), {})]
1130 + assert calls == [
1131 + ("set_viewport", (7, 1280, 720), {"restart_screencast": True})
1132 + ]
1133 +
1134 +
1135 +@pytest.mark.anyio
1136 +async def test_browser_runtime_remounts_same_viewport_when_restarting_screencast():
1137 + viewport_calls = []
1138 + stopped = []
1139 + settled = []
1140 +
1141 + class FakePage:
1142 + viewport_size = {"width": 1280, "height": 720}
1143 +
1144 + async def set_viewport_size(self, viewport):
1145 + viewport_calls.append(dict(viewport))
1146 +
1147 + core = _BrowserRuntimeCore("ctx")
1148 + core.context = object()
1149 + core.pages[7] = browser_runtime_module.BrowserPage(id=7, page=FakePage())
1150 +
1151 + async def fake_stop_screencasts(browser_id):
1152 + stopped.append(browser_id)
1153 +
1154 + async def fake_settle(page, short=False):
1155 + settled.append(short)
1156 +
1157 + async def fake_state(browser_id):
1158 + return {"id": browser_id}
1159 +
1160 + core._stop_screencasts_for_browser = fake_stop_screencasts
1161 + core._settle = fake_settle
1162 + core._state = fake_state
1163 +
1164 + result = await core.set_viewport(7, 1280, 720, restart_screencast=True)
1165 +
1166 + assert result == {
1167 + "state": {"id": 7},
1168 + "viewport": {"width": 1280, "height": 720},
1169 + }
1170 + assert viewport_calls == [
1171 + {"width": 1281, "height": 720},
1172 + {"width": 1280, "height": 720},
1173 + ]
1174 + assert stopped == [7]
1175 + assert settled == [True]
1176 +
1177 +
1178 +@pytest.mark.anyio
1179 +async def test_browser_runtime_remounts_initial_changed_viewport():
1180 + calls = []
1181 + stopped = []
1182 + settled = []
1183 +
1184 + class FakePage:
1185 + viewport_size = {"width": 1024, "height": 768}
1186 +
1187 + async def set_viewport_size(self, viewport):
1188 + calls.append(("viewport", dict(viewport)))
1189 +
1190 + core = _BrowserRuntimeCore("ctx")
1191 + core.context = object()
1192 + core.pages[7] = browser_runtime_module.BrowserPage(id=7, page=FakePage())
1193 +
1194 + async def fake_stop_screencasts(browser_id):
1195 + stopped.append(browser_id)
1196 +
1197 + async def fake_settle(page, short=False):
1198 + settled.append(short)
1199 +
1200 + async def fake_state(browser_id):
1201 + return {"id": browser_id}
1202 +
1203 + core._stop_screencasts_for_browser = fake_stop_screencasts
1204 + core._settle = fake_settle
1205 + core._state = fake_state
1206 +
1207 + result = await core.set_viewport(7, 672, 789)
1208 +
1209 + assert result == {
1210 + "state": {"id": 7},
1211 + "viewport": {"width": 672, "height": 789},
1212 + }
1213 + assert calls == [
1214 + ("viewport", {"width": 672, "height": 789}),
1215 + ("viewport", {"width": 673, "height": 789}),
1216 + ("viewport", {"width": 672, "height": 789}),
1217 + ]
1218 + assert stopped == [7]
1219 + assert settled == [True]
1220
1221
1222 @pytest.mark.anyio
webui/components/canvas/right-canvas-store.js
+43 -5
@@ -98,7 +98,9 @@ const model = {
98 async open(surfaceId = "", payload = {}) {
99 const targetId = surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "";
100 const surface = this.getSurface(targetId);
101 - if (!surface) return false;
101 + if (!surface) {
102 + return false;
103 + }
104 if (typeof surface.canOpen === "function" && surface.canOpen(payload) === false) {
105 return false;
106 }
@@ -140,12 +142,48 @@ const model = {
142
143 async dockSurface(surfaceId, payload = {}) {
144 const surface = this.getSurface(surfaceId);
143 - if (!surface) return false;
145 + if (!surface) {
146 + return false;
147 + }
148 const modalPath = payload.modalPath || surface.modalPath || "";
145 - if (modalPath && globalThis.isModalOpen?.(modalPath)) {
146 - await globalThis.closeModal?.(modalPath);
149 + let handoffStarted = false;
150 + try {
151 + await surface.beginDockHandoff?.(payload);
152 + handoffStarted = true;
153 +
154 + const closed = await this.closeDockSourceModal(payload, modalPath);
155 + if (closed === false) {
156 + await surface.cancelDockHandoff?.(payload);
157 + return false;
158 + }
159 +
160 + const openPayload = { ...payload, source: "modal" };
161 + delete openPayload.closeSourceModal;
162 + const opened = await this.open(surfaceId, openPayload);
163 + await surface.finishDockHandoff?.({ ...openPayload, opened });
164 + return opened;
165 + } catch (error) {
166 + if (handoffStarted) {
167 + await surface.cancelDockHandoff?.(payload);
168 + }
169 + console.error(`Canvas surface ${surfaceId} failed to dock`, error);
170 + return false;
171 + }
172 + },
173 +
174 + async closeDockSourceModal(payload = {}, modalPath = "") {
175 + if (typeof payload.closeSourceModal === "function") {
176 + return (await payload.closeSourceModal()) !== false;
177 }
148 - return await this.open(surfaceId, { ...payload, source: "modal" });
178 +
179 + const sourceModalPath = payload.sourceModalPath || modalPath;
180 + if (sourceModalPath && globalThis.isModalOpen?.(sourceModalPath)) {
181 + return (await globalThis.closeModal?.(sourceModalPath)) !== false;
182 + }
183 + if (modalPath && modalPath !== sourceModalPath && globalThis.isModalOpen?.(modalPath)) {
184 + return (await globalThis.closeModal?.(modalPath)) !== false;
185 + }
186 + return true;
187 },
188
189 async undockSurface(surfaceId = "", payload = {}) {
webui/components/canvas/right-canvas.css
+1 -5
@@ -181,11 +181,9 @@ body.right-canvas-resizing {
181 align-items: center;
182 gap: 7px;
183 min-width: 0;
184 - height: 34px;
184 padding: 0 10px;
186 - border-radius: 7px 7px 0 0;
185 background: transparent;
188 - opacity: 0.7;
186 + opacity: 0.4;
187 white-space: nowrap;
188 transition: border-color 0.16s ease, background-color 0.16s ease, opacity 0.16s ease;
189 }
@@ -193,8 +191,6 @@ body.right-canvas-resizing {
191 .right-canvas-tab:hover,
192 .right-canvas-tab.is-active {
193 opacity: 1;
196 - border-color: var(--right-canvas-border);
197 - background: color-mix(in srgb, var(--color-panel) 72%, transparent);
194 }
195
196 .right-canvas-tab .material-symbols-outlined {
webui/js/modals.js
+21 -6
@@ -1,6 +1,7 @@
1 // Import the component loader and page utilities
2 import { importComponent } from "/js/components.js";
3 import { callJsExtensions } from "/js/extensions.js";
4 +import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
5
6 // Modal functionality
7 const modalStack = [];
@@ -193,12 +194,26 @@ function configureModalDockButton(modal, doc) {
194 button.setAttribute("aria-label", metadata.title);
195 button.innerHTML = `<span class="material-symbols-outlined" aria-hidden="true">${metadata.icon}</span>`;
196 button.addEventListener("click", async () => {
196 - const canvas = globalThis.Alpine?.store?.("rightCanvas")
197 - || (await import("/components/canvas/right-canvas-store.js")).store;
198 - await canvas?.dockSurface?.(metadata.surfaceId, {
199 - modalPath: metadata.modalPath,
200 - source: "modal",
201 - });
197 + if (button.disabled) return;
198 + button.disabled = true;
199 + try {
200 + await rightCanvasStore.dockSurface?.(metadata.surfaceId, {
201 + modalPath: metadata.modalPath,
202 + sourceModalPath: modal.path,
203 + source: "modal",
204 + closeSourceModal: async () => {
205 + const closed = await closeModal(modal.path);
206 + if (closed === false) return false;
207 + if (document.contains(modal.element)) {
208 + const fallbackClosed = await closeModal();
209 + if (fallbackClosed === false) return false;
210 + }
211 + return !document.contains(modal.element);
212 + },
213 + });
214 + } finally {
215 + if (document.contains(button)) button.disabled = false;
216 + }
217 });
218
219 modal.close?.insertAdjacentElement("beforebegin", button);