diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 15f96bb94..4bd30f0a5 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -29,6 +29,7 @@ import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter } from "@/components/command-center";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DownloadToast } from "@/components/download-toast";
+import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar";
import { ProjectPickerModal } from "@/components/project-picker-modal";
@@ -479,6 +480,7 @@ function AppContainer({
/>
+
);
diff --git a/packages/app/src/components/quitting-overlay.tsx b/packages/app/src/components/quitting-overlay.tsx
new file mode 100644
index 000000000..8151391cb
--- /dev/null
+++ b/packages/app/src/components/quitting-overlay.tsx
@@ -0,0 +1,67 @@
+import { useEffect, useState } from "react";
+import { Text, View } from "react-native";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { LoadingSpinner } from "@/components/ui/loading-spinner";
+import { getIsElectronRuntime } from "@/constants/layout";
+import { listenToDesktopEvent } from "@/desktop/electron/events";
+
+export function QuittingOverlay() {
+ const { theme } = useUnistyles();
+ const [quitting, setQuitting] = useState(false);
+
+ useEffect(() => {
+ if (!getIsElectronRuntime()) return;
+
+ let cancelled = false;
+ let unlisten: (() => void) | null = null;
+
+ void listenToDesktopEvent("quitting", () => {
+ if (!cancelled) setQuitting(true);
+ }).then((fn) => {
+ if (cancelled) {
+ fn();
+ } else {
+ unlisten = fn;
+ }
+ return undefined;
+ });
+
+ return () => {
+ cancelled = true;
+ unlisten?.();
+ };
+ }, []);
+
+ if (!quitting) return null;
+
+ return (
+
+
+ Quitting Paseo…
+ Stopping the local daemon.
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ overlay: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: theme.colors.surface1,
+ alignItems: "center",
+ justifyContent: "center",
+ gap: theme.spacing[3],
+ zIndex: 9999,
+ },
+ title: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.base,
+ },
+ detail: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ },
+}));
diff --git a/packages/desktop/src/daemon/quit-lifecycle.test.ts b/packages/desktop/src/daemon/quit-lifecycle.test.ts
index a0d856aa3..d9af82b65 100644
--- a/packages/desktop/src/daemon/quit-lifecycle.test.ts
+++ b/packages/desktop/src/daemon/quit-lifecycle.test.ts
@@ -75,9 +75,9 @@ describe("quit-lifecycle", () => {
);
});
- it("gates quit until the stop decision completes, then lets the next quit pass through", async () => {
+ it("preventDefaults the first quit, runs the async stop decision, then exits hard", async () => {
let resolveStopDecision: (() => void) | null = null;
- const app = { quit: vi.fn() };
+ const app = { exit: vi.fn() };
const closeTransportSessions = vi.fn();
const onStopError = vi.fn();
const preventDefault = vi.fn();
@@ -99,20 +99,20 @@ describe("quit-lifecycle", () => {
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(closeTransportSessions).toHaveBeenCalledTimes(1);
- expect(app.quit).not.toHaveBeenCalled();
+ expect(app.exit).not.toHaveBeenCalled();
expect(resolveStopDecision).not.toBeNull();
resolveStopDecision?.();
await Promise.resolve();
await Promise.resolve();
- expect(app.quit).toHaveBeenCalledTimes(1);
+ expect(app.exit).toHaveBeenCalledWith(0);
expect(onStopError).not.toHaveBeenCalled();
handleBeforeQuit({ preventDefault: secondPreventDefault });
expect(secondPreventDefault).not.toHaveBeenCalled();
expect(closeTransportSessions).toHaveBeenCalledTimes(2);
- expect(app.quit).toHaveBeenCalledTimes(1);
+ expect(app.exit).toHaveBeenCalledTimes(1);
});
});
diff --git a/packages/desktop/src/daemon/quit-lifecycle.ts b/packages/desktop/src/daemon/quit-lifecycle.ts
index f2abdbfc8..247ca2b3f 100644
--- a/packages/desktop/src/daemon/quit-lifecycle.ts
+++ b/packages/desktop/src/daemon/quit-lifecycle.ts
@@ -11,7 +11,7 @@ interface BeforeQuitEvent {
}
interface BeforeQuitApp {
- quit(): void;
+ exit(code: number): void;
}
export interface StopOnQuitDeps {
@@ -53,30 +53,25 @@ export function createBeforeQuitHandler({
stopDesktopManagedDaemonIfNeeded: () => Promise;
onStopError: (error: unknown) => void;
}): (event: BeforeQuitEvent) => void {
- let allowingQuitToContinue = false;
- let quittingInProgress = false;
+ // We always preventDefault on first quit so we can run the async stop
+ // decision, then call app.exit(0) — which bypasses Electron's
+ // close → window-all-closed → will-quit chain. The window-all-closed
+ // listener is a darwin no-op (macOS convention) and would otherwise
+ // veto a re-fired app.quit().
+ let quitting = false;
return (event) => {
closeTransportSessions();
-
- if (allowingQuitToContinue) {
- return;
- }
-
+ if (quitting) return;
+ quitting = true;
event.preventDefault();
- if (quittingInProgress) {
- return;
- }
- quittingInProgress = true;
void stopDesktopManagedDaemonIfNeeded()
.catch((error) => {
onStopError(error);
})
.finally(() => {
- allowingQuitToContinue = true;
- quittingInProgress = false;
- app.quit();
+ app.exit(0);
});
};
}
diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts
index c586e6211..8b641829c 100644
--- a/packages/desktop/src/main.ts
+++ b/packages/desktop/src/main.ts
@@ -9,7 +9,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
-import { app, BrowserWindow, dialog, ipcMain, nativeImage, net, protocol } from "electron";
+import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
import {
parseCliPassthroughArgsFromArgv,
@@ -218,7 +218,6 @@ async function createMainWindow(): Promise {
const { loadReactDevTools } = await import("./features/react-devtools.js");
await loadReactDevTools();
await mainWindow.loadURL(DEV_SERVER_URL);
- mainWindow.webContents.openDevTools({ mode: "detach" });
return;
}
@@ -402,13 +401,9 @@ void bootstrap().catch((error) => {
});
function showDaemonShutdownDialog(): void {
- void dialog.showMessageBox({
- type: "info",
- message: "Shutting down Paseo daemon…",
- detail: "Paseo will quit once the local daemon has stopped.",
- buttons: [],
- noLink: true,
- });
+ for (const win of BrowserWindow.getAllWindows()) {
+ win.webContents.send("paseo:event:quitting", {});
+ }
}
app.on(