From 7035311c03d2c0a98b97fed61fb5bf1f118708fd Mon Sep 17 00:00:00 2001 From: Jeff Hedlund Date: Thu, 12 Nov 2026 16:14:04 -0500 Subject: [PATCH 1/2] feat(desktop): close to the notification area on Windows Closing Buzz's window on Windows quit the app and stopped every running agent. It now hides the window, as on macOS, and Buzz adds an icon to the notification area: a left click opens Buzz, and its menu offers Open Buzz and Quit Buzz. Quit takes the same exit as the app menu, so agents stop with Buzz. Opening Buzz again also shows the hidden window, through the existing single-instance handler. The tray-icon feature is enabled only for Windows builds. Ports block/buzz#6893. Refs #1632. Co-authored-by: Engineer Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jeff Hedlund --- docs/buzz-help.md | 3 ++ src-tauri/Cargo.toml | 2 + src-tauri/src/lib.rs | 16 +++++++ src-tauri/src/tray.rs | 109 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 src-tauri/src/tray.rs diff --git a/docs/buzz-help.md b/docs/buzz-help.md index bb2699f505..a8e2f73c01 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -728,6 +728,9 @@ for 30 days expires. - **Closing the window** in the macOS app hides it; Buzz and its agents keep running. Switching back to Buzz, for example with ⌘Tab or its Dock icon, shows it again. A minimized window stays in the Dock. ⌘Q quits. +- **Closing the window** in the Windows app hides it to the notification area; + Buzz and its agents keep running. Click Buzz's icon there, or open Buzz + again, to show it. Right-click the icon and choose **Quit Buzz** to quit. - **Sharing a Solo chat** grants access to its conversation history, not only the next message. Search for a channel in its access dialog and click **Share** beside the result. This makes a Private chat **View only** and posts a link diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f48302ac6f..376d570312 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -115,6 +115,8 @@ objc2-user-notifications = { version = "=0.3.2", default-features = false, featu objc2-web-kit = { version = "=0.3.2", default-features = false, features = ["std", "objc2-app-kit", "block2", "WKWebView", "WKUIDelegate", "WKFrameInfo", "WKSecurityOrigin", "WKWebViewConfiguration", "WKWebsiteDataStore", "WKHTTPCookieStore", "WKDownload", "WKDownloadDelegate", "WKNavigation", "WKNavigationDelegate"] } [target.'cfg(target_os = "windows")'.dependencies] +# The notification-area icon that reopens and quits Buzz (tray.rs). +tauri = { version = "=2.12.4", features = ["tray-icon"] } webview2-com = "=0.39.1" windows-core = "=0.62.2" tauri-winrt-notification = "=0.8.1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1940587756..0e99ea4fab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,6 +74,8 @@ mod microphone; mod mouse_nav; #[cfg(target_os = "macos")] mod reshow; +#[cfg(windows)] +mod tray; mod notifications; mod os_idle; @@ -1024,6 +1026,9 @@ pub fn run() { mouse_nav::install(app.handle()); #[cfg(target_os = "macos")] reshow::install(app.handle()); + // Before any close hides the window: the icon is the way back. + #[cfg(windows)] + tray::install(app.handle())?; #[cfg(target_os = "macos")] if let Some(home) = dirs::home_dir() { if let Err(error) = legacy_cli_shortcut::remove_stale(&home.join(".local/bin")) { @@ -1285,6 +1290,17 @@ pub fn run() { return; } } + // As on macOS; the notification-area icon reopens and quits Buzz. + #[cfg(windows)] + if window.label() == "main" { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + if let Err(error) = window.hide() { + eprintln!("Could not close Buzz window: {error}"); + } + return; + } + } browser::window_event(window, event); }) .build(context) diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000000..f198569cbf --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,109 @@ +//! Windows notification-area icon. +//! +//! Closing the main window on Windows hides it (see the `CloseRequested` +//! handler), so running agents keep working. This icon is the way back: a left +//! click opens Buzz, and its menu offers Open and Quit. Once the window is +//! hidden it is the way to quit, since Alt+F4 and the taskbar's Close only +//! close the window. +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Runtime, +}; + +const TRAY_ID: &str = "buzz-tray"; +const OPEN_ID: &str = "tray-open"; +const QUIT_ID: &str = "tray-quit"; + +#[derive(Debug, PartialEq, Eq)] +enum TrayCommand { + Open, + Quit, +} + +fn command(id: &str) -> Option { + match id { + OPEN_ID => Some(TrayCommand::Open), + QUIT_ID => Some(TrayCommand::Quit), + _ => None, + } +} + +/// Only a left-button release opens Buzz: the right button belongs to the +/// menu, and each click also reports a press. +fn opens(event: &TrayIconEvent) -> bool { + matches!( + event, + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } + ) +} + +/// Adds Buzz's icon, which lasts as long as the app. +pub(crate) fn install(app: &AppHandle) -> tauri::Result<()> { + let menu = Menu::with_items( + app, + &[ + &MenuItem::with_id(app, OPEN_ID, "Open Buzz", true, None::<&str>)?, + &MenuItem::with_id(app, QUIT_ID, "Quit Buzz", true, None::<&str>)?, + ], + )?; + let mut builder = TrayIconBuilder::with_id(TRAY_ID) + .menu(&menu) + .tooltip("Buzz") + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match command(event.id.as_ref()) { + Some(TrayCommand::Open) => crate::deep_links::focus_main(app), + // The same exit as quitting from the app menu: agents stop with Buzz. + Some(TrayCommand::Quit) => app.exit(0), + None => {} + }) + .on_tray_icon_event(|tray, event| { + if opens(&event) { + crate::deep_links::focus_main(tray.app_handle()); + } + }); + if let Some(icon) = app.default_window_icon() { + builder = builder.icon(icon.clone()); + } + builder.build(app)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tauri::{tray::TrayIconId, PhysicalPosition, Rect}; + + fn click(button: MouseButton, button_state: MouseButtonState) -> TrayIconEvent { + TrayIconEvent::Click { + id: TrayIconId::new(TRAY_ID), + position: PhysicalPosition::new(0.0, 0.0), + rect: Rect::default(), + button, + button_state, + } + } + + #[test] + fn menu_items_open_and_quit() { + assert_eq!(command(OPEN_ID), Some(TrayCommand::Open)); + assert_eq!(command(QUIT_ID), Some(TrayCommand::Quit)); + assert_eq!(command(""), None); + } + + #[test] + fn only_a_left_release_opens_buzz() { + assert!(opens(&click(MouseButton::Left, MouseButtonState::Up))); + assert!(!opens(&click(MouseButton::Left, MouseButtonState::Down))); + assert!(!opens(&click(MouseButton::Right, MouseButtonState::Up))); + assert!(!opens(&TrayIconEvent::Enter { + id: TrayIconId::new(TRAY_ID), + position: PhysicalPosition::new(0.0, 0.0), + rect: Rect::default(), + })); + } +} From 3c6f96f89581784a783c736751deb16641436718 Mon Sep 17 00:00:00 2001 From: Jeff Hedlund Date: Fri, 13 Nov 2026 15:39:38 -0500 Subject: [PATCH 2/2] fix(desktop): start without the notification-area icon if it fails Adding the Windows tray icon used `?` during setup, so any failure (for example the notification area being unavailable at launch) stopped Buzz from starting. The error is now logged instead, and closing the window hides it only while the icon exists; without one, Close quits as before, so the window is never hidden with no way back. Co-authored-by: Engineer Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jeff Hedlund --- src-tauri/src/lib.rs | 8 ++++++-- src-tauri/src/tray.rs | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0e99ea4fab..343f34ec55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1027,8 +1027,11 @@ pub fn run() { #[cfg(target_os = "macos")] reshow::install(app.handle()); // Before any close hides the window: the icon is the way back. + // Without it, closing quits as before (see the close handler). #[cfg(windows)] - tray::install(app.handle())?; + if let Err(error) = tray::install(app.handle()) { + eprintln!("Could not add Buzz to the notification area: {error}"); + } #[cfg(target_os = "macos")] if let Some(home) = dirs::home_dir() { if let Err(error) = legacy_cli_shortcut::remove_stale(&home.join(".local/bin")) { @@ -1291,8 +1294,9 @@ pub fn run() { } } // As on macOS; the notification-area icon reopens and quits Buzz. + // Hide only when that icon exists, or the window could not come back. #[cfg(windows)] - if window.label() == "main" { + if window.label() == "main" && tray::installed(window.app_handle()) { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); if let Err(error) = window.hide() { diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index f198569cbf..2d09b1e591 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -73,6 +73,12 @@ pub(crate) fn install(app: &AppHandle) -> tauri::Result<()> { Ok(()) } +/// Whether Buzz's icon is in the notification area, so a closed window can +/// come back. +pub(crate) fn installed(app: &AppHandle) -> bool { + app.tray_by_id(TRAY_ID).is_some() +} + #[cfg(test)] mod tests { use super::*; @@ -88,6 +94,16 @@ mod tests { } } + #[test] + fn installed_only_after_install() { + let app = tauri::test::mock_builder() + .build(crate::app_context()) + .expect("mock app"); + assert!(!installed(app.handle())); + install(app.handle()).expect("tray icon"); + assert!(installed(app.handle())); + } + #[test] fn menu_items_open_and_quit() { assert_eq!(command(OPEN_ID), Some(TrayCommand::Open));