diff --git a/bun.lock b/bun.lock index 562665f6..a056ccba 100644 --- a/bun.lock +++ b/bun.lock @@ -94,7 +94,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.9.4", + "version": "1.9.5", "devDependencies": { "@tauri-apps/cli": "^2", "@types/node": "^24.3.1", @@ -103,7 +103,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.9.4", + "version": "1.9.5", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -208,7 +208,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.9.4", + "version": "1.9.5", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.4.3", @@ -231,7 +231,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.9.4", + "version": "1.9.5", "bin": { "openchamber": "./bin/cli.js", }, @@ -262,11 +262,13 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "cron-parser": "^4.9.0", "express": "^5.1.0", "ghostty-web": "0.3.0", "http-proxy-middleware": "^3.0.5", "jose": "^6.1.3", "jsonc-parser": "^3.3.1", + "luxon": "^3.5.0", "next-themes": "^0.4.6", "node-pty": "1.2.0-beta.12", "openai": "^4.79.0", @@ -1433,6 +1435,8 @@ "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], + "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1981,6 +1985,8 @@ "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 5e5e4dbf..601b1af5 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -90,6 +90,28 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -128,6 +150,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -157,6 +190,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + [[package]] name = "async-process" version = "2.5.0" @@ -886,6 +930,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -924,6 +977,12 @@ dependencies = [ "tendril 0.5.0", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -2633,6 +2692,7 @@ dependencies = [ "objc2", "objc2-web-kit", "reqwest 0.12.28", + "rfd 0.15.4", "serde", "serde_json", "tauri", @@ -3020,6 +3080,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "potential_utf" version = "0.1.4" @@ -3166,6 +3232,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -3528,6 +3603,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rfd" version = "0.16.0" @@ -3795,6 +3894,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4553,7 +4658,7 @@ checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" dependencies = [ "log", "raw-window-handle", - "rfd", + "rfd 0.16.0", "serde", "serde_json", "tauri", @@ -5283,6 +5388,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -5514,6 +5625,66 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.2", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.92" @@ -6484,6 +6655,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 0.7.15", "zvariant_derive", "zvariant_utils", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 8ebfe517..13481f63 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -39,3 +39,4 @@ strip = true [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" objc2-web-kit = "0.3" +rfd = "0.15" diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 32adb70f..5c22de11 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -17,7 +17,7 @@ use std::{ net::TcpListener, process::Command, sync::{ - atomic::{AtomicU64, Ordering}, + atomic::{AtomicU32, AtomicU64, Ordering}, Mutex, }, time::Duration, @@ -152,6 +152,8 @@ const MENU_ITEM_REQUEST_FEATURE_ID: &str = "menu_request_feature"; const MENU_ITEM_JOIN_DISCORD_ID: &str = "menu_join_discord"; #[cfg(target_os = "macos")] const MENU_ITEM_CLEAR_CACHE_ID: &str = "menu_clear_cache"; +#[cfg(target_os = "macos")] +const MENU_ITEM_QUIT_ID: &str = "menu_quit"; #[cfg(target_os = "macos")] const GITHUB_BUG_REPORT_URL: &str = @@ -162,6 +164,198 @@ const GITHUB_FEATURE_REQUEST_URL: &str = #[cfg(target_os = "macos")] const DISCORD_INVITE_URL: &str = "https://discord.gg/ZYRSdnwwKA"; +static QUIT_CONFIRMED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +static QUIT_CONFIRMATION_PENDING: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); +static QUIT_RISK_HAS_ENABLED_SCHEDULED_TASKS: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); +static QUIT_RISK_HAS_RUNNING_SCHEDULED_TASKS: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); +static QUIT_RISK_ENABLED_SCHEDULED_TASKS_COUNT: AtomicU32 = AtomicU32::new(0); +static QUIT_RISK_RUNNING_SCHEDULED_TASKS_COUNT: AtomicU32 = AtomicU32::new(0); +static QUIT_RISK_HAS_ACTIVE_TUNNEL: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); +static QUIT_RISK_POLLER_STARTED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[cfg(target_os = "macos")] +const QUIT_RISK_POLL_INTERVAL: Duration = Duration::from_secs(5); + +#[cfg(target_os = "macos")] +fn should_require_quit_confirmation() -> bool { + use std::sync::atomic::Ordering; + + QUIT_RISK_HAS_ACTIVE_TUNNEL.load(Ordering::Relaxed) + || QUIT_RISK_HAS_RUNNING_SCHEDULED_TASKS.load(Ordering::Relaxed) + || QUIT_RISK_HAS_ENABLED_SCHEDULED_TASKS.load(Ordering::Relaxed) +} + +#[cfg(target_os = "macos")] +fn quit_confirmation_message() -> String { + use std::sync::atomic::Ordering; + + let has_active_tunnel = QUIT_RISK_HAS_ACTIVE_TUNNEL.load(Ordering::Relaxed); + let running_tasks_count = QUIT_RISK_RUNNING_SCHEDULED_TASKS_COUNT.load(Ordering::Relaxed); + let enabled_tasks_count = QUIT_RISK_ENABLED_SCHEDULED_TASKS_COUNT.load(Ordering::Relaxed); + + let mut reasons: Vec = Vec::new(); + if has_active_tunnel { + reasons.push("an active tunnel".to_string()); + } + if running_tasks_count > 0 { + reasons.push(format!( + "{} running scheduled task{}", + running_tasks_count, + if running_tasks_count == 1 { "" } else { "s" } + )); + } + if enabled_tasks_count > 0 { + reasons.push(format!( + "{} enabled scheduled task{}", + enabled_tasks_count, + if enabled_tasks_count == 1 { "" } else { "s" } + )); + } + + if reasons.is_empty() { + "Background processes (sidecar, SSH sessions) will be stopped.".to_string() + } else { + format!( + "OpenChamber detected {}. Quitting now will stop sidecar/background processes and may interrupt pending work.", + reasons.join(", ") + ) + } +} + +#[cfg(target_os = "macos")] +const NS_TERMINATE_CANCEL: isize = 0; +#[cfg(target_os = "macos")] +const NS_TERMINATE_NOW: isize = 1; + +#[cfg(target_os = "macos")] +unsafe extern "C-unwind" fn application_should_terminate_with_confirmation( + _: &objc2::runtime::AnyObject, + _: objc2::runtime::Sel, + _: *mut std::ffi::c_void, +) -> isize { + use std::sync::atomic::Ordering; + + if QUIT_CONFIRMED.load(Ordering::SeqCst) { + return NS_TERMINATE_NOW; + } + + if !should_require_quit_confirmation() { + QUIT_CONFIRMED.store(true, Ordering::SeqCst); + return NS_TERMINATE_NOW; + } + + if QUIT_CONFIRMATION_PENDING.swap(true, Ordering::SeqCst) { + return NS_TERMINATE_CANCEL; + } + + let message = quit_confirmation_message(); + let confirmed = matches!( + rfd::MessageDialog::new() + .set_title("Quit OpenChamber?") + .set_description(&message) + .set_level(rfd::MessageLevel::Warning) + .set_buttons(rfd::MessageButtons::OkCancel) + .show(), + rfd::MessageDialogResult::Ok | rfd::MessageDialogResult::Yes + ); + + QUIT_CONFIRMATION_PENDING.store(false, Ordering::SeqCst); + + if confirmed { + QUIT_CONFIRMED.store(true, Ordering::SeqCst); + NS_TERMINATE_NOW + } else { + NS_TERMINATE_CANCEL + } +} + +#[cfg(target_os = "macos")] +fn install_macos_quit_confirmation_hook() { + use objc2::ffi; + use objc2::runtime::{AnyClass, AnyObject, Imp, Sel}; + use std::ffi::CStr; + + unsafe { + let Some(delegate_class) = AnyClass::get(CStr::from_bytes_with_nul_unchecked( + b"TaoAppDelegateParent\0", + )) else { + log::warn!("[desktop] TaoAppDelegateParent class not found; dock Quit confirmation hook skipped"); + return; + }; + + let selector = Sel::register(c"applicationShouldTerminate:"); + if !ffi::class_getInstanceMethod(delegate_class, selector).is_null() { + return; + } + + let imp: Imp = std::mem::transmute( + application_should_terminate_with_confirmation + as unsafe extern "C-unwind" fn(&AnyObject, Sel, *mut std::ffi::c_void) -> isize, + ); + + let added = ffi::class_addMethod( + delegate_class as *const _ as *mut _, + selector, + imp, + b"q@:@\0".as_ptr().cast(), + ); + + if !added.as_bool() { + log::warn!("[desktop] failed to install applicationShouldTerminate hook"); + } + } +} + +#[cfg(not(target_os = "macos"))] +fn install_macos_quit_confirmation_hook() {} + +#[cfg(target_os = "macos")] +fn request_quit_with_confirmation(app: &tauri::AppHandle) { + use std::sync::atomic::Ordering; + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; + + if !should_require_quit_confirmation() { + QUIT_CONFIRMED.store(true, Ordering::SeqCst); + app.exit(0); + return; + } + + if QUIT_CONFIRMATION_PENDING.swap(true, Ordering::SeqCst) { + return; + } + + // When app has only hidden windows (common after closing last window), + // ensure at least one window is visible so native dialog reliably appears. + let windows = app.webview_windows(); + let has_visible = windows.values().any(|w| w.is_visible().unwrap_or(false)); + if !has_visible { + if let Some(hidden) = windows.values().find(|w| !w.is_visible().unwrap_or(true)) { + let _ = hidden.show(); + let _ = hidden.set_focus(); + } + } + + let message = quit_confirmation_message(); + let handle = app.clone(); + app.dialog() + .message(message) + .title("Quit OpenChamber?") + .buttons(MessageDialogButtons::OkCancel) + .kind(tauri_plugin_dialog::MessageDialogKind::Warning) + .show(move |confirmed| { + QUIT_CONFIRMATION_PENDING.store(false, Ordering::SeqCst); + if confirmed { + QUIT_CONFIRMED.store(true, Ordering::SeqCst); + handle.exit(0); + } + }); +} + #[cfg(target_os = "macos")] fn build_macos_menu( app: &tauri::AppHandle, @@ -394,7 +588,13 @@ fn build_macos_menu( &PredefinedMenuItem::hide(app, None)?, &PredefinedMenuItem::hide_others(app, None)?, &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::quit(app, None)?, + &MenuItem::with_id( + app, + MENU_ITEM_QUIT_ID, + format!("Quit {}", pkg_info.name), + true, + Some("Cmd+Q"), + )?, ], )?, &Submenu::with_items( @@ -1913,6 +2113,93 @@ async fn wait_for_local_opencode_ready_with( last_probe } +#[cfg(target_os = "macos")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScheduledTasksQuitRiskResponse { + has_enabled_scheduled_tasks: bool, + has_running_scheduled_tasks: bool, + #[serde(default)] + enabled_scheduled_tasks_count: u32, + #[serde(default)] + running_scheduled_tasks_count: u32, +} + +#[cfg(target_os = "macos")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TunnelStatusResponse { + active: bool, +} + +#[cfg(target_os = "macos")] +async fn refresh_quit_risk_flags(local_base_url: &str) { + use std::sync::atomic::Ordering; + + let trimmed = local_base_url.trim_end_matches('/'); + if trimmed.is_empty() { + return; + } + + let client = match reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(2)) + .build() + { + Ok(client) => client, + Err(_) => return, + }; + + let scheduled_url = format!("{trimmed}/api/openchamber/scheduled-tasks/status"); + let tunnel_url = format!("{trimmed}/api/openchamber/tunnel/status"); + + let scheduled_future = client.get(scheduled_url).send(); + let tunnel_future = client.get(tunnel_url).send(); + let (scheduled_result, tunnel_result) = tokio::join!(scheduled_future, tunnel_future); + + if let Ok(response) = scheduled_result { + if response.status().is_success() { + if let Ok(payload) = response.json::().await { + let enabled_count = payload.enabled_scheduled_tasks_count; + let running_count = payload.running_scheduled_tasks_count; + QUIT_RISK_ENABLED_SCHEDULED_TASKS_COUNT.store(enabled_count, Ordering::Relaxed); + QUIT_RISK_RUNNING_SCHEDULED_TASKS_COUNT.store(running_count, Ordering::Relaxed); + QUIT_RISK_HAS_ENABLED_SCHEDULED_TASKS + .store(payload.has_enabled_scheduled_tasks || enabled_count > 0, Ordering::Relaxed); + QUIT_RISK_HAS_RUNNING_SCHEDULED_TASKS + .store(payload.has_running_scheduled_tasks || running_count > 0, Ordering::Relaxed); + } + } + } + + if let Ok(response) = tunnel_result { + if response.status().is_success() { + if let Ok(payload) = response.json::().await { + QUIT_RISK_HAS_ACTIVE_TUNNEL.store(payload.active, Ordering::Relaxed); + } + } + } +} + +#[cfg(target_os = "macos")] +fn start_quit_risk_poller(local_base_url: String) { + use std::sync::atomic::Ordering; + + if QUIT_RISK_POLLER_STARTED.swap(true, Ordering::SeqCst) { + return; + } + + tauri::async_runtime::spawn(async move { + loop { + refresh_quit_risk_flags(&local_base_url).await; + tokio::time::sleep(QUIT_RISK_POLL_INTERVAL).await; + } + }); +} + +#[cfg(not(target_os = "macos"))] +fn start_quit_risk_poller(_local_base_url: String) {} + /// Uses the same probe_with_retry policy as startup/new-window (soft + hard) /// so that first-launch/recovery remote connect accepts slow-but-valid hosts. #[tauri::command] @@ -3611,6 +3898,10 @@ fn main() { }); return; } + if id == MENU_ITEM_QUIT_ID { + request_quit_with_confirmation(app); + return; + } } }) .on_window_event(|window, event| { @@ -3624,12 +3915,10 @@ fn main() { } if let tauri::WindowEvent::Destroyed = event { - // Clean up focus tracking for the destroyed window. if let Some(state) = app.try_state::() { state.remove_window(&label); } - // Remove stale per-window init script. if let Some(state) = app.try_state::() { state .scripts @@ -3637,24 +3926,25 @@ fn main() { .expect("desktop ui injection mutex") .remove(&label); } - - // If this was the last window, kill the sidecar and exit. - let remaining = app.webview_windows().len(); - if remaining == 0 { - if let Some(state) = app.try_state::() { - state.shutdown_all(&app); - } - kill_sidecar(app.clone()); - app.exit(0); - } } if matches!(event, tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_)) { schedule_window_state_persist(window.clone(), false); } - if let tauri::WindowEvent::CloseRequested { .. } = event { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { schedule_window_state_persist(window.clone(), true); + + let remaining_visible = app + .webview_windows() + .values() + .filter(|w| w.is_visible().unwrap_or(false)) + .count(); + + if remaining_visible <= 1 { + api.prevent_close(); + let _ = window.hide(); + } } }) .invoke_handler(tauri::generate_handler![ @@ -3754,6 +4044,7 @@ fn main() { if let Some(state) = handle.try_state::() { *state.url.lock().expect("sidecar url mutex") = Some(local_url.clone()); } + start_quit_risk_poller(local_url.clone()); let local_origin = url::Url::parse(&local_ui_url) .ok() @@ -3855,10 +4146,18 @@ fn main() { .build(tauri::generate_context!()) .expect("failed to build Tauri application"); + install_macos_quit_confirmation_hook(); + app.run(|app_handle, event| { match event { - tauri::RunEvent::ExitRequested { .. } => { - // Best-effort cleanup; never block shutdown. + tauri::RunEvent::ExitRequested { api, .. } => { + use std::sync::atomic::Ordering; + if !QUIT_CONFIRMED.load(Ordering::SeqCst) { + api.prevent_exit(); + #[cfg(target_os = "macos")] + request_quit_with_confirmation(app_handle); + return; + } if let Some(state) = app_handle.try_state::() { state.shutdown_all(app_handle); } @@ -3875,9 +4174,18 @@ fn main() { has_visible_windows, .. } => { - // macOS: clicking dock icon when no windows are open opens a new one. if !has_visible_windows { - open_new_window(app_handle); + let windows = app_handle.webview_windows(); + let hidden = windows + .values() + .find(|w| !w.is_visible().unwrap_or(true)); + if let Some(w) = hidden { + let _ = w.show(); + let _ = w.set_focus(); + } else { + drop(windows); + open_new_window(app_handle); + } } } _ => {} diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index b2413a95..4927b87c 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -19,6 +19,8 @@ import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multi import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect'; import { BranchSelector, useBranchOptions } from './BranchSelector'; import { AgentSelector } from './AgentSelector'; +import { CommandAutocomplete, type CommandAutocompleteHandle } from '@/components/chat/CommandAutocomplete'; +import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete'; import { isDesktopShell, startDesktopWindowDrag } from '@/lib/desktop'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; @@ -99,7 +101,14 @@ export const MultiRunLauncher: React.FC = ({ const [setupCommands, setSetupCommands] = React.useState([]); const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false); const [isLoadingSetupCommands, setIsLoadingSetupCommands] = React.useState(false); + const [showFileMention, setShowFileMention] = React.useState(false); + const [mentionQuery, setMentionQuery] = React.useState(''); + const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false); + const [commandQuery, setCommandQuery] = React.useState(''); const fileInputRef = React.useRef(null); + const promptTextareaRef = React.useRef(null); + const mentionRef = React.useRef(null); + const commandRef = React.useRef(null); const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); const homeDirectory = useDirectoryStore((state) => state.homeDirectory ?? null); @@ -389,6 +398,129 @@ export const MultiRunLauncher: React.FC = ({ setAttachedFiles((prev) => prev.filter((f) => f.id !== id)); }; + const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => { + if (value.startsWith('/')) { + const firstSpace = value.indexOf(' '); + const firstNewline = value.indexOf('\n'); + const commandEnd = Math.min( + firstSpace === -1 ? value.length : firstSpace, + firstNewline === -1 ? value.length : firstNewline, + ); + + if (cursorPosition <= commandEnd && firstSpace === -1) { + setCommandQuery(value.substring(1, commandEnd)); + setShowCommandAutocomplete(true); + setShowFileMention(false); + return; + } + } + + setShowCommandAutocomplete(false); + + const textBeforeCursor = value.substring(0, cursorPosition); + const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); + if (lastAtSymbol !== -1) { + const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null; + const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1); + const isWordBoundary = !charBefore || /\s/.test(charBefore); + if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { + setMentionQuery(textAfterAt); + setShowFileMention(true); + } else { + setShowFileMention(false); + } + return; + } + + setShowFileMention(false); + }, []); + + const handlePromptKeyDown = React.useCallback((event: React.KeyboardEvent) => { + if (showCommandAutocomplete && commandRef.current) { + if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') { + event.preventDefault(); + commandRef.current.handleKeyDown(event.key); + return; + } + } + + if (showFileMention && mentionRef.current) { + if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') { + event.preventDefault(); + mentionRef.current.handleKeyDown(event.key); + } + } + }, [showCommandAutocomplete, showFileMention]); + + const handleAutocompleteFileSelect = React.useCallback((file: { name: string; path: string; relativePath?: string }) => { + const textarea = promptTextareaRef.current; + const cursorPosition = textarea?.selectionStart ?? prompt.length; + const textBeforeCursor = prompt.substring(0, cursorPosition); + const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); + const mentionPath = (file.relativePath && file.relativePath.trim().length > 0) + ? file.relativePath.trim() + : (file.path || file.name); + + const startIndex = lastAtSymbol !== -1 ? lastAtSymbol : cursorPosition; + const nextPrompt = `${prompt.substring(0, startIndex)}@${mentionPath} ${prompt.substring(cursorPosition)}`; + const nextCursor = startIndex + mentionPath.length + 2; + + setPrompt(nextPrompt); + setShowFileMention(false); + setMentionQuery(''); + + requestAnimationFrame(() => { + const currentTextarea = promptTextareaRef.current; + if (currentTextarea) { + currentTextarea.selectionStart = nextCursor; + currentTextarea.selectionEnd = nextCursor; + currentTextarea.focus(); + } + updateAutocompleteState(nextPrompt, nextCursor); + }); + }, [prompt, updateAutocompleteState]); + + const handleAutocompleteAgentSelect = React.useCallback((agentName: string) => { + const textarea = promptTextareaRef.current; + const cursorPosition = textarea?.selectionStart ?? prompt.length; + const textBeforeCursor = prompt.substring(0, cursorPosition); + const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); + const startIndex = lastAtSymbol !== -1 ? lastAtSymbol : cursorPosition; + const nextPrompt = `${prompt.substring(0, startIndex)}@${agentName} ${prompt.substring(cursorPosition)}`; + const nextCursor = startIndex + agentName.length + 2; + + setPrompt(nextPrompt); + setShowFileMention(false); + setMentionQuery(''); + + requestAnimationFrame(() => { + const currentTextarea = promptTextareaRef.current; + if (currentTextarea) { + currentTextarea.selectionStart = nextCursor; + currentTextarea.selectionEnd = nextCursor; + currentTextarea.focus(); + } + updateAutocompleteState(nextPrompt, nextCursor); + }); + }, [prompt, updateAutocompleteState]); + + const handleAutocompleteCommandSelect = React.useCallback((command: { name: string }) => { + const nextPrompt = `/${command.name} `; + setPrompt(nextPrompt); + setShowCommandAutocomplete(false); + setCommandQuery(''); + + requestAnimationFrame(() => { + const currentTextarea = promptTextareaRef.current; + if (currentTextarea) { + currentTextarea.focus(); + currentTextarea.selectionStart = currentTextarea.value.length; + currentTextarea.selectionEnd = currentTextarea.value.length; + } + updateAutocompleteState(nextPrompt, nextPrompt.length); + }); + }, [updateAutocompleteState]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -645,14 +777,56 @@ export const MultiRunLauncher: React.FC = ({ {/* ── Prompt ── */}
Prompt -