Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
{
Expand Down
7 changes: 5 additions & 2 deletions src/apps/desktop/src/api/system_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
116 changes: 71 additions & 45 deletions src/apps/desktop/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,30 +30,38 @@ use crate::startup_trace::DesktopStartupTrace;
static TRAY_ICON: OnceLock<tauri::tray::TrayIcon> = OnceLock::new();
static TRAY_UNREAD_COUNT: Mutex<u32> = 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 {
Expand All @@ -77,6 +85,20 @@ async fn load_ai_experience(app: &AppHandle) -> Option<AIExperienceConfig> {
.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;
}
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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");
Expand Down Expand Up @@ -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();
Expand All @@ -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<tauri::image::Image<'static>, image::ImageError> {
fn macos_tray_icon() -> Result<tauri::image::Image<'static>, image::ImageError> {
let mark = image::load_from_memory(include_bytes!(
"../../../../assets/brand/source/openbitfun-app-mark.png"
))?
Expand All @@ -306,15 +359,7 @@ fn macos_tray_icon(has_unread: bool) -> Result<tauri::image::Image<'static>, 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(),
Expand All @@ -323,25 +368,6 @@ fn macos_tray_icon(has_unread: bool) -> Result<tauri::image::Image<'static>, 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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,7 @@ const WorkspaceSessionFilterMenu: React.FC = () => {
) : null}
<MenuItem
onClick={() => {
for (const session of flowChatStore.getState().sessions.values()) {
if (session.hasUnreadCompletion) flowChatStore.clearSessionUnreadCompletion(session.sessionId);
}
flowChatStore.clearAllSessionUnreadCompletions();
close();
}}
>
Expand Down
23 changes: 22 additions & 1 deletion src/web-ui/src/flow_chat/services/trayUnreadService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -21,6 +30,8 @@ afterEach(() => {
source.notify = undefined;
source.desktop = true;
source.send.mockClear();
source.markAllRead.mockClear();
source.onMarkAllRead = undefined;
vi.useRealTimers();
});

Expand Down Expand Up @@ -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()();
Expand Down
5 changes: 5 additions & 0 deletions src/web-ui/src/flow_chat/services/trayUnreadService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}
14 changes: 14 additions & 0 deletions src/web-ui/src/flow_chat/store/FlowChatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
8 changes: 8 additions & 0 deletions src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<void> {
try {
Expand Down
Loading