fix(tabs): Escape in the rail search box clears only the search (#580)

The global key handler in setupEventListeners() sits on document in the
capture phase, so it ran before the box's inline onkeydown: the Escape that
cleared the search also ran closeAllPanels() (collapsing the Monitor and
Subagents panels), closeHelp() and closeSessionManager(). Calling
stopPropagation() from the inline handler came too late.

The global Escape branch now claims an Escape whose target is
#tabRailSearch while the box holds text, the same way it already claims
one for the group menu, the grouped-rail drag and the Tiles count menu,
and routes it to handleTabRailSearchKeydown(). An empty box still leaves
Escape to the global handler, and an Escape that cancels an IME
composition is left to the IME.

Tests: the unit test installs the real global listener and asserts that
Escape with text in the box closes nothing, that an empty box closes as
before, and that an Escape outside the box is not claimed. The browser
test installs setupEventListeners() for real in Chromium, so the
capture-before-inline ordering is the shipped one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-10 04:05:57 +02:00
parent 2881ef5fa5
commit ac5f2aa3f4
3 changed files with 114 additions and 3 deletions
+24
View File
@@ -117,6 +117,14 @@ describe('vertical rail session search in Chromium', () => {
};
w.__setApp(app);
w.__app = app;
// The real global key handler (document, capture phase). The close
// methods it calls live in settings-ui.js / panels-ui.js, which are not
// loaded here, so they record instead.
app.setupColorPicker = () => undefined;
app.closeAllPanels = () => w.__closed.push('panels');
app.closeHelp = () => w.__closed.push('help');
app.closeSessionManager = () => w.__closed.push('session-manager');
app.setupEventListeners();
});
});
@@ -144,6 +152,7 @@ describe('vertical rail session search in Chromium', () => {
app._resetTabRailSearch();
app._fullRenderSessionTabs();
w.__activation = null;
w.__closed = [];
}, LAYOUT);
});
@@ -201,6 +210,21 @@ describe('vertical rail session search in Chromium', () => {
expect(await paintedRows()).toEqual(['alpha', 'web', 'notes']);
});
it('Escape with text in the box clears it and closes nothing else', async () => {
const search = page.getByRole('searchbox', { name: 'Search sessions' });
await search.fill('review');
expect(await paintedRows()).toEqual(['review']);
await search.press('Escape');
expect(await search.inputValue()).toBe('');
expect(await paintedRows()).toEqual(['alpha', 'web', 'notes']);
expect(await page.evaluate(() => (window as any).__closed)).toEqual([]);
// An empty box leaves Escape to the global handler, which closes as always.
await search.press('Escape');
expect(await page.evaluate(() => (window as any).__closed)).toEqual(['panels', 'help', 'session-manager']);
});
it('walks only the matches with the arrow keys', async () => {
await page.getByRole('searchbox', { name: 'Search sessions' }).fill('a');
// 'a' matches Alpha API, Roadmap, API Review and Dashboard, not Notes.
+74
View File
@@ -532,6 +532,80 @@ describe('the sidebar filter keeps its own matching', () => {
});
});
describe('Escape in the search box', () => {
/**
* The real global key handler from setupEventListeners(), on document in the
* capture phase, with the close methods it calls (settings-ui.js and
* panels-ui.js are not loaded here) as recorders. The box in this harness
* has no inline onkeydown, so only the global handler can clear it.
*/
function installGlobalKeys(app: Record<string, any>) {
const closed: string[] = [];
app.setupColorPicker = () => {};
app.closeAllPanels = () => closed.push('panels');
app.closeHelp = () => closed.push('help');
app.closeSessionManager = () => closed.push('session-manager');
app.closeCommandPalette = () => {};
app.closeShortcutOverlay = () => {};
const add = vi.spyOn(document, 'addEventListener');
app.setupEventListeners();
const listener = add.mock.calls.find(([type]) => type === 'keydown')![1] as EventListener;
add.mockRestore();
return { closed, remove: () => document.removeEventListener('keydown', listener, true) };
}
const escape = () => new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
it('clears a search and closes nothing else, though the global handler runs first', () => {
const app = makeApp();
app._fullRenderSessionTabs();
const keys = installGlobalKeys(app);
try {
input().value = 'review';
app.setTabRailSearch(input().value);
input().focus();
const event = escape();
input().dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(input().value).toBe('');
expect(app._tabRailSearch).toBe('');
expect(visibleRows()).toEqual(['alpha', 'web', 'roadmap', 'review', 'notes']);
expect(keys.closed).toEqual([]);
// The box is empty now: the next Escape is the global handler's again.
input().dispatchEvent(escape());
expect(keys.closed).toEqual(['panels', 'help', 'session-manager']);
} finally {
keys.remove();
}
});
it('only claims an Escape that lands in the box', () => {
const app = makeApp();
app._fullRenderSessionTabs();
const keys = installGlobalKeys(app);
try {
app.setTabRailSearch('review');
document.body.dispatchEvent(escape());
expect(keys.closed).toEqual(['panels', 'help', 'session-manager']);
expect(app._tabRailSearch).toBe('review');
expect(visibleRows()).toEqual(['review']);
} finally {
keys.remove();
}
});
it('leaves an Escape that cancels an IME composition to the IME', () => {
const app = makeApp();
app._fullRenderSessionTabs();
app.setTabRailSearch('review');
const composing = new window.KeyboardEvent('keydown', { key: 'Escape', isComposing: true, cancelable: true });
app.handleTabRailSearchKeydown(composing);
expect(composing.defaultPrevented).toBe(false);
expect(app._tabRailSearch).toBe('review');
});
});
// ─── Markup + zh-CN ───────────────────────────────────────────────────────────
describe('markup and zh-CN', () => {