From 52fea4e0ec1ccb3c5e5c12f625a5fee4596cbf61 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 16:12:06 +0100 Subject: [PATCH 1/8] Add agent tray menu Signed-off-by: kenny lopez --- desktop/src-tauri/Cargo.lock | 2 + desktop/src-tauri/Cargo.toml | 6 +- desktop/src-tauri/src/lib.rs | 3 + desktop/src-tauri/src/tray_menu.rs | 475 +++++++++++++++++++++++++ desktop/src/app/AppShell.tsx | 2 + desktop/src/app/useAppShellTrayMenu.ts | 16 + desktop/src/app/useTrayMenu.ts | 128 +++++++ 7 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 desktop/src-tauri/src/tray_menu.rs create mode 100644 desktop/src/app/useAppShellTrayMenu.ts create mode 100644 desktop/src/app/useTrayMenu.ts diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 66553ef595..36ce44073b 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 324218a49d..7620107d0e 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 = ["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..44f089695a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -28,6 +28,7 @@ mod reset; mod secret_store; mod shutdown; mod templates; +mod tray_menu; mod util; #[cfg(target_os = "linux")] pub mod webkit_rendering; @@ -360,6 +361,7 @@ pub fn run() { .manage(commands::pairing::PairingHandle::new()) .setup(move |app| { let app_handle = app.handle().clone(); + tray_menu::init(&app_handle)?; // ── Phase 2: boot-time sentinel wipe ────────────────────────────── // Must run before migrations and identity resolution so the wipe @@ -895,6 +897,7 @@ pub fn run() { archive::read_unindexed_observer_rows, is_auto_update_supported, set_window_vibrancy, + tray_menu::update_tray_agent_activity, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs new file mode 100644 index 0000000000..c8a42a35cb --- /dev/null +++ b/desktop/src-tauri/src/tray_menu.rs @@ -0,0 +1,475 @@ +//! 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::NSString; +use serde::Deserialize; +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 TrayMenuState { + activity_items: Mutex>>, +} + +fn show_main_window(app: &AppHandle) { + let Some(window) = app.get_webview_window("main") else { + 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 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")] + { + primary + } + + #[cfg(not(target_os = "macos"))] + { + format!("{primary} — #{}", activity.channel_name) + } +} + +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> { + 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); + let _ = app.emit("tray-new-channel", ()); + } + 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); + let _ = app.emit("tray-open-channel", channel_id); + } + } +} + +/// 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), + }); + 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)?; + apply_activity_presentation(&tray, activities, recent_activities) + .map_err(anyhow::Error::msg)?; + Ok(()) +} + +/// 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(()) +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 75f57257cc..0e8f2880f5 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 { useAppShellTrayMenu } from "@/app/useAppShellTrayMenu"; const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); @@ -631,6 +632,7 @@ export function AppShell() { () => setIsCreateChannelOpen(true), [], ); + useAppShellTrayMenu(channels, goChannel, handleOpenCreateChannel); React.useLayoutEffect(() => { if (settingsOpen) { return; diff --git a/desktop/src/app/useAppShellTrayMenu.ts b/desktop/src/app/useAppShellTrayMenu.ts new file mode 100644 index 0000000000..a0ed67fc86 --- /dev/null +++ b/desktop/src/app/useAppShellTrayMenu.ts @@ -0,0 +1,16 @@ +import type { Channel } from "@/shared/api/types"; + +import { useTrayMenu } from "@/app/useTrayMenu"; + +/** Connects the native tray menu to the AppShell's channel navigation. */ +export function useAppShellTrayMenu( + channels: Channel[], + goChannel: (channelId: string) => Promise, + openCreateChannel: () => void, +): void { + useTrayMenu({ + channels, + goChannel, + openCreateChannel, + }); +} diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts new file mode 100644 index 0000000000..d6af5ba7db --- /dev/null +++ b/desktop/src/app/useTrayMenu.ts @@ -0,0 +1,128 @@ +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; +}; + +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, + }); + }, [activities, recentActivities]); + + React.useEffect(() => { + if (!isTauri()) return; + + const unlistenChannel = listen("tray-open-channel", (event) => { + void goChannel(event.payload); + }); + const unlistenNewChannel = listen("tray-new-channel", () => { + openCreateChannel(); + }); + + return () => { + void unlistenChannel.then((unlisten) => unlisten()); + void unlistenNewChannel.then((unlisten) => unlisten()); + }; + }, [goChannel, openCreateChannel]); +} From 2b58b5b71c67ff90edde429d1cfc0b5b11ee01d1 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 16:40:45 +0100 Subject: [PATCH 2/8] Handle tray menu lifecycle Signed-off-by: kenny lopez --- desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/tray_menu.rs | 55 ++++++++++++++++++- desktop/src/app/useTrayMenu.ts | 35 +++++++++--- .../features/communities/useCommunityInit.ts | 5 ++ desktop/src/shared/api/trayMenu.ts | 6 ++ 5 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 desktop/src/shared/api/trayMenu.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 44f089695a..b08912633e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -897,6 +897,8 @@ pub fn run() { archive::read_unindexed_observer_rows, is_auto_update_supported, set_window_vibrancy, + tray_menu::clear_tray_agent_activity, + tray_menu::take_tray_actions, tray_menu::update_tray_agent_activity, ]) .build(tauri::generate_context!()) diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs index c8a42a35cb..bb6c14c1ae 100644 --- a/desktop/src-tauri/src/tray_menu.rs +++ b/desktop/src-tauri/src/tray_menu.rs @@ -12,7 +12,7 @@ use std::{ use objc2::MainThreadMarker; #[cfg(target_os = "macos")] use objc2_foundation::NSString; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tauri::{ image::Image, menu::{Menu, MenuItem, PredefinedMenuItem}, @@ -195,12 +195,24 @@ struct TrayActivityMenuItem { struct TrayMenuState { activity_items: Mutex>>, + pending_actions: Mutex>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum TrayAction { + NewChannel, + OpenChannel { channel_id: String }, } 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; @@ -210,6 +222,20 @@ fn show_main_window(app: &AppHandle) { } } +fn queue_tray_action(app: &AppHandle, action: TrayAction) { + let state = app.state::>(); + let Ok(mut actions) = state.pending_actions.lock() else { + eprintln!("buzz-desktop: tray action queue is unavailable"); + return; + }; + actions.push(action); + drop(actions); + + 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)?) } @@ -382,7 +408,7 @@ fn handle_menu_event(app: &AppHandle, id: &str) { OPEN_BUZZ_ID => show_main_window(app), NEW_CHANNEL_ID => { show_main_window(app); - let _ = app.emit("tray-new-channel", ()); + queue_tray_action(app, TrayAction::NewChannel); } QUIT_ID => app.exit(0), _ => { @@ -394,7 +420,12 @@ fn handle_menu_event(app: &AppHandle, id: &str) { .split_once(OPEN_CHANNEL_ACTIVITY_SEPARATOR) .map(|(channel_id, _)| channel_id) .unwrap_or(channel_id); - let _ = app.emit("tray-open-channel", channel_id); + queue_tray_action( + app, + TrayAction::OpenChannel { + channel_id: channel_id.into(), + }, + ); } } } @@ -408,6 +439,7 @@ pub fn init(app: &AppHandle) -> tauri::Result<()> { let (menu, activity_items) = build_menu(app, activities, recent_activities)?; app.manage(TrayMenuState { activity_items: Mutex::new(activity_items), + pending_actions: Mutex::new(Vec::new()), }); let tray = TrayIconBuilder::with_id(TRAY_ID) .menu(&menu) @@ -420,6 +452,23 @@ pub fn init(app: &AppHandle) -> tauri::Result<()> { 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 actions = state + .pending_actions + .lock() + .map_err(|_| "Buzz tray action queue is unavailable".to_string())?; + Ok(std::mem::take(&mut *actions)) +} + +/// Clears community-scoped agent activity from the native tray menu. +#[tauri::command] +pub fn clear_tray_agent_activity(app: AppHandle) -> Result<(), String> { + 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( diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index d6af5ba7db..f47f6ae0cb 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -23,6 +23,10 @@ type TrayAgentActivity = { elapsed: string; }; +type TrayAction = + | { kind: "newChannel" } + | { kind: "openChannel"; channelId: string }; + const MAX_RECENT_TRAY_ACTIVITIES = 5; /** @@ -113,16 +117,31 @@ export function useTrayMenu({ React.useEffect(() => { if (!isTauri()) return; - const unlistenChannel = listen("tray-open-channel", (event) => { - void goChannel(event.payload); - }); - const unlistenNewChannel = listen("tray-new-channel", () => { - openCreateChannel(); - }); + let disposed = false; + let unlisten: (() => void) | undefined; + + const handlePendingActions = async () => { + const actions = await invoke("take_tray_actions"); + if (disposed) return; + for (const action of actions) { + if (action.kind === "newChannel") { + openCreateChannel(); + } else { + void goChannel(action.channelId); + } + } + }; + + void (async () => { + unlisten = await listen("tray-action-available", () => { + void handlePendingActions(); + }); + await handlePendingActions(); + })(); return () => { - void unlistenChannel.then((unlisten) => unlisten()); - void unlistenNewChannel.then((unlisten) => unlisten()); + disposed = true; + unlisten?.(); }; }, [goChannel, openCreateChannel]); } diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index abb49485d2..71b9ccf7c4 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { isTauri } from "@tauri-apps/api/core"; import { relayClient } from "@/shared/api/relayClient"; import { resetRateLimitGate } from "@/shared/api/relayRateLimitGate"; @@ -8,6 +9,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 +55,9 @@ function resetCommunityState({ resetAgentObserverStore(); resetActiveAgentTurnsStore(); resetAgentWorkingSignal(); + if (isTauri()) { + 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"); +} From 88d4ed0fb4a90b633c86ffd1b19a883b3038a65e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 16:54:31 +0100 Subject: [PATCH 3/8] Address tray menu review feedback Signed-off-by: kenny lopez --- desktop/src/app/AppShell.tsx | 12 ++++++------ desktop/src/app/useAppShellTrayMenu.ts | 17 +++++++++++------ desktop/src/app/useTrayMenu.ts | 8 +++++++- desktop/src/testing/e2eBridge.ts | 5 +++++ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 0e8f2880f5..5127dacf21 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -96,7 +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 { useAppShellTrayMenu } from "@/app/useAppShellTrayMenu"; +import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu"; const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); @@ -614,16 +614,13 @@ export function AppShell() { }, [openSearchHit], ); - useAppShellLifecycleEffects({ homeBadgeCountExcludingHighPriority, unreadChannelIds, unreadChannelNotificationCount, }); - // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); - const handleOpenNewDm = React.useCallback( () => void goNewMessage(), [goNewMessage], @@ -632,7 +629,6 @@ export function AppShell() { () => setIsCreateChannelOpen(true), [], ); - useAppShellTrayMenu(channels, goChannel, handleOpenCreateChannel); React.useLayoutEffect(() => { if (settingsOpen) { return; @@ -708,9 +704,13 @@ export function AppShell() { markChannelRead, selectedView, }); - return ( + Promise, - openCreateChannel: () => void, -): void { +/** Keeps the ticking native tray menu outside AppShell's render cycle. */ +export function AppShellTrayMenu({ + 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 index f47f6ae0cb..69fd496c10 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -121,6 +121,7 @@ export function useTrayMenu({ let unlisten: (() => void) | undefined; const handlePendingActions = async () => { + if (disposed) return; const actions = await invoke("take_tray_actions"); if (disposed) return; for (const action of actions) { @@ -133,9 +134,14 @@ export function useTrayMenu({ }; void (async () => { - unlisten = await listen("tray-action-available", () => { + const nextUnlisten = await listen("tray-action-available", () => { void handlePendingActions(); }); + if (disposed) { + nextUnlisten(); + return; + } + unlisten = nextUnlisten; await handlePendingActions(); })(); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7b13273c60..b5e9127ed7 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9865,6 +9865,11 @@ export function maybeInstallE2eTauriMocks() { } return; } + case "update_tray_agent_activity": + case "clear_tray_agent_activity": + return null; + case "take_tray_actions": + return []; case "get_profile": return handleGetProfile(activeConfig); case "update_profile": From ae0b9b0789ba0d04efd093db85b0fe975df21c6e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 17:02:06 +0100 Subject: [PATCH 4/8] Harden tray menu compatibility Signed-off-by: kenny lopez --- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/tray_menu.rs | 43 +++++++++++++++++++++++++++--- desktop/src/app/useTrayMenu.ts | 7 ++++- desktop/src/testing/e2eBridge.ts | 1 + 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 7620107d0e..375c0672ea 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -44,7 +44,7 @@ notify-rust = "4" [target.'cfg(target_os = "macos")'.dependencies] 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 = ["NSString"] } +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" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b08912633e..1dd3551754 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -898,6 +898,7 @@ pub fn run() { is_auto_update_supported, set_window_vibrancy, tray_menu::clear_tray_agent_activity, + tray_menu::requeue_tray_actions, tray_menu::take_tray_actions, tray_menu::update_tray_agent_activity, ]) diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs index bb6c14c1ae..6b9707f3be 100644 --- a/desktop/src-tauri/src/tray_menu.rs +++ b/desktop/src-tauri/src/tray_menu.rs @@ -11,7 +11,7 @@ use std::{ #[cfg(target_os = "macos")] use objc2::MainThreadMarker; #[cfg(target_os = "macos")] -use objc2_foundation::NSString; +use objc2_foundation::{NSProcessInfo, NSString}; use serde::{Deserialize, Serialize}; use tauri::{ image::Image, @@ -198,7 +198,7 @@ struct TrayMenuState { pending_actions: Mutex>, } -#[derive(Debug, Serialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum TrayAction { NewChannel, @@ -245,7 +245,11 @@ fn agent_item_label(activity: &TrayAgentActivity) -> String { #[cfg(target_os = "macos")] { - primary + if supports_menu_item_subtitles() { + primary + } else { + format!("{primary} — #{}", activity.channel_name) + } } #[cfg(not(target_os = "macos"))] @@ -254,6 +258,17 @@ fn agent_item_label(activity: &TrayAgentActivity) -> String { } } +#[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}{}", @@ -346,6 +361,10 @@ fn apply_activity_presentation( activities: &[TrayAgentActivity], recent_activities: &[TrayAgentActivity], ) -> Result<(), String> { + if !supports_menu_item_subtitles() { + return Ok(()); + } + let subtitles = activities .iter() .chain(recent_activities) @@ -463,6 +482,24 @@ pub fn take_tray_actions(app: AppHandle) -> Result( + app: AppHandle, + mut actions: Vec, +) -> Result<(), String> { + let state = app.state::>(); + let mut pending_actions = state + .pending_actions + .lock() + .map_err(|_| "Buzz tray action queue is unavailable".to_string())?; + actions.append(&mut pending_actions); + *pending_actions = actions; + drop(pending_actions); + app.emit("tray-action-available", ()) + .map_err(|error| error.to_string()) +} + /// Clears community-scoped agent activity from the native tray menu. #[tauri::command] pub fn clear_tray_agent_activity(app: AppHandle) -> Result<(), String> { diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index 69fd496c10..f11afe2cf7 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -123,7 +123,12 @@ export function useTrayMenu({ const handlePendingActions = async () => { if (disposed) return; const actions = await invoke("take_tray_actions"); - if (disposed) return; + if (disposed) { + if (actions.length > 0) { + await invoke("requeue_tray_actions", { actions }); + } + return; + } for (const action of actions) { if (action.kind === "newChannel") { openCreateChannel(); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b5e9127ed7..6ea70d8327 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9867,6 +9867,7 @@ export function maybeInstallE2eTauriMocks() { } case "update_tray_agent_activity": case "clear_tray_agent_activity": + case "requeue_tray_actions": return null; case "take_tray_actions": return []; From 5331b823ff68ddd110859a925a233b17d3f5f7e6 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 18:40:25 +0100 Subject: [PATCH 5/8] Keep main window available for tray actions Signed-off-by: kenny lopez --- desktop/src-tauri/src/lib.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1dd3551754..9fb6cae87a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -64,9 +64,9 @@ 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")] @@ -913,6 +913,20 @@ 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::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); From c6d4e698ecf713e170eb3de76c07db6d064773d6 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 19:47:15 +0100 Subject: [PATCH 6/8] Clear stale tray actions on community switch Signed-off-by: kenny lopez --- desktop/src-tauri/src/lib.rs | 2 +- desktop/src-tauri/src/tray_menu.rs | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9fb6cae87a..c0561d4e4a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -916,7 +916,7 @@ pub fn run() { #[cfg(target_os = "macos")] RunEvent::WindowEvent { label, - event: WindowEvent::CloseRequested { api }, + event: WindowEvent::CloseRequested { api, .. }, .. } if label == "main" => { // Keep the webview alive so Buzz can be reopened from its tray menu. diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs index 6b9707f3be..82b5c452c3 100644 --- a/desktop/src-tauri/src/tray_menu.rs +++ b/desktop/src-tauri/src/tray_menu.rs @@ -500,9 +500,18 @@ pub fn requeue_tray_actions( .map_err(|error| error.to_string()) } -/// Clears community-scoped agent activity from the native tray menu. +/// 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 pending_actions = state + .pending_actions + .lock() + .map_err(|_| "Buzz tray action queue is unavailable".to_string())?; + pending_actions.retain(|action| matches!(action, TrayAction::NewChannel)); + drop(pending_actions); + update_tray_agent_activity(app, Vec::new(), Vec::new()) } From f0b0c335f0a5fbe0c6fb053dac723cdb2ded4c46 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 29 Jul 2026 13:58:08 -0600 Subject: [PATCH 7/8] Harden macOS tray menu lifecycle Scope the tray integration to macOS, fence channel actions across community changes, restore the main window from Dock reopen events, and keep cosmetic tray presentation failures from aborting startup. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src-tauri/src/lib.rs | 10 ++ desktop/src-tauri/src/tray_menu.rs | 120 ++++++++++++++---- ...ellTrayMenu.ts => useAppShellTrayMenu.tsx} | 20 +++ desktop/src/app/useTrayMenu.ts | 2 + .../features/communities/useCommunityInit.ts | 3 +- 5 files changed, 129 insertions(+), 26 deletions(-) rename desktop/src/app/{useAppShellTrayMenu.ts => useAppShellTrayMenu.tsx} (52%) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c0561d4e4a..35ba41bad4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -28,6 +28,7 @@ mod reset; mod secret_store; mod shutdown; mod templates; +#[cfg(target_os = "macos")] mod tray_menu; mod util; #[cfg(target_os = "linux")] @@ -68,6 +69,8 @@ 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"; @@ -361,6 +364,7 @@ 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 ────────────────────────────── @@ -897,9 +901,13 @@ 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!()) @@ -913,6 +921,8 @@ 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, diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs index 82b5c452c3..d733cd1f13 100644 --- a/desktop/src-tauri/src/tray_menu.rs +++ b/desktop/src-tauri/src/tray_menu.rs @@ -193,19 +193,27 @@ struct TrayActivityMenuItem { agent_item: MenuItem, } +struct TrayActionQueue { + community_generation: u64, + pending_actions: Vec, +} + struct TrayMenuState { activity_items: Mutex>>, - pending_actions: Mutex>, + action_queue: Mutex, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum TrayAction { NewChannel, - OpenChannel { channel_id: String }, + OpenChannel { + channel_id: String, + community_generation: u64, + }, } -fn show_main_window(app: &AppHandle) { +pub(crate) fn show_main_window(app: &AppHandle) { let Some(window) = app.get_webview_window("main") else { return; }; @@ -222,14 +230,21 @@ fn show_main_window(app: &AppHandle) { } } -fn queue_tray_action(app: &AppHandle, action: TrayAction) { +fn queue_tray_action(app: &AppHandle, mut action: TrayAction) { let state = app.state::>(); - let Ok(mut actions) = state.pending_actions.lock() else { + let Ok(mut queue) = state.action_queue.lock() else { eprintln!("buzz-desktop: tray action queue is unavailable"); return; }; - actions.push(action); - drop(actions); + 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}"); @@ -443,6 +458,7 @@ fn handle_menu_event(app: &AppHandle, id: &str) { app, TrayAction::OpenChannel { channel_id: channel_id.into(), + community_generation: 0, }, ); } @@ -458,7 +474,10 @@ pub fn init(app: &AppHandle) -> tauri::Result<()> { let (menu, activity_items) = build_menu(app, activities, recent_activities)?; app.manage(TrayMenuState { activity_items: Mutex::new(activity_items), - pending_actions: Mutex::new(Vec::new()), + action_queue: Mutex::new(TrayActionQueue { + community_generation: 0, + pending_actions: Vec::new(), + }), }); let tray = TrayIconBuilder::with_id(TRAY_ID) .menu(&menu) @@ -466,8 +485,9 @@ pub fn init(app: &AppHandle) -> tauri::Result<()> { .icon_as_template(true) .on_menu_event(|app, event| handle_menu_event(app, event.id.as_ref())) .build(app)?; - apply_activity_presentation(&tray, activities, recent_activities) - .map_err(anyhow::Error::msg)?; + if let Err(error) = apply_activity_presentation(&tray, activities, recent_activities) { + eprintln!("buzz-desktop: failed to apply tray menu presentation: {error}"); + } Ok(()) } @@ -475,27 +495,39 @@ pub fn init(app: &AppHandle) -> tauri::Result<()> { #[tauri::command] pub fn take_tray_actions(app: AppHandle) -> Result, String> { let state = app.state::>(); - let mut actions = state - .pending_actions + let mut queue = state + .action_queue .lock() .map_err(|_| "Buzz tray action queue is unavailable".to_string())?; - Ok(std::mem::take(&mut *actions)) + Ok(std::mem::take(&mut queue.pending_actions)) } -/// Restores actions that were drained as the frontend unmounted. +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, - mut actions: Vec, + actions: Vec, ) -> Result<(), String> { let state = app.state::>(); - let mut pending_actions = state - .pending_actions + let mut queue = state + .action_queue .lock() .map_err(|_| "Buzz tray action queue is unavailable".to_string())?; - actions.append(&mut pending_actions); - *pending_actions = actions; - drop(pending_actions); + requeue_actions(&mut queue, actions); + drop(queue); app.emit("tray-action-available", ()) .map_err(|error| error.to_string()) } @@ -505,12 +537,15 @@ pub fn requeue_tray_actions( #[tauri::command] pub fn clear_tray_agent_activity(app: AppHandle) -> Result<(), String> { let state = app.state::>(); - let mut pending_actions = state - .pending_actions + let mut queue = state + .action_queue .lock() .map_err(|_| "Buzz tray action queue is unavailable".to_string())?; - pending_actions.retain(|action| matches!(action, TrayAction::NewChannel)); - drop(pending_actions); + 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()) } @@ -568,3 +603,38 @@ pub fn update_tray_agent_activity( *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/useAppShellTrayMenu.ts b/desktop/src/app/useAppShellTrayMenu.tsx similarity index 52% rename from desktop/src/app/useAppShellTrayMenu.ts rename to desktop/src/app/useAppShellTrayMenu.tsx index 48b89e959c..e4b9774848 100644 --- a/desktop/src/app/useAppShellTrayMenu.ts +++ b/desktop/src/app/useAppShellTrayMenu.tsx @@ -1,4 +1,5 @@ import type { Channel } from "@/shared/api/types"; +import { isMacPlatform } from "@/shared/lib/platform"; import { useTrayMenu } from "@/app/useTrayMenu"; @@ -11,6 +12,25 @@ export function AppShellTrayMenu({ channels: Channel[]; goChannel: (channelId: string) => Promise; openCreateChannel: () => void; +}) { + if (!isMacPlatform()) return null; + return ( + + ); +} + +function MacAppShellTrayMenu({ + channels, + goChannel, + openCreateChannel, +}: { + channels: Channel[]; + goChannel: (channelId: string) => Promise; + openCreateChannel: () => void; }): null { useTrayMenu({ channels, diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index f11afe2cf7..355c8e5d4f 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -111,6 +111,8 @@ export function useTrayMenu({ void invoke("update_tray_agent_activity", { activities, recentActivities, + }).catch((error) => { + console.error("Failed to update the macOS tray menu", error); }); }, [activities, recentActivities]); diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 71b9ccf7c4..afa69f913f 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -1,5 +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"; @@ -55,7 +56,7 @@ function resetCommunityState({ resetAgentObserverStore(); resetActiveAgentTurnsStore(); resetAgentWorkingSignal(); - if (isTauri()) { + if (isTauri() && isMacPlatform()) { void clearTrayAgentActivity(); } if (resetAvatarState) { From 5d798fccce7205a2bd82a38299a8cf2badfc3828 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 29 Jul 2026 14:03:15 -0600 Subject: [PATCH 8/8] Keep AppShell within file-size limit Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/app/AppShell.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 5288aa681b..b856434e61 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -620,7 +620,6 @@ export function AppShell() { unreadChannelIds, unreadChannelNotificationCount, }); - // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); const handleOpenNewDm = React.useCallback( () => void goNewMessage(),