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 {