From 6c7f2a5e289b3637d9bfca64f60644d8f02efb12 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:03:51 +0000 Subject: [PATCH 1/5] feat(contracts): add confirmQuit client setting Desktop clients read it before quitting; defaults to on. --- packages/contracts/src/settings.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cbb547b95fb..ebe5ccc902e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -112,6 +112,9 @@ export type FontFamilyPreference = typeof FontFamilyPreference.Type; export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Desktop-only: ask before the app quits (Cmd/Ctrl+Q, the Quit menu item, + // or any other app-level quit). Browser clients ignore it. + confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -748,6 +751,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), + confirmQuit: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), From a0363301ff9453a298929243be7e5f221acdc504 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:04:04 +0000 Subject: [PATCH 2/5] feat(desktop): confirm before quitting the app Cmd/Ctrl+Q and the Quit menu item now prompt before the shutdown sequence starts, unless the user turned the setting off. Quits with no window on screen (updater relaunch, second instance, last window closed, signals) stay silent so the app can never be stranded without UI. --- apps/desktop/src/app/DesktopLifecycle.test.ts | 384 ++++++++++++++---- apps/desktop/src/app/DesktopLifecycle.ts | 133 ++++-- apps/desktop/src/ipc/methods/wsl.test.ts | 12 + .../settings/DesktopClientSettings.test.ts | 1 + 4 files changed, 419 insertions(+), 111 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index be9d7f3451f..307ed660f44 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -1,119 +1,206 @@ import { assert, describe, it } from "@effect/vitest"; +import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import type * as Electron from "electron"; import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +type AppListeners = Map void>; + +interface Harness { + readonly listeners: AppListeners; + readonly quitCalls: Array; + readonly messageBoxes: Array; +} + +// The before-quit handler resolves through promise callbacks, so tests wait a +// macrotask for the confirmation and shutdown chain to settle. +const settle = Effect.promise( + () => + new Promise((resolve) => { + setImmediate(resolve); + }), +); + +function makeElectronAppLayer(harness: Harness) { + return Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.sync(() => { + harness.quitCalls.push("quit"); + }), + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + getAppMetrics: Effect.succeed([]), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: () => Effect.succeed(true), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: (listener) => + Effect.acquireRelease( + Effect.sync(() => { + harness.listeners.set("before-quit-for-update", listener); + }), + () => + Effect.sync(() => { + harness.listeners.delete("before-quit-for-update"); + }), + ).pipe(Effect.asVoid), + on: (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + harness.listeners.set( + eventName, + listener as unknown as (...args: readonly unknown[]) => void, + ); + }), + () => + Effect.sync(() => { + harness.listeners.delete(eventName); + }), + ).pipe(Effect.asVoid), + } satisfies ElectronApp.ElectronApp["Service"]); +} + +function makeLayer(input: { + readonly harness: Harness; + readonly platform: NodeJS.Platform; + readonly confirmResponse: number; + readonly hasWindow: boolean; + readonly clientSettings: ClientSettings; +}) { + const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, + }); + + const electronDialogLayer = Layer.succeed( + ElectronDialog.ElectronDialog, + ElectronDialog.ElectronDialog.of({ + pickFolder: () => Effect.succeed(Option.none()), + confirm: () => Effect.succeed(false), + showMessageBox: (options) => + Effect.sync(() => { + input.harness.messageBoxes.push(options); + return { response: input.confirmResponse, checkboxChecked: false }; + }), + showErrorBox: () => Effect.void, + }), + ); + + const electronWindowLayer = Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({ + currentMainOrFirst: Effect.succeed( + input.hasWindow ? Option.some({} as Electron.BrowserWindow) : Option.none(), + ), + } as ElectronWindow.ElectronWindow["Service"]), + ); + + const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected window creation"), + ensureMain: Effect.die("unexpected window creation"), + revealOrCreateMain: Effect.die("unexpected window creation"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + dispatchMenuAction: () => Effect.void, + syncAppearance: Effect.void, + }); + + const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: input.platform, + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + + const shutdownLayer = Layer.succeed( + DesktopShutdown.DesktopShutdown, + DesktopShutdown.DesktopShutdown.of({ + request: Effect.void, + awaitRequest: Effect.void, + markComplete: Effect.void, + awaitComplete: Effect.void, + isComplete: Effect.succeed(true), + }), + ); + + return DesktopLifecycle.layer.pipe( + Layer.provideMerge(makeElectronAppLayer(input.harness)), + Layer.provideMerge(electronThemeLayer), + Layer.provideMerge(electronDialogLayer), + Layer.provideMerge(electronWindowLayer), + Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(shutdownLayer), + Layer.provideMerge(DesktopClientSettings.layerTest(Option.some(input.clientSettings))), + Layer.provideMerge(DesktopState.layer), + ); +} + +function makeHarness(): Harness { + return { listeners: new Map(), quitCalls: [], messageBoxes: [] }; +} + +function emitBeforeQuit(listeners: AppListeners): boolean { + let prevented = false; + const event = { + preventDefault: () => { + prevented = true; + }, + } as Electron.Event; + listeners.get("before-quit")?.(event); + return prevented; +} + describe("DesktopLifecycle", () => { for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray) { it.effect(`lets the updater's quit event proceed on ${platform}`, () => { - const appListeners = new Map void>(); - - const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { - metadata: Effect.die("unexpected metadata read"), - name: Effect.succeed("T3 Code"), - whenReady: Effect.void, - quit: Effect.void, - exit: () => Effect.void, - relaunch: () => Effect.void, - setPath: () => Effect.void, - setName: () => Effect.void, - setAboutPanelOptions: () => Effect.void, - setAppUserModelId: () => Effect.void, - getAppMetrics: Effect.succeed([]), - isDefaultProtocolClient: () => Effect.succeed(false), - setAsDefaultProtocolClient: () => Effect.succeed(true), - setDesktopName: () => Effect.void, - setDockIcon: () => Effect.void, - appendCommandLineSwitch: () => Effect.void, - removeCommandLineSwitch: () => Effect.void, - onBeforeQuitForUpdate: (listener) => - Effect.acquireRelease( - Effect.sync(() => { - appListeners.set("before-quit-for-update", listener); - }), - () => - Effect.sync(() => { - appListeners.delete("before-quit-for-update"); - }), - ).pipe(Effect.asVoid), - on: (eventName, listener) => - Effect.acquireRelease( - Effect.sync(() => { - appListeners.set( - eventName, - listener as unknown as (...args: readonly unknown[]) => void, - ); - }), - () => - Effect.sync(() => { - appListeners.delete(eventName); - }), - ).pipe(Effect.asVoid), - } satisfies ElectronApp.ElectronApp["Service"]); - - const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { - shouldUseDarkColors: Effect.succeed(false), - setSource: () => Effect.void, - onUpdated: () => Effect.void, - }); - - const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { - createMain: Effect.die("unexpected window creation"), - ensureMain: Effect.die("unexpected window creation"), - revealOrCreateMain: Effect.die("unexpected window creation"), - activate: Effect.void, - createMainIfBackendReady: Effect.void, - showConnectingSplash: Effect.void, - handleBackendReady: () => Effect.void, - handleBackendNotReady: Effect.void, - flushMainWindowBounds: Effect.void, - dispatchMenuAction: () => Effect.void, - syncAppearance: Effect.void, - }); - - const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + const harness = makeHarness(); + const layer = makeLayer({ + harness, platform, - isDevelopment: false, - } as DesktopEnvironment.DesktopEnvironment["Service"]); - - const layer = DesktopLifecycle.layer.pipe( - Layer.provideMerge(electronAppLayer), - Layer.provideMerge(electronThemeLayer), - Layer.provideMerge(desktopWindowLayer), - Layer.provideMerge(environmentLayer), - Layer.provideMerge(DesktopShutdown.layer), - Layer.provideMerge(DesktopState.layer), - ); + confirmResponse: 0, + hasWindow: true, + clientSettings: DEFAULT_CLIENT_SETTINGS, + }); return Effect.scoped( Effect.gen(function* () { const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; yield* lifecycle.register; - appListeners.get("before-quit-for-update")?.(); - - let prevented = false; - const event = { - preventDefault: () => { - prevented = true; - }, - } as Electron.Event; - appListeners.get("before-quit")?.(event); + harness.listeners.get("before-quit-for-update")?.(); + const prevented = emitBeforeQuit(harness.listeners); assert.isFalse( prevented, "cancelling this event prevents the updater from completing its relaunch", ); + assert.deepEqual(harness.messageBoxes, []); const state = yield* DesktopState.DesktopState; assert.isTrue(yield* Ref.get(state.quitting)); @@ -121,4 +208,129 @@ describe("DesktopLifecycle", () => { ).pipe(Effect.provide(layer)); }); } + + it.effect("keeps the app running when the quit confirmation is dismissed", () => { + const harness = makeHarness(); + const layer = makeLayer({ + harness, + platform: "darwin", + confirmResponse: 0, + hasWindow: true, + clientSettings: DEFAULT_CLIENT_SETTINGS, + }); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + assert.isTrue(emitBeforeQuit(harness.listeners)); + yield* settle; + + assert.lengthOf(harness.messageBoxes, 1); + assert.deepEqual(harness.quitCalls, []); + const state = yield* DesktopState.DesktopState; + assert.isFalse(yield* Ref.get(state.quitting)); + }), + ).pipe(Effect.provide(layer)); + }); + + it.effect("shuts down and quits once the confirmation is accepted", () => { + const harness = makeHarness(); + const layer = makeLayer({ + harness, + platform: "darwin", + confirmResponse: 1, + hasWindow: true, + clientSettings: DEFAULT_CLIENT_SETTINGS, + }); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + assert.isTrue(emitBeforeQuit(harness.listeners)); + yield* settle; + + assert.lengthOf(harness.messageBoxes, 1); + assert.deepEqual(harness.quitCalls, ["quit"]); + const state = yield* DesktopState.DesktopState; + assert.isTrue(yield* Ref.get(state.quitting)); + }), + ).pipe(Effect.provide(layer)); + }); + + it.effect("quits without asking when the confirmation setting is off", () => { + const harness = makeHarness(); + const layer = makeLayer({ + harness, + platform: "darwin", + confirmResponse: 0, + hasWindow: true, + clientSettings: { ...DEFAULT_CLIENT_SETTINGS, confirmQuit: false }, + }); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + assert.isTrue(emitBeforeQuit(harness.listeners)); + yield* settle; + + assert.deepEqual(harness.messageBoxes, []); + assert.deepEqual(harness.quitCalls, ["quit"]); + }), + ).pipe(Effect.provide(layer)); + }); + + it.effect("quits without asking when no window is open", () => { + const harness = makeHarness(); + const layer = makeLayer({ + harness, + platform: "win32", + confirmResponse: 0, + hasWindow: false, + clientSettings: DEFAULT_CLIENT_SETTINGS, + }); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + assert.isTrue(emitBeforeQuit(harness.listeners)); + yield* settle; + + assert.deepEqual(harness.messageBoxes, []); + assert.deepEqual(harness.quitCalls, ["quit"]); + }), + ).pipe(Effect.provide(layer)); + }); + + it.effect("asks once while a confirmation dialog is already open", () => { + const harness = makeHarness(); + const layer = makeLayer({ + harness, + platform: "darwin", + confirmResponse: 0, + hasWindow: true, + clientSettings: DEFAULT_CLIENT_SETTINGS, + }); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + assert.isTrue(emitBeforeQuit(harness.listeners)); + assert.isTrue(emitBeforeQuit(harness.listeners)); + yield* settle; + + assert.lengthOf(harness.messageBoxes, 1); + assert.deepEqual(harness.quitCalls, []); + }), + ).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index ab03d18f38d..cdde6facbcf 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -1,6 +1,8 @@ +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -10,8 +12,11 @@ import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; @@ -28,15 +33,18 @@ export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass { + const electronWindow = yield* ElectronWindow.ElectronWindow; + if (Option.isNone(yield* electronWindow.currentMainOrFirst)) { + return true; + } + + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const settings = yield* clientSettings.get; + const confirmQuit = Option.match(settings, { + onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, + onSome: (value) => value.confirmQuit, + }); + if (!confirmQuit) { + return true; + } + + const electronApp = yield* ElectronApp.ElectronApp; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const appName = yield* electronApp.name; + const result = yield* electronDialog + .showMessageBox({ + type: "question", + title: `Quit ${appName}`, + message: `Quit ${appName}?`, + detail: "Running agents and terminals will be stopped.", + buttons: ["Cancel", "Quit"], + defaultId: QUIT_BUTTON_INDEX, + cancelId: 0, + noLink: true, + }) + .pipe( + Effect.catch((error: ElectronDialog.ElectronDialogShowMessageBoxError) => + logLifecycleError("quit confirmation dialog failed", { error }).pipe( + Effect.as({ response: QUIT_BUTTON_INDEX, checkboxChecked: false }), + ), + ), + ); + return result.response === QUIT_BUTTON_INDEX; + }, +); + +interface QuitGate { + allowed: boolean; + updaterAllowed: boolean; + confirming: boolean; +} + function handleBeforeQuit( event: Electron.Event, runEffect: (effect: Effect.Effect) => Promise, - allowQuit: () => boolean, - markQuitAllowed: () => void, + gate: QuitGate, ): void { - if (allowQuit()) { + if (gate.allowed || gate.updaterAllowed) { void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; @@ -104,22 +171,46 @@ function handleBeforeQuit( } event.preventDefault(); + if (gate.confirming) { + return; + } + gate.confirming = true; + + const quitAfterShutdown = () => { + gate.allowed = true; + void runEffect( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.quit; + }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), + ); + }; + void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; + const wasQuitting = yield* Ref.get(state.quitting); + if (!wasQuitting && !(yield* confirmQuitRequested())) { + yield* logLifecycleInfo("quit cancelled from confirmation dialog"); + return false; + } yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); yield* requestDesktopShutdownAndWait(); + return true; }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), - ).finally(() => { - markQuitAllowed(); - void runEffect( - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.quit; - }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), - ); - }); + ).then( + (shouldQuit) => { + gate.confirming = false; + if (shouldQuit) { + quitAfterShutdown(); + } + }, + () => { + gate.confirming = false; + quitAfterShutdown(); + }, + ); } function quitFromSignal( @@ -175,8 +266,7 @@ export const make = DesktopLifecycle.of({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); - let quitAllowed = false; - let updaterQuitAllowed = false; + const quitGate: QuitGate = { allowed: false, updaterAllowed: false, confirming: false }; yield* electronTheme.onUpdated(() => { void runEffect( desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), @@ -186,7 +276,7 @@ export const make = DesktopLifecycle.of({ // Electron's updater owns the remaining quit/install/relaunch sequence. // Cancelling the following app "before-quit" event breaks that sequence, // most visibly on macOS where the native updater performs the relaunch. - updaterQuitAllowed = true; + quitGate.updaterAllowed = true; void runEffect( logLifecycleInfo("allowing updater-controlled quit").pipe( Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"), @@ -194,14 +284,7 @@ export const make = DesktopLifecycle.of({ ); }); yield* electronApp.on("before-quit", (event: Electron.Event) => { - handleBeforeQuit( - event, - runEffect, - () => quitAllowed || updaterQuitAllowed, - () => { - quitAllowed = true; - }, - ); + handleBeforeQuit(event, runEffect, quitGate); }); yield* electronApp.on("activate", () => { void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate"))); diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 3e07ae7f39b..38435e286fa 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; import * as DesktopState from "../../app/DesktopState.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; @@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( ElectronTheme.ElectronTheme, ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), ), + Layer.succeed( + ElectronDialog.ElectronDialog, + ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]), + ), + Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({} as ElectronWindow.ElectronWindow["Service"]), + ), + DesktopClientSettings.layerTest(), ); describe("WSL IPC", () => { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 53ef74f2191..d2b1d2d6d32 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -14,6 +14,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, + confirmQuit: true, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], From b4f304358ecfe53ad70ec3848109ecbcfff56ed9 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:04:12 +0000 Subject: [PATCH 3/5] feat(web): add quit confirmation setting Desktop-only General row, on by default, included in restore defaults. --- .../components/settings/SettingsPanels.tsx | 29 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 5 ++++ 2 files changed, 34 insertions(+) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9a5fe319568..2907323bf88 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -650,12 +650,16 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit + ? ["Quit confirmation"] + : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ], [ isTextGenerationModelDirty, isBackgroundActivityDirty, settings.autoOpenPlanSidebar, + settings.confirmQuit, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -713,6 +717,7 @@ export function useSettingsRestore(onRestored?: () => void) { addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, @@ -2079,6 +2084,30 @@ export function GeneralSettingsPanel() { } /> + {isElectron ? ( + + updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) + } + /> + ) : null + } + control={ + updateSettings({ confirmQuit: Boolean(checked) })} + aria-label="Confirm quitting" + /> + } + /> + ) : null} + Date: Thu, 6 Aug 2026 13:25:43 +0000 Subject: [PATCH 4/5] fix(desktop): never let the quit prompt strand the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirmation was app-modal with no owner, so a hidden or minimized window could leave a prompt the user never sees, and the in-flight guard then swallowed every later quit request — the app looked frozen and could not be closed even with the setting turned off. The dialog is now attached to the main window and reveals it first, and a repeat quit request while a confirmation is pending quits instead of being dropped. Logs the resolved quit path so a hang can be traced. --- apps/desktop/src/app/DesktopLifecycle.test.ts | 30 +++++++++----- apps/desktop/src/app/DesktopLifecycle.ts | 40 ++++++++++++------- apps/desktop/src/electron/ElectronDialog.ts | 9 ++++- 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 307ed660f44..d00344a8d09 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -20,10 +20,16 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; type AppListeners = Map void>; +const mainWindow = { id: 1 } as Electron.BrowserWindow; + interface Harness { readonly listeners: AppListeners; readonly quitCalls: Array; - readonly messageBoxes: Array; + readonly messageBoxes: Array<{ + readonly options: Electron.MessageBoxOptions; + readonly owner: Option.Option; + }>; + readonly revealedWindows: Array; } // The before-quit handler resolves through promise callbacks, so tests wait a @@ -100,9 +106,9 @@ function makeLayer(input: { ElectronDialog.ElectronDialog.of({ pickFolder: () => Effect.succeed(Option.none()), confirm: () => Effect.succeed(false), - showMessageBox: (options) => + showMessageBox: (options, owner = Option.none()) => Effect.sync(() => { - input.harness.messageBoxes.push(options); + input.harness.messageBoxes.push({ options, owner }); return { response: input.confirmResponse, checkboxChecked: false }; }), showErrorBox: () => Effect.void, @@ -112,9 +118,11 @@ function makeLayer(input: { const electronWindowLayer = Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - currentMainOrFirst: Effect.succeed( - input.hasWindow ? Option.some({} as Electron.BrowserWindow) : Option.none(), - ), + currentMainOrFirst: Effect.succeed(input.hasWindow ? Option.some(mainWindow) : Option.none()), + reveal: (window) => + Effect.sync(() => { + input.harness.revealedWindows.push(window); + }), } as ElectronWindow.ElectronWindow["Service"]), ); @@ -162,7 +170,7 @@ function makeLayer(input: { } function makeHarness(): Harness { - return { listeners: new Map(), quitCalls: [], messageBoxes: [] }; + return { listeners: new Map(), quitCalls: [], messageBoxes: [], revealedWindows: [] }; } function emitBeforeQuit(listeners: AppListeners): boolean { @@ -228,6 +236,8 @@ describe("DesktopLifecycle", () => { yield* settle; assert.lengthOf(harness.messageBoxes, 1); + assert.deepEqual(harness.messageBoxes[0]?.owner, Option.some(mainWindow)); + assert.deepEqual(harness.revealedWindows, [mainWindow]); assert.deepEqual(harness.quitCalls, []); const state = yield* DesktopState.DesktopState; assert.isFalse(yield* Ref.get(state.quitting)); @@ -309,7 +319,7 @@ describe("DesktopLifecycle", () => { ).pipe(Effect.provide(layer)); }); - it.effect("asks once while a confirmation dialog is already open", () => { + it.effect("quits without asking again when the user insists mid-confirmation", () => { const harness = makeHarness(); const layer = makeLayer({ harness, @@ -329,7 +339,9 @@ describe("DesktopLifecycle", () => { yield* settle; assert.lengthOf(harness.messageBoxes, 1); - assert.deepEqual(harness.quitCalls, []); + assert.deepEqual(harness.quitCalls, ["quit"]); + const state = yield* DesktopState.DesktopState; + assert.isTrue(yield* Ref.get(state.quitting)); }), ).pipe(Effect.provide(layer)); }); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index cdde6facbcf..713d369454d 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -109,7 +109,8 @@ const confirmQuitRequested = Effect.fn("desktop.lifecycle.confirmQuitRequested") | ElectronWindow.ElectronWindow > { const electronWindow = yield* ElectronWindow.ElectronWindow; - if (Option.isNone(yield* electronWindow.currentMainOrFirst)) { + const owner = yield* electronWindow.currentMainOrFirst; + if (Option.isNone(owner)) { return true; } @@ -120,23 +121,30 @@ const confirmQuitRequested = Effect.fn("desktop.lifecycle.confirmQuitRequested") onSome: (value) => value.confirmQuit, }); if (!confirmQuit) { + yield* logLifecycleInfo("quit confirmation disabled, quitting"); return true; } const electronApp = yield* ElectronApp.ElectronApp; const electronDialog = yield* ElectronDialog.ElectronDialog; const appName = yield* electronApp.name; + // The dialog is window-modal: a hidden or minimized owner would hide it + // too, leaving a prompt nobody can answer and an app that won't quit. + yield* electronWindow.reveal(owner.value); const result = yield* electronDialog - .showMessageBox({ - type: "question", - title: `Quit ${appName}`, - message: `Quit ${appName}?`, - detail: "Running agents and terminals will be stopped.", - buttons: ["Cancel", "Quit"], - defaultId: QUIT_BUTTON_INDEX, - cancelId: 0, - noLink: true, - }) + .showMessageBox( + { + type: "question", + title: `Quit ${appName}`, + message: `Quit ${appName}?`, + detail: "Running agents and terminals will be stopped.", + buttons: ["Cancel", "Quit"], + defaultId: QUIT_BUTTON_INDEX, + cancelId: 0, + noLink: true, + }, + owner, + ) .pipe( Effect.catch((error: ElectronDialog.ElectronDialogShowMessageBoxError) => logLifecycleError("quit confirmation dialog failed", { error }).pipe( @@ -171,9 +179,10 @@ function handleBeforeQuit( } event.preventDefault(); - if (gate.confirming) { - return; - } + // Quitting again while a confirmation is still up means the user is + // insisting, so honour it instead of asking twice. Swallowing the request + // would strand the app for good if the prompt is never answered. + const skipConfirmation = gate.confirming; gate.confirming = true; const quitAfterShutdown = () => { @@ -181,6 +190,7 @@ function handleBeforeQuit( void runEffect( Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; + yield* logLifecycleInfo("shutdown finished, quitting"); yield* electronApp.quit; }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), ); @@ -190,7 +200,7 @@ function handleBeforeQuit( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const wasQuitting = yield* Ref.get(state.quitting); - if (!wasQuitting && !(yield* confirmQuitRequested())) { + if (!skipConfirmation && !wasQuitting && !(yield* confirmQuitRequested())) { yield* logLifecycleInfo("quit cancelled from confirmation dialog"); return false; } diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index be633971bea..1d922eb1e93 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -97,6 +97,7 @@ export class ElectronDialog extends Context.Service< ) => Effect.Effect; readonly showMessageBox: ( options: Electron.MessageBoxOptions, + owner?: Option.Option, ) => Effect.Effect; readonly showErrorBox: (title: string, content: string) => Effect.Effect; } @@ -170,9 +171,13 @@ export const make = ElectronDialog.of({ }); return result.response === CONFIRM_BUTTON_INDEX; }), - showMessageBox: (options) => + showMessageBox: (options, owner = Option.none()) => Effect.tryPromise({ - try: () => Electron.dialog.showMessageBox(options), + try: () => + Option.match(owner, { + onNone: () => Electron.dialog.showMessageBox(options), + onSome: (ownerWindow) => Electron.dialog.showMessageBox(ownerWindow, options), + }), catch: (cause) => new ElectronDialogShowMessageBoxError({ type: options.type ?? null, From c0a33cb2b26756576f8aeba374c30f16bd135e8f Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:31:39 +0000 Subject: [PATCH 5/5] fix(web): keep desktop-only settings out of browser search The quit confirmation row only renders in the desktop app, so a browser search hit linked to an anchor that does not exist. --- .../src/components/settings/settingsSearch.test.ts | 5 +++++ apps/web/src/components/settings/settingsSearch.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 464f92547e5..6a29c39b29e 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -59,6 +59,11 @@ describe("searchSettings", () => { expect(searchSettings(" ", ITEMS)).toEqual([]); }); + it("hides desktop-only settings from browser search", () => { + expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); + expect(searchSettings("quit confirmation")).toEqual([]); + }); + it("keeps catalog result ids unique", () => { const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 92463bac729..fc5a75b9025 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,3 +1,5 @@ +import { isElectron } from "~/env"; + export type SettingsPath = | "/settings/general" | "/settings/appearance" @@ -13,6 +15,9 @@ export interface SettingsSearchItem { readonly title: string; readonly to: SettingsPath; readonly targetId?: string; + // Its row only renders in the desktop app, so a browser result would land on + // an anchor that isn't there. + readonly desktopOnly?: boolean; } /** @@ -145,6 +150,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "quit-confirmation", title: "Quit confirmation", to: "/settings/general", + desktopOnly: true, }, { id: "text-generation-model", @@ -229,5 +235,9 @@ export function searchSettings( const normalizedQuery = normalizeSearchText(query); if (normalizedQuery.length === 0) return []; - return items.filter((item) => normalizeSearchText(item.title).includes(normalizedQuery)); + return items.filter( + (item) => + (isElectron || item.desktopOnly !== true) && + normalizeSearchText(item.title).includes(normalizedQuery), + ); }