From d72c1bc84816cd12d0ea2064f061638b019eaacc Mon Sep 17 00:00:00 2001 From: "burrito.space" Date: Fri, 22 May 2026 09:21:47 +0200 Subject: [PATCH] fix(esc): guard escUnhandledPolicy and escGlobalHandler against overlay exit cooldown After dismissing the windows-switcher overlay with ESC, the same ESC keystroke lands on the workspace window (escUnhandledPolicy) or the global shortcut handler (escGlobalHandler) before the cooldown window expires, causing the switcher to re-open in a tight loop. Both call sites now check isWithinOverlayExitCooldown() and skip the cmd:execute:windows publish when within 300ms of exitOverlay. Regression test: izui-state.test.ts test 11 verifies the coordinator signals the cooldown correctly so callers can gate the switcher open. --- apps/desktop/main/izui-state.test.ts | 41 ++++++++++++++++++++++++++++ apps/desktop/main/main.ts | 4 +++ apps/desktop/main/windows.ts | 10 +++++++ 3 files changed, 55 insertions(+) diff --git a/apps/desktop/main/izui-state.test.ts b/apps/desktop/main/izui-state.test.ts index 0629c853..c7e1db4d 100644 --- a/apps/desktop/main/izui-state.test.ts +++ b/apps/desktop/main/izui-state.test.ts @@ -1267,6 +1267,47 @@ describe('IZUI State Coordinator Tests', () => { assert.ok(coordinator.getSession()); assert.deepStrictEqual(coordinator.getSession()!.windowStack, [5, 6, 100]); }); + + it('11. Overlay exit cooldown blocks immediate re-open: isWithinOverlayExitCooldown is true right after exitOverlay', () => { + // Regression: after dismissing the windows-switcher overlay with ESC, + // the ESC key lands on the workspace window. escUnhandledPolicy returns + // open-switcher for workspace in an active session. Without the cooldown + // check in handleEscapeForWindow the switcher re-opens immediately. + // This test verifies the coordinator signals the cooldown correctly so + // the caller can gate the switcher open. + const winA = createMockWindow(5); + mockWindows.push(winA); + + const coordinator = izuiState.getIzuiCoordinator(); + coordinator.startSession('active'); + coordinator.pushWindow(5); + coordinator.pushWindow(100); + coordinator.setFocusedWindow(5); + + // Before overlay: no cooldown + assert.strictEqual(coordinator.isWithinOverlayExitCooldown(), false, + 'No cooldown before overlay opens'); + + coordinator.enterOverlay(100, [5]); + + // Overlay open: still no exit cooldown (entry cooldown, not exit) + assert.strictEqual(coordinator.isWithinOverlayExitCooldown(), false, + 'No exit cooldown while overlay is open'); + + // Overlay closes via ESC (exitOverlay called by closed-event handler) + coordinator.exitOverlay(); + + // Immediately after: exit cooldown active — workspace ESC must NOT open switcher + assert.strictEqual(coordinator.isWithinOverlayExitCooldown(), true, + 'Exit cooldown must be active right after exitOverlay to block re-open'); + + // With a 0ms custom cooldown the check confirms we are past a zero-width window + assert.strictEqual(coordinator.isWithinOverlayExitCooldown(0), false, + 'isWithinOverlayExitCooldown(0) false — confirms cooldown is time-based'); + + // State is back to active (the switcher should stay closed, not reopen) + assert.strictEqual(coordinator.getState(), 'active'); + }); }); describe('exitOverlayForAppBlur', () => { diff --git a/apps/desktop/main/main.ts b/apps/desktop/main/main.ts index 7e5bfcd8..1e26be0f 100644 --- a/apps/desktop/main/main.ts +++ b/apps/desktop/main/main.ts @@ -395,6 +395,10 @@ export async function initialize(): Promise { // Same pattern Spotlight / Alfred / Raycast use for their hotkeys. let _escGlobalRegistered = false; const escGlobalHandler = (): void => { + if (getIzuiCoordinator().isWithinOverlayExitCooldown()) { + DEBUG && console.log('[esc-fallback] Skipping escGlobalHandler — within overlay exit cooldown'); + return; + } DEBUG && console.log('[esc-fallback] global ESC fired — publishing cmd:execute:windows'); publish(getSystemAddress(), 'cmd:execute:windows', {}); }; diff --git a/apps/desktop/main/windows.ts b/apps/desktop/main/windows.ts index a9281f39..c6216f9b 100644 --- a/apps/desktop/main/windows.ts +++ b/apps/desktop/main/windows.ts @@ -225,6 +225,16 @@ async function handleEscapeForWindow(bw: BrowserWindow, source: 'host' | 'webvie } else { // escPolicy returned 'nothing' — renderer is at root and window won't close. // Consult second-phase policy for unhandled ESC. + // + // Guard: if an overlay just closed (within 300ms), skip the switcher open. + // Without this guard, the ESC that dismissed the overlay lands on the + // workspace window immediately after exitOverlay() runs, and + // escUnhandledPolicy('active', 'workspace') returns 'open-switcher', + // causing the switcher to re-open in a tight loop. + if (coordinator.isWithinOverlayExitCooldown()) { + DEBUG && console.log(`[esc] Skipping escUnhandledPolicy — within overlay exit cooldown`); + return; + } const unhandledAction = escUnhandledPolicy(sessionState, role); DEBUG && console.log(`[esc] escUnhandledPolicy(${sessionState}, ${role}) -> ${unhandledAction}`); if (unhandledAction === 'open-switcher') { -- 2.51.2