Commit 17832ac
fix(browser): screenshot and inspect tabs that aren't on screen (#264)
## Summary
- `screenshot_tab` failed for any browser tab that wasn't the visible
pane. Hiding a tab detached its `WebContentsView` from the window, and a
parentless view has no compositor surface and reports a 0×0 viewport —
so `capturePage()` threw `UnknownVizError` (surfacing as `harness HTTP
500: capture failed`), or for a never-displayed tab produced an empty
image that the control server returned as **HTTP 200 with `data: ''`**,
which the MCP bridge reported as a bare "screenshot failed" with nothing
logged anywhere. `get_tab_clickables` shared the root cause: a 0×0
viewport makes a page look like it has no buttons.
- Hidden views are now **parked** in a lazily-created, never-shown
frameless window sized to fit them, so they keep laying out and
painting. Capture falls back to CDP `Page.captureScreenshot({
fromSurface: false })` when the on-screen surface is missing. Empty /
zero-size captures are now an error carrying a readable reason, never a
200.
- Separately: `create_browser_tab` didn't normalize a bare host, so
`example.com` failed with `ERR_INVALID_URL` despite the tool description
promising a scheme would be prepended. `navigate()` did normalize. Both
managers now share one `normalizeBrowserUrl` helper.
### Notes on the approach
Three alternatives were probed and rejected with evidence:
- **CDP `fromSurface: false` alone** — fails on a parentless view
("Unable to capture screenshot"), and hangs or crashes the process on a
never-attached 0×0 view. It needs the view parented, which is what the
park window provides.
- **`setBounds()` in `create()` alone** — a parentless view stays 0×0 no
matter what bounds you set.
- **Attaching the view "behind" the main renderer view** — impossible:
`win.contentView.children.length` is 0 for a `BrowserWindow`, so its
page isn't a child view and any added view composites *above* the UI.
The parked tab would visibly leak over the app.
The park window is `show: false`, `skipTaskbar: true`, frameless and
square-cornered (a title bar and rounded corners clip the region a
parked view can paint). A quit guard destroys it once no real window is
left, so it can't suppress `window-all-closed` on Linux/Windows.
## Test plan
Covered by tests:
- [x] `normalizeBrowserUrl` — bare host, explicit schemes
(`about:`/`file:`/`data:`), `host:port` vs scheme ambiguity, loopback →
`http`, whitespace, empty
- [x] `viewportCaptureError` / `encodedCaptureError` — 0×0 bounds,
NaN/negative, zero-size image, empty buffer
- [x] `/browser/screenshot` responses — 200 with data, 500 with the
verbatim reason, 500 for empty data, 500 for a vanished tab
- [x] `npm run typecheck`, `npx electron-vite build`, `npx vitest run`
Verified by hand in a running dev instance (real MCP HTTP path):
- [x] Reproduced the original `UnknownVizError` live, then confirmed the
fallback returns a real image
- [x] Bare host `example.com` → `https://example.com/`
- [x] Screenshot of a tab hidden by another tab taking the pane → fresh,
correct content
- [x] `get_tab_clickables` returns a real viewport and finds targets
Verified by hand against the real `BrowserManager` in an Electron
process, images inspected visually:
- [x] visible 900×600, parked-after-hide, never-displayed at 1280×800,
parked 1400×1000 fully painted (no black band), and a parked tab
navigated while hidden returning the *new* page
- [x] Park window stays `isVisible() === false`, host window keeps
focus, `destroyAll()` tears it down
Not verified by hand: the literal "switch the sidebar to another
worktree" gesture (can't drive the native UI) — the identical `hide()` →
detach path was induced by opening a second tab that displaces the
first.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>1 parent 2891a7d commit 17832ac
10 files changed
Lines changed: 448 additions & 51 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
127 | 127 | | |
128 | 128 | | |
129 | 129 | | |
130 | | - | |
131 | | - | |
132 | | - | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
133 | 133 | | |
134 | 134 | | |
135 | 135 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
| 27 | + | |
27 | 28 | | |
28 | 29 | | |
29 | 30 | | |
| |||
311 | 312 | | |
312 | 313 | | |
313 | 314 | | |
314 | | - | |
| 315 | + | |
315 | 316 | | |
316 | 317 | | |
317 | 318 | | |
| |||
468 | 469 | | |
469 | 470 | | |
470 | 471 | | |
471 | | - | |
472 | | - | |
473 | | - | |
| 472 | + | |
| 473 | + | |
474 | 474 | | |
475 | 475 | | |
476 | 476 | | |
| |||
619 | 619 | | |
620 | 620 | | |
621 | 621 | | |
622 | | - | |
| 622 | + | |
623 | 623 | | |
624 | 624 | | |
625 | 625 | | |
626 | 626 | | |
627 | | - | |
628 | | - | |
629 | | - | |
630 | | - | |
631 | 627 | | |
632 | | - | |
633 | | - | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
634 | 637 | | |
635 | | - | |
636 | | - | |
637 | | - | |
638 | | - | |
639 | | - | |
640 | | - | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
641 | 641 | | |
642 | 642 | | |
643 | 643 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
20 | 26 | | |
21 | 27 | | |
22 | 28 | | |
| |||
54 | 60 | | |
55 | 61 | | |
56 | 62 | | |
57 | | - | |
| 63 | + | |
58 | 64 | | |
59 | 65 | | |
0 commit comments