| author | |
| committer | |
| log | caa8a0db98f9d32e98a1645caa7f7dd37b14f187 |
| tree | 8eb1f61512ff70ca9e101ef818ae06948bae671c |
| parent | 01f3e50eca5102ccd881de6f942a98fe6a518ad4 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }); |
| 254 | 254 | ||
| 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); |
| 265 | 265 | 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([]); |
| 269 | 269 | ||
| 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 | ); | ||
| 273 | 280 | ||
| 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 and | 282 | // 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 | }); |
| 380 | 387 | ||
| 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 quit | 12 | // 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 after | 13 | // 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 with | 14 | // 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. | ||
| 16 | export const QUIT_HOLD_RELEASE_GRACE_MS = 600; | 17 | export const QUIT_HOLD_RELEASE_GRACE_MS = 600; |
| 17 | // A slow repeat rate can exceed the fixed grace. Waiting for two observed | 18 | // 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 plain | 55 | // 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 | // 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; |
| 58 | 59 | ||
| 59 | const clearWatchdog = () => { | 60 | const clearWatchdog = () => { |
| ... | @@ -64,9 +65,9 @@ export function makeQuitShortcutHandler( | ... | @@ -64,9 +65,9 @@ export function makeQuitShortcutHandler( |
| 64 | }; | 65 | }; |
| 65 | 66 | ||
| 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 | }; |
| 90 | 92 | ||
| ... | @@ -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; |
| 140 | 142 | ||
| 141 | // Any other key (or an extra modifier) pressed mid-hold breaks the | 143 | // Other keys cancel the hold and the first tap, even after release. |
| 142 | // gesture; without this the hold timer keeps running through the | 144 | // Keep this separate from release(), which also runs when a fresh Q |
| 143 | // interruption and the next qualifying repeat would quit early. The | 145 | // keydown follows a keyUp that macOS did not deliver. |
| 144 | // interrupted press also stops counting toward a double press, but only | 146 | 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(); |
| 172 | 171 | ||
| 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 | <div | 47 | <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 | <SettingsRow | 2745 | <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 | <SettingResetButton | 2750 | <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 |
| 89 | another new thread with the same workspace mode and base branch. **New worktree** remains selected, | 89 | another new thread with the same workspace mode and base branch. **New worktree** remains selected, |
| 90 | but the new thread does not reuse the worktree created for the thread that just started. | 90 | but the new thread does not reuse the worktree created for the thread that just started. |
| 91 | 91 | ||
| 92 | ## Desktop quit shortcut | ||
| 93 | |||
| 94 | Use `Cmd+Q` on macOS or `Ctrl+Q` on Windows and Linux. In the default **Hold** mode, hold the | ||
| 95 | shortcut for 1.2 seconds or press it twice within 500 milliseconds. The second press quits | ||
| 96 | immediately. You can keep Command or Control held between presses, or release both keys. | ||
| 97 | |||
| 98 | Hold mode needs keyboard repeat. If holding does not quit, use two quick presses or choose | ||
| 99 | **Quit** from the application menu. | ||
| 100 | |||
| 101 | Change **Quit shortcut** in **Settings** → **General** → **Confirmations**. **Direct** quits on | ||
| 102 | the first press. **Double press** requires two quick presses and does not accept a hold. The | ||
| 103 | application menu's **Quit** action always quits immediately. | ||
| 104 | |||
| 92 | ## `when` Conditions | 105 | ## `when` Conditions |
| 93 | 106 | ||
| 94 | A `when` expression is evaluated against context keys describing the current UI state. The keys | 107 | A `when` expression is evaluated against context keys describing the current UI state. The keys |