authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 06:18:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 06:18:31-07:00
logcaa8a0db98f9d32e98a1645caa7f7dd37b14f187
tree8eb1f61512ff70ca9e101ef818ae06948bae671c
parent01f3e50eca5102ccd881de6f942a98fe6a518ad4
signature Signed by PGP key B5690EEEBB952194

fix(desktop): quit immediately on a second shortcut press (#9657)


5 files changed, 91 insertions(+), 38 deletions(-)

apps/desktop/src/window/QuitHold.test.ts+57-18
...@@ -252,24 +252,31 @@ describe("makeQuitShortcutHandler", () => {...@@ -252,24 +252,31 @@ describe("makeQuitShortcutHandler", () => {
252 expect(harness.notifications).toEqual([]);252 expect(harness.notifications).toEqual([]);
253 });253 });
254254
255 it("honors a quick double press when both key releases beat their mode reads", async () => {255 it.each(["direct", "hold", "double-click"] as const)(
256 const resolvers: Array<(mode: QuitConfirmationMode) => void> = [];256 "quits on a quick second press without waiting for a pending %s mode read",
257 const harness = makeHarness({257 async (mode) => {
258 getMode: () => new Promise((resolve) => resolvers.push(resolve)),258 const resolvers: Array<(mode: QuitConfirmationMode) => void> = [];
259 });259 const harness = makeHarness({
260 await harness.send(makeInput({}));260 getMode: () => new Promise((resolve) => resolvers.push(resolve)),
261 await harness.send(makeInput({ type: "keyUp" }));261 });
262 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);262 await harness.send(makeInput({}));
263 await harness.send(makeInput({}));263 await harness.send(makeInput({ type: "keyUp" }));
264 await harness.send(makeInput({ type: "keyUp" }));264 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
265265 await harness.send(makeInput({}));
266 resolvers[1]?.("double-click");266
267 await Promise.resolve();267 expect(harness.quit).toHaveBeenCalledTimes(1);
268 await Promise.resolve();268 expect(harness.notifications).toEqual([]);
269269
270 expect(harness.quit).toHaveBeenCalledTimes(1);270 await harness.send(makeInput({ type: "keyUp" }));
271 expect(harness.notifications).toEqual([]);271
272 });272 resolvers[0]?.(mode);
273 await Promise.resolve();
274 await Promise.resolve();
275
276 expect(harness.quit).toHaveBeenCalledTimes(1);
277 expect(harness.notifications).toEqual([]);
278 },
279 );
273280
274 it("discards a stale mode resolution from a superseded press", async () => {281 it("discards a stale mode resolution from a superseded press", async () => {
275 // Press #1's mode is still pending when the user releases and282 // Press #1's mode is still pending when the user releases and
...@@ -378,6 +385,38 @@ describe("makeQuitShortcutHandler", () => {...@@ -378,6 +385,38 @@ describe("makeQuitShortcutHandler", () => {
378 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);385 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
379 });386 });
380387
388 it("quits on a quick second press in hold mode when the first release is unseen", async () => {
389 const harness = makeHarness();
390 await harness.send(makeInput({}));
391 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
392 await harness.send(makeInput({}));
393
394 expect(harness.concealWindow).not.toHaveBeenCalled();
395 expect(harness.quit).toHaveBeenCalledTimes(1);
396 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
397 });
398
399 it("does not count auto-repeat as a second press", async () => {
400 const harness = makeHarness();
401 await harness.send(makeInput({}));
402 await harness.holdFor(QUIT_DOUBLE_PRESS_MS - 100);
403
404 expect(harness.quit).not.toHaveBeenCalled();
405 expect(harness.notifications).toEqual([HOLD_DOWN]);
406 });
407
408 it("does not count a released tap after another shortcut interrupts it", async () => {
409 const harness = makeHarness();
410 await harness.send(makeInput({}));
411 await harness.send(makeInput({ type: "keyUp" }));
412 await harness.send(makeInput({ key: "c" }));
413 vi.advanceTimersByTime(100);
414 await harness.send(makeInput({}));
415
416 expect(harness.quit).not.toHaveBeenCalled();
417 expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]);
418 });
419
381 it("cancels the hold when another key interrupts it", async () => {420 it("cancels the hold when another key interrupts it", async () => {
382 const harness = makeHarness();421 const harness = makeHarness();
383 await harness.send(makeInput({}));422 await harness.send(makeInput({}));
apps/desktop/src/window/QuitHold.ts+17-18
...@@ -12,7 +12,8 @@ export const QUIT_DOUBLE_PRESS_MS = 500;...@@ -12,7 +12,8 @@ export const QUIT_DOUBLE_PRESS_MS = 500;
12// tap release can go completely unseen and a release-based timer would quit12// tap release can go completely unseen and a release-based timer would quit
13// anyway. Once held, quitting waits for Q keyUp or a quiet grace period after13// anyway. Once held, quitting waits for Q keyUp or a quiet grace period after
14// repeats stop so they cannot reach the next app. Keyboards with14// repeats stop so they cannot reach the next app. Keyboards with
15// auto-repeat disabled fall back to the application menu Quit action.15// auto-repeat disabled must use a double press or the application menu Quit action.
16// Supporting holds without repeats requires a native physical key-state check.
16export const QUIT_HOLD_RELEASE_GRACE_MS = 600;17export const QUIT_HOLD_RELEASE_GRACE_MS = 600;
17// A slow repeat rate can exceed the fixed grace. Waiting for two observed18// A slow repeat rate can exceed the fixed grace. Waiting for two observed
18// cadences keeps the timer behind the next repeat without slowing normal rates.19// cadences keeps the timer behind the next repeat without slowing normal rates.
...@@ -52,8 +53,8 @@ export function makeQuitShortcutHandler(...@@ -52,8 +53,8 @@ export function makeQuitShortcutHandler(
52 let lastRepeatAt = 0;53 let lastRepeatAt = 0;
53 let repeatCadenceMs = 0;54 let repeatCadenceMs = 0;
54 // Incremented when a press is superseded or explicitly cancelled. A plain55 // Incremented when a press is superseded or explicitly cancelled. A plain
55 // key release does not invalidate its pending mode read: direct mode and a56 // key release does not invalidate its pending mode read: a direct-mode
56 // completed second press must still be honored after that read settles.57 // press must still quit after that read settles.
57 let generation = 0;58 let generation = 0;
5859
59 const clearWatchdog = () => {60 const clearWatchdog = () => {
...@@ -64,9 +65,9 @@ export function makeQuitShortcutHandler(...@@ -64,9 +65,9 @@ export function makeQuitShortcutHandler(
64 };65 };
6566
66 const release = (cancelPendingMode = true, keepDoublePressHint = false) => {67 const release = (cancelPendingMode = true, keepDoublePressHint = false) => {
68 if (cancelPendingMode) generation += 1;
67 if (!holding && !notified) return;69 if (!holding && !notified) return;
68 const keepHint = keepDoublePressHint && mode === "double-click" && notified;70 const keepHint = keepDoublePressHint && mode === "double-click" && notified;
69 if (cancelPendingMode) generation += 1;
70 holding = false;71 holding = false;
71 armed = false;72 armed = false;
72 quitOnRelease = false;73 quitOnRelease = false;
...@@ -85,6 +86,7 @@ export function makeQuitShortcutHandler(...@@ -85,6 +86,7 @@ export function makeQuitShortcutHandler(
85 // Dismisses any overlay first so a cancelled quit cannot leave a stale hint.86 // Dismisses any overlay first so a cancelled quit cannot leave a stale hint.
86 const quitNow = () => {87 const quitNow = () => {
87 release();88 release();
89 lastPressAt = 0;
88 options.quit();90 options.quit();
89 };91 };
9092
...@@ -138,13 +140,10 @@ export function makeQuitShortcutHandler(...@@ -138,13 +140,10 @@ export function makeQuitShortcutHandler(
138 // quit shortcut, so it must not cancel an active double-press window.140 // quit shortcut, so it must not cancel an active double-press window.
139 if (key === modifierKey && !input.alt && !input.shift) return;141 if (key === modifierKey && !input.alt && !input.shift) return;
140142
141 // Any other key (or an extra modifier) pressed mid-hold breaks the143 // Other keys cancel the hold and the first tap, even after release.
142 // gesture; without this the hold timer keeps running through the144 // Keep this separate from release(), which also runs when a fresh Q
143 // interruption and the next qualifying repeat would quit early. The145 // keydown follows a keyUp that macOS did not deliver.
144 // interrupted press also stops counting toward a double press, but only146 if (!input.isAutoRepeat) {
145 // here, not in release(), which runs mid-restart on an unseen-release
146 // re-press and must not wipe that press's own tap timestamp.
147 if ((holding || notified) && !input.isAutoRepeat) {
148 lastPressAt = 0;147 lastPressAt = 0;
149 release();148 release();
150 }149 }
...@@ -171,6 +170,13 @@ export function makeQuitShortcutHandler(...@@ -171,6 +170,13 @@ export function makeQuitShortcutHandler(
171 if (holding || notified) release();170 if (holding || notified) release();
172171
173 generation += 1;172 generation += 1;
173 // Every mode accepts two presses. Quit before reading settings so a slow
174 // read cannot delay the second press. Repeats never reach this branch.
175 if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) {
176 quitNow();
177 return;
178 }
179
174 const pressGeneration = generation;180 const pressGeneration = generation;
175 holding = true;181 holding = true;
176 heldSince = now;182 heldSince = now;
...@@ -181,13 +187,6 @@ export function makeQuitShortcutHandler(...@@ -181,13 +187,6 @@ export function makeQuitShortcutHandler(
181 quitNow();187 quitNow();
182 return;188 return;
183 }189 }
184 // Keep a second press as an escape hatch when macOS misses the events
185 // that would complete a hold.
186 if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) {
187 quitNow();
188 return;
189 }
190
191 if (resolvedMode === "double-click") {190 if (resolvedMode === "double-click") {
192 const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now);191 const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now);
193 if (remainingMs <= 0) {192 if (remainingMs <= 0) {
apps/web/src/components/QuitHoldOverlay.tsx+3-1
...@@ -40,7 +40,9 @@ export function QuitHoldOverlay() {...@@ -40,7 +40,9 @@ export function QuitHoldOverlay() {
40 if (!visibleMode) return null;40 if (!visibleMode) return null;
41 const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q";41 const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q";
42 const message =42 const message =
43 visibleMode === "hold" ? `Hold ${shortcut} to Quit` : `Press ${shortcut} again to Quit`;43 visibleMode === "hold"
44 ? `Hold ${shortcut} or press twice to quit`
45 : `Press ${shortcut} again to quit`;
44 return (46 return (
45 <div47 <div
46 role="status"48 role="status"
apps/web/src/components/settings/SettingsPanels.tsx+1-1
...@@ -2744,7 +2744,7 @@ export function GeneralSettingsPanel() {...@@ -2744,7 +2744,7 @@ export function GeneralSettingsPanel() {
2744 {isElectron ? (2744 {isElectron ? (
2745 <SettingsRow2745 <SettingsRow
2746 {...searchableSetting("quit-confirmation")}2746 {...searchableSetting("quit-confirmation")}
2747 description="Choose whether the desktop app quits immediately, after a hold, or after two quick presses."2747 description="Hold mode also quits on two quick presses."
2748 resetAction={2748 resetAction={
2749 settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? (2749 settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? (
2750 <SettingResetButton2750 <SettingResetButton
docs/user/keybindings.md+13
...@@ -89,6 +89,19 @@ Background submission from a new thread is the exception. `mod+enter` starts tha...@@ -89,6 +89,19 @@ Background submission from a new thread is the exception. `mod+enter` starts tha
89another new thread with the same workspace mode and base branch. **New worktree** remains selected,89another new thread with the same workspace mode and base branch. **New worktree** remains selected,
90but the new thread does not reuse the worktree created for the thread that just started.90but the new thread does not reuse the worktree created for the thread that just started.
9191
92## Desktop quit shortcut
93
94Use `Cmd+Q` on macOS or `Ctrl+Q` on Windows and Linux. In the default **Hold** mode, hold the
95shortcut for 1.2 seconds or press it twice within 500 milliseconds. The second press quits
96immediately. You can keep Command or Control held between presses, or release both keys.
97
98Hold mode needs keyboard repeat. If holding does not quit, use two quick presses or choose
99**Quit** from the application menu.
100
101Change **Quit shortcut** in **Settings** → **General** → **Confirmations**. **Direct** quits on
102the first press. **Double press** requires two quick presses and does not accept a hold. The
103application menu's **Quit** action always quits immediately.
104
92## `when` Conditions105## `when` Conditions
93106
94A `when` expression is evaluated against context keys describing the current UI state. The keys107A `when` expression is evaluated against context keys describing the current UI state. The keys