ADR-0018: Consolidated browser focus & keyboard routing
Status: Active · Date: 2026-07-25
References
- Browser Focus & Key Routing — Delivery Notes — what shipped, where it lives, and the manual-verification surface
- ADR-0006: Plain browser-style tab cycling — refined here: Ctrl+Tab is now center-scoped and routed through the consolidated listener
- ADR-0009: Browser find-in-page — one of the shortcuts (cmd-f) that flowed through the duplicated paths this ADR unifies
Context
A Tranquil browser tab embeds its page in an Electron <webview> guest. A focused guest
swallows keystrokes before Pulsar’s keymap ever sees them, so every browser shortcut had to be
delivered two ways: the normal keymap → atom.commands.add('atom-workspace', …) handler (works
when host DOM has focus) and a guest path — the guest preload forwards each keydown over IPC
(bp-client → main → cz-init stamps a webContentsId) to a webview-key-events handler that
either intercepts it or calls the view’s keyHandler.
Over time this grew into a tangle of drifting, duplicated logic — the run of “after X, key Y doesn’t work” bugs (ctrl+tab dead after a cmd-click; cmd+w / cmd+t / cmd+s not firing after ctrl+tab) traced back to it:
- The same shortcut lived in 2–3 places — a keymap command and a
keyHandlerswitchcase and sometimes awebview-key-eventsintercept — each free to drift. They already had: mixedgetCenter().getActivePaneItem()vs plaingetActivePaneItem()targeting; anevent.abortKeyBinding()fallthrough on only three of ~eight handlers (the rest silently swallowed the key on non-browser items); and case-sensitivekeyHandlercases (lowercase-only for several shortcuts) that a shifted key slipped past. - The
webview-key-eventslistener was registered per tab (inside the per-view IPC wiring), so N tabs meant N listeners on one channel, none torn down on tab close. - cmd-k + arrow (split) had three independent implementations, each with its own one-second
pending-state timer: a host-renderer capture interceptor, the guest
keyHandler, and a “stuck-focus” branch in the IPC handler. - A guest-focus side-channel raced Atom’s own delegation. An
onDidChangeActivePaneItemsubscription unconditionally focused the guest<webview>on every active-item change, while corepane-element.jsfocuses the active item’s view only when the pane already has focus — two mechanisms, different targets, firing on every switch.
The invariant under all of it: never leave DOM focus inside a hidden <webview> — a hidden
focused guest stops forwarding keydowns, which is what silently broke keys after a tab switch.
Decision
Give each concern one home; let the two delivery paths converge on it; keep core shortcuts working
by default. Concretely, all in owned packages (tranquil-browser, plus the tranquil-config center-cycle commands and tranquil-client keymaps — no third-party code touched):
- One
activeBrowser()accessor + a declarative command table (lib/browser-commands.js).activeBrowser()resolves the target once (the focused item if it’s a browser — covering a browser dragged into a dock — else the center’s active browser). ASPECStable declares each routable command (focus-url, find, save-url, toggle-url-bar, go-back/forward, refresh, hard-refresh, print); the generated handlers all fall through withevent.abortKeyBinding()when there’s no active browser, so on an editor the keystroke reaches its normal core binding. - The guest
keyHandlercollapses onto the table. It no longer reimplements shortcuts: it maps the forwarded event through onekeyToCommandId()(centralized, case-insensitive, per-platform modifiers) and dispatches the sametranquil-browser:*command the keymap uses. Only keys with no command (F12/F5/F10) stay view-local. - One window-level
webview-key-eventslistener replaces the per-tab ones. It resolves the source view bywebContentsId, runs the handful of global pre-guard actions (Ctrl+Tab, command palette, cmd-w) that must fire before the active-item guard, then routes to the guest’skeyHandleror the stuck-focus split. - One cmd-k split owner (
lib/split-chord.js). All three former call sites share its single pending state. The host interceptor now defers to core when the active item is not a browser (Pulsar’s owncmd-k <arrow>runs), and only suppresses core’s duplicate on the actual split. - A view
focus()contract replaces the side-channel. The browser view’s root element (the nodeViewRegistrycaches and returns for the model) gets afocus()that focuses its guest<webview>, so Atom’s own pane-element delegation focuses the guest through the normal machinery. TheonDidChangeActivePaneItemside-channel is removed; focus now follows only when the pane had focus, which is the more-correct behavior and upholds the never-focus-a-hidden-guest invariant.
Supporting: win/linux keymap parity for the browser shortcuts; and a set of CDP regression smoke
suites (tranquil-test-suite) that pin the focus/selection outcomes as a safety net for the change.
Options considered
- Option A: Consolidate onto one source of truth per concern (chosen). Each shortcut, the guest listener, the split chord, and guest focus each get a single implementation; the keymap and guest paths converge on them. Higher up-front change, but removes the drift that caused the bug family and makes future shortcuts a one-line table entry. Guarded by regression suites + a manual pass.
- Option B: Leave the dual paths, fix bugs point-by-point. Rejected: this is what produced the “after X, key Y doesn’t work” run — each fix touched one of 2–3 copies and the next divergence surfaced elsewhere.
- Option C: Drop the guest path and rely on the keymap alone. Rejected: a focused
<webview>genuinely swallows keys before the keymap, so the IPC-forwarded path is load-bearing, not incidental. Consolidating it is the fix, not removing it.
Consequences
- Core Pulsar shortcuts keep working unless a Tranquil shortcut overrides them. Every routable browser command falls through to its core/editor binding on non-browser items; the guest intercepts fire only when a focused webview would otherwise eat the key; the host cmd-k interceptor defers to core off browsers. Net: editors are untouched; browser tabs get the overrides.
- Refines ADR-0006. Ctrl+Tab still cycles positionally with no MRU
popup, but now targets the center pane regardless of which dock holds focus
(
tranquil:show-next/previous-item-in-center) and is dispatched from the consolidated guest listener rather than an inlinewebview-key-eventsblock. - Behavior deltas (intended): browser shortcuts are now case-insensitive; refresh/hard-refresh act
on the two-tier
activeBrowser()(so they work from a focused dock too); plain-editor cmd-k+arrow now runs Pulsar’s own split; guest focus follows activation only when the pane had focus. The per-tabwebview-key-eventslistener leak is gone (one listener, disposed on deactivate). - Not automatable end-to-end. The CDP suites cover command behavior, host-level focus, tree-view
selection, and
.urloutput, but cannot inject a keydown into a focused guest webview — so the guest-focused keystroke paths and cmd-k splits are validated by a manual pass (done for this change).
Validation
Regression smoke suites (tranquil-test-suite, run over CDP) stay green across every phase:
ctrl+tab cycles the center from a focused dock and lands focus there; the tree-view keeps its
selection when a pathless tab activates; a background-tab open steals neither the active item nor
focus; and the save-url command writes the bookmark. The guest-focused shortcut set, the three cmd-k
split contexts, and the Phase-5 focus flows (tab click, ctrl+tab across dock/center, close-focus-next,
vertical-tab-list click, window reload) were confirmed by a manual pass in the running app.
Implementation: tranquil-browser (lib/browser-commands.js, lib/split-chord.js, lib/tranquil-browser.js, lib/tranquil-browser-view.js, lib/utils.js, keymap), the tranquil-config center-cycle commands, and the tranquil-client keymaps.