mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add macOS agent menu-bar menu (#3565)
## Summary - Add a **macOS-only** monochrome Buzz menu-bar icon with **Running** and **Recent** agent sections. - Show each agent as one selectable macOS row with `Name · elapsed` and its channel underneath. - Keep completed work available for quick channel re-entry, alongside New Channel, Open Buzz, and Quit Buzz. - Keep the main window alive on close and restore it from both the menu bar and the macOS Dock. - Fence queued channel actions by community generation so an in-flight action from the previous community cannot navigate the newly selected community. - Leave Windows and Linux unchanged; cross-platform tray lifecycle support can follow with platform-specific validation. <img width="812" height="760" alt="Buzz macOS agent menu" src="https://github.com/user-attachments/assets/19bfd874-8f06-4496-bdda-7ed2e7b5733f" /> ## Validation - `cargo fmt --check` - Desktop Tauri suite: 1,860 passed, 14 ignored after merging current `main` - Desktop tests: 3,769 passed - Desktop lint, file-size, text, and pubkey checks pass (two pre-existing informational template-literal notices) - Regression coverage verifies stale `OpenChannel` actions are discarded across community changes while `NewChannel` survives ## Manual validation remaining A native macOS smoke test is still requested before merge: menu appearance, elapsed updates, Running → Recent, channel navigation, New Channel, minimized/closed/Dock restore, Open Buzz, and Quit. E2E stubs the tray IPC and does not exercise the native menu. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Generated
+2
@@ -1047,7 +1047,9 @@ dependencies = [
|
||||
"neteq",
|
||||
"nostr",
|
||||
"notify-rust",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"opus",
|
||||
"plist",
|
||||
"png 0.18.1",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Instant> = 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<Vec<TrayAgentActivity>> {
|
||||
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<Vec<TrayAgentActivity>> {
|
||||
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<R: Runtime> {
|
||||
activity_id: String,
|
||||
channel_id: String,
|
||||
agent_item: MenuItem<R>,
|
||||
}
|
||||
|
||||
struct TrayActionQueue {
|
||||
community_generation: u64,
|
||||
pending_actions: Vec<TrayAction>,
|
||||
}
|
||||
|
||||
struct TrayMenuState<R: Runtime> {
|
||||
activity_items: Mutex<Vec<TrayActivityMenuItem<R>>>,
|
||||
action_queue: Mutex<TrayActionQueue>,
|
||||
}
|
||||
|
||||
#[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<R: Runtime>(app: &AppHandle<R>) {
|
||||
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<R: Runtime>(app: &AppHandle<R>, mut action: TrayAction) {
|
||||
let state = app.state::<TrayMenuState<R>>();
|
||||
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<R: Runtime>(app: &AppHandle<R>, menu: &Menu<R>) -> 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<bool> = 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<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
activities: &[TrayAgentActivity],
|
||||
recent_activities: &[TrayAgentActivity],
|
||||
) -> tauri::Result<(Menu<R>, Vec<TrayActivityMenuItem<R>>)> {
|
||||
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<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
menu: &Menu<R>,
|
||||
activities: &[TrayAgentActivity],
|
||||
activity_items: &mut Vec<TrayActivityMenuItem<R>>,
|
||||
) -> 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<R: Runtime>(
|
||||
tray: &TrayIcon<R>,
|
||||
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::<Vec<_>>();
|
||||
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<R: Runtime>(
|
||||
_tray: &TrayIcon<R>,
|
||||
_activities: &[TrayAgentActivity],
|
||||
_recent_activities: &[TrayAgentActivity],
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>) -> 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<R: Runtime>(app: AppHandle<R>) -> Result<Vec<TrayAction>, String> {
|
||||
let state = app.state::<TrayMenuState<R>>();
|
||||
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<TrayAction>) {
|
||||
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<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
actions: Vec<TrayAction>,
|
||||
) -> Result<(), String> {
|
||||
let state = app.state::<TrayMenuState<R>>();
|
||||
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<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
|
||||
let state = app.state::<TrayMenuState<R>>();
|
||||
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<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
activities: Vec<TrayAgentActivity>,
|
||||
recent_activities: Vec<TrayAgentActivity>,
|
||||
) -> 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::<TrayMenuState<R>>();
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<PreventSleepProvider>
|
||||
<AppShellTrayMenu
|
||||
channels={channels}
|
||||
goChannel={goChannel}
|
||||
openCreateChannel={handleOpenCreateChannel}
|
||||
/>
|
||||
<ChannelNavigationProvider channels={channels}>
|
||||
<AppShellProvider
|
||||
value={{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
|
||||
import { useTrayMenu } from "@/app/useTrayMenu";
|
||||
|
||||
/** Keeps the ticking native tray menu outside AppShell's render cycle. */
|
||||
export function AppShellTrayMenu({
|
||||
channels,
|
||||
goChannel,
|
||||
openCreateChannel,
|
||||
}: {
|
||||
channels: Channel[];
|
||||
goChannel: (channelId: string) => Promise<unknown>;
|
||||
openCreateChannel: () => void;
|
||||
}) {
|
||||
if (!isMacPlatform()) return null;
|
||||
return (
|
||||
<MacAppShellTrayMenu
|
||||
channels={channels}
|
||||
goChannel={goChannel}
|
||||
openCreateChannel={openCreateChannel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MacAppShellTrayMenu({
|
||||
channels,
|
||||
goChannel,
|
||||
openCreateChannel,
|
||||
}: {
|
||||
channels: Channel[];
|
||||
goChannel: (channelId: string) => Promise<unknown>;
|
||||
openCreateChannel: () => void;
|
||||
}): null {
|
||||
useTrayMenu({
|
||||
channels,
|
||||
goChannel,
|
||||
openCreateChannel,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
openCreateChannel: () => void;
|
||||
}): void {
|
||||
const activeTurns = useActiveAgentTurnsByChannel();
|
||||
const now = useNow(1000);
|
||||
const managedAgents = useManagedAgentsQuery().data;
|
||||
const relayAgents = useRelayAgentsQuery().data;
|
||||
const previousActivitiesRef = React.useRef(
|
||||
new Map<string, TrayAgentActivity>(),
|
||||
);
|
||||
const [recentActivities, setRecentActivities] = React.useState<
|
||||
TrayAgentActivity[]
|
||||
>([]);
|
||||
|
||||
const activities = React.useMemo<TrayAgentActivity[]>(() => {
|
||||
const channelNames = new Map(
|
||||
channels.map((channel) => [channel.id, channel.name]),
|
||||
);
|
||||
const agentNames = new Map<string, string>();
|
||||
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<TrayAction[]>("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]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
|
||||
/** Clears community-scoped agent activity from the native tray menu. */
|
||||
export function clearTrayAgentActivity(): Promise<void> {
|
||||
return invokeTauri("clear_tray_agent_activity");
|
||||
}
|
||||
@@ -9873,6 +9873,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":
|
||||
|
||||
Reference in New Issue
Block a user