diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab4d093..04467c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Diff reviews can be annotated line by line in both Unified and Editor views. Use the comment action on a changed line to write a note and add its file, line number, and code context to the active composer; collect multiple comments and send them to the agent in one prompt. +- Settings: Notifications, off by default. With it on, a system notification appears when a turn finishes or an agent waits on an approval or question in a session that is not on screen, whether MonoCode is in the background or another session is open; clicking it jumps to that session. Turning it on asks macOS for permission, and a blocked state links to System Settings. The Sounds setting decides whether the notification plays a sound, and the in-app cue is skipped when the banner fires. ## [0.1.32] - 2026-09-04 diff --git a/Cargo.lock b/Cargo.lock index 35521111..99d50263 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2110,6 +2110,20 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2176,6 +2190,7 @@ dependencies = [ "base64 0.22.1", "block2", "libc", + "notify-rust", "objc2", "objc2-app-kit", "objc2-foundation", @@ -2245,6 +2260,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -4107,6 +4136,17 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 05a00aa7..c0ffee80 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,9 +34,12 @@ block2 = "0.6.2" objc2 = "0.6" objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSButton", "NSColor", "NSControl", "NSDockTile", "NSImage", "NSLayoutAnchor", "NSLayoutConstraint", "NSMenu", "NSMenuItem", "NSResponder", "NSView", "NSWindow", "objc2-core-foundation"] } objc2-foundation = { version = "0.3", features = ["NSGeometry", "NSError", "NSString"] } -objc2-user-notifications = { version = "0.3.2", features = ["UNUserNotificationCenter", "block2"] } +objc2-user-notifications = { version = "0.3.2", features = ["UNUserNotificationCenter", "UNNotification", "UNNotificationAction", "UNNotificationCategory", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationSound", "block2"] } raw-window-handle = "0.6" +[target.'cfg(target_os = "linux")'.dependencies] +notify-rust = "4.18" + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5c66bbdd..fba0a86d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod linear; mod macos; mod menu; mod notes; +mod notifications; mod project_logo; mod pty; mod rate_limits; @@ -165,6 +166,10 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ default_cwd, home_dir, + notifications::notification_permission, + notifications::request_notification_permission, + notifications::show_notification, + notifications::open_notification_settings, fs::list_dir, fs::list_project_files, fs::git_diff_stats, @@ -300,6 +305,7 @@ pub fn run() { #[cfg(target_os = "macos")] { macos::request_badge_authorization(); + notifications::install_delegate(handle); #[cfg(debug_assertions)] macos::prefer_bundle_dock_icon(); } diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 8eb02485..73e2312b 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -378,21 +378,39 @@ thread_local! { /// Since macOS 12, `NSDockTile` badge updates are ignored unless the app has /// requested `UNUserNotificationCenter` authorization with the badge option. /// Must run on the main thread after launch (`RunEvent::Ready`), not in setup. +/// +/// Only re-requests once the user has already answered the prompt: the +/// one-time system dialog is reserved for the Notifications toggle, so a +/// badge-only request at startup must not consume it. Until then the badge +/// stays off. pub(crate) fn request_badge_authorization() { - let Some(mtm) = MainThreadMarker::new() else { + if MainThreadMarker::new().is_none() { return; - }; + } use block2::RcBlock; use objc2::runtime::Bool; use objc2_foundation::NSError; - use objc2_user_notifications::{UNAuthorizationOptions, UNUserNotificationCenter}; + use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNNotificationSettings, + UNUserNotificationCenter, + }; + use std::ptr::NonNull; let center = UNUserNotificationCenter::currentNotificationCenter(); - let options = UNAuthorizationOptions::Badge; - let handler = RcBlock::new(|_granted: Bool, _error: *mut NSError| {}); - center.requestAuthorizationWithOptions_completionHandler(options, &handler); - let _ = mtm; + let handler = RcBlock::new(|settings: NonNull| { + let settings = unsafe { settings.as_ref() }; + if settings.authorizationStatus() == UNAuthorizationStatus::NotDetermined { + return; + } + let done = RcBlock::new(|_granted: Bool, _error: *mut NSError| {}); + UNUserNotificationCenter::currentNotificationCenter() + .requestAuthorizationWithOptions_completionHandler( + UNAuthorizationOptions::Badge, + &done, + ); + }); + center.getNotificationSettingsWithCompletionHandler(&handler); } pub(crate) fn install_dock_menu(app: &AppHandle) { @@ -497,15 +515,28 @@ fn relaunch_from_dev_bundle() -> Result<(), String> { let bundled = macos_dir.join("monocode"); let _ = std::fs::remove_file(&bundled); - if std::fs::hard_link(&exe, &bundled).is_err() { - std::fs::copy(&exe, &bundled).map_err(|e| e.to_string())?; - } + // A copy, not a hard link: re-signing below rewrites the file, and the + // linked original is the executable running this code. + std::fs::copy(&exe, &bundled).map_err(|e| e.to_string())?; let mut perms = std::fs::metadata(&bundled) .map_err(|e| e.to_string())? .permissions(); perms.set_mode(0o755); std::fs::set_permissions(&bundled, perms).map_err(|e| e.to_string())?; + // The linker's ad-hoc signature carries a `monocode-` identifier. + // UNUserNotificationCenter refuses authorization, without prompting, + // unless the signing identifier matches CFBundleIdentifier. + let signed = Command::new("/usr/bin/codesign") + .args(["--force", "--sign", "-", "--identifier", DEV_BUNDLE_ID]) + .arg(&app) + .status() + .map(|status| status.success()) + .unwrap_or(false); + if !signed { + eprintln!("monocode: macos dev bundle: codesign failed; notifications stay off"); + } + let err = Command::new(&bundled) .args(std::env::args_os().skip(1)) .exec(); @@ -525,6 +556,9 @@ fn write_dev_bundle_icons(app: &std::path::Path) -> Result<(), String> { Ok(()) } +/// Must match `CFBundleIdentifier` in `DEV_BUNDLE_PLIST` and tauri.conf.json. +#[cfg(debug_assertions)] +const DEV_BUNDLE_ID: &str = "com.monocode.desktop"; #[cfg(debug_assertions)] const DEV_ICNS: &[u8] = include_bytes!("../icons/icon.icns"); #[cfg(debug_assertions)] diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs new file mode 100644 index 00000000..b94ac845 --- /dev/null +++ b/src-tauri/src/notifications.rs @@ -0,0 +1,431 @@ +//! Desktop notifications for turns that end or stall while the window is in +//! the background. +//! +//! macOS goes through `UNUserNotificationCenter` directly: the app already +//! links it for the Dock badge, it reports the real authorization state, and +//! a delegate turns a click into a jump back to the session. Linux uses the +//! freedesktop notification bus, which has no permission model. + +use serde::Serialize; +use tauri::AppHandle; + +/// Emitted to every window when the user clicks a notification. Payload is +/// the session id; the window that owns that session handles it. +pub const CLICK_EVENT: &str = "monocode:notification-click"; + +#[cfg(target_os = "macos")] +pub use platform::install_delegate; + +/// Each platform constructs only the variants it can reach, so the lint is +/// silenced for the whole enum rather than per target. +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Permission { + /// Never asked, or not yet answered. + Prompt, + Granted, + /// Declined at the prompt, or alerts switched off in System Settings. + Denied, + /// No notification backend on this platform. + Unsupported, +} + +#[tauri::command] +pub async fn notification_permission() -> Permission { + platform::permission().await +} + +#[tauri::command] +pub async fn request_notification_permission() -> Permission { + platform::request_permission().await +} + +#[tauri::command] +pub fn show_notification( + app: AppHandle, + session_id: String, + title: String, + subtitle: String, + body: String, + sound: bool, +) -> Result<(), String> { + platform::show(&app, &session_id, &title, &subtitle, &body, sound) +} + +/// Opens the app's page in the OS notification settings, where the user can +/// re-enable alerts after declining the prompt. +#[tauri::command] +pub fn open_notification_settings(app: AppHandle) -> Result<(), String> { + platform::open_settings(&app) +} + +#[cfg(target_os = "macos")] +mod platform { + use std::cell::RefCell; + use std::ptr::NonNull; + use std::sync::mpsc; + + use block2::RcBlock; + use objc2::rc::Retained; + use objc2::runtime::{Bool, NSObject, NSObjectProtocol, ProtocolObject}; + use objc2::{define_class, AnyThread, DefinedClass, MainThreadMarker}; + use objc2_foundation::{NSArray, NSError, NSSet, NSString}; + use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, + UNNotification, UNNotificationAction, UNNotificationActionOptions, UNNotificationCategory, + UNNotificationCategoryOptions, UNNotificationPresentationOptions, UNNotificationRequest, + UNNotificationResponse, UNNotificationSetting, UNNotificationSettings, UNNotificationSound, + UNUserNotificationCenter, UNUserNotificationCenterDelegate, + }; + use tauri::{AppHandle, Emitter}; + + use super::{Permission, CLICK_EVENT}; + + /// Request identifiers carry the session so a click can find it without + /// touching `userInfo`. Each request gets a fresh suffix: reusing one + /// replaces the previous banner, and macOS drops replacements that land + /// while the app is frontmost. + const ID_PREFIX: &str = "session:"; + + fn request_identifier(session_id: &str) -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{ID_PREFIX}{session_id}/{nanos}") + } + + fn session_from_identifier(identifier: &str) -> Option<&str> { + let rest = identifier.strip_prefix(ID_PREFIX)?; + Some(rest.split('/').next().unwrap_or(rest)) + } + + /// Category with a single "Show" button, so the banner offers the jump + /// explicitly instead of relying on a click on the body. + const CATEGORY: &str = "monocode.session"; + const SHOW_ACTION: &str = "monocode.session.show"; + + fn options() -> UNAuthorizationOptions { + UNAuthorizationOptions::Alert + | UNAuthorizationOptions::Sound + | UNAuthorizationOptions::Badge + } + + fn map_settings(settings: &UNNotificationSettings) -> Permission { + match settings.authorizationStatus() { + UNAuthorizationStatus::NotDetermined => Permission::Prompt, + UNAuthorizationStatus::Denied => Permission::Denied, + // Authorized for badges only still leaves alerts off. + _ if settings.alertSetting() == UNNotificationSetting::Disabled => Permission::Denied, + _ => Permission::Granted, + } + } + + /// Completion handlers run on a UN background queue. The ObjC objects + /// are released before any `.await` so the command future stays `Send`; + /// only the channel crosses into the async runtime. + fn query_permission() -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(); + let handler = RcBlock::new(move |settings: NonNull| { + let settings = unsafe { settings.as_ref() }; + let _ = tx.send(map_settings(settings)); + }); + UNUserNotificationCenter::currentNotificationCenter() + .getNotificationSettingsWithCompletionHandler(&handler); + rx + } + + fn start_request() -> mpsc::Receiver<()> { + let (tx, rx) = mpsc::channel(); + let handler = RcBlock::new(move |_granted: Bool, _error: *mut NSError| { + let _ = tx.send(()); + }); + UNUserNotificationCenter::currentNotificationCenter() + .requestAuthorizationWithOptions_completionHandler(options(), &handler); + rx + } + + async fn wait(rx: mpsc::Receiver) -> Option { + tauri::async_runtime::spawn_blocking(move || rx.recv().ok()) + .await + .ok() + .flatten() + } + + pub(super) async fn permission() -> Permission { + wait(query_permission()).await.unwrap_or(Permission::Denied) + } + + pub(super) async fn request_permission() -> Permission { + wait(start_request()).await; + permission().await + } + + pub(super) fn show( + _app: &AppHandle, + session_id: &str, + title: &str, + subtitle: &str, + body: &str, + sound: bool, + ) -> Result<(), String> { + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(title)); + content.setSubtitle(&NSString::from_str(subtitle)); + content.setBody(&NSString::from_str(body)); + content.setCategoryIdentifier(&NSString::from_str(CATEGORY)); + if sound { + content.setSound(Some(&UNNotificationSound::defaultSound())); + } + let identifier = NSString::from_str(&request_identifier(session_id)); + let request = UNNotificationRequest::requestWithIdentifier_content_trigger( + &identifier, + &content, + None, + ); + let on_done = RcBlock::new(|error: *mut NSError| { + if !error.is_null() { + let error = unsafe { &*error }; + eprintln!("monocode: notification rejected: {error}"); + } + }); + // Adding while authorization is still undetermined fails with + // UNErrorDomain 1, so ask first; the call is a no-op once decided. + let on_authorized = RcBlock::new(move |granted: Bool, _error: *mut NSError| { + if !granted.as_bool() { + eprintln!("monocode: notifications not authorized; skipping banner"); + return; + } + UNUserNotificationCenter::currentNotificationCenter() + .addNotificationRequest_withCompletionHandler(&request, Some(&on_done)); + }); + UNUserNotificationCenter::currentNotificationCenter() + .requestAuthorizationWithOptions_completionHandler(options(), &on_authorized); + Ok(()) + } + + pub(super) fn open_settings(app: &AppHandle) -> Result<(), String> { + let url = format!( + "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id={}", + app.config().identifier + ); + std::process::Command::new("open") + .arg(url) + .spawn() + .map(|_| ()) + .map_err(|err| err.to_string()) + } + + struct DelegateIvars { + app: AppHandle, + } + + define_class!( + #[unsafe(super(NSObject))] + #[name = "MonoCodeNotificationDelegate"] + #[ivars = DelegateIvars] + struct Delegate; + + unsafe impl NSObjectProtocol for Delegate {} + + unsafe impl UNUserNotificationCenterDelegate for Delegate { + /// Without this macOS drops banners while the app is frontmost, + /// and a finished background session deserves one either way. + #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))] + fn will_present( + &self, + _center: &UNUserNotificationCenter, + _notification: &UNNotification, + completion: &block2::DynBlock, + ) { + completion.call((UNNotificationPresentationOptions::Banner + | UNNotificationPresentationOptions::List + | UNNotificationPresentationOptions::Sound,)); + } + + #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] + fn did_receive( + &self, + _center: &UNUserNotificationCenter, + response: &UNNotificationResponse, + completion: &block2::DynBlock, + ) { + let identifier = response.notification().request().identifier().to_string(); + if let Some(session_id) = session_from_identifier(&identifier) { + let _ = self.ivars().app.emit(CLICK_EVENT, session_id); + } + completion.call(()); + } + } + ); + + thread_local! { + static DELEGATE: RefCell>> = const { RefCell::new(None) }; + } + + /// Must run on the main thread once the app is ready; the center keeps a + /// weak reference, so the delegate is retained here for the app lifetime. + pub fn install_delegate(app: &AppHandle) { + if MainThreadMarker::new().is_none() { + return; + } + let delegate = Delegate::alloc().set_ivars(DelegateIvars { app: app.clone() }); + let delegate: Retained = unsafe { objc2::msg_send![super(delegate), init] }; + let center = UNUserNotificationCenter::currentNotificationCenter(); + center.setDelegate(Some(ProtocolObject::from_ref(&*delegate))); + DELEGATE.with(|slot| *slot.borrow_mut() = Some(delegate)); + + let show = UNNotificationAction::actionWithIdentifier_title_options( + &NSString::from_str(SHOW_ACTION), + &NSString::from_str("Show"), + UNNotificationActionOptions::Foreground, + ); + let category = + UNNotificationCategory::categoryWithIdentifier_actions_intentIdentifiers_options( + &NSString::from_str(CATEGORY), + &NSArray::from_retained_slice(&[show]), + &NSArray::new(), + UNNotificationCategoryOptions::empty(), + ); + center.setNotificationCategories(&NSSet::from_retained_slice(&[category])); + } + + #[cfg(test)] + mod tests { + use super::*; + use objc2::runtime::AnyProtocol; + use objc2::{sel, ClassType}; + + #[test] + fn identifier_round_trips_the_session() { + let id = request_identifier("549ae7ac"); + assert_eq!(session_from_identifier(&id), Some("549ae7ac")); + assert_eq!(session_from_identifier("other"), None); + } + + #[test] + fn delegate_registers_protocol_methods() { + let cls = Delegate::class(); + let proto = + AnyProtocol::get(c"UNUserNotificationCenterDelegate").expect("protocol loaded"); + assert!(cls.conforms_to(proto)); + assert!(cls.responds_to(sel!( + userNotificationCenter:willPresentNotification:withCompletionHandler: + ))); + assert!(cls.responds_to(sel!( + userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler: + ))); + } + } +} + +#[cfg(target_os = "linux")] +mod platform { + use tauri::{AppHandle, Emitter}; + + use super::{Permission, CLICK_EVENT}; + + pub(super) async fn permission() -> Permission { + Permission::Granted + } + + pub(super) async fn request_permission() -> Permission { + Permission::Granted + } + + pub(super) fn show( + app: &AppHandle, + session_id: &str, + title: &str, + subtitle: &str, + body: &str, + sound: bool, + ) -> Result<(), String> { + let mut notification = notify_rust::Notification::new(); + notification + .appname("MonoCode") + .summary(&format!("{title}: {subtitle}")) + // The body is agent output; servers render it as markup. + .body(&escape_markup(body)) + .icon("monocode") + // Servers only report the click when a "default" action exists. + .action("default", "Show"); + if sound { + notification.sound_name("message-new-instant"); + } + let handle = notification.show().map_err(|err| err.to_string())?; + let app = app.clone(); + let session_id = session_id.to_string(); + // `wait_for_action` blocks until the notification closes. + std::thread::spawn(move || { + handle.wait_for_action(|action| { + if action == "default" { + let _ = app.emit(CLICK_EVENT, session_id.as_str()); + } + }); + }); + Ok(()) + } + + /// The freedesktop spec parses the body as a subset of HTML. + fn escape_markup(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(ch), + } + } + out + } + + pub(super) fn open_settings(_app: &AppHandle) -> Result<(), String> { + Err("no notification settings page on this platform".into()) + } + + #[cfg(test)] + mod tests { + use super::escape_markup; + + #[test] + fn escapes_markup_in_bodies() { + assert_eq!( + escape_markup("x & y"), + "<b>x</b> & y" + ); + } + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +mod platform { + use tauri::AppHandle; + + use super::Permission; + + pub(super) async fn permission() -> Permission { + Permission::Unsupported + } + + pub(super) async fn request_permission() -> Permission { + Permission::Unsupported + } + + pub(super) fn show( + _app: &AppHandle, + _session_id: &str, + _title: &str, + _subtitle: &str, + _body: &str, + _sound: bool, + ) -> Result<(), String> { + Err("notifications are not supported on this platform".into()) + } + + pub(super) fn open_settings(_app: &AppHandle) -> Result<(), String> { + Err("notifications are not supported on this platform".into()) + } +} diff --git a/src/App.tsx b/src/App.tsx index 6ebf9942..70fa9a94 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -257,6 +257,13 @@ import { syncDockBadge } from "./lib/dockBadge"; import { liveAgentsFromSessions } from "./lib/liveAgents"; import { hiddenApprovalNotices } from "./lib/approvalToast"; import { nextUnseenFinishedSessions } from "./lib/sessionDone"; +import { + loadNotificationsEnabled, + NOTIFICATION_CLICK_EVENT, + notifySession, + probeNotificationPermission, + setWindowFocused, +} from "./lib/notifications"; import { playCue } from "./lib/sounds"; import { deferUnhandledEscape, @@ -901,6 +908,30 @@ export default function App({ const approvalSessionIds = approvalSessionIdsRef.current; const activeSessionId = active?.id; + const activeSessionIdRef = useRef(activeSessionId); + activeSessionIdRef.current = activeSessionId; + + const notifiedApprovalIdsRef = useRef>(new Set()); + useEffect(() => { + const previous = notifiedApprovalIdsRef.current; + notifiedApprovalIdsRef.current = approvalSessionIds; + for (const id of approvalSessionIds) { + if (previous.has(id)) continue; + const session = sessionsRef.current.find((s) => s.id === id); + if (session) { + void notifySession( + session, + "needsInput", + id === activeSessionIdRef.current, + ); + } + } + }, [approvalSessionIds]); + + // Cache the OS decision so a turn ending later can skip a denied banner. + useEffect(() => { + if (loadNotificationsEnabled()) void probeNotificationPermission(); + }, []); const busyForDoneRef = useRef(busySessionIds); const focusedForDoneRef = useRef(activeSessionId); const unseenFinishedRef = useRef>(new Set()); @@ -940,6 +971,7 @@ export default function App({ let unlisten: (() => void) | undefined; void getCurrentWindow() .onFocusChanged(({ payload: focused }) => { + setWindowFocused(focused); if (focused) { flushHarnessEvents(); syncDockBadge(sessionsRef.current); @@ -3571,7 +3603,18 @@ export default function App({ : finalized; }), ); - playCue("turnFinished"); + // Next tick: the flush above has rendered by then, so the banner + // quotes the reply's final text rather than the previous batch. + window.setTimeout(() => { + const finished = sessionsRef.current.find((s) => s.id === sessionId); + const visible = sessionId === activeSessionIdRef.current; + const sent = finished + ? notifySession(finished, "finished", visible) + : Promise.resolve(false); + void sent.then((ok) => { + if (!ok) playCue("turnFinished"); + }); + }, 0); notifyReviewChanged(sessionId); notifyGitChanged(); nudgeWorkspace(workCwd); @@ -4353,6 +4396,7 @@ export default function App({ onNewTerminalTab, onToggleProjectTerminal, openSettings, + onOpenApprovalSession, }); actions.current = { onNew, @@ -4376,6 +4420,7 @@ export default function App({ onNewTerminalTab, onToggleProjectTerminal, openSettings, + onOpenApprovalSession, }; const debounce = useRef({ name: "", at: 0 }); @@ -4553,6 +4598,12 @@ export default function App({ listen("open_model_picker", () => { window.dispatchEvent(new Event("open_model_picker")); }), + // Every window hears the click; only the one holding the session acts. + listen(NOTIFICATION_CLICK_EVENT, ({ payload: sessionId }) => { + if (!sessionsRef.current.some((s) => s.id === sessionId)) return; + void getCurrentWindow().setFocus(); + actions.current.onOpenApprovalSession(sessionId); + }), ]; return () => { void Promise.all(unlisten).then((fns) => fns.forEach((fn) => fn())); diff --git a/src/lib/notifications.test.ts b/src/lib/notifications.test.ts new file mode 100644 index 00000000..8626e123 --- /dev/null +++ b/src/lib/notifications.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + loadNotificationsEnabled, + NOTIFICATIONS_DEFAULT, + notificationText, + saveNotificationsEnabled, + shouldNotify, +} from "./notifications"; +import { newSession, type Session } from "./session"; + +const KEY = "monocode.notifications"; + +function chat(patch: Partial = {}): Session { + const session = newSession("claude", "/tmp/a"); + session.title = "claude · Fix the sidebar"; + session.blocks = [{ id: "u1", role: "user", text: "hello" }]; + return { ...session, ...patch, blocks: patch.blocks ?? session.blocks }; +} + +describe("notifications setting", () => { + beforeEach(mockLocalStorage); + afterEach(() => localStorage.removeItem(KEY)); + + it("is off until the user opts in", () => { + expect(NOTIFICATIONS_DEFAULT).toBe(false); + expect(loadNotificationsEnabled()).toBe(false); + }); + + it("round-trips", () => { + saveNotificationsEnabled(true); + expect(loadNotificationsEnabled()).toBe(true); + saveNotificationsEnabled(false); + expect(loadNotificationsEnabled()).toBe(false); + }); +}); + +describe("shouldNotify", () => { + it("stays quiet while the session is on screen in a focused window", () => { + expect( + shouldNotify({ + enabled: true, + permission: "granted", + windowFocused: true, + sessionVisible: true, + }), + ).toBe(false); + }); + + it("fires for a session that is not on screen even when focused", () => { + expect( + shouldNotify({ + enabled: true, + permission: "granted", + windowFocused: true, + sessionVisible: false, + }), + ).toBe(true); + }); + + it("respects the toggle and the OS decision", () => { + expect( + shouldNotify({ + enabled: false, + permission: "granted", + windowFocused: false, + sessionVisible: true, + }), + ).toBe(false); + expect( + shouldNotify({ + enabled: true, + permission: "denied", + windowFocused: false, + sessionVisible: true, + }), + ).toBe(false); + expect( + shouldNotify({ + enabled: true, + permission: "unsupported", + windowFocused: false, + sessionVisible: true, + }), + ).toBe(false); + }); + + it("fires when unfocused and allowed, or still undecided", () => { + expect( + shouldNotify({ + enabled: true, + permission: "granted", + windowFocused: false, + sessionVisible: true, + }), + ).toBe(true); + expect( + shouldNotify({ + enabled: true, + permission: "prompt", + windowFocused: false, + sessionVisible: true, + }), + ).toBe(true); + }); +}); + +describe("notificationText", () => { + it("leads with the app, then the session title, then the reply", () => { + const session = chat({ + blocks: [ + { id: "u1", role: "user", text: "hello" }, + { + id: "a1", + role: "assistant", + text: "\n\nDone. Sidebar\nfixed.\n\nDetails below.", + }, + ], + }); + expect(notificationText(session, "finished")).toEqual({ + title: "MonoCode", + subtitle: "Fix the sidebar", + body: "Done. Sidebar fixed.", + }); + }); + + it("falls back to a generic body without a reply", () => { + expect(notificationText(chat(), "finished").body).toBe( + "Claude Code finished", + ); + }); + + it("clips long bodies", () => { + const session = chat({ + blocks: [{ id: "a1", role: "assistant", text: "x".repeat(400) }], + }); + const body = notificationText(session, "finished").body; + expect(body.length).toBe(240); + expect(body.endsWith("…")).toBe(true); + }); + + it("names the pending approval", () => { + const session = chat({ + blocks: [ + { + id: "p1", + role: "approval", + text: "Run npm test", + tool: { title: "Run npm test" }, + approval: { requestId: 1 }, + }, + ], + }); + expect(notificationText(session, "needsInput")).toEqual({ + title: "MonoCode", + subtitle: "Fix the sidebar", + body: "Approve: Run npm test", + }); + }); + + it("prefers the question prompt over an approval", () => { + const session = chat({ + pendingQuestion: { + requestId: 2, + questions: [ + { + id: "q", + prompt: "Which database?", + multiSelect: false, + allowCustom: false, + options: [], + }, + ], + }, + }); + expect(notificationText(session, "needsInput")).toEqual({ + title: "MonoCode", + subtitle: "Fix the sidebar", + body: "Which database?", + }); + }); +}); + +function mockLocalStorage() { + const data = new Map(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + removeItem: (key: string) => { + data.delete(key); + }, + }, + }); +} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 00000000..b4e347d9 --- /dev/null +++ b/src/lib/notifications.ts @@ -0,0 +1,192 @@ +import { invoke } from "@tauri-apps/api/core"; +import { HARNESS_TITLE, sessionDisplayTitle, type Session } from "./session"; +import { loadSoundsEnabled } from "./sounds"; + +const KEY = "monocode.notifications"; + +/** Off until the user opts in; enabling asks the OS for permission. */ +export const NOTIFICATIONS_DEFAULT = false; + +export const NOTIFICATIONS_CHANGE_EVENT = "monocode:notifications-change"; + +/** Rust emits this with the session id when a notification is clicked. */ +export const NOTIFICATION_CLICK_EVENT = "monocode:notification-click"; + +export type NotificationPermission = + | "prompt" + | "granted" + | "denied" + | "unsupported"; + +export function loadNotificationsEnabled(): boolean { + try { + const raw = localStorage.getItem(KEY); + if (raw == null) return NOTIFICATIONS_DEFAULT; + return raw === "1" || raw === "true"; + } catch { + return NOTIFICATIONS_DEFAULT; + } +} + +export function saveNotificationsEnabled(value: boolean) { + try { + localStorage.setItem(KEY, value ? "1" : "0"); + } catch { + // private mode / quota + } + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent(NOTIFICATIONS_CHANGE_EVENT, { detail: value }), + ); +} + +let permission: NotificationPermission = "prompt"; + +/** Last permission the OS reported; refreshed by the probes below. */ +export function cachedNotificationPermission(): NotificationPermission { + return permission; +} + +export async function probeNotificationPermission(): Promise { + try { + permission = await invoke("notification_permission"); + } catch { + permission = "unsupported"; + } + return permission; +} + +/** Shows the OS prompt when undecided; otherwise reports the current state. */ +export async function requestNotificationPermission(): Promise { + try { + permission = await invoke( + "request_notification_permission", + ); + } catch { + permission = "unsupported"; + } + return permission; +} + +export function openNotificationSettings(): Promise { + return invoke("open_notification_settings"); +} + +/** + * Tracked from Tauri's focus event rather than `document.hasFocus()`, which + * WKWebView keeps reporting true after the window drops to the background. + */ +let windowFocused = + typeof document !== "undefined" ? document.hasFocus() : true; + +export function setWindowFocused(focused: boolean) { + windowFocused = focused; +} + +/** + * A banner only earns its place while the user is looking elsewhere: another + * app, or another session. The transcript already shows the change on the + * session that is on screen. + */ +export function shouldNotify({ + enabled, + permission, + windowFocused, + sessionVisible, +}: { + enabled: boolean; + permission: NotificationPermission; + windowFocused: boolean; + sessionVisible: boolean; +}): boolean { + if (!enabled || (windowFocused && sessionVisible)) return false; + return permission === "granted" || permission === "prompt"; +} + +export type NotificationEvent = "finished" | "needsInput"; + +/** App name, then the session title, then the reply itself. */ +export type NotificationText = { title: string; subtitle: string; body: string }; + +const BODY_MAX = 240; + +export function notificationText( + session: Session, + event: NotificationEvent, +): NotificationText { + const title = "MonoCode"; + const subtitle = sessionDisplayTitle(session.title, session.harness); + const harness = HARNESS_TITLE[session.harness]; + if (event === "needsInput") { + const question = session.pendingQuestion; + if (question) { + const prompt = question.title || question.questions[0]?.prompt; + return { + title, + subtitle, + body: clip(prompt || `${harness} has a question for you`), + }; + } + const pending = [...session.blocks] + .reverse() + .find((block) => block.approval && !block.approval.decided); + const what = pending?.tool?.title || pending?.text; + return { + title, + subtitle, + body: clip(what ? `Approve: ${what}` : `${harness} needs your approval`), + }; + } + const reply = [...session.blocks] + .reverse() + .find((block) => block.role === "assistant" && block.text.trim()); + return { + title, + subtitle, + body: clip(reply?.text || `${harness} finished`), + }; +} + +/** First paragraph, whitespace collapsed; macOS wraps and truncates the rest. */ +function clip(text: string): string { + const paragraph = + text + .split(/\n\s*\n/) + .map((part) => part.replace(/\s+/g, " ").trim()) + .find((part) => part.length > 0) ?? ""; + return paragraph.length > BODY_MAX + ? `${paragraph.slice(0, BODY_MAX - 1)}…` + : paragraph; +} + +/** + * Sends the banner when policy allows. Resolves true once the OS accepted it + * so callers can skip the in-app cue: the OS sound stands in for it. A + * rejected dispatch resolves false so the cue still plays. + */ +export async function notifySession( + session: Session, + event: NotificationEvent, + sessionVisible: boolean, +): Promise { + const decision = shouldNotify({ + enabled: loadNotificationsEnabled(), + permission, + windowFocused, + sessionVisible, + }); + if (!decision) return false; + const { title, subtitle, body } = notificationText(session, event); + try { + await invoke("show_notification", { + sessionId: session.id, + title, + subtitle, + body, + sound: loadSoundsEnabled(), + }); + return true; + } catch { + return false; + } +} diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index 1fd326e2..411d3a1a 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -136,6 +136,15 @@ import { type SettingsSectionId, } from "../lib/settings"; import { loadSoundsEnabled, playCue, saveSoundsEnabled } from "../lib/sounds"; +import { + cachedNotificationPermission, + loadNotificationsEnabled, + openNotificationSettings, + probeNotificationPermission, + requestNotificationPermission, + saveNotificationsEnabled, + type NotificationPermission, +} from "../lib/notifications"; import { installPendingUpdate, readAppVersion, @@ -276,8 +285,25 @@ function GeneralPage({ loadLiveAgentsEnabled, ); const [soundsEnabled, setSoundsEnabled] = useState(loadSoundsEnabled); + const [notificationsEnabled, setNotificationsEnabled] = useState( + loadNotificationsEnabled, + ); + const [notificationPermission, setNotificationPermission] = + useState(cachedNotificationPermission); const [claudeHooks, setClaudeHooks] = useState(loadClaudeHooks); + // The user may flip the switch in System Settings and come back: re-read + // the OS state whenever the window regains focus while the toggle is on. + useEffect(() => { + if (!notificationsEnabled) return; + const refresh = () => { + void probeNotificationPermission().then(setNotificationPermission); + }; + refresh(); + window.addEventListener("focus", refresh); + return () => window.removeEventListener("focus", refresh); + }, [notificationsEnabled]); + useEffect(() => { const onAnchor = (event: Event) => { setTranscriptAnchor((event as CustomEvent).detail === true); @@ -333,6 +359,13 @@ function GeneralPage({ setSoundsEnabled(next); }; + const onNotificationsEnabled = (next: boolean) => { + saveNotificationsEnabled(next); + setNotificationsEnabled(next); + if (!next) return; + void requestNotificationPermission().then(setNotificationPermission); + }; + const onClaudeHooks = (next: boolean) => { saveClaudeHooks(next); setClaudeHooks(next); @@ -434,6 +467,24 @@ function GeneralPage({ > + + {notificationsEnabled && notificationPermission === "denied" ? ( + + ) : null} + {notificationsEnabled && notificationPermission === "unsupported" ? ( + + Not available on this platform + + ) : null} + + + Permission needed + {IS_MAC ? ( + + ) : null} + + ); +} + function Toggle({ label, on,