From a5bfd480056e93f275e130cbdec4dbfb383df440 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 29 May 2026 21:06:23 -0400 Subject: [PATCH] fix(global-search): close the command palette on Escape deterministically (#1256) When the global command palette was opened with Ctrl+K, pressing Escape sometimes failed to close it. While the palette is open the cross-node stack search streams results in and re-renders the list; an Escape that landed during that re-render churn was intermittently dropped before the dialog dismissed, leaving the palette stuck open. The palette now handles Escape on its search input and closes itself, instead of depending only on the dialog's built-in dismissal. The close is idempotent, so nothing changes when the dialog already dismisses on the same key. Adds a unit test covering the Escape close. --- frontend/src/components/GlobalCommandPalette.tsx | 13 +++++++++++++ .../__tests__/GlobalCommandPalette.test.tsx | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/frontend/src/components/GlobalCommandPalette.tsx b/frontend/src/components/GlobalCommandPalette.tsx index 8bda9cfc..4b8c6cbf 100644 --- a/frontend/src/components/GlobalCommandPalette.tsx +++ b/frontend/src/components/GlobalCommandPalette.tsx @@ -161,6 +161,19 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl placeholder="Search the app..." value={query} onValueChange={setQuery} + // Own the Escape-to-close instead of relying solely on Radix + // Dialog's dismissable layer. While the palette is open the + // cross-node stack search streams results in and re-renders the + // tree; an Escape that lands during that churn was observed to be + // dropped before Radix's layer dismissed it. Closing here via + // handleOpenChange(false) keeps Escape deterministic and is + // idempotent if Radix also dismisses on the same key. + onKeyDown={e => { + if (e.key === 'Escape') { + e.preventDefault(); + handleOpenChange(false); + } + }} /> {showEmptyState && ( diff --git a/frontend/src/components/__tests__/GlobalCommandPalette.test.tsx b/frontend/src/components/__tests__/GlobalCommandPalette.test.tsx index 3c4ee433..3f69f5e5 100644 --- a/frontend/src/components/__tests__/GlobalCommandPalette.test.tsx +++ b/frontend/src/components/__tests__/GlobalCommandPalette.test.tsx @@ -166,6 +166,22 @@ describe('GlobalCommandPalette', () => { }); }); + it('closes the palette when Escape is pressed in the search input', async () => { + renderPalette(); + open(); + const input = screen.getByPlaceholderText('Search the app...'); + fireEvent.keyDown(input, { key: 'Escape' }); + // Behavioral smoke test only: in jsdom Radix's dismissable layer already + // closes a single-layer dialog on Escape, so this cannot isolate the + // palette's own Escape handler from that fallback. The deterministic-close + // regression the handler fixes (Escape dropped during streaming re-renders) + // is guarded by the Playwright command-palette spec. + await waitFor(() => { + const dialog = screen.queryByRole('dialog'); + expect(dialog?.getAttribute('data-state') ?? 'unmounted').not.toBe('open'); + }); + }); + it('disables an offline node row', () => { nodesValue = { nodes: [node(1, 'local'), node(2, 'opsix', 'offline')],