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);