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
+1 -1
View File
@@ -335,7 +335,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
+1 -1
View File
@@ -653,7 +653,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
### Command palette and shortcut registry
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ The global Escape handler (app.js) calls every overlay close method on every Escape, in the capture phase and so before the focused element's own Escape handler, which means a close method with side effects beyond hiding must return early when its overlay is not open: `closeCommandPalette()` and `closeSessionManager()` hand focus back on close (`_restoreOverlayFocus()`, panels-ui.js), and run unconditionally that restore stole focus from split Pane B and from any text field, and turned the inline tab rename's Escape into a commit. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
**Terminal smart copy** (#211): `Ctrl+C` copies the selection when there is one and stays the interrupt when there isn't. Three rules keep that split honest, and breaking any of them silently costs the user their interrupt key:
+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. */
+52 -10
View File
@@ -492,16 +492,19 @@ describe('overlay focus restoration (Escape must not strand the keyboard)', () =
const priorElement = { focus: vi.fn(), isConnected: true, tagName: 'TEXTAREA' };
const body = { tagName: 'BODY' };
let active: any = priorElement;
// The harness builds its element map internally, so getElementById reads it
// through this binding, filled in once the harness returns.
let els: Record<string, any> = {};
const { app, elements, makeClassList } = loadPaletteHarness({
document: {
getElementById: (id: string) => (globalThis as any).__els?.[id] ?? null,
getElementById: (id: string) => els[id] ?? null,
get activeElement() {
return active;
},
body,
},
});
(globalThis as any).__els = elements;
els = elements;
app.terminal = { focus: terminalTextarea.focus };
app.activeSessionId = 'sess-beta';
// The overlay's own focus() is what moves focus in a real browser; the
@@ -509,7 +512,36 @@ describe('overlay focus restoration (Escape must not strand the keyboard)', () =
elements.commandPaletteSearch.focus = vi.fn(() => {
active = elements.commandPaletteSearch;
});
return { app, elements, priorElement, terminalTextarea, body, makeClassList, setActive: (v: any) => (active = v) };
// The restore keeps a focus that already left the overlay, so the modal has
// to know its own search box is inside it, as the real DOM does.
elements.commandPaletteModal.contains = (el: any) => el === elements.commandPaletteSearch;
// Same wiring for the Session Manager. A real classList: the close guard
// reads `contains('active')`, and a stub without it reports "not open" and
// skips the restore.
const installSessionManager = () => {
const search: any = { value: '', addEventListener: vi.fn() };
search.focus = vi.fn(() => {
active = search;
});
elements.sessionManagerSearch = search;
elements.sessionManagerModal = {
classList: makeClassList(),
addEventListener: vi.fn(),
contains: (el: any) => el === search,
};
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
app._loadSessionManagerList = vi.fn();
};
return {
app,
elements,
priorElement,
terminalTextarea,
body,
makeClassList,
installSessionManager,
setActive: (v: any) => (active = v),
};
}
it('returns focus to whatever had it when the command palette closes', () => {
@@ -569,17 +601,27 @@ describe('overlay focus restoration (Escape must not strand the keyboard)', () =
});
it('restores focus on the session manager too, not just the palette', async () => {
const { app, elements, priorElement, makeClassList } = focusHarness();
// A real classList: the close guard reads `contains('active')`, and a stub
// without it reports "not open" and skips the restore this test is about.
elements.sessionManagerModal = { classList: makeClassList(), addEventListener: vi.fn() };
elements.sessionManagerSearch = { value: '', focus: vi.fn(), addEventListener: vi.fn() };
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
app._loadSessionManagerList = vi.fn();
const { app, priorElement, installSessionManager } = focusHarness();
installSessionManager();
await app.openSessionManager();
app.closeSessionManager();
expect(priorElement.focus).toHaveBeenCalledTimes(1);
});
it('keeps the terminal focus the row menu gave it when the session manager closes', async () => {
// "Switch to session" and "Open folder" (terminal-ui.js) call selectSession(),
// which focuses the terminal on desktop, and only THEN closeSessionManager().
// Opened from its header button, the saved focus is that button, so an
// unconditional restore pulled focus off the session the user just picked.
const { app, priorElement, terminalTextarea, installSessionManager, setActive } = focusHarness();
installSessionManager();
await app.openSessionManager();
app.selectSession = vi.fn(() => setActive(terminalTextarea));
app.selectSession('sess-alpha');
app.closeSessionManager();
expect(priorElement.focus).not.toHaveBeenCalled();
expect(terminalTextarea.focus).not.toHaveBeenCalled();
});
});
describe('panel close helpers', () => {