From af262093620afc814af1ea5fd2d2455b36ac42b8 Mon Sep 17 00:00:00 2001
From: Cvv9 <Varun.cumbamangalam@oralens.com>
Date: Sat, 25 Jul 2026 13:35:45 +0530
Subject: [PATCH] Add close-to-tray option to the desktop app

Add a "Keep Buzz running in the tray" General setting. When enabled,
closing the main window hides it to a system tray icon instead of
quitting; left-click or the tray menu reopens it, and "Quit Buzz"
exits. The preference is persisted in localStorage and pushed to the
backend, which decides at window-close time whether to hide or quit.

The tray icon is created lazily only while the feature is enabled, so
users who never opt in never see it. Enabling fails closed: if the tray
icon cannot be built the setting stays off, so the close button always
has a way to quit. A genuine app exit marks the window as quitting so
teardown is not trapped by the hide-to-tray handler, the macOS dock
icon re-shows a hidden window, and Linux opens the tray menu on
left-click where activation events are unreliable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: benthecarman <benthecarman@live.com>
Signed-off-by: Cvv9 <Varun.cumbamangalam@oralens.com>
Signed-off-by: Cvv <varun.cumbamangalam@oralens.com>
---
 desktop/src-tauri/Cargo.lock                  |   1 +
 desktop/src-tauri/Cargo.toml                  |   2 +-
 desktop/src-tauri/src/commands/mod.rs         |   2 +
 desktop/src-tauri/src/commands/tray.rs        |  23 +++
 desktop/src-tauri/src/lib.rs                  |  16 ++
 desktop/src-tauri/src/tray.rs                 | 150 ++++++++++++++++++
 desktop/src/app/App.tsx                       |  18 +++
 .../features/settings/hooks/useCloseToTray.ts |  31 ++++
 .../src/features/settings/lib/closeToTray.ts  |  44 +++++
 .../settings/ui/GeneralSettingsCard.tsx       |  41 +++++
 .../features/settings/ui/SettingsPanels.tsx   |  11 ++
 .../src/features/settings/ui/SettingsView.tsx |   9 +-
 12 files changed, 346 insertions(+), 2 deletions(-)
 create mode 100644 desktop/src-tauri/src/commands/tray.rs
 create mode 100644 desktop/src-tauri/src/tray.rs
 create mode 100644 desktop/src/features/settings/hooks/useCloseToTray.ts
 create mode 100644 desktop/src/features/settings/lib/closeToTray.ts
 create mode 100644 desktop/src/features/settings/ui/GeneralSettingsCard.tsx

diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index bf84cd0d33..2c888ff4b0 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -9727,6 +9727,7 @@ dependencies = [
  "gtk",
  "heck 0.5.0",
  "http",
+ "image",
  "jni 0.21.1",
  "libc",
  "log",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 7606e48ac6..7e2fc1c835 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -58,7 +58,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", "image-png"] }
 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/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 66ef7ef17b..764425a18c 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -55,6 +55,7 @@ mod relay_reconnect;
 mod social;
 mod team_snapshot;
 mod teams;
+mod tray;
 mod updater;
 mod window_chrome;
 mod window_vibrancy;
@@ -106,6 +107,7 @@ pub use relay_reconnect::*;
 pub use social::*;
 pub use team_snapshot::*;
 pub use teams::*;
+pub use tray::*;
 pub use updater::*;
 pub use window_chrome::*;
 pub use window_vibrancy::*;
diff --git a/desktop/src-tauri/src/commands/tray.rs b/desktop/src-tauri/src/commands/tray.rs
new file mode 100644
index 0000000000..be37f86629
--- /dev/null
+++ b/desktop/src-tauri/src/commands/tray.rs
@@ -0,0 +1,23 @@
+/// Set whether closing the main window keeps Buzz running in the background
+/// instead of quitting. The frontend owns the persisted preference
+/// (`localStorage`) and pushes it here on launch and whenever it changes.
+///
+/// Windows and Linux need a tray icon as the reopen/quit affordance. macOS
+/// keeps the standard Dock icon, and `RunEvent::Reopen` restores the window.
+#[tauri::command]
+pub fn set_close_to_tray(enabled: bool, app: tauri::AppHandle) -> Result<(), String> {
+    #[cfg(not(target_os = "macos"))]
+    {
+        if enabled {
+            crate::tray::build_tray_icon(&app)
+                .map_err(|error| format!("failed to create tray icon: {error}"))?;
+        } else {
+            crate::tray::remove_tray_icon(&app);
+        }
+    }
+    // macOS keeps the Dock icon instead of a tray icon, so `app` is unused.
+    #[cfg(target_os = "macos")]
+    let _ = &app;
+    crate::tray::set_close_to_tray_enabled(enabled);
+    Ok(())
+}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 35f4eae866..ddcb4b482b 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;
 mod util;
 #[cfg(target_os = "linux")]
 pub mod webkit_rendering;
@@ -345,6 +346,7 @@ pub fn run() {
     let builder = builder;
 
     let app = builder
+        .on_window_event(tray::handle_window_event)
         .register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| {
             let app = ctx.app_handle().clone();
             tauri::async_runtime::spawn(async move {
@@ -390,6 +392,10 @@ pub fn run() {
                 return Ok(());
             }
 
+            // On Windows and Linux, the background-mode tray icon is created
+            // lazily when the frontend pushes the persisted preference. macOS
+            // uses its standard Dock icon to reopen the hidden window.
+
             // Run all pre-identity data migrations before state loads from disk.
             if reset_outcome.completed {
                 migration::run_boot_migrations_after_reset(&app_handle);
@@ -878,6 +884,7 @@ pub fn run() {
             fetch_workspace_icon,
             fetch_join_policy,
             set_prevent_sleep_active,
+            set_close_to_tray,
             get_agent_memory,
             relay_reconnect_hook,
             relay_reconnect_hook_configured,
@@ -908,6 +915,9 @@ pub fn run() {
     let restart_requested = Arc::new(AtomicBool::new(false));
     app.run(move |app_handle, event| match event {
         RunEvent::ExitRequested { code, .. } => {
+            // Mark a genuine quit so the close-to-tray window handler lets the
+            // window close instead of hiding it during teardown.
+            tray::mark_quitting();
             if is_restart_request(code) {
                 restart_requested.store(true, Ordering::SeqCst);
             }
@@ -930,6 +940,12 @@ pub fn run() {
             #[cfg(all(feature = "mesh-llm", target_os = "macos"))]
             hard_exit_after_mesh_shutdown();
         }
+        // macOS: clicking the dock icon while the window is hidden to the tray
+        // re-shows it (the standard re-open affordance).
+        #[cfg(target_os = "macos")]
+        RunEvent::Reopen { .. } => {
+            tray::show_main_window(app_handle);
+        }
         _ => {}
     });
 }
diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs
new file mode 100644
index 0000000000..abd9b73c5e
--- /dev/null
+++ b/desktop/src-tauri/src/tray.rs
@@ -0,0 +1,150 @@
+//! Background-window lifecycle and system tray handling.
+//!
+//! When "Keep Buzz running when the window closes" is on, closing the main
+//! window hides it instead of quitting. Windows and Linux expose a tray icon:
+//! left-click (or "Show Buzz") reopens the window, and "Quit Buzz" exits.
+//! macOS uses its normal Dock icon and application Quit command.
+//!
+//! The non-macOS tray icon exists only while the setting is enabled.
+
+use std::sync::atomic::{AtomicBool, Ordering};
+
+use tauri::{AppHandle, Manager, WindowEvent};
+
+// These flags describe process-wide native window lifecycle, not
+// community-scoped application data, so they intentionally live beside the
+// tray handlers instead of growing the already-oversized AppState.
+static CLOSE_TO_TRAY: AtomicBool = AtomicBool::new(false);
+static QUITTING: AtomicBool = AtomicBool::new(false);
+
+/// Stable id for the close-to-tray icon, used to create and remove it.
+#[cfg(not(target_os = "macos"))]
+const TRAY_ID: &str = "main-tray";
+
+/// Apply the frontend-owned persisted preference to the native close handler.
+pub fn set_close_to_tray_enabled(enabled: bool) {
+    CLOSE_TO_TRAY.store(enabled, Ordering::SeqCst);
+}
+
+/// Allow window teardown to proceed during a genuine application exit.
+pub fn mark_quitting() {
+    QUITTING.store(true, Ordering::SeqCst);
+}
+
+/// Show, unminimize, and focus the main window. Used by the tray menu,
+/// left-click, and the macOS dock-reopen handler to surface the window after
+/// close-to-tray has hidden it.
+pub fn show_main_window(app: &AppHandle) {
+    if let Some(window) = app.get_webview_window("main") {
+        let _ = window.show();
+        let _ = window.unminimize();
+        let _ = window.set_focus();
+    }
+}
+
+/// Builder `on_window_event` handler implementing close-to-tray: when the user
+/// closes the main window and the setting is on, hide the window instead of
+/// quitting. A genuine quit (tray "Quit Buzz", or any app exit, which the
+/// `RunEvent::ExitRequested` handler marks) sets `quitting` first so the window
+/// is allowed to close normally.
+pub fn handle_window_event(window: &tauri::Window, event: &WindowEvent) {
+    if let WindowEvent::CloseRequested { api, .. } = event {
+        if should_hide_window(
+            window.label(),
+            CLOSE_TO_TRAY.load(Ordering::SeqCst),
+            QUITTING.load(Ordering::SeqCst),
+        ) {
+            api.prevent_close();
+            let _ = window.hide();
+        }
+    }
+}
+
+fn should_hide_window(label: &str, keep_running: bool, quitting: bool) -> bool {
+    label == "main" && keep_running && !quitting
+}
+
+/// Build the system tray icon with a "Show Buzz" / "Quit Buzz" menu. Left-click
+/// reopens the window; "Quit Buzz" sets the `quitting` flag (so the close
+/// handler does not re-hide the window) and exits the app. Idempotent — a
+/// no-op if the tray icon already exists.
+#[cfg(not(target_os = "macos"))]
+pub fn build_tray_icon(app: &AppHandle) -> tauri::Result<()> {
+    use tauri::menu::{Menu, MenuItem};
+    use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
+
+    if app.tray_by_id(TRAY_ID).is_some() {
+        return Ok(());
+    }
+
+    let show_item = MenuItem::with_id(app, "tray-show", "Show Buzz", true, None::<&str>)?;
+    let quit_item = MenuItem::with_id(app, "tray-quit", "Quit Buzz", true, None::<&str>)?;
+    let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
+
+    let mut builder = TrayIconBuilder::with_id(TRAY_ID)
+        .tooltip("Buzz")
+        .menu(&menu)
+        // Linux (StatusNotifierItem) frequently does not deliver left-click
+        // activation events, so left-click-to-show is unreliable there; open
+        // the menu on left-click instead so "Show Buzz" stays reachable.
+        // macOS/Windows keep left-click-to-show with the menu on right-click.
+        .show_menu_on_left_click(cfg!(target_os = "linux"))
+        .on_menu_event(|app, event| match event.id.as_ref() {
+            "tray-show" => show_main_window(app),
+            "tray-quit" => {
+                mark_quitting();
+                app.exit(0);
+            }
+            _ => {}
+        })
+        .on_tray_icon_event(|tray, event| {
+            if let TrayIconEvent::Click {
+                button: MouseButton::Left,
+                button_state: MouseButtonState::Up,
+                ..
+            } = event
+            {
+                show_main_window(tray.app_handle());
+            }
+        });
+
+    // Reuse the bundled window icon for the tray glyph.
+    if let Some(icon) = app.default_window_icon() {
+        builder = builder.icon(icon.clone());
+    }
+
+    builder.build(app)?;
+    Ok(())
+}
+
+/// Remove the tray icon. Called when the user turns close-to-tray off so the
+/// icon does not linger after the feature is disabled.
+#[cfg(not(target_os = "macos"))]
+pub fn remove_tray_icon(app: &AppHandle) {
+    app.remove_tray_by_id(TRAY_ID);
+}
+
+#[cfg(test)]
+mod tests {
+    use super::should_hide_window;
+
+    #[test]
+    fn hides_main_window_when_background_mode_is_enabled() {
+        assert!(should_hide_window("main", true, false));
+    }
+
+    #[test]
+    fn allows_main_window_to_close_during_explicit_quit() {
+        assert!(!should_hide_window("main", true, true));
+    }
+
+    #[test]
+    fn allows_close_when_background_mode_is_disabled() {
+        assert!(!should_hide_window("main", false, false));
+    }
+
+    #[test]
+    fn never_intercepts_auxiliary_windows() {
+        assert!(!should_hide_window("settings", true, false));
+    }
+}
diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index 90b5bf5ebc..3fd7e08dfe 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -35,6 +35,11 @@ import {
   type MachineOnboardingPage,
   type PostOnboardingNavigation,
 } from "@/features/onboarding/ui/MachineOnboardingFlow";
+import {
+  applyCloseToTray,
+  getCloseToTrayPref,
+  setCloseToTrayPref,
+} from "@/features/settings/lib/closeToTray";
 import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
 import { PendingInviteGate } from "@/features/onboarding/ui/PendingInviteGate";
 import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen";
@@ -699,6 +704,19 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
 export function App() {
   useReloadShortcut();
   useInitialRenderReady();
+
+  // Push the persisted close-to-tray preference to the backend on launch so the
+  // window-close handler knows whether to hide-to-tray before the user ever
+  // opens Settings.
+  useEffect(() => {
+    const enabled = getCloseToTrayPref();
+    void applyCloseToTray(enabled).then((applied) => {
+      if (enabled && !applied) {
+        setCloseToTrayPref(false);
+      }
+    });
+  }, []);
+
   const [sharedIdentity, setSharedIdentity] = useState<boolean | null>(null);
   const [queryClient] = useState(createBuzzQueryClient);
 
diff --git a/desktop/src/features/settings/hooks/useCloseToTray.ts b/desktop/src/features/settings/hooks/useCloseToTray.ts
new file mode 100644
index 0000000000..42ad5f2016
--- /dev/null
+++ b/desktop/src/features/settings/hooks/useCloseToTray.ts
@@ -0,0 +1,31 @@
+import * as React from "react";
+
+import {
+  applyCloseToTray,
+  getCloseToTrayPref,
+  setCloseToTrayPref,
+} from "../lib/closeToTray";
+
+/**
+ * Reads/writes the "Keep Buzz running in the tray" preference. Persists to
+ * localStorage and pushes the value to the Tauri backend on every change.
+ */
+export function useCloseToTray() {
+  const [enabled, setEnabledState] = React.useState(getCloseToTrayPref);
+  const [isUpdating, setIsUpdating] = React.useState(false);
+
+  const setEnabled = React.useCallback(async (next: boolean) => {
+    const previous = getCloseToTrayPref();
+    setIsUpdating(true);
+    const applied = await applyCloseToTray(next);
+    if (applied) {
+      setEnabledState(next);
+      setCloseToTrayPref(next);
+    } else {
+      setEnabledState(previous);
+    }
+    setIsUpdating(false);
+  }, []);
+
+  return { enabled, isUpdating, setEnabled };
+}
diff --git a/desktop/src/features/settings/lib/closeToTray.ts b/desktop/src/features/settings/lib/closeToTray.ts
new file mode 100644
index 0000000000..ddbc0fea67
--- /dev/null
+++ b/desktop/src/features/settings/lib/closeToTray.ts
@@ -0,0 +1,44 @@
+import { invoke, isTauri } from "@tauri-apps/api/core";
+
+/**
+ * Persistence + backend sync for the "keep Buzz running" setting.
+ *
+ * The frontend owns the source of truth (localStorage). The Rust side reads a
+ * flag set via the `set_close_to_tray` command — it decides, at window-close
+ * time, whether to hide the window to the tray instead of quitting. We push the
+ * persisted value to the backend on launch and whenever the toggle changes.
+ */
+const CLOSE_TO_TRAY_KEY = "buzz-close-to-tray";
+
+/** Read the persisted preference. Defaults to keeping Buzz running. */
+export function getCloseToTrayPref(): boolean {
+  try {
+    return window.localStorage.getItem(CLOSE_TO_TRAY_KEY) !== "false";
+  } catch {
+    return true;
+  }
+}
+
+/** Persist the preference to localStorage. */
+export function setCloseToTrayPref(enabled: boolean): void {
+  try {
+    window.localStorage.setItem(CLOSE_TO_TRAY_KEY, enabled ? "true" : "false");
+  } catch {
+    // Best-effort — a storage failure just means the backend keeps the
+    // last-applied value for this session.
+  }
+}
+
+/** Push the current value to the Tauri backend. No-op outside Tauri. */
+export async function applyCloseToTray(enabled: boolean): Promise<boolean> {
+  if (!isTauri()) {
+    return true;
+  }
+  try {
+    await invoke("set_close_to_tray", { enabled });
+    return true;
+  } catch (err) {
+    console.warn("set_close_to_tray command failed:", err);
+    return false;
+  }
+}
diff --git a/desktop/src/features/settings/ui/GeneralSettingsCard.tsx b/desktop/src/features/settings/ui/GeneralSettingsCard.tsx
new file mode 100644
index 0000000000..8e7138eab4
--- /dev/null
+++ b/desktop/src/features/settings/ui/GeneralSettingsCard.tsx
@@ -0,0 +1,41 @@
+import { Switch } from "@/shared/ui/switch";
+import { useCloseToTray } from "../hooks/useCloseToTray";
+import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
+import { SettingsSectionHeader } from "./SettingsSectionHeader";
+
+export function GeneralSettingsCard() {
+  const { enabled, isUpdating, setEnabled } = useCloseToTray();
+
+  return (
+    <section className="min-w-0" data-testid="settings-general">
+      <SettingsSectionHeader
+        title="General"
+        description="App behavior on this machine."
+      />
+
+      <SettingsOptionGroup>
+        <SettingsOptionRow>
+          <div className="min-w-0">
+            <label
+              className="text-sm font-medium"
+              htmlFor="close-to-tray-switch"
+            >
+              Keep Buzz running when the window closes
+            </label>
+            <p className="text-sm font-normal text-muted-foreground">
+              Continue receiving notifications and running agents. Reopen Buzz
+              from the Dock or system tray; use Quit to exit completely.
+            </p>
+          </div>
+          <Switch
+            checked={enabled}
+            data-testid="close-to-tray-toggle"
+            disabled={isUpdating}
+            id="close-to-tray-switch"
+            onCheckedChange={setEnabled}
+          />
+        </SettingsOptionRow>
+      </SettingsOptionGroup>
+    </section>
+  );
+}
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index e74d1f3837..422a004fe5 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -1,6 +1,7 @@
 import { useMemo, useState } from "react";
 import { AnimatePresence, motion, useReducedMotion } from "motion/react";
 import {
+  AppWindow,
   Archive,
   BellRing,
   Bot,
@@ -71,6 +72,7 @@ import {
 import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
 import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel";
 import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
+import { GeneralSettingsCard } from "./GeneralSettingsCard";
 import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
 import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard";
 import { MobilePairingCard } from "./MobilePairingCard";
@@ -92,6 +94,7 @@ export type SettingsSection =
   | "channel-templates"
   | "compute"
   | "appearance"
+  | "general"
   | "shortcuts"
   | "hosted-communities"
   | "community-members"
@@ -111,6 +114,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [
   "channel-templates",
   "compute",
   "appearance",
+  "general",
   "shortcuts",
   "hosted-communities",
   "community-members",
@@ -189,6 +193,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
     label: "Compute",
     icon: Cpu,
   },
+  {
+    value: "general",
+    label: "General",
+    icon: AppWindow,
+  },
   {
     value: "shortcuts",
     label: "Shortcuts",
@@ -823,6 +832,8 @@ export function renderSettingsSection(
       return <MeshComputeSettingsCard />;
     case "appearance":
       return <ThemeSettingsCard />;
+    case "general":
+      return <GeneralSettingsCard />;
     case "shortcuts":
       return <KeyboardShortcutsCard />;
     case "hosted-communities":
diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx
index 20389b420a..6081885292 100644
--- a/desktop/src/features/settings/ui/SettingsView.tsx
+++ b/desktop/src/features/settings/ui/SettingsView.tsx
@@ -69,7 +69,14 @@ const settingsNavGroups: Array<{
   },
   {
     label: "App",
-    sections: ["agents", "compute", "experimental", "mobile", "updates"],
+    sections: [
+      "general",
+      "agents",
+      "compute",
+      "experimental",
+      "mobile",
+      "updates",
+    ],
   },
 ];
 
