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", {...@@ -128,9 +128,19 @@ contextBridge.exposeInMainWorld("desktopBridge", {
128 };128 };
129 },129 },
130 onQuitShortcut: (listener) => {130 onQuitShortcut: (listener) => {
131 const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {131 const wrappedListener = (_event: Electron.IpcRendererEvent, hint: unknown) => {
132 if (state !== "down" && state !== "up") return;132 if (typeof hint !== "object" || hint === null || !("state" in hint)) return;
133 listener(state);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 }
134 };144 };
135145
136 ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener);146 ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener);
apps/desktop/src/settings/DesktopClientSettings.test.ts+1-1
...@@ -19,7 +19,7 @@ const clientSettings: ClientSettings = {...@@ -19,7 +19,7 @@ const clientSettings: ClientSettings = {
19 browserDefaultAppearance: "dark",19 browserDefaultAppearance: "dark",
20 browserRecordingFrameRate: 60,20 browserRecordingFrameRate: 60,
21 browserAutoShowFloatingPreview: false,21 browserAutoShowFloatingPreview: false,
22 confirmQuit: true,22 confirmQuit: "double-click",
23 confirmThreadArchive: true,23 confirmThreadArchive: true,
24 confirmThreadDelete: false,24 confirmThreadDelete: false,
25 confirmThreadUnpin: false,25 confirmThreadUnpin: false,
apps/desktop/src/window/DesktopWindow.ts+8-9
...@@ -27,7 +27,7 @@ import * as PreviewManager from "../preview/Manager.ts";...@@ -27,7 +27,7 @@ import * as PreviewManager from "../preview/Manager.ts";
27import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";27import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
28import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";28import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
29import * as ElectronApp from "../electron/ElectronApp.ts";29import * as ElectronApp from "../electron/ElectronApp.ts";
30import { makeQuitHoldHandler } from "./QuitHold.ts";30import { makeQuitShortcutHandler } from "./QuitHold.ts";
3131
32const TITLEBAR_HEIGHT = 40;32const TITLEBAR_HEIGHT = 40;
33const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux33const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
...@@ -551,12 +551,11 @@ export const make = Effect.gen(function* () {...@@ -551,12 +551,11 @@ export const make = Effect.gen(function* () {
551 // close-terminal shortcut can outlive the terminal that handled its first551 // close-terminal shortcut can outlive the terminal that handled its first
552 // press, so reject repeats before they reach the native window accelerator.552 // press, so reject repeats before they reach the native window accelerator.
553 // Deliberate presses still flow through the renderer or native menu.553 // Deliberate presses still flow through the renderer or native menu.
554 // Chrome-style hold-to-quit: intercept the quit accelerator before the554 // Intercept the quit accelerator before the native menu sees it and apply
555 // native menu sees it and only quit after the shortcut is held. The555 // the configured direct, hold, or double-press behavior.
556 // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL.556 const quitShortcutHandler = makeQuitShortcutHandler({
557 const quitHoldHandler = makeQuitHoldHandler({
558 platform: environment.platform,557 platform: environment.platform,
559 isEnabled: () =>558 getMode: () =>
560 runPromise(559 runPromise(
561 Effect.map(560 Effect.map(
562 clientSettings.get,561 clientSettings.get,
...@@ -566,9 +565,9 @@ export const make = Effect.gen(function* () {...@@ -566,9 +565,9 @@ export const make = Effect.gen(function* () {
566 }),565 }),
567 ),566 ),
568 ),567 ),
569 notify: (state) => {568 notify: (hint) => {
570 if (!window.isDestroyed()) {569 if (!window.isDestroyed()) {
571 window.webContents.send(QUIT_SHORTCUT_CHANNEL, state);570 window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint);
572 }571 }
573 },572 },
574 quit: () => {573 quit: () => {
...@@ -576,7 +575,7 @@ export const make = Effect.gen(function* () {...@@ -576,7 +575,7 @@ export const make = Effect.gen(function* () {
576 },575 },
577 });576 });
578 window.webContents.on("before-input-event", (event, input) => {577 window.webContents.on("before-input-event", (event, input) => {
579 quitHoldHandler(event, input);578 quitShortcutHandler(event, input);
580 if (input.type !== "keyDown" || !input.isAutoRepeat) return;579 if (input.type !== "keyDown" || !input.isAutoRepeat) return;
581 const modifier = environment.platform === "darwin" ? input.meta : input.control;580 const modifier = environment.platform === "darwin" ? input.meta : input.control;
582 if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") {581 if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") {
apps/desktop/src/window/QuitHold.test.ts+162-40
...@@ -1,12 +1,17 @@...@@ -1,12 +1,17 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22
3import {3import {
4 makeQuitHoldHandler,4 makeQuitShortcutHandler,
5 QUIT_DOUBLE_TAP_MS,5 QUIT_DOUBLE_PRESS_MS,
6 QUIT_HOLD_DURATION_MS,6 QUIT_HOLD_DURATION_MS,
7 QUIT_HOLD_RELEASE_GRACE_MS,7 QUIT_HOLD_RELEASE_GRACE_MS,
8} from "./QuitHold.ts";8} 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
11function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput {16function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput {
12 return {17 return {
...@@ -22,22 +27,22 @@ function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput {...@@ -22,22 +27,22 @@ function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput {
22}27}
2328
24function makeHarness(options?: {29function makeHarness(options?: {
25 enabled?: boolean;30 mode?: QuitConfirmationMode;
26 platform?: NodeJS.Platform;31 platform?: NodeJS.Platform;
27 isEnabled?: () => Promise<boolean>;32 getMode?: () => Promise<QuitConfirmationMode>;
28}) {33}) {
29 const notifications: Array<QuitHoldState> = [];34 const notifications: Array<QuitShortcutHintEvent> = [];
30 const quit = vi.fn();35 const quit = vi.fn();
31 const handler = makeQuitHoldHandler({36 const handler = makeQuitShortcutHandler({
32 platform: options?.platform ?? "darwin",37 platform: options?.platform ?? "darwin",
33 isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)),38 getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")),
34 notify: (state) => notifications.push(state),39 notify: (event) => notifications.push(event),
35 quit,40 quit,
36 });41 });
37 const preventDefault = vi.fn();42 const preventDefault = vi.fn();
38 const send = async (input: QuitHoldKeyInput) => {43 const send = async (input: QuitHoldKeyInput) => {
39 handler({ preventDefault }, input);44 handler({ preventDefault }, input);
40 // Let the isEnabled promise settle.45 // Let the getMode promise settle.
41 await Promise.resolve();46 await Promise.resolve();
42 await Promise.resolve();47 await Promise.resolve();
43 };48 };
...@@ -55,7 +60,7 @@ function makeHarness(options?: {...@@ -55,7 +60,7 @@ function makeHarness(options?: {
55 return { notifications, quit, preventDefault, send, holdFor };60 return { notifications, quit, preventDefault, send, holdFor };
56}61}
5762
58describe("makeQuitHoldHandler", () => {63describe("makeQuitShortcutHandler", () => {
59 beforeEach(() => {64 beforeEach(() => {
60 vi.useFakeTimers();65 vi.useFakeTimers();
61 });66 });
...@@ -69,12 +74,12 @@ describe("makeQuitHoldHandler", () => {...@@ -69,12 +74,12 @@ describe("makeQuitHoldHandler", () => {
69 const harness = makeHarness();74 const harness = makeHarness();
70 await harness.send(makeInput({}));75 await harness.send(makeInput({}));
71 expect(harness.preventDefault).toHaveBeenCalledTimes(1);76 expect(harness.preventDefault).toHaveBeenCalledTimes(1);
72 expect(harness.notifications).toEqual(["down"]);77 expect(harness.notifications).toEqual([HOLD_DOWN]);
7378
74 vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS);79 vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS);
75 expect(harness.quit).not.toHaveBeenCalled();80 expect(harness.quit).not.toHaveBeenCalled();
76 // The watchdog dismisses the hint once the press is clearly over.81 // 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]);
78 });83 });
7984
80 it("quits after a completed hold is released", async () => {85 it("quits after a completed hold is released", async () => {
...@@ -86,7 +91,7 @@ describe("makeQuitHoldHandler", () => {...@@ -86,7 +91,7 @@ describe("makeQuitHoldHandler", () => {
86 expect(harness.quit).not.toHaveBeenCalled();91 expect(harness.quit).not.toHaveBeenCalled();
87 vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);92 vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);
88 expect(harness.quit).toHaveBeenCalledTimes(1);93 expect(harness.quit).toHaveBeenCalledTimes(1);
89 expect(harness.notifications).toEqual(["down", "up"]);94 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
90 });95 });
9196
92 it("waits for Q release when Cmd is released first", async () => {97 it("waits for Q release when Cmd is released first", async () => {
...@@ -108,7 +113,7 @@ describe("makeQuitHoldHandler", () => {...@@ -108,7 +113,7 @@ describe("makeQuitHoldHandler", () => {
108 await harness.send(makeInput({}));113 await harness.send(makeInput({}));
109 await harness.holdFor(500);114 await harness.holdFor(500);
110 await harness.send(makeInput({ type: "keyUp" }));115 await harness.send(makeInput({ type: "keyUp" }));
111 expect(harness.notifications).toEqual(["down", "up"]);116 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
112 vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);117 vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);
113 expect(harness.quit).not.toHaveBeenCalled();118 expect(harness.quit).not.toHaveBeenCalled();
114 });119 });
...@@ -117,61 +122,179 @@ describe("makeQuitHoldHandler", () => {...@@ -117,61 +122,179 @@ describe("makeQuitHoldHandler", () => {
117 const harness = makeHarness();122 const harness = makeHarness();
118 await harness.send(makeInput({}));123 await harness.send(makeInput({}));
119 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));124 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]);
121 vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);126 vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);
122 expect(harness.quit).not.toHaveBeenCalled();127 expect(harness.quit).not.toHaveBeenCalled();
123 });128 });
124129
125 it("quits without showing a hint when hold-to-quit is disabled", async () => {130 it("quits without showing a hint in direct mode", async () => {
126 const harness = makeHarness({ enabled: false });131 const harness = makeHarness({ mode: "direct" });
127 await harness.send(makeInput({}));132 await harness.send(makeInput({}));
128 expect(harness.quit).toHaveBeenCalledTimes(1);133 expect(harness.quit).toHaveBeenCalledTimes(1);
129 expect(harness.notifications).toEqual([]);134 expect(harness.notifications).toEqual([]);
130 });135 });
131136
132 it("discards a stale isEnabled resolution from a superseded press", async () => {137 it("honors direct mode when the key is released before its mode read settles", async () => {
133 // Press #1's isEnabled is still pending when the user releases and138 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
134 // presses again; its late resolution must not act for press #2.196 // 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> = [];
136 const harness = makeHarness({198 const harness = makeHarness({
137 isEnabled: () => new Promise((resolve) => resolvers.push(resolve)),199 getMode: () => new Promise((resolve) => resolvers.push(resolve)),
138 });200 });
139 await harness.send(makeInput({}));201 await harness.send(makeInput({}));
140 await harness.send(makeInput({ type: "keyUp" }));202 await harness.send(makeInput({ type: "keyUp" }));
141 // Outside the double-tap window, so the second press starts a new hold.203 // Outside the double-press window, so the second press starts a new hold.
142 vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100);204 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100);
143 await harness.send(makeInput({}));205 await harness.send(makeInput({}));
144 expect(resolvers).toHaveLength(2);206 expect(resolvers).toHaveLength(2);
145207
146 // Press #1 resolves late with "disabled" — it must not quit press #2.208 // Press #1 resolves late with "direct". It must not quit press #2.
147 resolvers[0]?.(false);209 resolvers[0]?.("direct");
148 await Promise.resolve();210 await Promise.resolve();
149 await Promise.resolve();211 await Promise.resolve();
150 expect(harness.quit).not.toHaveBeenCalled();212 expect(harness.quit).not.toHaveBeenCalled();
151213
152 // Press #2 resolves enabled and completes a full hold.214 // Press #2 resolves to hold and completes the gesture.
153 resolvers[1]?.(true);215 resolvers[1]?.("hold");
154 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);216 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
155 await harness.send(makeInput({ type: "keyUp" }));217 await harness.send(makeInput({ type: "keyUp" }));
156 expect(harness.quit).toHaveBeenCalledTimes(1);218 expect(harness.quit).toHaveBeenCalledTimes(1);
157 });219 });
158220
159 it("quits on a quick double tap, even when the first release was never seen", async () => {221 it("quits on a quick double press in double-click mode when the first release is unseen", async () => {
160 const harness = makeHarness();222 const harness = makeHarness({ mode: "double-click" });
161 await harness.send(makeInput({}));223 await harness.send(makeInput({}));
162 vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100);224 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
163 await harness.send(makeInput({}));225 await harness.send(makeInput({}));
164 expect(harness.quit).toHaveBeenCalledTimes(1);226 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]);
165 });241 });
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 () => {
168 const harness = makeHarness();291 const harness = makeHarness();
169 await harness.send(makeInput({}));292 await harness.send(makeInput({}));
170 await harness.send(makeInput({ type: "keyUp" }));293 await harness.send(makeInput({ type: "keyUp" }));
171 vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100);294 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
172 await harness.send(makeInput({}));295 await harness.send(makeInput({}));
173 expect(harness.quit).not.toHaveBeenCalled();296 expect(harness.quit).not.toHaveBeenCalled();
174 expect(harness.notifications).toEqual(["down", "up", "down"]);297 expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]);
175 });298 });
176299
177 it("cancels the hold when another key interrupts it", async () => {300 it("cancels the hold when another key interrupts it", async () => {
...@@ -180,21 +303,20 @@ describe("makeQuitHoldHandler", () => {...@@ -180,21 +303,20 @@ describe("makeQuitHoldHandler", () => {
180 await harness.holdFor(500);303 await harness.holdFor(500);
181 // Shift pressed mid-hold breaks the gesture...304 // Shift pressed mid-hold breaks the gesture...
182 await harness.send(makeInput({ shift: true }));305 await harness.send(makeInput({ shift: true }));
183 expect(harness.notifications).toEqual(["down", "up"]);306 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
184 // ...so later repeats past the threshold must not quit.307 // ...so later repeats past the threshold must not quit.
185 await harness.holdFor(QUIT_HOLD_DURATION_MS);308 await harness.holdFor(QUIT_HOLD_DURATION_MS);
186 expect(harness.quit).not.toHaveBeenCalled();309 expect(harness.quit).not.toHaveBeenCalled();
187 });310 });
188311
189 it("does not count an interrupted press toward a double tap", async () => {312 it("does not count an interrupted press toward a double press", async () => {
190 const harness = makeHarness();313 const harness = makeHarness({ mode: "double-click" });
191 await harness.send(makeInput({}));314 await harness.send(makeInput({}));
192 await harness.send(makeInput({ shift: true }));315 await harness.send(makeInput({ shift: true }));
193 // A fresh press right after the interruption starts a new hold, not a316 // A fresh press right after the interruption starts a new attempt.
194 // double-tap quit.
195 await harness.send(makeInput({}));317 await harness.send(makeInput({}));
196 expect(harness.quit).not.toHaveBeenCalled();318 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]);
198 });320 });
199321
200 it("ignores other shortcuts", async () => {322 it("ignores other shortcuts", async () => {
apps/desktop/src/window/QuitHold.ts+73-44
...@@ -1,15 +1,12 @@...@@ -1,15 +1,12 @@
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.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.
22
3// Chrome-style hold-to-quit. The quit accelerator is intercepted in3import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts";
4// before-input-event (which runs before the native menu accelerator), and the4
5// app only quits after the shortcut has been held for QUIT_HOLD_DURATION_MS5// The quit accelerator is intercepted in before-input-event, which runs
6// and released.6// before the native menu accelerator. Quitting from the application menu is
7// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap7// untouched and always quits immediately.
8// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application
9// menu itself is untouched and quits immediately.
10export const QUIT_HOLD_DURATION_MS = 1200;8export const QUIT_HOLD_DURATION_MS = 1200;
11// A second quick tap of the shortcut is the user insisting: quit immediately.9export const QUIT_DOUBLE_PRESS_MS = 500;
12export const QUIT_DOUBLE_TAP_MS = 500;
13// "Still held" is proven by auto-repeat keydowns, not by the absence of a10// "Still held" is proven by auto-repeat keydowns, not by the absence of a
14// release: macOS suppresses a letter keyUp while the command key is down, so a11// release: macOS suppresses a letter keyUp while the command key is down, so a
15// 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
...@@ -18,8 +15,6 @@ export const QUIT_DOUBLE_TAP_MS = 500;...@@ -18,8 +15,6 @@ export const QUIT_DOUBLE_TAP_MS = 500;
18// auto-repeat disabled fall back to the application menu Quit action.15// auto-repeat disabled fall back to the application menu Quit action.
19export const QUIT_HOLD_RELEASE_GRACE_MS = 600;16export const QUIT_HOLD_RELEASE_GRACE_MS = 600;
2017
21export type QuitHoldState = "down" | "up";
22
23export interface QuitHoldKeyInput {18export interface QuitHoldKeyInput {
24 readonly type: string;19 readonly type: string;
25 readonly key: string;20 readonly key: string;
...@@ -30,27 +25,29 @@ export interface QuitHoldKeyInput {...@@ -30,27 +25,29 @@ export interface QuitHoldKeyInput {
30 readonly isAutoRepeat: boolean;25 readonly isAutoRepeat: boolean;
31}26}
3227
33export interface QuitHoldOptions {28export interface QuitShortcutOptions {
34 readonly platform: NodeJS.Platform;29 readonly platform: NodeJS.Platform;
35 readonly isEnabled: () => Promise<boolean>;30 readonly getMode: () => Promise<QuitConfirmationMode>;
36 readonly notify: (state: QuitHoldState) => void;31 readonly notify: (event: QuitShortcutHintEvent) => void;
37 readonly quit: () => void;32 readonly quit: () => void;
38}33}
3934
40export function makeQuitHoldHandler(35export function makeQuitShortcutHandler(
41 options: QuitHoldOptions,36 options: QuitShortcutOptions,
42): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void {37): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void {
43 const modifierKey = options.platform === "darwin" ? "meta" : "control";38 const modifierKey = options.platform === "darwin" ? "meta" : "control";
44 let watchdog: NodeJS.Timeout | undefined;39 let watchdog: NodeJS.Timeout | undefined;
45 let holding = false;40 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.
47 let armed = false;44 let armed = false;
48 let quitOnRelease = false;45 let quitOnRelease = false;
49 let heldSince = 0;46 let heldSince = 0;
50 let lastPressAt = 0;47 let lastPressAt = 0;
51 // Incremented on every new press and every release/quit so a pending48 // Incremented when a press is superseded or explicitly cancelled. A plain
52 // isEnabled() resolution from a superseded press cannot arm (or quit for)49 // key release does not invalidate its pending mode read: direct mode and a
53 // the current one.50 // completed second press must still be honored after that read settles.
54 let generation = 0;51 let generation = 0;
5552
56 const clearWatchdog = () => {53 const clearWatchdog = () => {
...@@ -60,19 +57,24 @@ export function makeQuitHoldHandler(...@@ -60,19 +57,24 @@ export function makeQuitHoldHandler(
60 }57 }
61 };58 };
6259
63 const release = () => {60 const release = (cancelPendingMode = true, keepDoublePressHint = false) => {
64 if (!holding) return;61 if (!holding && !notified) return;
65 const shouldNotify = armed || quitOnRelease;62 const keepHint = keepDoublePressHint && mode === "double-click" && notified;
66 generation += 1;63 if (cancelPendingMode) generation += 1;
67 holding = false;64 holding = false;
68 armed = false;65 armed = false;
69 quitOnRelease = false;66 quitOnRelease = false;
67 if (keepHint) return;
68
69 mode = undefined;
70 clearWatchdog();70 clearWatchdog();
71 if (shouldNotify) options.notify("up");71 if (notified) {
72 notified = false;
73 options.notify({ state: "up" });
74 }
72 };75 };
7376
74 // Dismisses any overlay first: if the quit is cancelled downstream the77 // Dismisses any overlay first so a cancelled quit cannot leave a stale hint.
75 // renderer must not be left with a stuck "Hold to Quit" hint.
76 const quitNow = () => {78 const quitNow = () => {
77 release();79 release();
78 options.quit();80 options.quit();
...@@ -83,11 +85,11 @@ export function makeQuitHoldHandler(...@@ -83,11 +85,11 @@ export function makeQuitHoldHandler(
83 if (input.type === "keyUp") {85 if (input.type === "keyUp") {
84 if (key === "q") {86 if (key === "q") {
85 const shouldQuit = quitOnRelease;87 const shouldQuit = quitOnRelease;
86 release();88 release(false, true);
87 if (shouldQuit) options.quit();89 if (shouldQuit) options.quit();
88 } else if (key === modifierKey) {90 } else if (key === modifierKey) {
89 if (!quitOnRelease) {91 if (!quitOnRelease) {
90 release();92 release(false, true);
91 } else {93 } else {
92 watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS);94 watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS);
93 }95 }
...@@ -104,13 +106,17 @@ export function makeQuitHoldHandler(...@@ -104,13 +106,17 @@ export function makeQuitHoldHandler(
104106
105 const modifierDown = options.platform === "darwin" ? input.meta : input.control;107 const modifierDown = options.platform === "darwin" ? input.meta : input.control;
106 if (!modifierDown || input.alt || input.shift || key !== "q") {108 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
107 // Any other key (or an extra modifier) pressed mid-hold breaks the113 // Any other key (or an extra modifier) pressed mid-hold breaks the
108 // gesture; without this the hold timer keeps running through the114 // gesture; without this the hold timer keeps running through the
109 // interruption and the next qualifying repeat would quit early. The115 // interruption and the next qualifying repeat would quit early. The
110 // interrupted press also stops counting toward a double tap — but only116 // interrupted press also stops counting toward a double press, but only
111 // here, not in release(), which runs mid-restart on an unseen-release117 // here, not in release(), which runs mid-restart on an unseen-release
112 // re-press and must not wipe that press's own tap timestamp.118 // re-press and must not wipe that press's own tap timestamp.
113 if (holding && !input.isAutoRepeat) {119 if ((holding || notified) && !input.isAutoRepeat) {
114 lastPressAt = 0;120 lastPressAt = 0;
115 release();121 release();
116 }122 }
...@@ -120,7 +126,7 @@ export function makeQuitHoldHandler(...@@ -120,7 +126,7 @@ export function makeQuitHoldHandler(
120 event.preventDefault();126 event.preventDefault();
121127
122 if (input.isAutoRepeat) {128 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) {
124 armed = false;130 armed = false;
125 quitOnRelease = true;131 quitOnRelease = true;
126 clearWatchdog();132 clearWatchdog();
...@@ -131,28 +137,51 @@ export function makeQuitHoldHandler(...@@ -131,28 +137,51 @@ export function makeQuitHoldHandler(
131 const now = Date.now();137 const now = Date.now();
132 const previousPressAt = lastPressAt;138 const previousPressAt = lastPressAt;
133 lastPressAt = now;139 lastPressAt = now;
134 // A fresh keydown while "holding" means the key came back down after a140 // A fresh keydown supersedes the current physical hold or the hint kept
135 // release macOS never delivered — so both branches below see real taps.141 // alive after a detected release.
136 if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) {142 if (holding || notified) release();
137 quitNow();
138 return;
139 }
140 if (holding) release();
141143
142 generation += 1;144 generation += 1;
143 const pressGeneration = generation;145 const pressGeneration = generation;
144 holding = true;146 holding = true;
145 heldSince = now;147 heldSince = now;
146 void options.isEnabled().then(148 void options.getMode().then(
147 (enabled) => {149 (resolvedMode) => {
148 if (generation !== pressGeneration) return;150 if (generation !== pressGeneration) return;
149 if (!enabled) {151 if (resolvedMode === "direct") {
150 // Hold-to-quit disabled: a single press quits immediately.
151 quitNow();152 quitNow();
152 return;153 return;
153 }154 }
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
154 armed = true;184 armed = true;
155 options.notify("down");
156 // No auto-repeat by then means the key was released (possibly with a185 // No auto-repeat by then means the key was released (possibly with a
157 // suppressed keyUp) or repeat is disabled; either way, don't quit.186 // suppressed keyUp) or repeat is disabled; either way, don't quit.
158 watchdog = setTimeout(() => {187 watchdog = setTimeout(() => {
apps/web/src/components/QuitHoldOverlay.tsx+20-13
...@@ -2,29 +2,34 @@ import { useEffect, useState } from "react";...@@ -2,29 +2,34 @@ import { useEffect, useState } from "react";
22
3import { isMacPlatform } from "../lib/utils";3import { isMacPlatform } from "../lib/utils";
44
5// Matches the hold duration in apps/desktop/src/window/QuitHold.ts: the hint5// A released hold hint lingers for the original hold duration. Double-press
6// from a quick tap lingers for as long as a full hold would have taken.6// hints disappear as soon as their acceptance window closes.
7const HIDE_AFTER_RELEASE_MS = 1200;7const HOLD_HINT_LINGER_MS = 1200;
88
9/**9/**
10 * Chrome-style "Hold ⌘Q to Quit" hint. The desktop main process intercepts10 * The desktop main process intercepts the quit accelerator and pushes
11 * the quit accelerator and pushes press/release states; a quick tap shows11 * press/release states while it waits for a hold or second press.
12 * this pill while a full hold quits the app.
13 */12 */
14export function QuitHoldOverlay() {13export function QuitHoldOverlay() {
15 const [visible, setVisible] = useState(false);14 const [visibleMode, setVisibleMode] = useState<"hold" | "double-click" | null>(null);
1615
17 useEffect(() => {16 useEffect(() => {
18 const subscribe = window.desktopBridge?.onQuitShortcut;17 const subscribe = window.desktopBridge?.onQuitShortcut;
19 if (!subscribe) return;18 if (!subscribe) return;
20 let hideTimer: number | undefined;19 let hideTimer: number | undefined;
21 const unsubscribe = subscribe((state) => {20 let pressedMode: "hold" | "double-click" = "hold";
21 const unsubscribe = subscribe((hint) => {
22 window.clearTimeout(hideTimer);22 window.clearTimeout(hideTimer);
23 if (state === "down") {23 if (hint.state === "down") {
24 setVisible(true);24 pressedMode = hint.mode;
25 setVisibleMode(hint.mode);
25 return;26 return;
26 }27 }
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);
28 });33 });
29 return () => {34 return () => {
30 window.clearTimeout(hideTimer);35 window.clearTimeout(hideTimer);
...@@ -32,15 +37,17 @@ export function QuitHoldOverlay() {...@@ -32,15 +37,17 @@ export function QuitHoldOverlay() {
32 };37 };
33 }, []);38 }, []);
3439
35 if (!visible) return null;40 if (!visibleMode) return null;
36 const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q";41 const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q";
42 const message =
43 visibleMode === "hold" ? `Hold ${shortcut} to Quit` : `Press ${shortcut} again to Quit`;
37 return (44 return (
38 <div45 <div
39 role="status"46 role="status"
40 className="pointer-events-none fixed inset-x-0 top-[22%] z-100 flex justify-center"47 className="pointer-events-none fixed inset-x-0 top-[22%] z-100 flex justify-center"
41 >48 >
42 <div className="rounded-full bg-neutral-700/95 px-8 py-4 text-2xl font-bold text-white shadow-xl">49 <div className="rounded-full bg-neutral-700/95 px-8 py-4 text-2xl font-bold text-white shadow-xl">
43 Hold {shortcut} to Quit50 {message}
44 </div>51 </div>
45 </div>52 </div>
46 );53 );
apps/web/src/components/settings/SettingsPanels.tsx+29-10
...@@ -34,6 +34,7 @@ import {...@@ -34,6 +34,7 @@ import {
34 MIN_PROMPT_FONT_SIZE,34 MIN_PROMPT_FONT_SIZE,
35 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,35 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
36 MIN_TERMINAL_FONT_SIZE,36 MIN_TERMINAL_FONT_SIZE,
37 type QuitConfirmationMode,
37} from "@t3tools/contracts/settings";38} from "@t3tools/contracts/settings";
38import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";39import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
39import { createModelSelection } from "@t3tools/shared/model";40import { createModelSelection } from "@t3tools/shared/model";
...@@ -163,6 +164,12 @@ const TIMESTAMP_FORMAT_LABELS = {...@@ -163,6 +164,12 @@ const TIMESTAMP_FORMAT_LABELS = {
163 "24-hour": "24-hour",164 "24-hour": "24-hour",
164} as const;165} as const;
165166
167const QUIT_CONFIRMATION_MODE_LABELS: Record<QuitConfirmationMode, string> = {
168 direct: "Direct",
169 hold: "Hold",
170 "double-click": "Double press",
171};
172
166const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, string> = {173const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, string> = {
167 balanced: "Balanced",174 balanced: "Balanced",
168 performance: "Performance",175 performance: "Performance",
...@@ -542,9 +549,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -542,9 +549,7 @@ export function useSettingsRestore(onRestored?: () => void) {
542 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete549 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
543 ? ["Delete confirmation"]550 ? ["Delete confirmation"]
544 : []),551 : []),
545 ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit552 ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit shortcut"] : []),
546 ? ["Quit confirmation"]
547 : []),
548 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),553 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),
549 ...getChangedBrowserSettingLabels(settings),554 ...getChangedBrowserSettingLabels(settings),
550 ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess555 ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess
...@@ -2422,11 +2427,11 @@ export function GeneralSettingsPanel() {...@@ -2422,11 +2427,11 @@ export function GeneralSettingsPanel() {
2422 {isElectron ? (2427 {isElectron ? (
2423 <SettingsRow2428 <SettingsRow
2424 {...searchableSetting("quit-confirmation")}2429 {...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."
2426 resetAction={2431 resetAction={
2427 settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? (2432 settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? (
2428 <SettingResetButton2433 <SettingResetButton
2429 label="quit confirmation"2434 label="quit shortcut behavior"
2430 onClick={() =>2435 onClick={() =>
2431 updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit })2436 updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit })
2432 }2437 }
...@@ -2434,11 +2439,25 @@ export function GeneralSettingsPanel() {...@@ -2434,11 +2439,25 @@ export function GeneralSettingsPanel() {
2434 ) : null2439 ) : null
2435 }2440 }
2436 control={2441 control={
2437 <Switch2442 <Select
2438 checked={settings.confirmQuit}2443 value={settings.confirmQuit}
2439 onCheckedChange={(checked) => updateSettings({ confirmQuit: Boolean(checked) })}2444 onValueChange={(value) => {
2440 aria-label="Hold to quit"2445 if (value === "direct" || value === "hold" || value === "double-click") {
2441 />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>
2442 }2461 }
2443 />2462 />
2444 ) : null}2463 ) : null}
apps/web/src/components/settings/settingsSearch.ts+2-2
...@@ -238,9 +238,9 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -238,9 +238,9 @@ export const SETTINGS_SEARCH_ITEMS = [
238 },238 },
239 {239 {
240 id: "quit-confirmation",240 id: "quit-confirmation",
241 title: "Hold to quit",241 title: "Quit shortcut",
242 to: "/settings/general",242 to: "/settings/general",
243 searchTerms: ["confirmation shortcut desktop app exit"],243 searchTerms: ["confirmation desktop app exit direct hold double click press twice"],
244 desktopOnly: true,244 desktopOnly: true,
245 },245 },
246 {246 {
packages/contracts/src/ipc.ts+8-5
...@@ -91,7 +91,7 @@ import { EnvironmentId } from "./baseSchemas.ts";...@@ -91,7 +91,7 @@ import { EnvironmentId } from "./baseSchemas.ts";
91import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";91import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";
92import { AdvertisedEndpoint } from "./remoteAccess.ts";92import { AdvertisedEndpoint } from "./remoteAccess.ts";
93import { ExecutionEnvironmentDescriptor } from "./environment.ts";93import { ExecutionEnvironmentDescriptor } from "./environment.ts";
94import type { ClientSettings } from "./settings.ts";94import type { ClientSettings, QuitConfirmationMode } from "./settings.ts";
95import type { EditorId } from "./editor.ts";95import type { EditorId } from "./editor.ts";
96import type {96import type {
97 SourceControlCloneRepositoryInput,97 SourceControlCloneRepositoryInput,
...@@ -116,6 +116,10 @@ export interface ContextMenuItem<T extends string = string> {...@@ -116,6 +116,10 @@ export interface ContextMenuItem<T extends string = string> {
116 children?: readonly ContextMenuItem<T>[];116 children?: readonly ContextMenuItem<T>[];
117}117}
118118
119export type QuitShortcutHintEvent =
120 | { readonly state: "down"; readonly mode: Exclude<QuitConfirmationMode, "direct"> }
121 | { readonly state: "up" };
122
119export interface ContextMenuItemSchemaType {123export interface ContextMenuItemSchemaType {
120 readonly id: string;124 readonly id: string;
121 readonly label: string;125 readonly label: string;
...@@ -1140,11 +1144,10 @@ export interface DesktopBridge {...@@ -1140,11 +1144,10 @@ export interface DesktopBridge {
1140 probeRemoteEditors?: () => Promise<readonly EditorId[]>;1144 probeRemoteEditors?: () => Promise<readonly EditorId[]>;
1141 onMenuAction: (listener: (action: string) => void) => () => void;1145 onMenuAction: (listener: (action: string) => void) => () => void;
1142 /**1146 /**
1143 * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed,1147 * Quit-confirmation hint pushes. Optional: older desktop builds never emit
1144 * "up" when it is released before the hold completes. Optional: older1148 * them.
1145 * desktop builds never emit it.
1146 */1149 */
1147 onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void;1150 onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void;
1148 getWindowFullscreenState: () => boolean;1151 getWindowFullscreenState: () => boolean;
1149 onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;1152 onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;
1150 getUpdateState: () => Promise<DesktopUpdateState>;1153 getUpdateState: () => Promise<DesktopUpdateState>;
packages/contracts/src/settings.test.ts+26
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
1515
16const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema);16const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema);
17const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch);17const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch);
18const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema);
18const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);19const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
19const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);20const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
20const encodeServerSettings = Schema.encodeSync(ServerSettings);21const encodeServerSettings = Schema.encodeSync(ServerSettings);
...@@ -66,6 +67,31 @@ describe("ClientSettings word wrap", () => {...@@ -66,6 +67,31 @@ describe("ClientSettings word wrap", () => {
66 });67 });
67});68});
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
69describe("ClientSettings browser recording frame rate", () => {95describe("ClientSettings browser recording frame rate", () => {
70 it("defaults to 30 fps", () => {96 it("defaults to 30 fps", () => {
71 expect(decodeClientSettings({}).browserRecordingFrameRate).toBe(30);97 expect(decodeClientSettings({}).browserRecordingFrameRate).toBe(30);
packages/contracts/src/settings.ts+22-4
...@@ -124,6 +124,22 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill",...@@ -124,6 +124,22 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill",
124export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type;124export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type;
125export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork";125export 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
127/**143/**
128 * A user-chosen font family (a single name or a comma-separated list). Empty144 * A user-chosen font family (a single name or a comma-separated list). Empty
129 * means "use the app default"; clients compose their own fallback stacks.145 * means "use the app default"; clients compose their own fallback stacks.
...@@ -183,9 +199,11 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -183,9 +199,11 @@ export const ClientSettingsSchema = Schema.Struct({
183 browserAutoShowFloatingPreview: Schema.Boolean.pipe(199 browserAutoShowFloatingPreview: Schema.Boolean.pipe(
184 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)),200 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)),
185 ),201 ),
186 // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the202 // Desktop-only. Boolean values from older settings files decode to their
187 // app quits; a quick tap only shows a hint. Browser clients ignore it.203 // equivalent mode and encode back as the canonical string value.
188 confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),204 confirmQuit: QuitConfirmationModeSetting.pipe(
205 Schema.withDecodingDefault(Effect.succeed(DEFAULT_QUIT_CONFIRMATION_MODE)),
206 ),
189 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),207 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
190 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),208 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
191 confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),209 confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
...@@ -938,7 +956,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -938,7 +956,7 @@ export const ClientSettingsPatch = Schema.Struct({
938 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),956 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),
939 browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate),957 browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate),
940 browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean),958 browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean),
941 confirmQuit: Schema.optionalKey(Schema.Boolean),959 confirmQuit: Schema.optionalKey(QuitConfirmationMode),
942 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),960 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
943 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),961 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
944 confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),962 confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),