From 195cfdb0e2c50c393c8b765e68aad3d6b6a728f1 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 22 Jul 2026 16:32:58 -0400 Subject: [PATCH 01/13] Allow Command-Escape keybindings --- .../NativeKeybindingCapture.test.ts | 39 ++++++++++++ .../keybindings/NativeKeybindingCapture.ts | 62 +++++++++++++++++++ apps/desktop/src/preload.ts | 8 +++ apps/desktop/src/preview/Manager.test.ts | 22 +++++++ apps/desktop/src/preview/Manager.ts | 9 +++ apps/desktop/src/preview/PickPreload.ts | 9 +++ apps/desktop/src/window/DesktopWindow.test.ts | 50 +++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 44 +++++++++++++ .../KeybindingsSettings.logic.test.ts | 32 ++++++++++ .../settings/KeybindingsSettings.tsx | 10 +-- 10 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts create mode 100644 apps/desktop/src/keybindings/NativeKeybindingCapture.ts diff --git a/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts b/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts new file mode 100644 index 000000000000..b4c26252770a --- /dev/null +++ b/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { nativeKeybindingCaptureInput } from "./NativeKeybindingCapture.ts"; + +describe("nativeKeybindingCaptureInput", () => { + it("forwards Command-Escape with its modifiers", () => { + expect( + nativeKeybindingCaptureInput({ + type: "keyDown", + key: "Escape", + meta: true, + control: false, + alt: false, + shift: true, + }), + ).toEqual({ + key: "Escape", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: true, + }); + }); + + it.each([ + ["bare Escape", { type: "keyDown", key: "Escape", meta: false }], + ["Command keyup", { type: "keyUp", key: "Escape", meta: true }], + ["another Command shortcut", { type: "keyDown", key: "k", meta: true }], + ])("ignores %s", (_name, input) => { + expect( + nativeKeybindingCaptureInput({ + control: false, + alt: false, + shift: false, + ...input, + }), + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/keybindings/NativeKeybindingCapture.ts b/apps/desktop/src/keybindings/NativeKeybindingCapture.ts new file mode 100644 index 000000000000..b920c4a15e05 --- /dev/null +++ b/apps/desktop/src/keybindings/NativeKeybindingCapture.ts @@ -0,0 +1,62 @@ +import type { Input } from "electron"; + +export const NATIVE_KEYBINDING_CAPTURE_CHANNEL = "desktop:native-keybinding-capture"; + +export interface NativeKeybindingCaptureInput { + readonly key: "Escape"; + readonly metaKey: true; + readonly ctrlKey: boolean; + readonly altKey: boolean; + readonly shiftKey: boolean; +} + +export function nativeKeybindingCaptureInput( + input: Pick, +): NativeKeybindingCaptureInput | null { + const key = input.key.toLowerCase(); + if (input.type !== "keyDown" || (key !== "escape" && key !== "esc") || !input.meta) { + return null; + } + + return { + key: "Escape", + metaKey: true, + ctrlKey: input.control, + altKey: input.alt, + shiftKey: input.shift, + }; +} + +export function dispatchNativeKeybindingCaptureInput(input: unknown): void { + if ( + typeof input !== "object" || + input === null || + !("key" in input) || + input.key !== "Escape" || + !("metaKey" in input) || + input.metaKey !== true || + !("ctrlKey" in input) || + typeof input.ctrlKey !== "boolean" || + !("altKey" in input) || + typeof input.altKey !== "boolean" || + !("shiftKey" in input) || + typeof input.shiftKey !== "boolean" + ) { + return; + } + + const target = document.activeElement ?? document; + + target.dispatchEvent( + new KeyboardEvent("keydown", { + key: input.key, + code: "Escape", + metaKey: input.metaKey, + ctrlKey: input.ctrlKey, + altKey: input.altKey, + shiftKey: input.shiftKey, + bubbles: true, + cancelable: true, + }), + ); +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2aa345ee5847..1e43469c1712 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -8,9 +8,17 @@ import { exposeClerkBridge } from "@clerk/electron/preload"; import { contextBridge, ipcRenderer } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; +import { + dispatchNativeKeybindingCaptureInput, + NATIVE_KEYBINDING_CAPTURE_CHANNEL, +} from "./keybindings/NativeKeybindingCapture.ts"; exposeClerkBridge({ passkeys: true }); +ipcRenderer.on(NATIVE_KEYBINDING_CAPTURE_CHANNEL, (_event, input: unknown) => { + dispatchNativeKeybindingCaptureInput(input); +}); + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index a6ef30c2742a..5fdb20b306ae 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -18,6 +18,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { NATIVE_KEYBINDING_CAPTURE_CHANNEL } from "../keybindings/NativeKeybindingCapture.ts"; import * as BrowserSession from "./BrowserSession.ts"; import * as PreviewManager from "./Manager.ts"; @@ -371,6 +372,27 @@ describe("PreviewManager", () => { expect(loadURL).toHaveBeenCalledOnce(); expect(loadURL).toHaveBeenCalledWith("http://localhost:3200/"); + + const beforeInputEvent = { preventDefault: vi.fn() }; + listeners.get("before-input-event")?.( + beforeInputEvent as never, + { + type: "keyDown", + key: "Escape", + meta: true, + control: false, + alt: false, + shift: false, + } as never, + ); + expect(webviewSend).toHaveBeenCalledWith(NATIVE_KEYBINDING_CAPTURE_CHANNEL, { + key: "Escape", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }); + expect(beforeInputEvent.preventDefault).toHaveBeenCalledOnce(); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 169fe2992dca..1c464699238f 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -50,6 +50,10 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; +import { + nativeKeybindingCaptureInput, + NATIVE_KEYBINDING_CAPTURE_CHANNEL, +} from "../keybindings/NativeKeybindingCapture.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, @@ -1382,6 +1386,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); return; } + const captureInput = nativeKeybindingCaptureInput(input); + if (captureInput) { + event.preventDefault(); + wc.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, captureInput); + } runFork(forwardShortcut(event, input)); }; yield* Scope.addFinalizer( diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index d03673400ab5..a0938819e392 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1,5 +1,10 @@ // @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. import { ipcRenderer } from "electron"; + +import { + dispatchNativeKeybindingCaptureInput, + NATIVE_KEYBINDING_CAPTURE_CHANNEL, +} from "../keybindings/NativeKeybindingCapture.ts"; import { getElementContext } from "react-grab/primitives"; import type { DesktopPreviewAnnotationTheme, @@ -24,6 +29,10 @@ import { HUMAN_INPUT_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; + +ipcRenderer.on(NATIVE_KEYBINDING_CAPTURE_CHANNEL, (_event, input: unknown) => { + dispatchNativeKeybindingCaptureInput(input); +}); const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const Z_INDEX_OVERLAY = 2147483646; const PRIMARY = "var(--t3-primary)"; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3aedd2ea6c0e..5d67aff431b4 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -14,8 +14,25 @@ import * as TestClock from "effect/testing/TestClock"; import * as Electron from "electron"; import { vi } from "vite-plus/test"; +const { + globalShortcutIsRegistered, + globalShortcutRegister, + globalShortcutUnregister, + getFocusedWebContents, +} = vi.hoisted(() => ({ + globalShortcutIsRegistered: vi.fn<(accelerator: string) => boolean>(() => false), + globalShortcutRegister: vi.fn<(accelerator: string, callback: () => void) => boolean>(() => true), + globalShortcutUnregister: vi.fn<(accelerator: string) => void>(), + getFocusedWebContents: vi.fn<() => Electron.WebContents | null>(() => null), +})); + vi.mock("electron", async (importOriginal) => ({ ...(await importOriginal()), + globalShortcut: { + isRegistered: globalShortcutIsRegistered, + register: globalShortcutRegister, + unregister: globalShortcutUnregister, + }, session: { fromPartition: vi.fn(() => ({ getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/1.2.3"), @@ -30,6 +47,9 @@ vi.mock("electron", async (importOriginal) => ({ }, ]), }, + webContents: { + getFocusedWebContents, + }, })); import * as DesktopAssets from "../app/DesktopAssets.ts"; @@ -42,6 +62,7 @@ import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; +import { NATIVE_KEYBINDING_CAPTURE_CHANNEL } from "../keybindings/NativeKeybindingCapture.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -434,6 +455,35 @@ describe("DesktopWindow", () => { assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); + + const focusedWebContents = { + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }; + getFocusedWebContents.mockReturnValue( + focusedWebContents as unknown as Electron.WebContents, + ); + fakeWindow.windowListeners.get("focus")?.(); + const registration = globalShortcutRegister.mock.calls.at(-1); + if (!registration) { + assert.fail("expected Command-Escape to be registered"); + } + assert.equal(registration[0], "Command+Escape"); + registration[1](); + assert.deepEqual(focusedWebContents.send.mock.calls, [ + [ + NATIVE_KEYBINDING_CAPTURE_CHANNEL, + { + key: "Escape", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + ], + ]); + fakeWindow.windowListeners.get("blur")?.(); + assert.deepEqual(globalShortcutUnregister.mock.calls.at(-1), ["Command+Escape"]); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index bf8c681448fe..d9d14cfbaef3 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -19,6 +19,11 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import { + type NativeKeybindingCaptureInput, + nativeKeybindingCaptureInput, + NATIVE_KEYBINDING_CAPTURE_CHANNEL, +} from "../keybindings/NativeKeybindingCapture.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -32,6 +37,14 @@ const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; const RENDERER_RECOVERY_RELOAD_DELAY_MS = 500; const RENDERER_RECOVERY_MAX_ATTEMPTS = 3; const RENDERER_RECOVERY_WINDOW_MS = 60_000; +const MACOS_MOD_ESCAPE_ACCELERATOR = "Command+Escape"; +const MACOS_MOD_ESCAPE_INPUT: NativeKeybindingCaptureInput = { + key: "Escape", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, +}; const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ -2, // ERR_FAILED -7, // ERR_TIMED_OUT @@ -357,6 +370,36 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); } + let unregisterNativeModEscape = () => {}; + if (environment.platform === "darwin") { + const registerNativeModEscape = () => { + if (Electron.globalShortcut.isRegistered(MACOS_MOD_ESCAPE_ACCELERATOR)) { + return; + } + const registered = Electron.globalShortcut.register(MACOS_MOD_ESCAPE_ACCELERATOR, () => { + const focusedWebContents = Electron.webContents.getFocusedWebContents(); + if (focusedWebContents && !focusedWebContents.isDestroyed()) { + focusedWebContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, MACOS_MOD_ESCAPE_INPUT); + } + }); + if (!registered) { + void runPromise(logWindowWarning("failed to register Command-Escape shortcut")); + } + }; + unregisterNativeModEscape = () => { + Electron.globalShortcut.unregister(MACOS_MOD_ESCAPE_ACCELERATOR); + }; + window.on("focus", registerNativeModEscape); + window.on("blur", unregisterNativeModEscape); + } else { + window.webContents.on("before-input-event", (event, input) => { + const captureInput = nativeKeybindingCaptureInput(input); + if (captureInput) { + event.preventDefault(); + window.webContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, captureInput); + } + }); + } let boundsPersistFiber: Fiber.Fiber | undefined; let pendingBoundsPersistFiber: Fiber.Fiber | undefined; let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; @@ -702,6 +745,7 @@ export const make = Effect.gen(function* () { } window.on("closed", () => { + unregisterNativeModEscape(); clearDevelopmentLoadRetry(); clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 22a91b7c1504..83ba378b5ced 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -64,6 +64,38 @@ describe("KeybindingsSettings.logic", () => { ).toBe("mod+shift+k"); }); + it.each([ + ["MacIntel", { metaKey: true, ctrlKey: false }], + ["Win32", { metaKey: false, ctrlKey: true }], + ])("captures modified Escape as mod+esc on %s", (platform, modifiers) => { + expect( + keybindingFromKeyboardEvent( + { + key: "Escape", + ...modifiers, + altKey: false, + shiftKey: false, + }, + platform, + ), + ).toBe("mod+esc"); + }); + + it("leaves unmodified Escape available to cancel keybinding capture", () => { + expect( + keybindingFromKeyboardEvent( + { + key: "Escape", + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + "MacIntel", + ), + ).toBeNull(); + }); + it("serializes shortcuts and when expressions for upserts", () => { expect( shortcutToKeybindingInput({ diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index edbd3f36a0c4..84205e889027 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -804,11 +804,11 @@ function KeybindingTableRow({ const captureKeybinding = (event: KeyboardEvent) => { if (event.key === "Tab") return; event.preventDefault(); - if (event.key === "Escape") { + const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); + if (!next && event.key === "Escape") { setDraft({ keyDraft: row.key, isRecording: false }); return; } - const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); if (!next) return; setDraft({ keyDraft: next, isRecording: false }); }; @@ -847,8 +847,8 @@ function KeybindingTableRow({ ) : ( ) => { if (event.key === "Tab") return; event.preventDefault(); - if (event.key === "Escape") { + const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); + if (!next && event.key === "Escape") { setDraft({ keyDraft: "", isRecording: false }); return; } - const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); if (!next) return; setDraft({ keyDraft: next, isRecording: false }); }; From 3b8c08349b930bbcd7425d576c9712dca07fec09 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 12:02:31 -0400 Subject: [PATCH 02/13] Fix native Escape shortcut routing --- .../NativeKeybindingCapture.test.ts | 71 ++++++++++++---- .../keybindings/NativeKeybindingCapture.ts | 11 ++- apps/desktop/src/preview/Manager.test.ts | 13 ++- apps/desktop/src/preview/Manager.ts | 15 +++- apps/desktop/src/preview/PickPreload.ts | 7 -- apps/desktop/src/window/DesktopWindow.test.ts | 66 ++++++++------- apps/desktop/src/window/DesktopWindow.ts | 83 ++++++++++++++----- .../src/components/chat/ComposerStashMenu.tsx | 3 +- .../components/chat/ExpandedImageDialog.tsx | 3 +- .../components/chat/ModelPickerContent.tsx | 3 +- .../src/components/files/FileBrowserPanel.tsx | 3 +- .../components/files/fileEditorDismissal.ts | 4 +- .../components/preview/PreviewChromeRow.tsx | 3 +- .../settings/KeybindingsSettings.tsx | 4 +- apps/web/src/contextMenuFallback.ts | 3 +- apps/web/src/keybindings.test.ts | 11 +++ apps/web/src/keybindings.ts | 8 ++ apps/web/src/routes/_chat.tsx | 4 +- apps/web/src/routes/settings.tsx | 3 +- 19 files changed, 226 insertions(+), 92 deletions(-) diff --git a/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts b/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts index b4c26252770a..c7af4d11e7a2 100644 --- a/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts +++ b/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts @@ -5,14 +5,17 @@ import { nativeKeybindingCaptureInput } from "./NativeKeybindingCapture.ts"; describe("nativeKeybindingCaptureInput", () => { it("forwards Command-Escape with its modifiers", () => { expect( - nativeKeybindingCaptureInput({ - type: "keyDown", - key: "Escape", - meta: true, - control: false, - alt: false, - shift: true, - }), + nativeKeybindingCaptureInput( + { + type: "keyDown", + key: "Escape", + meta: true, + control: false, + alt: false, + shift: true, + }, + "darwin", + ), ).toEqual({ key: "Escape", metaKey: true, @@ -22,18 +25,50 @@ describe("nativeKeybindingCaptureInput", () => { }); }); + it("forwards Control-Escape as mod+esc on non-macOS platforms", () => { + expect( + nativeKeybindingCaptureInput( + { + type: "keyDown", + key: "Escape", + meta: false, + control: true, + alt: true, + shift: false, + }, + "win32", + ), + ).toEqual({ + key: "Escape", + metaKey: false, + ctrlKey: true, + altKey: true, + shiftKey: false, + }); + }); + it.each([ - ["bare Escape", { type: "keyDown", key: "Escape", meta: false }], - ["Command keyup", { type: "keyUp", key: "Escape", meta: true }], - ["another Command shortcut", { type: "keyDown", key: "k", meta: true }], - ])("ignores %s", (_name, input) => { + ["bare Escape", { type: "keyDown", key: "Escape", meta: false }, "darwin"], + ["Command keyup", { type: "keyUp", key: "Escape", meta: true }, "darwin"], + ["another Command shortcut", { type: "keyDown", key: "k", meta: true }, "darwin"], + ["Meta-Escape on Windows", { type: "keyDown", key: "Escape", meta: true }, "win32"], + ] satisfies ReadonlyArray< + readonly [ + string, + { readonly type: string; readonly key: string; readonly meta: boolean }, + NodeJS.Platform, + ] + >)("ignores %s", (_name, input, platform) => { expect( - nativeKeybindingCaptureInput({ - control: false, - alt: false, - shift: false, - ...input, - }), + nativeKeybindingCaptureInput( + { + control: false, + alt: false, + shift: false, + ...input, + }, + platform, + ), ).toBeNull(); }); }); diff --git a/apps/desktop/src/keybindings/NativeKeybindingCapture.ts b/apps/desktop/src/keybindings/NativeKeybindingCapture.ts index b920c4a15e05..6a773f72952f 100644 --- a/apps/desktop/src/keybindings/NativeKeybindingCapture.ts +++ b/apps/desktop/src/keybindings/NativeKeybindingCapture.ts @@ -4,7 +4,7 @@ export const NATIVE_KEYBINDING_CAPTURE_CHANNEL = "desktop:native-keybinding-capt export interface NativeKeybindingCaptureInput { readonly key: "Escape"; - readonly metaKey: true; + readonly metaKey: boolean; readonly ctrlKey: boolean; readonly altKey: boolean; readonly shiftKey: boolean; @@ -12,15 +12,17 @@ export interface NativeKeybindingCaptureInput { export function nativeKeybindingCaptureInput( input: Pick, + platform: NodeJS.Platform, ): NativeKeybindingCaptureInput | null { const key = input.key.toLowerCase(); - if (input.type !== "keyDown" || (key !== "escape" && key !== "esc") || !input.meta) { + const modPressed = platform === "darwin" ? input.meta : input.control; + if (input.type !== "keyDown" || (key !== "escape" && key !== "esc") || !modPressed) { return null; } return { key: "Escape", - metaKey: true, + metaKey: input.meta, ctrlKey: input.control, altKey: input.alt, shiftKey: input.shift, @@ -34,9 +36,10 @@ export function dispatchNativeKeybindingCaptureInput(input: unknown): void { !("key" in input) || input.key !== "Escape" || !("metaKey" in input) || - input.metaKey !== true || + typeof input.metaKey !== "boolean" || !("ctrlKey" in input) || typeof input.ctrlKey !== "boolean" || + (!input.metaKey && !input.ctrlKey) || !("altKey" in input) || typeof input.altKey !== "boolean" || !("shiftKey" in input) || diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 5fdb20b306ae..f80344e6c106 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -373,6 +373,12 @@ describe("PreviewManager", () => { expect(loadURL).toHaveBeenCalledOnce(); expect(loadURL).toHaveBeenCalledWith("http://localhost:3200/"); + const mainWindowSend = vi.fn(); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { send: mainWindowSend }, + } as never); const beforeInputEvent = { preventDefault: vi.fn() }; listeners.get("before-input-event")?.( beforeInputEvent as never, @@ -385,13 +391,18 @@ describe("PreviewManager", () => { shift: false, } as never, ); - expect(webviewSend).toHaveBeenCalledWith(NATIVE_KEYBINDING_CAPTURE_CHANNEL, { + yield* Effect.yieldNow; + expect(mainWindowSend).toHaveBeenCalledWith(NATIVE_KEYBINDING_CAPTURE_CHANNEL, { key: "Escape", metaKey: true, ctrlKey: false, altKey: false, shiftKey: false, }); + expect(webviewSend).not.toHaveBeenCalledWith( + NATIVE_KEYBINDING_CAPTURE_CHANNEL, + expect.anything(), + ); expect(beforeInputEvent.preventDefault).toHaveBeenCalledOnce(); }), ), diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 1c464699238f..054fa4d860c5 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -51,6 +51,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; import { + type NativeKeybindingCaptureInput, nativeKeybindingCaptureInput, NATIVE_KEYBINDING_CAPTURE_CHANNEL, } from "../keybindings/NativeKeybindingCapture.ts"; @@ -1376,6 +1377,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ], }); }); + const forwardNativeKeybindingCapture = Effect.fn( + "PreviewManager.forwardNativeKeybindingCapture", + )(function* (input: NativeKeybindingCaptureInput) { + const mainWindow = yield* Ref.get(mainWindowRef); + if (Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) { + return; + } + mainWindow.value.webContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, input); + }); const beforeInput = (event: Electron.Event, input: Electron.Input): void => { if (isPreviewRefreshShortcut(input)) { event.preventDefault(); @@ -1386,10 +1396,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); return; } - const captureInput = nativeKeybindingCaptureInput(input); + const captureInput = nativeKeybindingCaptureInput(input, hostPlatform); if (captureInput) { event.preventDefault(); - wc.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, captureInput); + runFork(forwardNativeKeybindingCapture(captureInput)); + return; } runFork(forwardShortcut(event, input)); }; diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index a0938819e392..fdeccc3fe415 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1,10 +1,6 @@ // @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. import { ipcRenderer } from "electron"; -import { - dispatchNativeKeybindingCaptureInput, - NATIVE_KEYBINDING_CAPTURE_CHANNEL, -} from "../keybindings/NativeKeybindingCapture.ts"; import { getElementContext } from "react-grab/primitives"; import type { DesktopPreviewAnnotationTheme, @@ -30,9 +26,6 @@ import { START_PICK_CHANNEL, } from "./GuestProtocol.ts"; -ipcRenderer.on(NATIVE_KEYBINDING_CAPTURE_CHANNEL, (_event, input: unknown) => { - dispatchNativeKeybindingCaptureInput(input); -}); const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const Z_INDEX_OVERLAY = 2147483646; const PRIMARY = "var(--t3-primary)"; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5d67aff431b4..363e33956877 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -14,17 +14,15 @@ import * as TestClock from "effect/testing/TestClock"; import * as Electron from "electron"; import { vi } from "vite-plus/test"; -const { - globalShortcutIsRegistered, - globalShortcutRegister, - globalShortcutUnregister, - getFocusedWebContents, -} = vi.hoisted(() => ({ - globalShortcutIsRegistered: vi.fn<(accelerator: string) => boolean>(() => false), - globalShortcutRegister: vi.fn<(accelerator: string, callback: () => void) => boolean>(() => true), - globalShortcutUnregister: vi.fn<(accelerator: string) => void>(), - getFocusedWebContents: vi.fn<() => Electron.WebContents | null>(() => null), -})); +const { globalShortcutIsRegistered, globalShortcutRegister, globalShortcutUnregister } = vi.hoisted( + () => ({ + globalShortcutIsRegistered: vi.fn<(accelerator: string) => boolean>(() => false), + globalShortcutRegister: vi.fn<(accelerator: string, callback: () => void) => boolean>( + () => true, + ), + globalShortcutUnregister: vi.fn<(accelerator: string) => void>(), + }), +); vi.mock("electron", async (importOriginal) => ({ ...(await importOriginal()), @@ -47,9 +45,6 @@ vi.mock("electron", async (importOriginal) => ({ }, ]), }, - webContents: { - getFocusedWebContents, - }, })); import * as DesktopAssets from "../app/DesktopAssets.ts"; @@ -456,34 +451,49 @@ describe("DesktopWindow", () => { assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); - const focusedWebContents = { - isDestroyed: vi.fn(() => false), - send: vi.fn(), - }; - getFocusedWebContents.mockReturnValue( - focusedWebContents as unknown as Electron.WebContents, - ); + const registrationStart = globalShortcutRegister.mock.calls.length; fakeWindow.windowListeners.get("focus")?.(); - const registration = globalShortcutRegister.mock.calls.at(-1); + const registrations = globalShortcutRegister.mock.calls.slice(registrationStart); + assert.deepEqual( + registrations.map(([accelerator]) => accelerator), + [ + "Command+Escape", + "Command+Control+Escape", + "Command+Alt+Escape", + "Command+Shift+Escape", + "Command+Control+Alt+Escape", + "Command+Control+Shift+Escape", + "Command+Alt+Shift+Escape", + "Command+Control+Alt+Shift+Escape", + ], + ); + const registration = registrations.find( + ([accelerator]) => accelerator === "Command+Alt+Shift+Escape", + ); if (!registration) { - assert.fail("expected Command-Escape to be registered"); + assert.fail("expected Command-Option-Shift-Escape to be registered"); } - assert.equal(registration[0], "Command+Escape"); registration[1](); - assert.deepEqual(focusedWebContents.send.mock.calls, [ + assert.deepEqual(fakeWindow.send.mock.calls, [ [ NATIVE_KEYBINDING_CAPTURE_CHANNEL, { key: "Escape", metaKey: true, ctrlKey: false, - altKey: false, - shiftKey: false, + altKey: true, + shiftKey: true, }, ], ]); + const unregisterStart = globalShortcutUnregister.mock.calls.length; fakeWindow.windowListeners.get("blur")?.(); - assert.deepEqual(globalShortcutUnregister.mock.calls.at(-1), ["Command+Escape"]); + assert.deepEqual( + globalShortcutUnregister.mock.calls + .slice(unregisterStart) + .map(([accelerator]) => accelerator), + registrations.map(([accelerator]) => accelerator), + ); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index d9d14cfbaef3..5a0d05cca7b2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -37,14 +37,43 @@ const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; const RENDERER_RECOVERY_RELOAD_DELAY_MS = 500; const RENDERER_RECOVERY_MAX_ATTEMPTS = 3; const RENDERER_RECOVERY_WINDOW_MS = 60_000; -const MACOS_MOD_ESCAPE_ACCELERATOR = "Command+Escape"; -const MACOS_MOD_ESCAPE_INPUT: NativeKeybindingCaptureInput = { - key: "Escape", - metaKey: true, - ctrlKey: false, - altKey: false, - shiftKey: false, -}; +const MACOS_MOD_ESCAPE_SHORTCUTS: ReadonlyArray<{ + readonly accelerator: string; + readonly input: NativeKeybindingCaptureInput; +}> = [ + { + accelerator: "Command+Escape", + input: { key: "Escape", metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, + }, + { + accelerator: "Command+Control+Escape", + input: { key: "Escape", metaKey: true, ctrlKey: true, altKey: false, shiftKey: false }, + }, + { + accelerator: "Command+Alt+Escape", + input: { key: "Escape", metaKey: true, ctrlKey: false, altKey: true, shiftKey: false }, + }, + { + accelerator: "Command+Shift+Escape", + input: { key: "Escape", metaKey: true, ctrlKey: false, altKey: false, shiftKey: true }, + }, + { + accelerator: "Command+Control+Alt+Escape", + input: { key: "Escape", metaKey: true, ctrlKey: true, altKey: true, shiftKey: false }, + }, + { + accelerator: "Command+Control+Shift+Escape", + input: { key: "Escape", metaKey: true, ctrlKey: true, altKey: false, shiftKey: true }, + }, + { + accelerator: "Command+Alt+Shift+Escape", + input: { key: "Escape", metaKey: true, ctrlKey: false, altKey: true, shiftKey: true }, + }, + { + accelerator: "Command+Control+Alt+Shift+Escape", + input: { key: "Escape", metaKey: true, ctrlKey: true, altKey: true, shiftKey: true }, + }, +]; const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ -2, // ERR_FAILED -7, // ERR_TIMED_OUT @@ -372,28 +401,42 @@ export const make = Effect.gen(function* () { } let unregisterNativeModEscape = () => {}; if (environment.platform === "darwin") { + const registeredAccelerators = new Set(); const registerNativeModEscape = () => { - if (Electron.globalShortcut.isRegistered(MACOS_MOD_ESCAPE_ACCELERATOR)) { - return; - } - const registered = Electron.globalShortcut.register(MACOS_MOD_ESCAPE_ACCELERATOR, () => { - const focusedWebContents = Electron.webContents.getFocusedWebContents(); - if (focusedWebContents && !focusedWebContents.isDestroyed()) { - focusedWebContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, MACOS_MOD_ESCAPE_INPUT); + for (const shortcut of MACOS_MOD_ESCAPE_SHORTCUTS) { + if ( + registeredAccelerators.has(shortcut.accelerator) || + Electron.globalShortcut.isRegistered(shortcut.accelerator) + ) { + continue; + } + const registered = Electron.globalShortcut.register(shortcut.accelerator, () => { + if (!window.isDestroyed()) { + window.webContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, shortcut.input); + } + }); + if (registered) { + registeredAccelerators.add(shortcut.accelerator); + } else { + void runPromise( + logWindowWarning("failed to register native Escape shortcut", { + accelerator: shortcut.accelerator, + }), + ); } - }); - if (!registered) { - void runPromise(logWindowWarning("failed to register Command-Escape shortcut")); } }; unregisterNativeModEscape = () => { - Electron.globalShortcut.unregister(MACOS_MOD_ESCAPE_ACCELERATOR); + for (const accelerator of registeredAccelerators) { + Electron.globalShortcut.unregister(accelerator); + } + registeredAccelerators.clear(); }; window.on("focus", registerNativeModEscape); window.on("blur", unregisterNativeModEscape); } else { window.webContents.on("before-input-event", (event, input) => { - const captureInput = nativeKeybindingCaptureInput(input); + const captureInput = nativeKeybindingCaptureInput(input, environment.platform); if (captureInput) { event.preventDefault(); window.webContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, captureInput); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 9e9238515332..9bfe67848b96 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -1,6 +1,7 @@ import { BookmarkIcon, XIcon } from "lucide-react"; import { memo, useEffect, useState } from "react"; +import { isUnmodifiedEscape } from "../../keybindings"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "~/lib/utils"; import { type PromptStashEntry } from "../../promptStashStore"; @@ -49,7 +50,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { useEffect(() => { const handler = (event: KeyboardEvent) => { - if (event.key === "Escape") { + if (isUnmodifiedEscape(event)) { event.preventDefault(); event.stopPropagation(); onClose(); diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index fd14c68b0c4d..aaed425fa560 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useState } from "react"; import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; +import { isUnmodifiedEscape } from "../../keybindings"; import { Button } from "../ui/button"; import type { ExpandedImagePreview } from "./ExpandedImagePreview"; @@ -21,7 +22,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.key === "Escape") { + if (isUnmodifiedEscape(event)) { event.preventDefault(); event.stopPropagation(); onClose(); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 7c86ec630141..b17587e33f2f 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -28,6 +28,7 @@ import { ModelEsque } from "./providerIconUtils"; import { modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, + isUnmodifiedEscape, resolveShortcutCommand, shortcutLabelForCommand, } from "../../keybindings"; @@ -673,7 +674,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={(e) => { - if (e.key === "Escape") { + if (isUnmodifiedEscape(e)) { e.preventDefault(); e.stopPropagation(); props.onRequestClose?.(); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index ff658693a70c..681bfa44d90c 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -14,6 +14,7 @@ import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { isUnmodifiedEscape } from "~/keybindings"; import { useTheme } from "~/hooks/useTheme"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -89,7 +90,7 @@ function FileSearchField(props: { spellCheck={false} onChange={(event) => props.onValueChange(event.target.value)} onKeyDown={(event) => { - if (event.key !== "Escape") return; + if (!isUnmodifiedEscape(event)) return; props.onClose(); event.currentTarget.blur(); }} diff --git a/apps/web/src/components/files/fileEditorDismissal.ts b/apps/web/src/components/files/fileEditorDismissal.ts index 1291cfe9aeea..e91361c503f9 100644 --- a/apps/web/src/components/files/fileEditorDismissal.ts +++ b/apps/web/src/components/files/fileEditorDismissal.ts @@ -1,3 +1,5 @@ +import { isUnmodifiedEscape } from "~/keybindings"; + interface FileEditorDismissalOptions { root: HTMLElement; editor: { @@ -38,7 +40,7 @@ export function installFileEditorDismissal({ dismissFileEditorInteraction({ root, editor, onDismiss }); }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape" || isBlocked() || !isFileEditorFocused(root)) return; + if (!isUnmodifiedEscape(event) || isBlocked() || !isFileEditorFocused(root)) return; event.preventDefault(); event.stopImmediatePropagation(); dismissFileEditorInteraction({ root, editor, onDismiss }); diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 958b30a47978..213b33724aed 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -19,6 +19,7 @@ import { import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { isUnmodifiedEscape } from "~/keybindings"; import { cn } from "~/lib/utils"; interface Props { @@ -187,7 +188,7 @@ export function PreviewChromeRow({ }} onKeyDown={(event) => { if (event.key === "Enter") submit(event); - if (event.key === "Escape") { + if (isUnmodifiedEscape(event)) { event.preventDefault(); setDraft(url); inputRef.current?.blur(); diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 84205e889027..c4ae4fa2c31a 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -35,7 +35,7 @@ import { import { isElectron } from "../../env"; import { useOpenInPreferredEditor } from "../../editorPreferences"; -import { formatShortcutLabel } from "../../keybindings"; +import { formatShortcutLabel, isUnmodifiedEscape } from "../../keybindings"; import { cn } from "../../lib/utils"; import { primaryServerAvailableEditorsAtom, @@ -154,7 +154,7 @@ function ExpandableHeaderSearch({ if (query.length === 0) onOpenChange(false); }} onKeyDown={(event) => { - if (event.key === "Escape") { + if (isUnmodifiedEscape(event)) { event.preventDefault(); onChange(""); onOpenChange(false); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 50f4340e22dc..ee9e1794154e 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -1,4 +1,5 @@ import type { ContextMenuItem } from "@t3tools/contracts"; +import { isUnmodifiedEscape } from "./keybindings"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -129,7 +130,7 @@ export function showContextMenuFallback( }; const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { + if (isUnmodifiedEscape(event)) { event.preventDefault(); cleanup(null); } diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 11aa97dc8e86..f9de5f5236de 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -14,6 +14,7 @@ import { modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, isOpenFavoriteEditorShortcut, + isUnmodifiedEscape, isTerminalClearShortcut, isTerminalCloseShortcut, isTerminalNewShortcut, @@ -43,6 +44,16 @@ function event(overrides: Partial = {}): ShortcutEventLike { }; } +describe("isUnmodifiedEscape", () => { + it("matches bare Escape but not modified Escape", () => { + assert.isTrue(isUnmodifiedEscape(event({ key: "Escape" }))); + assert.isFalse(isUnmodifiedEscape(event({ key: "Escape", metaKey: true }))); + assert.isFalse(isUnmodifiedEscape(event({ key: "Escape", ctrlKey: true }))); + assert.isFalse(isUnmodifiedEscape(event({ key: "Escape", altKey: true }))); + assert.isFalse(isUnmodifiedEscape(event({ key: "Escape", shiftKey: true }))); + }); +}); + function modShortcut( key: string, overrides: Partial> = {}, diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 9d6109a77806..bd7f49cdbc97 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -27,6 +27,14 @@ export interface ShortcutModifierStateLike { altKey: boolean; } +export function isUnmodifiedEscape( + event: Pick, +): boolean { + return ( + event.key === "Escape" && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey + ); +} + export interface ShortcutMatchContext { terminalFocus: boolean; terminalOpen: boolean; diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index e084e22c2cbb..f8b2090db67e 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -14,7 +14,7 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { resolveShortcutCommand } from "../keybindings"; +import { isUnmodifiedEscape, resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; @@ -71,7 +71,7 @@ function ChatRouteGlobalShortcuts() { return; } - if (event.key === "Escape" && selectedThreadKeysSize > 0) { + if (isUnmodifiedEscape(event) && selectedThreadKeysSize > 0) { event.preventDefault(); clearSelection(); return; diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index f14793ba5446..60397c92fed6 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -14,6 +14,7 @@ import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; import { Button } from "../components/ui/button"; import { SidebarInset } from "../components/ui/sidebar"; import { isElectron } from "../env"; +import { isUnmodifiedEscape } from "../keybindings"; import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; @@ -51,7 +52,7 @@ function SettingsContentLayout() { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (event.key === "Escape") { + if (isUnmodifiedEscape(event)) { event.preventDefault(); const activeElement = document.activeElement; From d7583f52bb101a2a297875fb8bd0a5bdb0547cff Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 10 Aug 2026 15:28:07 -0400 Subject: [PATCH 03/13] fix(desktop): preserve modified Escape shortcuts --- .../NativeKeybindingCapture.test.ts | 50 ++++++++++++++++++- .../keybindings/NativeKeybindingCapture.ts | 3 +- apps/desktop/src/window/DesktopWindow.test.ts | 44 ++++++++++++---- apps/desktop/src/window/DesktopWindow.ts | 39 +++++++++++++-- apps/web/src/components/CommandPalette.tsx | 8 ++- .../src/components/chat/ComposerStashMenu.tsx | 4 +- .../components/chat/ExpandedImageDialog.tsx | 4 +- .../components/chat/ModelPickerContent.tsx | 4 +- .../src/components/files/FileBrowserPanel.tsx | 4 +- .../components/files/fileEditorDismissal.ts | 4 +- .../components/preview/PreviewChromeRow.tsx | 4 +- .../settings/KeybindingsSettings.tsx | 4 +- .../settings/ProjectSettingsPanel.tsx | 4 +- apps/web/src/contextMenuFallback.ts | 4 +- apps/web/src/keybindings.test.ts | 18 ++++--- apps/web/src/keybindings.ts | 8 +-- apps/web/src/routes/_chat.tsx | 4 +- apps/web/src/routes/settings.tsx | 4 +- 18 files changed, 159 insertions(+), 55 deletions(-) diff --git a/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts b/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts index c7af4d11e7a2..a2f484a675db 100644 --- a/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts +++ b/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { nativeKeybindingCaptureInput } from "./NativeKeybindingCapture.ts"; +import { + dispatchNativeKeybindingCaptureInput, + nativeKeybindingCaptureInput, +} from "./NativeKeybindingCapture.ts"; describe("nativeKeybindingCaptureInput", () => { it("forwards Command-Escape with its modifiers", () => { @@ -71,4 +74,47 @@ describe("nativeKeybindingCaptureInput", () => { ), ).toBeNull(); }); + + it("dispatches native shortcuts at the app window unless a keybinding recorder is active", () => { + const appDispatch = vi.fn(() => true); + const activeDispatch = vi.fn(() => true); + const activeElement = { + dispatchEvent: activeDispatch, + hasAttribute: vi.fn(() => false), + }; + vi.stubGlobal("window", { dispatchEvent: appDispatch }); + vi.stubGlobal("document", { activeElement }); + vi.stubGlobal( + "KeyboardEvent", + class { + readonly type: string; + readonly init: KeyboardEventInit; + + constructor(type: string, init: KeyboardEventInit) { + this.type = type; + this.init = init; + } + }, + ); + + const input = { + key: "Escape" as const, + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }; + + try { + dispatchNativeKeybindingCaptureInput(input); + expect(appDispatch).toHaveBeenCalledOnce(); + expect(activeDispatch).not.toHaveBeenCalled(); + + activeElement.hasAttribute.mockReturnValue(true); + dispatchNativeKeybindingCaptureInput(input); + expect(activeDispatch).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllGlobals(); + } + }); }); diff --git a/apps/desktop/src/keybindings/NativeKeybindingCapture.ts b/apps/desktop/src/keybindings/NativeKeybindingCapture.ts index 6a773f72952f..6881730b5940 100644 --- a/apps/desktop/src/keybindings/NativeKeybindingCapture.ts +++ b/apps/desktop/src/keybindings/NativeKeybindingCapture.ts @@ -48,7 +48,8 @@ export function dispatchNativeKeybindingCaptureInput(input: unknown): void { return; } - const target = document.activeElement ?? document; + const activeElement = document.activeElement; + const target = activeElement?.hasAttribute("data-keybinding-capture") ? activeElement : window; target.dispatchEvent( new KeyboardEvent("keydown", { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 363e33956877..06075daaac15 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -14,18 +14,33 @@ import * as TestClock from "effect/testing/TestClock"; import * as Electron from "electron"; import { vi } from "vite-plus/test"; -const { globalShortcutIsRegistered, globalShortcutRegister, globalShortcutUnregister } = vi.hoisted( - () => ({ - globalShortcutIsRegistered: vi.fn<(accelerator: string) => boolean>(() => false), - globalShortcutRegister: vi.fn<(accelerator: string, callback: () => void) => boolean>( - () => true, - ), - globalShortcutUnregister: vi.fn<(accelerator: string) => void>(), - }), -); +const { + appListeners, + browserWindowGetFocusedWindow, + globalShortcutIsRegistered, + globalShortcutRegister, + globalShortcutUnregister, +} = vi.hoisted(() => ({ + appListeners: new Map void>(), + browserWindowGetFocusedWindow: vi.fn<() => Electron.BrowserWindow | null>(() => null), + globalShortcutIsRegistered: vi.fn<(accelerator: string) => boolean>(() => false), + globalShortcutRegister: vi.fn<(accelerator: string, callback: () => void) => boolean>(() => true), + globalShortcutUnregister: vi.fn<(accelerator: string) => void>(), +})); vi.mock("electron", async (importOriginal) => ({ ...(await importOriginal()), + app: { + on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + appListeners.set(eventName, listener); + }), + off: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + if (appListeners.get(eventName) === listener) appListeners.delete(eventName); + }), + }, + BrowserWindow: { + getFocusedWindow: browserWindowGetFocusedWindow, + }, globalShortcut: { isRegistered: globalShortcutIsRegistered, register: globalShortcutRegister, @@ -452,7 +467,7 @@ describe("DesktopWindow", () => { assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); const registrationStart = globalShortcutRegister.mock.calls.length; - fakeWindow.windowListeners.get("focus")?.(); + appListeners.get("browser-window-focus")?.(); const registrations = globalShortcutRegister.mock.calls.slice(registrationStart); assert.deepEqual( registrations.map(([accelerator]) => accelerator), @@ -487,7 +502,14 @@ describe("DesktopWindow", () => { ], ]); const unregisterStart = globalShortcutUnregister.mock.calls.length; - fakeWindow.windowListeners.get("blur")?.(); + browserWindowGetFocusedWindow.mockReturnValue({} as Electron.BrowserWindow); + appListeners.get("browser-window-blur")?.(); + yield* TestClock.adjust(1); + assert.equal(globalShortcutUnregister.mock.calls.length, unregisterStart); + + browserWindowGetFocusedWindow.mockReturnValue(null); + appListeners.get("browser-window-blur")?.(); + yield* TestClock.adjust(1); assert.deepEqual( globalShortcutUnregister.mock.calls .slice(unregisterStart) diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 5a0d05cca7b2..4314f785258d 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -399,10 +399,18 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); } - let unregisterNativeModEscape = () => {}; + let disposeNativeModEscape = () => {}; if (environment.platform === "darwin") { const registeredAccelerators = new Set(); + let blurFiber: Fiber.Fiber | undefined; + const cancelPendingBlur = () => { + if (blurFiber === undefined) return; + const fiber = blurFiber; + blurFiber = undefined; + runFork(Fiber.interrupt(fiber)); + }; const registerNativeModEscape = () => { + cancelPendingBlur(); for (const shortcut of MACOS_MOD_ESCAPE_SHORTCUTS) { if ( registeredAccelerators.has(shortcut.accelerator) || @@ -426,14 +434,35 @@ export const make = Effect.gen(function* () { } } }; - unregisterNativeModEscape = () => { + const unregisterNativeModEscape = () => { for (const accelerator of registeredAccelerators) { Electron.globalShortcut.unregister(accelerator); } registeredAccelerators.clear(); }; - window.on("focus", registerNativeModEscape); - window.on("blur", unregisterNativeModEscape); + const handleBrowserWindowBlur = () => { + cancelPendingBlur(); + blurFiber = runFork( + Effect.sleep(1).pipe( + Effect.andThen( + Effect.sync(() => { + blurFiber = undefined; + if (Electron.BrowserWindow.getFocusedWindow() === null) { + unregisterNativeModEscape(); + } + }), + ), + ), + ); + }; + Electron.app.on("browser-window-focus", registerNativeModEscape); + Electron.app.on("browser-window-blur", handleBrowserWindowBlur); + disposeNativeModEscape = () => { + cancelPendingBlur(); + Electron.app.off("browser-window-focus", registerNativeModEscape); + Electron.app.off("browser-window-blur", handleBrowserWindowBlur); + unregisterNativeModEscape(); + }; } else { window.webContents.on("before-input-event", (event, input) => { const captureInput = nativeKeybindingCaptureInput(input, environment.platform); @@ -788,7 +817,7 @@ export const make = Effect.gen(function* () { } window.on("closed", () => { - unregisterNativeModEscape(); + disposeNativeModEscape(); clearDevelopmentLoadRetry(); clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ad9099629681..27a504ea8a64 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -127,7 +127,11 @@ import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { resolveDefaultProviderModelSelection } from "../providerInstances"; -import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; +import { + isEscapeDismissal, + resolveShortcutCommand, + threadJumpIndexFromCommand, +} from "../keybindings"; import { CommandDialog, CommandDialogPopup } from "./ui/command"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; @@ -411,7 +415,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { useEffect(() => { if (!state.open || state.mode === "command") return; const onEscapeKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.isComposing || event.key !== "Escape") return; + if (event.isComposing || !isEscapeDismissal(event)) return; event.preventDefault(); event.stopPropagation(); toggleMode("command"); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 9bfe67848b96..7ee64380858d 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -1,7 +1,7 @@ import { BookmarkIcon, XIcon } from "lucide-react"; import { memo, useEffect, useState } from "react"; -import { isUnmodifiedEscape } from "../../keybindings"; +import { isEscapeDismissal } from "../../keybindings"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "~/lib/utils"; import { type PromptStashEntry } from "../../promptStashStore"; @@ -50,7 +50,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { useEffect(() => { const handler = (event: KeyboardEvent) => { - if (isUnmodifiedEscape(event)) { + if (isEscapeDismissal(event)) { event.preventDefault(); event.stopPropagation(); onClose(); diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index aaed425fa560..26e0f726ee2c 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useEffect, useState } from "react"; import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; -import { isUnmodifiedEscape } from "../../keybindings"; +import { isEscapeDismissal } from "../../keybindings"; import { Button } from "../ui/button"; import type { ExpandedImagePreview } from "./ExpandedImagePreview"; @@ -22,7 +22,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (isUnmodifiedEscape(event)) { + if (isEscapeDismissal(event)) { event.preventDefault(); event.stopPropagation(); onClose(); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index b17587e33f2f..5b15174e7426 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -28,7 +28,7 @@ import { ModelEsque } from "./providerIconUtils"; import { modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, - isUnmodifiedEscape, + isEscapeDismissal, resolveShortcutCommand, shortcutLabelForCommand, } from "../../keybindings"; @@ -674,7 +674,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={(e) => { - if (isUnmodifiedEscape(e)) { + if (isEscapeDismissal(e)) { e.preventDefault(); e.stopPropagation(); props.onRequestClose?.(); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 681bfa44d90c..d5e8fe4e2b73 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -14,7 +14,7 @@ import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { isUnmodifiedEscape } from "~/keybindings"; +import { isEscapeDismissal } from "~/keybindings"; import { useTheme } from "~/hooks/useTheme"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -90,7 +90,7 @@ function FileSearchField(props: { spellCheck={false} onChange={(event) => props.onValueChange(event.target.value)} onKeyDown={(event) => { - if (!isUnmodifiedEscape(event)) return; + if (!isEscapeDismissal(event)) return; props.onClose(); event.currentTarget.blur(); }} diff --git a/apps/web/src/components/files/fileEditorDismissal.ts b/apps/web/src/components/files/fileEditorDismissal.ts index e91361c503f9..e117fd942aa3 100644 --- a/apps/web/src/components/files/fileEditorDismissal.ts +++ b/apps/web/src/components/files/fileEditorDismissal.ts @@ -1,4 +1,4 @@ -import { isUnmodifiedEscape } from "~/keybindings"; +import { isEscapeDismissal } from "~/keybindings"; interface FileEditorDismissalOptions { root: HTMLElement; @@ -40,7 +40,7 @@ export function installFileEditorDismissal({ dismissFileEditorInteraction({ root, editor, onDismiss }); }; const handleKeyDown = (event: KeyboardEvent) => { - if (!isUnmodifiedEscape(event) || isBlocked() || !isFileEditorFocused(root)) return; + if (!isEscapeDismissal(event) || isBlocked() || !isFileEditorFocused(root)) return; event.preventDefault(); event.stopImmediatePropagation(); dismissFileEditorInteraction({ root, editor, onDismiss }); diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 213b33724aed..4a4eb708149e 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -19,7 +19,7 @@ import { import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -import { isUnmodifiedEscape } from "~/keybindings"; +import { isEscapeDismissal } from "~/keybindings"; import { cn } from "~/lib/utils"; interface Props { @@ -188,7 +188,7 @@ export function PreviewChromeRow({ }} onKeyDown={(event) => { if (event.key === "Enter") submit(event); - if (isUnmodifiedEscape(event)) { + if (isEscapeDismissal(event)) { event.preventDefault(); setDraft(url); inputRef.current?.blur(); diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index c4ae4fa2c31a..2897651fa1b0 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -35,7 +35,7 @@ import { import { isElectron } from "../../env"; import { useOpenInPreferredEditor } from "../../editorPreferences"; -import { formatShortcutLabel, isUnmodifiedEscape } from "../../keybindings"; +import { formatShortcutLabel, isEscapeDismissal } from "../../keybindings"; import { cn } from "../../lib/utils"; import { primaryServerAvailableEditorsAtom, @@ -154,7 +154,7 @@ function ExpandableHeaderSearch({ if (query.length === 0) onOpenChange(false); }} onKeyDown={(event) => { - if (isUnmodifiedEscape(event)) { + if (isEscapeDismissal(event)) { event.preventDefault(); onChange(""); onOpenChange(false); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 50cf9c318040..6a26b54def43 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -44,7 +44,7 @@ import { } from "../../hooks/useSettings"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; -import { shortcutLabelForCommand } from "../../keybindings"; +import { isEscapeDismissal, shortcutLabelForCommand } from "../../keybindings"; import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; import { readLocalApi } from "../../localApi"; import { @@ -159,7 +159,7 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (event.key !== "Escape") return; + if (!isEscapeDismissal(event)) return; event.preventDefault(); const activeElement = document.activeElement; if (activeElement instanceof HTMLElement) { diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index ee9e1794154e..197bcad28c07 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -1,5 +1,5 @@ import type { ContextMenuItem } from "@t3tools/contracts"; -import { isUnmodifiedEscape } from "./keybindings"; +import { isEscapeDismissal } from "./keybindings"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -130,7 +130,7 @@ export function showContextMenuFallback( }; const onKeyDown = (event: KeyboardEvent) => { - if (isUnmodifiedEscape(event)) { + if (isEscapeDismissal(event)) { event.preventDefault(); cleanup(null); } diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index f9de5f5236de..7c7dc82e4d80 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -14,7 +14,7 @@ import { modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, isOpenFavoriteEditorShortcut, - isUnmodifiedEscape, + isEscapeDismissal, isTerminalClearShortcut, isTerminalCloseShortcut, isTerminalNewShortcut, @@ -44,13 +44,15 @@ function event(overrides: Partial = {}): ShortcutEventLike { }; } -describe("isUnmodifiedEscape", () => { - it("matches bare Escape but not modified Escape", () => { - assert.isTrue(isUnmodifiedEscape(event({ key: "Escape" }))); - assert.isFalse(isUnmodifiedEscape(event({ key: "Escape", metaKey: true }))); - assert.isFalse(isUnmodifiedEscape(event({ key: "Escape", ctrlKey: true }))); - assert.isFalse(isUnmodifiedEscape(event({ key: "Escape", altKey: true }))); - assert.isFalse(isUnmodifiedEscape(event({ key: "Escape", shiftKey: true }))); +describe("isEscapeDismissal", () => { + it("preserves ordinary Escape dismissal while reserving mod+Escape", () => { + assert.isTrue(isEscapeDismissal(event({ key: "Escape" }), "MacIntel")); + assert.isFalse(isEscapeDismissal(event({ key: "Escape", metaKey: true }), "MacIntel")); + assert.isTrue(isEscapeDismissal(event({ key: "Escape", ctrlKey: true }), "MacIntel")); + assert.isTrue(isEscapeDismissal(event({ key: "Escape", altKey: true }), "MacIntel")); + assert.isTrue(isEscapeDismissal(event({ key: "Escape", shiftKey: true }), "MacIntel")); + assert.isFalse(isEscapeDismissal(event({ key: "Escape", ctrlKey: true }), "Win32")); + assert.isTrue(isEscapeDismissal(event({ key: "Escape", metaKey: true }), "Win32")); }); }); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index bd7f49cdbc97..9a9becb18384 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -27,12 +27,12 @@ export interface ShortcutModifierStateLike { altKey: boolean; } -export function isUnmodifiedEscape( +export function isEscapeDismissal( event: Pick, + platform = navigator.platform, ): boolean { - return ( - event.key === "Escape" && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey - ); + const modPressed = isMacPlatform(platform) ? event.metaKey : event.ctrlKey; + return event.key === "Escape" && !modPressed; } export interface ShortcutMatchContext { diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index f8b2090db67e..850c142fad59 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -14,7 +14,7 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isUnmodifiedEscape, resolveShortcutCommand } from "../keybindings"; +import { isEscapeDismissal, resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; @@ -71,7 +71,7 @@ function ChatRouteGlobalShortcuts() { return; } - if (isUnmodifiedEscape(event) && selectedThreadKeysSize > 0) { + if (isEscapeDismissal(event) && selectedThreadKeysSize > 0) { event.preventDefault(); clearSelection(); return; diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 60397c92fed6..b0597e329378 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -14,7 +14,7 @@ import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; import { Button } from "../components/ui/button"; import { SidebarInset } from "../components/ui/sidebar"; import { isElectron } from "../env"; -import { isUnmodifiedEscape } from "../keybindings"; +import { isEscapeDismissal } from "../keybindings"; import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; @@ -52,7 +52,7 @@ function SettingsContentLayout() { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (isUnmodifiedEscape(event)) { + if (isEscapeDismissal(event)) { event.preventDefault(); const activeElement = document.activeElement; From d83a665b2182efd8fbc07b9a62f83f9e40bc5ffa Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 10 Aug 2026 16:03:24 -0400 Subject: [PATCH 04/13] fix(desktop): harden native Escape capture --- apps/desktop/src/window/DesktopWindow.test.ts | 33 ++++++++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 4 ++- .../src/components/projectScriptEditor.tsx | 1 + 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 06075daaac15..cdb9084ccf44 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -216,6 +216,9 @@ function makeTestLayer(input: { readonly beforeMainWindowBoundsUpdate?: ( bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; + readonly setPreviewMainWindow?: ( + window: Electron.BrowserWindow, + ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; @@ -292,7 +295,7 @@ function makeTestLayer(input: { electronWindowLayer, Layer.mock(PreviewManager.PreviewManager)({ getBrowserSession: () => Effect.succeed({} as Electron.Session), - setMainWindow: () => Effect.void, + setMainWindow: input.setPreviewMainWindow ?? (() => Effect.void), isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), }), @@ -520,6 +523,34 @@ describe("DesktopWindow", () => { }), ); + it.effect("removes native Escape listeners when preview setup fails", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + setPreviewMainWindow: () => + Effect.fail( + new PreviewManager.PreviewOperationError({ + operation: "setMainWindow", + cause: new Error("preview setup failed"), + }), + ), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* Effect.flip(desktopWindow.createMain); + + assert.isFalse(appListeners.has("browser-window-focus")); + assert.isFalse(appListeners.has("browser-window-blur")); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 4314f785258d..2e1c2d90199a 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -559,7 +559,9 @@ export const make = Effect.gen(function* () { ); flushMainWindowBounds = flushBoundsPersist; - yield* previewManager.setMainWindow(window); + yield* previewManager + .setMainWindow(window) + .pipe(Effect.onError(() => Effect.sync(disposeNativeModEscape))); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( typeof params.partition !== "string" || diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 4b728c2e07eb..d8ee23983620 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -315,6 +315,7 @@ export function ProjectScriptEditorDialog({ Date: Tue, 25 Aug 2026 22:37:03 -0400 Subject: [PATCH 05/13] fix(web): reserve only mod-Escape keybindings --- apps/web/src/components/settings/KeybindingsSettings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 9b42e2c9a796..9d94970a3586 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -778,7 +778,7 @@ function KeybindingTableRow({ if (event.key === "Tab") return; event.preventDefault(); const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); - if (!next && event.key === "Escape") { + if (isEscapeDismissal(event)) { setDraft({ keyDraft: row.key, isRecording: false }); return; } @@ -949,7 +949,7 @@ function NewKeybindingTableRow({ if (event.key === "Tab") return; event.preventDefault(); const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); - if (!next && event.key === "Escape") { + if (isEscapeDismissal(event)) { setDraft({ keyDraft: "", isRecording: false }); return; } From 9458be4243d621a532b7917ad043ddf1c4e72570 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 22:47:46 -0400 Subject: [PATCH 06/13] fix(web): reserve mod-Escape across dismissals --- apps/web/src/components/LegacySidebar.tsx | 3 ++- apps/web/src/components/Sidebar.tsx | 5 +++-- apps/web/src/components/chat/ChatHeader.tsx | 3 ++- apps/web/src/components/diffs/DiffCommentAnnotation.tsx | 3 ++- .../src/components/pullRequest/PullRequestDetailPanel.tsx | 3 ++- .../src/components/pullRequest/PullRequestMarkdownEditor.tsx | 3 ++- .../components/pullRequest/PullRequestReviewAnnotation.tsx | 3 ++- apps/web/src/components/settings/SettingsPanels.tsx | 3 ++- apps/web/src/components/settings/SettingsSidebarNav.tsx | 3 ++- apps/web/src/components/settings/ThemeEditorPanel.tsx | 3 ++- apps/web/src/components/settings/ThemeSettings.tsx | 3 ++- 11 files changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 6d6e26c18e35..2a1bac98959e 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -98,6 +98,7 @@ import { useUiStateStore, } from "../uiStateStore"; import { + isEscapeDismissal, resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, @@ -614,7 +615,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr event.preventDefault(); renamingCommittedRef.current = true; void commitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { + } else if (isEscapeDismissal(event)) { event.preventDefault(); renamingCommittedRef.current = true; cancelRename(); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 63681a54813a..1e650a1849fe 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -75,6 +75,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../env"; import { + isEscapeDismissal, resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, @@ -1023,7 +1024,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { event.preventDefault(); renameCommittedRef.current = true; onCommitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { + } else if (isEscapeDismissal(event)) { event.preventDefault(); renameCommittedRef.current = true; onCancelRename(); @@ -2362,7 +2363,7 @@ export default function Sidebar() { // IME composition (Japanese/Chinese input) uses the same keys; committing // a candidate must not move the highlight or navigate away mid-compose. if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "Escape" && isSearchingThreads) { + if (isEscapeDismissal(event) && isSearchingThreads) { event.preventDefault(); event.stopPropagation(); clearThreadSearch(); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index dbba327489ac..e7b3e56f9062 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -45,6 +45,7 @@ import { WorkspaceBreadcrumbSeparator, } from "../WorkspaceBreadcrumb"; import { cn } from "~/lib/utils"; +import { isEscapeDismissal } from "../../keybindings"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -276,7 +277,7 @@ export const ChatHeader = memo(function ChatHeader({ if (event.key === "Enter") { renameCommittedRef.current = true; commitRename(event.currentTarget.value); - } else if (event.key === "Escape") { + } else if (isEscapeDismissal(event)) { renameCommittedRef.current = true; setRenaming(null); } diff --git a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx index d210732b6b60..c2ce682998f0 100644 --- a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx +++ b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx @@ -3,6 +3,7 @@ import { useState, type ReactNode } from "react"; import { Button } from "~/components/ui/button"; import { Textarea } from "~/components/ui/textarea"; +import { isEscapeDismissal } from "../../keybindings"; import { isCommentSubmitShortcut } from "./commentSubmitShortcut"; @@ -91,7 +92,7 @@ export function DiffCommentAnnotation({ event.currentTarget.setSelectionRange(end, end); }} onKeyDown={(event) => { - if (event.key === "Escape") { + if (isEscapeDismissal(event)) { event.preventDefault(); onCancel(); } diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 731a3acecef4..68c002afb449 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -65,6 +65,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { isEscapeDismissal } from "../../keybindings"; import { AlertDialog, AlertDialogClose, @@ -1648,7 +1649,7 @@ export function PullRequestDetailPanel({ if (event.key === "Enter") { event.preventDefault(); void saveTitle(titleDraft); - } else if (event.key === "Escape") { + } else if (isEscapeDismissal(event)) { event.preventDefault(); setTitleScope(null); } diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx index d5d2ee0a4757..968d40f66181 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx @@ -3,6 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { cn } from "~/lib/utils"; +import { isEscapeDismissal } from "../../keybindings"; import { Button } from "../ui/button"; import { Textarea } from "../ui/textarea"; import { PullRequestMarkdown } from "./PullRequestMarkdown"; @@ -56,7 +57,7 @@ export function PullRequestMarkdownEditor({
{ - if (event.key !== "Escape" || saving) return; + if (!isEscapeDismissal(event) || saving) return; event.preventDefault(); onCancel(); }} diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index 90a1926d1fad..4f3672b86e2a 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -22,6 +22,7 @@ import { useRef, useState } from "react"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { cn } from "~/lib/utils"; +import { isEscapeDismissal } from "../../keybindings"; import { Button } from "../ui/button"; import { Textarea } from "../ui/textarea"; import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut"; @@ -46,7 +47,7 @@ function submitKeys(input: { readonly onCancel?: (() => void) | undefined; }) { return (event: React.KeyboardEvent) => { - if (event.key === "Escape" && input.onCancel) { + if (isEscapeDismissal(event) && input.onCancel) { event.preventDefault(); input.onCancel(); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e77c05549265..a6fc766449d6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -83,6 +83,7 @@ import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../. import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { isEscapeDismissal } from "../../keybindings"; import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { @@ -1625,7 +1626,7 @@ function FontFamilySettingsRow({ }} onKeyDown={(event) => { if (event.key === "Enter") flushDraft(); - if (event.key === "Escape") { + if (isEscapeDismissal(event)) { // Discard uncommitted typing without closing the settings page, // which is what an unhandled Escape does. event.preventDefault(); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 734c2989d917..372f2964d82c 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; +import { isEscapeDismissal } from "../../keybindings"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd } from "../ui/kbd"; @@ -152,7 +153,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { ); const handleSearchKeyDown = useCallback( (event: KeyboardEvent) => { - if (event.key === "Escape" && isSearching) { + if (isEscapeDismissal(event) && isSearching) { event.preventDefault(); event.stopPropagation(); clearSearch(); diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 0bb0d1b0ec18..31a5c3ffe82c 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -34,6 +34,7 @@ import { type ThemeColorRole, type ThemeDefinition, } from "../../themePalette"; +import { isEscapeDismissal } from "../../keybindings"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -687,7 +688,7 @@ export function ThemeEditorPanel({ shouldDisarmAfterClick = false; }; const cancelInspection = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; + if (!isEscapeDismissal(event)) return; event.preventDefault(); clearHover(); clearInspectorSelection(); diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 929e00d6c707..8934c7c3cc9f 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -10,6 +10,7 @@ import { UploadIcon, } from "lucide-react"; import { useCallback, useEffect, useState, type ReactElement } from "react"; +import { isEscapeDismissal } from "../../keybindings"; import { cn } from "../../lib/utils"; import { getThemeDefinition, @@ -173,7 +174,7 @@ function ThemeLibraryCard({ } }} onKeyDown={(event) => { - if (event.key === "Escape") setRadialModeOpen(null); + if (isEscapeDismissal(event)) setRadialModeOpen(null); }} onMouseLeave={() => setRadialModeOpen(null)} > From 991c566e96c1066a13bd56af4a3086c26251c847 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 22:54:12 -0400 Subject: [PATCH 07/13] fix(web): align script keybinding capture --- apps/web/src/components/projectScriptEditor.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index d8ee23983620..ab060bd99e9b 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -23,6 +23,7 @@ import { decodeProjectScriptKeybindingRule, } from "~/lib/projectScriptKeybindings"; import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; +import { isEscapeDismissal } from "~/keybindings"; import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts"; import { AlertDialog, @@ -181,6 +182,7 @@ export function ProjectScriptEditorDialog({ setKeybinding(""); return; } + if (isEscapeDismissal(event)) return; const next = keybindingFromKeyboardEvent(event, navigator.platform); if (!next) return; setKeybinding(next); From 689ec23c20d26466a7dc7e879b34158d5518c2e3 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 23:13:27 -0400 Subject: [PATCH 08/13] fix(web): keep mod-Escape recorder open --- apps/web/src/components/projectScriptEditor.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index ab060bd99e9b..4c91d0bf6f9e 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -246,7 +246,15 @@ export function ProjectScriptEditorDialog({ <> { + onOpenChange={(open, eventDetails) => { + if ( + !open && + eventDetails.reason === "escape-key" && + !isEscapeDismissal(eventDetails.event) + ) { + eventDetails.cancel(); + return; + } if (!open) { setIconPickerOpen(false); onClose(); From 635e573581f1662c5ac067ff2f030369b5dfaaa2 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 23:19:43 -0400 Subject: [PATCH 09/13] fix(web): preserve mod-Escape in Base UI overlays --- apps/web/src/components/CommandPalette.tsx | 8 ++++++++ apps/web/src/components/chat/ModelPickerContent.tsx | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 93fb387942fc..effd0cb25a6c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -493,6 +493,14 @@ export function CommandPalette({ children }: { children: ReactNode }) { { + if ( + !open && + eventDetails.reason === "escape-key" && + !isEscapeDismissal(eventDetails.event) + ) { + eventDetails.cancel(); + return; + } if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { eventDetails.cancel(); toggleMode("command"); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8c71b10aff58..010a8b6fa94d 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -630,6 +630,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { open virtualized value={modelPickerModelKey(props.activeInstanceId, props.model)} + onOpenChange={(open, eventDetails) => { + if ( + !open && + eventDetails.reason === "escape-key" && + !isEscapeDismissal(eventDetails.event) + ) { + eventDetails.cancel(); + } + }} onItemHighlighted={(modelKey, eventDetails) => { highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) { From 9ed0163898eed060943a573aed37ab0cbcbd6152 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 23:35:07 -0400 Subject: [PATCH 10/13] fix(web): guard overlay Escape dismissal --- apps/web/src/components/CommandPalette.tsx | 10 +---- .../components/chat/ModelPickerContent.tsx | 9 ---- .../components/chat/ProviderModelPicker.tsx | 4 +- .../src/components/projectScriptEditor.tsx | 11 +---- apps/web/src/keybindings.test.ts | 42 +++++++++++++++++++ apps/web/src/keybindings.ts | 33 +++++++++++++++ 6 files changed, 82 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index effd0cb25a6c..deda5be938b8 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -153,6 +153,7 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { + cancelNonDismissalEscape, isEscapeDismissal, resolveShortcutCommand, threadJumpIndexFromCommand, @@ -493,14 +494,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { { - if ( - !open && - eventDetails.reason === "escape-key" && - !isEscapeDismissal(eventDetails.event) - ) { - eventDetails.cancel(); - return; - } + if (cancelNonDismissalEscape(open, eventDetails)) return; if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { eventDetails.cancel(); toggleMode("command"); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 010a8b6fa94d..8c71b10aff58 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -630,15 +630,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { open virtualized value={modelPickerModelKey(props.activeInstanceId, props.model)} - onOpenChange={(open, eventDetails) => { - if ( - !open && - eventDetails.reason === "escape-key" && - !isEscapeDismissal(eventDetails.event) - ) { - eventDetails.cancel(); - } - }} onItemHighlighted={(modelKey, eventDetails) => { highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) { diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 5566160bcf3e..0984235bb297 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -18,6 +18,7 @@ import { } from "./providerIconUtils"; import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ComposerControl, ComposerControlChevron } from "./ComposerControl"; +import { cancelNonDismissalEscape } from "../../keybindings"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /** @@ -134,7 +135,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { return ( { + onOpenChange={(open, eventDetails) => { + if (cancelNonDismissalEscape(open, eventDetails)) return; if (props.disabled) { setIsMenuOpen(false); return; diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 4c91d0bf6f9e..691395db72c9 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -23,7 +23,7 @@ import { decodeProjectScriptKeybindingRule, } from "~/lib/projectScriptKeybindings"; import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; -import { isEscapeDismissal } from "~/keybindings"; +import { cancelNonDismissalEscape, isEscapeDismissal } from "~/keybindings"; import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts"; import { AlertDialog, @@ -247,14 +247,7 @@ export function ProjectScriptEditorDialog({ { - if ( - !open && - eventDetails.reason === "escape-key" && - !isEscapeDismissal(eventDetails.event) - ) { - eventDetails.cancel(); - return; - } + if (cancelNonDismissalEscape(open, eventDetails)) return; if (!open) { setIconPickerOpen(false); onClose(); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 5e86afbeb4a8..1a9ea4fe2d7c 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -7,6 +7,7 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { + cancelNonDismissalEscape, formatShortcutLabel, isChatNewShortcut, isChatNewLocalShortcut, @@ -44,6 +45,47 @@ function event(overrides: Partial = {}): ShortcutEventLike { }; } +describe("cancelNonDismissalEscape", () => { + it.each([ + ["MacIntel", { metaKey: true, ctrlKey: false }], + ["Win32", { metaKey: false, ctrlKey: true }], + ])("cancels Base UI dismissal for modified Escape on %s", (platform, modifiers) => { + let canceled = false; + assert.isTrue( + cancelNonDismissalEscape( + false, + { + reason: "escape-key", + event: event({ key: "Escape", ...modifiers }), + cancel: () => { + canceled = true; + }, + }, + platform, + ), + ); + assert.isTrue(canceled); + }); + + it("preserves ordinary Escape dismissal", () => { + let canceled = false; + assert.isFalse( + cancelNonDismissalEscape( + false, + { + reason: "escape-key", + event: event({ key: "Escape" }), + cancel: () => { + canceled = true; + }, + }, + "MacIntel", + ), + ); + assert.isFalse(canceled); + }); +}); + describe("isEscapeDismissal", () => { it("preserves ordinary Escape dismissal while reserving mod+Escape", () => { assert.isTrue(isEscapeDismissal(event({ key: "Escape" }), "MacIntel")); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index c2aa046153a8..fd4d0a1f6557 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -35,6 +35,39 @@ export function isEscapeDismissal( return event.key === "Escape" && !modPressed; } +export function cancelNonDismissalEscape( + open: boolean, + eventDetails: { reason: string; event: unknown; cancel: () => void }, + platform = navigator.platform, +): boolean { + if (open || eventDetails.reason !== "escape-key") return false; + + const event = eventDetails.event; + if (typeof event !== "object" || event === null) return false; + if (!("key" in event) || typeof event.key !== "string") return false; + if (!("metaKey" in event) || typeof event.metaKey !== "boolean") return false; + if (!("ctrlKey" in event) || typeof event.ctrlKey !== "boolean") return false; + if (!("shiftKey" in event) || typeof event.shiftKey !== "boolean") return false; + if (!("altKey" in event) || typeof event.altKey !== "boolean") return false; + if ( + isEscapeDismissal( + { + key: event.key, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + }, + platform, + ) + ) { + return false; + } + + eventDetails.cancel(); + return true; +} + export interface ShortcutMatchContext { terminalFocus: boolean; terminalOpen: boolean; From c87375d6b53f7a112d99ba872070d7674cf049aa Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 23:45:14 -0400 Subject: [PATCH 11/13] fix(web): reserve mod-Escape in overlay roots --- apps/web/src/components/CommandPalette.tsx | 2 -- .../components/chat/ProviderModelPicker.tsx | 4 +--- .../web/src/components/projectScriptEditor.tsx | 5 ++--- apps/web/src/components/ui/alert-dialog.tsx | 14 +++++++++++++- apps/web/src/components/ui/autocomplete.tsx | 18 +++++++++++++++++- apps/web/src/components/ui/combobox.tsx | 10 +++++++++- apps/web/src/components/ui/command.tsx | 14 +++++++++++++- apps/web/src/components/ui/dialog.tsx | 14 +++++++++++++- apps/web/src/components/ui/menu.tsx | 14 +++++++++++++- apps/web/src/components/ui/popover.tsx | 14 +++++++++++++- apps/web/src/components/ui/select.tsx | 16 +++++++++++++++- apps/web/src/components/ui/sheet.tsx | 14 +++++++++++++- apps/web/src/components/ui/tooltip.tsx | 14 +++++++++++++- 13 files changed, 135 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index deda5be938b8..93fb387942fc 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -153,7 +153,6 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { - cancelNonDismissalEscape, isEscapeDismissal, resolveShortcutCommand, threadJumpIndexFromCommand, @@ -494,7 +493,6 @@ export function CommandPalette({ children }: { children: ReactNode }) { { - if (cancelNonDismissalEscape(open, eventDetails)) return; if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { eventDetails.cancel(); toggleMode("command"); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 0984235bb297..5566160bcf3e 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -18,7 +18,6 @@ import { } from "./providerIconUtils"; import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ComposerControl, ComposerControlChevron } from "./ComposerControl"; -import { cancelNonDismissalEscape } from "../../keybindings"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /** @@ -135,8 +134,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { return ( { - if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange={(open) => { if (props.disabled) { setIsMenuOpen(false); return; diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 691395db72c9..ab060bd99e9b 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -23,7 +23,7 @@ import { decodeProjectScriptKeybindingRule, } from "~/lib/projectScriptKeybindings"; import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; -import { cancelNonDismissalEscape, isEscapeDismissal } from "~/keybindings"; +import { isEscapeDismissal } from "~/keybindings"; import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts"; import { AlertDialog, @@ -246,8 +246,7 @@ export function ProjectScriptEditorDialog({ <> { - if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange={(open) => { if (!open) { setIconPickerOpen(false); onClose(); diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index 4f57e920118b..b190d5145f8a 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -3,6 +3,7 @@ import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; import { cn } from "~/lib/utils"; +import { cancelNonDismissalEscape } from "~/keybindings"; import { DIALOG_BACKDROP_CLASS, DIALOG_MOBILE_SHEET_CLASS, @@ -11,7 +12,18 @@ import { const AlertDialogCreateHandle = AlertDialogPrimitive.createHandle; -const AlertDialog = AlertDialogPrimitive.Root; +function AlertDialog(props: AlertDialogPrimitive.Root.Props) { + const { onOpenChange, ...rootProps } = props; + return ( + { + if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange?.(open, eventDetails); + }} + /> + ); +} const AlertDialogPortal = AlertDialogPrimitive.Portal; diff --git a/apps/web/src/components/ui/autocomplete.tsx b/apps/web/src/components/ui/autocomplete.tsx index b81701e25925..0681498bc228 100644 --- a/apps/web/src/components/ui/autocomplete.tsx +++ b/apps/web/src/components/ui/autocomplete.tsx @@ -6,8 +6,24 @@ import { ChevronsUpDownIcon, XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Input } from "~/components/ui/input"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { cancelNonDismissalEscape } from "~/keybindings"; -const Autocomplete = AutocompletePrimitive.Root; +const AutocompleteRoot = AutocompletePrimitive.Root as ( + props: AutocompletePrimitive.Root.Props, +) => React.JSX.Element; + +function Autocomplete(props: AutocompletePrimitive.Root.Props) { + const { onOpenChange, ...rootProps } = props; + return ( + { + if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange?.(open, eventDetails); + }} + /> + ); +} function AutocompleteInput({ className, diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index cf3a46142ad2..d697291ebf2b 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -7,6 +7,7 @@ import * as React from "react"; import { cn } from "~/lib/utils"; import { Input } from "~/components/ui/input"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { cancelNonDismissalEscape } from "~/keybindings"; const ComboboxContext = React.createContext<{ chipsRef: React.RefObject | null; @@ -19,11 +20,18 @@ const ComboboxContext = React.createContext<{ function Combobox( props: ComboboxPrimitive.Root.Props, ) { + const { onOpenChange, ...rootProps } = props; const chipsRef = React.useRef(null); const value = React.useMemo(() => ({ chipsRef, multiple: !!props.multiple }), [props.multiple]); return ( - + { + if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange?.(open, eventDetails); + }} + /> ); } diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index c1952e9c6f36..1d7c6ca222d6 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -17,8 +17,20 @@ import { } from "~/components/ui/autocomplete"; import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles"; import { Button } from "~/components/ui/button"; +import { cancelNonDismissalEscape } from "~/keybindings"; -const CommandDialog = CommandDialogPrimitive.Root; +function CommandDialog(props: CommandDialogPrimitive.Root.Props) { + const { onOpenChange, ...rootProps } = props; + return ( + { + if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange?.(open, eventDetails); + }} + /> + ); +} const CommandDialogPortal = CommandDialogPrimitive.Portal; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index a96f648803aa..046a905140c9 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -10,10 +10,22 @@ import { DIALOG_POPUP_CLASS, } from "~/components/ui/dialog-styles"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { cancelNonDismissalEscape } from "~/keybindings"; const DialogCreateHandle = DialogPrimitive.createHandle; -const Dialog = DialogPrimitive.Root; +function Dialog(props: DialogPrimitive.Root.Props) { + const { onOpenChange, ...rootProps } = props; + return ( + { + if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange?.(open, eventDetails); + }} + /> + ); +} const DialogPortal = DialogPrimitive.Portal; diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index b66782ebe2d1..32827e022d48 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -5,10 +5,22 @@ import { ChevronRightIcon } from "lucide-react"; import type * as React from "react"; import { cn } from "~/lib/utils"; +import { cancelNonDismissalEscape } from "~/keybindings"; const MenuCreateHandle = MenuPrimitive.createHandle; -const Menu = MenuPrimitive.Root; +function Menu(props: MenuPrimitive.Root.Props) { + const { onOpenChange, ...rootProps } = props; + return ( + { + if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange?.(open, eventDetails); + }} + /> + ); +} const MenuPortal = MenuPrimitive.Portal; diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 9534112e4e72..638a4f672f64 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -3,10 +3,22 @@ import { Popover as PopoverPrimitive } from "@base-ui/react/popover"; import { cn } from "~/lib/utils"; +import { cancelNonDismissalEscape } from "~/keybindings"; const PopoverCreateHandle = PopoverPrimitive.createHandle; -const Popover = PopoverPrimitive.Root; +function Popover(props: PopoverPrimitive.Root.Props) { + const { onOpenChange, ...rootProps } = props; + return ( + { + if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange?.(open, eventDetails); + }} + /> + ); +} function PopoverTrigger({ className, children, ...props }: PopoverPrimitive.Trigger.Props) { return ( diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 46b2c8eb1ba0..4420823d3c96 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -8,8 +8,22 @@ import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react import type * as React from "react"; import { cn } from "~/lib/utils"; +import { cancelNonDismissalEscape } from "~/keybindings"; -const Select = SelectPrimitive.Root; +function Select( + props: SelectPrimitive.Root.Props, +) { + const { onOpenChange, ...rootProps } = props; + return ( + { + if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange?.(open, eventDetails); + }} + /> + ); +} const selectTriggerVariants = cva( "relative inline-flex cursor-pointer select-none items-center justify-between gap-2 border rounded-lg text-left text-base outline-none transition-[color,box-shadow,background-color] data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4", diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index 9d0436b661da..647b885d839f 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -5,8 +5,20 @@ import { XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { cancelNonDismissalEscape } from "~/keybindings"; -const Sheet = SheetPrimitive.Root; +function Sheet(props: SheetPrimitive.Root.Props) { + const { onOpenChange, ...rootProps } = props; + return ( + { + if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange?.(open, eventDetails); + }} + /> + ); +} const SheetPortal = SheetPrimitive.Portal; diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 77b15a01e16b..83dc2705dfcf 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -1,12 +1,24 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; import { cn } from "~/lib/utils"; +import { cancelNonDismissalEscape } from "~/keybindings"; const TooltipCreateHandle = TooltipPrimitive.createHandle; const TooltipProvider = TooltipPrimitive.Provider; -const Tooltip = TooltipPrimitive.Root; +function Tooltip(props: TooltipPrimitive.Root.Props) { + const { onOpenChange, ...rootProps } = props; + return ( + { + if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange?.(open, eventDetails); + }} + /> + ); +} function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) { return ; From 046e768c19e28a66caf9f4d2804977748463f7ac Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 23:54:15 -0400 Subject: [PATCH 12/13] fix(web): propagate reserved Escape shortcuts --- apps/web/src/components/ui/menu.tsx | 12 +++++++++++- apps/web/src/keybindings.test.ts | 10 ++++++++++ apps/web/src/keybindings.ts | 8 +++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 32827e022d48..a5365ced4bfc 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -233,7 +233,17 @@ function MenuShortcut({ className, ...props }: React.ComponentProps<"kbd">) { } function MenuSub(props: MenuPrimitive.SubmenuRoot.Props) { - return ; + const { onOpenChange, ...rootProps } = props; + return ( + { + if (cancelNonDismissalEscape(open, eventDetails)) return; + onOpenChange?.(open, eventDetails); + }} + /> + ); } function MenuSubTrigger({ diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 1a9ea4fe2d7c..9c012b28a33f 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -51,6 +51,7 @@ describe("cancelNonDismissalEscape", () => { ["Win32", { metaKey: false, ctrlKey: true }], ])("cancels Base UI dismissal for modified Escape on %s", (platform, modifiers) => { let canceled = false; + let allowedPropagation = false; assert.isTrue( cancelNonDismissalEscape( false, @@ -60,15 +61,20 @@ describe("cancelNonDismissalEscape", () => { cancel: () => { canceled = true; }, + allowPropagation: () => { + allowedPropagation = true; + }, }, platform, ), ); assert.isTrue(canceled); + assert.isTrue(allowedPropagation); }); it("preserves ordinary Escape dismissal", () => { let canceled = false; + let allowedPropagation = false; assert.isFalse( cancelNonDismissalEscape( false, @@ -78,11 +84,15 @@ describe("cancelNonDismissalEscape", () => { cancel: () => { canceled = true; }, + allowPropagation: () => { + allowedPropagation = true; + }, }, "MacIntel", ), ); assert.isFalse(canceled); + assert.isFalse(allowedPropagation); }); }); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index fd4d0a1f6557..cf8d628d8cba 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -37,7 +37,12 @@ export function isEscapeDismissal( export function cancelNonDismissalEscape( open: boolean, - eventDetails: { reason: string; event: unknown; cancel: () => void }, + eventDetails: { + reason: string; + event: unknown; + cancel: () => void; + allowPropagation: () => void; + }, platform = navigator.platform, ): boolean { if (open || eventDetails.reason !== "escape-key") return false; @@ -65,6 +70,7 @@ export function cancelNonDismissalEscape( } eventDetails.cancel(); + eventDetails.allowPropagation(); return true; } From e0b0897ae4123cafc747f7522c5137e4991ebe21 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 26 Aug 2026 00:01:00 -0400 Subject: [PATCH 13/13] fix(desktop): scope native shortcut listeners --- apps/desktop/src/window/DesktopWindow.test.ts | 10 ++++++ apps/desktop/src/window/DesktopWindow.ts | 33 +++++++++++-------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 9594c94d16fd..5054ce6ad6ca 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -169,6 +169,16 @@ const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClien const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({ quit: Effect.void, + on: (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + appListeners.set(eventName, listener as (...args: readonly unknown[]) => void); + }), + () => + Effect.sync(() => { + if (appListeners.get(eventName) === listener) appListeners.delete(eventName); + }), + ).pipe(Effect.asVoid), }); const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 7f117ab0c448..48d0c015c2ef 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1,10 +1,12 @@ import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; import * as Electron from "electron"; @@ -417,7 +419,7 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); } - let disposeNativeModEscape = () => {}; + let disposeNativeModEscape: Effect.Effect = Effect.void; if (environment.platform === "darwin") { const registeredAccelerators = new Set(); let blurFiber: Fiber.Fiber | undefined; @@ -473,14 +475,21 @@ export const make = Effect.gen(function* () { ), ); }; - Electron.app.on("browser-window-focus", registerNativeModEscape); - Electron.app.on("browser-window-blur", handleBrowserWindowBlur); - disposeNativeModEscape = () => { - cancelPendingBlur(); - Electron.app.off("browser-window-focus", registerNativeModEscape); - Electron.app.off("browser-window-blur", handleBrowserWindowBlur); - unregisterNativeModEscape(); - }; + const nativeModEscapeScope = yield* Scope.make("sequential"); + disposeNativeModEscape = Scope.close(nativeModEscapeScope, Exit.void).pipe( + Effect.andThen( + Effect.sync(() => { + cancelPendingBlur(); + unregisterNativeModEscape(); + }), + ), + ); + yield* electronApp + .on("browser-window-focus", registerNativeModEscape) + .pipe(Effect.provideService(Scope.Scope, nativeModEscapeScope)); + yield* electronApp + .on("browser-window-blur", handleBrowserWindowBlur) + .pipe(Effect.provideService(Scope.Scope, nativeModEscapeScope)); } else { window.webContents.on("before-input-event", (event, input) => { const captureInput = nativeKeybindingCaptureInput(input, environment.platform); @@ -577,9 +586,7 @@ export const make = Effect.gen(function* () { ); flushMainWindowBounds = flushBoundsPersist; - yield* previewManager - .setMainWindow(window) - .pipe(Effect.onError(() => Effect.sync(disposeNativeModEscape))); + yield* previewManager.setMainWindow(window).pipe(Effect.onError(() => disposeNativeModEscape)); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( typeof params.partition !== "string" || @@ -867,7 +874,7 @@ export const make = Effect.gen(function* () { } window.on("closed", () => { - disposeNativeModEscape(); + void runPromise(disposeNativeModEscape); clearDevelopmentLoadRetry(); clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window)));