From 4bdca2aa5dae5f0039d8cd81f5dda5731fb06a30 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 17 Sep 2026 18:45:22 +0800 Subject: [PATCH] feat(desktop): replace the menu bar unread count with a tray mark-all-read The numeric badge beside the menu bar mark could not be dismissed without opening each session one by one, and the only "mark all as read" lived two menus deep in the nav panel. Remove the badge: the macOS tray image no longer reserves trailing space for it and the tray title is never set. The count still travels from the web UI, but it now only feeds the tray menu's "Mark all as read" entry, which carries it in the label and disables itself at zero. The entry forwards its intent through a tray event so the web UI keeps owning read receipts, and `clearAllSessionUnreadCompletions` goes through the existing per-session path so persisted receipts and activity acknowledgements stay identical to clearing sessions one by one. --- .../technical/tauri-command-map.json | 2 +- src/apps/desktop/src/api/system_api.rs | 7 +- src/apps/desktop/src/tray.rs | 116 +++++++++++------- .../components/WorkspaceSessionFilterMenu.tsx | 4 +- .../services/trayUnreadService.test.ts | 23 +++- .../flow_chat/services/trayUnreadService.ts | 5 + .../src/flow_chat/store/FlowChatStore.ts | 14 +++ .../api/service-api/SystemAPI.ts | 8 ++ 8 files changed, 127 insertions(+), 52 deletions(-) diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 6001ee7057..b18113eb1e 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -8818,7 +8818,7 @@ "visibility": "implementation", "rustPath": "api::system_api::set_tray_unread_count", "sourceFile": "src/apps/desktop/src/api/system_api.rs", - "signature": "fn set_tray_unread_count(request: SetTrayUnreadCountRequest) -> Result<(), String>", + "signature": "fn set_tray_unread_count( app: tauri::AppHandle, request: SetTrayUnreadCountRequest, ) -> Result<(), String>", "remoteWorkspacePolicy": "LocalOnly" }, { diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index ae3cb84724..eed4292e1d 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -721,8 +721,11 @@ pub struct SetTrayUnreadCountRequest { } #[tauri::command] -pub async fn set_tray_unread_count(request: SetTrayUnreadCountRequest) -> Result<(), String> { - crate::tray::set_unread_count(request.count) +pub async fn set_tray_unread_count( + app: tauri::AppHandle, + request: SetTrayUnreadCountRequest, +) -> Result<(), String> { + crate::tray::set_unread_count(&app, request.count) } /// Initialize the desktop tray after the startup shell has become interactive. diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs index 7c5a10a908..4a8926d3de 100644 --- a/src/apps/desktop/src/tray.rs +++ b/src/apps/desktop/src/tray.rs @@ -18,7 +18,7 @@ use std::time::Instant; use tauri::menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Emitter, Manager}; use openbitfun_core::service::config::app_language::get_app_language; use openbitfun_core::service::config::types::AIExperienceConfig; @@ -30,30 +30,38 @@ use crate::startup_trace::DesktopStartupTrace; static TRAY_ICON: OnceLock = OnceLock::new(); static TRAY_UNREAD_COUNT: Mutex = Mutex::new(0); static TRAY_SETUP_LOCK: Mutex<()> = Mutex::new(()); + +/// Emitted when the user picks "Mark all as read" from the tray menu. The web +/// UI owns read receipts, so the tray only forwards the intent. +pub const TRAY_MARK_ALL_READ_EVENT: &str = "tray://mark-all-read"; const TRAY_TRACE_CATEGORY: &str = "native_background"; struct TrayStrings { show_app: &'static str, quit_app: &'static str, desktop_pet: &'static str, + mark_all_read: &'static str, } const STRINGS_ZH_CN: TrayStrings = TrayStrings { show_app: "显示 OpenBitFun", quit_app: "退出 OpenBitFun", desktop_pet: "显示桌面宠物", + mark_all_read: "全部标为已读", }; const STRINGS_ZH_TW: TrayStrings = TrayStrings { show_app: "顯示 OpenBitFun", quit_app: "退出 OpenBitFun", desktop_pet: "顯示桌面寵物", + mark_all_read: "全部標為已讀", }; const STRINGS_EN_US: TrayStrings = TrayStrings { show_app: "Show OpenBitFun", quit_app: "Quit OpenBitFun", desktop_pet: "Show desktop pet", + mark_all_read: "Mark all as read", }; fn tray_strings(locale: &LocaleId) -> &'static TrayStrings { @@ -77,6 +85,20 @@ async fn load_ai_experience(app: &AppHandle) -> Option { .ok() } +/// Carry the pending count in the label so the menu answers "how many?" on its +/// own; the menu bar icon itself never shows a number. +fn mark_all_read_label(strings: &TrayStrings, count: u32) -> String { + if count == 0 { + strings.mark_all_read.to_string() + } else { + format!("{} ({})", strings.mark_all_read, count) + } +} + +fn current_unread_count() -> u32 { + TRAY_UNREAD_COUNT.lock().map(|count| *count).unwrap_or(0) +} + pub async fn rebuild_tray_menu_public(app: &AppHandle) { rebuild_tray_menu(app).await; } @@ -104,6 +126,17 @@ async fn rebuild_tray_menu(app: &AppHandle) { Err(_) => return, }; + let mark_read_item = match MenuItemBuilder::with_id( + "mark_all_read", + mark_all_read_label(s, current_unread_count()), + ) + .enabled(current_unread_count() > 0) + .build(app) + { + Ok(i) => i, + Err(_) => return, + }; + let show_item = match MenuItemBuilder::with_id("show_window", s.show_app).build(app) { Ok(i) => i, Err(_) => return, @@ -114,6 +147,8 @@ async fn rebuild_tray_menu(app: &AppHandle) { }; let menu = match MenuBuilder::new(app) + .item(&mark_read_item) + .separator() .item(&pet_item) .separator() .item(&show_item) @@ -180,12 +215,20 @@ pub fn setup_tray( let pet_item = CheckMenuItemBuilder::with_id("toggle_desktop_pet", STRINGS_EN_US.desktop_pet) .checked(false) .build(app)?; + let mark_read_item = MenuItemBuilder::with_id( + "mark_all_read", + mark_all_read_label(&STRINGS_EN_US, current_unread_count()), + ) + .enabled(current_unread_count() > 0) + .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)?; startup_trace.record_elapsed_step(TRAY_TRACE_CATEGORY, "setup_tray.menu_items", step_started); let step_started = Instant::now(); let initial_menu = MenuBuilder::new(app) + .item(&mark_read_item) + .separator() .item(&pet_item) .separator() .item(&show_item) @@ -196,7 +239,7 @@ pub fn setup_tray( let step_started = Instant::now(); #[cfg(target_os = "macos")] - let icon = macos_tray_icon(false)?; + let icon = macos_tray_icon()?; #[cfg(not(target_os = "macos"))] let icon = app .default_window_icon() @@ -213,7 +256,13 @@ pub fn setup_tray( .tooltip("OpenBitFun") .on_menu_event(|app, event| { let id = event.id.as_ref(); - if id == "show_window" { + if id == "mark_all_read" { + // The web UI owns read receipts and will push the cleared count + // back through `set_tray_unread_count`. + if let Err(error) = app.emit(TRAY_MARK_ALL_READ_EVENT, ()) { + log::warn!("Failed to emit tray mark-all-read: {}", error); + } + } else if id == "show_window" { show_main_window(app); } else if id == "quit" { log::info!("Quit requested from tray menu"); @@ -250,10 +299,6 @@ pub fn setup_tray( let step_started = Instant::now(); let _ = TRAY_ICON.set(tray); - let count = TRAY_UNREAD_COUNT - .lock() - .map_err(|_| "Tray unread count lock poisoned")?; - apply_unread_count(*count)?; startup_trace.record_elapsed_step(TRAY_TRACE_CATEGORY, "setup_tray.store", step_started); let step_started = Instant::now(); @@ -278,19 +323,27 @@ pub fn setup_tray( } /// Presentation state from the controller's session projection, never a peer mutation. -pub fn set_unread_count(count: u32) -> Result<(), String> { - let mut current = TRAY_UNREAD_COUNT - .lock() - .map_err(|_| "Tray unread count lock poisoned")?; - *current = count; - apply_unread_count(count).map_err(|error| error.to_string()) +pub fn set_unread_count(app: &AppHandle, count: u32) -> Result<(), String> { + { + let mut current = TRAY_UNREAD_COUNT + .lock() + .map_err(|_| "Tray unread count lock poisoned")?; + if *current == count { + return Ok(()); + } + *current = count; + } + // The menu carries the count in its label and is disabled at zero, so it has + // to follow the count rather than wait for the next 60 s refresh. + let app = app.clone(); + tauri::async_runtime::spawn(async move { + rebuild_tray_menu(&app).await; + }); + Ok(()) } -/// Reserve space in the image layout instead of inserting whitespace into the title. -/// tray-icon displays macOS images at 18 pt high; four extra points of trailing -/// space bring the native image/title gap to approximately six points. #[cfg(target_os = "macos")] -fn macos_tray_icon(has_unread: bool) -> Result, image::ImageError> { +fn macos_tray_icon() -> Result, image::ImageError> { let mark = image::load_from_memory(include_bytes!( "../../../../assets/brand/source/openbitfun-app-mark.png" ))? @@ -306,15 +359,7 @@ fn macos_tray_icon(has_unread: bool) -> Result, ima let inset = image::imageops::resize(&mark, 370, 370, image::imageops::FilterType::Lanczos3); image::imageops::overlay(&mut mark, &inset, 20, 20); let mark = image::imageops::resize(&mark, 64, 64, image::imageops::FilterType::Lanczos3); - let (width, height) = mark.dimensions(); - let image = if has_unread { - let trailing_space = (height * 4 + 9) / 18; - let mut canvas = image::RgbaImage::new(width + trailing_space, height); - image::imageops::replace(&mut canvas, &mark, 0, 0); - canvas - } else { - mark - }; + let image = mark; let (width, height) = image.dimensions(); Ok(tauri::image::Image::new_owned( image.into_raw(), @@ -323,25 +368,6 @@ fn macos_tray_icon(has_unread: bool) -> Result, ima )) } -fn apply_unread_count(count: u32) -> tauri::Result<()> { - #[cfg(target_os = "macos")] - if let Some(tray) = TRAY_ICON.get() { - let title = if count == 0 { - String::new() - } else { - count.to_string() - }; - let icon = - macos_tray_icon(count > 0).map_err(|error| tauri::Error::Anyhow(error.into()))?; - // Replacing an image with set_icon resets its template flag on macOS. - // Apply both together so startup and count updates retain system tinting. - tray.set_icon_with_as_template(Some(icon), true)?; - tray.set_title(Some(title))?; - } - #[cfg(not(target_os = "macos"))] - let _ = count; - Ok(()) -} pub fn show_main_window(app: &tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { diff --git a/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx b/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx index f1b5eaf873..b0b25c7fba 100644 --- a/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx @@ -272,9 +272,7 @@ const WorkspaceSessionFilterMenu: React.FC = () => { ) : null} { - for (const session of flowChatStore.getState().sessions.values()) { - if (session.hasUnreadCompletion) flowChatStore.clearSessionUnreadCompletion(session.sessionId); - } + flowChatStore.clearAllSessionUnreadCompletions(); close(); }} > diff --git a/src/web-ui/src/flow_chat/services/trayUnreadService.test.ts b/src/web-ui/src/flow_chat/services/trayUnreadService.test.ts index eec89b666d..ce622958c0 100644 --- a/src/web-ui/src/flow_chat/services/trayUnreadService.test.ts +++ b/src/web-ui/src/flow_chat/services/trayUnreadService.test.ts @@ -6,12 +6,21 @@ const source = vi.hoisted(() => ({ notify: undefined as (() => void) | undefined, desktop: true, send: vi.fn().mockResolvedValue(undefined), + markAllRead: vi.fn(), + onMarkAllRead: undefined as (() => void) | undefined, })); vi.mock('@/infrastructure/runtime', () => ({ isTauriRuntime: () => source.desktop })); -vi.mock('@/infrastructure/api/service-api/SystemAPI', () => ({ systemAPI: { setTrayUnreadCount: source.send } })); +vi.mock('@/infrastructure/api/service-api/SystemAPI', () => ({ systemAPI: { + setTrayUnreadCount: source.send, + onTrayMarkAllRead: (callback: () => void) => { + source.onMarkAllRead = callback; + return () => { source.onMarkAllRead = undefined; }; + }, +} })); vi.mock('../store/FlowChatStore', () => ({ flowChatStore: { getState: () => ({ sessions: source.sessions }), subscribe: (notify: () => void) => { source.notify = notify; return () => { source.notify = undefined; }; }, + clearAllSessionUnreadCompletions: source.markAllRead, } })); import { countUnreadSessions, installTrayUnreadService } from './trayUnreadService'; @@ -21,6 +30,8 @@ afterEach(() => { source.notify = undefined; source.desktop = true; source.send.mockClear(); + source.markAllRead.mockClear(); + source.onMarkAllRead = undefined; vi.useRealTimers(); }); @@ -96,6 +107,16 @@ describe('tray unread projection', () => { dispose(); }); + it('clears every unread receipt when the tray menu asks, and detaches on dispose', async () => { + vi.useFakeTimers(); + const dispose = installTrayUnreadService(); + await vi.advanceTimersByTimeAsync(100); + source.onMarkAllRead?.(); + expect(source.markAllRead).toHaveBeenCalledTimes(1); + dispose(); + expect(source.onMarkAllRead).toBeUndefined(); + }); + it('does not install native IO on web surfaces', () => { source.desktop = false; installTrayUnreadService()(); diff --git a/src/web-ui/src/flow_chat/services/trayUnreadService.ts b/src/web-ui/src/flow_chat/services/trayUnreadService.ts index e5175d9b79..575b1eb7f4 100644 --- a/src/web-ui/src/flow_chat/services/trayUnreadService.ts +++ b/src/web-ui/src/flow_chat/services/trayUnreadService.ts @@ -44,10 +44,15 @@ export function installTrayUnreadService(): () => void { timer = setTimeout(() => { timer = undefined; void flush(); }, 100); }; const unsubscribe = flowChatStore.subscribe(schedule); + // The tray only forwards the intent; read receipts stay owned by the store. + const unlistenMarkAllRead = systemAPI.onTrayMarkAllRead(() => { + flowChatStore.clearAllSessionUnreadCompletions(); + }); schedule(); return () => { disposed = true; unsubscribe(); + unlistenMarkAllRead(); if (timer) clearTimeout(timer); }; } diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 3f4e1539f2..27294b644c 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -6725,6 +6725,20 @@ export class FlowChatStore { } } + /** + * Acknowledge every unread completion at once. Each session still goes through + * `clearSessionUnreadCompletion` so persisted receipts and activity + * acknowledgements stay identical to clearing them one by one. + */ + public clearAllSessionUnreadCompletions(): number { + const unread: string[] = []; + for (const session of this.state.sessions.values()) { + if (session.hasUnreadCompletion) unread.push(session.sessionId); + } + for (const sessionId of unread) this.clearSessionUnreadCompletion(sessionId); + return unread.length; + } + /** Mirror only the summary's read marker, never its state into the Turn model. */ public applySessionActivityReceipt(summary: SessionActivitySummary): void { this.setState(prev => { 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 fbc5490851..36d9ecb4cc 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts @@ -30,6 +30,9 @@ export interface ToggleMainWindowFullscreenResponse { } /** Close-button behavior values (matches `app.close_button_behavior` config key). */ +/** Emitted by the tray menu; the web UI owns the actual read receipts. */ +const TRAY_MARK_ALL_READ_EVENT = 'tray://mark-all-read'; + export type CloseBehavior = 'quit' | 'minimize_to_tray' | 'ask'; export interface SystemInfo { @@ -316,6 +319,11 @@ export class SystemAPI { await api.invoke('set_tray_unread_count', { request: { count } }); } + /** Desktop only: the tray menu's "Mark all as read" entry was picked. */ + onTrayMarkAllRead(callback: () => void): () => void { + return api.listen(TRAY_MARK_ALL_READ_EVENT, () => callback()); + } + /** Desktop only: initialize the system tray after the startup shell is visible. */ async initializeTrayAfterStartup(): Promise { try {