feat: add scheduled tasks with locale-aware scheduling and safer desktop quit flow (#920)
* 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
This commit is contained in:
committed by
GitHub
parent
ea5c19e934
commit
4f228f768d
@@ -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=="],
|
||||
|
||||
Generated
+173
-1
@@ -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",
|
||||
|
||||
@@ -39,3 +39,4 @@ strip = true
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-web-kit = "0.3"
|
||||
rfd = "0.15"
|
||||
|
||||
@@ -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<String> = 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<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
@@ -394,7 +588,13 @@ fn build_macos_menu<R: tauri::Runtime>(
|
||||
&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::<ScheduledTasksQuitRiskResponse>().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::<TunnelStatusResponse>().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::<WindowFocusState>() {
|
||||
state.remove_window(&label);
|
||||
}
|
||||
|
||||
// Remove stale per-window init script.
|
||||
if let Some(state) = app.try_state::<DesktopUiInjectionState>() {
|
||||
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::<DesktopSshManagerState>() {
|
||||
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::<SidecarState>() {
|
||||
*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::<DesktopSshManagerState>() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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<MultiRunLauncherProps> = ({
|
||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||
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<HTMLInputElement>(null);
|
||||
const promptTextareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory ?? null);
|
||||
@@ -389,6 +398,129 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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<MultiRunLauncherProps> = ({
|
||||
{/* ── Prompt ── */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel htmlFor="prompt" required>Prompt</FieldLabel>
|
||||
<Textarea
|
||||
id="prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Enter the prompt to send to all models..."
|
||||
className="typography-meta min-h-[100px] max-h-[300px] resize-none overflow-y-auto field-sizing-content"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="prompt"
|
||||
ref={promptTextareaRef}
|
||||
value={prompt}
|
||||
onChange={(event) => {
|
||||
const nextPrompt = event.target.value;
|
||||
setPrompt(nextPrompt);
|
||||
const cursorPosition = event.target.selectionStart ?? nextPrompt.length;
|
||||
updateAutocompleteState(nextPrompt, cursorPosition);
|
||||
}}
|
||||
onKeyDown={handlePromptKeyDown}
|
||||
placeholder="Enter the prompt to send to all models..."
|
||||
className="typography-meta min-h-[100px] max-h-[300px] resize-none overflow-y-auto field-sizing-content"
|
||||
required
|
||||
/>
|
||||
|
||||
{showCommandAutocomplete ? (
|
||||
<CommandAutocomplete
|
||||
ref={commandRef}
|
||||
searchQuery={commandQuery}
|
||||
onCommandSelect={handleAutocompleteCommandSelect}
|
||||
onClose={() => setShowCommandAutocomplete(false)}
|
||||
style={{
|
||||
left: 0,
|
||||
top: 'auto',
|
||||
bottom: 'calc(100% + 6px)',
|
||||
marginBottom: 0,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showFileMention ? (
|
||||
<FileMentionAutocomplete
|
||||
ref={mentionRef}
|
||||
searchQuery={mentionQuery}
|
||||
onFileSelect={handleAutocompleteFileSelect}
|
||||
onAgentSelect={handleAutocompleteAgentSelect}
|
||||
onClose={() => setShowFileMention(false)}
|
||||
style={{
|
||||
left: 0,
|
||||
top: 'auto',
|
||||
bottom: 'calc(100% + 6px)',
|
||||
marginBottom: 0,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* File attachments inline */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
|
||||
@@ -109,6 +109,8 @@ const VisualSectionContent: React.FC = () => {
|
||||
return <OpenChamberVisualSettings visibleSettings={[
|
||||
'theme',
|
||||
'pwaInstallName',
|
||||
'timeFormat',
|
||||
'weekStart',
|
||||
'fontSize',
|
||||
'terminalFontSize',
|
||||
'spacing',
|
||||
|
||||
@@ -139,11 +139,45 @@ const ACTIVITY_RENDER_MODE_OPTIONS: Option<'collapsed' | 'summary'>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const TIME_FORMAT_OPTIONS: Option<'auto' | '12h' | '24h'>[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
label: 'Auto',
|
||||
description: 'Use system locale preference.',
|
||||
},
|
||||
{
|
||||
id: '24h',
|
||||
label: '24-hour',
|
||||
description: 'Show time as 14:15.',
|
||||
},
|
||||
{
|
||||
id: '12h',
|
||||
label: '12-hour',
|
||||
description: 'Show time as 02:15 PM.',
|
||||
},
|
||||
];
|
||||
|
||||
const WEEK_START_OPTIONS: Option<'auto' | 'monday' | 'sunday'>[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
label: 'Auto',
|
||||
description: 'Use locale week start.',
|
||||
},
|
||||
{
|
||||
id: 'monday',
|
||||
label: 'Monday',
|
||||
},
|
||||
{
|
||||
id: 'sunday',
|
||||
label: 'Sunday',
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage';
|
||||
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage';
|
||||
|
||||
interface OpenChamberVisualSettingsProps {
|
||||
/** Which settings to show. If undefined, shows all. */
|
||||
@@ -193,6 +227,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const setShowExpandedBashTools = useUIStore(state => state.setShowExpandedBashTools);
|
||||
const showExpandedEditTools = useUIStore(state => state.showExpandedEditTools);
|
||||
const setShowExpandedEditTools = useUIStore(state => state.setShowExpandedEditTools);
|
||||
const timeFormatPreference = useUIStore(state => state.timeFormatPreference);
|
||||
const setTimeFormatPreference = useUIStore(state => state.setTimeFormatPreference);
|
||||
const weekStartPreference = useUIStore(state => state.weekStartPreference);
|
||||
const setWeekStartPreference = useUIStore(state => state.setWeekStartPreference);
|
||||
const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar);
|
||||
const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar);
|
||||
const isSettingsDialogOpen = useUIStore(state => state.isSettingsDialogOpen);
|
||||
@@ -310,6 +348,16 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
void updateDesktopSettings({ showExpandedEditTools: enabled });
|
||||
}, [setShowExpandedEditTools]);
|
||||
|
||||
const handleTimeFormatPreferenceChange = React.useCallback((value: 'auto' | '12h' | '24h') => {
|
||||
setTimeFormatPreference(value);
|
||||
void updateDesktopSettings({ timeFormatPreference: value });
|
||||
}, [setTimeFormatPreference]);
|
||||
|
||||
const handleWeekStartPreferenceChange = React.useCallback((value: 'auto' | 'monday' | 'sunday') => {
|
||||
setWeekStartPreference(value);
|
||||
void updateDesktopSettings({ weekStartPreference: value });
|
||||
}, [setWeekStartPreference]);
|
||||
|
||||
const lightThemes = React.useMemo(
|
||||
() => availableThemes
|
||||
.filter((theme) => theme.metadata.variant === 'light')
|
||||
@@ -345,7 +393,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
};
|
||||
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const hasAppearanceSettings = (shouldShow('theme') || shouldShow('pwaInstallName')) && !isVSCode;
|
||||
const hasAppearanceSettings = (shouldShow('theme') || shouldShow('pwaInstallName') || shouldShow('timeFormat') || shouldShow('weekStart')) && !isVSCode;
|
||||
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('inputBarOffset');
|
||||
const hasNavigationSettings = shouldShow('terminalQuickKeys') && !isMobile;
|
||||
const hasBehaviorSettings = shouldShow('mermaidRendering')
|
||||
@@ -494,6 +542,43 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(shouldShow('timeFormat') || shouldShow('weekStart')) && (
|
||||
<div className="mt-1 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
|
||||
{shouldShow('timeFormat') && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground shrink-0">Time Format</span>
|
||||
<Select value={timeFormatPreference} onValueChange={(value: 'auto' | '12h' | '24h') => handleTimeFormatPreferenceChange(value)}>
|
||||
<SelectTrigger aria-label="Select time format" className="w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_FORMAT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('weekStart') && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground shrink-0">Week Starts On</span>
|
||||
<Select value={weekStartPreference} onValueChange={(value: 'auto' | 'monday' | 'sunday') => handleWeekStartPreferenceChange(value)}>
|
||||
<SelectTrigger aria-label="Select week start" className="w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WEEK_START_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,587 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
RiLoader4Line,
|
||||
RiPlayLine,
|
||||
RiEdit2Line,
|
||||
RiDeleteBinLine,
|
||||
RiAddLine,
|
||||
RiFolderLine,
|
||||
RiTimerLine,
|
||||
RiHistoryLine,
|
||||
RiCheckboxCircleLine,
|
||||
RiErrorWarningLine,
|
||||
RiPulseLine,
|
||||
} from '@remixicon/react';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import {
|
||||
deleteScheduledTask,
|
||||
fetchScheduledTasks,
|
||||
runScheduledTaskNow,
|
||||
upsertScheduledTask,
|
||||
type ScheduledTask,
|
||||
type ScheduledTaskStatus,
|
||||
} from '@/lib/scheduledTasksApi';
|
||||
import { ScheduledTaskEditorDialog } from './ScheduledTaskEditorDialog';
|
||||
|
||||
const WEEKDAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
const scheduleTimes = (task: ScheduledTask): string[] => {
|
||||
const raw = Array.isArray(task.schedule.times)
|
||||
? task.schedule.times
|
||||
: (task.schedule.time ? [task.schedule.time] : []);
|
||||
const valid = raw.filter((value) => typeof value === 'string' && /^([01]\d|2[0-3]):([0-5]\d)$/.test(value));
|
||||
return Array.from(new Set(valid)).sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const formatSchedule = (task: ScheduledTask): string => {
|
||||
const timesLabel = scheduleTimes(task).join(', ') || '--:--';
|
||||
if (task.schedule.kind === 'daily') {
|
||||
return `Daily ${timesLabel}${task.schedule.timezone ? ` (${task.schedule.timezone})` : ''}`;
|
||||
}
|
||||
if (task.schedule.kind === 'weekly') {
|
||||
const days = Array.isArray(task.schedule.weekdays)
|
||||
? task.schedule.weekdays.map((value) => WEEKDAY_NAMES[value] || '?').join(', ')
|
||||
: '';
|
||||
return `Weekly ${days} ${timesLabel}${task.schedule.timezone ? ` (${task.schedule.timezone})` : ''}`;
|
||||
}
|
||||
if (task.schedule.kind === 'once') {
|
||||
const date = typeof task.schedule.date === 'string' && task.schedule.date.trim().length > 0
|
||||
? task.schedule.date
|
||||
: 'Unknown date';
|
||||
const time = typeof task.schedule.time === 'string' && task.schedule.time.trim().length > 0
|
||||
? task.schedule.time
|
||||
: '--:--';
|
||||
return `One-time ${date} ${time}${task.schedule.timezone ? ` (${task.schedule.timezone})` : ''}`;
|
||||
}
|
||||
return `Cron: ${task.schedule.cron || ''}${task.schedule.timezone ? ` (${task.schedule.timezone})` : ''}`;
|
||||
};
|
||||
|
||||
const formatClockTime = (value?: number): string => {
|
||||
if (!value || !Number.isFinite(value)) {
|
||||
return '';
|
||||
}
|
||||
return new Date(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const formatRelativeTime = (value?: number): string => {
|
||||
if (!value || !Number.isFinite(value)) {
|
||||
return '';
|
||||
}
|
||||
const diff = value - Date.now();
|
||||
const abs = Math.abs(diff);
|
||||
const minute = 60_000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
const future = diff >= 0;
|
||||
if (abs < minute) {
|
||||
return future ? 'in <1m' : 'just now';
|
||||
}
|
||||
if (abs < hour) {
|
||||
const m = Math.round(abs / minute);
|
||||
return future ? `in ${m}m` : `${m}m ago`;
|
||||
}
|
||||
if (abs < day) {
|
||||
const h = Math.floor(abs / hour);
|
||||
const m = Math.round((abs % hour) / minute);
|
||||
const body = m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
return future ? `in ${body}` : `${body} ago`;
|
||||
}
|
||||
const d = Math.floor(abs / day);
|
||||
const h = Math.round((abs % day) / hour);
|
||||
const body = h > 0 ? `${d}d ${h}h` : `${d}d`;
|
||||
return future ? `in ${body}` : `${body} ago`;
|
||||
};
|
||||
|
||||
type StatusTone = 'success' | 'error' | 'warning' | 'muted';
|
||||
|
||||
const STATUS_META: Record<
|
||||
ScheduledTaskStatus,
|
||||
{
|
||||
tone: StatusTone;
|
||||
label: string;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
spin?: boolean;
|
||||
}
|
||||
> = {
|
||||
success: { tone: 'success', label: 'Success', Icon: RiCheckboxCircleLine },
|
||||
error: { tone: 'error', label: 'Error', Icon: RiErrorWarningLine },
|
||||
running: { tone: 'warning', label: 'Running', Icon: RiLoader4Line, spin: true },
|
||||
idle: { tone: 'muted', label: 'Idle', Icon: RiPulseLine },
|
||||
};
|
||||
|
||||
const toneStyle = (tone: StatusTone): React.CSSProperties => {
|
||||
if (tone === 'muted') {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
color: `var(--status-${tone})`,
|
||||
backgroundColor: `var(--status-${tone}-background)`,
|
||||
borderColor: `var(--status-${tone}-border)`,
|
||||
};
|
||||
};
|
||||
|
||||
export function ScheduledTasksDialog() {
|
||||
const open = useUIStore((state) => state.isScheduledTasksDialogOpen);
|
||||
const setOpen = useUIStore((state) => state.setScheduledTasksDialogOpen);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const { currentTheme } = useThemeSystem();
|
||||
|
||||
const [selectedProjectID, setSelectedProjectID] = React.useState<string>('');
|
||||
const [tasks, setTasks] = React.useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [editorOpen, setEditorOpen] = React.useState(false);
|
||||
const [editorTask, setEditorTask] = React.useState<ScheduledTask | null>(null);
|
||||
const [mutatingTaskID, setMutatingTaskID] = React.useState<string | null>(null);
|
||||
|
||||
const selectedProject = React.useMemo(
|
||||
() => projects.find((project) => project.id === selectedProjectID) || null,
|
||||
[projects, selectedProjectID],
|
||||
);
|
||||
|
||||
const renderProjectLabel = React.useCallback((project: ProjectEntry) => {
|
||||
const displayLabel = project.label?.trim() || formatDirectoryName(project.path, homeDirectory || undefined);
|
||||
const imageUrl = getProjectIconImageUrl(
|
||||
{ id: project.id, iconImage: project.iconImage ?? null },
|
||||
{
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
},
|
||||
);
|
||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const iconColor = project.color ? PROJECT_COLOR_MAP[project.color] : undefined;
|
||||
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
{imageUrl ? (
|
||||
<span
|
||||
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
|
||||
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
||||
>
|
||||
<img src={imageUrl} alt="" className="h-full w-full object-contain" draggable={false} />
|
||||
</span>
|
||||
) : ProjectIcon ? (
|
||||
<ProjectIcon className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<RiFolderLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
|
||||
)}
|
||||
<span className="truncate">{displayLabel}</span>
|
||||
</span>
|
||||
);
|
||||
}, [homeDirectory, currentTheme.metadata.variant, currentTheme.colors.surface.foreground]);
|
||||
|
||||
const reloadTasks = React.useCallback(async (projectID: string, options?: { silent?: boolean }) => {
|
||||
if (!projectID) {
|
||||
setTasks([]);
|
||||
return;
|
||||
}
|
||||
if (!options?.silent) {
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const nextTasks = await fetchScheduledTasks(projectID);
|
||||
nextTasks.sort((a, b) => {
|
||||
if (a.enabled !== b.enabled) {
|
||||
return a.enabled ? -1 : 1;
|
||||
}
|
||||
const byName = a.name.localeCompare(b.name);
|
||||
if (byName !== 0) {
|
||||
return byName;
|
||||
}
|
||||
return (a.state?.nextRunAt || Number.MAX_SAFE_INTEGER) - (b.state?.nextRunAt || Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
setTasks(nextTasks);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to load scheduled tasks');
|
||||
if (!options?.silent) {
|
||||
setTasks([]);
|
||||
}
|
||||
} finally {
|
||||
if (!options?.silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const preferredProjectID = activeProject?.id || projects[0]?.id || '';
|
||||
setSelectedProjectID(preferredProjectID);
|
||||
if (preferredProjectID) {
|
||||
void reloadTasks(preferredProjectID);
|
||||
} else {
|
||||
setTasks([]);
|
||||
}
|
||||
}, [open, activeProject, projects, reloadTasks]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
let timeoutID: ReturnType<typeof setTimeout> | null = null;
|
||||
const unsubscribe = subscribeOpenchamberEvents((event) => {
|
||||
if (event.type !== 'scheduled-task-ran') {
|
||||
return;
|
||||
}
|
||||
if (event.projectId !== selectedProjectID) {
|
||||
return;
|
||||
}
|
||||
if (timeoutID) {
|
||||
clearTimeout(timeoutID);
|
||||
}
|
||||
timeoutID = setTimeout(() => {
|
||||
void reloadTasks(selectedProjectID, { silent: true });
|
||||
}, 400);
|
||||
});
|
||||
return () => {
|
||||
if (timeoutID) {
|
||||
clearTimeout(timeoutID);
|
||||
}
|
||||
unsubscribe();
|
||||
};
|
||||
}, [open, selectedProjectID, reloadTasks]);
|
||||
|
||||
const handleSaveTask = React.useCallback(async (taskDraft: Partial<ScheduledTask>) => {
|
||||
if (!selectedProjectID) {
|
||||
throw new Error('Choose a project first');
|
||||
}
|
||||
await upsertScheduledTask(selectedProjectID, taskDraft);
|
||||
await reloadTasks(selectedProjectID);
|
||||
toast.success('Scheduled task saved');
|
||||
}, [selectedProjectID, reloadTasks]);
|
||||
|
||||
const handleToggleEnabled = React.useCallback(async (task: ScheduledTask, enabled: boolean) => {
|
||||
if (!selectedProjectID) {
|
||||
return;
|
||||
}
|
||||
setMutatingTaskID(task.id);
|
||||
setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, enabled } : item)));
|
||||
try {
|
||||
await upsertScheduledTask(selectedProjectID, {
|
||||
...task,
|
||||
enabled,
|
||||
});
|
||||
await reloadTasks(selectedProjectID, { silent: true });
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update task');
|
||||
await reloadTasks(selectedProjectID, { silent: true });
|
||||
} finally {
|
||||
setMutatingTaskID(null);
|
||||
}
|
||||
}, [selectedProjectID, reloadTasks]);
|
||||
|
||||
const handleDeleteTask = React.useCallback(async (task: ScheduledTask) => {
|
||||
if (!selectedProjectID) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(`Delete scheduled task "${task.name}"?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMutatingTaskID(task.id);
|
||||
try {
|
||||
await deleteScheduledTask(selectedProjectID, task.id);
|
||||
await reloadTasks(selectedProjectID, { silent: true });
|
||||
toast.success('Scheduled task deleted');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete task');
|
||||
} finally {
|
||||
setMutatingTaskID(null);
|
||||
}
|
||||
}, [selectedProjectID, reloadTasks]);
|
||||
|
||||
const handleRunNow = React.useCallback(async (task: ScheduledTask) => {
|
||||
if (!selectedProjectID) {
|
||||
return;
|
||||
}
|
||||
setMutatingTaskID(task.id);
|
||||
try {
|
||||
await runScheduledTaskNow(selectedProjectID, task.id);
|
||||
await Promise.all([
|
||||
reloadTasks(selectedProjectID, { silent: true }),
|
||||
refreshGlobalSessions(),
|
||||
]);
|
||||
toast.success('Task started');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to run task');
|
||||
} finally {
|
||||
setMutatingTaskID(null);
|
||||
}
|
||||
}, [selectedProjectID, reloadTasks]);
|
||||
|
||||
const projectSelector = (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="typography-meta text-muted-foreground">Project</span>
|
||||
<Select
|
||||
value={selectedProjectID || '__none'}
|
||||
onValueChange={(value) => {
|
||||
const nextProjectID = value === '__none' ? '' : value;
|
||||
setSelectedProjectID(nextProjectID);
|
||||
if (nextProjectID) {
|
||||
void reloadTasks(nextProjectID);
|
||||
} else {
|
||||
setTasks([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={isMobile ? 'w-full' : undefined}>
|
||||
{selectedProject ? (
|
||||
<SelectValue>{renderProjectLabel(selectedProject)}</SelectValue>
|
||||
) : (
|
||||
<SelectValue placeholder="Select project" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.length === 0 ? <SelectItem value="__none">No projects</SelectItem> : null}
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{renderProjectLabel(project)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
const openNewTaskEditor = () => {
|
||||
setEditorTask(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const tasksContent = (
|
||||
<div className="space-y-4">
|
||||
{!isMobile ? (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{projectSelector}
|
||||
<Button onClick={openNewTaskEditor} disabled={!selectedProjectID}>
|
||||
<RiAddLine className="mr-1 h-4 w-4" /> New task
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
projectSelector
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" /> Loading tasks...
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-4 typography-meta text-muted-foreground">
|
||||
{selectedProjectID ? 'No scheduled tasks yet.' : 'Select a project to manage scheduled tasks.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{tasks.map((task) => {
|
||||
const isBusy = mutatingTaskID === task.id;
|
||||
const status = (task.state?.lastStatus || 'idle') as ScheduledTaskStatus;
|
||||
const meta = STATUS_META[status];
|
||||
const nextAt = task.state?.nextRunAt;
|
||||
const lastAt = task.state?.lastRunAt;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={cn(
|
||||
'rounded-lg border border-border p-4 transition-opacity',
|
||||
!task.enabled && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-header truncate font-semibold text-foreground">
|
||||
{task.name}
|
||||
</div>
|
||||
<div className="typography-micro truncate text-muted-foreground">
|
||||
{formatSchedule(task)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 typography-micro text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<RiTimerLine className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-foreground">Next</span>
|
||||
{nextAt ? (
|
||||
<>
|
||||
<span className="text-foreground">{formatRelativeTime(nextAt)}</span>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span>{formatClockTime(nextAt)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>—</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<RiHistoryLine className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-foreground">Last run</span>
|
||||
{status === 'running' ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1"
|
||||
style={{ color: 'var(--status-warning)' }}
|
||||
>
|
||||
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
|
||||
running now
|
||||
</span>
|
||||
) : lastAt ? (
|
||||
<>
|
||||
{meta.tone !== 'muted' ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1"
|
||||
style={{ color: `var(--status-${meta.tone})` }}
|
||||
>
|
||||
<meta.Icon className="h-3.5 w-3.5" />
|
||||
{meta.label}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span>{formatRelativeTime(lastAt)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>never</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{task.state?.lastError ? (
|
||||
<div
|
||||
className="mt-3 flex items-start gap-2 rounded-md border p-2 typography-micro"
|
||||
style={toneStyle('error')}
|
||||
>
|
||||
<RiErrorWarningLine className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 break-words">{task.state.lastError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<label
|
||||
className={cn(
|
||||
'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium',
|
||||
task.enabled ? 'text-foreground' : 'text-muted-foreground',
|
||||
isBusy && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={task.enabled}
|
||||
onChange={(enabled) => void handleToggleEnabled(task, enabled)}
|
||||
ariaLabel={task.enabled ? `Pause ${task.name}` : `Enable ${task.name}`}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
{task.enabled ? 'Enabled' : 'Paused'}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleRunNow(task)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<RiPlayLine className="h-4 w-4" /> run now
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditorTask(task);
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
disabled={isBusy}
|
||||
aria-label={`Edit ${task.name}`}
|
||||
>
|
||||
<RiEdit2Line className="h-4 w-4" /> edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => void handleDeleteTask(task)}
|
||||
disabled={isBusy}
|
||||
aria-label={`Delete ${task.name}`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<MobileOverlayPanel
|
||||
open={open}
|
||||
title="Scheduled tasks"
|
||||
onClose={() => setOpen(false)}
|
||||
contentMaxHeightClassName="max-h-[min(80vh,640px)]"
|
||||
renderHeader={(closeButton) => (
|
||||
<div className="flex flex-col gap-1 border-b border-border/40 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">Scheduled tasks</h2>
|
||||
{closeButton}
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Server-side tasks that create a new session and send a configured prompt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
footer={(
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={openNewTaskEditor}
|
||||
disabled={!selectedProjectID}
|
||||
>
|
||||
<RiAddLine className="mr-1 h-4 w-4" /> New task
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{tasksContent}
|
||||
</MobileOverlayPanel>
|
||||
) : (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Scheduled tasks</DialogTitle>
|
||||
<DialogDescription>Server-side tasks that create a new session and send a configured prompt.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{tasksContent}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<ScheduledTaskEditorDialog
|
||||
open={editorOpen}
|
||||
task={editorTask}
|
||||
onOpenChange={setEditorOpen}
|
||||
onSave={handleSaveTask}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/us
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { NewWorktreeDialog } from './NewWorktreeDialog';
|
||||
import { ScheduledTasksDialog } from './ScheduledTasksDialog';
|
||||
import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
@@ -71,6 +72,7 @@ import {
|
||||
import { refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
|
||||
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||
@@ -267,6 +269,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const setAboutDialogOpen = useUIStore((state) => state.setAboutDialogOpen);
|
||||
const deviceInfo = useDeviceInfo();
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const setScheduledTasksDialogOpen = useUIStore((state) => state.setScheduledTasksDialogOpen);
|
||||
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
|
||||
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
|
||||
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
|
||||
@@ -407,6 +410,27 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
};
|
||||
}, [currentDirectory, syncSessionStructureSignature]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const unsubscribe = subscribeOpenchamberEvents((event) => {
|
||||
if (event.type !== 'scheduled-task-ran') {
|
||||
return;
|
||||
}
|
||||
if (refreshTimeout) {
|
||||
clearTimeout(refreshTimeout);
|
||||
}
|
||||
refreshTimeout = setTimeout(() => {
|
||||
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
|
||||
}, 500);
|
||||
});
|
||||
return () => {
|
||||
if (refreshTimeout) {
|
||||
clearTimeout(refreshTimeout);
|
||||
}
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
||||
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||
const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false);
|
||||
@@ -1511,6 +1535,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
searchMatchCount={searchMatchCount}
|
||||
collapseAllProjects={collapseAllProjects}
|
||||
expandAllProjects={expandAllProjects}
|
||||
openScheduledTasksDialog={() => setScheduledTasksDialogOpen(true)}
|
||||
/>
|
||||
|
||||
<SidebarProjectsList
|
||||
@@ -1603,6 +1628,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScheduledTasksDialog />
|
||||
|
||||
{useMobileNotesPanel ? (
|
||||
<MobileOverlayPanel
|
||||
open={projectNotesPanelOpen}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
RiContractUpDownLine,
|
||||
RiExpandUpDownLine,
|
||||
RiStickyNoteLine,
|
||||
RiCalendarScheduleLine,
|
||||
} from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
@@ -47,6 +48,7 @@ type Props = {
|
||||
searchMatchCount: number;
|
||||
collapseAllProjects: () => void;
|
||||
expandAllProjects: () => void;
|
||||
openScheduledTasksDialog: () => void;
|
||||
};
|
||||
|
||||
export function SidebarHeader(props: Props): React.ReactNode {
|
||||
@@ -74,6 +76,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
searchMatchCount,
|
||||
collapseAllProjects,
|
||||
expandAllProjects,
|
||||
openScheduledTasksDialog,
|
||||
} = props;
|
||||
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
@@ -115,9 +118,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New session</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -132,6 +133,22 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New multi-run</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openScheduledTasksDialog}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Scheduled tasks"
|
||||
>
|
||||
<RiCalendarScheduleLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Scheduled tasks</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{useMobileNotesPanel ? (
|
||||
<Tooltip>
|
||||
|
||||
@@ -20,6 +20,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from '@/components/multirun/ModelMultiSelect';
|
||||
import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSelector';
|
||||
import { AgentSelector } from '@/components/multirun/AgentSelector';
|
||||
import { CommandAutocomplete, type CommandAutocompleteHandle } from '@/components/chat/CommandAutocomplete';
|
||||
import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
@@ -63,9 +65,15 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||
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<HTMLInputElement>(null);
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null);
|
||||
@@ -199,6 +207,110 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
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 handleAutocompleteFileSelect = React.useCallback((file: { name: string; path: string; relativePath?: string }) => {
|
||||
const cursorPosition = textareaRef.current?.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 = textareaRef.current;
|
||||
if (currentTextarea) {
|
||||
currentTextarea.selectionStart = nextCursor;
|
||||
currentTextarea.selectionEnd = nextCursor;
|
||||
currentTextarea.focus();
|
||||
}
|
||||
updateAutocompleteState(nextPrompt, nextCursor);
|
||||
});
|
||||
}, [prompt, updateAutocompleteState]);
|
||||
|
||||
const handleAutocompleteAgentSelect = React.useCallback((agentName: string) => {
|
||||
const cursorPosition = textareaRef.current?.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 = textareaRef.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 = textareaRef.current;
|
||||
if (currentTextarea) {
|
||||
currentTextarea.focus();
|
||||
currentTextarea.selectionStart = currentTextarea.value.length;
|
||||
currentTextarea.selectionEnd = currentTextarea.value.length;
|
||||
}
|
||||
updateAutocompleteState(nextPrompt, nextPrompt.length);
|
||||
});
|
||||
}, [updateAutocompleteState]);
|
||||
|
||||
// Use either local submitting state or external isCreating prop
|
||||
const isSubmittingOrCreating = isSubmitting || isCreating;
|
||||
|
||||
@@ -254,6 +366,10 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
setSelectedAgent('');
|
||||
setAttachedFiles([]);
|
||||
setBaseBranch('HEAD');
|
||||
setShowCommandAutocomplete(false);
|
||||
setShowFileMention(false);
|
||||
setCommandQuery('');
|
||||
setMentionQuery('');
|
||||
} catch (error) {
|
||||
console.error('Failed to create agent group:', error);
|
||||
toast.error('Failed to create agent group');
|
||||
@@ -266,6 +382,22 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
// Early return during IME composition
|
||||
if (isIMECompositionEvent(e)) return;
|
||||
|
||||
if (showCommandAutocomplete && commandRef.current) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
commandRef.current.handleKeyDown(e.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (showFileMention && mentionRef.current) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
mentionRef.current.handleKeyDown(e.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter submits if valid, Shift+Enter adds newline
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -412,20 +544,26 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
<label htmlFor="prompt" className="typography-ui-label font-medium text-foreground">
|
||||
Prompt
|
||||
</label>
|
||||
<div
|
||||
className="rounded-xl border border-border/80 overflow-hidden focus-within:ring-1 focus-within:ring-primary/50"
|
||||
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
|
||||
>
|
||||
{/* Text Area */}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id="prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything..."
|
||||
className="min-h-[100px] max-h-[300px] resize-none border-0 bg-transparent dark:bg-transparent px-4 py-3 typography-markdown focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="rounded-xl border border-border/80 overflow-hidden focus-within:ring-1 focus-within:ring-primary/50"
|
||||
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
|
||||
>
|
||||
{/* Text Area */}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id="prompt"
|
||||
value={prompt}
|
||||
onChange={(event) => {
|
||||
const nextPrompt = event.target.value;
|
||||
setPrompt(nextPrompt);
|
||||
const cursorPosition = event.target.selectionStart ?? nextPrompt.length;
|
||||
updateAutocompleteState(nextPrompt, cursorPosition);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything..."
|
||||
className="min-h-[100px] max-h-[300px] resize-none border-0 bg-transparent dark:bg-transparent px-4 py-3 typography-markdown focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
|
||||
{/* Attached Files Display */}
|
||||
{attachedFiles.length > 0 && (
|
||||
@@ -455,8 +593,8 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Controls */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-border/40 bg-transparent">
|
||||
{/* Footer Controls */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-border/40 bg-transparent">
|
||||
{/* Left Controls - Attachments */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -501,7 +639,41 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
<RiSendPlane2Line className="h-[18px] w-[18px]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCommandAutocomplete ? (
|
||||
<CommandAutocomplete
|
||||
ref={commandRef}
|
||||
searchQuery={commandQuery}
|
||||
onCommandSelect={handleAutocompleteCommandSelect}
|
||||
onClose={() => setShowCommandAutocomplete(false)}
|
||||
style={{
|
||||
left: 0,
|
||||
top: 'auto',
|
||||
bottom: 'calc(100% + 6px)',
|
||||
marginBottom: 0,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showFileMention ? (
|
||||
<FileMentionAutocomplete
|
||||
ref={mentionRef}
|
||||
searchQuery={mentionQuery}
|
||||
onFileSelect={handleAutocompleteFileSelect}
|
||||
onAgentSelect={handleAutocompleteAgentSelect}
|
||||
onClose={() => setShowFileMention(false)}
|
||||
style={{
|
||||
left: 0,
|
||||
top: 'auto',
|
||||
bottom: 'calc(100% + 6px)',
|
||||
marginBottom: 0,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -122,6 +122,8 @@ export type DesktopSettings = {
|
||||
showToolFileIcons?: boolean;
|
||||
showExpandedBashTools?: boolean;
|
||||
showExpandedEditTools?: boolean;
|
||||
timeFormatPreference?: 'auto' | '12h' | '24h';
|
||||
weekStartPreference?: 'auto' | 'sunday' | 'monday';
|
||||
chatRenderMode?: 'sorted' | 'live';
|
||||
activityRenderMode?: 'collapsed' | 'summary';
|
||||
mermaidRenderingMode?: 'svg' | 'ascii';
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
export type ScheduledTaskRanEvent = {
|
||||
type: 'scheduled-task-ran';
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
ranAt: number;
|
||||
status: 'running' | 'success' | 'error';
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
type OpenChamberEvent = ScheduledTaskRanEvent;
|
||||
type Listener = (event: OpenChamberEvent) => void;
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempt = 0;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const MAX_RECONNECT_DELAY_MS = 30_000;
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (reconnectTimer || listeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(1_000 * Math.pow(2, Math.min(reconnectAttempt, 5)), MAX_RECONNECT_DELAY_MS);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
reconnectAttempt += 1;
|
||||
connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const cleanupSource = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
eventSource = null;
|
||||
};
|
||||
|
||||
const parseEnvelope = (raw: string): { type: string; properties: unknown } | null => {
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const type = typeof parsed?.type === 'string' ? parsed.type : '';
|
||||
const properties = parsed?.properties;
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
return { type, properties };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchFromEnvelope = (envelope: { type: string; properties: unknown }) => {
|
||||
if (envelope.type === 'openchamber:event-stream-ready') {
|
||||
reconnectAttempt = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.type !== 'openchamber:scheduled-task-ran') {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = envelope.properties && typeof envelope.properties === 'object'
|
||||
? envelope.properties as Record<string, unknown>
|
||||
: null;
|
||||
const projectId = typeof parsed?.projectId === 'string' ? parsed.projectId : '';
|
||||
const taskId = typeof parsed?.taskId === 'string' ? parsed.taskId : '';
|
||||
const ranAt = typeof parsed?.ranAt === 'number' ? parsed.ranAt : Date.now();
|
||||
const rawStatus = parsed?.status;
|
||||
const status = rawStatus === 'running' || rawStatus === 'error' ? rawStatus : 'success';
|
||||
if (!projectId || !taskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEvent: ScheduledTaskRanEvent = {
|
||||
type: 'scheduled-task-ran',
|
||||
projectId,
|
||||
taskId,
|
||||
ranAt,
|
||||
status,
|
||||
...(typeof parsed?.sessionId === 'string' && parsed.sessionId.length > 0 ? { sessionId: parsed.sessionId } : {}),
|
||||
};
|
||||
for (const listener of listeners) {
|
||||
listener(nextEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (typeof window === 'undefined' || listeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
cleanupSource();
|
||||
|
||||
const source = new EventSource('/api/openchamber/events');
|
||||
source.onmessage = (event) => {
|
||||
const envelope = parseEnvelope(event.data);
|
||||
if (!envelope) {
|
||||
return;
|
||||
}
|
||||
dispatchFromEnvelope(envelope);
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
cleanupSource();
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
eventSource = source;
|
||||
};
|
||||
|
||||
export const subscribeOpenchamberEvents = (listener: Listener): (() => void) => {
|
||||
listeners.add(listener);
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
reconnectAttempt = 0;
|
||||
cleanupSource();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -371,6 +371,18 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
if (typeof settings.showExpandedEditTools === 'boolean' && settings.showExpandedEditTools !== store.showExpandedEditTools) {
|
||||
store.setShowExpandedEditTools(settings.showExpandedEditTools);
|
||||
}
|
||||
if (typeof settings.timeFormatPreference === 'string'
|
||||
&& (settings.timeFormatPreference === 'auto' || settings.timeFormatPreference === '12h' || settings.timeFormatPreference === '24h')) {
|
||||
if (settings.timeFormatPreference !== store.timeFormatPreference) {
|
||||
store.setTimeFormatPreference(settings.timeFormatPreference);
|
||||
}
|
||||
}
|
||||
if (typeof settings.weekStartPreference === 'string'
|
||||
&& (settings.weekStartPreference === 'auto' || settings.weekStartPreference === 'sunday' || settings.weekStartPreference === 'monday')) {
|
||||
if (settings.weekStartPreference !== store.weekStartPreference) {
|
||||
store.setWeekStartPreference(settings.weekStartPreference);
|
||||
}
|
||||
}
|
||||
if (typeof settings.chatRenderMode === 'string'
|
||||
&& (settings.chatRenderMode === 'sorted' || settings.chatRenderMode === 'live')) {
|
||||
if (settings.chatRenderMode !== store.chatRenderMode) {
|
||||
@@ -780,6 +792,14 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.showExpandedEditTools === 'boolean') {
|
||||
result.showExpandedEditTools = candidate.showExpandedEditTools;
|
||||
}
|
||||
if (typeof candidate.timeFormatPreference === 'string'
|
||||
&& (candidate.timeFormatPreference === 'auto' || candidate.timeFormatPreference === '12h' || candidate.timeFormatPreference === '24h')) {
|
||||
result.timeFormatPreference = candidate.timeFormatPreference;
|
||||
}
|
||||
if (typeof candidate.weekStartPreference === 'string'
|
||||
&& (candidate.weekStartPreference === 'auto' || candidate.weekStartPreference === 'sunday' || candidate.weekStartPreference === 'monday')) {
|
||||
result.weekStartPreference = candidate.weekStartPreference;
|
||||
}
|
||||
if (typeof candidate.chatRenderMode === 'string'
|
||||
&& (candidate.chatRenderMode === 'sorted' || candidate.chatRenderMode === 'live')) {
|
||||
result.chatRenderMode = candidate.chatRenderMode;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
export type ScheduledTaskStatus = 'idle' | 'running' | 'success' | 'error';
|
||||
|
||||
export type ScheduledTask = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
schedule: {
|
||||
kind: 'daily' | 'weekly' | 'once' | 'cron';
|
||||
times?: string[];
|
||||
time?: string;
|
||||
date?: string;
|
||||
weekdays?: number[];
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
};
|
||||
execution: {
|
||||
prompt: string;
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
variant?: string;
|
||||
agent?: string;
|
||||
};
|
||||
state: {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastRunAt?: number;
|
||||
lastStatus?: ScheduledTaskStatus;
|
||||
lastError?: string;
|
||||
lastDurationMs?: number;
|
||||
lastSessionId?: string;
|
||||
nextRunAt?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const parseErrorMessage = async (response: Response, fallback: string) => {
|
||||
try {
|
||||
const parsed = await response.json();
|
||||
if (parsed && typeof parsed.error === 'string' && parsed.error.trim().length > 0) {
|
||||
return parsed.error;
|
||||
}
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const ensureProjectID = (projectID: string): string => {
|
||||
const trimmed = typeof projectID === 'string' ? projectID.trim() : '';
|
||||
if (!trimmed) {
|
||||
throw new Error('projectId is required');
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export const fetchScheduledTasks = async (projectID: string): Promise<ScheduledTask[]> => {
|
||||
const safeProjectID = ensureProjectID(projectID);
|
||||
const response = await fetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseErrorMessage(response, 'Failed to load scheduled tasks'));
|
||||
}
|
||||
const parsed = await response.json().catch(() => null);
|
||||
if (!parsed || !Array.isArray(parsed.tasks)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.tasks as ScheduledTask[];
|
||||
};
|
||||
|
||||
export const upsertScheduledTask = async (projectID: string, task: Partial<ScheduledTask>): Promise<ScheduledTask[]> => {
|
||||
const safeProjectID = ensureProjectID(projectID);
|
||||
const response = await fetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ task }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseErrorMessage(response, 'Failed to save scheduled task'));
|
||||
}
|
||||
const parsed = await response.json().catch(() => null);
|
||||
if (!parsed || !Array.isArray(parsed.tasks)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.tasks as ScheduledTask[];
|
||||
};
|
||||
|
||||
export const deleteScheduledTask = async (projectID: string, taskID: string): Promise<ScheduledTask[]> => {
|
||||
const safeProjectID = ensureProjectID(projectID);
|
||||
const safeTaskID = ensureProjectID(taskID);
|
||||
const response = await fetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks/${encodeURIComponent(safeTaskID)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseErrorMessage(response, 'Failed to delete scheduled task'));
|
||||
}
|
||||
const parsed = await response.json().catch(() => null);
|
||||
if (!parsed || !Array.isArray(parsed.tasks)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.tasks as ScheduledTask[];
|
||||
};
|
||||
|
||||
export const runScheduledTaskNow = async (projectID: string, taskID: string): Promise<{ sessionId?: string }> => {
|
||||
const safeProjectID = ensureProjectID(projectID);
|
||||
const safeTaskID = ensureProjectID(taskID);
|
||||
const response = await fetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks/${encodeURIComponent(safeTaskID)}/run`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseErrorMessage(response, 'Failed to run scheduled task'));
|
||||
}
|
||||
const parsed = await response.json().catch(() => null);
|
||||
return {
|
||||
sessionId: typeof parsed?.sessionId === 'string' && parsed.sessionId.length > 0 ? parsed.sessionId : undefined,
|
||||
};
|
||||
};
|
||||
@@ -13,6 +13,8 @@ export type UserMessageRenderingMode = 'markdown' | 'plain';
|
||||
export type ChatRenderMode = 'sorted' | 'live';
|
||||
export type ActivityRenderMode = 'collapsed' | 'summary';
|
||||
export type SessionRetentionAction = 'archive' | 'delete';
|
||||
export type TimeFormatPreference = 'auto' | '12h' | '24h';
|
||||
export type WeekStartPreference = 'auto' | 'sunday' | 'monday';
|
||||
|
||||
type ContextPanelTab = {
|
||||
id: string;
|
||||
@@ -487,6 +489,7 @@ interface UIStore {
|
||||
isOpenCodeStatusDialogOpen: boolean;
|
||||
openCodeStatusText: string;
|
||||
isSessionCreateDialogOpen: boolean;
|
||||
isScheduledTasksDialogOpen: boolean;
|
||||
isSettingsDialogOpen: boolean;
|
||||
isModelSelectorOpen: boolean;
|
||||
sidebarSection: SidebarSection;
|
||||
@@ -556,6 +559,8 @@ interface UIStore {
|
||||
showToolFileIcons: boolean;
|
||||
showExpandedBashTools: boolean;
|
||||
showExpandedEditTools: boolean;
|
||||
timeFormatPreference: TimeFormatPreference;
|
||||
weekStartPreference: WeekStartPreference;
|
||||
mermaidRenderingMode: MermaidRenderingMode;
|
||||
userMessageRenderingMode: UserMessageRenderingMode;
|
||||
stickyUserHeader: boolean;
|
||||
@@ -606,6 +611,7 @@ interface UIStore {
|
||||
setOpenCodeStatusDialogOpen: (open: boolean) => void;
|
||||
setOpenCodeStatusText: (text: string) => void;
|
||||
setSessionCreateDialogOpen: (open: boolean) => void;
|
||||
setScheduledTasksDialogOpen: (open: boolean) => void;
|
||||
setSettingsDialogOpen: (open: boolean) => void;
|
||||
setModelSelectorOpen: (open: boolean) => void;
|
||||
applyTheme: () => void;
|
||||
@@ -667,6 +673,8 @@ interface UIStore {
|
||||
setShowToolFileIcons: (value: boolean) => void;
|
||||
setShowExpandedBashTools: (value: boolean) => void;
|
||||
setShowExpandedEditTools: (value: boolean) => void;
|
||||
setTimeFormatPreference: (value: TimeFormatPreference) => void;
|
||||
setWeekStartPreference: (value: WeekStartPreference) => void;
|
||||
setMermaidRenderingMode: (value: MermaidRenderingMode) => void;
|
||||
setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void;
|
||||
setStickyUserHeader: (value: boolean) => void;
|
||||
@@ -720,6 +728,7 @@ export const useUIStore = create<UIStore>()(
|
||||
isOpenCodeStatusDialogOpen: false,
|
||||
openCodeStatusText: '',
|
||||
isSessionCreateDialogOpen: false,
|
||||
isScheduledTasksDialogOpen: false,
|
||||
isSettingsDialogOpen: false,
|
||||
isModelSelectorOpen: false,
|
||||
sidebarSection: 'sessions',
|
||||
@@ -783,6 +792,8 @@ export const useUIStore = create<UIStore>()(
|
||||
showToolFileIcons: true,
|
||||
showExpandedBashTools: false,
|
||||
showExpandedEditTools: false,
|
||||
timeFormatPreference: 'auto',
|
||||
weekStartPreference: 'auto',
|
||||
mermaidRenderingMode: 'svg',
|
||||
userMessageRenderingMode: 'markdown',
|
||||
stickyUserHeader: true,
|
||||
@@ -1256,6 +1267,10 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ isSessionCreateDialogOpen: open });
|
||||
},
|
||||
|
||||
setScheduledTasksDialogOpen: (open) => {
|
||||
set({ isScheduledTasksDialogOpen: open });
|
||||
},
|
||||
|
||||
setSettingsDialogOpen: (open) => {
|
||||
set((state) => {
|
||||
if (!open) {
|
||||
@@ -1691,6 +1706,14 @@ export const useUIStore = create<UIStore>()(
|
||||
setShowExpandedEditTools: (value) => {
|
||||
set({ showExpandedEditTools: value });
|
||||
},
|
||||
|
||||
setTimeFormatPreference: (value) => {
|
||||
set({ timeFormatPreference: value });
|
||||
},
|
||||
|
||||
setWeekStartPreference: (value) => {
|
||||
set({ weekStartPreference: value });
|
||||
},
|
||||
setMermaidRenderingMode: (value) => {
|
||||
set({ mermaidRenderingMode: value });
|
||||
},
|
||||
@@ -1890,6 +1913,8 @@ export const useUIStore = create<UIStore>()(
|
||||
showToolFileIcons: state.showToolFileIcons,
|
||||
showExpandedBashTools: state.showExpandedBashTools,
|
||||
showExpandedEditTools: state.showExpandedEditTools,
|
||||
timeFormatPreference: state.timeFormatPreference,
|
||||
weekStartPreference: state.weekStartPreference,
|
||||
mermaidRenderingMode: state.mermaidRenderingMode,
|
||||
userMessageRenderingMode: state.userMessageRenderingMode,
|
||||
stickyUserHeader: state.stickyUserHeader,
|
||||
|
||||
@@ -48,11 +48,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",
|
||||
|
||||
@@ -56,6 +56,7 @@ import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolut
|
||||
import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js';
|
||||
import { createSessionRuntime } from './lib/opencode/session-runtime.js';
|
||||
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
|
||||
import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js';
|
||||
import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js';
|
||||
import { createTunnelWiringRuntime } from './lib/opencode/tunnel-wiring-runtime.js';
|
||||
import { createStartupPipelineRuntime } from './lib/opencode/startup-pipeline-runtime.js';
|
||||
@@ -66,6 +67,7 @@ import { createNotificationTriggerRuntime } from './lib/notifications/runtime.js
|
||||
import { createPushRuntime } from './lib/notifications/push-runtime.js';
|
||||
import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js';
|
||||
import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
|
||||
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
|
||||
import webPush from 'web-push';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -74,6 +76,7 @@ const __dirname = path.dirname(__filename);
|
||||
const DEFAULT_PORT = 3000;
|
||||
const DESKTOP_NOTIFY_PREFIX = '[OpenChamberDesktopNotify] ';
|
||||
const uiNotificationClients = new Set();
|
||||
const uiOpenChamberEventClients = new Set();
|
||||
const HEALTH_CHECK_INTERVAL = 15000;
|
||||
const SHUTDOWN_TIMEOUT = 10000;
|
||||
const MODELS_DEV_API_URL = 'https://models.dev/api.json';
|
||||
@@ -146,6 +149,7 @@ const sanitizeProjects = (...args) => settingsNormalizationRuntime.sanitizeProje
|
||||
|
||||
const OPENCHAMBER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber');
|
||||
const OPENCHAMBER_USER_THEMES_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'themes');
|
||||
const OPENCHAMBER_PROJECTS_CONFIG_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'projects');
|
||||
|
||||
const MAX_THEME_JSON_BYTES = 512 * 1024;
|
||||
|
||||
@@ -313,6 +317,12 @@ const sessionRuntime = createSessionRuntime({
|
||||
getNotificationClients: () => uiNotificationClients,
|
||||
});
|
||||
|
||||
const projectConfigRuntime = createProjectConfigRuntime({
|
||||
fsPromises,
|
||||
path,
|
||||
projectsDirPath: OPENCHAMBER_PROJECTS_CONFIG_DIR,
|
||||
});
|
||||
|
||||
// HMR-persistent state via globalThis
|
||||
// These values survive Vite HMR reloads to prevent zombie OpenCode processes
|
||||
const hmrStateRuntime = createHmrStateRuntime({
|
||||
@@ -754,6 +764,36 @@ const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCo
|
||||
const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args);
|
||||
const refreshOpenCodeAfterConfigChange = (...args) => openCodeLifecycleRuntime.refreshOpenCodeAfterConfigChange(...args);
|
||||
const startHealthMonitoring = () => openCodeLifecycleRuntime.startHealthMonitoring(HEALTH_CHECK_INTERVAL);
|
||||
const scheduledTasksRuntime = createScheduledTasksRuntime({
|
||||
projectConfigRuntime,
|
||||
listProjects: async () => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
return sanitizeProjects(settings?.projects || []);
|
||||
},
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
waitForOpenCodeReady,
|
||||
emitTaskRunEvent: (event) => {
|
||||
for (const client of uiOpenChamberEventClients) {
|
||||
try {
|
||||
writeSseEvent(client, {
|
||||
type: 'openchamber:scheduled-task-ran',
|
||||
properties: {
|
||||
projectId: event.projectID,
|
||||
taskId: event.taskID,
|
||||
ranAt: event.ranAt,
|
||||
status: event.status,
|
||||
...(event.sessionID ? { sessionId: event.sessionID } : {}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
uiOpenChamberEventClients.delete(client);
|
||||
}
|
||||
}
|
||||
},
|
||||
logger: console,
|
||||
});
|
||||
|
||||
const ensureGlobalWatcherStarted = async () => {
|
||||
if (globalWatcherStartPromise) {
|
||||
return globalWatcherStartPromise;
|
||||
@@ -820,6 +860,7 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({
|
||||
activeTunnelController = value;
|
||||
},
|
||||
tunnelAuthController,
|
||||
scheduledTasksRuntime,
|
||||
});
|
||||
|
||||
const gracefulShutdown = (...args) => gracefulShutdownRuntime.gracefulShutdown(...args);
|
||||
@@ -969,6 +1010,10 @@ async function main(options = {}) {
|
||||
getOpenCodeAuthHeaders,
|
||||
getOpenCodePort: () => openCodePort,
|
||||
buildAugmentedPath,
|
||||
projectConfigRuntime,
|
||||
scheduledTasksRuntime,
|
||||
getOpenChamberEventClients: () => uiOpenChamberEventClients,
|
||||
writeSseEvent,
|
||||
});
|
||||
|
||||
const startupPipelineResult = await startupPipelineRuntime.run({
|
||||
@@ -1014,6 +1059,12 @@ async function main(options = {}) {
|
||||
});
|
||||
terminalRuntime = startupPipelineResult.terminalRuntime;
|
||||
|
||||
try {
|
||||
await scheduledTasksRuntime.start();
|
||||
} catch (error) {
|
||||
console.warn('[ScheduledTasks] Failed to start runtime:', error?.message || error);
|
||||
}
|
||||
|
||||
return {
|
||||
expressApp: app,
|
||||
httpServer: server,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
|
||||
import { registerConfigEntityRoutes } from './config-entity-routes.js';
|
||||
import { registerSettingsUtilityRoutes } from './core-routes.js';
|
||||
import { registerProjectIconRoutes } from './project-icon-routes.js';
|
||||
import { registerScheduledTaskRoutes } from '../scheduled-tasks/routes.js';
|
||||
import { registerSkillRoutes } from './skill-routes.js';
|
||||
import { registerOpenCodeRoutes } from './routes.js';
|
||||
|
||||
@@ -52,6 +53,10 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
getOpenCodeAuthHeaders,
|
||||
getOpenCodePort,
|
||||
buildAugmentedPath,
|
||||
projectConfigRuntime,
|
||||
scheduledTasksRuntime,
|
||||
getOpenChamberEventClients,
|
||||
writeSseEvent,
|
||||
} = routeDependencies;
|
||||
|
||||
const { getProviderSources, removeProviderConfig } = await import('./index.js');
|
||||
@@ -91,6 +96,15 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
resolveGitBinaryForSpawn,
|
||||
});
|
||||
|
||||
registerScheduledTaskRoutes(app, {
|
||||
readSettingsFromDiskMigrated,
|
||||
sanitizeProjects,
|
||||
projectConfigRuntime,
|
||||
scheduledTasksRuntime,
|
||||
getOpenChamberEventClients,
|
||||
writeSseEvent,
|
||||
});
|
||||
|
||||
const {
|
||||
getAgentSources,
|
||||
getAgentConfig,
|
||||
|
||||
@@ -307,6 +307,18 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
if (typeof candidate.showExpandedEditTools === 'boolean') {
|
||||
result.showExpandedEditTools = candidate.showExpandedEditTools;
|
||||
}
|
||||
if (typeof candidate.timeFormatPreference === 'string') {
|
||||
const mode = candidate.timeFormatPreference.trim();
|
||||
if (mode === 'auto' || mode === '12h' || mode === '24h') {
|
||||
result.timeFormatPreference = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.weekStartPreference === 'string') {
|
||||
const mode = candidate.weekStartPreference.trim();
|
||||
if (mode === 'auto' || mode === 'sunday' || mode === 'monday') {
|
||||
result.weekStartPreference = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.chatRenderMode === 'string') {
|
||||
const mode = candidate.chatRenderMode.trim();
|
||||
if (mode === 'sorted' || mode === 'live') {
|
||||
|
||||
@@ -8,6 +8,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
||||
syncToHmrState,
|
||||
openCodeWatcherRuntime,
|
||||
sessionRuntime,
|
||||
scheduledTasksRuntime,
|
||||
getHealthCheckInterval,
|
||||
clearHealthCheckInterval,
|
||||
getTerminalRuntime,
|
||||
@@ -36,6 +37,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
||||
|
||||
openCodeWatcherRuntime.stop();
|
||||
sessionRuntime.dispose();
|
||||
scheduledTasksRuntime?.stop?.();
|
||||
|
||||
const healthCheckInterval = getHealthCheckInterval();
|
||||
if (healthCheckInterval) {
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
import { DateTime, IANAZone } from 'luxon';
|
||||
import parser from 'cron-parser';
|
||||
|
||||
const PROJECT_CONFIG_VERSION = 1;
|
||||
const MAX_TASK_NAME_LENGTH = 80;
|
||||
const MAX_TASK_PROMPT_LENGTH = 20_000;
|
||||
const MAX_CRON_LENGTH = 200;
|
||||
const MAX_LAST_ERROR_LENGTH = 2_000;
|
||||
|
||||
const asNonEmptyString = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const clampLength = (value, maxLength) => {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
||||
};
|
||||
|
||||
const normalizeStatus = (value) => {
|
||||
if (value === 'running' || value === 'success' || value === 'error' || value === 'idle') {
|
||||
return value;
|
||||
}
|
||||
return 'idle';
|
||||
};
|
||||
|
||||
const normalizeTimeValue = (value) => {
|
||||
const time = asNonEmptyString(value);
|
||||
if (!time) {
|
||||
return null;
|
||||
}
|
||||
if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(time)) {
|
||||
return null;
|
||||
}
|
||||
return time;
|
||||
};
|
||||
|
||||
const normalizeDateValue = (value) => {
|
||||
const date = asNonEmptyString(value);
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = DateTime.fromISO(date, { zone: 'UTC' });
|
||||
if (!parsed.isValid || parsed.toFormat('yyyy-LL-dd') !== date) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
};
|
||||
|
||||
const normalizeWeekdays = (value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unique = new Set();
|
||||
for (const entry of value) {
|
||||
if (!Number.isInteger(entry)) {
|
||||
return null;
|
||||
}
|
||||
if (entry < 0 || entry > 6) {
|
||||
return null;
|
||||
}
|
||||
unique.add(entry);
|
||||
}
|
||||
|
||||
if (unique.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Array.from(unique).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
const resolveScheduleTimes = (value, existingSchedule) => {
|
||||
const times = [];
|
||||
|
||||
if (Array.isArray(value?.times)) {
|
||||
for (const item of value.times) {
|
||||
const normalized = normalizeTimeValue(item);
|
||||
if (!normalized) {
|
||||
throw new Error('schedule.times must contain HH:mm values');
|
||||
}
|
||||
times.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
const legacySingleTime = normalizeTimeValue(value?.time);
|
||||
if (legacySingleTime) {
|
||||
times.push(legacySingleTime);
|
||||
}
|
||||
|
||||
if (times.length === 0 && Array.isArray(existingSchedule?.times)) {
|
||||
for (const item of existingSchedule.times) {
|
||||
const normalized = normalizeTimeValue(item);
|
||||
if (normalized) {
|
||||
times.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueSorted = Array.from(new Set(times)).sort((a, b) => a.localeCompare(b));
|
||||
if (uniqueSorted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return uniqueSorted;
|
||||
};
|
||||
|
||||
const resolveDefaultTimezone = () => {
|
||||
const resolved = DateTime.local().zoneName;
|
||||
if (resolved && IANAZone.isValidZone(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
return 'UTC';
|
||||
};
|
||||
|
||||
const normalizeTimezone = (value, fallback = resolveDefaultTimezone()) => {
|
||||
const timezone = asNonEmptyString(value);
|
||||
if (!timezone) {
|
||||
return fallback;
|
||||
}
|
||||
return IANAZone.isValidZone(timezone) ? timezone : null;
|
||||
};
|
||||
|
||||
const validateCronExpression = (expression, timezone) => {
|
||||
try {
|
||||
const iterator = parser.parseExpression(expression, {
|
||||
tz: timezone,
|
||||
currentDate: new Date(),
|
||||
});
|
||||
iterator.next();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeSchedule = (value, existingSchedule) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('schedule is required');
|
||||
}
|
||||
|
||||
const kind = asNonEmptyString(value.kind);
|
||||
if (kind !== 'daily' && kind !== 'weekly' && kind !== 'once' && kind !== 'cron') {
|
||||
throw new Error('schedule.kind must be daily, weekly, once, or cron');
|
||||
}
|
||||
|
||||
const fallbackTimezone = existingSchedule?.timezone || resolveDefaultTimezone();
|
||||
const timezone = normalizeTimezone(value.timezone, fallbackTimezone);
|
||||
if (!timezone) {
|
||||
throw new Error('schedule.timezone must be a valid IANA timezone');
|
||||
}
|
||||
|
||||
if (kind === 'daily') {
|
||||
const times = resolveScheduleTimes(value, existingSchedule);
|
||||
if (!times) {
|
||||
throw new Error('schedule.times must include at least one HH:mm value for daily schedule');
|
||||
}
|
||||
return { kind, times, timezone };
|
||||
}
|
||||
|
||||
if (kind === 'weekly') {
|
||||
const times = resolveScheduleTimes(value, existingSchedule);
|
||||
if (!times) {
|
||||
throw new Error('schedule.times must include at least one HH:mm value for weekly schedule');
|
||||
}
|
||||
const weekdays = normalizeWeekdays(value.weekdays);
|
||||
if (!weekdays) {
|
||||
throw new Error('schedule.weekdays must include values from 0 to 6 for weekly schedule');
|
||||
}
|
||||
return { kind, times, weekdays, timezone };
|
||||
}
|
||||
|
||||
if (kind === 'once') {
|
||||
const date = normalizeDateValue(value.date);
|
||||
if (!date) {
|
||||
throw new Error('schedule.date must be YYYY-MM-DD for once schedule');
|
||||
}
|
||||
|
||||
const time = normalizeTimeValue(value.time);
|
||||
if (!time) {
|
||||
throw new Error('schedule.time must be HH:mm for once schedule');
|
||||
}
|
||||
|
||||
return { kind, date, time, timezone };
|
||||
}
|
||||
|
||||
const cron = clampLength(asNonEmptyString(value.cron) || '', MAX_CRON_LENGTH);
|
||||
if (!cron) {
|
||||
throw new Error('schedule.cron is required for cron schedule');
|
||||
}
|
||||
|
||||
if (!validateCronExpression(cron, timezone)) {
|
||||
throw new Error('schedule.cron is invalid');
|
||||
}
|
||||
|
||||
return { kind, cron, timezone };
|
||||
};
|
||||
|
||||
const normalizeExecution = (value) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('execution is required');
|
||||
}
|
||||
|
||||
const prompt = clampLength(asNonEmptyString(value.prompt) || '', MAX_TASK_PROMPT_LENGTH);
|
||||
const providerID = asNonEmptyString(value.providerID);
|
||||
const modelID = asNonEmptyString(value.modelID);
|
||||
const variant = asNonEmptyString(value.variant);
|
||||
const agent = asNonEmptyString(value.agent);
|
||||
|
||||
if (!prompt) {
|
||||
throw new Error('execution.prompt is required');
|
||||
}
|
||||
if (!providerID) {
|
||||
throw new Error('execution.providerID is required');
|
||||
}
|
||||
if (!modelID) {
|
||||
throw new Error('execution.modelID is required');
|
||||
}
|
||||
|
||||
return {
|
||||
prompt,
|
||||
providerID,
|
||||
modelID,
|
||||
...(variant ? { variant } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeState = (value, fallback) => {
|
||||
const source = value && typeof value === 'object' ? value : fallback || {};
|
||||
const lastRunAt = typeof source.lastRunAt === 'number' && Number.isFinite(source.lastRunAt)
|
||||
? Math.max(0, Math.round(source.lastRunAt))
|
||||
: undefined;
|
||||
const lastDurationMs = typeof source.lastDurationMs === 'number' && Number.isFinite(source.lastDurationMs)
|
||||
? Math.max(0, Math.round(source.lastDurationMs))
|
||||
: undefined;
|
||||
const nextRunAt = typeof source.nextRunAt === 'number' && Number.isFinite(source.nextRunAt)
|
||||
? Math.max(0, Math.round(source.nextRunAt))
|
||||
: undefined;
|
||||
const lastSessionId = asNonEmptyString(source.lastSessionId);
|
||||
const lastErrorRaw = asNonEmptyString(source.lastError);
|
||||
const lastError = lastErrorRaw ? clampLength(lastErrorRaw, MAX_LAST_ERROR_LENGTH) : undefined;
|
||||
|
||||
return {
|
||||
createdAt: typeof source.createdAt === 'number' && Number.isFinite(source.createdAt)
|
||||
? Math.max(0, Math.round(source.createdAt))
|
||||
: Date.now(),
|
||||
updatedAt: typeof source.updatedAt === 'number' && Number.isFinite(source.updatedAt)
|
||||
? Math.max(0, Math.round(source.updatedAt))
|
||||
: Date.now(),
|
||||
lastStatus: normalizeStatus(source.lastStatus),
|
||||
...(typeof lastRunAt === 'number' ? { lastRunAt } : {}),
|
||||
...(typeof lastDurationMs === 'number' ? { lastDurationMs } : {}),
|
||||
...(typeof nextRunAt === 'number' ? { nextRunAt } : {}),
|
||||
...(lastSessionId ? { lastSessionId } : {}),
|
||||
...(lastError ? { lastError } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeTaskForStorage = (value, options) => {
|
||||
const {
|
||||
now,
|
||||
createId,
|
||||
existingTask,
|
||||
allowCreate,
|
||||
} = options;
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('task is required');
|
||||
}
|
||||
|
||||
const incomingId = asNonEmptyString(value.id);
|
||||
const existingId = asNonEmptyString(existingTask?.id);
|
||||
|
||||
if (existingTask) {
|
||||
if (incomingId && incomingId !== existingId) {
|
||||
throw new Error('task.id is immutable');
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingTask && incomingId && !allowCreate) {
|
||||
throw new Error('task.id does not exist');
|
||||
}
|
||||
|
||||
const id = existingId || incomingId || createId();
|
||||
const name = clampLength(asNonEmptyString(value.name) || '', MAX_TASK_NAME_LENGTH);
|
||||
if (!name) {
|
||||
throw new Error('task.name is required');
|
||||
}
|
||||
|
||||
const enabled = typeof value.enabled === 'boolean'
|
||||
? value.enabled
|
||||
: (existingTask?.enabled ?? true);
|
||||
|
||||
const schedule = normalizeSchedule(value.schedule, existingTask?.schedule);
|
||||
const execution = normalizeExecution(value.execution);
|
||||
|
||||
const nowMs = Math.max(0, Math.round(now));
|
||||
const baseState = normalizeState(value.state, existingTask?.state);
|
||||
const state = {
|
||||
...baseState,
|
||||
createdAt: existingTask?.state?.createdAt ?? baseState.createdAt ?? nowMs,
|
||||
updatedAt: nowMs,
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
enabled,
|
||||
schedule,
|
||||
execution,
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
||||
const createEmptyProjectConfig = () => ({
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: [],
|
||||
});
|
||||
|
||||
export const createProjectConfigRuntime = (deps) => {
|
||||
const {
|
||||
fsPromises,
|
||||
path,
|
||||
projectsDirPath,
|
||||
createTaskID,
|
||||
} = deps;
|
||||
|
||||
const taskIDFactory = typeof createTaskID === 'function'
|
||||
? createTaskID
|
||||
: (() => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `task_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
});
|
||||
|
||||
const writeLocks = new Map();
|
||||
|
||||
const sanitizeProjectID = (projectID) => {
|
||||
const value = asNonEmptyString(projectID);
|
||||
if (!value) {
|
||||
throw new Error('projectId is required');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._:-]+$/.test(value)) {
|
||||
throw new Error('projectId contains unsupported characters');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const resolveProjectConfigPath = (projectID) => {
|
||||
const safeProjectID = sanitizeProjectID(projectID);
|
||||
return path.join(projectsDirPath, `${safeProjectID}.json`);
|
||||
};
|
||||
|
||||
const readProjectConfigFromDisk = async (projectID) => {
|
||||
const filePath = resolveProjectConfigPath(projectID);
|
||||
|
||||
try {
|
||||
const raw = await fsPromises.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return createEmptyProjectConfig();
|
||||
}
|
||||
const tasksRaw = Array.isArray(parsed.scheduledTasks) ? parsed.scheduledTasks : [];
|
||||
const now = Date.now();
|
||||
const scheduledTasks = [];
|
||||
for (const task of tasksRaw) {
|
||||
try {
|
||||
const normalized = normalizeTaskForStorage(task, {
|
||||
now,
|
||||
createId: taskIDFactory,
|
||||
existingTask: null,
|
||||
allowCreate: true,
|
||||
});
|
||||
scheduledTasks.push(normalized);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return createEmptyProjectConfig();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeProjectConfigToDisk = async (projectID, config) => {
|
||||
const filePath = resolveProjectConfigPath(projectID);
|
||||
const parentDirectory = path.dirname(filePath);
|
||||
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
await fsPromises.mkdir(parentDirectory, { recursive: true });
|
||||
await fsPromises.writeFile(temporaryPath, JSON.stringify(config, null, 2), 'utf8');
|
||||
await fsPromises.rename(temporaryPath, filePath);
|
||||
};
|
||||
|
||||
const withProjectWriteLock = async (projectID, mutate) => {
|
||||
const key = sanitizeProjectID(projectID);
|
||||
const previous = writeLocks.get(key) || Promise.resolve();
|
||||
let release;
|
||||
const next = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const chained = previous.finally(() => next);
|
||||
writeLocks.set(key, chained);
|
||||
|
||||
await previous;
|
||||
try {
|
||||
return await mutate();
|
||||
} finally {
|
||||
release();
|
||||
const current = writeLocks.get(key);
|
||||
if (current === chained) {
|
||||
writeLocks.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const listScheduledTasks = async (projectID) => {
|
||||
const config = await readProjectConfigFromDisk(projectID);
|
||||
return config.scheduledTasks;
|
||||
};
|
||||
|
||||
const upsertScheduledTask = async (projectID, taskInput) => {
|
||||
return withProjectWriteLock(projectID, async () => {
|
||||
const now = Date.now();
|
||||
const current = await readProjectConfigFromDisk(projectID);
|
||||
const incomingID = asNonEmptyString(taskInput?.id);
|
||||
const existingIndex = incomingID
|
||||
? current.scheduledTasks.findIndex((task) => task.id === incomingID)
|
||||
: -1;
|
||||
const existingTask = existingIndex >= 0 ? current.scheduledTasks[existingIndex] : null;
|
||||
|
||||
const normalizedTask = normalizeTaskForStorage(taskInput, {
|
||||
now,
|
||||
createId: taskIDFactory,
|
||||
existingTask,
|
||||
allowCreate: true,
|
||||
});
|
||||
|
||||
const nextTasks = current.scheduledTasks.slice();
|
||||
const created = !existingTask;
|
||||
if (existingIndex >= 0) {
|
||||
nextTasks[existingIndex] = normalizedTask;
|
||||
} else {
|
||||
nextTasks.push(normalizedTask);
|
||||
}
|
||||
|
||||
const nextConfig = {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
};
|
||||
await writeProjectConfigToDisk(projectID, nextConfig);
|
||||
|
||||
return {
|
||||
task: normalizedTask,
|
||||
tasks: nextTasks,
|
||||
created,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const deleteScheduledTask = async (projectID, taskID) => {
|
||||
return withProjectWriteLock(projectID, async () => {
|
||||
const normalizedTaskID = asNonEmptyString(taskID);
|
||||
if (!normalizedTaskID) {
|
||||
throw new Error('taskId is required');
|
||||
}
|
||||
|
||||
const current = await readProjectConfigFromDisk(projectID);
|
||||
const nextTasks = current.scheduledTasks.filter((task) => task.id !== normalizedTaskID);
|
||||
const deleted = nextTasks.length !== current.scheduledTasks.length;
|
||||
|
||||
if (deleted) {
|
||||
await writeProjectConfigToDisk(projectID, {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
deleted,
|
||||
tasks: nextTasks,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateScheduledTaskState = async (projectID, taskID, statePatch) => {
|
||||
return withProjectWriteLock(projectID, async () => {
|
||||
const normalizedTaskID = asNonEmptyString(taskID);
|
||||
if (!normalizedTaskID) {
|
||||
throw new Error('taskId is required');
|
||||
}
|
||||
|
||||
const current = await readProjectConfigFromDisk(projectID);
|
||||
const taskIndex = current.scheduledTasks.findIndex((task) => task.id === normalizedTaskID);
|
||||
if (taskIndex === -1) {
|
||||
return { task: null, tasks: current.scheduledTasks };
|
||||
}
|
||||
|
||||
const currentTask = current.scheduledTasks[taskIndex];
|
||||
const patchObject = statePatch && typeof statePatch === 'object' ? statePatch : {};
|
||||
const nextTask = {
|
||||
...currentTask,
|
||||
state: normalizeState(
|
||||
{
|
||||
...currentTask.state,
|
||||
...patchObject,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
currentTask.state,
|
||||
),
|
||||
};
|
||||
|
||||
const nextTasks = current.scheduledTasks.slice();
|
||||
nextTasks[taskIndex] = nextTask;
|
||||
|
||||
await writeProjectConfigToDisk(projectID, {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
});
|
||||
|
||||
return {
|
||||
task: nextTask,
|
||||
tasks: nextTasks,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
listScheduledTasks,
|
||||
upsertScheduledTask,
|
||||
deleteScheduledTask,
|
||||
updateScheduledTaskState,
|
||||
resolveProjectConfigPath,
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
MAX_TASK_NAME_LENGTH,
|
||||
MAX_TASK_PROMPT_LENGTH,
|
||||
MAX_CRON_LENGTH,
|
||||
MAX_LAST_ERROR_LENGTH,
|
||||
normalizeTaskForStorage,
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdtemp, rm } from 'fs/promises';
|
||||
import { createProjectConfigRuntime } from './project-config.js';
|
||||
|
||||
const createRuntime = async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-scheduled-project-config-'));
|
||||
const runtime = createProjectConfigRuntime({
|
||||
fsPromises: await import('fs/promises'),
|
||||
path,
|
||||
projectsDirPath: tempRoot,
|
||||
createTaskID: () => 'task-fixed-id',
|
||||
});
|
||||
return {
|
||||
runtime,
|
||||
cleanup: async () => {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('project-config runtime', () => {
|
||||
it('creates and persists a scheduled task', async () => {
|
||||
const { runtime, cleanup } = await createRuntime();
|
||||
try {
|
||||
const result = await runtime.upsertScheduledTask('project-test', {
|
||||
name: 'Nightly digest',
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: 'daily',
|
||||
time: '09:30',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
execution: {
|
||||
prompt: 'Summarize repository changes',
|
||||
providerID: 'openai',
|
||||
modelID: 'gpt-4.1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.task.id).toBe('task-fixed-id');
|
||||
const reloaded = await runtime.listScheduledTasks('project-test');
|
||||
expect(reloaded).toHaveLength(1);
|
||||
expect(reloaded[0].name).toBe('Nightly digest');
|
||||
expect(reloaded[0].schedule.timezone).toBe('UTC');
|
||||
expect(reloaded[0].schedule.times).toEqual(['09:30']);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid cron expressions', async () => {
|
||||
const { runtime, cleanup } = await createRuntime();
|
||||
try {
|
||||
await expect(runtime.upsertScheduledTask('project-test', {
|
||||
name: 'Invalid cron task',
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: 'cron',
|
||||
cron: 'invalid cron',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
execution: {
|
||||
prompt: 'Run checks',
|
||||
providerID: 'openai',
|
||||
modelID: 'gpt-4.1',
|
||||
},
|
||||
})).rejects.toThrow('schedule.cron is invalid');
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts one-time schedule with date and time', async () => {
|
||||
const { runtime, cleanup } = await createRuntime();
|
||||
try {
|
||||
const result = await runtime.upsertScheduledTask('project-test', {
|
||||
name: 'One-time review',
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: 'once',
|
||||
date: '2026-04-20',
|
||||
time: '13:45',
|
||||
timezone: 'Europe/Kyiv',
|
||||
},
|
||||
execution: {
|
||||
prompt: 'Create a release summary',
|
||||
providerID: 'openai',
|
||||
modelID: 'gpt-4.1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.task.schedule.kind).toBe('once');
|
||||
expect(result.task.schedule.date).toBe('2026-04-20');
|
||||
expect(result.task.schedule.time).toBe('13:45');
|
||||
expect(result.task.schedule.timezone).toBe('Europe/Kyiv');
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
# Scheduled Tasks module
|
||||
|
||||
Server-owned scheduled task runtime and routes for OpenChamber-only automation.
|
||||
|
||||
## Scope
|
||||
|
||||
- Per-project scheduled task persistence is owned by `packages/web/server/lib/projects/project-config.js`.
|
||||
- Runtime orchestration and execution is owned by this module.
|
||||
- This module is OpenChamber feature logic; it is intentionally separate from OpenCode proxy/runtime internals.
|
||||
|
||||
## Files
|
||||
|
||||
- `packages/web/server/lib/scheduled-tasks/runtime.js`
|
||||
- Next-run computation (daily/weekly/cron compatibility)
|
||||
- Timer scheduling and queueing
|
||||
- Concurrency controls
|
||||
- Session create + prompt_async execution
|
||||
- Emits OpenChamber task-run events
|
||||
|
||||
- `packages/web/server/lib/scheduled-tasks/routes.js`
|
||||
- Scheduled task CRUD endpoints
|
||||
- Manual run endpoint
|
||||
- OpenChamber events SSE stream endpoint
|
||||
|
||||
## Public exports (runtime.js)
|
||||
|
||||
- `createScheduledTasksRuntime(dependencies)`
|
||||
- Returned API:
|
||||
- `start()`
|
||||
- `stop()`
|
||||
- `syncAllProjects()`
|
||||
- `syncProject(projectId)`
|
||||
- `runNow(projectId, taskId)`
|
||||
|
||||
## Public exports (routes.js)
|
||||
|
||||
- `registerScheduledTaskRoutes(app, dependencies)`
|
||||
- Registers:
|
||||
- `GET /api/projects/:projectId/scheduled-tasks`
|
||||
- `PUT /api/projects/:projectId/scheduled-tasks`
|
||||
- `DELETE /api/projects/:projectId/scheduled-tasks/:taskId`
|
||||
- `POST /api/projects/:projectId/scheduled-tasks/:taskId/run`
|
||||
- `GET /api/openchamber/scheduled-tasks/status`
|
||||
- `GET /api/openchamber/events`
|
||||
@@ -0,0 +1,215 @@
|
||||
const asNonEmptyString = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const parseProjectID = (req) => asNonEmptyString(req?.params?.projectId);
|
||||
const parseTaskID = (req) => asNonEmptyString(req?.params?.taskId);
|
||||
|
||||
export const registerScheduledTaskRoutes = (app, dependencies) => {
|
||||
const {
|
||||
readSettingsFromDiskMigrated,
|
||||
sanitizeProjects,
|
||||
projectConfigRuntime,
|
||||
scheduledTasksRuntime,
|
||||
getOpenChamberEventClients,
|
||||
writeSseEvent,
|
||||
} = dependencies;
|
||||
|
||||
const findProjectByID = async (projectID) => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const projects = sanitizeProjects(settings?.projects || []);
|
||||
return projects.find((project) => project.id === projectID) || null;
|
||||
};
|
||||
|
||||
app.get('/api/projects/:projectId/scheduled-tasks', async (req, res) => {
|
||||
const projectID = parseProjectID(req);
|
||||
if (!projectID) {
|
||||
return res.status(400).json({ error: 'projectId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await findProjectByID(projectID);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const tasks = await projectConfigRuntime.listScheduledTasks(projectID);
|
||||
return res.json({ tasks });
|
||||
} catch (error) {
|
||||
console.error('[ScheduledTasks] failed to load tasks:', error);
|
||||
return res.status(500).json({ error: 'Failed to load scheduled tasks' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/projects/:projectId/scheduled-tasks', async (req, res) => {
|
||||
const projectID = parseProjectID(req);
|
||||
if (!projectID) {
|
||||
return res.status(400).json({ error: 'projectId is required' });
|
||||
}
|
||||
|
||||
const taskInput = req.body && typeof req.body === 'object' ? req.body.task : null;
|
||||
if (!taskInput || typeof taskInput !== 'object') {
|
||||
return res.status(400).json({ error: 'task payload is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await findProjectByID(projectID);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const upserted = await projectConfigRuntime.upsertScheduledTask(projectID, taskInput);
|
||||
await scheduledTasksRuntime.syncProject(projectID);
|
||||
const freshTasks = await projectConfigRuntime.listScheduledTasks(projectID);
|
||||
const freshTask = freshTasks.find((task) => task.id === upserted.task.id) || upserted.task;
|
||||
|
||||
return res.json({
|
||||
tasks: freshTasks,
|
||||
task: freshTask,
|
||||
created: upserted.created,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to save scheduled task';
|
||||
const statusCode = message.toLowerCase().includes('required') || message.toLowerCase().includes('invalid')
|
||||
? 400
|
||||
: 500;
|
||||
if (statusCode === 500) {
|
||||
console.error('[ScheduledTasks] failed to save task:', error);
|
||||
}
|
||||
return res.status(statusCode).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:projectId/scheduled-tasks/:taskId', async (req, res) => {
|
||||
const projectID = parseProjectID(req);
|
||||
const taskID = parseTaskID(req);
|
||||
if (!projectID) {
|
||||
return res.status(400).json({ error: 'projectId is required' });
|
||||
}
|
||||
if (!taskID) {
|
||||
return res.status(400).json({ error: 'taskId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await findProjectByID(projectID);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const result = await projectConfigRuntime.deleteScheduledTask(projectID, taskID);
|
||||
if (!result.deleted) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
await scheduledTasksRuntime.syncProject(projectID);
|
||||
const freshTasks = await projectConfigRuntime.listScheduledTasks(projectID);
|
||||
return res.json({ tasks: freshTasks });
|
||||
} catch (error) {
|
||||
console.error('[ScheduledTasks] failed to delete task:', error);
|
||||
return res.status(500).json({ error: 'Failed to delete scheduled task' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects/:projectId/scheduled-tasks/:taskId/run', async (req, res) => {
|
||||
const projectID = parseProjectID(req);
|
||||
const taskID = parseTaskID(req);
|
||||
if (!projectID) {
|
||||
return res.status(400).json({ error: 'projectId is required' });
|
||||
}
|
||||
if (!taskID) {
|
||||
return res.status(400).json({ error: 'taskId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await findProjectByID(projectID);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const result = await scheduledTasksRuntime.runNow(projectID, taskID);
|
||||
if (result.running || result.queued) {
|
||||
return res.status(409).json({ error: result.error || 'Task already running' });
|
||||
}
|
||||
if (result.skipped) {
|
||||
return res.status(404).json({ error: 'Task not found or disabled' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
return res.status(500).json({
|
||||
error: result.error || 'Task run failed',
|
||||
task: result.task,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
task: result.task,
|
||||
sessionId: result.sessionID,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ScheduledTasks] failed to run task:', error);
|
||||
return res.status(500).json({ error: 'Failed to run scheduled task' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/openchamber/scheduled-tasks/status', async (_req, res) => {
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const projects = sanitizeProjects(settings?.projects || []);
|
||||
|
||||
let enabledCount = 0;
|
||||
let runningCount = 0;
|
||||
|
||||
for (const project of projects) {
|
||||
try {
|
||||
const tasks = await projectConfigRuntime.listScheduledTasks(project.id);
|
||||
for (const task of tasks) {
|
||||
if (task?.enabled) {
|
||||
enabledCount += 1;
|
||||
}
|
||||
if (task?.state?.lastStatus === 'running') {
|
||||
runningCount += 1;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
hasEnabledScheduledTasks: enabledCount > 0,
|
||||
hasRunningScheduledTasks: runningCount > 0,
|
||||
enabledScheduledTasksCount: enabledCount,
|
||||
runningScheduledTasksCount: runningCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ScheduledTasks] failed to resolve scheduled task status:', error);
|
||||
return res.status(500).json({ error: 'Failed to resolve scheduled task status' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/openchamber/events', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders?.();
|
||||
|
||||
const clients = getOpenChamberEventClients();
|
||||
clients.add(res);
|
||||
|
||||
try {
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:event-stream-ready',
|
||||
properties: {
|
||||
connectedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
req.on('close', () => {
|
||||
clients.delete(res);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,749 @@
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
import { DateTime } from 'luxon';
|
||||
import parser from 'cron-parser';
|
||||
|
||||
const DEFAULT_GLOBAL_CONCURRENCY = 4;
|
||||
const DEFAULT_PROJECT_CONCURRENCY = 2;
|
||||
const DEFAULT_MAX_RUN_MS = 30 * 60 * 1000;
|
||||
const JITTER_MAX_MS = 2_000;
|
||||
const TASK_TITLE_MAX_LENGTH = 120;
|
||||
const TASK_DUE_SLACK_MS = 5_000;
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
||||
|
||||
const buildTaskKey = (projectID, taskID) => `${projectID}:${taskID}`;
|
||||
|
||||
const parseTimeParts = (time) => {
|
||||
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(typeof time === 'string' ? time : '');
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
hour: Number(match[1]),
|
||||
minute: Number(match[2]),
|
||||
};
|
||||
};
|
||||
|
||||
const applyTimeToDate = (baseDateTime, time) => {
|
||||
const parsed = parseTimeParts(time);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return baseDateTime.set({
|
||||
hour: parsed.hour,
|
||||
minute: parsed.minute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const resolveScheduleTimes = (schedule) => {
|
||||
const times = [];
|
||||
if (Array.isArray(schedule?.times)) {
|
||||
for (const candidate of schedule.times) {
|
||||
if (typeof candidate === 'string' && /^([01]\d|2[0-3]):([0-5]\d)$/.test(candidate)) {
|
||||
times.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (times.length === 0 && typeof schedule?.time === 'string' && /^([01]\d|2[0-3]):([0-5]\d)$/.test(schedule.time)) {
|
||||
times.push(schedule.time);
|
||||
}
|
||||
return Array.from(new Set(times)).sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const weekdayAsZeroBased = (dateTime) => {
|
||||
if (!dateTime || typeof dateTime.weekday !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return dateTime.weekday % 7;
|
||||
};
|
||||
|
||||
const safeErrorMessage = (error, maxLength = 2_000) => {
|
||||
const raw = error instanceof Error
|
||||
? (error.message || String(error))
|
||||
: String(error ?? 'Unknown error');
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return 'Unknown error';
|
||||
}
|
||||
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
|
||||
};
|
||||
|
||||
export const parseScheduledCommandPrompt = (prompt) => {
|
||||
if (typeof prompt !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstLine = trimmed.split(/\r?\n/, 1)[0] || '';
|
||||
const [head, ...tail] = firstLine.split(/\s+/);
|
||||
const commandName = (head || '').slice(1).trim();
|
||||
if (!commandName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
command: commandName,
|
||||
arguments: tail.join(' ').trim(),
|
||||
};
|
||||
};
|
||||
|
||||
export const computeNextRunAt = (task, nowMs = Date.now()) => {
|
||||
if (!task?.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const schedule = task.schedule;
|
||||
if (!schedule || typeof schedule !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const zone = typeof schedule.timezone === 'string' && schedule.timezone.trim().length > 0
|
||||
? schedule.timezone.trim()
|
||||
: DateTime.local().zoneName;
|
||||
|
||||
const now = DateTime.fromMillis(nowMs, { zone });
|
||||
if (!now.isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (schedule.kind === 'daily') {
|
||||
const times = resolveScheduleTimes(schedule);
|
||||
if (times.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const minAllowed = now.plus({ milliseconds: TASK_DUE_SLACK_MS });
|
||||
|
||||
for (const time of times) {
|
||||
const candidateToday = applyTimeToDate(now, time);
|
||||
if (!candidateToday || !candidateToday.isValid) {
|
||||
continue;
|
||||
}
|
||||
if (candidateToday > minAllowed) {
|
||||
return candidateToday.toMillis();
|
||||
}
|
||||
}
|
||||
|
||||
const tomorrow = now.plus({ days: 1 });
|
||||
const firstTomorrow = applyTimeToDate(tomorrow, times[0]);
|
||||
return firstTomorrow?.isValid ? firstTomorrow.toMillis() : null;
|
||||
}
|
||||
|
||||
if (schedule.kind === 'weekly') {
|
||||
if (!Array.isArray(schedule.weekdays) || schedule.weekdays.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const times = resolveScheduleTimes(schedule);
|
||||
if (times.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const weekdaysSet = new Set(schedule.weekdays);
|
||||
const minAllowed = now.plus({ milliseconds: TASK_DUE_SLACK_MS });
|
||||
|
||||
for (let dayOffset = 0; dayOffset <= 14; dayOffset += 1) {
|
||||
const dayCandidate = now.plus({ days: dayOffset });
|
||||
const zeroBasedWeekday = weekdayAsZeroBased(dayCandidate);
|
||||
if (zeroBasedWeekday === null || !weekdaysSet.has(zeroBasedWeekday)) {
|
||||
continue;
|
||||
}
|
||||
for (const time of times) {
|
||||
const withTime = applyTimeToDate(dayCandidate, time);
|
||||
if (!withTime || !withTime.isValid) {
|
||||
continue;
|
||||
}
|
||||
if (withTime > minAllowed) {
|
||||
return withTime.toMillis();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (schedule.kind === 'once') {
|
||||
if (typeof schedule.date !== 'string' || typeof schedule.time !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = DateTime.fromFormat(
|
||||
`${schedule.date} ${schedule.time}`,
|
||||
'yyyy-LL-dd HH:mm',
|
||||
{ zone },
|
||||
);
|
||||
if (!parsed.isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minAllowed = now.plus({ milliseconds: TASK_DUE_SLACK_MS });
|
||||
if (parsed <= minAllowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.toMillis();
|
||||
}
|
||||
|
||||
if (schedule.kind === 'cron') {
|
||||
try {
|
||||
const iterator = parser.parseExpression(schedule.cron, {
|
||||
tz: zone,
|
||||
currentDate: new Date(nowMs),
|
||||
});
|
||||
return iterator.next().getTime();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const formatScheduledSessionTitle = (task, nowMs = Date.now()) => {
|
||||
const timezone = typeof task?.schedule?.timezone === 'string' && task.schedule.timezone.trim().length > 0
|
||||
? task.schedule.timezone.trim()
|
||||
: DateTime.local().zoneName;
|
||||
const stamp = DateTime.fromMillis(nowMs, { zone: timezone }).toFormat('yyyy-LL-dd HH:mm');
|
||||
const taskName = typeof task?.name === 'string' && task.name.trim().length > 0
|
||||
? task.name.trim()
|
||||
: 'Scheduled task';
|
||||
const suffix = ` ${stamp}`;
|
||||
const maxTaskNameLength = Math.max(1, TASK_TITLE_MAX_LENGTH - suffix.length);
|
||||
const trimmedName = taskName.length > maxTaskNameLength
|
||||
? taskName.slice(0, maxTaskNameLength)
|
||||
: taskName;
|
||||
return `${trimmedName}${suffix}`;
|
||||
};
|
||||
|
||||
export const createScheduledTasksRuntime = (deps) => {
|
||||
const {
|
||||
projectConfigRuntime,
|
||||
listProjects,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
waitForOpenCodeReady,
|
||||
emitTaskRunEvent,
|
||||
logger = console,
|
||||
maxGlobalConcurrency = DEFAULT_GLOBAL_CONCURRENCY,
|
||||
maxProjectConcurrency = DEFAULT_PROJECT_CONCURRENCY,
|
||||
maxRunDurationMs = DEFAULT_MAX_RUN_MS,
|
||||
} = deps;
|
||||
|
||||
let started = false;
|
||||
const tasksByProject = new Map();
|
||||
const projectPathByID = new Map();
|
||||
const timersByTaskKey = new Map();
|
||||
const queuedTaskKeys = new Set();
|
||||
const runningTaskKeys = new Set();
|
||||
const runningCountByProject = new Map();
|
||||
let runningGlobalCount = 0;
|
||||
const queue = [];
|
||||
|
||||
const clearTimerForKey = (taskKey) => {
|
||||
const timer = timersByTaskKey.get(taskKey);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timersByTaskKey.delete(taskKey);
|
||||
}
|
||||
};
|
||||
|
||||
const clearProjectTimers = (projectID) => {
|
||||
const tasks = tasksByProject.get(projectID);
|
||||
if (!tasks) {
|
||||
return;
|
||||
}
|
||||
for (const task of tasks.values()) {
|
||||
clearTimerForKey(buildTaskKey(projectID, task.id));
|
||||
queuedTaskKeys.delete(buildTaskKey(projectID, task.id));
|
||||
}
|
||||
};
|
||||
|
||||
const setProjectTasks = (projectID, tasks) => {
|
||||
clearProjectTimers(projectID);
|
||||
const taskMap = new Map();
|
||||
for (const task of tasks) {
|
||||
taskMap.set(task.id, task);
|
||||
}
|
||||
tasksByProject.set(projectID, taskMap);
|
||||
};
|
||||
|
||||
const scheduleTask = (projectID, taskID, nextRunAt) => {
|
||||
const taskKey = buildTaskKey(projectID, taskID);
|
||||
clearTimerForKey(taskKey);
|
||||
|
||||
if (!Number.isFinite(nextRunAt) || nextRunAt <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delayBase = Math.max(0, Math.round(nextRunAt - Date.now()));
|
||||
const jitter = Math.floor(Math.random() * (JITTER_MAX_MS + 1));
|
||||
const delay = delayBase + jitter;
|
||||
const boundedDelay = Math.min(delay, MAX_TIMER_DELAY_MS);
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
if (delay > MAX_TIMER_DELAY_MS) {
|
||||
scheduleTask(projectID, taskID, nextRunAt);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimerForKey(taskKey);
|
||||
const taskMap = tasksByProject.get(projectID);
|
||||
const task = taskMap?.get(taskID);
|
||||
if (!task || !task.enabled) {
|
||||
return;
|
||||
}
|
||||
queueTaskRun(projectID, taskID, 'scheduled');
|
||||
pumpQueue();
|
||||
}, boundedDelay);
|
||||
|
||||
timersByTaskKey.set(taskKey, timer);
|
||||
};
|
||||
|
||||
const updateInMemoryTask = (projectID, nextTask) => {
|
||||
if (!nextTask) {
|
||||
return;
|
||||
}
|
||||
const taskMap = tasksByProject.get(projectID);
|
||||
if (!taskMap) {
|
||||
return;
|
||||
}
|
||||
taskMap.set(nextTask.id, nextTask);
|
||||
};
|
||||
|
||||
const syncTaskSchedule = async (projectID, task) => {
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
const nextRunAt = computeNextRunAt(task, Date.now());
|
||||
const statePatch = {
|
||||
nextRunAt: Number.isFinite(nextRunAt) ? nextRunAt : undefined,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const result = await projectConfigRuntime.updateScheduledTaskState(projectID, task.id, statePatch);
|
||||
if (result.task) {
|
||||
updateInMemoryTask(projectID, result.task);
|
||||
if (result.task.enabled && Number.isFinite(result.task.state?.nextRunAt)) {
|
||||
scheduleTask(projectID, result.task.id, result.task.state.nextRunAt);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureProjectPath = async (projectID) => {
|
||||
if (projectPathByID.has(projectID)) {
|
||||
return projectPathByID.get(projectID) || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await listProjects();
|
||||
const project = projects.find((item) => item?.id === projectID && item?.path);
|
||||
if (project?.path) {
|
||||
projectPathByID.set(projectID, project.path);
|
||||
return project.path;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const syncProject = async (projectID) => {
|
||||
await ensureProjectPath(projectID);
|
||||
|
||||
const tasks = await projectConfigRuntime.listScheduledTasks(projectID);
|
||||
setProjectTasks(projectID, tasks);
|
||||
|
||||
for (const task of tasks) {
|
||||
await syncTaskSchedule(projectID, task);
|
||||
}
|
||||
|
||||
return tasks;
|
||||
};
|
||||
|
||||
const syncAllProjects = async () => {
|
||||
const projects = await listProjects();
|
||||
const activeProjectIDs = new Set();
|
||||
projectPathByID.clear();
|
||||
for (const project of projects) {
|
||||
if (!project?.id || !project?.path) {
|
||||
continue;
|
||||
}
|
||||
activeProjectIDs.add(project.id);
|
||||
projectPathByID.set(project.id, project.path);
|
||||
}
|
||||
|
||||
for (const existingProjectID of Array.from(tasksByProject.keys())) {
|
||||
if (!activeProjectIDs.has(existingProjectID)) {
|
||||
clearProjectTimers(existingProjectID);
|
||||
tasksByProject.delete(existingProjectID);
|
||||
}
|
||||
}
|
||||
|
||||
for (const projectID of activeProjectIDs) {
|
||||
await syncProject(projectID);
|
||||
}
|
||||
};
|
||||
|
||||
const queueTaskRun = (projectID, taskID, reason) => {
|
||||
const taskKey = buildTaskKey(projectID, taskID);
|
||||
if (queuedTaskKeys.has(taskKey) || runningTaskKeys.has(taskKey)) {
|
||||
return;
|
||||
}
|
||||
queuedTaskKeys.add(taskKey);
|
||||
queue.push({ projectID, taskID, reason });
|
||||
};
|
||||
|
||||
const canRunTask = (projectID) => {
|
||||
if (runningGlobalCount >= maxGlobalConcurrency) {
|
||||
return false;
|
||||
}
|
||||
const projectRunning = runningCountByProject.get(projectID) || 0;
|
||||
return projectRunning < maxProjectConcurrency;
|
||||
};
|
||||
|
||||
const buildPromptAsyncPayload = (task) => ({
|
||||
model: {
|
||||
providerID: task.execution.providerID,
|
||||
modelID: task.execution.modelID,
|
||||
},
|
||||
...(task.execution.agent ? { agent: task.execution.agent } : {}),
|
||||
...(task.execution.variant ? { variant: task.execution.variant } : {}),
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: task.execution.prompt,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const runPromptAsync = async ({ baseUrl, authHeaders, sessionID, projectPath, task }) => {
|
||||
const promptUrl = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}/prompt_async`);
|
||||
promptUrl.searchParams.set('directory', projectPath);
|
||||
const response = await fetch(promptUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authHeaders,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(buildPromptAsyncPayload(task)),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`prompt_async failed (${response.status})${body ? `: ${body}` : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
const runScheduledCommandIfApplicable = async ({ client, projectPath, sessionID, task }) => {
|
||||
const parsed = parseScheduledCommandPrompt(task?.execution?.prompt);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let commands = [];
|
||||
try {
|
||||
const response = await client.command.list({ directory: projectPath });
|
||||
commands = Array.isArray(response?.data) ? response.data : [];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasMatchingCommand = commands.some((command) => command?.name === parsed.command);
|
||||
if (!hasMatchingCommand) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await client.session.command({
|
||||
sessionID,
|
||||
directory: projectPath,
|
||||
command: parsed.command,
|
||||
arguments: parsed.arguments,
|
||||
...(task.execution.agent ? { agent: task.execution.agent } : {}),
|
||||
model: `${task.execution.providerID}/${task.execution.modelID}`,
|
||||
...(task.execution.variant ? { variant: task.execution.variant } : {}),
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const runTaskWithWatchdog = async (projectID, task, reason) => {
|
||||
const startedAt = Date.now();
|
||||
const title = formatScheduledSessionTitle(task, startedAt);
|
||||
const projectPath = projectPathByID.get(projectID);
|
||||
if (!projectPath) {
|
||||
throw new Error('project path is unavailable');
|
||||
}
|
||||
|
||||
if (typeof waitForOpenCodeReady === 'function') {
|
||||
await waitForOpenCodeReady(10_000, 250);
|
||||
}
|
||||
|
||||
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
const client = createOpencodeClient({
|
||||
baseUrl,
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
const sessionResponse = await client.session.create({
|
||||
directory: projectPath,
|
||||
title,
|
||||
});
|
||||
const sessionID = sessionResponse?.data?.id;
|
||||
if (!sessionID) {
|
||||
throw new Error('failed to create session');
|
||||
}
|
||||
|
||||
try {
|
||||
emitTaskRunEvent?.({
|
||||
projectID,
|
||||
taskID: task.id,
|
||||
ranAt: startedAt,
|
||||
status: 'running',
|
||||
sessionID,
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
const executedAsCommand = await runScheduledCommandIfApplicable({
|
||||
client,
|
||||
projectPath,
|
||||
sessionID,
|
||||
task,
|
||||
});
|
||||
if (!executedAsCommand) {
|
||||
await runPromptAsync({
|
||||
baseUrl,
|
||||
authHeaders,
|
||||
sessionID,
|
||||
projectPath,
|
||||
task,
|
||||
});
|
||||
}
|
||||
|
||||
const finishedAt = Date.now();
|
||||
return {
|
||||
sessionID,
|
||||
durationMs: Math.max(0, finishedAt - startedAt),
|
||||
reason,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
};
|
||||
};
|
||||
|
||||
const runTask = async (projectID, taskID, reason) => {
|
||||
const taskMap = tasksByProject.get(projectID);
|
||||
const task = taskMap?.get(taskID);
|
||||
if (!task || !task.enabled) {
|
||||
return { ok: false, skipped: true };
|
||||
}
|
||||
|
||||
const taskKey = buildTaskKey(projectID, taskID);
|
||||
if (runningTaskKeys.has(taskKey)) {
|
||||
return { ok: false, running: true };
|
||||
}
|
||||
|
||||
runningTaskKeys.add(taskKey);
|
||||
runningGlobalCount += 1;
|
||||
runningCountByProject.set(projectID, (runningCountByProject.get(projectID) || 0) + 1);
|
||||
|
||||
const runStartedAt = Date.now();
|
||||
await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, {
|
||||
lastRunAt: runStartedAt,
|
||||
lastStatus: 'running',
|
||||
lastError: undefined,
|
||||
updatedAt: runStartedAt,
|
||||
}).then((result) => {
|
||||
if (result.task) {
|
||||
updateInMemoryTask(projectID, result.task);
|
||||
}
|
||||
});
|
||||
|
||||
let status = 'success';
|
||||
let sessionID;
|
||||
let durationMs = 0;
|
||||
let errorMessage;
|
||||
|
||||
try {
|
||||
const runPromise = runTaskWithWatchdog(projectID, task, reason);
|
||||
let timeoutID;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeoutID = setTimeout(() => {
|
||||
reject(new Error('scheduled task run timed out'));
|
||||
}, maxRunDurationMs);
|
||||
});
|
||||
|
||||
const result = await Promise.race([runPromise, timeoutPromise]).finally(() => {
|
||||
if (timeoutID) {
|
||||
clearTimeout(timeoutID);
|
||||
}
|
||||
});
|
||||
sessionID = result.sessionID;
|
||||
durationMs = result.durationMs;
|
||||
status = 'success';
|
||||
logger.info?.(
|
||||
'[ScheduledTasks] run completed',
|
||||
{ projectID, taskID, status, reason, sessionID, durationMs }
|
||||
);
|
||||
} catch (error) {
|
||||
status = 'error';
|
||||
errorMessage = safeErrorMessage(error);
|
||||
logger.warn?.('[ScheduledTasks] run failed', {
|
||||
projectID,
|
||||
taskID,
|
||||
reason,
|
||||
status,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
const finishedAt = Date.now();
|
||||
if (!durationMs) {
|
||||
durationMs = Math.max(0, finishedAt - runStartedAt);
|
||||
}
|
||||
let latestTask = (tasksByProject.get(projectID)?.get(taskID)) || task;
|
||||
const shouldConsumeOneTimeTask = latestTask?.schedule?.kind === 'once' && reason === 'scheduled';
|
||||
if (shouldConsumeOneTimeTask && latestTask?.enabled) {
|
||||
try {
|
||||
const consumed = await projectConfigRuntime.upsertScheduledTask(projectID, {
|
||||
...latestTask,
|
||||
enabled: false,
|
||||
});
|
||||
latestTask = consumed.task || latestTask;
|
||||
updateInMemoryTask(projectID, latestTask);
|
||||
} catch (consumeError) {
|
||||
logger.warn?.('[ScheduledTasks] failed to consume one-time task', {
|
||||
projectID,
|
||||
taskID,
|
||||
error: safeErrorMessage(consumeError),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nextRunAt = computeNextRunAt(latestTask, finishedAt);
|
||||
|
||||
const statePatch = {
|
||||
lastStatus: status,
|
||||
lastDurationMs: durationMs,
|
||||
lastError: status === 'error' ? errorMessage : undefined,
|
||||
lastSessionId: status === 'success' ? sessionID : undefined,
|
||||
nextRunAt: Number.isFinite(nextRunAt) ? nextRunAt : undefined,
|
||||
updatedAt: finishedAt,
|
||||
};
|
||||
|
||||
const stateResult = await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, statePatch);
|
||||
if (stateResult.task) {
|
||||
updateInMemoryTask(projectID, stateResult.task);
|
||||
if (stateResult.task.enabled && Number.isFinite(stateResult.task.state?.nextRunAt)) {
|
||||
scheduleTask(projectID, taskID, stateResult.task.state.nextRunAt);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
emitTaskRunEvent?.({
|
||||
projectID,
|
||||
taskID,
|
||||
ranAt: finishedAt,
|
||||
status,
|
||||
...(sessionID ? { sessionID } : {}),
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
runningTaskKeys.delete(taskKey);
|
||||
runningGlobalCount = Math.max(0, runningGlobalCount - 1);
|
||||
const nextProjectCount = Math.max(0, (runningCountByProject.get(projectID) || 1) - 1);
|
||||
if (nextProjectCount === 0) {
|
||||
runningCountByProject.delete(projectID);
|
||||
} else {
|
||||
runningCountByProject.set(projectID, nextProjectCount);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: status === 'success',
|
||||
status,
|
||||
sessionID,
|
||||
task: stateResult.task || null,
|
||||
error: errorMessage,
|
||||
};
|
||||
};
|
||||
|
||||
const pumpQueue = () => {
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
|
||||
let consumed = false;
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const item = queue[index];
|
||||
if (!canRunTask(item.projectID)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
queue.splice(index, 1);
|
||||
index -= 1;
|
||||
|
||||
const taskKey = buildTaskKey(item.projectID, item.taskID);
|
||||
queuedTaskKeys.delete(taskKey);
|
||||
consumed = true;
|
||||
|
||||
void runTask(item.projectID, item.taskID, item.reason).finally(() => {
|
||||
pumpQueue();
|
||||
});
|
||||
}
|
||||
|
||||
if (!consumed && queue.length > 0) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const runNow = async (projectID, taskID) => {
|
||||
const taskKey = buildTaskKey(projectID, taskID);
|
||||
if (runningTaskKeys.has(taskKey)) {
|
||||
return {
|
||||
ok: false,
|
||||
running: true,
|
||||
error: 'task is already running',
|
||||
};
|
||||
}
|
||||
if (queuedTaskKeys.has(taskKey)) {
|
||||
return {
|
||||
ok: false,
|
||||
queued: true,
|
||||
error: 'task is already queued',
|
||||
};
|
||||
}
|
||||
|
||||
return runTask(projectID, taskID, 'manual');
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
await syncAllProjects();
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
started = false;
|
||||
for (const timer of timersByTaskKey.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timersByTaskKey.clear();
|
||||
queuedTaskKeys.clear();
|
||||
queue.length = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
syncAllProjects,
|
||||
syncProject,
|
||||
runNow,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { computeNextRunAt, formatScheduledSessionTitle, parseScheduledCommandPrompt } from './runtime.js';
|
||||
|
||||
describe('scheduled-tasks runtime helpers', () => {
|
||||
it('computes next daily run in timezone', () => {
|
||||
const nowUtc = Date.UTC(2025, 0, 1, 8, 0, 0);
|
||||
const next = computeNextRunAt({
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: 'daily',
|
||||
times: ['09:30'],
|
||||
timezone: 'UTC',
|
||||
},
|
||||
}, nowUtc);
|
||||
|
||||
expect(next).toBe(Date.UTC(2025, 0, 1, 9, 30, 0));
|
||||
});
|
||||
|
||||
it('computes weekly next run using weekdays', () => {
|
||||
// Monday 2025-01-06 10:00:00 UTC
|
||||
const nowUtc = Date.UTC(2025, 0, 6, 10, 0, 0);
|
||||
const next = computeNextRunAt({
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: 'weekly',
|
||||
times: ['09:00'],
|
||||
weekdays: [1, 3],
|
||||
timezone: 'UTC',
|
||||
},
|
||||
}, nowUtc);
|
||||
|
||||
// Wednesday 2025-01-08 09:00:00 UTC
|
||||
expect(next).toBe(Date.UTC(2025, 0, 8, 9, 0, 0));
|
||||
});
|
||||
|
||||
it('picks nearest time from multiple daily times', () => {
|
||||
const nowUtc = Date.UTC(2025, 0, 1, 9, 20, 0);
|
||||
const next = computeNextRunAt({
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: 'daily',
|
||||
times: ['09:15', '09:45', '18:00'],
|
||||
timezone: 'UTC',
|
||||
},
|
||||
}, nowUtc);
|
||||
|
||||
expect(next).toBe(Date.UTC(2025, 0, 1, 9, 45, 0));
|
||||
});
|
||||
|
||||
it('computes one-time next run for future date', () => {
|
||||
const nowUtc = Date.UTC(2026, 3, 15, 10, 0, 0);
|
||||
const next = computeNextRunAt({
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: 'once',
|
||||
date: '2026-04-16',
|
||||
time: '13:30',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
}, nowUtc);
|
||||
|
||||
expect(next).toBe(Date.UTC(2026, 3, 16, 13, 30, 0));
|
||||
});
|
||||
|
||||
it('returns null for past one-time schedule', () => {
|
||||
const nowUtc = Date.UTC(2026, 3, 16, 14, 0, 0);
|
||||
const next = computeNextRunAt({
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: 'once',
|
||||
date: '2026-04-16',
|
||||
time: '13:30',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
}, nowUtc);
|
||||
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
it('formats session title with timestamp suffix', () => {
|
||||
const title = formatScheduledSessionTitle({
|
||||
name: 'Morning Sync',
|
||||
schedule: { timezone: 'UTC' },
|
||||
}, Date.UTC(2025, 2, 10, 7, 5, 0));
|
||||
|
||||
expect(title).toBe('Morning Sync 2025-03-10 07:05');
|
||||
});
|
||||
|
||||
it('parses slash command prompt for scheduled command mode', () => {
|
||||
expect(parseScheduledCommandPrompt('/review src/components')).toEqual({
|
||||
command: 'review',
|
||||
arguments: 'src/components',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when prompt is not a slash command', () => {
|
||||
expect(parseScheduledCommandPrompt('Summarize open issues')).toBeNull();
|
||||
expect(parseScheduledCommandPrompt('/')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user