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", () => {
252252 expect(harness.notifications).toEqual([]);
253253 });
254254
255 it("honors a quick double press when both key releases beat their mode reads", async () => {
256 const resolvers: Array<(mode: QuitConfirmationMode) => void> = [];
257 const harness = makeHarness({
258 getMode: () => new Promise((resolve) => resolvers.push(resolve)),
259 });
260 await harness.send(makeInput({}));
261 await harness.send(makeInput({ type: "keyUp" }));
262 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
263 await harness.send(makeInput({}));
264 await harness.send(makeInput({ type: "keyUp" }));
265
266 resolvers[1]?.("double-click");
267 await Promise.resolve();
268 await Promise.resolve();
269
270 expect(harness.quit).toHaveBeenCalledTimes(1);
271 expect(harness.notifications).toEqual([]);
272 });
255 it.each(["direct", "hold", "double-click"] as const)(
256 "quits on a quick second press without waiting for a pending %s mode read",
257 async (mode) => {
258 const resolvers: Array<(mode: QuitConfirmationMode) => void> = [];
259 const harness = makeHarness({
260 getMode: () => new Promise((resolve) => resolvers.push(resolve)),
261 });
262 await harness.send(makeInput({}));
263 await harness.send(makeInput({ type: "keyUp" }));
264 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
265 await harness.send(makeInput({}));
266
267 expect(harness.quit).toHaveBeenCalledTimes(1);
268 expect(harness.notifications).toEqual([]);
269
270 await harness.send(makeInput({ type: "keyUp" }));
271
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
274281 it("discards a stale mode resolution from a superseded press", async () => {
275282 // Press #1's mode is still pending when the user releases and
......@@ -378,6 +385,38 @@ describe("makeQuitShortcutHandler", () => {
378385 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
379386 });
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
381420 it("cancels the hold when another key interrupts it", async () => {
382421 const harness = makeHarness();
383422 await harness.send(makeInput({}));
apps/desktop/src/window/QuitHold.ts+17-18
......@@ -12,7 +12,8 @@ export const QUIT_DOUBLE_PRESS_MS = 500;
1212// tap release can go completely unseen and a release-based timer would quit
1313// anyway. Once held, quitting waits for Q keyUp or a quiet grace period after
1414// 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.
1617export const QUIT_HOLD_RELEASE_GRACE_MS = 600;
1718// A slow repeat rate can exceed the fixed grace. Waiting for two observed
1819// cadences keeps the timer behind the next repeat without slowing normal rates.
......@@ -52,8 +53,8 @@ export function makeQuitShortcutHandler(
5253 let lastRepeatAt = 0;
5354 let repeatCadenceMs = 0;
5455 // Incremented when a press is superseded or explicitly cancelled. A plain
55 // key release does not invalidate its pending mode read: direct mode and a
56 // completed second press must still be honored after that read settles.
56 // key release does not invalidate its pending mode read: a direct-mode
57 // press must still quit after that read settles.
5758 let generation = 0;
5859
5960 const clearWatchdog = () => {
......@@ -64,9 +65,9 @@ export function makeQuitShortcutHandler(
6465 };
6566
6667 const release = (cancelPendingMode = true, keepDoublePressHint = false) => {
68 if (cancelPendingMode) generation += 1;
6769 if (!holding && !notified) return;
6870 const keepHint = keepDoublePressHint && mode === "double-click" && notified;
69 if (cancelPendingMode) generation += 1;
7071 holding = false;
7172 armed = false;
7273 quitOnRelease = false;
......@@ -85,6 +86,7 @@ export function makeQuitShortcutHandler(
8586 // Dismisses any overlay first so a cancelled quit cannot leave a stale hint.
8687 const quitNow = () => {
8788 release();
89 lastPressAt = 0;
8890 options.quit();
8991 };
9092
......@@ -138,13 +140,10 @@ export function makeQuitShortcutHandler(
138140 // quit shortcut, so it must not cancel an active double-press window.
139141 if (key === modifierKey && !input.alt && !input.shift) return;
140142
141 // Any other key (or an extra modifier) pressed mid-hold breaks the
142 // gesture; without this the hold timer keeps running through the
143 // interruption and the next qualifying repeat would quit early. The
144 // interrupted press also stops counting toward a double press, but only
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) {
143 // Other keys cancel the hold and the first tap, even after release.
144 // Keep this separate from release(), which also runs when a fresh Q
145 // keydown follows a keyUp that macOS did not deliver.
146 if (!input.isAutoRepeat) {
148147 lastPressAt = 0;
149148 release();
150149 }
......@@ -171,6 +170,13 @@ export function makeQuitShortcutHandler(
171170 if (holding || notified) release();
172171
173172 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
174180 const pressGeneration = generation;
175181 holding = true;
176182 heldSince = now;
......@@ -181,13 +187,6 @@ export function makeQuitShortcutHandler(
181187 quitNow();
182188 return;
183189 }
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
191190 if (resolvedMode === "double-click") {
192191 const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now);
193192 if (remainingMs <= 0) {
apps/web/src/components/QuitHoldOverlay.tsx+3-1
......@@ -40,7 +40,9 @@ export function QuitHoldOverlay() {
4040 if (!visibleMode) return null;
4141 const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q";
4242 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`;
4446 return (
4547 <div
4648 role="status"
apps/web/src/components/settings/SettingsPanels.tsx+1-1
......@@ -2744,7 +2744,7 @@ export function GeneralSettingsPanel() {
27442744 {isElectron ? (
27452745 <SettingsRow
27462746 {...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."
27482748 resetAction={
27492749 settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? (
27502750 <SettingResetButton
docs/user/keybindings.md+13
......@@ -89,6 +89,19 @@ Background submission from a new thread is the exception. `mod+enter` starts tha
8989another new thread with the same workspace mode and base branch. **New worktree** remains selected,
9090but 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
92105## `when` Conditions
93106
94107A `when` expression is evaluated against context keys describing the current UI state. The keys