Docs

ADR-0018: Consolidated browser focus & keyboard routing

Status: Active · Date: 2026-07-25

References

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 keyHandler switch case and sometimes a webview-key-events intercept — each free to drift. They already had: mixed getCenter().getActivePaneItem() vs plain getActivePaneItem() targeting; an event.abortKeyBinding() fallthrough on only three of ~eight handlers (the rest silently swallowed the key on non-browser items); and case-sensitive keyHandler cases (lowercase-only for several shortcuts) that a shifted key slipped past.
  • The webview-key-events listener 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 onDidChangeActivePaneItem subscription unconditionally focused the guest <webview> on every active-item change, while core pane-element.js focuses 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):

  1. 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). A SPECS table 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 with event.abortKeyBinding() when there’s no active browser, so on an editor the keystroke reaches its normal core binding.
  2. The guest keyHandler collapses onto the table. It no longer reimplements shortcuts: it maps the forwarded event through one keyToCommandId() (centralized, case-insensitive, per-platform modifiers) and dispatches the same tranquil-browser:* command the keymap uses. Only keys with no command (F12/F5/F10) stay view-local.
  3. One window-level webview-key-events listener replaces the per-tab ones. It resolves the source view by webContentsId, 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’s keyHandler or the stuck-focus split.
  4. 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 own cmd-k <arrow> runs), and only suppresses core’s duplicate on the actual split.
  5. A view focus() contract replaces the side-channel. The browser view’s root element (the node ViewRegistry caches and returns for the model) gets a focus() that focuses its guest <webview>, so Atom’s own pane-element delegation focuses the guest through the normal machinery. The onDidChangeActivePaneItem side-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 inline webview-key-events block.
  • 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-tab webview-key-events listener 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 .url output, 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.