authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-01 13:11:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 13:11:16-07:00
log9d1879b142a2f5d01383357646a4679d1a2bd202
treee7002b9c4102c7f0e7d24a4911e54c5b7e2957a3
parentef7014d851f56bb037a9da963095ffd883c7fa08
signature Signed by PGP key B5690EEEBB952194

feat(desktop): add configurable quit shortcut confirmation (#9076)


11 files changed, 364 insertions(+), 131 deletions(-)

apps/desktop/src/preload.ts+13-3
......@@ -128,9 +128,19 @@ contextBridge.exposeInMainWorld("desktopBridge", {
128128 };
129129 },
130130 onQuitShortcut: (listener) => {
131 const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
132 if (state !== "down" && state !== "up") return;
133 listener(state);
131 const wrappedListener = (_event: Electron.IpcRendererEvent, hint: unknown) => {
132 if (typeof hint !== "object" || hint === null || !("state" in hint)) return;
133 if (hint.state === "up") {
134 listener({ state: "up" });
135 return;
136 }
137 if (
138 hint.state === "down" &&
139 "mode" in hint &&
140 (hint.mode === "hold" || hint.mode === "double-click")
141 ) {
142 listener({ state: "down", mode: hint.mode });
143 }
134144 };
135145
136146 ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener);
apps/desktop/src/settings/DesktopClientSettings.test.ts+1-1
......@@ -19,7 +19,7 @@ const clientSettings: ClientSettings = {
1919 browserDefaultAppearance: "dark",
2020 browserRecordingFrameRate: 60,
2121 browserAutoShowFloatingPreview: false,
22 confirmQuit: true,
22 confirmQuit: "double-click",
2323 confirmThreadArchive: true,
2424 confirmThreadDelete: false,
2525 confirmThreadUnpin: false,
apps/desktop/src/window/DesktopWindow.ts+8-9
......@@ -27,7 +27,7 @@ import * as PreviewManager from "../preview/Manager.ts";
2727import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
2828import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
2929import * as ElectronApp from "../electron/ElectronApp.ts";
30import { makeQuitHoldHandler } from "./QuitHold.ts";
30import { makeQuitShortcutHandler } from "./QuitHold.ts";
3131
3232const TITLEBAR_HEIGHT = 40;
3333const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
......@@ -551,12 +551,11 @@ export const make = Effect.gen(function* () {
551551 // close-terminal shortcut can outlive the terminal that handled its first
552552 // press, so reject repeats before they reach the native window accelerator.
553553 // Deliberate presses still flow through the renderer or native menu.
554 // Chrome-style hold-to-quit: intercept the quit accelerator before the
555 // native menu sees it and only quit after the shortcut is held. The
556 // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL.
557 const quitHoldHandler = makeQuitHoldHandler({
554 // Intercept the quit accelerator before the native menu sees it and apply
555 // the configured direct, hold, or double-press behavior.
556 const quitShortcutHandler = makeQuitShortcutHandler({
558557 platform: environment.platform,
559 isEnabled: () =>
558 getMode: () =>
560559 runPromise(
561560 Effect.map(
562561 clientSettings.get,
......@@ -566,9 +565,9 @@ export const make = Effect.gen(function* () {
566565 }),
567566 ),
568567 ),
569 notify: (state) => {
568 notify: (hint) => {
570569 if (!window.isDestroyed()) {
571 window.webContents.send(QUIT_SHORTCUT_CHANNEL, state);
570 window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint);
572571 }
573572 },
574573 quit: () => {
......@@ -576,7 +575,7 @@ export const make = Effect.gen(function* () {
576575 },
577576 });
578577 window.webContents.on("before-input-event", (event, input) => {
579 quitHoldHandler(event, input);
578 quitShortcutHandler(event, input);
580579 if (input.type !== "keyDown" || !input.isAutoRepeat) return;
581580 const modifier = environment.platform === "darwin" ? input.meta : input.control;
582581 if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") {
apps/desktop/src/window/QuitHold.test.ts+162-40
......@@ -1,12 +1,17 @@
11import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22
33import {
4 makeQuitHoldHandler,
5 QUIT_DOUBLE_TAP_MS,
4 makeQuitShortcutHandler,
5 QUIT_DOUBLE_PRESS_MS,
66 QUIT_HOLD_DURATION_MS,
77 QUIT_HOLD_RELEASE_GRACE_MS,
88} from "./QuitHold.ts";
9import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts";
9import type { QuitHoldKeyInput } from "./QuitHold.ts";
10import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts";
11
12const HOLD_DOWN = { state: "down", mode: "hold" } as const;
13const DOUBLE_CLICK_DOWN = { state: "down", mode: "double-click" } as const;
14const UP = { state: "up" } as const;
1015
1116function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput {
1217 return {
......@@ -22,22 +27,22 @@ function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput {
2227}
2328
2429function makeHarness(options?: {
25 enabled?: boolean;
30 mode?: QuitConfirmationMode;
2631 platform?: NodeJS.Platform;
27 isEnabled?: () => Promise<boolean>;
32 getMode?: () => Promise<QuitConfirmationMode>;
2833}) {
29 const notifications: Array<QuitHoldState> = [];
34 const notifications: Array<QuitShortcutHintEvent> = [];
3035 const quit = vi.fn();
31 const handler = makeQuitHoldHandler({
36 const handler = makeQuitShortcutHandler({
3237 platform: options?.platform ?? "darwin",
33 isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)),
34 notify: (state) => notifications.push(state),
38 getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")),
39 notify: (event) => notifications.push(event),
3540 quit,
3641 });
3742 const preventDefault = vi.fn();
3843 const send = async (input: QuitHoldKeyInput) => {
3944 handler({ preventDefault }, input);
40 // Let the isEnabled promise settle.
45 // Let the getMode promise settle.
4146 await Promise.resolve();
4247 await Promise.resolve();
4348 };
......@@ -55,7 +60,7 @@ function makeHarness(options?: {
5560 return { notifications, quit, preventDefault, send, holdFor };
5661}
5762
58describe("makeQuitHoldHandler", () => {
63describe("makeQuitShortcutHandler", () => {
5964 beforeEach(() => {
6065 vi.useFakeTimers();
6166 });
......@@ -69,12 +74,12 @@ describe("makeQuitHoldHandler", () => {
6974 const harness = makeHarness();
7075 await harness.send(makeInput({}));
7176 expect(harness.preventDefault).toHaveBeenCalledTimes(1);
72 expect(harness.notifications).toEqual(["down"]);
77 expect(harness.notifications).toEqual([HOLD_DOWN]);
7378
7479 vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS);
7580 expect(harness.quit).not.toHaveBeenCalled();
7681 // The watchdog dismisses the hint once the press is clearly over.
77 expect(harness.notifications).toEqual(["down", "up"]);
82 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
7883 });
7984
8085 it("quits after a completed hold is released", async () => {
......@@ -86,7 +91,7 @@ describe("makeQuitHoldHandler", () => {
8691 expect(harness.quit).not.toHaveBeenCalled();
8792 vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);
8893 expect(harness.quit).toHaveBeenCalledTimes(1);
89 expect(harness.notifications).toEqual(["down", "up"]);
94 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
9095 });
9196
9297 it("waits for Q release when Cmd is released first", async () => {
......@@ -108,7 +113,7 @@ describe("makeQuitHoldHandler", () => {
108113 await harness.send(makeInput({}));
109114 await harness.holdFor(500);
110115 await harness.send(makeInput({ type: "keyUp" }));
111 expect(harness.notifications).toEqual(["down", "up"]);
116 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
112117 vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);
113118 expect(harness.quit).not.toHaveBeenCalled();
114119 });
......@@ -117,61 +122,179 @@ describe("makeQuitHoldHandler", () => {
117122 const harness = makeHarness();
118123 await harness.send(makeInput({}));
119124 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
120 expect(harness.notifications).toEqual(["down", "up"]);
125 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
121126 vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);
122127 expect(harness.quit).not.toHaveBeenCalled();
123128 });
124129
125 it("quits without showing a hint when hold-to-quit is disabled", async () => {
126 const harness = makeHarness({ enabled: false });
130 it("quits without showing a hint in direct mode", async () => {
131 const harness = makeHarness({ mode: "direct" });
127132 await harness.send(makeInput({}));
128133 expect(harness.quit).toHaveBeenCalledTimes(1);
129134 expect(harness.notifications).toEqual([]);
130135 });
131136
132 it("discards a stale isEnabled resolution from a superseded press", async () => {
133 // Press #1's isEnabled is still pending when the user releases and
137 it("honors direct mode when the key is released before its mode read settles", async () => {
138 let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined;
139 const harness = makeHarness({
140 getMode: () =>
141 new Promise((resolve) => {
142 resolveMode = resolve;
143 }),
144 });
145 await harness.send(makeInput({}));
146 await harness.send(makeInput({ type: "keyUp" }));
147
148 resolveMode?.("direct");
149 await Promise.resolve();
150 await Promise.resolve();
151
152 expect(harness.quit).toHaveBeenCalledTimes(1);
153 expect(harness.notifications).toEqual([]);
154 });
155
156 it("does not arm hold mode after a released key's mode read settles", async () => {
157 let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined;
158 const harness = makeHarness({
159 getMode: () =>
160 new Promise((resolve) => {
161 resolveMode = resolve;
162 }),
163 });
164 await harness.send(makeInput({}));
165 await harness.send(makeInput({ type: "keyUp" }));
166
167 resolveMode?.("hold");
168 await Promise.resolve();
169 await Promise.resolve();
170
171 expect(harness.quit).not.toHaveBeenCalled();
172 expect(harness.notifications).toEqual([]);
173 });
174
175 it("honors a quick double press when both key releases beat their mode reads", async () => {
176 const resolvers: Array<(mode: QuitConfirmationMode) => void> = [];
177 const harness = makeHarness({
178 getMode: () => new Promise((resolve) => resolvers.push(resolve)),
179 });
180 await harness.send(makeInput({}));
181 await harness.send(makeInput({ type: "keyUp" }));
182 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
183 await harness.send(makeInput({}));
184 await harness.send(makeInput({ type: "keyUp" }));
185
186 resolvers[1]?.("double-click");
187 await Promise.resolve();
188 await Promise.resolve();
189
190 expect(harness.quit).toHaveBeenCalledTimes(1);
191 expect(harness.notifications).toEqual([]);
192 });
193
194 it("discards a stale mode resolution from a superseded press", async () => {
195 // Press #1's mode is still pending when the user releases and
134196 // presses again; its late resolution must not act for press #2.
135 const resolvers: Array<(enabled: boolean) => void> = [];
197 const resolvers: Array<(mode: QuitConfirmationMode) => void> = [];
136198 const harness = makeHarness({
137 isEnabled: () => new Promise((resolve) => resolvers.push(resolve)),
199 getMode: () => new Promise((resolve) => resolvers.push(resolve)),
138200 });
139201 await harness.send(makeInput({}));
140202 await harness.send(makeInput({ type: "keyUp" }));
141 // Outside the double-tap window, so the second press starts a new hold.
142 vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100);
203 // Outside the double-press window, so the second press starts a new hold.
204 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100);
143205 await harness.send(makeInput({}));
144206 expect(resolvers).toHaveLength(2);
145207
146 // Press #1 resolves late with "disabled" — it must not quit press #2.
147 resolvers[0]?.(false);
208 // Press #1 resolves late with "direct". It must not quit press #2.
209 resolvers[0]?.("direct");
148210 await Promise.resolve();
149211 await Promise.resolve();
150212 expect(harness.quit).not.toHaveBeenCalled();
151213
152 // Press #2 resolves enabled and completes a full hold.
153 resolvers[1]?.(true);
214 // Press #2 resolves to hold and completes the gesture.
215 resolvers[1]?.("hold");
154216 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
155217 await harness.send(makeInput({ type: "keyUp" }));
156218 expect(harness.quit).toHaveBeenCalledTimes(1);
157219 });
158220
159 it("quits on a quick double tap, even when the first release was never seen", async () => {
160 const harness = makeHarness();
221 it("quits on a quick double press in double-click mode when the first release is unseen", async () => {
222 const harness = makeHarness({ mode: "double-click" });
161223 await harness.send(makeInput({}));
162 vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100);
224 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
163225 await harness.send(makeInput({}));
164226 expect(harness.quit).toHaveBeenCalledTimes(1);
227 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]);
228 });
229
230 it("keeps the double-press hint visible after key release until the window ends", async () => {
231 const harness = makeHarness({ mode: "double-click" });
232 await harness.send(makeInput({}));
233 vi.advanceTimersByTime(100);
234 await harness.send(makeInput({ type: "keyUp" }));
235 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]);
236
237 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 101);
238 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]);
239 vi.advanceTimersByTime(1);
240 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]);
165241 });
166242
167 it("treats two slow taps as separate presses", async () => {
243 it("accepts a second full shortcut after the modifier is released and pressed again", async () => {
244 const harness = makeHarness({ mode: "double-click" });
245 await harness.send(makeInput({}));
246 await harness.send(makeInput({ type: "keyUp" }));
247 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
248 vi.advanceTimersByTime(100);
249
250 await harness.send(makeInput({ key: "Meta" }));
251 await harness.send(makeInput({}));
252
253 expect(harness.quit).toHaveBeenCalledTimes(1);
254 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]);
255 });
256
257 it("expires a delayed double-press hint from keydown rather than mode resolution", async () => {
258 let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined;
259 const harness = makeHarness({
260 getMode: () =>
261 new Promise((resolve) => {
262 resolveMode = resolve;
263 }),
264 });
265 await harness.send(makeInput({}));
266 vi.advanceTimersByTime(100);
267 await harness.send(makeInput({ type: "keyUp" }));
268 vi.advanceTimersByTime(100);
269 resolveMode?.("double-click");
270 await Promise.resolve();
271 await Promise.resolve();
272 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]);
273
274 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 201);
275 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]);
276 vi.advanceTimersByTime(1);
277 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]);
278 });
279
280 it("treats two slow presses as separate attempts in double-click mode", async () => {
281 const harness = makeHarness({ mode: "double-click" });
282 await harness.send(makeInput({}));
283 await harness.send(makeInput({ type: "keyUp" }));
284 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100);
285 await harness.send(makeInput({}));
286 expect(harness.quit).not.toHaveBeenCalled();
287 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]);
288 });
289
290 it("does not treat two quick presses as a quit in hold mode", async () => {
168291 const harness = makeHarness();
169292 await harness.send(makeInput({}));
170293 await harness.send(makeInput({ type: "keyUp" }));
171 vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100);
294 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
172295 await harness.send(makeInput({}));
173296 expect(harness.quit).not.toHaveBeenCalled();
174 expect(harness.notifications).toEqual(["down", "up", "down"]);
297 expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]);
175298 });
176299
177300 it("cancels the hold when another key interrupts it", async () => {
......@@ -180,21 +303,20 @@ describe("makeQuitHoldHandler", () => {
180303 await harness.holdFor(500);
181304 // Shift pressed mid-hold breaks the gesture...
182305 await harness.send(makeInput({ shift: true }));
183 expect(harness.notifications).toEqual(["down", "up"]);
306 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
184307 // ...so later repeats past the threshold must not quit.
185308 await harness.holdFor(QUIT_HOLD_DURATION_MS);
186309 expect(harness.quit).not.toHaveBeenCalled();
187310 });
188311
189 it("does not count an interrupted press toward a double tap", async () => {
190 const harness = makeHarness();
312 it("does not count an interrupted press toward a double press", async () => {
313 const harness = makeHarness({ mode: "double-click" });
191314 await harness.send(makeInput({}));
192315 await harness.send(makeInput({ shift: true }));
193 // A fresh press right after the interruption starts a new hold, not a
194 // double-tap quit.
316 // A fresh press right after the interruption starts a new attempt.
195317 await harness.send(makeInput({}));
196318 expect(harness.quit).not.toHaveBeenCalled();
197 expect(harness.notifications).toEqual(["down", "up", "down"]);
319 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]);
198320 });
199321
200322 it("ignores other shortcuts", async () => {
apps/desktop/src/window/QuitHold.ts+73-44
......@@ -1,15 +1,12 @@
11// @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime.
22
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 after the shortcut has been held for QUIT_HOLD_DURATION_MS
6// and released.
7// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap
8// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application
9// menu itself is untouched and quits immediately.
3import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts";
4
5// The quit accelerator is intercepted in before-input-event, which runs
6// before the native menu accelerator. Quitting from the application menu is
7// untouched and always quits immediately.
108export const QUIT_HOLD_DURATION_MS = 1200;
11// A second quick tap of the shortcut is the user insisting: quit immediately.
12export const QUIT_DOUBLE_TAP_MS = 500;
9export const QUIT_DOUBLE_PRESS_MS = 500;
1310// "Still held" is proven by auto-repeat keydowns, not by the absence of a
1411// release: macOS suppresses a letter keyUp while the command key is down, so a
1512// tap release can go completely unseen and a release-based timer would quit
......@@ -18,8 +15,6 @@ export const QUIT_DOUBLE_TAP_MS = 500;
1815// auto-repeat disabled fall back to the application menu Quit action.
1916export const QUIT_HOLD_RELEASE_GRACE_MS = 600;
2017
21export type QuitHoldState = "down" | "up";
22
2318export interface QuitHoldKeyInput {
2419 readonly type: string;
2520 readonly key: string;
......@@ -30,27 +25,29 @@ export interface QuitHoldKeyInput {
3025 readonly isAutoRepeat: boolean;
3126}
3227
33export interface QuitHoldOptions {
28export interface QuitShortcutOptions {
3429 readonly platform: NodeJS.Platform;
35 readonly isEnabled: () => Promise<boolean>;
36 readonly notify: (state: QuitHoldState) => void;
30 readonly getMode: () => Promise<QuitConfirmationMode>;
31 readonly notify: (event: QuitShortcutHintEvent) => void;
3732 readonly quit: () => void;
3833}
3934
40export function makeQuitHoldHandler(
41 options: QuitHoldOptions,
35export function makeQuitShortcutHandler(
36 options: QuitShortcutOptions,
4237): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void {
4338 const modifierKey = options.platform === "darwin" ? "meta" : "control";
4439 let watchdog: NodeJS.Timeout | undefined;
4540 let holding = false;
46 // Set once isEnabled resolves true; auto-repeats may only complete the hold when armed.
41 let mode: QuitConfirmationMode | undefined;
42 let notified = false;
43 // Set once getMode resolves to hold; auto-repeats may only complete the hold when armed.
4744 let armed = false;
4845 let quitOnRelease = false;
4946 let heldSince = 0;
5047 let lastPressAt = 0;
51 // Incremented on every new press and every release/quit so a pending
52 // isEnabled() resolution from a superseded press cannot arm (or quit for)
53 // the current one.
48 // Incremented when a press is superseded or explicitly cancelled. A plain
49 // key release does not invalidate its pending mode read: direct mode and a
50 // completed second press must still be honored after that read settles.
5451 let generation = 0;
5552
5653 const clearWatchdog = () => {
......@@ -60,19 +57,24 @@ export function makeQuitHoldHandler(
6057 }
6158 };
6259
63 const release = () => {
64 if (!holding) return;
65 const shouldNotify = armed || quitOnRelease;
66 generation += 1;
60 const release = (cancelPendingMode = true, keepDoublePressHint = false) => {
61 if (!holding && !notified) return;
62 const keepHint = keepDoublePressHint && mode === "double-click" && notified;
63 if (cancelPendingMode) generation += 1;
6764 holding = false;
6865 armed = false;
6966 quitOnRelease = false;
67 if (keepHint) return;
68
69 mode = undefined;
7070 clearWatchdog();
71 if (shouldNotify) options.notify("up");
71 if (notified) {
72 notified = false;
73 options.notify({ state: "up" });
74 }
7275 };
7376
74 // Dismisses any overlay first: if the quit is cancelled downstream the
75 // renderer must not be left with a stuck "Hold to Quit" hint.
77 // Dismisses any overlay first so a cancelled quit cannot leave a stale hint.
7678 const quitNow = () => {
7779 release();
7880 options.quit();
......@@ -83,11 +85,11 @@ export function makeQuitHoldHandler(
8385 if (input.type === "keyUp") {
8486 if (key === "q") {
8587 const shouldQuit = quitOnRelease;
86 release();
88 release(false, true);
8789 if (shouldQuit) options.quit();
8890 } else if (key === modifierKey) {
8991 if (!quitOnRelease) {
90 release();
92 release(false, true);
9193 } else {
9294 watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS);
9395 }
......@@ -104,13 +106,17 @@ export function makeQuitHoldHandler(
104106
105107 const modifierDown = options.platform === "darwin" ? input.meta : input.control;
106108 if (!modifierDown || input.alt || input.shift || key !== "q") {
109 // Re-pressing the platform modifier is the first half of a second full
110 // quit shortcut, so it must not cancel an active double-press window.
111 if (key === modifierKey && !input.alt && !input.shift) return;
112
107113 // Any other key (or an extra modifier) pressed mid-hold breaks the
108114 // gesture; without this the hold timer keeps running through the
109115 // interruption and the next qualifying repeat would quit early. The
110 // interrupted press also stops counting toward a double tap — but only
116 // interrupted press also stops counting toward a double press, but only
111117 // here, not in release(), which runs mid-restart on an unseen-release
112118 // re-press and must not wipe that press's own tap timestamp.
113 if (holding && !input.isAutoRepeat) {
119 if ((holding || notified) && !input.isAutoRepeat) {
114120 lastPressAt = 0;
115121 release();
116122 }
......@@ -120,7 +126,7 @@ export function makeQuitHoldHandler(
120126 event.preventDefault();
121127
122128 if (input.isAutoRepeat) {
123 if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) {
129 if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) {
124130 armed = false;
125131 quitOnRelease = true;
126132 clearWatchdog();
......@@ -131,28 +137,51 @@ export function makeQuitHoldHandler(
131137 const now = Date.now();
132138 const previousPressAt = lastPressAt;
133139 lastPressAt = now;
134 // A fresh keydown while "holding" means the key came back down after a
135 // release macOS never delivered — so both branches below see real taps.
136 if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) {
137 quitNow();
138 return;
139 }
140 if (holding) release();
140 // A fresh keydown supersedes the current physical hold or the hint kept
141 // alive after a detected release.
142 if (holding || notified) release();
141143
142144 generation += 1;
143145 const pressGeneration = generation;
144146 holding = true;
145147 heldSince = now;
146 void options.isEnabled().then(
147 (enabled) => {
148 void options.getMode().then(
149 (resolvedMode) => {
148150 if (generation !== pressGeneration) return;
149 if (!enabled) {
150 // Hold-to-quit disabled: a single press quits immediately.
151 if (resolvedMode === "direct") {
151152 quitNow();
152153 return;
153154 }
155 if (
156 resolvedMode === "double-click" &&
157 previousPressAt !== 0 &&
158 now - previousPressAt <= QUIT_DOUBLE_PRESS_MS
159 ) {
160 quitNow();
161 return;
162 }
163
164 if (resolvedMode === "double-click") {
165 const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now);
166 if (remainingMs <= 0) {
167 release();
168 return;
169 }
170 mode = resolvedMode;
171 notified = true;
172 options.notify({ state: "down", mode: resolvedMode });
173 watchdog = setTimeout(release, remainingMs);
174 return;
175 }
176
177 // A hold cannot be armed after its physical press has ended.
178 if (!holding) return;
179
180 mode = resolvedMode;
181 notified = true;
182 options.notify({ state: "down", mode: resolvedMode });
183
154184 armed = true;
155 options.notify("down");
156185 // No auto-repeat by then means the key was released (possibly with a
157186 // suppressed keyUp) or repeat is disabled; either way, don't quit.
158187 watchdog = setTimeout(() => {
apps/web/src/components/QuitHoldOverlay.tsx+20-13
......@@ -2,29 +2,34 @@ import { useEffect, useState } from "react";
22
33import { isMacPlatform } from "../lib/utils";
44
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;
5// A released hold hint lingers for the original hold duration. Double-press
6// hints disappear as soon as their acceptance window closes.
7const HOLD_HINT_LINGER_MS = 1200;
88
99/**
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.
10 * The desktop main process intercepts the quit accelerator and pushes
11 * press/release states while it waits for a hold or second press.
1312 */
1413export function QuitHoldOverlay() {
15 const [visible, setVisible] = useState(false);
14 const [visibleMode, setVisibleMode] = useState<"hold" | "double-click" | null>(null);
1615
1716 useEffect(() => {
1817 const subscribe = window.desktopBridge?.onQuitShortcut;
1918 if (!subscribe) return;
2019 let hideTimer: number | undefined;
21 const unsubscribe = subscribe((state) => {
20 let pressedMode: "hold" | "double-click" = "hold";
21 const unsubscribe = subscribe((hint) => {
2222 window.clearTimeout(hideTimer);
23 if (state === "down") {
24 setVisible(true);
23 if (hint.state === "down") {
24 pressedMode = hint.mode;
25 setVisibleMode(hint.mode);
2526 return;
2627 }
27 hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS);
28 if (pressedMode === "double-click") {
29 setVisibleMode(null);
30 return;
31 }
32 hideTimer = window.setTimeout(() => setVisibleMode(null), HOLD_HINT_LINGER_MS);
2833 });
2934 return () => {
3035 window.clearTimeout(hideTimer);
......@@ -32,15 +37,17 @@ export function QuitHoldOverlay() {
3237 };
3338 }, []);
3439
35 if (!visible) return null;
40 if (!visibleMode) return null;
3641 const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q";
42 const message =
43 visibleMode === "hold" ? `Hold ${shortcut} to Quit` : `Press ${shortcut} again to Quit`;
3744 return (
3845 <div
3946 role="status"
4047 className="pointer-events-none fixed inset-x-0 top-[22%] z-100 flex justify-center"
4148 >
4249 <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
50 {message}
4451 </div>
4552 </div>
4653 );
apps/web/src/components/settings/SettingsPanels.tsx+29-10
......@@ -34,6 +34,7 @@ import {
3434 MIN_PROMPT_FONT_SIZE,
3535 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
3636 MIN_TERMINAL_FONT_SIZE,
37 type QuitConfirmationMode,
3738} from "@t3tools/contracts/settings";
3839import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
3940import { createModelSelection } from "@t3tools/shared/model";
......@@ -163,6 +164,12 @@ const TIMESTAMP_FORMAT_LABELS = {
163164 "24-hour": "24-hour",
164165} as const;
165166
167const QUIT_CONFIRMATION_MODE_LABELS: Record<QuitConfirmationMode, string> = {
168 direct: "Direct",
169 hold: "Hold",
170 "double-click": "Double press",
171};
172
166173const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, string> = {
167174 balanced: "Balanced",
168175 performance: "Performance",
......@@ -542,9 +549,7 @@ export function useSettingsRestore(onRestored?: () => void) {
542549 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
543550 ? ["Delete confirmation"]
544551 : []),
545 ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit
546 ? ["Quit confirmation"]
547 : []),
552 ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit shortcut"] : []),
548553 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),
549554 ...getChangedBrowserSettingLabels(settings),
550555 ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess
......@@ -2422,11 +2427,11 @@ export function GeneralSettingsPanel() {
24222427 {isElectron ? (
24232428 <SettingsRow
24242429 {...searchableSetting("quit-confirmation")}
2425 description="Require holding the quit shortcut before the desktop app quits. A quick tap shows a hint instead."
2430 description="Choose whether the desktop app quits immediately, after a hold, or after two quick presses."
24262431 resetAction={
24272432 settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? (
24282433 <SettingResetButton
2429 label="quit confirmation"
2434 label="quit shortcut behavior"
24302435 onClick={() =>
24312436 updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit })
24322437 }
......@@ -2434,11 +2439,25 @@ export function GeneralSettingsPanel() {
24342439 ) : null
24352440 }
24362441 control={
2437 <Switch
2438 checked={settings.confirmQuit}
2439 onCheckedChange={(checked) => updateSettings({ confirmQuit: Boolean(checked) })}
2440 aria-label="Hold to quit"
2441 />
2442 <Select
2443 value={settings.confirmQuit}
2444 onValueChange={(value) => {
2445 if (value === "direct" || value === "hold" || value === "double-click") {
2446 updateSettings({ confirmQuit: value });
2447 }
2448 }}
2449 >
2450 <SelectTrigger className="w-full sm:w-40" aria-label="Quit shortcut behavior">
2451 <SelectValue>{QUIT_CONFIRMATION_MODE_LABELS[settings.confirmQuit]}</SelectValue>
2452 </SelectTrigger>
2453 <SelectPopup align="end" alignItemWithTrigger={false}>
2454 {Object.entries(QUIT_CONFIRMATION_MODE_LABELS).map(([value, label]) => (
2455 <SelectItem hideIndicator key={value} value={value}>
2456 {label}
2457 </SelectItem>
2458 ))}
2459 </SelectPopup>
2460 </Select>
24422461 }
24432462 />
24442463 ) : null}
apps/web/src/components/settings/settingsSearch.ts+2-2
......@@ -238,9 +238,9 @@ export const SETTINGS_SEARCH_ITEMS = [
238238 },
239239 {
240240 id: "quit-confirmation",
241 title: "Hold to quit",
241 title: "Quit shortcut",
242242 to: "/settings/general",
243 searchTerms: ["confirmation shortcut desktop app exit"],
243 searchTerms: ["confirmation desktop app exit direct hold double click press twice"],
244244 desktopOnly: true,
245245 },
246246 {
packages/contracts/src/ipc.ts+8-5
......@@ -91,7 +91,7 @@ import { EnvironmentId } from "./baseSchemas.ts";
9191import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";
9292import { AdvertisedEndpoint } from "./remoteAccess.ts";
9393import { ExecutionEnvironmentDescriptor } from "./environment.ts";
94import type { ClientSettings } from "./settings.ts";
94import type { ClientSettings, QuitConfirmationMode } from "./settings.ts";
9595import type { EditorId } from "./editor.ts";
9696import type {
9797 SourceControlCloneRepositoryInput,
......@@ -116,6 +116,10 @@ export interface ContextMenuItem<T extends string = string> {
116116 children?: readonly ContextMenuItem<T>[];
117117}
118118
119export type QuitShortcutHintEvent =
120 | { readonly state: "down"; readonly mode: Exclude<QuitConfirmationMode, "direct"> }
121 | { readonly state: "up" };
122
119123export interface ContextMenuItemSchemaType {
120124 readonly id: string;
121125 readonly label: string;
......@@ -1140,11 +1144,10 @@ export interface DesktopBridge {
11401144 probeRemoteEditors?: () => Promise<readonly EditorId[]>;
11411145 onMenuAction: (listener: (action: string) => void) => () => void;
11421146 /**
1143 * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed,
1144 * "up" when it is released before the hold completes. Optional: older
1145 * desktop builds never emit it.
1147 * Quit-confirmation hint pushes. Optional: older desktop builds never emit
1148 * them.
11461149 */
1147 onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void;
1150 onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void;
11481151 getWindowFullscreenState: () => boolean;
11491152 onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;
11501153 getUpdateState: () => Promise<DesktopUpdateState>;
packages/contracts/src/settings.test.ts+26
......@@ -15,6 +15,7 @@ import {
1515
1616const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema);
1717const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch);
18const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema);
1819const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
1920const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
2021const encodeServerSettings = Schema.encodeSync(ServerSettings);
......@@ -66,6 +67,31 @@ describe("ClientSettings word wrap", () => {
6667 });
6768});
6869
70describe("ClientSettings quit confirmation", () => {
71 it("defaults to hold", () => {
72 expect(decodeClientSettings({}).confirmQuit).toBe("hold");
73 });
74
75 it.each(["direct", "hold", "double-click"] as const)("accepts the %s mode", (mode) => {
76 expect(decodeClientSettings({ confirmQuit: mode }).confirmQuit).toBe(mode);
77 expect(decodeClientSettingsPatch({ confirmQuit: mode }).confirmQuit).toBe(mode);
78 });
79
80 it.each([
81 [true, "hold"],
82 [false, "direct"],
83 ] as const)("migrates the legacy %s value to %s", (legacyValue, mode) => {
84 const settings = decodeClientSettings({ confirmQuit: legacyValue });
85
86 expect(settings.confirmQuit).toBe(mode);
87 expect(encodeClientSettings(settings).confirmQuit).toBe(mode);
88 });
89
90 it("rejects legacy booleans at the patch boundary", () => {
91 expect(() => decodeClientSettingsPatch({ confirmQuit: true })).toThrow();
92 });
93});
94
6995describe("ClientSettings browser recording frame rate", () => {
7096 it("defaults to 30 fps", () => {
7197 expect(decodeClientSettings({}).browserRecordingFrameRate).toBe(30);
packages/contracts/src/settings.ts+22-4
......@@ -124,6 +124,22 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill",
124124export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type;
125125export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork";
126126
127export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]);
128export type QuitConfirmationMode = typeof QuitConfirmationMode.Type;
129export const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold";
130
131const LegacyConfirmQuit = Schema.Boolean.pipe(
132 Schema.decodeTo(
133 QuitConfirmationMode,
134 SchemaTransformation.transform({
135 decode: (confirmQuit): QuitConfirmationMode => (confirmQuit ? "hold" : "direct"),
136 encode: (mode) => mode === "hold",
137 }),
138 ),
139);
140
141const QuitConfirmationModeSetting = Schema.Union([QuitConfirmationMode, LegacyConfirmQuit]);
142
127143/**
128144 * A user-chosen font family (a single name or a comma-separated list). Empty
129145 * means "use the app default"; clients compose their own fallback stacks.
......@@ -183,9 +199,11 @@ export const ClientSettingsSchema = Schema.Struct({
183199 browserAutoShowFloatingPreview: Schema.Boolean.pipe(
184200 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)),
185201 ),
186 // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the
187 // app quits; a quick tap only shows a hint. Browser clients ignore it.
188 confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
202 // Desktop-only. Boolean values from older settings files decode to their
203 // equivalent mode and encode back as the canonical string value.
204 confirmQuit: QuitConfirmationModeSetting.pipe(
205 Schema.withDecodingDefault(Effect.succeed(DEFAULT_QUIT_CONFIRMATION_MODE)),
206 ),
189207 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
190208 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
191209 confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
......@@ -938,7 +956,7 @@ export const ClientSettingsPatch = Schema.Struct({
938956 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),
939957 browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate),
940958 browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean),
941 confirmQuit: Schema.optionalKey(Schema.Boolean),
959 confirmQuit: Schema.optionalKey(QuitConfirmationMode),
942960 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
943961 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
944962 confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),