diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index d4f7a4a2d4..fdff4bf74c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1047,7 +1047,9 @@ dependencies = [ "neteq", "nostr", "notify-rust", + "objc2", "objc2-app-kit", + "objc2-foundation", "opus", "plist", "png 0.18.1", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 8bb643fea3..1bc493b0aa 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -42,7 +42,9 @@ keyring = { version = "3.6.3", default-features = false, features = ["sync-secre notify-rust = "4" [target.'cfg(target_os = "macos")'.dependencies] -objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback"] } +objc2 = { version = "0.6.4", default-features = false } +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSProcessInfo", "NSString"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } security-framework = { version = "3.7.0", features = ["OSX_10_15"] } window-vibrancy = "0.6" @@ -58,7 +60,7 @@ user-idle = { version = "0.6", default-features = false } atomic-write-file = "0.3" anyhow = "1" dirs = "6" -tauri = { version = "2", features = ["macos-private-api"] } +tauri = { version = "2", features = ["macos-private-api", "tray-icon"] } tauri-plugin-deep-link = "2" tauri-plugin-opener = "2" tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35f4eae866..35ba41bad4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -28,6 +28,8 @@ mod reset; mod secret_store; mod shutdown; mod templates; +#[cfg(target_os = "macos")] +mod tray_menu; mod util; #[cfg(target_os = "linux")] pub mod webkit_rendering; @@ -63,10 +65,12 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }; -#[cfg(target_os = "macos")] -use tauri::Listener; use tauri::{Emitter, Manager, RunEvent}; +#[cfg(target_os = "macos")] +use tauri::{Listener, WindowEvent}; use tauri_plugin_window_state::StateFlags; +#[cfg(target_os = "macos")] +use tray_menu::show_main_window; #[cfg(target_os = "macos")] const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; @@ -360,6 +364,8 @@ pub fn run() { .manage(commands::pairing::PairingHandle::new()) .setup(move |app| { let app_handle = app.handle().clone(); + #[cfg(target_os = "macos")] + tray_menu::init(&app_handle)?; // ── Phase 2: boot-time sentinel wipe ────────────────────────────── // Must run before migrations and identity resolution so the wipe @@ -895,6 +901,14 @@ pub fn run() { archive::read_unindexed_observer_rows, is_auto_update_supported, set_window_vibrancy, + #[cfg(target_os = "macos")] + tray_menu::clear_tray_agent_activity, + #[cfg(target_os = "macos")] + tray_menu::requeue_tray_actions, + #[cfg(target_os = "macos")] + tray_menu::take_tray_actions, + #[cfg(target_os = "macos")] + tray_menu::update_tray_agent_activity, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); @@ -907,6 +921,22 @@ pub fn run() { let run_shutdown_done = Arc::clone(&shutdown_done); let restart_requested = Arc::new(AtomicBool::new(false)); app.run(move |app_handle, event| match event { + #[cfg(target_os = "macos")] + RunEvent::Reopen { .. } => show_main_window(app_handle), + #[cfg(target_os = "macos")] + RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { api, .. }, + .. + } if label == "main" => { + // Keep the webview alive so Buzz can be reopened from its tray menu. + api.prevent_close(); + if let Some(window) = app_handle.get_webview_window("main") { + if let Err(error) = window.hide() { + eprintln!("buzz-desktop: failed to hide main window: {error}"); + } + } + } RunEvent::ExitRequested { code, .. } => { if is_restart_request(code) { restart_requested.store(true, Ordering::SeqCst); diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs new file mode 100644 index 0000000000..d733cd1f13 --- /dev/null +++ b/desktop/src-tauri/src/tray_menu.rs @@ -0,0 +1,640 @@ +//! Native system-tray menu for the desktop app. +//! +//! The webview owns the live agent-turn state. It sends the small display +//! projection here so the native menu can remain useful while Buzz is hidden. + +use std::{ + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +#[cfg(target_os = "macos")] +use objc2::MainThreadMarker; +#[cfg(target_os = "macos")] +use objc2_foundation::{NSProcessInfo, NSString}; +use serde::{Deserialize, Serialize}; +use tauri::{ + image::Image, + menu::{Menu, MenuItem, PredefinedMenuItem}, + tray::{TrayIcon, TrayIconBuilder}, + AppHandle, Emitter, Manager, Runtime, +}; + +const TRAY_ID: &str = "buzz-tray"; +const OPEN_BUZZ_ID: &str = "tray-open-buzz"; +const NEW_CHANNEL_ID: &str = "tray-new-channel"; +const QUIT_ID: &str = "tray-quit"; +const OPEN_CHANNEL_PREFIX: &str = "tray-open-channel:"; +const OPEN_CHANNEL_ACTIVITY_SEPARATOR: char = '|'; +#[cfg(target_os = "macos")] +const TRAY_MENU_MINIMUM_WIDTH: f64 = 320.0; + +static PREVIEW_STARTED_AT: OnceLock = OnceLock::new(); + +/// A local-only menu preview for demonstrating the working-agent section +/// without connecting to a relay. It is deliberately unavailable in release +/// builds and must be explicitly enabled when launching the debug app. +fn preview_activities() -> Option> { + if !cfg!(debug_assertions) || std::env::var("BUZZ_TRAY_MENU_DEMO").ok().as_deref() != Some("1") + { + return None; + } + + let preview_elapsed = PREVIEW_STARTED_AT.get_or_init(Instant::now).elapsed(); + + Some(vec![ + TrayAgentActivity { + activity_id: "tray-preview-planning-scout".into(), + agent_name: "Scout".into(), + channel_id: "tray-preview-planning".into(), + channel_name: "planning".into(), + elapsed: format_elapsed(Duration::from_secs(192) + preview_elapsed), + }, + TrayAgentActivity { + activity_id: "tray-preview-planning-builder".into(), + agent_name: "Builder".into(), + channel_id: "tray-preview-planning".into(), + channel_name: "planning".into(), + elapsed: format_elapsed(Duration::from_secs(68) + preview_elapsed), + }, + TrayAgentActivity { + activity_id: "tray-preview-mobile-reviewer".into(), + agent_name: "Reviewer".into(), + channel_id: "tray-preview-mobile".into(), + channel_name: "mobile".into(), + elapsed: format_elapsed(Duration::from_secs(31) + preview_elapsed), + }, + ]) +} + +fn preview_recent_activities() -> Option> { + if !cfg!(debug_assertions) || std::env::var("BUZZ_TRAY_MENU_DEMO").ok().as_deref() != Some("1") + { + return None; + } + + Some(vec![TrayAgentActivity { + activity_id: "recent:tray-preview-design-architect".into(), + agent_name: "Architect".into(), + channel_id: "tray-preview-design".into(), + channel_name: "design".into(), + elapsed: "4m 25s".into(), + }]) +} + +fn format_elapsed(elapsed: Duration) -> String { + let total_seconds = elapsed.as_secs(); + if total_seconds < 60 { + return format!("{total_seconds}s"); + } + + let seconds = total_seconds % 60; + let total_minutes = total_seconds / 60; + if total_minutes < 60 { + return format!("{total_minutes}m {seconds}s"); + } + + let minutes = total_minutes % 60; + let hours = total_minutes / 60; + format!("{hours}h {minutes}m {seconds}s") +} + +/// Builds the standalone Buzz bee as a transparent, macOS template image. +/// +/// The app icon includes a rounded square, which is useful for the Dock but +/// looks out of place beside the monochrome menu-bar icons. Keeping this +/// vector-derived mask here also lets macOS tint it correctly in light and +/// dark menu bars without a separate bitmap asset. +fn tray_bee_icon() -> Image<'static> { + const WIDTH: u32 = 64; + const HEIGHT: u32 = 43; + const SAMPLES_PER_AXIS: u32 = 4; + const BEE_WIDTH: f32 = 466.0; + const BEE_HEIGHT: f32 = 309.0; + + fn circle_contains(x: f32, y: f32, center_x: f32, center_y: f32, radius: f32) -> bool { + let delta_x = x - center_x; + let delta_y = y - center_y; + delta_x * delta_x + delta_y * delta_y <= radius * radius + } + + fn rounded_rect_contains( + x: f32, + y: f32, + left: f32, + top: f32, + width: f32, + height: f32, + radius: f32, + ) -> bool { + let right = left + width; + let bottom = top + height; + let closest_x = x.clamp(left + radius, right - radius); + let closest_y = y.clamp(top + radius, bottom - radius); + let delta_x = x - closest_x; + let delta_y = y - closest_y; + delta_x * delta_x + delta_y * delta_y <= radius * radius + } + + fn bee_contains(x: f32, y: f32) -> bool { + let silhouette = circle_contains(x, y, 91.7, 154.5, 91.7) + || circle_contains(x, y, 374.3, 154.5, 91.7) + || rounded_rect_contains(x, y, 128.0, 0.0, 210.0, 309.0, 34.0); + let cutout = circle_contains(x, y, 193.3, 84.4, 27.0) + || circle_contains(x, y, 276.0, 84.4, 27.0) + || rounded_rect_contains(x, y, 166.3, 157.2, 136.9, 38.3, 5.0) + || rounded_rect_contains(x, y, 166.9, 235.1, 136.2, 37.6, 5.0); + + silhouette && !cutout + } + + let mut rgba = vec![0; (WIDTH * HEIGHT * 4) as usize]; + let samples = SAMPLES_PER_AXIS * SAMPLES_PER_AXIS; + + for pixel_y in 0..HEIGHT { + for pixel_x in 0..WIDTH { + let mut covered_samples = 0; + for sample_y in 0..SAMPLES_PER_AXIS { + for sample_x in 0..SAMPLES_PER_AXIS { + let x = (pixel_x as f32 + (sample_x as f32 + 0.5) / SAMPLES_PER_AXIS as f32) + / WIDTH as f32 + * BEE_WIDTH; + let y = (pixel_y as f32 + (sample_y as f32 + 0.5) / SAMPLES_PER_AXIS as f32) + / HEIGHT as f32 + * BEE_HEIGHT; + if bee_contains(x, y) { + covered_samples += 1; + } + } + } + + let index = ((pixel_y * WIDTH + pixel_x) * 4) as usize; + rgba[index + 3] = (covered_samples * u8::MAX as u32 / samples) as u8; + } + } + + Image::new_owned(rgba, WIDTH, HEIGHT) +} + +/// A running agent and its current channel. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TrayAgentActivity { + activity_id: String, + agent_name: String, + channel_id: String, + channel_name: String, + elapsed: String, +} + +struct TrayActivityMenuItem { + activity_id: String, + channel_id: String, + agent_item: MenuItem, +} + +struct TrayActionQueue { + community_generation: u64, + pending_actions: Vec, +} + +struct TrayMenuState { + activity_items: Mutex>>, + action_queue: Mutex, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum TrayAction { + NewChannel, + OpenChannel { + channel_id: String, + community_generation: u64, + }, +} + +pub(crate) fn show_main_window(app: &AppHandle) { + let Some(window) = app.get_webview_window("main") else { + return; + }; + if let Err(error) = window.unminimize() { + eprintln!("buzz-desktop: failed to restore main window from tray: {error}"); + return; + } + if let Err(error) = window.show() { + eprintln!("buzz-desktop: failed to show main window from tray: {error}"); + return; + } + if let Err(error) = window.set_focus() { + eprintln!("buzz-desktop: failed to focus main window from tray: {error}"); + } +} + +fn queue_tray_action(app: &AppHandle, mut action: TrayAction) { + let state = app.state::>(); + let Ok(mut queue) = state.action_queue.lock() else { + eprintln!("buzz-desktop: tray action queue is unavailable"); + return; + }; + if let TrayAction::OpenChannel { + community_generation, + .. + } = &mut action + { + *community_generation = queue.community_generation; + } + queue.pending_actions.push(action); + drop(queue); + + if let Err(error) = app.emit("tray-action-available", ()) { + eprintln!("buzz-desktop: failed to notify frontend of tray action: {error}"); + } +} + +fn append_separator(app: &AppHandle, menu: &Menu) -> tauri::Result<()> { + menu.append(&PredefinedMenuItem::separator(app)?) +} + +fn agent_item_label(activity: &TrayAgentActivity) -> String { + let primary = format!("{} · {}", activity.agent_name, activity.elapsed); + + #[cfg(target_os = "macos")] + { + if supports_menu_item_subtitles() { + primary + } else { + format!("{primary} — #{}", activity.channel_name) + } + } + + #[cfg(not(target_os = "macos"))] + { + format!("{primary} — #{}", activity.channel_name) + } +} + +#[cfg(target_os = "macos")] +fn supports_menu_item_subtitles() -> bool { + static SUPPORTS_SUBTITLES: OnceLock = OnceLock::new(); + *SUPPORTS_SUBTITLES.get_or_init(|| { + NSProcessInfo::processInfo() + .operatingSystemVersion() + .majorVersion + >= 14 + }) +} + +fn channel_item_id(activity: &TrayAgentActivity) -> String { + format!( + "{OPEN_CHANNEL_PREFIX}{}{OPEN_CHANNEL_ACTIVITY_SEPARATOR}{}", + activity.channel_id, activity.activity_id + ) +} + +fn build_menu( + app: &AppHandle, + activities: &[TrayAgentActivity], + recent_activities: &[TrayAgentActivity], +) -> tauri::Result<(Menu, Vec>)> { + let menu = Menu::new(app)?; + let mut activity_items = + Vec::with_capacity(activities.len().saturating_add(recent_activities.len())); + + let running = MenuItem::new(app, "Running", false, None::<&str>)?; + menu.append(&running)?; + + if activities.is_empty() { + let empty = MenuItem::new(app, "No agents are running", false, None::<&str>)?; + menu.append(&empty)?; + } else { + append_activity_items(app, &menu, activities, &mut activity_items)?; + } + + if !recent_activities.is_empty() { + append_separator(app, &menu)?; + let recent = MenuItem::new(app, "Recent", false, None::<&str>)?; + menu.append(&recent)?; + append_activity_items(app, &menu, recent_activities, &mut activity_items)?; + } + + append_separator(app, &menu)?; + menu.append(&MenuItem::with_id( + app, + NEW_CHANNEL_ID, + "New Channel", + true, + None::<&str>, + )?)?; + append_separator(app, &menu)?; + menu.append(&MenuItem::with_id( + app, + OPEN_BUZZ_ID, + "Open Buzz", + true, + None::<&str>, + )?)?; + append_separator(app, &menu)?; + menu.append(&MenuItem::with_id( + app, + QUIT_ID, + "Quit Buzz", + true, + None::<&str>, + )?)?; + + Ok((menu, activity_items)) +} + +fn append_activity_items( + app: &AppHandle, + menu: &Menu, + activities: &[TrayAgentActivity], + activity_items: &mut Vec>, +) -> tauri::Result<()> { + for activity in activities { + let agent_item = MenuItem::with_id( + app, + channel_item_id(activity), + agent_item_label(activity), + true, + None::<&str>, + )?; + menu.append(&agent_item)?; + activity_items.push(TrayActivityMenuItem { + activity_id: activity.activity_id.clone(), + channel_id: activity.channel_id.clone(), + agent_item, + }); + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn apply_activity_presentation( + tray: &TrayIcon, + activities: &[TrayAgentActivity], + recent_activities: &[TrayAgentActivity], +) -> Result<(), String> { + if !supports_menu_item_subtitles() { + return Ok(()); + } + + let subtitles = activities + .iter() + .chain(recent_activities) + .map(|activity| format!("#{}", activity.channel_name)) + .collect::>(); + let running_count = activities.len(); + let recent_count = recent_activities.len(); + + tray.with_inner_tray_icon(move |inner| { + let Some(status_item) = inner.ns_status_item() else { + return; + }; + let Some(main_thread) = MainThreadMarker::new() else { + return; + }; + let Some(menu) = status_item.menu(main_thread) else { + return; + }; + menu.setMinimumWidth(TRAY_MENU_MINIMUM_WIDTH); + + let mut item_index = 1; + for subtitle in subtitles.iter().take(running_count) { + if let Some(item) = menu.itemAtIndex(item_index) { + let subtitle = NSString::from_str(subtitle); + item.setSubtitle(Some(&subtitle)); + } + item_index += 1; + } + + if running_count == 0 { + item_index += 1; + } + + if recent_count > 0 { + // The separator and Recent heading precede the completed rows. + item_index += 2; + for subtitle in subtitles.iter().skip(running_count) { + if let Some(item) = menu.itemAtIndex(item_index) { + let subtitle = NSString::from_str(subtitle); + item.setSubtitle(Some(&subtitle)); + } + item_index += 1; + } + } + }) + .map_err(|error| error.to_string()) +} + +#[cfg(not(target_os = "macos"))] +fn apply_activity_presentation( + _tray: &TrayIcon, + _activities: &[TrayAgentActivity], + _recent_activities: &[TrayAgentActivity], +) -> Result<(), String> { + Ok(()) +} + +fn handle_menu_event(app: &AppHandle, id: &str) { + match id { + OPEN_BUZZ_ID => show_main_window(app), + NEW_CHANNEL_ID => { + show_main_window(app); + queue_tray_action(app, TrayAction::NewChannel); + } + QUIT_ID => app.exit(0), + _ => { + let Some(channel_id) = id.strip_prefix(OPEN_CHANNEL_PREFIX) else { + return; + }; + show_main_window(app); + let channel_id = channel_id + .split_once(OPEN_CHANNEL_ACTIVITY_SEPARATOR) + .map(|(channel_id, _)| channel_id) + .unwrap_or(channel_id); + queue_tray_action( + app, + TrayAction::OpenChannel { + channel_id: channel_id.into(), + community_generation: 0, + }, + ); + } + } +} + +/// Installs the persistent Buzz tray icon with the initial empty activity menu. +pub fn init(app: &AppHandle) -> tauri::Result<()> { + let preview_activities = preview_activities(); + let preview_recent_activities = preview_recent_activities(); + let activities = preview_activities.as_deref().unwrap_or(&[]); + let recent_activities = preview_recent_activities.as_deref().unwrap_or(&[]); + let (menu, activity_items) = build_menu(app, activities, recent_activities)?; + app.manage(TrayMenuState { + activity_items: Mutex::new(activity_items), + action_queue: Mutex::new(TrayActionQueue { + community_generation: 0, + pending_actions: Vec::new(), + }), + }); + let tray = TrayIconBuilder::with_id(TRAY_ID) + .menu(&menu) + .icon(tray_bee_icon()) + .icon_as_template(true) + .on_menu_event(|app, event| handle_menu_event(app, event.id.as_ref())) + .build(app)?; + if let Err(error) = apply_activity_presentation(&tray, activities, recent_activities) { + eprintln!("buzz-desktop: failed to apply tray menu presentation: {error}"); + } + Ok(()) +} + +/// Drains actions selected from the tray while the frontend was unavailable. +#[tauri::command] +pub fn take_tray_actions(app: AppHandle) -> Result, String> { + let state = app.state::>(); + let mut queue = state + .action_queue + .lock() + .map_err(|_| "Buzz tray action queue is unavailable".to_string())?; + Ok(std::mem::take(&mut queue.pending_actions)) +} + +fn requeue_actions(queue: &mut TrayActionQueue, mut actions: Vec) { + actions.retain(|action| match action { + TrayAction::NewChannel => true, + TrayAction::OpenChannel { + community_generation, + .. + } => *community_generation == queue.community_generation, + }); + actions.append(&mut queue.pending_actions); + queue.pending_actions = actions; +} + +/// Restores actions that were drained as the frontend unmounted. Channel +/// actions from a previous community generation are discarded. +#[tauri::command] +pub fn requeue_tray_actions( + app: AppHandle, + actions: Vec, +) -> Result<(), String> { + let state = app.state::>(); + let mut queue = state + .action_queue + .lock() + .map_err(|_| "Buzz tray action queue is unavailable".to_string())?; + requeue_actions(&mut queue, actions); + drop(queue); + app.emit("tray-action-available", ()) + .map_err(|error| error.to_string()) +} + +/// Clears community-scoped agent activity and queued channel navigation from +/// the native tray menu. +#[tauri::command] +pub fn clear_tray_agent_activity(app: AppHandle) -> Result<(), String> { + let state = app.state::>(); + let mut queue = state + .action_queue + .lock() + .map_err(|_| "Buzz tray action queue is unavailable".to_string())?; + queue.community_generation = queue.community_generation.wrapping_add(1); + queue + .pending_actions + .retain(|action| matches!(action, TrayAction::NewChannel)); + drop(queue); + + update_tray_agent_activity(app, Vec::new(), Vec::new()) +} + +/// Replaces the native menu's activity section with the current live work. +#[tauri::command] +pub fn update_tray_agent_activity( + app: AppHandle, + activities: Vec, + recent_activities: Vec, +) -> Result<(), String> { + let preview_activities = preview_activities(); + let preview_recent_activities = preview_recent_activities(); + let activities = preview_activities.as_deref().unwrap_or(&activities); + let recent_activities = preview_recent_activities + .as_deref() + .unwrap_or(&recent_activities); + let state = app.state::>(); + let mut activity_items = state + .activity_items + .lock() + .map_err(|_| "Buzz tray menu state is unavailable".to_string())?; + + if activity_items.len() == activities.len().saturating_add(recent_activities.len()) + && activity_items + .iter() + .zip(activities.iter().chain(recent_activities)) + .all(|(item, activity)| { + item.activity_id == activity.activity_id && item.channel_id == activity.channel_id + }) + { + for (item, activity) in activity_items + .iter() + .zip(activities.iter().chain(recent_activities)) + { + item.agent_item + .set_text(agent_item_label(activity)) + .map_err(|error| error.to_string())?; + } + let tray = app + .tray_by_id(TRAY_ID) + .ok_or_else(|| "Buzz tray icon is not available".to_string())?; + apply_activity_presentation(&tray, activities, recent_activities)?; + return Ok(()); + } + + let (menu, next_activity_items) = + build_menu(&app, activities, recent_activities).map_err(|error| error.to_string())?; + let tray = app + .tray_by_id(TRAY_ID) + .ok_or_else(|| "Buzz tray icon is not available".to_string())?; + tray.set_menu(Some(menu)) + .map_err(|error| error.to_string())?; + apply_activity_presentation(&tray, activities, recent_activities)?; + *activity_items = next_activity_items; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{requeue_actions, TrayAction, TrayActionQueue}; + + #[test] + fn stale_channel_actions_are_not_requeued_after_community_change() { + let mut queue = TrayActionQueue { + community_generation: 2, + pending_actions: Vec::new(), + }; + + requeue_actions( + &mut queue, + vec![TrayAction::OpenChannel { + channel_id: "old-channel".into(), + community_generation: 1, + }], + ); + + assert!(queue.pending_actions.is_empty()); + } + + #[test] + fn new_channel_actions_survive_community_change() { + let mut queue = TrayActionQueue { + community_generation: 2, + pending_actions: Vec::new(), + }; + + requeue_actions(&mut queue, vec![TrayAction::NewChannel]); + + assert_eq!(queue.pending_actions, vec![TrayAction::NewChannel]); + } +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index abbbf29610..b856434e61 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -96,6 +96,7 @@ import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; +import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu"; const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); @@ -614,16 +615,12 @@ export function AppShell() { }, [openSearchHit], ); - useAppShellLifecycleEffects({ homeBadgeCountExcludingHighPriority, unreadChannelIds, unreadChannelNotificationCount, }); - - // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); - const handleOpenNewDm = React.useCallback( () => void goNewMessage(), [goNewMessage], @@ -707,9 +704,13 @@ export function AppShell() { markChannelRead, selectedView, }); - return ( + Promise; + openCreateChannel: () => void; +}) { + if (!isMacPlatform()) return null; + return ( + + ); +} + +function MacAppShellTrayMenu({ + channels, + goChannel, + openCreateChannel, +}: { + channels: Channel[]; + goChannel: (channelId: string) => Promise; + openCreateChannel: () => void; +}): null { + useTrayMenu({ + channels, + goChannel, + openCreateChannel, + }); + return null; +} diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts new file mode 100644 index 0000000000..355c8e5d4f --- /dev/null +++ b/desktop/src/app/useTrayMenu.ts @@ -0,0 +1,160 @@ +import * as React from "react"; +import { isTauri, invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; + +import { + getActiveTurnsForAgent, + useActiveAgentTurnsByChannel, +} from "@/features/agents/activeAgentTurnsStore"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { useNow } from "@/shared/lib/useNow"; +import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import type { Channel } from "@/shared/api/types"; + +type TrayAgentActivity = { + activityId: string; + agentName: string; + channelId: string; + channelName: string; + elapsed: string; +}; + +type TrayAction = + | { kind: "newChannel" } + | { kind: "openChannel"; channelId: string }; + +const MAX_RECENT_TRAY_ACTIVITIES = 5; + +/** + * Keeps Buzz's native tray menu synchronized with active agent turns and + * forwards its navigation actions into the React app. + */ +export function useTrayMenu({ + channels, + goChannel, + openCreateChannel, +}: { + channels: Channel[]; + goChannel: (channelId: string) => Promise; + openCreateChannel: () => void; +}): void { + const activeTurns = useActiveAgentTurnsByChannel(); + const now = useNow(1000); + const managedAgents = useManagedAgentsQuery().data; + const relayAgents = useRelayAgentsQuery().data; + const previousActivitiesRef = React.useRef( + new Map(), + ); + const [recentActivities, setRecentActivities] = React.useState< + TrayAgentActivity[] + >([]); + + const activities = React.useMemo(() => { + const channelNames = new Map( + channels.map((channel) => [channel.id, channel.name]), + ); + const agentNames = new Map(); + for (const agent of [...(managedAgents ?? []), ...(relayAgents ?? [])]) { + agentNames.set(normalizePubkey(agent.pubkey), agent.name); + } + + return activeTurns.flatMap((channelTurn) => + channelTurn.agentPubkeys.map((pubkey) => { + const agentTurn = getActiveTurnsForAgent(pubkey).find( + (turn) => turn.channelId === channelTurn.channelId, + ); + + return { + activityId: `${channelTurn.channelId}:${normalizePubkey(pubkey)}`, + agentName: + agentNames.get(normalizePubkey(pubkey)) ?? + `Agent ${truncatePubkey(pubkey)}`, + channelId: channelTurn.channelId, + channelName: + channelNames.get(channelTurn.channelId) ?? "Unknown channel", + elapsed: formatElapsed( + now - (agentTurn?.anchorAt ?? channelTurn.anchorAt), + ), + }; + }), + ); + }, [activeTurns, channels, managedAgents, now, relayAgents]); + + React.useEffect(() => { + const currentActivities = new Map( + activities.map((activity) => [activity.activityId, activity]), + ); + const completedActivities = [...previousActivitiesRef.current.entries()] + .filter(([activityId]) => !currentActivities.has(activityId)) + .map(([, activity]) => ({ + ...activity, + activityId: `recent:${activity.activityId}:${Date.now()}`, + })); + + if (completedActivities.length > 0) { + setRecentActivities((current) => + [...completedActivities, ...current].slice( + 0, + MAX_RECENT_TRAY_ACTIVITIES, + ), + ); + } + previousActivitiesRef.current = currentActivities; + }, [activities]); + + React.useEffect(() => { + if (!isTauri()) return; + void invoke("update_tray_agent_activity", { + activities, + recentActivities, + }).catch((error) => { + console.error("Failed to update the macOS tray menu", error); + }); + }, [activities, recentActivities]); + + React.useEffect(() => { + if (!isTauri()) return; + + let disposed = false; + let unlisten: (() => void) | undefined; + + const handlePendingActions = async () => { + if (disposed) return; + const actions = await invoke("take_tray_actions"); + if (disposed) { + if (actions.length > 0) { + await invoke("requeue_tray_actions", { actions }); + } + return; + } + for (const action of actions) { + if (action.kind === "newChannel") { + openCreateChannel(); + } else { + void goChannel(action.channelId); + } + } + }; + + void (async () => { + const nextUnlisten = await listen("tray-action-available", () => { + void handlePendingActions(); + }); + if (disposed) { + nextUnlisten(); + return; + } + unlisten = nextUnlisten; + await handlePendingActions(); + })(); + + return () => { + disposed = true; + unlisten?.(); + }; + }, [goChannel, openCreateChannel]); +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index abb49485d2..afa69f913f 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -1,4 +1,6 @@ import { useEffect, useRef, useState } from "react"; +import { isTauri } from "@tauri-apps/api/core"; +import { isMacPlatform } from "@/shared/lib/platform"; import { relayClient } from "@/shared/api/relayClient"; import { resetRateLimitGate } from "@/shared/api/relayRateLimitGate"; @@ -8,6 +10,7 @@ import { getDefaultRelayUrl, } from "@/shared/api/tauri"; import { getIdentity } from "@/shared/api/tauriIdentity"; +import { clearTrayAgentActivity } from "@/shared/api/trayMenu"; import { getOverrides } from "@/shared/features"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; @@ -53,6 +56,9 @@ function resetCommunityState({ resetAgentObserverStore(); resetActiveAgentTurnsStore(); resetAgentWorkingSignal(); + if (isTauri() && isMacPlatform()) { + void clearTrayAgentActivity(); + } if (resetAvatarState) { resetAvatarProfileSync(); resetAvatarPresentations(); diff --git a/desktop/src/shared/api/trayMenu.ts b/desktop/src/shared/api/trayMenu.ts new file mode 100644 index 0000000000..86581e0cbf --- /dev/null +++ b/desktop/src/shared/api/trayMenu.ts @@ -0,0 +1,6 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +/** Clears community-scoped agent activity from the native tray menu. */ +export function clearTrayAgentActivity(): Promise { + return invokeTauri("clear_tray_agent_activity"); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4cfc553df1..d6d8070050 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9866,6 +9866,12 @@ export function maybeInstallE2eTauriMocks() { } return; } + case "update_tray_agent_activity": + case "clear_tray_agent_activity": + case "requeue_tray_actions": + return null; + case "take_tray_actions": + return []; case "get_profile": return handleGetProfile(activeConfig); case "update_profile":