Redesign Browser viewer screencast transport and viewport fit

Replace the Browser viewer’s screenshot polling with CDP screencast streaming for much smoother navigation. The runtime now starts/stops CDP screencasts cleanly, acknowledges frames, drops stale frames, and keeps the WebSocket payload compatible with the existing viewer. Also fixes modal viewport sizing by sending the initial stage dimensions on subscribe, applying CDP emulation sizing before the first frame, avoiding image stretching, and increasing screencast JPEG quality to 92. Regression coverage was added for the screencast path, frame ack/drop behavior, viewport sizing, and UI rendering assumptions. -- Still needs thorough performance audit and optimization --

Alessandro committed Apr 26, 2026 at 02:28 UTC dccf017d2cf74bbec67151d03ba3e4282ea08c6b
5 files changed +637 -93
plugins/_browser/api/ws_browser.py
+151 -36
@@ -1,6 +1,8 @@
1 from __future__ import annotations
2
3 import asyncio
4 +import contextlib
5 +import time
6 from typing import Any, ClassVar
7
8 from agent import AgentContext
@@ -9,6 +11,12 @@ from helpers.ws_manager import WsResult
11 from plugins._browser.helpers.runtime import get_runtime
12
13
14 +FRAME_IDLE_TIMEOUT_SECONDS = 0.35
15 +FRAME_RETRY_DELAY_SECONDS = 0.5
16 +FRAME_STATE_REFRESH_SECONDS = 0.75
17 +SCREENCAST_QUALITY = 92
18 +
19 +
20 class WsBrowser(WsHandler):
21 _streams: ClassVar[dict[tuple[str, str], asyncio.Task[None]]] = {}
22
@@ -57,9 +65,17 @@ class WsBrowser(WsHandler):
65 browsers = listing.get("browsers") or []
66 if opened.get("id"):
67 listing["last_interacted_browser_id"] = opened.get("id")
60 - active_id = data.get("browser_id") or listing.get("last_interacted_browser_id")
61 - if not active_id and browsers:
62 - active_id = browsers[0].get("id")
68 + active_id = self._active_browser_id(listing, data.get("browser_id"))
69 + initial_viewport = self._viewport_from_data(data)
70 + if active_id and initial_viewport:
71 + await runtime.call(
72 + "set_viewport",
73 + active_id,
74 + initial_viewport["width"],
75 + initial_viewport["height"],
76 + )
77 + listing = await runtime.call("list")
78 + browsers = listing.get("browsers") or []
79
80 stream_key = (sid, context_id)
81 existing = self._streams.pop(stream_key, None)
@@ -187,46 +203,145 @@ class WsBrowser(WsHandler):
203 context_id: str,
204 browser_id: int | str | None,
205 ) -> None:
206 + runtime = None
207 + stream_id = None
208 while True:
209 try:
210 runtime = await get_runtime(context_id, create=False)
193 - if runtime:
194 - listing = await runtime.call("list")
195 - browsers = listing.get("browsers") or []
196 - browser_ids = {str(browser.get("id")) for browser in browsers}
197 - requested_id = str(browser_id or "") if browser_id else ""
198 - active_id = (
199 - browser_id
200 - if requested_id and requested_id in browser_ids
201 - else listing.get("last_interacted_browser_id")
202 - )
203 - if active_id and str(active_id) not in browser_ids:
204 - active_id = None
205 - if not active_id and browsers:
206 - active_id = browsers[0].get("id")
207 - if active_id:
208 - frame = await runtime.call("screenshot", active_id)
209 - frame["context_id"] = context_id
210 - frame["browsers"] = browsers
211 - await self.emit_to(sid, "browser_viewer_frame", frame)
212 - else:
213 - await self.emit_to(
214 - sid,
215 - "browser_viewer_frame",
216 - {
217 - "context_id": context_id,
218 - "browser_id": None,
219 - "browsers": browsers,
220 - "image": "",
221 - "mime": "",
222 - "state": None,
223 - },
211 + if not runtime:
212 + await self._emit_empty_frame(sid, context_id)
213 + await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
214 + continue
215 +
216 + listing = await runtime.call("list")
217 + browsers = listing.get("browsers") or []
218 + active_id = self._active_browser_id(listing, browser_id)
219 + if not active_id:
220 + await self._emit_empty_frame(sid, context_id, browsers=browsers)
221 + await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
222 + continue
223 +
224 + screencast = await runtime.call(
225 + "start_screencast",
226 + active_id,
227 + quality=SCREENCAST_QUALITY,
228 + every_nth_frame=1,
229 + )
230 + stream_id = screencast["stream_id"]
231 + active_id = screencast["browser_id"]
232 + state = screencast.get("state")
233 + await self.emit_to(
234 + sid,
235 + "browser_viewer_frame",
236 + {
237 + "context_id": context_id,
238 + "browser_id": active_id,
239 + "browsers": browsers,
240 + "image": "",
241 + "mime": "",
242 + "state": state,
243 + },
244 + )
245 +
246 + last_state_refresh = 0.0
247 + while True:
248 + now = time.monotonic()
249 + if now - last_state_refresh >= FRAME_STATE_REFRESH_SECONDS:
250 + listing = await runtime.call("list")
251 + browsers = listing.get("browsers") or []
252 + browser_ids = {str(browser.get("id")) for browser in browsers}
253 + if str(active_id) not in browser_ids:
254 + break
255 + state = self._state_for_browser(browsers, active_id, state)
256 + last_state_refresh = now
257 +
258 + try:
259 + frame = await runtime.call(
260 + "read_screencast_frame",
261 + stream_id,
262 + timeout=FRAME_IDLE_TIMEOUT_SECONDS,
263 )
225 - await asyncio.sleep(0.75)
264 + except TimeoutError:
265 + continue
266 +
267 + frame["context_id"] = context_id
268 + frame["browser_id"] = active_id
269 + frame["browsers"] = browsers
270 + frame["state"] = state
271 + await self.emit_to(sid, "browser_viewer_frame", frame)
272 except asyncio.CancelledError:
273 raise
274 except Exception:
229 - await asyncio.sleep(1.5)
275 + await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
276 + finally:
277 + if runtime and stream_id:
278 + with contextlib.suppress(Exception):
279 + await runtime.call("stop_screencast", stream_id)
280 + stream_id = None
281 +
282 + @staticmethod
283 + def _active_browser_id(
284 + listing: dict[str, Any],
285 + requested_browser_id: int | str | None,
286 + ) -> int | str | None:
287 + browsers = listing.get("browsers") or []
288 + browser_ids = {str(browser.get("id")) for browser in browsers}
289 + requested_id = str(requested_browser_id or "") if requested_browser_id else ""
290 + active_id = (
291 + requested_browser_id
292 + if requested_id and requested_id in browser_ids
293 + else listing.get("last_interacted_browser_id")
294 + )
295 + if active_id and str(active_id) not in browser_ids:
296 + active_id = None
297 + if not active_id and browsers:
298 + active_id = browsers[0].get("id")
299 + return active_id
300 +
301 + @staticmethod
302 + def _state_for_browser(
303 + browsers: list[dict[str, Any]],
304 + browser_id: int | str,
305 + current_state: dict[str, Any] | None,
306 + ) -> dict[str, Any] | None:
307 + for browser in browsers:
308 + if str(browser.get("id")) == str(browser_id):
309 + return browser
310 + return current_state
311 +
312 + async def _emit_empty_frame(
313 + self,
314 + sid: str,
315 + context_id: str,
316 + *,
317 + browsers: list[dict[str, Any]] | None = None,
318 + ) -> None:
319 + await self.emit_to(
320 + sid,
321 + "browser_viewer_frame",
322 + {
323 + "context_id": context_id,
324 + "browser_id": None,
325 + "browsers": browsers or [],
326 + "image": "",
327 + "mime": "",
328 + "state": None,
329 + },
330 + )
331 +
332 + @staticmethod
333 + def _viewport_from_data(data: dict[str, Any]) -> dict[str, int] | None:
334 + try:
335 + width = int(data.get("viewport_width") or data.get("width") or 0)
336 + height = int(data.get("viewport_height") or data.get("height") or 0)
337 + except (TypeError, ValueError):
338 + return None
339 + if width < 80 or height < 80:
340 + return None
341 + return {
342 + "width": max(320, min(4096, width)),
343 + "height": max(200, min(4096, height)),
344 + }
345
346 @staticmethod
347 def _context_id(data: dict[str, Any]) -> str:
plugins/_browser/helpers/runtime.py
+270 -1
@@ -3,12 +3,14 @@ from __future__ import annotations
3 import atexit
4 import asyncio
5 import base64
6 +import contextlib
7 import os
8 import re
9 import shutil
10 import signal
11 import threading
12 import time
13 +import uuid
14 from dataclasses import dataclass
15 from pathlib import Path
16 from typing import Any
@@ -27,6 +29,8 @@ CONTENT_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-page-content.js"
29 RUNTIME_DATA_KEY = "_browser_runtime"
30 DEFAULT_VIEWPORT = {"width": 1024, "height": 768}
31 CHROME_SINGLETON_FILES = ("SingletonLock", "SingletonCookie", "SingletonSocket")
32 +SCREENCAST_MAX_WIDTH = 4096
33 +SCREENCAST_MAX_HEIGHT = 4096
34
35 _SPECIAL_SCHEME_RE = re.compile(r"^(?:about|blob|data|file|mailto|tel):", re.I)
36 _URL_SCHEME_RE = re.compile(r"^[a-z][a-z\d+\-.]*://", re.I)
@@ -84,6 +88,195 @@ class BrowserPage:
88 page: Any
89
90
91 +class _BrowserScreencast:
92 + def __init__(
93 + self,
94 + *,
95 + stream_id: str,
96 + browser_id: int,
97 + session: Any,
98 + mime: str,
99 + ):
100 + self.id = stream_id
101 + self.browser_id = browser_id
102 + self.session = session
103 + self.mime = mime
104 + self.queue = asyncio.Queue(maxsize=1)
105 + self.stopped = False
106 + self._ack_tasks: set[asyncio.Task] = set()
107 + self._expected_width = 0
108 + self._expected_height = 0
109 + self._dimension_mismatches = 0
110 +
111 + async def start(
112 + self,
113 + *,
114 + quality: int,
115 + every_nth_frame: int,
116 + viewport: dict[str, int],
117 + ) -> None:
118 + self.session.on("Page.screencastFrame", self._on_frame)
119 + width = max(320, min(4096, int(viewport.get("width") or DEFAULT_VIEWPORT["width"])))
120 + height = max(200, min(4096, int(viewport.get("height") or DEFAULT_VIEWPORT["height"])))
121 + self._expected_width = width
122 + self._expected_height = height
123 + self._dimension_mismatches = 0
124 + with contextlib.suppress(Exception):
125 + await self.session.send("Page.enable")
126 + await self.session.send(
127 + "Emulation.setDeviceMetricsOverride",
128 + {
129 + "width": width,
130 + "height": height,
131 + "deviceScaleFactor": 1,
132 + "mobile": False,
133 + "dontSetVisibleSize": True,
134 + },
135 + )
136 + with contextlib.suppress(Exception):
137 + await self.session.send(
138 + "Emulation.setVisibleSize",
139 + {
140 + "width": width,
141 + "height": height,
142 + },
143 + )
144 + await self.session.send(
145 + "Page.startScreencast",
146 + {
147 + "format": "jpeg",
148 + "quality": max(20, min(95, int(quality))),
149 + "maxWidth": SCREENCAST_MAX_WIDTH,
150 + "maxHeight": SCREENCAST_MAX_HEIGHT,
151 + "everyNthFrame": max(1, int(every_nth_frame)),
152 + },
153 + )
154 +
155 + async def next_frame(self, timeout: float = 1.0) -> dict[str, Any]:
156 + frame = await asyncio.wait_for(self.queue.get(), timeout=max(0.1, float(timeout)))
157 + if frame is None:
158 + raise RuntimeError("Browser screencast stopped.")
159 + return frame
160 +
161 + async def stop(self) -> None:
162 + if self.stopped:
163 + return
164 + self.stopped = True
165 + self._drop_queued_frames()
166 + with contextlib.suppress(asyncio.QueueFull):
167 + self.queue.put_nowait(None)
168 + with contextlib.suppress(Exception):
169 + await self.session.send("Page.stopScreencast")
170 + for task in list(self._ack_tasks):
171 + task.cancel()
172 + if self._ack_tasks:
173 + await asyncio.gather(*self._ack_tasks, return_exceptions=True)
174 + self._ack_tasks.clear()
175 + with contextlib.suppress(Exception):
176 + await self.session.detach()
177 +
178 + def _on_frame(self, params: dict[str, Any]) -> None:
179 + if self.stopped:
180 + return
181 + task = asyncio.create_task(self._handle_frame(params or {}))
182 + self._ack_tasks.add(task)
183 + task.add_done_callback(self._ack_tasks.discard)
184 +
185 + async def _handle_frame(self, params: dict[str, Any]) -> None:
186 + try:
187 + data = params.get("data") or ""
188 + if data and self._frame_matches_viewport(data):
189 + self._queue_latest(
190 + {
191 + "browser_id": self.browser_id,
192 + "mime": self.mime,
193 + "image": data,
194 + "metadata": params.get("metadata") or {},
195 + }
196 + )
197 + finally:
198 + session_id = params.get("sessionId")
199 + if session_id is not None and not self.stopped:
200 + with contextlib.suppress(Exception):
201 + await self.session.send(
202 + "Page.screencastFrameAck",
203 + {"sessionId": int(session_id)},
204 + )
205 +
206 + def _queue_latest(self, frame: dict[str, Any]) -> None:
207 + self._drop_queued_frames()
208 + with contextlib.suppress(asyncio.QueueFull):
209 + self.queue.put_nowait(frame)
210 +
211 + def _frame_matches_viewport(self, data: str) -> bool:
212 + if not self._expected_width or not self._expected_height:
213 + return True
214 + size = self._jpeg_size(data)
215 + if not size:
216 + return True
217 + width, height = size
218 + if abs(width - self._expected_width) <= 2 and abs(height - self._expected_height) <= 2:
219 + return True
220 + self._dimension_mismatches += 1
221 + return self._dimension_mismatches > 10
222 +
223 + @staticmethod
224 + def _jpeg_size(data: str) -> tuple[int, int] | None:
225 + try:
226 + raw = base64.b64decode(data, validate=False)
227 + except Exception:
228 + return None
229 + if len(raw) < 10 or raw[:2] != b"\xff\xd8":
230 + return None
231 + index = 2
232 + standalone_markers = {0x01, *range(0xD0, 0xD8)}
233 + size_markers = {
234 + 0xC0,
235 + 0xC1,
236 + 0xC2,
237 + 0xC3,
238 + 0xC5,
239 + 0xC6,
240 + 0xC7,
241 + 0xC9,
242 + 0xCA,
243 + 0xCB,
244 + 0xCD,
245 + 0xCE,
246 + 0xCF,
247 + }
248 + while index < len(raw) - 9:
249 + if raw[index] != 0xFF:
250 + index += 1
251 + continue
252 + while index < len(raw) and raw[index] == 0xFF:
253 + index += 1
254 + if index >= len(raw):
255 + return None
256 + marker = raw[index]
257 + index += 1
258 + if marker in standalone_markers:
259 + continue
260 + if index + 2 > len(raw):
261 + return None
262 + segment_length = int.from_bytes(raw[index : index + 2], "big")
263 + if segment_length < 2 or index + segment_length > len(raw):
264 + return None
265 + if marker in size_markers and segment_length >= 7:
266 + height = int.from_bytes(raw[index + 3 : index + 5], "big")
267 + width = int.from_bytes(raw[index + 5 : index + 7], "big")
268 + return width, height
269 + index += segment_length
270 + return None
271 +
272 + def _drop_queued_frames(self) -> None:
273 + while True:
274 + try:
275 + self.queue.get_nowait()
276 + except asyncio.QueueEmpty:
277 + return
278 +
279 +
280 class BrowserRuntime:
281 def __init__(self, context_id: str):
282 self.context_id = str(context_id)
@@ -118,6 +311,7 @@ class _BrowserRuntimeCore:
311 self.playwright = None
312 self.context = None
313 self.pages: dict[int, BrowserPage] = {}
314 + self.screencasts: dict[str, _BrowserScreencast] = {}
315 self.next_browser_id = 1
316 self.last_interacted_browser_id: int | None = None
317 self._content_helper_source: str | None = None
@@ -378,6 +572,7 @@ class _BrowserRuntimeCore:
572 async def close_browser(self, browser_id: int | str | None = None) -> dict[str, Any]:
573 await self.ensure_started()
574 resolved_id = self._resolve_browser_id(browser_id)
575 + await self._stop_screencasts_for_browser(resolved_id)
576 page = self._page(resolved_id)
577 await page.close()
578 self.pages.pop(resolved_id, None)
@@ -387,6 +582,7 @@ class _BrowserRuntimeCore:
582
583 async def close_all_browsers(self) -> dict[str, Any]:
584 await self.ensure_started()
585 + await self._stop_all_screencasts()
586 for browser_id in list(self.pages):
587 try:
588 await self.pages[browser_id].page.close()
@@ -413,6 +609,58 @@ class _BrowserRuntimeCore:
609 "state": await self._state(resolved_id),
610 }
611
612 + async def start_screencast(
613 + self,
614 + browser_id: int | str | None = None,
615 + *,
616 + quality: int = 78,
617 + every_nth_frame: int = 1,
618 + ) -> dict[str, Any]:
619 + await self.ensure_started()
620 + resolved_id = self._resolve_browser_id(browser_id)
621 + page = self._page(resolved_id)
622 + stream_id = uuid.uuid4().hex
623 + session = await self.context.new_cdp_session(page)
624 + screencast = _BrowserScreencast(
625 + stream_id=stream_id,
626 + browser_id=resolved_id,
627 + session=session,
628 + mime="image/jpeg",
629 + )
630 + self.screencasts[stream_id] = screencast
631 + try:
632 + await screencast.start(
633 + quality=quality,
634 + every_nth_frame=every_nth_frame,
635 + viewport=page.viewport_size or DEFAULT_VIEWPORT,
636 + )
637 + except Exception:
638 + self.screencasts.pop(stream_id, None)
639 + await screencast.stop()
640 + raise
641 + self.last_interacted_browser_id = resolved_id
642 + return {
643 + "stream_id": stream_id,
644 + "browser_id": resolved_id,
645 + "state": await self._state(resolved_id),
646 + }
647 +
648 + async def read_screencast_frame(
649 + self,
650 + stream_id: str,
651 + *,
652 + timeout: float = 1.0,
653 + ) -> dict[str, Any]:
654 + screencast = self.screencasts.get(str(stream_id or ""))
655 + if not screencast:
656 + raise KeyError("Browser screencast is not active.")
657 + return await screencast.next_frame(timeout=timeout)
658 +
659 + async def stop_screencast(self, stream_id: str) -> None:
660 + screencast = self.screencasts.pop(str(stream_id or ""), None)
661 + if screencast:
662 + await screencast.stop()
663 +
664 async def set_viewport(
665 self,
666 browser_id: int | str | None,
@@ -426,7 +674,14 @@ class _BrowserRuntimeCore:
674 "width": max(320, min(4096, int(width or DEFAULT_VIEWPORT["width"]))),
675 "height": max(200, min(4096, int(height or DEFAULT_VIEWPORT["height"]))),
676 }
429 - await page.set_viewport_size(viewport)
677 + current_viewport = page.viewport_size or {}
678 + changed = (
679 + int(current_viewport.get("width") or 0) != viewport["width"]
680 + or int(current_viewport.get("height") or 0) != viewport["height"]
681 + )
682 + if changed:
683 + await page.set_viewport_size(viewport)
684 + await self._stop_screencasts_for_browser(resolved_id)
685 self.last_interacted_browser_id = resolved_id
686 return {"state": await self._state(resolved_id), "viewport": viewport}
687
@@ -490,6 +745,7 @@ class _BrowserRuntimeCore:
745 return await self._state(resolved_id)
746
747 async def close(self, delete_profile: bool = False) -> None:
748 + await self._stop_all_screencasts()
749 for browser_id in list(self.pages):
750 try:
751 await self.pages[browser_id].page.close()
@@ -618,6 +874,19 @@ class _BrowserRuntimeCore:
874 def _page(self, browser_id: int) -> Any:
875 return self.pages[int(browser_id)].page
876
877 + async def _stop_screencasts_for_browser(self, browser_id: int) -> None:
878 + stream_ids = [
879 + stream_id
880 + for stream_id, screencast in self.screencasts.items()
881 + if screencast.browser_id == int(browser_id)
882 + ]
883 + for stream_id in stream_ids:
884 + await self.stop_screencast(stream_id)
885 +
886 + async def _stop_all_screencasts(self) -> None:
887 + for stream_id in list(self.screencasts):
888 + await self.stop_screencast(stream_id)
889 +
890 async def _ensure_content_helper(self, page: Any) -> None:
891 has_helper = await page.evaluate(
892 "() => Boolean(globalThis.__spaceBrowserPageContent__?.capture)"
plugins/_browser/webui/browser-store.js
+80 -37
@@ -40,6 +40,9 @@ const model = {
40 _frameOff: null,
41 _stateOff: null,
42 _lastFrameAt: 0,
43 + _pendingFrameSrc: "",
44 + _frameRenderHandle: null,
45 + _frameRenderCancel: null,
46 _floatingCleanup: null,
47 _stageElement: null,
48 _stageResizeObserver: null,
@@ -298,21 +301,24 @@ const model = {
301 }
302 },
303
301 - async connectViewer() {
302 - if (!this.contextId) {
303 - this.connected = false;
304 - this.error = "No active chat context is selected.";
305 - return;
306 - }
307 - this.error = "";
308 - await this._bindSocketEvents();
309 - const response = await websocket.request(
310 - "browser_viewer_subscribe",
311 - {
312 - context_id: this.contextId,
313 - browser_id: this.activeBrowserId,
314 - },
315 - {
304 + async connectViewer() {
305 + if (!this.contextId) {
306 + this.connected = false;
307 + this.error = "No active chat context is selected.";
308 + return;
309 + }
310 + this.error = "";
311 + await this._bindSocketEvents();
312 + const initialViewport = this.currentViewportSize();
313 + const response = await websocket.request(
314 + "browser_viewer_subscribe",
315 + {
316 + context_id: this.contextId,
317 + browser_id: this.activeBrowserId,
318 + viewport_width: initialViewport?.width,
319 + viewport_height: initialViewport?.height,
320 + },
321 + {
322 timeoutMs: this.browserInstallExpected
323 ? BROWSER_FIRST_INSTALL_TIMEOUT_MS
324 : BROWSER_SUBSCRIBE_TIMEOUT_MS,
@@ -329,18 +335,25 @@ const model = {
335 async _bindSocketEvents() {
336 if (!this._frameOff) {
337 const frameHandler = ({ data }) => {
332 - if (data?.context_id !== this.contextId) return;
333 - this.browsers = data.browsers || this.browsers;
334 - this.setActiveBrowserId(data.browser_id || data.state?.id || this.activeBrowserId);
335 - this.frameState = data.state || null;
336 - if (!this.addressFocused && data.state?.currentUrl) {
337 - this.address = data.state.currentUrl;
338 - }
339 - this.frameSrc = data.image ? `data:${data.mime || "image/jpeg"};base64,${data.image}` : "";
340 - if (!data.image && !data.state) {
341 - this.setActiveBrowserId(null);
342 - this.frameState = null;
343 - this.frameSrc = "";
338 + if (data?.context_id !== this.contextId) return;
339 + this.browsers = data.browsers || this.browsers;
340 + this.setActiveBrowserId(data.browser_id || data.state?.id || this.activeBrowserId);
341 + if (data.state) {
342 + this.frameState = data.state;
343 + }
344 + if (!this.addressFocused && data.state?.currentUrl) {
345 + this.address = data.state.currentUrl;
346 + }
347 + if (data.image) {
348 + this.queueFrameRender(`data:${data.mime || "image/jpeg"};base64,${data.image}`);
349 + } else {
350 + this.cancelFrameRender();
351 + this.frameSrc = "";
352 + }
353 + if (!data.image && !data.state) {
354 + this.setActiveBrowserId(null);
355 + this.frameState = null;
356 + this.frameSrc = "";
357 }
358 this._lastFrameAt = Date.now();
359 };
@@ -356,12 +369,41 @@ const model = {
369 };
370 await websocket.on("browser_viewer_state", stateHandler);
371 this._stateOff = () => websocket.off("browser_viewer_state", stateHandler);
359 - }
360 - },
361 -
362 - async command(command, extra = {}) {
363 - this.error = "";
364 - const previousActiveBrowserId = this.activeBrowserId;
372 + }
373 + },
374 +
375 + queueFrameRender(frameSrc) {
376 + this._pendingFrameSrc = frameSrc;
377 + if (this._frameRenderHandle) return;
378 + const schedule = globalThis.requestAnimationFrame?.bind(globalThis);
379 + if (schedule) {
380 + this._frameRenderCancel = globalThis.cancelAnimationFrame?.bind(globalThis) || null;
381 + this._frameRenderHandle = schedule(() => this.flushFrameRender());
382 + return;
383 + }
384 + this._frameRenderCancel = globalThis.clearTimeout?.bind(globalThis) || null;
385 + this._frameRenderHandle = globalThis.setTimeout(() => this.flushFrameRender(), 16);
386 + },
387 +
388 + flushFrameRender() {
389 + this._frameRenderHandle = null;
390 + this._frameRenderCancel = null;
391 + this.frameSrc = this._pendingFrameSrc || "";
392 + this._pendingFrameSrc = "";
393 + },
394 +
395 + cancelFrameRender() {
396 + if (this._frameRenderHandle && this._frameRenderCancel) {
397 + this._frameRenderCancel(this._frameRenderHandle);
398 + }
399 + this._frameRenderHandle = null;
400 + this._frameRenderCancel = null;
401 + this._pendingFrameSrc = "";
402 + },
403 +
404 + async command(command, extra = {}) {
405 + this.error = "";
406 + const previousActiveBrowserId = this.activeBrowserId;
407 try {
408 const response = await websocket.request(
409 "browser_viewer_command",
@@ -577,10 +619,11 @@ const model = {
619 } catch {}
620 }
621 this._frameOff?.();
580 - this._stateOff?.();
581 - this._frameOff = null;
582 - this._stateOff = null;
583 - this._floatingCleanup?.();
622 + this._stateOff?.();
623 + this._frameOff = null;
624 + this._stateOff = null;
625 + this.cancelFrameRender();
626 + this._floatingCleanup?.();
627 this._floatingCleanup = null;
628 this._stageResizeObserver?.disconnect?.();
629 this._stageResizeObserver = null;
plugins/_browser/webui/main.html
+20 -18
@@ -782,24 +782,26 @@
782 color: #9f1239;
783 }
784
785 - .browser-stage {
786 - flex: 1 1 auto;
787 - display: flex;
788 - flex-direction: column;
789 - min-height: 0;
790 - overflow: auto;
791 - background: #fff;
792 - outline: none;
793 - }
785 + .browser-stage {
786 + flex: 1 1 auto;
787 + display: flex;
788 + flex-direction: column;
789 + min-height: 0;
790 + overflow: hidden;
791 + background: #fff;
792 + outline: none;
793 + }
794
795 - .browser-frame {
796 - flex: 0 0 auto;
797 - display: block;
798 - width: 100%;
799 - height: auto;
800 - user-select: none;
801 - background: #fff;
802 - }
795 + .browser-frame {
796 + flex: 0 0 auto;
797 + display: block;
798 + width: 100%;
799 + height: auto;
800 + min-width: 0;
801 + image-rendering: auto;
802 + user-select: none;
803 + background: #fff;
804 + }
805
806 .browser-status,
807 .browser-error,
@@ -884,4 +886,4 @@
886 </style>
887 </body>
888
887 -</html>
\ No newline at end of file
889 +</html>
tests/test_browser_agent_regressions.py
+116 -1
@@ -1,3 +1,4 @@
1 +import asyncio
2 import sys
3 import threading
4 from pathlib import Path
@@ -26,7 +27,11 @@ from plugins._browser.helpers.extension_manager import (
27 parse_chrome_web_store_extension_id,
28 )
29 import plugins._browser.helpers.extension_manager as browser_extension_manager_module
29 -from plugins._browser.helpers.runtime import _BrowserRuntimeCore, normalize_url
30 +from plugins._browser.helpers.runtime import (
31 + _BrowserRuntimeCore,
32 + _BrowserScreencast,
33 + normalize_url,
34 +)
35 import plugins._browser.helpers.runtime as browser_runtime_module
36 from plugins._browser.helpers.playwright import (
37 get_playwright_binary,
@@ -320,6 +325,116 @@ def test_browser_viewer_uses_tabs_for_session_switching():
325 assert "Using ${this.mainModelSummary}" in browser_store
326
327
328 +def test_browser_viewer_uses_cdp_screencast_transport():
329 + ws_browser = (PROJECT_ROOT / "plugins" / "_browser" / "api" / "ws_browser.py").read_text(
330 + encoding="utf-8"
331 + )
332 + main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "main.html").read_text(
333 + encoding="utf-8"
334 + )
335 + runtime = (
336 + PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"
337 + ).read_text(encoding="utf-8")
338 + browser_store = (
339 + PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
340 + ).read_text(encoding="utf-8")
341 +
342 + assert 'runtime.call("screenshot"' not in ws_browser
343 + assert "SCREENCAST_QUALITY = 92" in ws_browser
344 + assert "initial_viewport = self._viewport_from_data(data)" in ws_browser
345 + assert '"set_viewport"' in ws_browser
346 + assert "start_screencast" in ws_browser
347 + assert "read_screencast_frame" in ws_browser
348 + assert "stop_screencast" in ws_browser
349 + assert '"Page.startScreencast"' in runtime
350 + assert '"Page.screencastFrame"' in runtime
351 + assert '"Page.screencastFrameAck"' in runtime
352 + assert '"Page.stopScreencast"' in runtime
353 + assert '"Emulation.setDeviceMetricsOverride"' in runtime
354 + assert '"Emulation.setVisibleSize"' in runtime
355 + assert "asyncio.Queue(maxsize=1)" in runtime
356 + assert "await self._stop_screencasts_for_browser(resolved_id)" in runtime
357 + assert "queueFrameRender" in browser_store
358 + assert "requestAnimationFrame" in browser_store
359 + assert "viewport_width: initialViewport?.width" in browser_store
360 + assert "viewport_height: initialViewport?.height" in browser_store
361 + assert "this.frameState = data.state || null" not in browser_store
362 + assert "overflow: hidden;" in main_html
363 + assert "object-fit: fill;" not in main_html
364 + assert "height: auto;" in main_html
365 + assert "image-rendering: auto;" in main_html
366 +
367 +
368 +@pytest.mark.asyncio
369 +async def test_browser_screencast_acknowledges_and_drops_stale_frames():
370 + first_image = (
371 + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsL"
372 + "DBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/"
373 + "2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy"
374 + "MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAKAAoDASIAAhEBAxEB/8QAFQAB"
375 + "AAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhADE"
376 + "AAAAKf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAEFAqf/xAAUEQEAAAAAAAA"
377 + "AAAAAAAAAAAAA/9oACAEDAQE/ASP/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECA"
378 + "QE/ASP/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAY/Aqf/xAAUEAEAAAAAAA"
379 + "AAAAAAAAAAAAAA/9oACAEBAAE/ISf/2gAMAwEAAgADAAAAEP/EABQRAQAAAAAAAAA"
380 + "AAAAAAAAAAP/aAAgBAwEBPxAk/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEB"
381 + "PxAk/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxAn/9k="
382 + )
383 +
384 + class FakeSession:
385 + def __init__(self):
386 + self.handlers = {}
387 + self.sent = []
388 + self.detached = False
389 +
390 + def on(self, event, handler):
391 + self.handlers[event] = handler
392 +
393 + async def send(self, method, params=None):
394 + self.sent.append((method, params or {}))
395 +
396 + async def detach(self):
397 + self.detached = True
398 +
399 + session = FakeSession()
400 + screencast = _BrowserScreencast(
401 + stream_id="stream",
402 + browser_id=7,
403 + session=session,
404 + mime="image/jpeg",
405 + )
406 +
407 + await screencast.start(quality=92, every_nth_frame=1, viewport={"width": 1118, "height": 662})
408 + session.handlers["Page.screencastFrame"](
409 + {"data": first_image, "metadata": {"deviceWidth": 10}, "sessionId": 1}
410 + )
411 + session.handlers["Page.screencastFrame"](
412 + {"data": "second", "metadata": {"deviceWidth": 200}, "sessionId": 2}
413 + )
414 + await asyncio.sleep(0)
415 +
416 + frame = await screencast.next_frame(timeout=0.1)
417 +
418 + assert frame["browser_id"] == 7
419 + assert frame["image"] == "second"
420 + assert frame["metadata"]["deviceWidth"] == 200
421 + assert ("Emulation.setDeviceMetricsOverride", {
422 + "width": 1118,
423 + "height": 662,
424 + "deviceScaleFactor": 1,
425 + "mobile": False,
426 + "dontSetVisibleSize": True,
427 + }) in session.sent
428 + assert ("Emulation.setVisibleSize", {"width": 1118, "height": 662}) in session.sent
429 + assert ("Page.screencastFrameAck", {"sessionId": 1}) in session.sent
430 + assert ("Page.screencastFrameAck", {"sessionId": 2}) in session.sent
431 +
432 + await screencast.stop()
433 +
434 + assert ("Page.stopScreencast", {}) in session.sent
435 + assert session.detached is True
436 +
437 +
438 def test_browser_docker_installs_full_chromium_to_persistent_cache():
439 script = (
440 PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_playwright.sh"