From 66b3a7e0b2d181142f23e53323f0138df3864c39 Mon Sep 17 00:00:00 2001 From: goujan <102008348+goujandev@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:56:43 +0100 Subject: [PATCH 1/5] close to system tray on Windows --- src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 4 +++ src-tauri/src/tray.rs | 53 +++++++++++++++++++++++++++++++++++ src/App.tsx | 9 ++++-- src/lib/settings.ts | 26 ++++++++++++++++- src/surfaces/SettingsView.tsx | 22 ++++++++++++++- 6 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/tray.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d023c040..49071f6a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,7 @@ tauri-plugin-process = "2" libc = "0.2" [target.'cfg(windows)'.dependencies] +tauri = { version = "2", features = ["tray-icon"] } portable-pty = { version = "0.9", path = "../vendor/portable-pty" } windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_LibraryLoader", "Win32_System_Diagnostics_ToolHelp"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b933d11..001583a9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,8 @@ mod reminders; mod search; mod session_store; mod skills; +#[cfg(target_os = "windows")] +mod tray; mod window; mod window_transfer; #[cfg(windows)] @@ -178,6 +180,8 @@ pub fn run() { reminders::init(app.handle()); checkpoint::init(app.handle())?; menu::install(app.handle())?; + #[cfg(target_os = "windows")] + tray::install(app.handle())?; #[cfg(target_os = "macos")] { macos::install_dock_menu(app.handle()); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 00000000..3bf591f0 --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,53 @@ +//! Tray icon: the way back to a window that closing hid. +//! +//! Windows only. Closing a window hides it so the harness children keep +//! running, and a hidden window drops off the taskbar, so without this the +//! windows would be unreachable. + +use tauri::menu::{MenuBuilder, MenuItemBuilder}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::AppHandle; + +const SHOW: &str = "tray_show"; +const QUIT: &str = "tray_quit"; + +pub fn install(app: &AppHandle) -> tauri::Result<()> { + let show = MenuItemBuilder::with_id(SHOW, "Show MonoCode").build(app)?; + let quit = MenuItemBuilder::with_id(QUIT, "Quit MonoCode").build(app)?; + let menu = MenuBuilder::new(app).items(&[&show, &quit]).build()?; + + let mut tray = TrayIconBuilder::with_id("main") + .tooltip("MonoCode") + .menu(&menu) + // Left click reopens; the menu stays on the right button. + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id().as_ref() { + SHOW => { + let _ = crate::window::show_hidden_or_open_new(app); + } + QUIT => { + // The quit-while-busy dialog is parented to a window, so + // asking from a hidden one would leave it unanswerable. + let _ = crate::window::show_hidden_or_open_new(app); + crate::window::request_quit(app); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let _ = crate::window::show_hidden_or_open_new(tray.app_handle()); + } + }); + + if let Some(icon) = app.default_window_icon().cloned() { + tray = tray.icon(icon); + } + + tray.build(app)?; + Ok(()) +} diff --git a/src/App.tsx b/src/App.tsx index cce60b6b..5af0dfdc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -347,6 +347,7 @@ import { import { linearIssueDetails, peekLinearIssueDetails } from "./lib/linear"; import { gitlabWorkItemDetails, peekGitlabWorkItemDetails } from "./lib/gitlab"; import { + loadCloseToTray, loadLiveAgentsEnabled, loadNotesEnabled, loadDiffViewer, @@ -391,9 +392,9 @@ import type { InstalledUpdate } from "./lib/updateNotice"; import { bindResumedSessions, closeBusyWindow, + closeCurrentWindow, hasInFlightSessions, hideCurrentWindow, - closeCurrentWindow, isAppQuitting, persistLiveTranscripts, persistQuitState, @@ -1110,12 +1111,14 @@ export default function App({ // Listening here makes close our job. Letting the default path run // calls JS `window.destroy`, which Tauri denies without a permission. event.preventDefault(); + const toTray = loadCloseToTray(); if (hasInFlightSessions(sessionsRef.current)) { flushHarnessEvents(); - if (!IS_MAC) { + if (!toTray && !IS_MAC) { void closeBusyWindow(); return; } + // Not `persistQuitState`: that marks the live turns interrupted. void persistLiveTranscripts(sessionsRef.current); void hideCurrentWindow(); return; @@ -1129,7 +1132,7 @@ export default function App({ "unload", projectTerminalsRef.current, ).finally(() => { - void closeCurrentWindow(); + void (toTray ? hideCurrentWindow() : closeCurrentWindow()); }); }) .then((fn) => { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index b72b4446..10c2f934 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -1,4 +1,4 @@ -import { ALT, IS_MAC, MOD, SHIFT } from "./platform"; +import { ALT, IS_MAC, IS_WIN, MOD, SHIFT } from "./platform"; const SECTION_KEY = "monocode.settingsSection"; @@ -222,6 +222,30 @@ export function subscribeLiveAgentsEnabled(onStoreChange: () => void) { window.removeEventListener(LIVE_AGENTS_ENABLED_CHANGE_EVENT, onStoreChange); } +const CLOSE_TO_TRAY_KEY = "monocode.closeToTray"; + +export const CLOSE_TO_TRAY_DEFAULT = true; + +export function loadCloseToTray(): boolean { + // Close to tray is Windows-only: nowhere else installs a tray icon. + if (!IS_WIN) return false; + try { + const raw = localStorage.getItem(CLOSE_TO_TRAY_KEY); + if (raw == null) return CLOSE_TO_TRAY_DEFAULT; + return raw === "1" || raw === "true"; + } catch { + return CLOSE_TO_TRAY_DEFAULT; + } +} + +export function saveCloseToTray(value: boolean) { + try { + localStorage.setItem(CLOSE_TO_TRAY_KEY, value ? "1" : "0"); + } catch { + // private mode / quota + } +} + const GRID_ARCADE_ENABLED_KEY = "monocode.gridArcadeEnabled"; export const GRID_ARCADE_ENABLED_DEFAULT = true; diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index c7a27c65..d30679b1 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -117,7 +117,7 @@ import { subscribeModels, } from "../lib/models"; import { prettyCwd, projectKey, projectName } from "../lib/paths"; -import { IS_MAC } from "../lib/platform"; +import { IS_MAC, IS_WIN } from "../lib/platform"; import { loadArchivedProjects, looksLikeProject, @@ -161,6 +161,7 @@ import { filterKeybindings, KEYBINDINGS, loadClaudeHooks, + loadCloseToTray, loadComposerRunner, loadDiffViewer, loadFollowUpBehavior, @@ -168,6 +169,7 @@ import { loadLiveAgentsEnabled, loadNotesEnabled, saveClaudeHooks, + saveCloseToTray, saveComposerRunner, saveDiffViewer, saveFollowUpBehavior, @@ -357,6 +359,7 @@ function GeneralPage({ const [notificationPermission, setNotificationPermission] = useState(cachedNotificationPermission); const [claudeHooks, setClaudeHooks] = useState(loadClaudeHooks); + const [closeToTray, setCloseToTray] = useState(loadCloseToTray); // The user may flip the switch in System Settings and come back: re-read // the OS state whenever the window regains focus while the toggle is on. @@ -437,6 +440,11 @@ function GeneralPage({ setClaudeHooks(next); }; + const onCloseToTray = (next: boolean) => { + saveCloseToTray(next); + setCloseToTray(next); + }; + return ( <> + {IS_WIN && ( + + + + )} Date: Mon, 14 Sep 2026 08:25:38 +0100 Subject: [PATCH 2/5] Coordinate quit across every window - Poll every window for running chats and ask once, in one window - Exit only after each window reports its workspace saved - Count a window that never answers as busy rather than idle - Cover the stage transitions with unit tests --- src-tauri/src/lib.rs | 5 +- src-tauri/src/tray.rs | 7 +- src-tauri/src/window.rs | 502 +++++++++++++++++++++++++++++++++-- src/lib/appLifecycle.test.ts | 82 +++++- src/lib/appLifecycle.ts | 117 +++++--- src/main.tsx | 28 +- 6 files changed, 681 insertions(+), 60 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 891e641a..8ebcff70 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -343,7 +343,9 @@ pub fn run() { open_new_window, window::hide_window, window::destroy_window, - window::confirm_quit, + window::quit_poll_reply, + window::quit_decision, + window::quit_ready, window::set_window_glass_enabled, window_transfer::stage_window_transfer, window_transfer::take_window_transfer, @@ -381,6 +383,7 @@ pub fn run() { event: tauri::WindowEvent::Destroyed, .. } => { + window::forget_quit_window(handle, &label); let other_window = handle.webview_windows().keys().any(|name| name != &label); if !other_window { reap_harness_children(handle); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 3bf591f0..1950c071 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -25,12 +25,7 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { SHOW => { let _ = crate::window::show_hidden_or_open_new(app); } - QUIT => { - // The quit-while-busy dialog is parented to a window, so - // asking from a hidden one would leave it unanswerable. - let _ = crate::window::show_hidden_or_open_new(app); - crate::window::request_quit(app); - } + QUIT => crate::window::request_quit(app), _ => {} }) .on_tray_icon_event(|tray, event| { diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 666dd90f..165df526 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -1,15 +1,61 @@ +use std::collections::HashSet; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use serde::Serialize; #[cfg(any(target_os = "macos", target_os = "windows"))] use tauri::window::Color; #[cfg(target_os = "windows")] use tauri::window::{Effect, EffectsBuilder}; -use tauri::{AppHandle, Emitter, Manager, WebviewWindow, WebviewWindowBuilder}; +use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewWindow, WebviewWindowBuilder}; static WINDOW_COUNTER: AtomicU32 = AtomicU32::new(1); static ALLOW_EXIT: AtomicBool = AtomicBool::new(false); -const QUIT_REQUESTED: &str = "quit_requested"; +const QUIT_POLL: &str = "quit_poll"; +const QUIT_CONFIRM: &str = "quit_confirm"; +const QUIT_COMMIT: &str = "quit_commit"; +const QUIT_ABORTED: &str = "quit_aborted"; + +/// A wedged webview must not strand the app. Missing the poll deadline is safe +/// because silence counts as busy, so it can be short. Confirming has no +/// timeout of its own: it is waiting on a person. +const POLL_TIMEOUT: Duration = Duration::from_secs(2); +const COMMIT_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone, Copy, PartialEq)] +enum Stage { + Polling, + Confirming, + Committing, +} + +/// One quit at a time, decided in one place. Quit events reach every window, so +/// letting each answer for itself let an idle window exit the app while a busy +/// one was still asking - killing agents nobody agreed to stop. +struct QuitRun { + id: u32, + stage: Stage, + /// Windows still owing a reply for the current stage. + pending: HashSet, + /// Windows that answered the poll, so their listeners are known to be live. + replied: HashSet, + in_flight: u32, + /// The window showing the dialog, so its close can abort the quit. + prompt: Option, +} + +static QUIT_RUN: Mutex> = Mutex::new(None); +static QUIT_COUNTER: AtomicU32 = AtomicU32::new(1); + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct QuitConfirm { + id: u32, + in_flight: u32, +} pub fn open_new_window(app: &AppHandle) -> Result<(), String> { let mut config = app @@ -120,28 +166,339 @@ pub fn allow_exit() -> bool { ALLOW_EXIT.load(Ordering::SeqCst) } -/// Ask the UI to persist in-flight chats, then call `confirm_quit`. +/// What the coordinator should do once a stage's bookkeeping is settled. +#[derive(Debug, PartialEq)] +enum Next { + Wait, + Confirm, + Exit, + Abort, +} + +/// A quit already under way owns the decision. Starting a second one would +/// orphan the open dialog: its answer arrives for a run that no longer exists. +fn begin_run(slot: &mut Option, id: u32, labels: Vec) -> bool { + if slot.is_some() { + return false; + } + *slot = Some(QuitRun { + id, + stage: Stage::Polling, + pending: labels.into_iter().collect(), + replied: HashSet::new(), + in_flight: 0, + prompt: None, + }); + true +} + +fn record_reply(slot: &mut Option, id: u32, label: &str, in_flight: u32) -> Next { + let Some(run) = slot.as_mut() else { + return Next::Wait; + }; + if run.id != id || run.stage != Stage::Polling { + return Next::Wait; + } + run.pending.remove(label); + run.replied.insert(label.to_string()); + run.in_flight += in_flight; + if run.pending.is_empty() { + Next::Confirm + } else { + Next::Wait + } +} + +/// Silence counts as busy. A window that never answered may well have a turn +/// running, and guessing idle is what killed agents in the first place. +/// +/// Closes once: a final reply and the poll timeout can land together, and +/// asking twice would send a second dialog that cancels the first. +fn close_poll(slot: &mut Option, id: u32) -> Option { + let run = slot + .as_mut() + .filter(|run| run.id == id && run.stage == Stage::Polling)?; + run.in_flight += run.pending.len() as u32; + run.pending.clear(); + run.stage = Stage::Confirming; + Some(run.in_flight) +} + +fn open_commit(slot: &mut Option, id: u32, labels: Vec) -> bool { + let Some(run) = slot.as_mut().filter(|run| run.id == id) else { + return false; + }; + run.stage = Stage::Committing; + run.pending = labels.into_iter().collect(); + true +} + +fn record_ready(slot: &mut Option, id: u32, label: &str) -> Next { + let Some(run) = slot.as_mut() else { + return Next::Wait; + }; + if run.id != id || run.stage != Stage::Committing { + return Next::Wait; + } + run.pending.remove(label); + if run.pending.is_empty() { + Next::Exit + } else { + Next::Wait + } +} + +fn drop_window(slot: &mut Option, label: &str) -> Next { + let Some(run) = slot.as_mut() else { + return Next::Wait; + }; + if run.stage == Stage::Confirming { + return if run.prompt.as_deref() == Some(label) { + Next::Abort + } else { + Next::Wait + }; + } + run.pending.remove(label); + if !run.pending.is_empty() { + return Next::Wait; + } + if run.stage == Stage::Polling { + Next::Confirm + } else { + Next::Exit + } +} + +/// Ask every window what it has running, then decide once for all of them. pub fn request_quit(app: &AppHandle) { + let labels: Vec = app.webview_windows().keys().cloned().collect(); + if labels.is_empty() { + confirm_quit(app.clone()); + return; + } + let id = QUIT_COUNTER.fetch_add(1, Ordering::Relaxed); + if !begin_run(&mut QUIT_RUN.lock().unwrap(), id, labels) { + resurface_prompt(app); + return; + } + if app.emit(QUIT_POLL, id).is_err() { + clear_run(id); + confirm_quit(app.clone()); + return; + } + watch_stage(app, id, Stage::Polling, POLL_TIMEOUT); +} + +/// One window's live turn count, counted before anything is killed. +#[tauri::command] +pub fn quit_poll_reply(app: AppHandle, window: WebviewWindow, id: u32, in_flight: u32) { + let next = record_reply(&mut QUIT_RUN.lock().unwrap(), id, window.label(), in_flight); + if next == Next::Confirm { + start_confirm(&app, id); + } +} + +/// Bring a parked dialog back into view. The run cannot be restarted, and with +/// close-to-tray the user cannot close that window to clear it either. +fn resurface_prompt(app: &AppHandle) { + let label = { + let guard = QUIT_RUN.lock().unwrap(); + let Some(run) = guard.as_ref() else { return }; + if run.stage != Stage::Confirming { + return; + } + run.prompt.clone() + }; + let Some(window) = label.and_then(|label| app.get_webview_window(&label)) else { + return; + }; + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); +} + +/// The answer to the one dialog the whole app gets to show. +#[tauri::command] +pub fn quit_decision(app: AppHandle, window: WebviewWindow, id: u32, confirmed: bool) { + { + let guard = QUIT_RUN.lock().unwrap(); + let Some(run) = guard.as_ref() else { return }; + if run.id != id || run.stage != Stage::Confirming { + return; + } + // Only the window that was asked gets to answer for everyone. + if run.prompt.as_deref() != Some(window.label()) { + return; + } + } + if confirmed { + start_commit(&app, id); + } else { + clear_run(id); + } +} + +/// One window has persisted. The last one out turns the lights off, so a slow +/// window cannot lose its workspace to a faster window's exit. +#[tauri::command] +pub fn quit_ready(app: AppHandle, window: WebviewWindow, id: u32, persisted: bool) { + // A window that could not save its workspace keeps the app open, the way a + // failed persist did before the handshake existed. The windows that did + // save are staying too, so take them back out of quitting. + if !persisted { + clear_run(id); + let _ = app.emit(QUIT_ABORTED, ()); + return; + } + let next = record_ready(&mut QUIT_RUN.lock().unwrap(), id, window.label()); + if next == Next::Exit { + confirm_quit(app); + } +} + +/// A window that closes mid-quit must not be waited on forever. +pub fn forget_quit_window(app: &AppHandle, label: &str) { + let (id, next) = { + let mut guard = QUIT_RUN.lock().unwrap(); + let Some(id) = guard.as_ref().map(|run| run.id) else { + return; + }; + (id, drop_window(&mut guard, label)) + }; + match next { + Next::Confirm => start_confirm(app, id), + Next::Exit => confirm_quit(app.clone()), + Next::Abort => clear_run(id), + Next::Wait => {} + } +} + +fn start_confirm(app: &AppHandle, id: u32) { + let Some(in_flight) = close_poll(&mut QUIT_RUN.lock().unwrap(), id) else { + return; + }; + if in_flight == 0 { + start_commit(app, id); + return; + } + if app.webview_windows().is_empty() { + clear_run(id); + confirm_quit(app.clone()); + return; + } + // Tray Quit arrives with every window hidden, and a dialog parented to a + // hidden window cannot be answered. + let _ = show_hidden_or_open_new(app); + let replied = { + let guard = QUIT_RUN.lock().unwrap(); + guard + .as_ref() + .filter(|run| run.id == id) + .map(|run| run.replied.clone()) + .unwrap_or_default() + }; + let Some(label) = prompt_window(app, &replied) else { + clear_run(id); + confirm_quit(app.clone()); + return; + }; + if let Some(run) = QUIT_RUN.lock().unwrap().as_mut() { + if run.id == id { + run.prompt = Some(label.clone()); + } + } + let payload = QuitConfirm { id, in_flight }; + if app + .emit_to(EventTarget::webview_window(&label), QUIT_CONFIRM, payload) + .is_err() + { + clear_run(id); + } +} + +fn start_commit(app: &AppHandle, id: u32) { + let labels: Vec = app.webview_windows().keys().cloned().collect(); + let empty = labels.is_empty(); + if !open_commit(&mut QUIT_RUN.lock().unwrap(), id, labels) { + return; + } + if empty || app.emit(QUIT_COMMIT, id).is_err() { + confirm_quit(app.clone()); + return; + } + watch_stage(app, id, Stage::Committing, COMMIT_TIMEOUT); +} + +/// Prefer the window the user is looking at; any window beats none. +/// +/// Only among windows that answered the poll, though: replying proves their +/// listeners are live. A window still booting would swallow the dialog, and +/// confirming has nothing to time out on. +fn prompt_window(app: &AppHandle, replied: &HashSet) -> Option { let windows = app.webview_windows(); - let target = windows - .values() - .find(|window| window.is_focused().unwrap_or(false)) + let mut labels: Vec = windows.keys().cloned().collect(); + labels.sort(); + let answered: Vec = labels + .iter() + .filter(|label| replied.contains(*label)) .cloned() - .or_else(|| windows.get("main").cloned()) - .or_else(|| windows.values().next().cloned()); - match target { - Some(window) => { - if window.emit(QUIT_REQUESTED, ()).is_err() { - confirm_quit(app.clone()); - } + .collect(); + let pool = if answered.is_empty() { + &labels + } else { + &answered + }; + let focused = pool.iter().find(|label| { + windows + .get(*label) + .is_some_and(|window| window.is_focused().unwrap_or(false)) + }); + if let Some(label) = focused { + return Some(label.clone()); + } + let visible = pool.iter().find(|label| { + windows + .get(*label) + .is_some_and(|window| window.is_visible().unwrap_or(false)) + }); + if let Some(label) = visible { + return Some(label.clone()); + } + pool.first().cloned() +} + +/// Nothing waits forever: a webview that never answers still lets the app quit. +fn watch_stage(app: &AppHandle, id: u32, stage: Stage, wait: Duration) { + let app = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(wait); + let stalled = QUIT_RUN + .lock() + .unwrap() + .as_ref() + .is_some_and(|run| run.id == id && run.stage == stage); + if !stalled { + return; } - None => confirm_quit(app.clone()), + match stage { + Stage::Polling => start_confirm(&app, id), + Stage::Committing => confirm_quit(app), + Stage::Confirming => {} + } + }); +} + +fn clear_run(id: u32) { + let mut guard = QUIT_RUN.lock().unwrap(); + if guard.as_ref().is_some_and(|run| run.id == id) { + *guard = None; } } /// Persist already happened in JS. Show windows so window-state doesn't save hidden. -#[tauri::command] pub fn confirm_quit(app: AppHandle) { + *QUIT_RUN.lock().unwrap() = None; ALLOW_EXIT.store(true, Ordering::SeqCst); for window in app.webview_windows().values() { let _ = window.show(); @@ -157,3 +514,118 @@ pub fn confirm_quit(app: AppHandle) { } app.exit(0); } + +#[cfg(test)] +mod tests { + use super::*; + + fn polling(labels: &[&str]) -> Option { + let mut slot = None; + let owned = labels.iter().map(|label| label.to_string()).collect(); + assert!(begin_run(&mut slot, 1, owned)); + slot + } + + fn in_flight(slot: &Option) -> Option { + slot.as_ref().map(|run| run.in_flight) + } + + #[test] + fn one_window_answering_does_not_decide_for_the_others() { + let mut slot = polling(&["main", "window-2"]); + assert_eq!(record_reply(&mut slot, 1, "main", 0), Next::Wait); + assert_eq!(record_reply(&mut slot, 1, "window-2", 2), Next::Confirm); + assert_eq!(close_poll(&mut slot, 1), Some(2)); + } + + #[test] + fn a_window_that_never_answers_counts_as_busy() { + let mut slot = polling(&["main", "window-2"]); + record_reply(&mut slot, 1, "main", 0); + // The poll timed out with window-2 still owing an answer. + assert_eq!(close_poll(&mut slot, 1), Some(1)); + } + + #[test] + fn replies_from_a_stale_run_are_ignored() { + let mut slot = polling(&["main"]); + assert_eq!(record_reply(&mut slot, 99, "main", 5), Next::Wait); + assert_eq!(in_flight(&slot), Some(0)); + } + + #[test] + fn a_second_quit_while_polling_is_ignored() { + let mut slot = polling(&["main"]); + assert!(!begin_run(&mut slot, 2, vec!["main".to_string()])); + assert_eq!(slot.as_ref().map(|run| run.id), Some(1)); + } + + #[test] + fn a_second_quit_leaves_the_open_dialog_in_charge() { + let mut slot = polling(&["main"]); + record_reply(&mut slot, 1, "main", 1); + close_poll(&mut slot, 1); + assert!(!begin_run(&mut slot, 2, vec!["main".to_string()])); + assert_eq!(slot.as_ref().map(|run| run.id), Some(1)); + } + + #[test] + fn only_windows_that_answered_are_offered_the_dialog() { + let mut slot = polling(&["main", "window-2"]); + record_reply(&mut slot, 1, "window-2", 1); + // main never answered, so it is still booting or wedged: asking it + // would leave the dialog unshown and the quit with nothing to await. + let replied = slot.as_ref().map(|run| run.replied.clone()); + assert_eq!(replied, Some(HashSet::from(["window-2".to_string()]))); + } + + #[test] + fn a_final_reply_and_the_timeout_cannot_both_close_the_poll() { + let mut slot = polling(&["main"]); + record_reply(&mut slot, 1, "main", 1); + assert_eq!(close_poll(&mut slot, 1), Some(1)); + assert_eq!(close_poll(&mut slot, 1), None); + } + + #[test] + fn the_app_exits_only_once_every_window_has_persisted() { + let mut slot = polling(&["main", "window-2"]); + record_reply(&mut slot, 1, "main", 1); + record_reply(&mut slot, 1, "window-2", 0); + close_poll(&mut slot, 1); + let labels = vec!["main".to_string(), "window-2".to_string()]; + assert!(open_commit(&mut slot, 1, labels)); + assert_eq!(record_ready(&mut slot, 1, "main"), Next::Wait); + assert_eq!(record_ready(&mut slot, 1, "window-2"), Next::Exit); + } + + #[test] + fn a_window_closing_mid_poll_is_not_waited_on() { + let mut slot = polling(&["main", "window-2"]); + record_reply(&mut slot, 1, "main", 0); + assert_eq!(drop_window(&mut slot, "window-2"), Next::Confirm); + } + + #[test] + fn closing_the_window_holding_the_dialog_aborts_the_quit() { + let mut slot = polling(&["main"]); + record_reply(&mut slot, 1, "main", 1); + close_poll(&mut slot, 1); + if let Some(run) = slot.as_mut() { + run.prompt = Some("main".to_string()); + } + assert_eq!(drop_window(&mut slot, "main"), Next::Abort); + } + + #[test] + fn another_window_closing_leaves_the_dialog_alone() { + let mut slot = polling(&["main", "window-2"]); + record_reply(&mut slot, 1, "main", 1); + record_reply(&mut slot, 1, "window-2", 0); + close_poll(&mut slot, 1); + if let Some(run) = slot.as_mut() { + run.prompt = Some("main".to_string()); + } + assert_eq!(drop_window(&mut slot, "window-2"), Next::Wait); + } +} diff --git a/src/lib/appLifecycle.test.ts b/src/lib/appLifecycle.test.ts index 69cd0746..57951cee 100644 --- a/src/lib/appLifecycle.test.ts +++ b/src/lib/appLifecycle.test.ts @@ -4,7 +4,13 @@ import { ask } from "@tauri-apps/plugin-dialog"; import { forgetHarnessSession, killAllChildren } from "./harness"; import { newSession } from "./session"; import { newTab } from "./layout"; -import { closeBusyWindow, setQuitWorkspace } from "./appLifecycle"; +import { + askQuitConfirmation, + closeBusyWindow, + commitQuit, + reportQuitPoll, + setQuitWorkspace, +} from "./appLifecycle"; import { collectWorkspaceSnapshot, hydrateWorkspaceSnapshot, @@ -252,3 +258,77 @@ describe("closing a busy window", () => { } }); }); + +describe("coordinated quit", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ask).mockResolvedValue(true); + }); + + function busyWorkspace() { + const session = newSession("cursor", "C:/test"); + session.busy = true; + session.blocks = [{ id: "user", role: "user", text: "test" }]; + const tab = newTab(session.id); + return setQuitWorkspace( + () => [session], + () => [tab], + () => tab.id, + () => session.cwd, + () => [], + () => new Map(), + vi.fn(), + ); + } + + function invokedWith(command: string) { + return vi + .mocked(invoke) + .mock.calls.find(([name]) => name === command)?.[1]; + } + + it("reports this window's live turns to the coordinator", async () => { + const release = busyWorkspace(); + try { + await reportQuitPoll(7); + expect(invokedWith("quit_poll_reply")).toEqual({ id: 7, inFlight: 1 }); + } finally { + release(); + } + }); + + it("reports nothing from a window with no workspace yet", async () => { + await reportQuitPoll(2); + expect(invokedWith("quit_poll_reply")).toEqual({ id: 2, inFlight: 0 }); + }); + + it("passes a declined dialog back as a refusal", async () => { + vi.mocked(ask).mockResolvedValue(false); + await askQuitConfirmation(3, 2); + expect(invokedWith("quit_decision")).toEqual({ id: 3, confirmed: false }); + }); + + it("asks once using the count from every window", async () => { + await askQuitConfirmation(4, 5); + expect(ask).toHaveBeenCalledTimes(1); + expect(vi.mocked(ask).mock.calls[0]?.[0]).toContain("5"); + expect(invokedWith("quit_decision")).toEqual({ id: 4, confirmed: true }); + }); + + // The whole point of the handshake: no window exits on its own. + it("persists and reports ready without exiting the app", async () => { + const release = busyWorkspace(); + try { + await commitQuit(9); + expect(invokedWith("workspace_set_snapshot")).toBeDefined(); + expect(invokedWith("quit_ready")).toEqual({ id: 9, persisted: true }); + expect( + vi + .mocked(invoke) + .mock.calls.some(([command]) => command === "confirm_quit"), + ).toBe(false); + } finally { + release(); + } + }); +}); diff --git a/src/lib/appLifecycle.ts b/src/lib/appLifecycle.ts index cd5b6086..73ad59e4 100644 --- a/src/lib/appLifecycle.ts +++ b/src/lib/appLifecycle.ts @@ -99,46 +99,103 @@ export function setQuitWorkspace( }; } -export async function handleQuitRequested(): Promise { +/** + * Persist this window for a quit the coordinator has already confirmed. + * Exiting is `confirm_quit`'s job, once every window has reported ready. + */ +export async function handleQuitRequested(): Promise { if (liveWorkspace) { liveWorkspace.flush(); - await confirmQuitAndExit( - liveWorkspace.sessions(), - liveWorkspace.tabs(), - liveWorkspace.activeTabId(), - liveWorkspace.projectCwd(), - liveWorkspace.projectReturnMemory(), - liveWorkspace.projectTerminals(), - ); - return; + quitting = true; + try { + await persistQuitState( + liveWorkspace.sessions(), + liveWorkspace.tabs(), + liveWorkspace.activeTabId(), + liveWorkspace.projectCwd(), + liveWorkspace.projectReturnMemory(), + "quit", + liveWorkspace.projectTerminals(), + ); + return true; + } catch { + quitting = false; + return false; + } } const { resumed } = await loadBootWorkspace(); const pending = resumed ?? bootingResumed; - if (pending) { - quitting = true; + quitting = true; + if (!pending) return true; + try { + await persistBootingResume(pending); + return true; + } catch { + quitting = false; + return false; + } +} + +/** Each window counts its own live turns; Rust sums them into one decision. */ +export async function reportQuitPoll(id: number): Promise { + let inFlight = 0; + if (liveWorkspace) { + liveWorkspace.flush(); + inFlight = inFlightRefs( + liveWorkspace.sessions(), + liveWorkspace.tabs(), + ).length; + } + await invoke("quit_poll_reply", { id, inFlight }).catch(() => undefined); +} + +/** The one quit dialog, shown by whichever window the coordinator picked. */ +export async function askQuitConfirmation( + id: number, + inFlight: number, +): Promise { + let confirmed = false; + if (!quitDialogOpen) { + quitDialogOpen = true; try { - await persistBootingResume(pending); - await invoke("confirm_quit"); + confirmed = await ask(quitWhileBusyMessage(inFlight), { + title: "MonoCode", + kind: "warning", + okLabel: "Quit", + }); } catch { - quitting = false; + confirmed = false; + } finally { + quitDialogOpen = false; } - return; } - await invoke("confirm_quit"); + await invoke("quit_decision", { id, confirmed }).catch(() => undefined); +} + +export async function commitQuit(id: number): Promise { + const persisted = await handleQuitRequested(); + await invoke("quit_ready", { id, persisted }).catch(() => undefined); +} + +/** + * A quit that stopped part-way because another window could not save. This + * window is staying open, so it must go back to persisting on unload. + */ +export function abortQuit(): void { + quitting = false; } /** Confirm and stop this window's work without terminating other windows. */ export async function closeBusyWindow(): Promise { if (!liveWorkspace) return; liveWorkspace.flush(); - await confirmQuitAndExit( + await confirmAndCloseWindow( liveWorkspace.sessions(), liveWorkspace.tabs(), liveWorkspace.activeTabId(), liveWorkspace.projectCwd(), liveWorkspace.projectReturnMemory(), liveWorkspace.projectTerminals(), - true, ); } @@ -360,27 +417,23 @@ async function persistBootingResume(workspace: ResumedWorkspace): Promise ).catch(() => undefined); } -async function confirmQuitAndExit( +async function confirmAndCloseWindow( sessions: Session[], tabs: WorkspaceTab[], activeTabId: string, projectCwd: string, memory: ProjectReturnMemory, projectTerminals: ProjectTerminalDock[] = [], - closeWindow = false, ): Promise { if (quitDialogOpen) return; quitDialogOpen = true; try { const refs = inFlightRefs(sessions, tabs); if (refs.length > 0) { - const ok = await ask(closeWindow - ? "Close this window and stop its running chats? Other windows will stay open." - : quitWhileBusyMessage(refs.length), { - title: "MonoCode", - kind: "warning", - okLabel: closeWindow ? "Close window" : "Quit", - }); + const ok = await ask( + "Close this window and stop its running chats? Other windows will stay open.", + { title: "MonoCode", kind: "warning", okLabel: "Close window" }, + ); if (!ok) return; } quitting = true; @@ -394,12 +447,8 @@ async function confirmQuitAndExit( "quit", projectTerminals, ); - if (closeWindow) { - await reapWindowRuntime(sessions, tabs, projectTerminals, false); - await closeCurrentWindow(); - } else { - await invoke("confirm_quit"); - } + await reapWindowRuntime(sessions, tabs, projectTerminals, false); + await closeCurrentWindow(); } catch { quitting = false; } diff --git a/src/main.tsx b/src/main.tsx index 9330f2f8..a1363331 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,17 @@ import React, { useLayoutEffect } from "react"; import ReactDOM from "react-dom/client"; import { listen } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import App from "./App"; import { activateWindowAppearance, initAppearance } from "./lib/appearance"; import { initSounds } from "./lib/sounds"; -import { handleQuitRequested, loadBootWorkspace } from "./lib/appLifecycle"; +import { + abortQuit, + askQuitConfirmation, + commitQuit, + loadBootWorkspace, + reportQuitPoll, +} from "./lib/appLifecycle"; import { consumeInstalledUpdate } from "./lib/updateNotice"; import "./index.css"; @@ -34,8 +41,23 @@ function BootGate({ children }: { children: React.ReactNode }) { return children; } -void listen("quit_requested", () => { - void handleQuitRequested(); +void listen("quit_poll", (event) => { + void reportQuitPoll(event.payload); +}); +// Scoped to this window on purpose: a global `listen` is registered as `Any`, +// which Tauri matches for every event regardless of the emitter's target, so +// one dialog would become one per window. +void getCurrentWebviewWindow().listen<{ id: number; inFlight: number }>( + "quit_confirm", + (event) => { + void askQuitConfirmation(event.payload.id, event.payload.inFlight); + }, +); +void listen("quit_commit", (event) => { + void commitQuit(event.payload); +}); +void listen("quit_aborted", () => { + abortQuit(); }); void loadBootWorkspace().then( From 89c7b3ffe6baa9457550412ed16e238194386b2e Mon Sep 17 00:00:00 2001 From: goujan <102008348+goujandev@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:43:52 +0100 Subject: [PATCH 3/5] Recover a stale quit confirmation and count every running turn - Abandon a confirming run that never gets an answer, so Quit cannot wedge - Count every in-flight session in the poll, not only resumable ones --- src-tauri/src/window.rs | 12 +++++++++--- src/lib/appLifecycle.test.ts | 22 ++++++++++++++++++++++ src/lib/appLifecycle.ts | 8 ++++---- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 165df526..587a4f31 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -20,10 +20,14 @@ const QUIT_COMMIT: &str = "quit_commit"; const QUIT_ABORTED: &str = "quit_aborted"; /// A wedged webview must not strand the app. Missing the poll deadline is safe -/// because silence counts as busy, so it can be short. Confirming has no -/// timeout of its own: it is waiting on a person. +/// because silence counts as busy, so it can be short. const POLL_TIMEOUT: Duration = Duration::from_secs(2); const COMMIT_TIMEOUT: Duration = Duration::from_secs(10); +/// Confirming waits on a person, so this is a backstop rather than a deadline: +/// a dialog that never arrives, or an answer that never gets back, would +/// otherwise leave Quit dead for the life of the process. Expiring only abandons +/// the run, so a later Quit starts a fresh one. +const CONFIRM_TIMEOUT: Duration = Duration::from_secs(300); #[derive(Clone, Copy, PartialEq)] enum Stage { @@ -414,7 +418,9 @@ fn start_confirm(app: &AppHandle, id: u32) { .is_err() { clear_run(id); + return; } + watch_stage(app, id, Stage::Confirming, CONFIRM_TIMEOUT); } fn start_commit(app: &AppHandle, id: u32) { @@ -484,7 +490,7 @@ fn watch_stage(app: &AppHandle, id: u32, stage: Stage, wait: Duration) { match stage { Stage::Polling => start_confirm(&app, id), Stage::Committing => confirm_quit(app), - Stage::Confirming => {} + Stage::Confirming => clear_run(id), } }); } diff --git a/src/lib/appLifecycle.test.ts b/src/lib/appLifecycle.test.ts index 57951cee..9a669b55 100644 --- a/src/lib/appLifecycle.test.ts +++ b/src/lib/appLifecycle.test.ts @@ -297,6 +297,28 @@ describe("coordinated quit", () => { } }); + it("counts a running Inbox Ask, which cannot be resumed", async () => { + const session = newSession("cursor", "C:/test"); + session.busy = true; + session.inboxAsk = true; + const tab = newTab(session.id); + const release = setQuitWorkspace( + () => [session], + () => [tab], + () => tab.id, + () => session.cwd, + () => [], + () => new Map(), + vi.fn(), + ); + try { + await reportQuitPoll(1); + expect(invokedWith("quit_poll_reply")).toEqual({ id: 1, inFlight: 1 }); + } finally { + release(); + } + }); + it("reports nothing from a window with no workspace yet", async () => { await reportQuitPoll(2); expect(invokedWith("quit_poll_reply")).toEqual({ id: 2, inFlight: 0 }); diff --git a/src/lib/appLifecycle.ts b/src/lib/appLifecycle.ts index 73ad59e4..a7b18902 100644 --- a/src/lib/appLifecycle.ts +++ b/src/lib/appLifecycle.ts @@ -9,6 +9,7 @@ import { import { hasInFlightSessions, inFlightRefs, + isInFlightSession, markTurnInterrupted, quitWhileBusyMessage, wasTurnInterrupted, @@ -141,10 +142,9 @@ export async function reportQuitPoll(id: number): Promise { let inFlight = 0; if (liveWorkspace) { liveWorkspace.flush(); - inFlight = inFlightRefs( - liveWorkspace.sessions(), - liveWorkspace.tabs(), - ).length; + // Every running turn, not just the resumable ones `inFlightRefs` keeps: + // an Inbox Ask still counts as work nobody agreed to throw away. + inFlight = liveWorkspace.sessions().filter(isInFlightSession).length; } await invoke("quit_poll_reply", { id, inFlight }).catch(() => undefined); } From 4df4af60d3ef4f43da68a5b593f034f7ce3daea6 Mon Sep 17 00:00:00 2001 From: goujan <102008348+goujandev@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:49:32 +0100 Subject: [PATCH 4/5] Fail a coordinated quit when its writes fail - Let required writes reject on quit so the coordinator hears about it - Keep best-effort persistence for unload, where a reload follows --- src/lib/appLifecycle.test.ts | 16 ++++++++++++++++ src/lib/appLifecycle.ts | 30 +++++++++++++++++++----------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/lib/appLifecycle.test.ts b/src/lib/appLifecycle.test.ts index 9a669b55..80e3f3c2 100644 --- a/src/lib/appLifecycle.test.ts +++ b/src/lib/appLifecycle.test.ts @@ -337,6 +337,22 @@ describe("coordinated quit", () => { expect(invokedWith("quit_decision")).toEqual({ id: 4, confirmed: true }); }); + it("tells the coordinator when a required quit write fails", async () => { + const release = busyWorkspace(); + vi.mocked(invoke).mockImplementation((command) => + command === "workspace_set_snapshot" + ? Promise.reject(new Error("disk full")) + : Promise.resolve(undefined), + ); + try { + await commitQuit(6); + expect(invokedWith("quit_ready")).toEqual({ id: 6, persisted: false }); + } finally { + vi.mocked(invoke).mockResolvedValue(undefined); + release(); + } + }); + // The whole point of the handshake: no window exits on its own. it("persists and reports ready without exiting the app", async () => { const release = busyWorkspace(); diff --git a/src/lib/appLifecycle.ts b/src/lib/appLifecycle.ts index a7b18902..077bd2f5 100644 --- a/src/lib/appLifecycle.ts +++ b/src/lib/appLifecycle.ts @@ -365,29 +365,37 @@ export async function persistQuitState( ): Promise { const refs = inFlightRefs(sessions, tabs); const interrupted = new Set(refs.map((ref) => ref.sessionId)); + // A quit ends the process, so a swallowed write is work that never comes + // back: let it reject and let the caller call the quit off. An unload is a + // reload, where best effort is enough and failing loudly helps nobody. + const write = (pending: Promise): Promise => + mode === "quit" ? pending : pending.catch(() => null); + await Promise.all( sessions.map(async (session) => { if (!shouldPersistSession(session)) return; const payload = interrupted.has(session.id) ? markTurnInterrupted(session) : session; - await upsertSession(payload).catch(() => null); + await write(upsertSession(payload)); }), ); - await saveWorkspaceSnapshot( - collectWorkspaceSnapshot( - tabs, - sessions, - activeTabId, - projectCwd, - memory, - projectTerminals, + await write( + saveWorkspaceSnapshot( + collectWorkspaceSnapshot( + tabs, + sessions, + activeTabId, + projectCwd, + memory, + projectTerminals, + ), ), - ).catch(() => undefined); + ); // Vite/webview reload must not wipe a restored snapshot: those chats are idle // in this process until Continue runs. if (mode === "quit" || refs.length > 0) { - await replaceInFlightSessions(refs).catch(() => undefined); + await write(replaceInFlightSessions(refs)); } } From 0ea0488e8ee46e529449d7ad0ba4940467ff5c62 Mon Sep 17 00:00:00 2001 From: goujan <102008348+goujandev@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:07:19 +0100 Subject: [PATCH 5/5] Recover a stale quit confirmation and count every running turn - Abandon a confirming run that never gets an answer, so Quit cannot wedge - Count every in-flight session in the poll, not only resumable ones --- src-tauri/src/window.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 587a4f31..4e77bd9d 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -286,11 +286,10 @@ pub fn request_quit(app: &AppHandle) { resurface_prompt(app); return; } - if app.emit(QUIT_POLL, id).is_err() { - clear_run(id); - confirm_quit(app.clone()); - return; - } + // One webview tearing down fails the whole emit, and exiting on that would + // kill every other window's work without asking. Let the poll time out + // instead: silence counts as busy, so the user is still asked. + let _ = app.emit(QUIT_POLL, id); watch_stage(app, id, Stage::Polling, POLL_TIMEOUT); } @@ -429,10 +428,13 @@ fn start_commit(app: &AppHandle, id: u32) { if !open_commit(&mut QUIT_RUN.lock().unwrap(), id, labels) { return; } - if empty || app.emit(QUIT_COMMIT, id).is_err() { + if empty { confirm_quit(app.clone()); return; } + // Same here: the windows that did receive it still deserve their save, so + // the commit timeout is the backstop rather than exiting on the spot. + let _ = app.emit(QUIT_COMMIT, id); watch_stage(app, id, Stage::Committing, COMMIT_TIMEOUT); }