History

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

Bilal Bakr committed 3 months ago

Commit b7dbbba1 parent 178da6b16 files · +539 −3Browse files

Unified · Split

16 files
  1. apps/desktop/src/ipc/channels.ts +1 −0
  2. apps/desktop/src/ipc/methods/wsl.test.ts +12 −0
  3. apps/desktop/src/preload.ts +11 −0
  4. apps/desktop/src/settings/DesktopClientSettings.test.ts +1 −0
  5. apps/desktop/src/window/DesktopWindow.test.ts +14 −0
  6. apps/desktop/src/window/DesktopWindow.ts +39 −1
  7. apps/desktop/src/window/QuitHold.test.ts +201 −0
  8. apps/desktop/src/window/QuitHold.ts +148 −0
  9. apps/web/src/AppRoot.test.tsx +3 −1
  10. apps/web/src/AppRoot.tsx +2 −0
  11. apps/web/src/components/QuitHoldOverlay.tsx +47 −0
  12. apps/web/src/components/settings/SettingsPanels.tsx +29 −0
  13. apps/web/src/components/settings/settingsSearch.test.ts +5 −0
  14. apps/web/src/components/settings/settingsSearch.ts +16 −1
  15. packages/contracts/src/ipc.ts +6 −0
  16. packages/contracts/src/settings.ts +4 −0
apps/desktop/src/ipc/channels.ts+1−0
@@ -5,6 +5,7 @@ export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; · 4 unmodified lines
55 export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
66 export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
77 export const MENU_ACTION_CHANNEL = "desktop:menu-action";
8+export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
89 export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
910 export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
1011 export const UPDATE_STATE_CHANNEL = "desktop:update-state";
apps/desktop/src/ipc/methods/wsl.test.ts+12−0
@@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; · 9 unmodified lines
1010 import * as DesktopShutdown from "../../app/DesktopShutdown.ts";
1111 import * as DesktopState from "../../app/DesktopState.ts";
1212 import * as ElectronApp from "../../electron/ElectronApp.ts";
13+import * as ElectronDialog from "../../electron/ElectronDialog.ts";
1314 import * as ElectronTheme from "../../electron/ElectronTheme.ts";
15+import * as ElectronWindow from "../../electron/ElectronWindow.ts";
1416 import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
17+import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
1518 import * as DesktopWindow from "../../window/DesktopWindow.ts";
1619 import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
1720 import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
@@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( · 52 unmodified lines
7073 ElectronTheme.ElectronTheme,
7174 ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]),
7275 ),
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(),
7385 );
7486
7587 describe("WSL IPC", () => {
apps/desktop/src/preload.ts+11−0
@@ -117,6 +117,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 116 unmodified lines
117117 ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
118118 };
119119 },
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+ },
120131 getWindowFullscreenState: () =>
121132 ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true,
122133 onWindowFullscreenStateChange: (listener) => {
apps/desktop/src/settings/DesktopClientSettings.test.ts+1−0
@@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; · 12 unmodified lines
1313 import * as DesktopClientSettings from "./DesktopClientSettings.ts";
1414
1515 const clientSettings: ClientSettings = {
16+ confirmQuit: true,
1617 confirmThreadArchive: true,
1718 confirmThreadDelete: false,
1819 dismissedProviderUpdateNotificationKeys: [],
apps/desktop/src/window/DesktopWindow.test.ts+14−0
@@ -37,6 +37,8 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; · 36 unmodified lines
3737 import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
3838 import * as DesktopState from "../app/DesktopState.ts";
3939 import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
40+import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
41+import * as ElectronApp from "../electron/ElectronApp.ts";
4042 import * as ElectronMenu from "../electron/ElectronMenu.ts";
4143 import * as ElectronShell from "../electron/ElectronShell.ts";
4244 import * as ElectronTheme from "../electron/ElectronTheme.ts";
@@ -128,6 +130,14 @@ function makeFakeBrowserWindow() { · 85 unmodified lines
128130 };
129131 }
130132
133+const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({
134+ get: Effect.succeed(Option.none()),
135+});
136+
137+const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({
138+ quit: Effect.void,
139+});
140+
131141 const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, {
132142 iconPaths: Effect.succeed({
133143 ico: Option.none<string>(),
@@ -253,8 +263,10 @@ function makeTestLayer(input: { · 119 unmodified lines
253263 desktopAssetsLayer,
254264 desktopEnvironmentLayer,
255265 desktopAppSettingsLayer,
266+ desktopClientSettingsLayer,
256267 desktopServerExposureLayer,
257268 DesktopState.layer,
269+ electronAppLayer,
258270 electronMenuLayer,
259271 Layer.succeed(ElectronShell.ElectronShell, {
260272 openExternal: (url) =>
@@ -356,7 +368,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n · 95 unmodified lines
356368 desktopAssetsLayer,
357369 desktopEnvironmentLayer,
358370 DesktopAppSettings.layerTest(),
371+ desktopClientSettingsLayer,
359372 desktopServerExposureLayer,
373+ electronAppLayer,
360374 electronMenuLayer,
361375 Layer.succeed(ElectronShell.ElectronShell, {
362376 openExternal: () => Effect.succeed(true),
apps/desktop/src/window/DesktopWindow.ts+39−1
@@ -8,6 +8,8 @@ import * as Ref from "effect/Ref"; · 7 unmodified lines
88
99 import * as Electron from "electron";
1010
11+import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
12+
1113 import * as DesktopAssets from "../app/DesktopAssets.ts";
1214 import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1315 import { makeComponentLogger } from "../app/DesktopObservability.ts";
@@ -16,9 +18,16 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; · 2 unmodified lines
1618 import * as ElectronShell from "../electron/ElectronShell.ts";
1719 import * as ElectronTheme from "../electron/ElectronTheme.ts";
1820 import * as ElectronWindow from "../electron/ElectronWindow.ts";
19−import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts";
21+import {
22+ MENU_ACTION_CHANNEL,
23+ QUIT_SHORTCUT_CHANNEL,
24+ WINDOW_FULLSCREEN_STATE_CHANNEL,
25+} from "../ipc/channels.ts";
2026 import * as PreviewManager from "../preview/Manager.ts";
2127 import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
28+import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
29+import * as ElectronApp from "../electron/ElectronApp.ts";
30+import { makeQuitHoldHandler } from "./QuitHold.ts";
2231
2332 const TITLEBAR_HEIGHT = 40;
2433 const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
@@ -51,6 +60,8 @@ type DesktopWindowRuntimeServices = · 26 unmodified lines
5160 | DesktopEnvironment.DesktopEnvironment
5261 | DesktopAssets.DesktopAssets
5362 | DesktopAppSettings.DesktopAppSettings
63+ | DesktopClientSettings.DesktopClientSettings
64+ | ElectronApp.ElectronApp
5465 | ElectronMenu.ElectronMenu
5566 | ElectronShell.ElectronShell
5667 | ElectronTheme.ElectronTheme
@@ -261,6 +272,8 @@ export const make = Effect.gen(function* () { · 204 unmodified lines
261272 const electronWindow = yield* ElectronWindow.ElectronWindow;
262273 const previewManager = yield* PreviewManager.PreviewManager;
263274 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
275+ const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
276+ const electronApp = yield* ElectronApp.ElectronApp;
264277 // Window-side latch for the primary backend's readiness. Set by
265278 // handleBackendReady (driven by the pool's onReady callback), cleared
266279 // by handleBackendNotReady (driven by onShutdown). Only consumed by
@@ -533,7 +546,32 @@ export const make = Effect.gen(function* () { · 266 unmodified lines
533546 // close-terminal shortcut can outlive the terminal that handled its first
534547 // press, so reject repeats before they reach the native window accelerator.
535548 // 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+ });
536573 window.webContents.on("before-input-event", (event, input) => {
574+ quitHoldHandler(event, input);
537575 if (input.type !== "keyDown" || !input.isAutoRepeat) return;
538576 const modifier = environment.platform === "darwin" ? input.meta : input.control;
539577 if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") {
apps/desktop/src/window/QuitHold.test.ts+201−0
@@ -0,0 +1,201 @@
1+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2+
3+import {
4+ makeQuitHoldHandler,
5+ QUIT_DOUBLE_TAP_MS,
6+ QUIT_HOLD_DURATION_MS,
7+ QUIT_HOLD_RELEASE_GRACE_MS,
8+} from "./QuitHold.ts";
9+import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts";
10+
11+function 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+
24+function 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+
58+describe("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+148−0
@@ -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.
9+export const QUIT_HOLD_DURATION_MS = 1200;
10+// A second quick tap of the shortcut is the user insisting: quit immediately.
11+export 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.
18+export const QUIT_HOLD_RELEASE_GRACE_MS = 600;
19+
20+export type QuitHoldState = "down" | "up";
21+
22+export 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+
32+export interface QuitHoldOptions {
33+ readonly platform: NodeJS.Platform;
34+ readonly isEnabled: () => Promise<boolean>;
35+ readonly notify: (state: QuitHoldState) => void;
36+ readonly quit: () => void;
37+}
38+
39+export 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"; · 3 unmodified lines
44
55 import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
66 import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
7+import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
78 import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
89 import type { AppRouter } from "./router";
910 import { AppRoot } from "./AppRoot";
@@ -16,9 +17,10 @@ describe("AppRoot", () => { · 6 unmodified lines
1617 const children = Children.toArray(
1718 (root as ReactElement<{ readonly children: ReactNode }>).props.children,
1819 );
19− expect(children).toHaveLength(3);
20+ expect(children).toHaveLength(4);
2021 expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider);
2122 expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts);
2223 expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost);
24+ expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay);
2325 });
2426 });
apps/web/src/AppRoot.tsx+2−0
@@ -2,6 +2,7 @@ import { RouterProvider } from "@tanstack/react-router"; · 1 unmodified line
22
33 import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
44 import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
5+import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
56 import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
67 import type { AppRouter } from "./router";
78
@@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { · 8 unmodified lines
1617 <RouterProvider router={router} />
1718 <PreviewAutomationHosts />
1819 <ElectronBrowserHost />
20+ <QuitHoldOverlay />
1921 </AppAtomRegistryProvider>
2022 );
2123 }
apps/web/src/components/QuitHoldOverlay.tsx+47−0
@@ -0,0 +1,47 @@
1+import { useEffect, useState } from "react";
2+
3+import { 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.
7+const 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+ */
14+export 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−0
@@ -526,11 +526,15 @@ export function useSettingsRestore(onRestored?: () => void) { · 525 unmodified lines
526526 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
527527 ? ["Delete confirmation"]
528528 : []),
529+ ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit
530+ ? ["Quit confirmation"]
531+ : []),
529532 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),
530533 ],
531534 [
532535 isTextGenerationModelDirty,
533536 isBackgroundActivityDirty,
537+ settings.confirmQuit,
534538 settings.confirmThreadArchive,
535539 settings.confirmThreadDelete,
536540 settings.addProjectBaseDirectory,
@@ -644,6 +648,7 @@ export function useSettingsRestore(onRestored?: () => void) { · 107 unmodified lines
644648 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
645649 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
646650 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
651+ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit,
647652 textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
648653 fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans,
649654 fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer,
@@ -2234,6 +2239,30 @@ export function GeneralSettingsPanel() { · 1584 unmodified lines
22342239 }
22352240 />
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+
22372266 <SettingsRow
22382267 {...searchableSetting("text-generation-model")}
22392268 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−0
@@ -60,6 +60,11 @@ describe("searchSettings", () => { · 59 unmodified lines
6060 expect(searchSettings(" ", ITEMS)).toEqual([]);
6161 });
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+
6368 it("keeps catalog result ids unique", () => {
6469 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
6570 expect(new Set(ids).size).toBe(ids.length);
apps/web/src/components/settings/settingsSearch.ts+16−1
@@ -1,3 +1,5 @@
1+import { isElectron } from "~/env";
2+
13 export type SettingsPath =
24 | "/settings/general"
35 | "/settings/appearance"
@@ -12,6 +14,9 @@ export interface SettingsSearchItem { · 8 unmodified lines
1214 readonly title: string;
1315 readonly to: SettingsPath;
1416 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;
1520 }
1621
1722 /**
@@ -149,6 +154,12 @@ export const SETTINGS_SEARCH_ITEMS = [ · 131 unmodified lines
149154 title: "Delete confirmation",
150155 to: "/settings/general",
151156 },
157+ {
158+ id: "quit-confirmation",
159+ title: "Hold to quit",
160+ to: "/settings/general",
161+ desktopOnly: true,
162+ },
152163 {
153164 id: "text-generation-model",
154165 title: "Text generation model",
@@ -236,5 +247,9 @@ export function searchSettings( · 81 unmodified lines
236247 const normalizedQuery = normalizeSearchText(query);
237248 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+ );
240255 }
packages/contracts/src/ipc.ts+6−0
@@ -1080,6 +1080,12 @@ export interface DesktopBridge { · 1079 unmodified lines
10801080 */
10811081 probeRemoteEditors?: () => Promise<readonly EditorId[]>;
10821082 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;
10831089 getWindowFullscreenState: () => boolean;
10841090 onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;
10851091 getUpdateState: () => Promise<DesktopUpdateState>;
packages/contracts/src/settings.ts+4−0
@@ -112,6 +112,9 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) · 111 unmodified lines
112112 export type FontFamilyPreference = typeof FontFamilyPreference.Type;
113113
114114 export 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))),
115118 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
116119 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
117120 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
@@ -756,6 +759,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 638 unmodified lines
756759 export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
757760
758761 export const ClientSettingsPatch = Schema.Struct({
762+ confirmQuit: Schema.optionalKey(Schema.Boolean),
759763 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
760764 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
761765 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),