Skip to content

Commit 9e47628

Browse files
mabry1985claude
andauthored
fix(panel): live-update when unfocused + non-distorting responsive resize (v0.6.2) (#16)
Two field-reported issues. 1. "Only live-navigates when the view is focused." The screencast stalled unless the panel had focus. Fixed defensively across the plausible causes: - Pin the page focused/visible over CDP (Emulation.setFocusEmulationEnabled) so it never throttles rendering when the operator's console view isn't the focused one. - Headed windows launch with anti-backgrounding flags (--disable-renderer-backgrounding, --disable-backgrounding-occluded-windows, --disable-background-timer-throttling) so an occluded window keeps drawing. - The panel reconnects (if the socket dropped) / forces a fresh frame on visibilitychange + focus, so switching back snaps to current instantly. 2. "Responsive is buggy." Root cause: the canvas stretched the old-aspect frame while the viewport round-tripped, so it distorted; and a drag re-armed the screencast on every observation. - canvas object-fit: contain — letterbox during the catch-up instead of distorting. - letterbox-aware input coordinate mapping. - dedupe set_viewport server-side (skip unchanged size) + debounce 180→220ms. No stream regression: nav still paints every page, resize still reshapes to the dock (validated live). 47 host-free tests pass; ruff clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bc12f73 commit 9e47628

9 files changed

Lines changed: 80 additions & 18 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## v0.6.2
4+
- **Keeps live-updating when the panel isn't focused.** The screencast used to stall unless the
5+
panel had focus. Fixed on three fronts: the page is now pinned focused/visible over CDP
6+
(`Emulation.setFocusEmulationEnabled`) so it never throttles rendering; headed windows launch
7+
with anti-backgrounding flags (`--disable-renderer-backgrounding` &co) so an occluded window
8+
keeps drawing; and the panel reconnects / forces a fresh frame on `visibilitychange` + `focus`
9+
so switching back snaps to current instantly.
10+
- **Responsive resize no longer distorts or thrashes.** The canvas uses `object-fit: contain`
11+
(no more stretched/squished frames while the viewport catches up), input mapping is
12+
letterbox-aware, and identical resize observations are deduped server-side so a drag doesn't
13+
re-arm the screencast repeatedly. Debounce nudged to 220ms.
14+
315
## v0.6.1
416
- **Full-stretch, responsive viewport.** The panel now resizes Chrome's layout viewport to
517
your dock's size (× device-pixel-ratio) over CDP as you resize/expand/collapse it — the page

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@ point.
4747

4848
The viewport is **full-stretch and responsive** — it resizes Chrome's layout viewport to your
4949
dock's size (× device-pixel-ratio) as you expand/collapse it, so the page reflows to fill rather
50-
than sitting as a fixed box, and the screencast **re-arms on every navigation** so you see the
51-
agent move through pages and sub-pages (not just the first load). Tune sharpness with
52-
`stream_quality`.
50+
than sitting as a fixed box (letterboxed with `object-fit` during the resize so it never
51+
distorts). The screencast **re-arms on every navigation** so you see the agent move through
52+
pages and sub-pages (not just the first load), and it **keeps updating even when the panel isn't
53+
focused** (the page is pinned focused/visible over CDP; headed windows launch with
54+
anti-backgrounding flags). Tune sharpness with `stream_quality`.
5355

5456
When no page is open the panel shows a **Start button** (not a dead end). Set `home_url` to a
5557
page and the panel **auto-opens** it — a homepage — otherwise Start opens `about:blank`.

browser_panel.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ async def _nav(body: dict = Body(...)):
165165
background:var(--pl-color-fg-muted,#9aa0aa)}
166166
.dot.ok{background:#22c55e}.dot.err{background:#ef4444}
167167
.stage{height:calc(100% - 38px);position:relative;background:var(--pl-color-bg-inset);overflow:hidden}
168-
canvas{width:100%;height:100%;display:block;outline:none;touch-action:none}
168+
canvas{width:100%;height:100%;display:block;outline:none;touch-action:none;object-fit:contain}
169169
#msg{display:none;position:absolute;inset:0;align-items:center;justify-content:center;
170170
padding:24px;box-sizing:border-box}
171171
.card{max-width:460px;text-align:center}
@@ -261,8 +261,13 @@ async def _nav(body: dict = Body(...)):
261261
// ── input forwarding: canvas events → CDP Input.dispatch* (coords in CSS px) ────
262262
function send(o){ if(ws&&ws.readyState===1) ws.send(JSON.stringify(o)); }
263263
function mods(e){ return {shift:e.shiftKey,ctrl:e.ctrlKey,alt:e.altKey,meta:e.metaKey}; }
264+
// map a client point → page CSS px, accounting for object-fit:contain letterboxing
265+
// (the frame's aspect may differ from the canvas box for a moment after a resize).
264266
function pos(e){ const r=cv.getBoundingClientRect();
265-
return { x:(e.clientX-r.left)/r.width*devW, y:(e.clientY-r.top)/r.height*devH }; }
267+
const bw=cv.width||devW, bh=cv.height||devH; // backing store = frame pixels
268+
const s=Math.min(r.width/bw, r.height/bh); // contain scale
269+
const dw=bw*s, dh=bh*s, ox=(r.width-dw)/2, oy=(r.height-dh)/2;
270+
return { x:(e.clientX-r.left-ox)/dw*devW, y:(e.clientY-r.top-oy)/dh*devH }; }
266271
const BTN={0:"left",1:"middle",2:"right"};
267272
cv.addEventListener("mousedown",(e)=>{ e.preventDefault(); cv.focus(); const p=pos(e);
268273
send({t:"mouse",action:"down",x:p.x,y:p.y,button:BTN[e.button]||"left",clickCount:e.detail||1,buttons:e.buttons,...mods(e)}); });
@@ -288,7 +293,13 @@ async def _nav(body: dict = Body(...)):
288293
function panelSize(){ const r=cv.parentElement.getBoundingClientRect();
289294
return { w:Math.round(r.width), h:Math.round(r.height), dpr:Math.min(window.devicePixelRatio||1, 2) }; }
290295
function sendResize(){ const s=panelSize(); if(s.w>10 && s.h>10) send({t:"resize",w:s.w,h:s.h,dpr:s.dpr}); }
291-
new ResizeObserver(()=>{ clearTimeout(rzTimer); rzTimer=setTimeout(sendResize, 180); }).observe(cv.parentElement);
296+
new ResizeObserver(()=>{ clearTimeout(rzTimer); rzTimer=setTimeout(sendResize, 220); }).observe(cv.parentElement);
297+
298+
// When the panel becomes visible/focused again, the console may have throttled it while
299+
// hidden — reconnect if the socket dropped, else force a fresh frame so it snaps to current.
300+
function onVisible(){ if(document.hidden) return; if(!connected) connect(); else { send({t:"refresh"}); sendResize(); } }
301+
document.addEventListener("visibilitychange", onVisible);
302+
window.addEventListener("focus", onVisible);
292303
293304
// ── boot ONCE — on the handshake (so apiFetch has the bearer for the gated ticket)
294305
// or an 800ms fallback for a standalone/older host that posts no init. ──────────

browser_stream.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ def __init__(self, page_ws_url: str, frame_cb, quality: int = 80):
218218
self._frame_cb = frame_cb
219219
self._quality = max(1, min(int(quality or 80), 100))
220220
self._cast = (1280, 800) # current screencast max frame size (device px)
221+
self._last_vp = None # last applied (css_w, css_h, scale) — dedupe resize thrash
221222
self._ws = None
222223
self._id = 0
223224
self._last_arm = 0.0 # debounce re-arms (multi-frame pages fire many events)
@@ -250,22 +251,34 @@ async def _arm_cast(self):
250251

251252
async def start_screencast(self, max_w: int = 1280, max_h: int = 800):
252253
await self._send("Page.enable") # also enables frameNavigated / loadEventFired for re-arm
254+
# Treat the page as permanently focused + visible. Otherwise Chrome throttles rendering
255+
# when the page isn't the focused surface, and the screencast stalls until the operator
256+
# clicks back into the panel ("only live-navigates when focused").
257+
await self._send("Emulation.setFocusEmulationEnabled", {"enabled": True})
253258
self._cast = (max_w, max_h)
254259
await self._arm_cast()
255260

256261
async def set_viewport(self, w, h, dpr=1.0):
257262
"""Resize Chrome's layout viewport to the panel and re-arm the screencast at the
258-
matching frame size — so the page reflows to fill, and stays crisp."""
263+
matching frame size — so the page reflows to fill, and stays crisp. Deduped: an
264+
unchanged size is a no-op (a drag fires many identical observations)."""
259265
cw, ch, scale, mw, mh = viewport_metrics(w, h, dpr)
266+
if (cw, ch, scale) == self._last_vp:
267+
return
268+
self._last_vp = (cw, ch, scale)
260269
await self._send("Emulation.setDeviceMetricsOverride",
261270
{"width": cw, "height": ch, "deviceScaleFactor": scale, "mobile": False})
262271
self._cast = (mw, mh)
263272
await self._arm_cast()
264273

265274
async def dispatch(self, msg: dict):
266-
if msg.get("t") == "resize":
275+
t = msg.get("t")
276+
if t == "resize":
267277
await self.set_viewport(msg.get("w"), msg.get("h"), msg.get("dpr", 1))
268278
return
279+
if t == "refresh": # panel became visible again → force a fresh frame
280+
await self._arm_cast()
281+
return
269282
cmd = input_to_cdp(msg)
270283
if cmd:
271284
await self._send(*cmd)

protoagent.plugin.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
id: agent_browser
22
name: Agent Browser
3-
version: 0.6.1
3+
version: 0.6.2
44
description: >-
55
Browser automation for protoAgent, backed by **agent-browser** (vercel-labs) — a
66
fast native-Rust CLI/daemon that drives Chrome over CDP with accessibility-tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "agent-browser-plugin"
3-
version = "0.6.1"
3+
version = "0.6.2"
44
description = "Browser-automation plugin for protoAgent, backed by vercel-labs/agent-browser (tools + skill + workflows + an interactive, drivable browser panel)."
55
requires-python = ">=3.11"
66

runtime.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,18 @@ def launch_flags(cfg: dict | None) -> list[str]:
2323
cfg = cfg or {}
2424
f: list[str] = []
2525
headed = bool(cfg.get("headed"))
26+
# Extra Chrome launch args (comma/newline separated); anti-detection + anti-throttle
27+
# flags get merged in below.
28+
args = [a.strip() for a in str(cfg.get("browser_args") or "").replace("\n", ",").split(",") if a.strip()]
2629
if headed:
2730
f.append("--headed")
31+
# A headed window gets throttled/paused when it loses focus or is occluded, which
32+
# stalls the live screencast. Keep it rendering so the panel updates even when the
33+
# operator is looking at another window.
34+
for a in ("--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding",
35+
"--disable-background-timer-throttling"):
36+
if a not in args:
37+
args.append(a)
2838
if str(cfg.get("profile") or "").strip():
2939
f += ["--profile", str(cfg["profile"]).strip()]
3040
if str(cfg.get("device") or "").strip():
@@ -37,10 +47,8 @@ def launch_flags(cfg: dict | None) -> list[str]:
3747
f += ["--max-output", str(int(cfg["max_output"]))]
3848

3949
# ── anti-detection ──────────────────────────────────────────────────────────
40-
# Extra Chrome launch args (comma/newline separated) + a UA override. `stealth` layers
41-
# on the common evasions: drop the `navigator.webdriver` automation flag, and (when
42-
# headless, where the UA says "HeadlessChrome") swap in a real desktop UA.
43-
args = [a.strip() for a in str(cfg.get("browser_args") or "").replace("\n", ",").split(",") if a.strip()]
50+
# `stealth` layers on the common evasions: drop the `navigator.webdriver` automation
51+
# flag, and (when headless, where the UA says "HeadlessChrome") swap in a real desktop UA.
4452
ua = str(cfg.get("user_agent") or "").strip()
4553
if bool(cfg.get("stealth")):
4654
if "--disable-blink-features=AutomationControlled" not in args:

tests/test_agent_browser.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@ def _toolmap(cfg=None):
2626
async def test_open_passes_url_and_curated_launch_flags(monkeypatch):
2727
rec = []
2828
monkeypatch.setattr(tools.subprocess, "run", fake_run(stdout="OPENED", record=rec))
29-
t = _toolmap({"binary": "ab", "headed": True, "allowed_domains": "x.com", "max_output": 500})
29+
# headless so argv is clean (headed injects anti-throttle --args; covered in test_runtime)
30+
t = _toolmap({"binary": "ab", "allowed_domains": "x.com", "max_output": 500})
3031
out = await t["browser_open"].ainvoke({"url": "https://x.com"})
3132
assert "OPENED" in out
32-
assert rec[-1] == ["ab", "--headed", "--allowed-domains", "x.com", "--max-output", "500", "open", "https://x.com"]
33+
assert rec[-1] == ["ab", "--allowed-domains", "x.com", "--max-output", "500", "open", "https://x.com"]
3334

3435

3536
async def test_open_blank_url_omits_it(monkeypatch):
@@ -159,6 +160,8 @@ def test_panel_page_wires_canvas_stream_and_input():
159160
assert 'u.protocol==="https:" ? "wss:" : "ws:"' in html # http→ws upgrade
160161
assert 'send({t:"mouse"' in html and 'send({t:"key"' in html # input forwarding
161162
assert "ResizeObserver" in html and 'send({t:"resize"' in html # responsive viewport tracking
163+
assert "object-fit:contain" in html # no distortion during resize
164+
assert "visibilitychange" in html and 'send({t:"refresh"' in html # refresh when re-shown
162165
assert "/api/plugins/agent_browser/nav" in html and "kit.apiFetch" in html # nav via gated route
163166
assert "startBrowser" in html and 'const HOME="";' in html # empty-state Start; blank home default
164167
# the removed dashboard-embed / screenshot modes leave no trace:

tests/test_runtime.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,25 @@ def test_default_is_empty():
1212

1313

1414
def test_curated_flags_stable_order():
15-
f = rt.launch_flags({"headed": True, "profile": "P", "device": "iPhone 16 Pro",
15+
# headless keeps the argv clean (headed injects anti-throttle --args, tested separately)
16+
f = rt.launch_flags({"profile": "P", "device": "iPhone 16 Pro",
1617
"allowed_domains": "x.com", "confirm_actions": "nav", "max_output": 500})
17-
assert f == ["--headed", "--profile", "P", "--device", "iPhone 16 Pro",
18+
assert f == ["--profile", "P", "--device", "iPhone 16 Pro",
1819
"--allowed-domains", "x.com", "--confirm-actions", "nav", "--max-output", "500"]
1920

2021

22+
def test_headed_injects_anti_throttle_args():
23+
f = rt.launch_flags({"headed": True})
24+
assert f[0] == "--headed" and f[1] == "--args"
25+
aset = set(f[2].split(","))
26+
assert {"--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding",
27+
"--disable-background-timer-throttling"} <= aset # keep a headed window rendering unfocused
28+
29+
30+
def test_headless_stays_clean():
31+
assert rt.launch_flags({"allowed_domains": "x.com"}) == ["--allowed-domains", "x.com"] # no --args
32+
33+
2134
def test_stealth_headless_adds_automation_arg_and_real_ua():
2235
f = rt.launch_flags({"stealth": True}) # headless by default
2336
assert f[f.index("--user-agent") + 1].startswith("Mozilla/5.0")

0 commit comments

Comments
 (0)