From 4f228f768de148fc44fc42b8d3ac874f00a0ead2 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 16 Apr 2026 15:55:08 +0300 Subject: [PATCH] feat: add scheduled tasks with locale-aware scheduling and safer desktop quit flow (#920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: keep desktop app running in background when closing last window Closing last window hides it instead of quitting — sidecar keeps running Cmd+Q now shows confirmation dialog warning about stopping background processes Clicking dock icon reopens hidden window or creates a new one * docs: add scheduled tasks impl plan * feat: add scheduled tasks runtime, api, and ui * feat: conditionally confirm desktop quit on risks * chore: remove scheduled tasks plan doc * feat: add scheduled tasks runtime and management UI Add server-side scheduled task runtime with project-backed config persistence Add task scheduling UI and API integration for creating and editing schedules Add tests for runtime scheduling behavior and project config validation * feat: add locale display preferences for scheduled tasks Add Appearance settings for time format and week start with settings.json persistence Apply preferences in scheduled task editor for time display and weekday ordering Rename Thinking level control and disable it when model variants are unavailable * feat: improve scheduled tasks editor and sidebar action order Reorder session sidebar header actions to separate creation and management tools Polish scheduled tasks dialog layout and controls for clearer editing flow * feat: polish scheduled task editor usability Improve scheduled task dialog layout for clearer scheduling controls Refine time and weekday inputs for more intuitive task configuration Update editor labels and control states for better model variant guidance * feat: add prompt autocomplete and command-aware scheduled runs Add @ and / autocomplete support to task, multi-run, and agent manager prompt fields Fix agent mention selection so subagents can be inserted from @ suggestions Run scheduled prompts as commands when they match slash commands, with message fallback --- bun.lock | 14 +- packages/desktop/src-tauri/Cargo.lock | 174 +- packages/desktop/src-tauri/Cargo.toml | 1 + packages/desktop/src-tauri/src/main.rs | 346 +++- .../components/multirun/MultiRunLauncher.tsx | 190 ++- .../sections/openchamber/OpenChamberPage.tsx | 2 + .../openchamber/OpenChamberVisualSettings.tsx | 89 +- .../session/ScheduledTaskEditorDialog.tsx | 1478 +++++++++++++++++ .../session/ScheduledTasksDialog.tsx | 587 +++++++ .../src/components/session/SessionSidebar.tsx | 27 + .../session/sidebar/SidebarHeader.tsx | 21 +- .../agent-manager/AgentManagerEmptyState.tsx | 204 ++- packages/ui/src/lib/desktop.ts | 2 + packages/ui/src/lib/openchamberEvents.ts | 130 ++ packages/ui/src/lib/persistence.ts | 20 + packages/ui/src/lib/scheduledTasksApi.ts | 123 ++ packages/ui/src/stores/useUIStore.ts | 25 + packages/web/package.json | 2 + packages/web/server/index.js | 51 + .../lib/opencode/feature-routes-runtime.js | 14 + .../server/lib/opencode/settings-helpers.js | 12 + .../server/lib/opencode/shutdown-runtime.js | 2 + .../web/server/lib/projects/project-config.js | 558 +++++++ .../lib/projects/project-config.test.js | 103 ++ .../lib/scheduled-tasks/DOCUMENTATION.md | 44 + .../web/server/lib/scheduled-tasks/routes.js | 215 +++ .../web/server/lib/scheduled-tasks/runtime.js | 749 +++++++++ .../lib/scheduled-tasks/runtime.test.js | 100 ++ 28 files changed, 5231 insertions(+), 52 deletions(-) create mode 100644 packages/ui/src/components/session/ScheduledTaskEditorDialog.tsx create mode 100644 packages/ui/src/components/session/ScheduledTasksDialog.tsx create mode 100644 packages/ui/src/lib/openchamberEvents.ts create mode 100644 packages/ui/src/lib/scheduledTasksApi.ts create mode 100644 packages/web/server/lib/projects/project-config.js create mode 100644 packages/web/server/lib/projects/project-config.test.js create mode 100644 packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md create mode 100644 packages/web/server/lib/scheduled-tasks/routes.js create mode 100644 packages/web/server/lib/scheduled-tasks/runtime.js create mode 100644 packages/web/server/lib/scheduled-tasks/runtime.test.js 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 -