Skip to content

fix(ui): stop Escape from stranding the keyboard after closing an overlay - #509

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
dignfei:fix/overlay-focus-restore
Oct 1, 2026
Merged

Ark0N merged 2 commits into
Ark0N:masterfrom
dignfei:fix/overlay-focus-restore

Conversation

@dignfei

@dignfei dignfei commented Sep 30, 2026

Copy link
Copy Markdown

The bug

After Escape closes the Command Palette or the Session Manager, the keyboard is dead: every keystroke goes nowhere until you click the terminal again.

Both overlays call search.focus() on open, and both closed by removing the active class and nothing else. Hiding a focused input does not hand focus back to anyone — the browser drops it on <body>.

Measured

headless chromium against a real shell session, one overlay at a time:

overlay activeElement after Esc can type afterwards
App Settings XTERM yes
Session Options XTERM yes
Token Stats XTERM yes
Monitor Panel XTERM yes
Session Manager BODY no
Command Palette BODY no

The four that work do so because they use FocusTrap, whose deactivate() restores focus to whatever held it before. These two never got one.

The fix

Save document.activeElement before the overlay steals focus, restore it on close. Every close path has the same hole — Escape, the close method, picking an item — so the restore lives in the close functions rather than in the global Escape chain.

Deliberately only the save/restore half of FocusTrap, not the whole thing: FocusTrap.activate() moves focus to the first focusable element, which in neither overlay is the search box, so adopting it wholesale would trade "type a filter the moment it opens" for "focus survives the close" — and the former is the reason Cmd+K exists.

The terminal fallback is gated on there being an active session: an overlay opened from the welcome screen has no terminal to return to, and focusing one on a phone summons the on-screen keyboard over a screen with no input on it.

Testing

Five new cases in test/command-palette-ui.test.ts. Checked against the unfixed code first — four of the five fail without this change. Re-verified in a real browser afterwards: all six overlays now report XTERM and accept typing.

npm test green (the 2 pre-existing docker-entrypoint failures on this machine reproduce on unmodified master), plus typecheck, lint, format:check and check:frontend-syntax.

…rlay

Both the Command Palette and the Session Manager call `search.focus()` on
open, and both closed by removing the `active` class and nothing else. Hiding
a focused input does not hand focus back to anyone — the browser drops it on
`<body>` — so after Escape closed the overlay every keystroke went nowhere and
the user had to click the terminal before they could type again.

Measured in headless chromium against a real shell session, one overlay at a
time:

  overlay            activeElement after Esc   can type afterwards
  App Settings       XTERM                     yes
  Session Options    XTERM                     yes
  Token Stats        XTERM                     yes
  Monitor Panel      XTERM                     yes
  Session Manager    BODY                      no    <- fixed here
  Command Palette    BODY                      no    <- fixed here

The four that worked did so because they use `FocusTrap`, whose `deactivate()`
restores focus to whatever held it before. These two never got one. Every close
path has the same hole — Escape, the close method, picking an item — so the
restore lives in the close functions rather than in the global Escape chain.

Deliberately only the save/restore half of `FocusTrap`, not the whole thing:
`FocusTrap.activate()` moves focus to the first focusable element, which in
neither overlay is the search box, so adopting it wholesale would trade "type a
filter the moment it opens" for "focus survives the close" — and the former is
the reason Cmd+K exists. The terminal fallback is gated on there being an
active session: an overlay opened from the welcome screen has no terminal to
return to, and focusing one on a phone summons the on-screen keyboard over a
screen with no input on it.

The five new cases were checked against the unfixed code first: four of them
fail without this change.
@Ark0N

Ark0N commented Sep 30, 2026

Copy link
Copy Markdown
Owner

Thanks for this, @dignfei, and welcome to Codeman! This PR saves whatever had focus before the Command Palette or the Session Manager opens and hands it back on close, so Escape no longer leaves the keyboard stranded on <body>. The diagnosis is right, and the six-overlay table in the description made it easy to check: I reproduced the bug on master in a real browser (focus lands on BODY after Escape) and confirmed this branch fixes it.

Two things need to change before it goes in. The first is the important one.

1. Every Escape press now moves focus into the main terminal, not only the ones that close these overlays (src/web/public/panels-ui.js:405 and :675, fallback at :383)

The global Escape handler in src/web/public/app.js:1235-1236 calls closeSessionManager() and closeCommandPalette() on every Escape, whether or not either overlay is open. Nothing was saved in that case, so _restoreOverlayFocus() falls through to this.terminal.focus() whenever a session is active. That handler runs in the capture phase, so the focus moves before the focused element's own Escape handler runs. I checked three cases in headless Chromium against this branch and against master:

  • Split view: with focus in Pane B, Escape still reaches Pane B, but the keys typed after it go to Pane A's session (Pane B got ESC, the primary terminal got a, b, c). On master all four keys stay in Pane B.
  • Any other text field (the File Viewer editor, the search and history filters, the case picker): after Escape, the next keystrokes go into the live terminal instead of the field. On master the field keeps them.
  • Inline tab rename: Escape now sends PUT /api/sessions/:id/name, so it commits the rename instead of cancelling it. The capture-phase focus fires the input's blur handler (which commits) before its own Escape handler (which cancels, src/web/public/session-ui.js:2981). On master Escape sends nothing.

The fix is to restore focus only when the overlay was actually open:

closeCommandPalette() {
  const modal = document.getElementById('commandPaletteModal');
  if (!modal?.classList.contains('active')) return;
  modal.classList.remove('active');
  this._restoreOverlayFocus('_commandPalettePrevFocus');
},

and the same shape in closeSessionManager(). I tried this locally: the palette and the Session Manager still return focus to the terminal, and all three cases above behave exactly as on master. Two test changes go with it:

  • The Session Manager test's modal stub (test/command-palette-ui.test.ts:551) has no classList.contains; switch it to the harness's makeClassList().
  • Please add a case that calls closeCommandPalette() and closeSessionManager() without opening them first, with an active session, and asserts the terminal was not focused. That is the path the global Escape chain takes, and none of the five new tests covers it.

2. The terminal fallback pops the on-screen keyboard on a tablet (src/web/public/panels-ui.js:383)

Gating on activeSessionId covers the welcome screen. On a touch device with the keyboard down, focus sits on <body>, so closing the Session Manager (reachable on tablets through the opt-in header button) focuses the terminal and brings the keyboard up. Picking a live row has the same effect: selectSession() deliberately skips the focus there, and the close then overrides it. The app already has a helper for exactly this, _shouldFocusTerminalForTabSwitch() (src/web/public/app.js:6555): true on desktop, and on touch only while the keyboard is already open. Please use it:

if (this.activeSessionId && this._shouldFocusTerminalForTabSwitch?.() !== false) this.terminal?.focus?.();

(The optional call keeps your vm test harness working as it is.)

With those two changes this is ready to merge. Thanks again for the careful measurement work, the before/after table made this review much faster.

Review feedback. The global Escape handler in app.js calls both
`closeSessionManager()` and `closeCommandPalette()` on every Escape, whether or
not either overlay is open, in the capture phase. Nothing was saved in that
case, so `_restoreOverlayFocus()` fell through to `terminal.focus()` and moved
focus before the focused element's own Escape handler ran:

- split view: with focus in Pane B, keys typed after Escape went to Pane A
- any text field (File Viewer editor, search and history filters, case picker):
  keys typed after Escape went into the terminal
- inline tab rename: the capture-phase focus fired the input's blur (which
  commits) before its own Escape handler (which cancels), so Escape committed
  the rename instead of cancelling it

Both close methods now bail out on `classList.contains('active')`.

Separately, gating the terminal fallback on `activeSessionId` alone only covered
the welcome screen. On a touch device with the keyboard down, focus sits on
`<body>`, so closing the Session Manager focused the terminal and brought the
keyboard up — `selectSession()` deliberately skips that focus, and this
overrode it. It now goes through `_shouldFocusTerminalForTabSwitch()`.

Tests: the Session Manager case's modal stub now uses the harness's
`makeClassList()` (without `contains` the new guard reads it as "not open" and
skips the restore the case is about), plus two new cases — closing either
overlay without opening it first with an active session asserts the terminal was
not focused, which is the path the global Escape chain takes and none of the
five existing cases covered, and a touch device with the keyboard down asserts
the same. Each was checked against the unguarded code: removing either guard
turns exactly its own case red.
@dignfei

dignfei commented Sep 30, 2026

Copy link
Copy Markdown
Author

Both changed, thanks for catching the first one — the capture-phase detail is what I missed, and the rename case in particular would have been an unpleasant surprise.

1. closeCommandPalette() and closeSessionManager() now bail out on classList.contains('active'), exactly the shape you suggested.

2. The fallback now goes through _shouldFocusTerminalForTabSwitch?.() as you wrote it.

Tests:

  • the Session Manager case's modal stub uses the harness's makeClassList() now (I had to export it from loadPaletteHarness; without contains the new guard reads the stub as "not open" and skips the restore that case is about)
  • added the case you asked for: closing either overlay without opening it first, with an active session, asserting the terminal was not focused
  • added a second one for the touch path: _shouldFocusTerminalForTabSwitch() returning false with focus on <body>

I checked both new cases against the unguarded code rather than just watching them go green — removing either guard turns exactly its own case red, and nothing else.

npm test, typecheck, lint, format:check, check:frontend-syntax and check:browser-excludes all pass here. (The two docker-entrypoint Update-Codeman.sh smoke cases fail on this machine, but they fail the same way on an unmodified master checkout and pass in CI, so that is my environment, not the branch.)

@Ark0N
Ark0N merged commit b5d8122 into Ark0N:master Oct 1, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 1, 2026
…ager (#509 review)

- _restoreOverlayFocus(key, modal) now leaves focus alone when something
  outside the overlay already holds it (not <body>, not inside the modal).
  The Session Manager's "Switch to session" and "Open folder" call
  selectSession() before closeSessionManager(), and the restore was pulling
  focus back from the terminal to the header button. Both close methods pass
  their modal; a regression test drives that order.
- Test harness: focusHarness() routes getElementById through a local binding
  instead of leaking globalThis.__els, and its modal stubs report their own
  search box as contained, as the real DOM does.
- CLAUDE.md and docs/architecture-invariants.md: record that the global
  Escape handler calls every close method on every Escape (capture phase),
  so a close method with side effects must return early when not open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 1, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Merged, thanks @dignfei! This ships in 1.33.3. The classList.contains('active') guards fixed the capture-phase problem cleanly. The six-overlay table made it obvious why only these two were broken, and taking only the save/restore half of FocusTrap, so typing a filter still works the moment Cmd+K opens, was a good call.

One commit at merge time (988f111) for an edge in the Session Manager's row menu. "Switch to session" and "Open folder" call selectSession(), which focuses the terminal, and then closeSessionManager(). If the manager was opened from its header button, the restore then pulled focus back to that button. _restoreOverlayFocus() now gets the modal and leaves focus alone when something outside the overlay already holds it, with a regression test for that order. I also swapped the test harness's globalThis.__els for a local, and wrote down in CLAUDE.md the rule your guard relies on: the global Escape handler calls every close method on every Escape, so a close method with side effects has to return early when its overlay isn't open.

On #510, the two items from this morning's review are all that's left before it can go in too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants