Investigation · Desktop / Browser pane

Why clicking inside a browser pane doesn't highlight it

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.

Branch: browser-click-regression Files changed: 3 Verified: CDP, dev build Date: 2026-09-10
Fixed
Click inside a browser pane didn't activate it

Wired the same activation path PagePane already had. Ships in this diff.

Not reproduced
Close (×) supposedly no-ops on an inactive browser pane

Three independent CDP repros, all closed cleanly. Left open pending a live repro.

01What regressed, and why

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.

02The fix

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.

apps/desktop/src/main/lib/browser/browser-manager.ts
+ 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.

Why the visual confirmation is inconclusive

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.

Split pane: browser page focused via CDP click, active-pane border unchanged
Browser pane (left) after a CDP click on "Learn more" inside the page. 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.
Left undone on purpose: getting a real positive here means giving the dev window actual OS focus and a real mouse click. That requires taking over the physical cursor, and the screen wasn't idle when I got to this point (an active Codex session was visibly mid-run) — so I stopped rather than hijack input on a live machine. Worth a direct click-test whenever it's convenient.

03The close-button hypothesis

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

Filmed CDP click closing an inactive browser pane
Filmed over CDP (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.

04Button size

Measured the browser pane's header icons directly from the DOM, not by eye:

Split and close icons in the browser pane header, magnified 4x
Split (TbLayoutRows) and close (HiMiniXMark) icons, magnified 4× from a real screenshot.
ElementHit targetGap to neighbor
Split icon18 × 18px2px
Close (×) icon18 × 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.

05Narrow panes: bigger icons, no overlap at any width

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.

Root cause

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].

Guards, in priority order

WidthWhat yields
< 360pxOptional chips: connection label (dot stays), subagents chip, page-watch chip, "Design" label (icon stays)
< 200pxSecondary terminal icons: copy ID, fork/handoff, rich input (still reachable via ⌘I / session dropdown)
anyTitle 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.

CDP audit (real layout, DPR 2)

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.

RunHeader widthsOverlapsEscapedResult
Before fix, 1100px window369 / 10221title painted over copy-ID and rich-input
Before fix, 900–640px244 / 6712buttons outside the header
After fix, 1728–520px843–256 / 25600floor at 256px, all buttons visible, no partial clips
After fix, min-width forced off (synthetic), 1100px369 / 10200secondary icons tier out; title + split + close
After fix, min-width forced off (synthetic), 900–640px244 / 6700title hidden, split partially clipped, close intact
Browser pane header at the 256px floor
Browser header at its 256px floor (900px window): back, forward, reload, truncated URL, icon-only Design, overflow, split, close — icons at the widened size, nothing touching.
Terminal pane header at 102px under the stress test
Stress test, min-width forced off, terminal header at 102px: secondary icons tiered out, title + split + close only, evenly spaced. The same width before the fix had two overlapping buttons.

06Regression caught: the container hid the URL suggestions

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-typenormal / inline-size (pane-header)
elementFromPoint on the suggestion row, "super" typedinside the suggestions list (not the banner)
Width audit re-run, 1728 → 640px0 overlaps, 0 escaped, 0 partial, floor 256px
URL suggestions rendering above the import banner after the fix
After the fix, same scenario as the report: the suggestion row renders fully above the banner, not clipped.

07Full-surface sweep, and a second bug it caught

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)CheckResult
Browser overflow menuopen, elementFromPoint on first item inside menu, closes on Escpass
Find baropens in the overlay layer above the webview, input is what's under the pointerpass
Design modearms (aria-pressed), banner shows, disarmspass
URL suggestionsabove the import banner (the earlier regression)pass
Terminal session dropdownopen, on toppass
Terminal connection popoveropen, on toppass
Click in page content activates paneborder flips terminal → browserpass (failed before the re-arm fix)
Narrow header audit @900px0 overlaps / escaped / partial / overflowpass
Close inactive pane via Xpane count 2 → 1, then re-splitpass
Find bar rendered above the page content
Find bar opened from the overflow menu, painted in the overlay layer above the page — the input is the element under the pointer.

08URL suggestions under the page — pre-existing, now fixed

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 listhits a suggestion <button>; list bottom 467px vs page top 147px
Nine-surface sweepall passing, suggestions check now asserts both the banner and the page
URL suggestions painting over the page body
After: the full list renders over the page body, readable and scrollable, from just under the banner.