Investigation · Desktop / Browser pane
A click-to-focus regression traced, fixed, and verified over CDP — plus a second, unconfirmed hypothesis about the close button that didn't hold up under repro.
Wired the same activation path PagePane already had. Ships in this diff.
Three independent CDP repros, all closed cleanly. Left open pending a live repro.
Clicking anywhere in a pane is supposed to activate it — standard IDE behavior, enforced by one onMouseDown on the pane's own wrapper (packages/panes/.../Pane.tsx). That works for ordinary pane content. It never worked for a browser pane's page content, and the reason is structural, not a one-line bug.
A browser pane's <webview> is deliberately hoisted to a single body-level container (browserRuntimeRegistry.ts), positioned over its placeholder with position: fixed. That's been true since the browser pane was first ported to v2 (v2-workspace, "port browser pane to v2 workspaces with global persistence") — reparenting an Electron <webview> destroys and respawns its guest process, so keeping it fixed under <body> is what lets a page survive tab switches and splits without reloading. The side effect: the webview is no longer a DOM descendant of the pane wrapper that owns activation, so a click inside the page never bubbles a mousedown to it.
The identical bug existed for PagePane (published-page iframes) and was fixed three days earlier — the fix comment says it outright:
// Make this the active pane. Clicking anywhere in a pane activates it, // but a click inside the page's iframe never reaches the pane's own // handler, so the frame reports it and the pane activates itself.
That fix works because PagePane's iframe runs Superset's own code, which can cooperatively postMessage a pointer-down event to the host. A browser pane's <webview> hosts an arbitrary third-party site — it can't be made to run that script. The equivalent fix for browser panes was never written, so the gap sat there until it became visible next to its now-fixed sibling.
Electron's WebContents exposes a focus event specifically documented for detecting focus moving between different WebContents in the same window — the same signal the existing zoom-hotkey code already reads via document.activeElement. That's the host-side hook: no injected script, no cooperation needed from guest content.
+ private setupFocusForward(paneId: string, wc: Electron.WebContents): void {
+ const handler = (): void => {
+ this.emit(`pane-focus:${paneId}`);
+ };
+ wc.on("focus", handler);
+ this.focusListeners.set(paneId, () => wc.off("focus", handler));
+ }
apps/desktop/src/lib/trpc/routers/browser/browser.ts
+ onPaneFocus: publicProcedure
+ .input(z.object({ paneId: z.string() }))
+ .subscription(({ input }) => observable<void>((emit) => {
+ const handler = () => emit.next();
+ browserManager.on(`pane-focus:${input.paneId}`, handler);
+ return () => browserManager.off(`pane-focus:${input.paneId}`, handler);
+ })),
.../BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts
+ const paneFocusSub = electronTrpcClient.browser.onPaneFocus.subscribe(
+ { paneId },
+ { onData: () => ctxRef.current.actions.focus() },
+ );
Typechecks clean. Confirmed the mechanism end-to-end over CDP: clicking inside the webview does move host-side DOM focus onto the <webview> element (document.activeElement.tagName === "WEBVIEW"), which is the same signal path the zoom hotkeys already depend on. What I could not get a clean visual read on is below.
Electron's WebContents focus/blur events fire for focus moving between WebContents in the same window — which presumes the window itself holds real OS focus. Driving a background dev window purely over CDP never gives it that: document.hasFocus() read false through every attempt below, even as the click visibly landed in the guest.
activeElement is the <webview> element, but the border state didn't flip — because document.hasFocus() was false for the whole session; the window was never OS-focused. That's a CDP-testing artifact, not evidence against the fix.Question on the table: does a browser pane's header × fail to close it when the pane isn't the active one in a split? Traced the code first — the close button lives inside the same wrapper that has onMouseDown={context.actions.focus}, so a click there should activate-then-close on every press, regardless of prior state. Reproduced it live to check, three times, twice with a naive CDP click and once with the corrected recipe (buttons:1 + pointerType:"mouse" on press/release, socket held open past the dispatch — a synthetic click can silently no-op without both).
Page.startScreencast, real frame timing, not stitched-together stills): browser pane confirmed inactive via its actual border-transparent class (not a visual guess), clicked its header ×, closed cleanly.Didn't reproduce. Either it needs a shape I haven't hit — two real browser panes side by side rather than browser+terminal (the pane-registry's split action defaults to a terminal regardless of source kind, so I couldn't get two live webviews into one split without a drag-to-merge I didn't get to), a specific build, or a genuine mouse click rather than CDP input — or it's an intermittent miss on the small target next to it (next section). Left alone until it's reproduced live.
Measured the browser pane's header icons directly from the DOM, not by eye:
TbLayoutRows) and close (HiMiniXMark) icons, magnified 4× from a real screenshot.| Element | Hit target | Gap to neighbor |
|---|---|---|
| Split icon | 18 × 18px | 2px |
| Close (×) icon | 18 × 18px | — |
18px targets with a 2px gap between two different actions is tight for a desktop pointer — enough that an imprecise click on close could land on split instead. That wouldn't explain a failure conditioned on active/inactive state, but it's worth widening the hit padding regardless, independent of whether the close-button hypothesis above ever reproduces.
Widening the icons directly (04) broke a narrow-pane fix. The real cause turned out to be older than that change, and the layout is now guarded so no header width can produce an overlap.
apps/desktop's Tailwind @source scanned the renderer and packages/ui but never packages/panes. So PANE_MIN_SIZE_CLASS_NAME's min-w-[260px] (defined only in panes) was never generated — computed min-width: 0px — and a pane could be squeezed to ~67px, where no icon row of any size fits. One @source line activates the existing minimum; the only utilities that newly activate are min-w-[260px] and min-h-[160px].
| Width | What yields |
|---|---|
| < 360px | Optional chips: connection label (dot stays), subagents chip, page-watch chip, "Design" label (icon stays) |
| < 200px | Secondary terminal icons: copy ID, fork/handoff, rich input (still reachable via ⌘I / session dropdown) |
| any | Title area clips (it has zero flex basis, so it squeezes first); extras wrapper clips from its left edge with justify-end — split/close are the last thing standing. Nothing ever paints over a neighbour or past the pane. |
The header is a named @container/pane-header, matching how the diff header and Open-in button already collapse by their own width rather than the viewport.
For each viewport width, every header button's visible rect (its layout rect clipped by overflow-hidden ancestors) is checked pairwise for intersection, for escaping its header, and the header for horizontal overflow. Emulated widths 1728 → 520px, browser + terminal split.
| Run | Header widths | Overlaps | Escaped | Result |
|---|---|---|---|---|
| Before fix, 1100px window | 369 / 102 | 2 | 1 | title painted over copy-ID and rich-input |
| Before fix, 900–640px | 244 / 67 | 1 | 2 | buttons outside the header |
| After fix, 1728–520px | 843–256 / 256 | 0 | 0 | floor at 256px, all buttons visible, no partial clips |
| After fix, min-width forced off (synthetic), 1100px | 369 / 102 | 0 | 0 | secondary icons tier out; title + split + close |
| After fix, min-width forced off (synthetic), 900–640px | 244 / 67 | 0 | 0 | title hidden, split partially clipped, close intact |
Reported from a screenshot: with a query typed in the browser pane's URL bar, the suggestion list rendered clipped and under the Import banner.
@container applies CSS layout containment, and layout containment makes the element its own stacking context. The suggestion list is a plain absolute … z-50 box inside the header, so once the header was the container its z-50 only counted inside the header — the banner, a later sibling outside it, painted on top.
Fix: the container now lives on the pane root, which has the same width (so every @min-[…]/pane-header query is unchanged) and whose stacking context holds both the header's popovers and the pane's banners — the same z-order as before this work.
| Check (CDP, after fix) | Result |
|---|---|
Header container-type / root container-type | normal / inline-size (pane-header) |
elementFromPoint on the suggestion row, "super" typed | inside the suggestions list (not the banner) |
| Width audit re-run, 1728 → 640px | 0 overlaps, 0 escaped, 0 partial, floor 256px |
Rule from this thread: check everything the change can touch, every time. So the header work now ships with a nine-surface CDP sweep — and running it found a real defect in the click-to-activate fix itself.
The bug: the mousedown watch did not survive a navigation. The executeJavaScript awaiting a click in the old document is orphaned when the guest navigates — it never rejects, it just never settles — so the loop parked on it forever. On the new page the guest showed hookInstalled: false; clicking the page no longer activated the pane. The earlier test plan claimed the opposite; it had not actually been exercised.
The fix: dom-ready starts a fresh loop per document under a generation counter; a stale loop exits at its next check and a late resolution is dropped. After navigating example.com → example.org the guest reports hookInstalled: true.
| Surface (post-navigation document) | Check | Result |
|---|---|---|
| Browser overflow menu | open, elementFromPoint on first item inside menu, closes on Esc | pass |
| Find bar | opens in the overlay layer above the webview, input is what's under the pointer | pass |
| Design mode | arms (aria-pressed), banner shows, disarms | pass |
| URL suggestions | above the import banner (the earlier regression) | pass |
| Terminal session dropdown | open, on top | pass |
| Terminal connection popover | open, on top | pass |
| Click in page content activates pane | border flips terminal → browser | pass (failed before the re-arm fix) |
| Narrow header audit @900px | 0 overlaps / escaped / partial / overflow | pass |
| Close inactive pane via X | pane count 2 → 1, then re-split | pass |
Second screenshot report: with the banner fixed, the suggestion list still got cut off where the page began. Before touching it: was this the container move, or older?
Probe (loaded page, "super" typed, elementFromPoint at the list's bottom row) | Hit |
|---|---|
| Container on the pane root (current) | WEBVIEW |
Containment removed live (container-type: normal — the pre-change stacking) | WEBVIEW |
Same result either way: pre-existing. The list was an absolute z-50 box inside the header, and the hoisted native <webview> paints over the entire pane tree — no z-index inside can beat it. That is the exact class #7232 solved by moving the find bar, composer and error state into the registry's overlay layer; the suggestion list was left out.
Fix: the list portals into that overlay (the host layer mirroring the page rect, painted above the webview), placed at the input's edges in the layer's coordinates and re-measured while open (ResizeObserver on the input and the overlay). It falls back to the in-place list until the pane's overlay is attached. Its top clamps to the overlay's edge, so with a banner present it starts just under the banner instead of overlapping it.
| After (CDP) | Result |
|---|---|
| Bottom row of the list | hits a suggestion <button>; list bottom 467px vs page top 147px |
| Nine-surface sweep | all passing, suggestions check now asserts both the banner and the page |