diff --git a/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts b/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts new file mode 100644 index 000000000000..a2f484a675db --- /dev/null +++ b/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + dispatchNativeKeybindingCaptureInput, + 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, + }, + "darwin", + ), + ).toEqual({ + key: "Escape", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: true, + }); + }); + + 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 }, "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, + }, + platform, + ), + ).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 new file mode 100644 index 000000000000..6881730b5940 --- /dev/null +++ b/apps/desktop/src/keybindings/NativeKeybindingCapture.ts @@ -0,0 +1,66 @@ +import type { Input } from "electron"; + +export const NATIVE_KEYBINDING_CAPTURE_CHANNEL = "desktop:native-keybinding-capture"; + +export interface NativeKeybindingCaptureInput { + readonly key: "Escape"; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly altKey: boolean; + readonly shiftKey: boolean; +} + +export function nativeKeybindingCaptureInput( + input: Pick, + platform: NodeJS.Platform, +): NativeKeybindingCaptureInput | null { + const key = input.key.toLowerCase(); + const modPressed = platform === "darwin" ? input.meta : input.control; + if (input.type !== "keyDown" || (key !== "escape" && key !== "esc") || !modPressed) { + return null; + } + + return { + key: "Escape", + metaKey: input.meta, + 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) || + 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) || + typeof input.shiftKey !== "boolean" + ) { + return; + } + + const activeElement = document.activeElement; + const target = activeElement?.hasAttribute("data-keybinding-capture") ? activeElement : window; + + 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 407c7c3ef498..799daf36d51f 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 3bf6d63051af..6d46dff67fdc 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"; @@ -499,6 +500,38 @@ 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, + { + type: "keyDown", + key: "Escape", + meta: true, + control: false, + alt: false, + shift: false, + } as never, + ); + 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 0d90e0175fe3..3539cc5e9677 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -52,6 +52,11 @@ 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"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, @@ -1661,6 +1666,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(); @@ -1671,6 +1685,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); return; } + const captureInput = nativeKeybindingCaptureInput(input, hostPlatform); + if (captureInput) { + event.preventDefault(); + runFork(forwardNativeKeybindingCapture(captureInput)); + return; + } runFork(forwardShortcut(event, input)); }; yield* Scope.addFinalizer( diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index f315bdcec738..ae3d9bc31d79 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1,5 +1,6 @@ // @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. import { ipcRenderer } from "electron"; + import { getElementContext } from "react-grab/primitives"; import type { DesktopPreviewAnnotationTheme, @@ -25,6 +26,7 @@ import { MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; + 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 036eddd8db78..5054ce6ad6ca 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -14,8 +14,38 @@ import * as TestClock from "effect/testing/TestClock"; import * as Electron from "electron"; import { vi } from "vite-plus/test"; +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, + unregister: globalShortcutUnregister, + }, session: { fromPartition: vi.fn(() => ({ getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/1.2.3"), @@ -44,6 +74,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"; @@ -138,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, { @@ -203,6 +244,9 @@ function makeTestLayer(input: { readonly beforeMainWindowBoundsUpdate?: ( bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; + readonly setPreviewMainWindow?: ( + window: Electron.BrowserWindow, + ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; readonly previewZoomReapplies?: number[]; }) { @@ -282,7 +326,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"), reapplyZoom: () => @@ -461,6 +505,85 @@ 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 registrationStart = globalShortcutRegister.mock.calls.length; + appListeners.get("browser-window-focus")?.(); + 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-Option-Shift-Escape to be registered"); + } + registration[1](); + assert.deepEqual(fakeWindow.send.mock.calls, [ + [ + NATIVE_KEYBINDING_CAPTURE_CHANNEL, + { + key: "Escape", + metaKey: true, + ctrlKey: false, + altKey: true, + shiftKey: true, + }, + ], + ]); + const unregisterStart = globalShortcutUnregister.mock.calls.length; + 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) + .map(([accelerator]) => accelerator), + registrations.map(([accelerator]) => accelerator), + ); + }).pipe(Effect.provide(layer)); + }), + ); + + 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)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 56411711eb6c..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"; @@ -25,6 +27,11 @@ import { } 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"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import { makeQuitHoldHandler } from "./QuitHold.ts"; @@ -41,6 +48,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_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 @@ -375,6 +419,86 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); } + let disposeNativeModEscape: Effect.Effect = Effect.void; + 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) || + 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, + }), + ); + } + } + }; + const unregisterNativeModEscape = () => { + for (const accelerator of registeredAccelerators) { + Electron.globalShortcut.unregister(accelerator); + } + registeredAccelerators.clear(); + }; + const handleBrowserWindowBlur = () => { + cancelPendingBlur(); + blurFiber = runFork( + Effect.sleep(1).pipe( + Effect.andThen( + Effect.sync(() => { + blurFiber = undefined; + if (Electron.BrowserWindow.getFocusedWindow() === null) { + 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); + 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; @@ -462,7 +586,7 @@ export const make = Effect.gen(function* () { ); flushMainWindowBounds = flushBoundsPersist; - yield* previewManager.setMainWindow(window); + yield* previewManager.setMainWindow(window).pipe(Effect.onError(() => disposeNativeModEscape)); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( typeof params.partition !== "string" || @@ -750,6 +874,7 @@ export const make = Effect.gen(function* () { } window.on("closed", () => { + void runPromise(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 c5ec3f095167..93fb387942fc 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -152,7 +152,11 @@ import { resolveDefaultProviderModelSelection, type ProviderInstanceEntry, } from "../providerInstances"; -import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; +import { + isEscapeDismissal, + resolveShortcutCommand, + threadJumpIndexFromCommand, +} from "../keybindings"; import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; @@ -426,7 +430,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/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/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index fc12831025b7..9eb6da5ece54 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -1,6 +1,7 @@ import { XIcon } from "lucide-react"; import { memo, useEffect, useRef, useState } from "react"; +import { isEscapeDismissal } from "../../keybindings"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "~/lib/utils"; import { type PromptStashEntry } from "../../promptStashStore"; @@ -66,7 +67,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { useEffect(() => { const handler = (event: KeyboardEvent) => { - if (event.key === "Escape") { + 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 fd14c68b0c4d..26e0f726ee2c 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 { isEscapeDismissal } 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 (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 8729b1bf8f00..8c71b10aff58 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, + isEscapeDismissal, resolveShortcutCommand, shortcutLabelForCommand, } from "../../keybindings"; @@ -674,7 +675,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={(e) => { - if (e.key === "Escape") { + if (isEscapeDismissal(e)) { e.preventDefault(); e.stopPropagation(); props.onRequestClose?.(); 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/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index cbe20f4d3a8d..e681326d3bb6 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 { isEscapeDismissal } from "~/keybindings"; import { useTheme } from "~/hooks/useTheme"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -90,7 +91,7 @@ function FileSearchField(props: { spellCheck={false} onChange={(event) => props.onValueChange(event.target.value)} onKeyDown={(event) => { - if (event.key !== "Escape") 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 1291cfe9aeea..e117fd942aa3 100644 --- a/apps/web/src/components/files/fileEditorDismissal.ts +++ b/apps/web/src/components/files/fileEditorDismissal.ts @@ -1,3 +1,5 @@ +import { isEscapeDismissal } 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 (!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 d6a64084218a..de636cfcf8c3 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 { isEscapeDismissal } from "~/keybindings"; import { cn } from "~/lib/utils"; interface Props { @@ -189,7 +190,7 @@ export function PreviewChromeRow({ }} onKeyDown={(event) => { if (event.key === "Enter") submit(event); - if (event.key === "Escape") { + if (isEscapeDismissal(event)) { event.preventDefault(); setDraft(url); inputRef.current?.blur(); diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 4b728c2e07eb..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); @@ -315,6 +317,7 @@ export function ProjectScriptEditorDialog({ { - 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/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 90eaaef99413..2cf081fbf2c2 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 ccbd1f06582e..9d94970a3586 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, isEscapeDismissal } from "../../keybindings"; import { cn } from "../../lib/utils"; import { primaryServerAvailableEditorsAtom, @@ -153,7 +153,7 @@ function ExpandableHeaderSearch({ if (query.length === 0) onOpenChange(false); }} onKeyDown={(event) => { - if (event.key === "Escape") { + if (isEscapeDismissal(event)) { event.preventDefault(); onChange(""); onOpenChange(false); @@ -777,11 +777,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 (isEscapeDismissal(event)) { setDraft({ keyDraft: row.key, isRecording: false }); return; } - const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); if (!next) return; setDraft({ keyDraft: next, isRecording: false }); }; @@ -820,8 +820,8 @@ function KeybindingTableRow({ ) : ( ) => { if (event.key === "Tab") return; event.preventDefault(); - if (event.key === "Escape") { + const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); + if (isEscapeDismissal(event)) { setDraft({ keyDraft: "", isRecording: false }); return; } - const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); if (!next) return; setDraft({ keyDraft: next, isRecording: false }); }; diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index b462eaca883b..b6c535652c36 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 { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; import { readLocalApi } from "../../localApi"; @@ -164,7 +164,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/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)} > 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..a5365ced4bfc 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; @@ -221,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/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 ; diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index ce8b8950a8b9..06a682b1782a 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -1,4 +1,5 @@ import type { ContextMenuItem } from "@t3tools/contracts"; +import { isEscapeDismissal } from "./keybindings"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -237,7 +238,7 @@ export function showContextMenuFallback( }; const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { + if (isEscapeDismissal(event)) { event.preventDefault(); cleanup(null); } diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index b6b98d7389b3..9c012b28a33f 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, @@ -14,6 +15,7 @@ import { modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, isOpenFavoriteEditorShortcut, + isEscapeDismissal, isTerminalClearShortcut, isTerminalCloseShortcut, isTerminalNewShortcut, @@ -43,6 +45,69 @@ 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; + let allowedPropagation = false; + assert.isTrue( + cancelNonDismissalEscape( + false, + { + reason: "escape-key", + event: event({ key: "Escape", ...modifiers }), + 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, + { + reason: "escape-key", + event: event({ key: "Escape" }), + cancel: () => { + canceled = true; + }, + allowPropagation: () => { + allowedPropagation = true; + }, + }, + "MacIntel", + ), + ); + assert.isFalse(canceled); + assert.isFalse(allowedPropagation); + }); +}); + +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")); + }); +}); + function modShortcut( key: string, overrides: Partial> = {}, diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index eb4637df21be..cf8d628d8cba 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -27,6 +27,53 @@ export interface ShortcutModifierStateLike { altKey: boolean; } +export function isEscapeDismissal( + event: Pick, + platform = navigator.platform, +): boolean { + const modPressed = isMacPlatform(platform) ? event.metaKey : event.ctrlKey; + return event.key === "Escape" && !modPressed; +} + +export function cancelNonDismissalEscape( + open: boolean, + eventDetails: { + reason: string; + event: unknown; + cancel: () => void; + allowPropagation: () => 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(); + eventDetails.allowPropagation(); + return true; +} + 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..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 { 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 (event.key === "Escape" && 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 5e921fca5c2c..db093742e453 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -15,6 +15,7 @@ import { Button } from "../components/ui/button"; import { SidebarInset } from "../components/ui/sidebar"; import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; import { isElectron } from "../env"; +import { isEscapeDismissal } from "../keybindings"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -50,7 +51,7 @@ function SettingsContentLayout() { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (event.key === "Escape") { + if (isEscapeDismissal(event)) { event.preventDefault(); const activeElement = document.activeElement;