authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-08-15 14:33:29+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 13:33:29+02:00
logb7dbbbaf6c394621cba57cf58dfcc1845f445ef6
treecc2ffd993ca66a33dc89e9e1c52fd21037106182
parent178da6bc3210b82c4a83f33c8b149f623a3375e1
signature Signed by PGP key B5690EEEBB952194

feat(desktop): Chrome-style hold-to-quit (#5508)


16 files changed, 539 insertions(+), 3 deletions(-)

apps/desktop/src/ipc/channels.ts+1
...@@ -5,6 +5,7 @@ export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";...@@ -5,6 +5,7 @@ export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
5export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";5export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
6export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";6export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
7export const MENU_ACTION_CHANNEL = "desktop:menu-action";7export const MENU_ACTION_CHANNEL = "desktop:menu-action";
8export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
8export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";9export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
9export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";10export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
10export const UPDATE_STATE_CHANNEL = "desktop:update-state";11export const UPDATE_STATE_CHANNEL = "desktop:update-state";
apps/desktop/src/ipc/methods/wsl.test.ts+12
...@@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";...@@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
10import * as DesktopShutdown from "../../app/DesktopShutdown.ts";10import * as DesktopShutdown from "../../app/DesktopShutdown.ts";
11import * as DesktopState from "../../app/DesktopState.ts";11import * as DesktopState from "../../app/DesktopState.ts";
12import * as ElectronApp from "../../electron/ElectronApp.ts";12import * as ElectronApp from "../../electron/ElectronApp.ts";
13import * as ElectronDialog from "../../electron/ElectronDialog.ts";
13import * as ElectronTheme from "../../electron/ElectronTheme.ts";14import * as ElectronTheme from "../../electron/ElectronTheme.ts";
15import * as ElectronWindow from "../../electron/ElectronWindow.ts";
14import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";16import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
17import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
15import * as DesktopWindow from "../../window/DesktopWindow.ts";18import * as DesktopWindow from "../../window/DesktopWindow.ts";
16import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";19import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
17import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";20import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
...@@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll(...@@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll(
70 ElectronTheme.ElectronTheme,73 ElectronTheme.ElectronTheme,
71 ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]),74 ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]),
72 ),75 ),
76 Layer.succeed(
77 ElectronDialog.ElectronDialog,
78 ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]),
79 ),
80 Layer.succeed(
81 ElectronWindow.ElectronWindow,
82 ElectronWindow.ElectronWindow.of({} as ElectronWindow.ElectronWindow["Service"]),
83 ),
84 DesktopClientSettings.layerTest(),
73);85);
7486
75describe("WSL IPC", () => {87describe("WSL IPC", () => {
apps/desktop/src/preload.ts+11
...@@ -117,6 +117,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -117,6 +117,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {
117 ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);117 ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
118 };118 };
119 },119 },
120 onQuitShortcut: (listener) => {
121 const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
122 if (state !== "down" && state !== "up") return;
123 listener(state);
124 };
125
126 ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener);
127 return () => {
128 ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener);
129 };
130 },
120 getWindowFullscreenState: () =>131 getWindowFullscreenState: () =>
121 ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true,132 ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true,
122 onWindowFullscreenStateChange: (listener) => {133 onWindowFullscreenStateChange: (listener) => {
apps/desktop/src/settings/DesktopClientSettings.test.ts+1
...@@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";...@@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
13import * as DesktopClientSettings from "./DesktopClientSettings.ts";13import * as DesktopClientSettings from "./DesktopClientSettings.ts";
1414
15const clientSettings: ClientSettings = {15const clientSettings: ClientSettings = {
16 confirmQuit: true,
16 confirmThreadArchive: true,17 confirmThreadArchive: true,
17 confirmThreadDelete: false,18 confirmThreadDelete: false,
18 dismissedProviderUpdateNotificationKeys: [],19 dismissedProviderUpdateNotificationKeys: [],
apps/desktop/src/window/DesktopWindow.test.ts+14
...@@ -37,6 +37,8 @@ import * as DesktopConfig from "../app/DesktopConfig.ts";...@@ -37,6 +37,8 @@ import * as DesktopConfig from "../app/DesktopConfig.ts";
37import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";37import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
38import * as DesktopState from "../app/DesktopState.ts";38import * as DesktopState from "../app/DesktopState.ts";
39import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";39import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
40import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
41import * as ElectronApp from "../electron/ElectronApp.ts";
40import * as ElectronMenu from "../electron/ElectronMenu.ts";42import * as ElectronMenu from "../electron/ElectronMenu.ts";
41import * as ElectronShell from "../electron/ElectronShell.ts";43import * as ElectronShell from "../electron/ElectronShell.ts";
42import * as ElectronTheme from "../electron/ElectronTheme.ts";44import * as ElectronTheme from "../electron/ElectronTheme.ts";
...@@ -128,6 +130,14 @@ function makeFakeBrowserWindow() {...@@ -128,6 +130,14 @@ function makeFakeBrowserWindow() {
128 };130 };
129}131}
130132
133const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({
134 get: Effect.succeed(Option.none()),
135});
136
137const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({
138 quit: Effect.void,
139});
140
131const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, {141const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, {
132 iconPaths: Effect.succeed({142 iconPaths: Effect.succeed({
133 ico: Option.none<string>(),143 ico: Option.none<string>(),
...@@ -253,8 +263,10 @@ function makeTestLayer(input: {...@@ -253,8 +263,10 @@ function makeTestLayer(input: {
253 desktopAssetsLayer,263 desktopAssetsLayer,
254 desktopEnvironmentLayer,264 desktopEnvironmentLayer,
255 desktopAppSettingsLayer,265 desktopAppSettingsLayer,
266 desktopClientSettingsLayer,
256 desktopServerExposureLayer,267 desktopServerExposureLayer,
257 DesktopState.layer,268 DesktopState.layer,
269 electronAppLayer,
258 electronMenuLayer,270 electronMenuLayer,
259 Layer.succeed(ElectronShell.ElectronShell, {271 Layer.succeed(ElectronShell.ElectronShell, {
260 openExternal: (url) =>272 openExternal: (url) =>
...@@ -356,7 +368,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n...@@ -356,7 +368,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
356 desktopAssetsLayer,368 desktopAssetsLayer,
357 desktopEnvironmentLayer,369 desktopEnvironmentLayer,
358 DesktopAppSettings.layerTest(),370 DesktopAppSettings.layerTest(),
371 desktopClientSettingsLayer,
359 desktopServerExposureLayer,372 desktopServerExposureLayer,
373 electronAppLayer,
360 electronMenuLayer,374 electronMenuLayer,
361 Layer.succeed(ElectronShell.ElectronShell, {375 Layer.succeed(ElectronShell.ElectronShell, {
362 openExternal: () => Effect.succeed(true),376 openExternal: () => Effect.succeed(true),
apps/desktop/src/window/DesktopWindow.ts+39-1
...@@ -8,6 +8,8 @@ import * as Ref from "effect/Ref";...@@ -8,6 +8,8 @@ import * as Ref from "effect/Ref";
88
9import * as Electron from "electron";9import * as Electron from "electron";
1010
11import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
12
11import * as DesktopAssets from "../app/DesktopAssets.ts";13import * as DesktopAssets from "../app/DesktopAssets.ts";
12import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
13import { makeComponentLogger } from "../app/DesktopObservability.ts";15import { makeComponentLogger } from "../app/DesktopObservability.ts";
...@@ -16,9 +18,16 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts";...@@ -16,9 +18,16 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts";
16import * as ElectronShell from "../electron/ElectronShell.ts";18import * as ElectronShell from "../electron/ElectronShell.ts";
17import * as ElectronTheme from "../electron/ElectronTheme.ts";19import * as ElectronTheme from "../electron/ElectronTheme.ts";
18import * as ElectronWindow from "../electron/ElectronWindow.ts";20import * as ElectronWindow from "../electron/ElectronWindow.ts";
19import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts";21import {
22 MENU_ACTION_CHANNEL,
23 QUIT_SHORTCUT_CHANNEL,
24 WINDOW_FULLSCREEN_STATE_CHANNEL,
25} from "../ipc/channels.ts";
20import * as PreviewManager from "../preview/Manager.ts";26import * as PreviewManager from "../preview/Manager.ts";
21import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";27import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
28import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
29import * as ElectronApp from "../electron/ElectronApp.ts";
30import { makeQuitHoldHandler } from "./QuitHold.ts";
2231
23const TITLEBAR_HEIGHT = 40;32const TITLEBAR_HEIGHT = 40;
24const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux33const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
...@@ -51,6 +60,8 @@ type DesktopWindowRuntimeServices =...@@ -51,6 +60,8 @@ type DesktopWindowRuntimeServices =
51 | DesktopEnvironment.DesktopEnvironment60 | DesktopEnvironment.DesktopEnvironment
52 | DesktopAssets.DesktopAssets61 | DesktopAssets.DesktopAssets
53 | DesktopAppSettings.DesktopAppSettings62 | DesktopAppSettings.DesktopAppSettings
63 | DesktopClientSettings.DesktopClientSettings
64 | ElectronApp.ElectronApp
54 | ElectronMenu.ElectronMenu65 | ElectronMenu.ElectronMenu
55 | ElectronShell.ElectronShell66 | ElectronShell.ElectronShell
56 | ElectronTheme.ElectronTheme67 | ElectronTheme.ElectronTheme
...@@ -261,6 +272,8 @@ export const make = Effect.gen(function* () {...@@ -261,6 +272,8 @@ export const make = Effect.gen(function* () {
261 const electronWindow = yield* ElectronWindow.ElectronWindow;272 const electronWindow = yield* ElectronWindow.ElectronWindow;
262 const previewManager = yield* PreviewManager.PreviewManager;273 const previewManager = yield* PreviewManager.PreviewManager;
263 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;274 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
275 const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
276 const electronApp = yield* ElectronApp.ElectronApp;
264 // Window-side latch for the primary backend's readiness. Set by277 // Window-side latch for the primary backend's readiness. Set by
265 // handleBackendReady (driven by the pool's onReady callback), cleared278 // handleBackendReady (driven by the pool's onReady callback), cleared
266 // by handleBackendNotReady (driven by onShutdown). Only consumed by279 // by handleBackendNotReady (driven by onShutdown). Only consumed by
...@@ -533,7 +546,32 @@ export const make = Effect.gen(function* () {...@@ -533,7 +546,32 @@ export const make = Effect.gen(function* () {
533 // close-terminal shortcut can outlive the terminal that handled its first546 // close-terminal shortcut can outlive the terminal that handled its first
534 // press, so reject repeats before they reach the native window accelerator.547 // press, so reject repeats before they reach the native window accelerator.
535 // Deliberate presses still flow through the renderer or native menu.548 // Deliberate presses still flow through the renderer or native menu.
549 // Chrome-style hold-to-quit: intercept the quit accelerator before the
550 // native menu sees it and only quit after the shortcut is held. The
551 // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL.
552 const quitHoldHandler = makeQuitHoldHandler({
553 platform: environment.platform,
554 isEnabled: () =>
555 runPromise(
556 Effect.map(
557 clientSettings.get,
558 Option.match({
559 onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit,
560 onSome: (settings) => settings.confirmQuit,
561 }),
562 ),
563 ),
564 notify: (state) => {
565 if (!window.isDestroyed()) {
566 window.webContents.send(QUIT_SHORTCUT_CHANNEL, state);
567 }
568 },
569 quit: () => {
570 void runPromise(electronApp.quit);
571 },
572 });
536 window.webContents.on("before-input-event", (event, input) => {573 window.webContents.on("before-input-event", (event, input) => {
574 quitHoldHandler(event, input);
537 if (input.type !== "keyDown" || !input.isAutoRepeat) return;575 if (input.type !== "keyDown" || !input.isAutoRepeat) return;
538 const modifier = environment.platform === "darwin" ? input.meta : input.control;576 const modifier = environment.platform === "darwin" ? input.meta : input.control;
539 if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") {577 if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") {
apps/desktop/src/window/QuitHold.test.ts created+201
...@@ -0,0 +1,201 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 makeQuitHoldHandler,
5 QUIT_DOUBLE_TAP_MS,
6 QUIT_HOLD_DURATION_MS,
7 QUIT_HOLD_RELEASE_GRACE_MS,
8} from "./QuitHold.ts";
9import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts";
10
11function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput {
12 return {
13 type: "keyDown",
14 key: "q",
15 meta: true,
16 control: false,
17 alt: false,
18 shift: false,
19 isAutoRepeat: false,
20 ...overrides,
21 };
22}
23
24function makeHarness(options?: {
25 enabled?: boolean;
26 platform?: NodeJS.Platform;
27 isEnabled?: () => Promise<boolean>;
28}) {
29 const notifications: Array<QuitHoldState> = [];
30 const quit = vi.fn();
31 const handler = makeQuitHoldHandler({
32 platform: options?.platform ?? "darwin",
33 isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)),
34 notify: (state) => notifications.push(state),
35 quit,
36 });
37 const preventDefault = vi.fn();
38 const send = async (input: QuitHoldKeyInput) => {
39 handler({ preventDefault }, input);
40 // Let the isEnabled promise settle.
41 await Promise.resolve();
42 await Promise.resolve();
43 };
44 // Simulates the OS auto-repeating the held shortcut every `intervalMs`.
45 const holdFor = async (
46 durationMs: number,
47 repeatOverrides: Partial<QuitHoldKeyInput> = {},
48 intervalMs = 100,
49 ) => {
50 for (let elapsed = 0; elapsed < durationMs; elapsed += intervalMs) {
51 vi.advanceTimersByTime(intervalMs);
52 await send(makeInput({ isAutoRepeat: true, ...repeatOverrides }));
53 }
54 };
55 return { notifications, quit, preventDefault, send, holdFor };
56}
57
58describe("makeQuitHoldHandler", () => {
59 beforeEach(() => {
60 vi.useFakeTimers();
61 });
62 afterEach(() => {
63 vi.useRealTimers();
64 });
65
66 it("shows the hint on a tap without quitting, even when the release is never seen", async () => {
67 // macOS suppresses the letter's keyUp while Cmd is held, so a tap may
68 // produce no keyUp at all. Quit must still not fire.
69 const harness = makeHarness();
70 await harness.send(makeInput({}));
71 expect(harness.preventDefault).toHaveBeenCalledTimes(1);
72 expect(harness.notifications).toEqual(["down"]);
73
74 vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS);
75 expect(harness.quit).not.toHaveBeenCalled();
76 // The watchdog dismisses the hint once the press is clearly over.
77 expect(harness.notifications).toEqual(["down", "up"]);
78 });
79
80 it("quits once the shortcut auto-repeats past the hold duration", async () => {
81 const harness = makeHarness();
82 await harness.send(makeInput({}));
83 await harness.holdFor(QUIT_HOLD_DURATION_MS - 200);
84 expect(harness.quit).not.toHaveBeenCalled();
85 await harness.holdFor(400);
86 expect(harness.quit).toHaveBeenCalledTimes(1);
87 // Exactly one hint cycle for the whole hold.
88 expect(harness.notifications).toEqual(["down", "up"]);
89 });
90
91 it("does not quit when the hold stops before the duration", async () => {
92 const harness = makeHarness();
93 await harness.send(makeInput({}));
94 await harness.holdFor(500);
95 await harness.send(makeInput({ type: "keyUp" }));
96 expect(harness.notifications).toEqual(["down", "up"]);
97 vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);
98 expect(harness.quit).not.toHaveBeenCalled();
99 });
100
101 it("cancels the hold when the modifier is released first", async () => {
102 const harness = makeHarness();
103 await harness.send(makeInput({}));
104 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
105 expect(harness.notifications).toEqual(["down", "up"]);
106 vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);
107 expect(harness.quit).not.toHaveBeenCalled();
108 });
109
110 it("quits immediately on a single press when disabled", async () => {
111 const harness = makeHarness({ enabled: false });
112 await harness.send(makeInput({}));
113 expect(harness.quit).toHaveBeenCalledTimes(1);
114 // The hint is dismissed in case the quit gets cancelled downstream.
115 expect(harness.notifications).toEqual(["down", "up"]);
116 });
117
118 it("discards a stale isEnabled resolution from a superseded press", async () => {
119 // Press #1's isEnabled is still pending when the user releases and
120 // presses again; its late resolution must not act for press #2.
121 const resolvers: Array<(enabled: boolean) => void> = [];
122 const harness = makeHarness({
123 isEnabled: () => new Promise((resolve) => resolvers.push(resolve)),
124 });
125 await harness.send(makeInput({}));
126 await harness.send(makeInput({ type: "keyUp" }));
127 // Outside the double-tap window, so the second press starts a new hold.
128 vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100);
129 await harness.send(makeInput({}));
130 expect(resolvers).toHaveLength(2);
131
132 // Press #1 resolves late with "disabled" — it must not quit press #2.
133 resolvers[0]?.(false);
134 await Promise.resolve();
135 await Promise.resolve();
136 expect(harness.quit).not.toHaveBeenCalled();
137
138 // Press #2 resolves enabled and completes a full hold.
139 resolvers[1]?.(true);
140 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
141 expect(harness.quit).toHaveBeenCalledTimes(1);
142 });
143
144 it("quits on a quick double tap, even when the first release was never seen", async () => {
145 const harness = makeHarness();
146 await harness.send(makeInput({}));
147 vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100);
148 await harness.send(makeInput({}));
149 expect(harness.quit).toHaveBeenCalledTimes(1);
150 });
151
152 it("treats two slow taps as separate presses", async () => {
153 const harness = makeHarness();
154 await harness.send(makeInput({}));
155 await harness.send(makeInput({ type: "keyUp" }));
156 vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100);
157 await harness.send(makeInput({}));
158 expect(harness.quit).not.toHaveBeenCalled();
159 expect(harness.notifications).toEqual(["down", "up", "down"]);
160 });
161
162 it("cancels the hold when another key interrupts it", async () => {
163 const harness = makeHarness();
164 await harness.send(makeInput({}));
165 await harness.holdFor(500);
166 // Shift pressed mid-hold breaks the gesture...
167 await harness.send(makeInput({ shift: true }));
168 expect(harness.notifications).toEqual(["down", "up"]);
169 // ...so later repeats past the threshold must not quit.
170 await harness.holdFor(QUIT_HOLD_DURATION_MS);
171 expect(harness.quit).not.toHaveBeenCalled();
172 });
173
174 it("does not count an interrupted press toward a double tap", async () => {
175 const harness = makeHarness();
176 await harness.send(makeInput({}));
177 await harness.send(makeInput({ shift: true }));
178 // A fresh press right after the interruption starts a new hold, not a
179 // double-tap quit.
180 await harness.send(makeInput({}));
181 expect(harness.quit).not.toHaveBeenCalled();
182 expect(harness.notifications).toEqual(["down", "up", "down"]);
183 });
184
185 it("ignores other shortcuts", async () => {
186 const harness = makeHarness();
187 await harness.send(makeInput({ key: "w" }));
188 await harness.send(makeInput({ shift: true }));
189 await harness.send(makeInput({ meta: false }));
190 expect(harness.preventDefault).not.toHaveBeenCalled();
191 expect(harness.notifications).toEqual([]);
192 });
193
194 it("uses control on non-mac platforms", async () => {
195 const harness = makeHarness({ platform: "linux" });
196 await harness.send(makeInput({ meta: false, control: true }));
197 expect(harness.preventDefault).toHaveBeenCalledTimes(1);
198 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true });
199 expect(harness.quit).toHaveBeenCalledTimes(1);
200 });
201});
apps/desktop/src/window/QuitHold.ts created+148
...@@ -0,0 +1,148 @@
1// @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime.
2
3// Chrome-style hold-to-quit. The quit accelerator is intercepted in
4// before-input-event (which runs before the native menu accelerator), and the
5// app only quits once the shortcut has been held for QUIT_HOLD_DURATION_MS.
6// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap
7// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application
8// menu itself is untouched and quits immediately.
9export const QUIT_HOLD_DURATION_MS = 1200;
10// A second quick tap of the shortcut is the user insisting: quit immediately.
11export const QUIT_DOUBLE_TAP_MS = 500;
12// "Still held" is proven by auto-repeat keydowns, not by the absence of a
13// release: macOS suppresses a letter's keyUp while the command key is down, so
14// a tap's release can go completely unseen and a release-based timer would
15// quit anyway. The press is treated as released once no key event has arrived
16// for QUIT_HOLD_RELEASE_GRACE_MS past the hold duration. Keyboards with
17// auto-repeat disabled cannot hold-to-quit and fall back to the menu's Quit.
18export const QUIT_HOLD_RELEASE_GRACE_MS = 600;
19
20export type QuitHoldState = "down" | "up";
21
22export interface QuitHoldKeyInput {
23 readonly type: string;
24 readonly key: string;
25 readonly meta: boolean;
26 readonly control: boolean;
27 readonly alt: boolean;
28 readonly shift: boolean;
29 readonly isAutoRepeat: boolean;
30}
31
32export interface QuitHoldOptions {
33 readonly platform: NodeJS.Platform;
34 readonly isEnabled: () => Promise<boolean>;
35 readonly notify: (state: QuitHoldState) => void;
36 readonly quit: () => void;
37}
38
39export function makeQuitHoldHandler(
40 options: QuitHoldOptions,
41): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void {
42 const modifierKey = options.platform === "darwin" ? "meta" : "control";
43 let watchdog: NodeJS.Timeout | undefined;
44 let holding = false;
45 // Set once isEnabled resolves true; auto-repeats may only quit when armed.
46 let armed = false;
47 let heldSince = 0;
48 let lastPressAt = 0;
49 // Incremented on every new press and every release/quit so a pending
50 // isEnabled() resolution from a superseded press cannot arm (or quit for)
51 // the current one.
52 let generation = 0;
53
54 const clearWatchdog = () => {
55 if (watchdog !== undefined) {
56 clearTimeout(watchdog);
57 watchdog = undefined;
58 }
59 };
60
61 const release = () => {
62 if (!holding) return;
63 generation += 1;
64 holding = false;
65 armed = false;
66 clearWatchdog();
67 options.notify("up");
68 };
69
70 // Dismisses the overlay first: if the quit is cancelled downstream the
71 // renderer must not be left with a stuck "Hold to Quit" hint.
72 const quitNow = () => {
73 release();
74 options.quit();
75 };
76
77 return (event, input) => {
78 const key = input.key.toLowerCase();
79 if (input.type === "keyUp") {
80 if (key === "q" || key === modifierKey) release();
81 return;
82 }
83 if (input.type !== "keyDown") return;
84
85 const modifierDown = options.platform === "darwin" ? input.meta : input.control;
86 if (!modifierDown || input.alt || input.shift || key !== "q") {
87 // Any other key (or an extra modifier) pressed mid-hold breaks the
88 // gesture; without this the hold timer keeps running through the
89 // interruption and the next qualifying repeat would quit early. The
90 // interrupted press also stops counting toward a double tap — but only
91 // here, not in release(), which runs mid-restart on an unseen-release
92 // re-press and must not wipe that press's own tap timestamp.
93 if (holding && !input.isAutoRepeat) {
94 lastPressAt = 0;
95 release();
96 }
97 return;
98 }
99
100 event.preventDefault();
101
102 if (input.isAutoRepeat) {
103 if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) {
104 quitNow();
105 }
106 return;
107 }
108
109 const now = Date.now();
110 const previousPressAt = lastPressAt;
111 lastPressAt = now;
112 // A fresh keydown while "holding" means the key came back down after a
113 // release macOS never delivered — so both branches below see real taps.
114 if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) {
115 quitNow();
116 return;
117 }
118 if (holding) release();
119
120 generation += 1;
121 const pressGeneration = generation;
122 holding = true;
123 heldSince = now;
124 options.notify("down");
125 void options.isEnabled().then(
126 (enabled) => {
127 if (generation !== pressGeneration) return;
128 if (!enabled) {
129 // Hold-to-quit disabled: a single press quits immediately.
130 quitNow();
131 return;
132 }
133 armed = true;
134 // No auto-repeat by then means the key was released (possibly with a
135 // suppressed keyUp) or repeat is disabled; either way, don't quit.
136 watchdog = setTimeout(() => {
137 watchdog = undefined;
138 release();
139 }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS);
140 },
141 // A failed settings read must never strand the quit request.
142 () => {
143 if (generation !== pressGeneration) return;
144 quitNow();
145 },
146 );
147 };
148}
apps/web/src/AppRoot.test.tsx+3-1
...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
44
5import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";5import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
6import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";6import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
7import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
7import { AppAtomRegistryProvider } from "./rpc/atomRegistry";8import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
8import type { AppRouter } from "./router";9import type { AppRouter } from "./router";
9import { AppRoot } from "./AppRoot";10import { AppRoot } from "./AppRoot";
...@@ -16,9 +17,10 @@ describe("AppRoot", () => {...@@ -16,9 +17,10 @@ describe("AppRoot", () => {
16 const children = Children.toArray(17 const children = Children.toArray(
17 (root as ReactElement<{ readonly children: ReactNode }>).props.children,18 (root as ReactElement<{ readonly children: ReactNode }>).props.children,
18 );19 );
19 expect(children).toHaveLength(3);20 expect(children).toHaveLength(4);
20 expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider);21 expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider);
21 expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts);22 expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts);
22 expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost);23 expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost);
24 expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay);
23 });25 });
24});26});
apps/web/src/AppRoot.tsx+2
...@@ -2,6 +2,7 @@ import { RouterProvider } from "@tanstack/react-router";...@@ -2,6 +2,7 @@ import { RouterProvider } from "@tanstack/react-router";
22
3import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";3import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
4import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";4import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
5import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
5import { AppAtomRegistryProvider } from "./rpc/atomRegistry";6import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
6import type { AppRouter } from "./router";7import type { AppRouter } from "./router";
78
...@@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {...@@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {
16 <RouterProvider router={router} />17 <RouterProvider router={router} />
17 <PreviewAutomationHosts />18 <PreviewAutomationHosts />
18 <ElectronBrowserHost />19 <ElectronBrowserHost />
20 <QuitHoldOverlay />
19 </AppAtomRegistryProvider>21 </AppAtomRegistryProvider>
20 );22 );
21}23}
apps/web/src/components/QuitHoldOverlay.tsx created+47
...@@ -0,0 +1,47 @@
1import { useEffect, useState } from "react";
2
3import { isMacPlatform } from "../lib/utils";
4
5// Matches the hold duration in apps/desktop/src/window/QuitHold.ts: the hint
6// from a quick tap lingers for as long as a full hold would have taken.
7const HIDE_AFTER_RELEASE_MS = 1200;
8
9/**
10 * Chrome-style "Hold ⌘Q to Quit" hint. The desktop main process intercepts
11 * the quit accelerator and pushes press/release states; a quick tap shows
12 * this pill while a full hold quits the app.
13 */
14export function QuitHoldOverlay() {
15 const [visible, setVisible] = useState(false);
16
17 useEffect(() => {
18 const subscribe = window.desktopBridge?.onQuitShortcut;
19 if (!subscribe) return;
20 let hideTimer: number | undefined;
21 const unsubscribe = subscribe((state) => {
22 window.clearTimeout(hideTimer);
23 if (state === "down") {
24 setVisible(true);
25 return;
26 }
27 hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS);
28 });
29 return () => {
30 window.clearTimeout(hideTimer);
31 unsubscribe();
32 };
33 }, []);
34
35 if (!visible) return null;
36 const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q";
37 return (
38 <div
39 role="status"
40 className="pointer-events-none fixed inset-x-0 top-[22%] z-100 flex justify-center"
41 >
42 <div className="rounded-full bg-neutral-700/95 px-8 py-4 text-2xl font-bold text-white shadow-xl">
43 Hold {shortcut} to Quit
44 </div>
45 </div>
46 );
47}
apps/web/src/components/settings/SettingsPanels.tsx+29
...@@ -526,11 +526,15 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -526,11 +526,15 @@ export function useSettingsRestore(onRestored?: () => void) {
526 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete526 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
527 ? ["Delete confirmation"]527 ? ["Delete confirmation"]
528 : []),528 : []),
529 ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit
530 ? ["Quit confirmation"]
531 : []),
529 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),532 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),
530 ],533 ],
531 [534 [
532 isTextGenerationModelDirty,535 isTextGenerationModelDirty,
533 isBackgroundActivityDirty,536 isBackgroundActivityDirty,
537 settings.confirmQuit,
534 settings.confirmThreadArchive,538 settings.confirmThreadArchive,
535 settings.confirmThreadDelete,539 settings.confirmThreadDelete,
536 settings.addProjectBaseDirectory,540 settings.addProjectBaseDirectory,
...@@ -644,6 +648,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -644,6 +648,7 @@ export function useSettingsRestore(onRestored?: () => void) {
644 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,648 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
645 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,649 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
646 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,650 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
651 confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit,
647 textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,652 textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
648 fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans,653 fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans,
649 fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer,654 fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer,
...@@ -2234,6 +2239,30 @@ export function GeneralSettingsPanel() {...@@ -2234,6 +2239,30 @@ export function GeneralSettingsPanel() {
2234 }2239 }
2235 />2240 />
22362241
2242 {isElectron ? (
2243 <SettingsRow
2244 {...searchableSetting("quit-confirmation")}
2245 description="Require holding the quit shortcut before the desktop app quits. A quick tap shows a hint instead."
2246 resetAction={
2247 settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? (
2248 <SettingResetButton
2249 label="quit confirmation"
2250 onClick={() =>
2251 updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit })
2252 }
2253 />
2254 ) : null
2255 }
2256 control={
2257 <Switch
2258 checked={settings.confirmQuit}
2259 onCheckedChange={(checked) => updateSettings({ confirmQuit: Boolean(checked) })}
2260 aria-label="Hold to quit"
2261 />
2262 }
2263 />
2264 ) : null}
2265
2237 <SettingsRow2266 <SettingsRow
2238 {...searchableSetting("text-generation-model")}2267 {...searchableSetting("text-generation-model")}
2239 description="Default model for generated text like thread titles and source control content. Source control settings can override it with a dedicated source control writer model."2268 description="Default model for generated text like thread titles and source control content. Source control settings can override it with a dedicated source control writer model."
apps/web/src/components/settings/settingsSearch.test.ts+5
...@@ -60,6 +60,11 @@ describe("searchSettings", () => {...@@ -60,6 +60,11 @@ describe("searchSettings", () => {
60 expect(searchSettings(" ", ITEMS)).toEqual([]);60 expect(searchSettings(" ", ITEMS)).toEqual([]);
61 });61 });
6262
63 it("hides desktop-only settings from browser search", () => {
64 expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true);
65 expect(searchSettings("quit confirmation")).toEqual([]);
66 });
67
63 it("keeps catalog result ids unique", () => {68 it("keeps catalog result ids unique", () => {
64 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);69 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
65 expect(new Set(ids).size).toBe(ids.length);70 expect(new Set(ids).size).toBe(ids.length);
apps/web/src/components/settings/settingsSearch.ts+16-1
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { isElectron } from "~/env";
2
1export type SettingsPath =3export type SettingsPath =
2 | "/settings/general"4 | "/settings/general"
3 | "/settings/appearance"5 | "/settings/appearance"
...@@ -12,6 +14,9 @@ export interface SettingsSearchItem {...@@ -12,6 +14,9 @@ export interface SettingsSearchItem {
12 readonly title: string;14 readonly title: string;
13 readonly to: SettingsPath;15 readonly to: SettingsPath;
14 readonly targetId?: string;16 readonly targetId?: string;
17 // Its row only renders in the desktop app, so a browser result would land on
18 // an anchor that isn't there.
19 readonly desktopOnly?: boolean;
15}20}
1621
17/**22/**
...@@ -149,6 +154,12 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -149,6 +154,12 @@ export const SETTINGS_SEARCH_ITEMS = [
149 title: "Delete confirmation",154 title: "Delete confirmation",
150 to: "/settings/general",155 to: "/settings/general",
151 },156 },
157 {
158 id: "quit-confirmation",
159 title: "Hold to quit",
160 to: "/settings/general",
161 desktopOnly: true,
162 },
152 {163 {
153 id: "text-generation-model",164 id: "text-generation-model",
154 title: "Text generation model",165 title: "Text generation model",
...@@ -236,5 +247,9 @@ export function searchSettings(...@@ -236,5 +247,9 @@ export function searchSettings(
236 const normalizedQuery = normalizeSearchText(query);247 const normalizedQuery = normalizeSearchText(query);
237 if (normalizedQuery.length === 0) return [];248 if (normalizedQuery.length === 0) return [];
238249
239 return items.filter((item) => normalizeSearchText(item.title).includes(normalizedQuery));250 return items.filter(
251 (item) =>
252 (isElectron || item.desktopOnly !== true) &&
253 normalizeSearchText(item.title).includes(normalizedQuery),
254 );
240}255}
packages/contracts/src/ipc.ts+6
...@@ -1080,6 +1080,12 @@ export interface DesktopBridge {...@@ -1080,6 +1080,12 @@ export interface DesktopBridge {
1080 */1080 */
1081 probeRemoteEditors?: () => Promise<readonly EditorId[]>;1081 probeRemoteEditors?: () => Promise<readonly EditorId[]>;
1082 onMenuAction: (listener: (action: string) => void) => () => void;1082 onMenuAction: (listener: (action: string) => void) => () => void;
1083 /**
1084 * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed,
1085 * "up" when it is released before the hold completes. Optional: older
1086 * desktop builds never emit it.
1087 */
1088 onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void;
1083 getWindowFullscreenState: () => boolean;1089 getWindowFullscreenState: () => boolean;
1084 onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;1090 onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;
1085 getUpdateState: () => Promise<DesktopUpdateState>;1091 getUpdateState: () => Promise<DesktopUpdateState>;
packages/contracts/src/settings.ts+4
...@@ -112,6 +112,9 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200))...@@ -112,6 +112,9 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200))
112export type FontFamilyPreference = typeof FontFamilyPreference.Type;112export type FontFamilyPreference = typeof FontFamilyPreference.Type;
113113
114export const ClientSettingsSchema = Schema.Struct({114export const ClientSettingsSchema = Schema.Struct({
115 // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the
116 // app quits; a quick tap only shows a hint. Browser clients ignore it.
117 confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
115 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),118 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
116 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),119 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
117 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(120 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
...@@ -756,6 +759,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -756,6 +759,7 @@ export const ServerSettingsPatch = Schema.Struct({
756export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;759export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
757760
758export const ClientSettingsPatch = Schema.Struct({761export const ClientSettingsPatch = Schema.Struct({
762 confirmQuit: Schema.optionalKey(Schema.Boolean),
759 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),763 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
760 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),764 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
761 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),765 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),