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;