fix(ui): keep a focus the row menu moved when closing the Session Manager (#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>
This commit is contained in:
Codeman maintainer
2026-10-01 11:18:56 +02:00
parent 5f5de5827e
commit 988f111cd0
4 changed files with 66 additions and 15 deletions
+12 -3
View File
@@ -382,11 +382,20 @@ Object.assign(CodemanApp.prototype, {
* 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 that
* has no input on it.
*
* ⚠️ A focus that already left the overlay is kept, not overridden. The
* Session Manager's row menu ("Switch to session", "Open folder") calls
* selectSession(), which focuses the terminal, BEFORE closeSessionManager();
* restoring there would pull focus back to the header button that opened the
* modal. Only a focus still inside `modal`, or one dropped on `<body>`, is
* the overlay's to hand back.
*/
_restoreOverlayFocus(key) {
_restoreOverlayFocus(key, modal) {
const prev = this[key];
this[key] = null;
const body = typeof document !== 'undefined' ? document.body : null;
const current = typeof document !== 'undefined' ? document.activeElement : null;
if (current && current !== body && modal?.contains?.(current) === false) return;
// `isConnected === false` means the element was removed while the overlay
// was open (a re-render of the tab strip, say); anything else — including
// a stub with no such property — is treated as still focusable.
@@ -435,7 +444,7 @@ Object.assign(CodemanApp.prototype, {
// (which cancels), turning a cancel into a rename.
if (!modal?.classList?.contains('active')) return;
modal.classList.remove('active');
this._restoreOverlayFocus('_commandPalettePrevFocus');
this._restoreOverlayFocus('_commandPalettePrevFocus', modal);
},
_wireCommandPalette() {
@@ -707,7 +716,7 @@ Object.assign(CodemanApp.prototype, {
// Same guard as closeCommandPalette — see the note there.
if (!modal?.classList?.contains('active')) return;
modal.classList.remove('active');
this._restoreOverlayFocus('_sessionManagerPrevFocus');
this._restoreOverlayFocus('_sessionManagerPrevFocus', modal);
},
/** Replace the Session Manager list body with a single status line. */