From c2d445812d1d8c6b11a7510a38d2e753a4208df0 Mon Sep 17 00:00:00 2001 From: GCWing Date: Mon, 11 May 2026 14:39:18 +0800 Subject: [PATCH] feat(desktop,web-ui,installer): Tokyo Night theme, tray, and settings/usage - Add Tokyo Night theme for web UI and installer (i18n sync) - Add desktop system tray and extend system/i18n APIs - Basics settings, AppLayout, usage report card, and Switch styling - Config/types and generative UI tool updates --- BitFun-Installer/scripts/sync-theme-i18n.cjs | 1 + .../src-tauri/src/installer/commands.rs | 1 + BitFun-Installer/src/i18n/locales/en.json | 3 +- BitFun-Installer/src/i18n/locales/zh-TW.json | 3 +- BitFun-Installer/src/i18n/locales/zh.json | 3 +- .../src/theme/installerThemesData.ts | 15 + BitFun-Installer/src/types/installer.ts | 3 +- Cargo.toml | 2 +- src/apps/desktop/src/api/i18n_api.rs | 9 + src/apps/desktop/src/api/system_api.rs | 25 +- src/apps/desktop/src/lib.rs | 30 +- src/apps/desktop/src/theme.rs | 10 + src/apps/desktop/src/tray.rs | 382 ++++++++++++++++++ .../implementations/generative_ui_tool.rs | 17 + src/crates/core/src/service/config/types.rs | 9 + src/web-ui/src/app/layout/AppLayout.tsx | 157 +++++-- .../components/Switch/Switch.scss | 39 +- .../usage/SessionUsageComponents.test.tsx | 5 +- .../usage/SessionUsageReportCard.scss | 41 +- .../usage/SessionUsageReportCard.tsx | 39 +- .../api/service-api/SystemAPI.ts | 23 ++ .../config/components/BasicsConfig.tsx | 101 ++++- .../src/infrastructure/theme/presets/index.ts | 3 + .../theme/presets/tokyo-night-theme.ts | 345 ++++++++++++++++ src/web-ui/src/locales/en-US/common.json | 6 + src/web-ui/src/locales/en-US/flow-chat.json | 2 +- .../src/locales/en-US/settings/basics.json | 23 ++ src/web-ui/src/locales/zh-CN/common.json | 6 + src/web-ui/src/locales/zh-CN/flow-chat.json | 2 +- .../src/locales/zh-CN/settings/basics.json | 26 +- src/web-ui/src/locales/zh-TW/common.json | 6 + src/web-ui/src/locales/zh-TW/flow-chat.json | 2 +- .../src/locales/zh-TW/settings/basics.json | 23 ++ 33 files changed, 1249 insertions(+), 113 deletions(-) create mode 100644 src/apps/desktop/src/tray.rs create mode 100644 src/web-ui/src/infrastructure/theme/presets/tokyo-night-theme.ts diff --git a/BitFun-Installer/scripts/sync-theme-i18n.cjs b/BitFun-Installer/scripts/sync-theme-i18n.cjs index d66735e33..53f4505e5 100644 --- a/BitFun-Installer/scripts/sync-theme-i18n.cjs +++ b/BitFun-Installer/scripts/sync-theme-i18n.cjs @@ -12,6 +12,7 @@ const THEME_IDS = [ "bitfun-china-night", "bitfun-cyber", "bitfun-slate", + "bitfun-tokyo-night", ]; function readJson(filePath) { diff --git a/BitFun-Installer/src-tauri/src/installer/commands.rs b/BitFun-Installer/src-tauri/src/installer/commands.rs index 01a4ffaeb..da1b07760 100644 --- a/BitFun-Installer/src-tauri/src/installer/commands.rs +++ b/BitFun-Installer/src-tauri/src/installer/commands.rs @@ -665,6 +665,7 @@ pub fn set_theme_preference(theme_preference: String) -> Result<(), String> { "bitfun-china-night", "bitfun-cyber", "bitfun-slate", + "bitfun-tokyo-night", ]; if !allowed.contains(&theme_preference.as_str()) { return Err("Unsupported theme preference".to_string()); diff --git a/BitFun-Installer/src/i18n/locales/en.json b/BitFun-Installer/src/i18n/locales/en.json index d9c737307..189b4c5ff 100644 --- a/BitFun-Installer/src/i18n/locales/en.json +++ b/BitFun-Installer/src/i18n/locales/en.json @@ -202,7 +202,8 @@ "bitfun-china-style": "Ink Charm", "bitfun-china-night": "Ink Night", "bitfun-cyber": "Cyber", - "bitfun-slate": "Slate" + "bitfun-slate": "Slate", + "bitfun-tokyo-night": "Tokyo Night" } }, "complete": { diff --git a/BitFun-Installer/src/i18n/locales/zh-TW.json b/BitFun-Installer/src/i18n/locales/zh-TW.json index 501deb51c..bcd90e1a4 100644 --- a/BitFun-Installer/src/i18n/locales/zh-TW.json +++ b/BitFun-Installer/src/i18n/locales/zh-TW.json @@ -202,7 +202,8 @@ "bitfun-china-style": "墨韻", "bitfun-china-night": "墨夜", "bitfun-cyber": "賽博", - "bitfun-slate": "石板灰" + "bitfun-slate": "石板灰", + "bitfun-tokyo-night": "東京夜" } }, "complete": { diff --git a/BitFun-Installer/src/i18n/locales/zh.json b/BitFun-Installer/src/i18n/locales/zh.json index 0cdb01727..c1319029b 100644 --- a/BitFun-Installer/src/i18n/locales/zh.json +++ b/BitFun-Installer/src/i18n/locales/zh.json @@ -202,7 +202,8 @@ "bitfun-china-style": "墨韵", "bitfun-china-night": "墨夜", "bitfun-cyber": "赛博", - "bitfun-slate": "石板灰" + "bitfun-slate": "石板灰", + "bitfun-tokyo-night": "东京夜" } }, "complete": { diff --git a/BitFun-Installer/src/theme/installerThemesData.ts b/BitFun-Installer/src/theme/installerThemesData.ts index 65f7e4f63..aae24b61d 100644 --- a/BitFun-Installer/src/theme/installerThemesData.ts +++ b/BitFun-Installer/src/theme/installerThemesData.ts @@ -134,6 +134,20 @@ export const THEMES: InstallerTheme[] = [ element: { subtle: 'rgba(0, 230, 255, 0.06)', soft: 'rgba(0, 230, 255, 0.09)', base: 'rgba(0, 230, 255, 0.13)', medium: 'rgba(0, 230, 255, 0.17)', strong: 'rgba(0, 230, 255, 0.22)', elevated: 'rgba(0, 230, 255, 0.27)' }, }, }, + { + id: 'bitfun-tokyo-night', + name: 'Tokyo Night', + type: 'dark', + colors: { + background: { primary: '#1a1b26', secondary: '#16161e', tertiary: '#14141b', quaternary: '#1e202e', elevated: '#20222c', workbench: '#16161e', flowchat: '#1a1b26', tooltip: 'rgba(22, 22, 30, 0.94)' }, + text: { primary: '#c0caf5', secondary: '#a9b1d6', muted: '#787c99', disabled: '#545c7e' }, + accent: { '50': 'rgba(122, 162, 247, 0.05)', '100': 'rgba(122, 162, 247, 0.08)', '200': 'rgba(122, 162, 247, 0.15)', '300': 'rgba(122, 162, 247, 0.25)', '400': 'rgba(122, 162, 247, 0.4)', '500': '#7aa2f7', '600': '#6183bb', '700': 'rgba(97, 131, 187, 0.85)', '800': 'rgba(97, 131, 187, 0.95)' }, + purple: { '50': 'rgba(187, 154, 247, 0.05)', '100': 'rgba(187, 154, 247, 0.08)', '200': 'rgba(187, 154, 247, 0.15)', '300': 'rgba(187, 154, 247, 0.25)', '400': 'rgba(187, 154, 247, 0.4)', '500': '#bb9af7', '600': '#9d7cd8', '700': 'rgba(157, 124, 216, 0.85)', '800': 'rgba(157, 124, 216, 0.95)' }, + semantic: { success: '#9ece6a', warning: '#e0af68', error: '#f7768e', info: '#7dcfff', highlight: '#e0af68', highlightBg: 'rgba(224, 175, 104, 0.15)' }, + border: { subtle: 'rgba(54, 59, 84, 0.45)', base: 'rgba(54, 59, 84, 0.6)', medium: 'rgba(54, 59, 84, 0.72)', strong: 'rgba(54, 59, 84, 0.85)', prominent: 'rgba(122, 162, 247, 0.45)' }, + element: { subtle: 'rgba(122, 162, 247, 0.06)', soft: 'rgba(122, 162, 247, 0.08)', base: 'rgba(122, 162, 247, 0.11)', medium: 'rgba(122, 162, 247, 0.14)', strong: 'rgba(122, 162, 247, 0.18)', elevated: 'rgba(122, 162, 247, 0.22)' }, + }, + }, { id: 'bitfun-slate', name: 'Slate', @@ -158,6 +172,7 @@ export const THEME_DISPLAY_ORDER: ThemeId[] = [ 'bitfun-china-style', 'bitfun-china-night', 'bitfun-cyber', + 'bitfun-tokyo-night', ]; export function findInstallerThemeById(id: ThemeId): InstallerTheme { diff --git a/BitFun-Installer/src/types/installer.ts b/BitFun-Installer/src/types/installer.ts index b50a966a9..be9a0f63a 100644 --- a/BitFun-Installer/src/types/installer.ts +++ b/BitFun-Installer/src/types/installer.ts @@ -20,7 +20,8 @@ export type ThemeId = | 'bitfun-china-style' | 'bitfun-china-night' | 'bitfun-cyber' - | 'bitfun-slate'; + | 'bitfun-slate' + | 'bitfun-tokyo-night'; /** Matches main app `themes.current` when following OS appearance. */ export const SYSTEM_THEME_ID = 'system' as const; diff --git a/Cargo.toml b/Cargo.toml index f6938256d..3c6ab4eb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,7 +103,7 @@ similar = "2.5" urlencoding = "2.1" # Tauri (desktop only) - tauri = { version = "2", features = ["unstable", "macos-private-api"] } + tauri = { version = "2", features = ["unstable", "macos-private-api", "tray-icon"] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2.6" tauri-plugin-fs = "2" diff --git a/src/apps/desktop/src/api/i18n_api.rs b/src/apps/desktop/src/api/i18n_api.rs index 3e7bece6e..5284308b8 100644 --- a/src/apps/desktop/src/api/i18n_api.rs +++ b/src/apps/desktop/src/api/i18n_api.rs @@ -93,6 +93,15 @@ pub async fn i18n_set_language( &_app, language, mode, edit_mode, ); } + + // Rebuild the system tray menu in the new language. + { + let app_handle = _app.clone(); + tauri::async_runtime::spawn(async move { + crate::tray::rebuild_tray_menu_public(&app_handle).await; + }); + } + Ok(format!("Language switched to: {}", language)) } Err(e) => { diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index f62bd00e7..d843d6cc1 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex}; use crate::api::app_state::AppState; use bitfun_core::service::system; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_updater::UpdaterExt; /// Emitted during `install_update` download; matches `installUpdateWithProgress` / frontend listener. @@ -309,6 +309,29 @@ pub struct SendNotificationRequest { pub body: Option, } +// ─── Window / Tray behavior commands ───────────────────────────────────────── + +/// Immediately exit the application (used by the "ask" dialog when the user +/// chooses to quit rather than minimize to tray). +#[tauri::command] +pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> { + log::info!("Quit requested via quit_app command"); + crate::perform_process_exit_cleanup(); + app.exit(0); + Ok(()) +} + +/// Hide the main window so it lives only in the system tray (used by the "ask" +/// dialog when the user chooses to minimize instead of quitting). +#[tauri::command] +pub async fn minimize_to_tray(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("main") { + window.hide().map_err(|e| e.to_string())?; + log::info!("Main window minimized to tray via command"); + } + Ok(()) +} + /// Send an OS-level desktop notification (Windows toast / macOS notification center). #[tauri::command] pub async fn send_system_notification( diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index a88ca8f8e..9c54ad02a 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -6,6 +6,7 @@ pub mod computer_use; pub mod logging; pub mod macos_menubar; pub mod theme; +pub mod tray; use bitfun_core::agentic::tools::computer_use_capability::set_computer_use_desktop_available; use bitfun_core::agentic::tools::computer_use_host::ComputerUseHostRef; @@ -21,7 +22,6 @@ use std::sync::{ Arc, }; use std::time::Instant; -#[cfg(target_os = "macos")] use tauri::Emitter; use tauri::Manager; @@ -77,12 +77,16 @@ static MAIN_WINDOW_HIDDEN_ON_MACOS: AtomicBool = AtomicBool::new(false); #[cfg(target_os = "macos")] static MAIN_WINDOW_CLOSE_PENDING_ON_MACOS: AtomicBool = AtomicBool::new(false); -#[cfg(target_os = "macos")] const MAIN_WINDOW_CLOSE_REQUESTED_EVENT: &str = "bitfun_main_window_close_requested"; #[cfg(target_os = "macos")] const MAIN_WINDOW_CLOSE_FALLBACK_HIDE_MS: u64 = 2_500; +// ─── Close-button behavior ──────────────────────────────────────────────────── +// The close-button behavior is owned by the frontend; the Rust window-event +// handler only emits a notification event and the frontend decides what to do. +// No per-platform caching needed here. + #[cfg(target_os = "macos")] pub(crate) fn mark_main_window_hidden_on_macos(hidden: bool) { MAIN_WINDOW_HIDDEN_ON_MACOS.store(hidden, Ordering::SeqCst); @@ -482,6 +486,11 @@ pub async fn run() { logging::spawn_log_cleanup_task(); + // Set up system tray icon. + if let Err(error) = crate::tray::setup_tray(app) { + log::warn!("Failed to set up system tray: {}", error); + } + log::info!("BitFun Desktop started successfully"); Ok(()) }) @@ -524,11 +533,16 @@ pub async fn run() { } #[cfg(not(target_os = "macos"))] - if let tauri::WindowEvent::CloseRequested { .. } = event { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { if window.label() == "main" { - if perform_process_exit_cleanup() { - log::info!("Main window close requested, cleaning up"); - window.app_handle().exit(0); + // Prevent the OS from closing the window; let the frontend + // decide whether to minimize to tray, show a dialog, or quit. + api.prevent_close(); + if let Err(error) = window.emit(MAIN_WINDOW_CLOSE_REQUESTED_EVENT, ()) { + log::warn!( + "Failed to emit main window close request event: {}", + error + ); } } } @@ -892,6 +906,8 @@ pub async fn run() { install_update, restart_app, send_system_notification, + api::system_api::quit_app, + api::system_api::minimize_to_tray, check_command_exists, check_commands_exist, run_system_command, @@ -1214,7 +1230,7 @@ fn setup_panic_hook() { })); } -fn perform_process_exit_cleanup() -> bool { +pub(crate) fn perform_process_exit_cleanup() -> bool { static CLEANUP_DONE: AtomicBool = AtomicBool::new(false); if CLEANUP_DONE diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index ef4695e00..16bb06544 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -90,6 +90,16 @@ impl ThemeConfig { text_muted: "rgba(255, 255, 255, 0.4)".to_string(), accent_color: "#00e6ff".to_string(), }), + "bitfun-tokyo-night" => Some(Self { + id: theme_id.to_string(), + bg_primary: "#1a1b26".to_string(), + bg_secondary: "#16161e".to_string(), + bg_scene: "#1a1b26".to_string(), + is_light: false, + text_primary: "#c0caf5".to_string(), + text_muted: "rgba(255, 255, 255, 0.4)".to_string(), + accent_color: "#7aa2f7".to_string(), + }), "bitfun-china-night" => Some(Self { id: theme_id.to_string(), bg_primary: "#1a1814".to_string(), diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs new file mode 100644 index 000000000..1e7389482 --- /dev/null +++ b/src/apps/desktop/src/tray.rs @@ -0,0 +1,382 @@ +//! System tray integration for BitFun Desktop. +//! +//! Creates a system tray icon with a context menu. On Windows and Linux the tray +//! icon is always visible while the process is running; on macOS the icon appears +//! in the macOS menu bar. +//! +//! Left-click – toggles the main window (show / hide). +//! Right-click – opens a context menu with: +//! • up to 5 recent sessions (sorted by last active time) +//! • "Show BitFun" +//! • "Quit BitFun" +//! +//! The context menu is rebuilt every time the user right-clicks so that recently +//! opened sessions are always up-to-date. + +use std::path::PathBuf; +use std::sync::OnceLock; + +use tauri::menu::{MenuBuilder, MenuItemBuilder}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{AppHandle, Emitter, Manager}; + +use bitfun_core::agentic::persistence::PersistenceManager; +use bitfun_core::infrastructure::PathManager; +use bitfun_core::service::config::app_language::get_app_language; +use bitfun_core::service::i18n::LocaleId; + +use crate::api::app_state::AppState; + +// ─── Event emitted to the webview when a tray session item is clicked ───────── + +pub const TRAY_OPEN_SESSION_EVENT: &str = "tray://open-session"; + +// ─── Persistent tray icon reference (needed to update the menu) ─────────────── + +static TRAY_ICON: OnceLock = OnceLock::new(); + +// ─── Tray menu i18n strings ─────────────────────────────────────────────────── + +struct TrayStrings { + show_app: &'static str, + quit_app: &'static str, + no_recent_sessions: &'static str, + recent_sessions_header: &'static str, +} + +const STRINGS_ZH_CN: TrayStrings = TrayStrings { + show_app: "显示 BitFun", + quit_app: "退出 BitFun", + no_recent_sessions: "暂无最近会话", + recent_sessions_header: "最近会话", +}; + +const STRINGS_ZH_TW: TrayStrings = TrayStrings { + show_app: "顯示 BitFun", + quit_app: "退出 BitFun", + no_recent_sessions: "暫無最近會話", + recent_sessions_header: "最近會話", +}; + +const STRINGS_EN_US: TrayStrings = TrayStrings { + show_app: "Show BitFun", + quit_app: "Quit BitFun", + no_recent_sessions: "No recent sessions", + recent_sessions_header: "Recent Sessions", +}; + +fn tray_strings(locale: &LocaleId) -> &'static TrayStrings { + match locale { + LocaleId::ZhCN => &STRINGS_ZH_CN, + LocaleId::ZhTW => &STRINGS_ZH_TW, + LocaleId::EnUS => &STRINGS_EN_US, + } +} + +// ─── Session info collected from persisted storage ─────────────────────────── + +struct TraySessionItem { + session_id: String, + label: String, + workspace_path: String, +} + +// Build a short label for a session menu item. Uses the session name if set, +// otherwise falls back to the session ID prefix. +fn session_label(session_name: &str, session_id: &str, workspace_name: &str) -> String { + let name = if session_name.is_empty() { + &session_id[..session_id.len().min(8)] + } else { + session_name + }; + // Truncate long names to keep the menu readable. + let truncated = if name.chars().count() > 40 { + let mut s: String = name.chars().take(38).collect(); + s.push_str("…"); + s + } else { + name.to_string() + }; + format!("[{}] {}", workspace_name, truncated) +} + +// Collect up to `limit` sessions sorted by last_active_at across all recent +// workspaces. Returns an empty list on any error rather than propagating. +async fn collect_recent_sessions(app: &AppHandle, limit: usize) -> Vec { + let app_state = match app.try_state::() { + Some(s) => s, + None => return Vec::new(), + }; + let path_manager = match app.try_state::>() { + Some(p) => p, + None => return Vec::new(), + }; + + let recent_workspaces = app_state.workspace_service.get_recent_workspaces().await; + + // Gather (last_active_at, TraySessionItem) tuples across all workspaces. + let mut all: Vec<(u64, TraySessionItem)> = Vec::new(); + + for workspace in &recent_workspaces { + // Skip remote workspaces – their session data lives on the remote host. + let root_path_str = workspace.root_path.to_string_lossy(); + if root_path_str.starts_with("ssh://") || root_path_str.contains('@') { + continue; + } + + let manager = match PersistenceManager::new(path_manager.inner().clone()) { + Ok(m) => m, + Err(_) => continue, + }; + let sessions = match manager.list_session_metadata(&workspace.root_path).await { + Ok(s) => s, + Err(_) => continue, + }; + + let workspace_name = if workspace.name.is_empty() { + workspace + .root_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| workspace.id.clone()) + } else { + workspace.name.clone() + }; + + for session in sessions { + // Skip hidden / sub-agent sessions. + use bitfun_core::agentic::core::session::SessionKind; + if session.session_kind != SessionKind::Standard { + continue; + } + let label = session_label( + &session.session_name, + &session.session_id, + &workspace_name, + ); + all.push(( + session.last_active_at, + TraySessionItem { + session_id: session.session_id, + label, + workspace_path: workspace.root_path.to_string_lossy().to_string(), + }, + )); + } + } + + // Sort by most recent first. + all.sort_by(|a, b| b.0.cmp(&a.0)); + all.into_iter().take(limit).map(|(_, item)| item).collect() +} + +// Rebuild the tray context menu with fresh session data and the current locale. +// Also callable from outside the module (e.g. after a language change). +pub async fn rebuild_tray_menu_public(app: &AppHandle) { + rebuild_tray_menu(app).await; +} + +async fn rebuild_tray_menu(app: &AppHandle) { + let sessions = collect_recent_sessions(app, 5).await; + let locale = get_app_language().await; + let s = tray_strings(&locale); + + let tray = match TRAY_ICON.get() { + Some(t) => t, + None => return, + }; + + let mut builder = MenuBuilder::new(app); + + // ── Recent sessions header ──────────────────────────────────────────────── + if !sessions.is_empty() { + let header = match MenuItemBuilder::with_id("_header", s.recent_sessions_header) + .enabled(false) + .build(app) + { + Ok(i) => i, + Err(_) => return, + }; + builder = builder.item(&header).separator(); + } + + // ── Session items ───────────────────────────────────────────────────────── + if sessions.is_empty() { + let no_sessions = + match MenuItemBuilder::with_id("no_sessions", s.no_recent_sessions) + .enabled(false) + .build(app) + { + Ok(i) => i, + Err(_) => return, + }; + builder = builder.item(&no_sessions); + } else { + for item in &sessions { + let id = format!("session:{}:{}", item.session_id, item.workspace_path); + let menu_item = match MenuItemBuilder::with_id(&id, &item.label).build(app) { + Ok(i) => i, + Err(_) => continue, + }; + builder = builder.item(&menu_item); + } + } + + // ── Fixed actions ───────────────────────────────────────────────────────── + let show_item = match MenuItemBuilder::with_id("show_window", s.show_app).build(app) { + Ok(i) => i, + Err(_) => return, + }; + let quit_item = match MenuItemBuilder::with_id("quit", s.quit_app).build(app) { + Ok(i) => i, + Err(_) => return, + }; + + let menu = match builder + .separator() + .item(&show_item) + .separator() + .item(&quit_item) + .build() + { + Ok(m) => m, + Err(e) => { + log::warn!("Failed to build tray menu: {}", e); + return; + } + }; + + if let Err(e) = tray.set_menu(Some(menu)) { + log::warn!("Failed to update tray menu: {}", e); + } +} + +// ─── Public entry point ─────────────────────────────────────────────────────── + +/// Build and attach the system tray icon to the Tauri application. +pub fn setup_tray(app: &tauri::App) -> Result<(), Box> { + // Build the initial (placeholder) menu; it will be replaced by rebuild_tray_menu + // shortly after startup once the locale and sessions are known. + let no_sessions_item = MenuItemBuilder::with_id("no_sessions", STRINGS_EN_US.no_recent_sessions) + .enabled(false) + .build(app)?; + let show_item = MenuItemBuilder::with_id("show_window", STRINGS_EN_US.show_app).build(app)?; + let quit_item = MenuItemBuilder::with_id("quit", STRINGS_EN_US.quit_app).build(app)?; + + let initial_menu = MenuBuilder::new(app) + .item(&no_sessions_item) + .separator() + .item(&show_item) + .separator() + .item(&quit_item) + .build()?; + + let icon = app + .default_window_icon() + .ok_or("No default window icon")? + .clone(); + + let tray = TrayIconBuilder::new() + .icon(icon) + .menu(&initial_menu) + .tooltip("BitFun") + .on_menu_event(|app, event| { + let id = event.id.as_ref(); + if id == "show_window" { + show_main_window(app); + } else if id == "quit" { + log::info!("Quit requested from tray menu"); + crate::perform_process_exit_cleanup(); + app.exit(0); + } else if let Some(rest) = id.strip_prefix("session:") { + // Format: "session:{session_id}:{workspace_path}" + if let Some(colon_pos) = rest.find(':') { + let session_id = &rest[..colon_pos]; + let workspace_path = &rest[colon_pos + 1..]; + log::info!( + "Tray session selected: session_id={}, workspace={}", + session_id, + workspace_path + ); + show_main_window(app); + if let Err(e) = app.emit( + TRAY_OPEN_SESSION_EVENT, + serde_json::json!({ + "sessionId": session_id, + "workspacePath": workspace_path, + }), + ) { + log::warn!("Failed to emit tray open-session event: {}", e); + } + } + } + }) + .on_tray_icon_event(|tray, event| match event { + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } => { + let app = tray.app_handle().clone(); + toggle_main_window(&app); + // Refresh the menu in the background so it's ready for the next + // right-click. We do it here (after a left-click) rather than on + // right-click to avoid racing with the OS menu display. + tauri::async_runtime::spawn(async move { + rebuild_tray_menu(&app).await; + }); + } + _ => {} + }) + .build(app)?; + + // Store the handle so rebuild_tray_menu can update it later. + let _ = TRAY_ICON.set(tray); + + // Eagerly populate the menu and then keep it fresh every 60 seconds. + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + // Short delay to let the workspace service finish initialising. + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + rebuild_tray_menu(&app_handle).await; + + // Periodic refresh so the menu stays current without any user interaction. + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + rebuild_tray_menu(&app_handle).await; + } + }); + + Ok(()) +} + +// ─── Window helpers ─────────────────────────────────────────────────────────── + +/// Show the main window and bring it to focus. +pub fn show_main_window(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + log::info!("Main window shown via tray"); + } else { + log::warn!("Tray: show_main_window called but main window not found"); + } +} + +/// Toggle the main window visibility. +fn toggle_main_window(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let visible = window.is_visible().unwrap_or(false); + if visible { + let _ = window.hide(); + log::info!("Main window hidden via tray toggle"); + } else { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + log::info!("Main window shown via tray toggle"); + } + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs b/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs index f5d5127d6..66a0d352f 100644 --- a/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs @@ -142,6 +142,23 @@ impl GenerativeUITool { shadow_base: "0 4px 12px rgba(0, 0, 0, 0.8)", style_notes: "neon cyber tooling, black surfaces, glowing cyan accents, still compact and workbench-first", }), + "bitfun-tokyo-night" => Some(ThemePromptSnapshot { + id: "bitfun-tokyo-night", + theme_type: "dark", + bg_primary: "#1a1b26", + bg_secondary: "#16161e", + bg_scene: "#1a1b26", + text_primary: "#c0caf5", + text_muted: "#787c99", + accent_500: "#7aa2f7", + accent_600: "#6183bb", + border_base: "rgba(54, 59, 84, 0.60)", + element_base: "rgba(122, 162, 247, 0.11)", + radius_base: "6px", + spacing_4: "16px", + shadow_base: "0 4px 12px rgba(0, 0, 0, 0.48)", + style_notes: "Tokyo Night indigo night, soft blue accent and violet secondary highlights, calm IDE mood", + }), "bitfun-china-style" => Some(ThemePromptSnapshot { id: "bitfun-china-style", theme_type: "light", diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index 44a62e29f..46e73be84 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -77,6 +77,10 @@ impl ProjectConfig { } /// App configuration. +fn default_close_button_behavior() -> String { + "quit".to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct AppConfig { @@ -100,6 +104,10 @@ pub struct AppConfig { /// the frontend owns the versioned format (StoredKeybindingsV1). #[serde(default, skip_serializing_if = "Option::is_none")] pub keybindings: Option, + /// What happens when the window close button is clicked on Windows / Linux. + /// Allowed values: "quit" | "minimize_to_tray" | "ask". + #[serde(default = "default_close_button_behavior")] + pub close_button_behavior: String, } /// App logging configuration. @@ -1266,6 +1274,7 @@ impl Default for AppConfig { session_config: AppSessionConfig::default(), ai_experience: AIExperienceConfig::default(), keybindings: None, + close_button_behavior: default_close_button_behavior(), } } } diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index ae4db2a64..f0e1ce170 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -29,6 +29,9 @@ import { AboutDialog } from '../components/AboutDialog'; import { MCPInteractionDialog } from '../components/MCPInteractionDialog/MCPInteractionDialog'; import { WorkspaceManager } from '../../tools/workspace'; import { workspaceAPI } from '@/infrastructure/api'; +import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; +import type { CloseBehavior } from '@/infrastructure/api/service-api/SystemAPI'; +import { confirmDialog } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; import { DailyAppUpdateGate } from '@/infrastructure/update'; import { useI18n } from '@/infrastructure/i18n'; @@ -75,7 +78,6 @@ const AppLayout: React.FC = ({ className = '' }) => { useWindowControls({ isToolbarMode }); const { state, switchLeftPanelTab, toggleLeftPanel, toggleRightPanel } = useApp(); - const allowNextNativeCloseRef = useRef(false); // ── Load user keybinding overrides from config on startup ──────────────── useEffect(() => { @@ -303,62 +305,74 @@ const AppLayout: React.FC = ({ className = '' }) => { // Save in-progress conversations before the native window is closed/hidden. React.useEffect(() => { let unlistenFn: (() => void) | null = null; - let handlingMacOSClose = false; + let handlingClose = false; const setupWindowCloseListener = async () => { if (!canUseNativeWindowControls) return; try { - if (isMacOS) { - const [{ listen }, { invoke }] = await Promise.all([ - import('@tauri-apps/api/event'), - import('@tauri-apps/api/core'), - ]); - - unlistenFn = await listen('bitfun_main_window_close_requested', async () => { - if (handlingMacOSClose) return; - handlingMacOSClose = true; + // Both macOS and Windows/Linux: Rust intercepts the native close request + // and emits this event. We save turns then decide what to do. + const [{ listen }, { invoke }] = await Promise.all([ + import('@tauri-apps/api/event'), + import('@tauri-apps/api/core'), + ]); + + unlistenFn = await listen('bitfun_main_window_close_requested', async () => { + if (handlingClose) return; + handlingClose = true; + + try { + const flowChatManager = FlowChatManager.getInstance(); + await flowChatManager.saveAllInProgressTurns(); + } catch (error) { + log.error('Failed to save conversations before close', error); + } + + if (isMacOS) { + // macOS always hides to keep the app alive in the dock. try { - const flowChatManager = FlowChatManager.getInstance(); - await flowChatManager.saveAllInProgressTurns(); + await invoke('hide_main_window_after_close_request'); } catch (error) { - log.error('Failed to save conversations before hiding main window', error); - } finally { - try { - await invoke('hide_main_window_after_close_request'); - } catch (error) { - log.error('Failed to hide main window after close request', error); - } finally { - handlingMacOSClose = false; - } + log.error('Failed to hide main window after close request', error); } - }); - return; - } - - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - const currentWindow = getCurrentWindow(); - - unlistenFn = await currentWindow.onCloseRequested(async (event: { preventDefault: () => void }) => { - if (allowNextNativeCloseRef.current) { - allowNextNativeCloseRef.current = false; + handlingClose = false; return; } + // Windows / Linux: read the user's close-button preference. + let behavior: CloseBehavior = 'quit'; try { - event.preventDefault(); - const flowChatManager = FlowChatManager.getInstance(); - await flowChatManager.saveAllInProgressTurns(); - } catch (error) { - log.error('Failed to save conversations, closing anyway', error); + behavior = (await configManager.getConfig('app.close_button_behavior')) ?? 'quit'; + } catch { + // Fall back to quit if config cannot be read. } try { - allowNextNativeCloseRef.current = true; - await currentWindow.close(); + if (behavior === 'minimize_to_tray') { + await systemAPI.minimizeToTray(); + } else if (behavior === 'ask') { + const shouldQuit = await confirmDialog({ + title: tCommon('closeDialog.title'), + message: tCommon('closeDialog.message'), + confirmText: tCommon('closeDialog.quit'), + cancelText: tCommon('closeDialog.minimizeToTray'), + showCancel: true, + }); + if (shouldQuit) { + await systemAPI.quitApp(); + } else { + await systemAPI.minimizeToTray(); + } + } else { + // quit + await systemAPI.quitApp(); + } } catch (error) { - allowNextNativeCloseRef.current = false; - throw error; + log.error('Failed to handle close request', { behavior, error }); + try { await systemAPI.quitApp(); } catch { /* ignore */ } + } finally { + handlingClose = false; } }); } catch (error) { @@ -368,7 +382,7 @@ const AppLayout: React.FC = ({ className = '' }) => { setupWindowCloseListener(); return () => { if (unlistenFn) unlistenFn(); }; - }, [canUseNativeWindowControls, isMacOS]); + }, [canUseNativeWindowControls, isMacOS, tCommon]); // Handle switch-to-files-panel event React.useEffect(() => { @@ -402,6 +416,65 @@ const AppLayout: React.FC = ({ className = '' }) => { return () => window.removeEventListener('toolbar-send-message', handleToolbarSendMessage); }, []); + // Listen for tray "open session" events (fired when user clicks a recent session in the + // tray context menu). Switch workspace if needed, then navigate to the session. + const pendingTraySessionRef = useRef(null); + useEffect(() => { + if (!canUseNativeWindowControls) return; + let unlisten: (() => void) | null = null; + + void (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + unlisten = await listen<{ sessionId: string; workspacePath: string }>( + 'tray://open-session', + async event => { + const { sessionId, workspacePath } = event.payload; + log.info('Tray open-session received', { sessionId, workspacePath }); + + const normalize = (p: string) => p.replace(/\\/g, '/').toLowerCase(); + const isSameWorkspace = + !!currentWorkspace?.rootPath && + normalize(currentWorkspace.rootPath) === normalize(workspacePath); + + if (!isSameWorkspace) { + // Switch workspace first; once loaded the second useEffect will + // fire openMainSession for the pending session. + pendingTraySessionRef.current = sessionId; + try { + await openWorkspace(workspacePath); + } catch (error) { + log.error('Tray: failed to open workspace', { workspacePath, error }); + pendingTraySessionRef.current = null; + } + } else { + const { openMainSession } = await import('@/flow_chat/services/openBtwSession'); + await openMainSession(sessionId); + } + } + ); + } catch (error) { + log.error('Failed to setup tray open-session listener', error); + } + })(); + + return () => { if (unlisten) unlisten(); }; + }, [canUseNativeWindowControls, currentWorkspace, openWorkspace]); + + // Once a workspace switch triggered by the tray completes, navigate to the + // pending session. + useEffect(() => { + const pending = pendingTraySessionRef.current; + if (!pending || !hasWorkspace) return; + pendingTraySessionRef.current = null; + void (async () => { + // Small delay to let FlowChatManager finish loading sessions. + await new Promise(r => setTimeout(r, 800)); + const { openMainSession } = await import('@/flow_chat/services/openBtwSession'); + await openMainSession(pending); + })(); + }, [hasWorkspace, currentWorkspace]); + // Toggle left panel: mod+B (VS Code convention) useShortcut( 'panel.toggleLeft', diff --git a/src/web-ui/src/component-library/components/Switch/Switch.scss b/src/web-ui/src/component-library/components/Switch/Switch.scss index d9f0ca5e2..b431b39a3 100644 --- a/src/web-ui/src/component-library/components/Switch/Switch.scss +++ b/src/web-ui/src/component-library/components/Switch/Switch.scss @@ -33,10 +33,15 @@ height: 100%; margin: 0; padding: 0; + appearance: none; opacity: 0; cursor: pointer; z-index: 1; + &:focus { + outline: none; + } + &:disabled { cursor: default; } @@ -56,8 +61,8 @@ pointer-events: none; &--checked { - background: var(--color-accent-100); - border-color: var(--color-accent-300); + background: var(--color-accent-200); + border-color: var(--color-accent-400); } } @@ -95,8 +100,13 @@ &__input:checked + &__track &__thumb { left: calc(100% - 18px); - background: var(--btn-primary-bg, var(--color-accent-500)); - border-color: var(--btn-primary-bg, var(--color-accent-500)); + background: var(--btn-primary-bg, var(--color-accent-500, #{tokens.$color-accent-500})); + border-color: var(--btn-primary-bg, var(--color-accent-500, #{tokens.$color-accent-500})); + } + + :root[data-theme-type='dark'] &__input:checked + &__track &__thumb { + background: var(--color-accent-500, #{tokens.$color-accent-500}); + border-color: var(--color-accent-600, #{tokens.$color-accent-600}); } &__input:focus-visible + &__track { @@ -104,6 +114,10 @@ outline-offset: 2px; } + &__input:not(:focus-visible) + &__track { + outline: none; + } + &:hover:not(&--disabled):not(&--loading) &__track { border-color: var(--border-base); } @@ -114,13 +128,22 @@ } &:hover:not(&--disabled):not(&--loading) &__input:checked + &__track { - background: var(--color-accent-200); - border-color: var(--color-accent-400); + background: var(--color-accent-300); + border-color: var(--color-accent-500, #{tokens.$color-accent-500}); } &:hover:not(&--disabled):not(&--loading) &__input:checked + &__track &__thumb { - background: var(--btn-primary-hover-bg, var(--color-accent-400)); - border-color: var(--btn-primary-hover-bg, var(--color-accent-400)); + background: var(--btn-primary-hover-bg, var(--color-accent-500, #{tokens.$color-accent-500})); + border-color: var(--btn-primary-hover-bg, var(--color-accent-500, #{tokens.$color-accent-500})); + } + + :root[data-theme-type='dark'] &:hover:not(&--disabled):not(&--loading) &__input:checked + &__track &__thumb { + background: color-mix(in srgb, var(--color-accent-500, #{tokens.$color-accent-500}) 88%, white); + border-color: var(--color-accent-500, #{tokens.$color-accent-500}); + } + + &__input:checked + &__track &__loading { + color: rgba(255, 255, 255, 0.92); } &__input:active:not(:disabled) + &__track { diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx index 2e1a8b128..eb3eea130 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx @@ -55,7 +55,7 @@ vi.mock('react-i18next', () => ({ 'usage.status.cacheNotReported': 'Cache not reported', 'usage.status.noFileChanges': 'No file changes', 'usage.status.notRecorded': 'Not recorded', - 'usage.card.eyebrow': 'Local report', + 'usage.card.heading': 'Session statistics', 'usage.card.turns': '{{count}} turns', 'usage.card.calls': '{{count}} calls', 'usage.card.operations': '{{count}} ops', @@ -346,7 +346,8 @@ describe('Session usage report UI components', () => { const cachedMetric = Array.from(container.querySelectorAll('.session-usage-report-card__metric')) .find(metric => metric.textContent?.includes('Cached')); expect(container.textContent).toContain('Partial'); - expect(container.textContent).toContain('Hover underlined values'); + const partialCoverageBadge = container.querySelector('.session-usage-report-card__coverage'); + expect(partialCoverageBadge?.parentElement?.getAttribute('data-tooltip')).toContain('Hover underlined values'); expect(cachedMetric?.textContent).toContain('Cache not reported'); expect(cachedMetric?.textContent).not.toMatch(/Cached\s*0/); expect(cachedMetric?.querySelector('[data-tooltip]')?.getAttribute('data-tooltip')) diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.scss b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.scss index 0983fcbcb..410529063 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.scss +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.scss @@ -72,9 +72,9 @@ } &__header, + &__title-block, &__actions, &__meta, - &__notice, &__metric, &__mini-list-row { display: flex; @@ -88,16 +88,14 @@ } &__title-block { + flex: 1 1 auto; min-width: 0; - } - - &__eyebrow { - color: var(--color-text-muted); - font-size: var(--flowchat-font-size-xs); - line-height: 1.3; + flex-wrap: nowrap; + gap: 8px 12px; } &__title { + flex: 0 1 auto; margin: 0; color: var(--color-text-primary); font-size: var(--flowchat-font-size-lg); @@ -134,11 +132,17 @@ color: var(--color-text-secondary); background: color-mix(in srgb, var(--element-bg-soft) 88%, transparent); } + + &--hint { + cursor: help; + } } &__meta { - flex-wrap: wrap; + flex: 1 1 12rem; + flex-wrap: nowrap; gap: 6px 10px; + min-width: 0; color: var(--color-text-secondary); font-size: var(--flowchat-font-size-xs); @@ -151,22 +155,6 @@ } } - &__notice { - gap: 6px; - min-height: 28px; - padding: 6px 8px; - border-left: 2px solid color-mix(in srgb, var(--color-warning) 72%, transparent); - background: color-mix(in srgb, var(--color-warning) 8%, transparent); - color: var(--color-text-secondary); - font-size: var(--flowchat-font-size-xs); - line-height: 1.4; - - svg { - flex: 0 0 auto; - color: var(--color-warning); - } - } - &__metrics { display: grid; grid-template-columns: repeat(4, minmax(116px, 1fr)); @@ -324,9 +312,14 @@ margin-right: 0.75rem; &__header { + flex-wrap: wrap; align-items: flex-start; } + &__title-block { + flex-basis: 100%; + } + &__metrics { grid-template-columns: 1fr; } diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx index 285594af8..bc57d4c40 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx @@ -179,17 +179,33 @@ export const SessionUsageReportCard: React.FC = ({ }, ]; + const coverageBadgeClassName = + `session-usage-report-card__coverage session-usage-report-card__coverage--${coverageTone}` + + (report.coverage.level !== 'complete' ? ' session-usage-report-card__coverage--hint' : ''); + return (
-
{t('usage.card.eyebrow')}
-

{t('usage.title')}

+

{t('usage.card.heading')}

+
+ {formatUsageTimestamp(generatedAt ?? report.generatedAt, t)} + {t('usage.card.turns', { count: report.scope.turnCount })} + {report.workspace.pathLabel || t('usage.unavailable')} +
- - {getCoverageLabel(report.coverage.level, t)} - + {report.coverage.level !== 'complete' ? ( + + + {getCoverageLabel(report.coverage.level, t)} + + + ) : ( + + {getCoverageLabel(report.coverage.level, t)} + + )} = ({
-
- {formatUsageTimestamp(generatedAt ?? report.generatedAt, t)} - {t('usage.card.turns', { count: report.scope.turnCount })} - {report.workspace.pathLabel || t('usage.unavailable')} -
- - {report.coverage.level !== 'complete' && ( -
- - {t('usage.coverage.partialNotice')} -
- )} -
{metrics.map(metric => { const Icon = metric.icon; diff --git a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts index 0ce6c031b..313ae4aa9 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts @@ -18,6 +18,9 @@ export interface CheckForUpdatesResponse { releaseDate: string | null; } +/** Close-button behavior values (matches `app.close_button_behavior` config key). */ +export type CloseBehavior = 'quit' | 'minimize_to_tray' | 'ask'; + export class SystemAPI { async getSystemInfo(): Promise { @@ -199,6 +202,26 @@ export class SystemAPI { throw createTauriCommandError('autostart_set', error, { enabled }); } } + + // ─── Window / Tray behavior ──────────────────────────────────────────────── + + /** Desktop only: immediately quit the application. */ + async quitApp(): Promise { + try { + await api.invoke('quit_app', { request: {} }); + } catch (error) { + throw createTauriCommandError('quit_app', error); + } + } + + /** Desktop only: hide the main window to the system tray. */ + async minimizeToTray(): Promise { + try { + await api.invoke('minimize_to_tray', { request: {} }); + } catch (error) { + throw createTauriCommandError('minimize_to_tray', error); + } + } } diff --git a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx index f102a0cf9..e426dae48 100644 --- a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx @@ -11,6 +11,7 @@ import { } from '@/component-library'; import { configAPI, workspaceAPI } from '@/infrastructure/api'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; +import type { CloseBehavior } from '@/infrastructure/api/service-api/SystemAPI'; import { getTerminalService } from '@/tools/terminal'; import type { ShellInfo } from '@/tools/terminal/types/session'; import { @@ -527,8 +528,105 @@ function BasicsTerminalSection() { ); } -function BasicsNotificationsSection() { +function BasicsWindowBehaviorSection() { const { t } = useTranslation('settings/basics'); + const isTauri = typeof window !== 'undefined' && '__TAURI__' in window; + const [behavior, setBehavior] = useState('quit'); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); + + const showMessage = useCallback((type: 'success' | 'error' | 'info', text: string) => { + setMessage({ type, text }); + setTimeout(() => setMessage(null), 3000); + }, []); + + const behaviorOptions = useMemo( + () => [ + { value: 'quit', label: t('windowBehavior.options.quit') }, + { value: 'minimize_to_tray', label: t('windowBehavior.options.minimizeToTray') }, + { value: 'ask', label: t('windowBehavior.options.ask') }, + ], + [t] + ); + + useEffect(() => { + if (!isTauri) { + setLoading(false); + return; + } + let cancelled = false; + void (async () => { + try { + setLoading(true); + const v = await configManager.getConfig('app.close_button_behavior'); + if (!cancelled) setBehavior(v ?? 'quit'); + } catch { + // Key absent on first launch — fall back to default silently. + if (!cancelled) setBehavior('quit'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [isTauri, showMessage, t]); + + const handleChange = useCallback( + async (value: string) => { + const previous = behavior; + const next = value as CloseBehavior; + setBehavior(next); + setSaving(true); + try { + await configManager.setConfig('app.close_button_behavior', next); + configManager.clearCache(); + showMessage('success', t('windowBehavior.messages.saved')); + } catch (error) { + setBehavior(previous); + log.error('Failed to save close behavior', { next, error }); + showMessage('error', t('windowBehavior.messages.saveFailed')); + } finally { + setSaving(false); + } + }, + [behavior, showMessage, t] + ); + + if (!isTauri) return null; + + if (loading) { + return ; + } + + return ( +
+
+ + + +
+