diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6bf247d6..6b3d1af2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -31,6 +31,10 @@ use tauri_plugin_shell::{ mod macos_notifications { use std::ffi::{CStr, CString}; use std::os::raw::{c_char, c_int}; + use std::sync::{Mutex, OnceLock}; + + use serde::Serialize; + use tauri::{AppHandle, Emitter}; const ERROR_BUFFER_LEN: usize = 1024; @@ -46,11 +50,23 @@ mod macos_notifications { fn cchh_send_user_notification( title: *const c_char, body: *const c_char, + target: *const c_char, error_buffer: *mut c_char, error_buffer_len: usize, ) -> bool; + fn cchh_set_notification_response_callback( + callback: Option, + ); } + #[derive(Clone, Serialize)] + #[serde(rename_all = "camelCase")] + struct NotificationClickPayload { + target: Option, + } + + static NOTIFICATION_APP_HANDLE: OnceLock>> = OnceLock::new(); + fn new_error_buffer() -> [c_char; ERROR_BUFFER_LEN] { [0; ERROR_BUFFER_LEN] } @@ -106,13 +122,51 @@ mod macos_notifications { permission_state() } - pub fn send_notification(title: String, body: Option) -> Result { + extern "C" fn handle_notification_response(target: *const c_char) { + let target = if target.is_null() { + None + } else { + let value = unsafe { CStr::from_ptr(target) } + .to_string_lossy() + .trim() + .to_string(); + (!value.is_empty()).then_some(value) + }; + + let Some(app) = NOTIFICATION_APP_HANDLE + .get() + .and_then(|handle| handle.lock().ok().and_then(|guard| guard.clone())) + else { + return; + }; + + super::show_main_window(&app); + let _ = app.emit("desktop-notification-clicked", NotificationClickPayload { target }); + } + + pub fn install_click_handler(app: AppHandle) { + let handle = NOTIFICATION_APP_HANDLE.get_or_init(|| Mutex::new(None)); + if let Ok(mut guard) = handle.lock() { + *guard = Some(app); + } + unsafe { cchh_set_notification_response_callback(Some(handle_notification_response)) }; + } + + pub fn send_notification( + title: String, + body: Option, + target: Option, + ) -> Result { let title = CString::new(title) .map_err(|_| "notification title contains an unsupported NUL byte".to_string())?; let body = body .map(CString::new) .transpose() .map_err(|_| "notification body contains an unsupported NUL byte".to_string())?; + let target = target + .map(CString::new) + .transpose() + .map_err(|_| "notification target contains an unsupported NUL byte".to_string())?; let mut error_buffer = new_error_buffer(); let sent = unsafe { @@ -120,6 +174,8 @@ mod macos_notifications { title.as_ptr(), body.as_ref() .map_or(std::ptr::null(), |value| value.as_ptr()), + target.as_ref() + .map_or(std::ptr::null(), |value| value.as_ptr()), error_buffer.as_mut_ptr(), ERROR_BUFFER_LEN, ) @@ -138,6 +194,8 @@ mod macos_notifications { #[cfg(not(target_os = "macos"))] mod macos_notifications { + use tauri::AppHandle; + pub fn permission_state() -> Result { Ok("unsupported".to_string()) } @@ -146,7 +204,13 @@ mod macos_notifications { Ok("unsupported".to_string()) } - pub fn send_notification(_title: String, _body: Option) -> Result { + pub fn install_click_handler(_app: AppHandle) {} + + pub fn send_notification( + _title: String, + _body: Option, + _target: Option, + ) -> Result { Ok(false) } } @@ -741,8 +805,12 @@ fn macos_request_notification_permission() -> Result { } #[tauri::command] -fn macos_send_notification(title: String, body: Option) -> Result { - macos_notifications::send_notification(title, body) +fn macos_send_notification( + title: String, + body: Option, + target: Option, +) -> Result { + macos_notifications::send_notification(title, body, target) } fn decode_terminal_output(pending: &mut Vec, chunk: &[u8]) -> String { @@ -1487,6 +1555,7 @@ pub fn run() { let app = builder .setup(|app| { setup_system_tray(app)?; + macos_notifications::install_click_handler(app.handle().clone()); restore_main_window_state(&app.handle()); let state = app.state::(); diff --git a/desktop/src-tauri/src/macos_notifications.m b/desktop/src-tauri/src/macos_notifications.m index 4753ac79..fe911499 100644 --- a/desktop/src-tauri/src/macos_notifications.m +++ b/desktop/src-tauri/src/macos_notifications.m @@ -5,9 +5,22 @@ #include #include +typedef void (*CCHHNotificationResponseCallback)(const char *target_json); + @interface CCHHNotificationDelegate : NSObject @end +static CCHHNotificationResponseCallback cchhNotificationResponseCallback = NULL; + +static void cchh_emit_notification_response(NSString *target) { + if (cchhNotificationResponseCallback == NULL) { + return; + } + + const char *targetJson = target != nil ? [target UTF8String] : NULL; + cchhNotificationResponseCallback(targetJson); +} + @implementation CCHHNotificationDelegate - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification @@ -18,6 +31,14 @@ completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionSound); } } + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center +didReceiveNotificationResponse:(UNNotificationResponse *)response + withCompletionHandler:(void (^)(void))completionHandler { + NSString *target = response.notification.request.content.userInfo[@"cchh_target"]; + cchh_emit_notification_response(target); + completionHandler(); +} @end @interface CCHHUserNotificationDelegate : NSObject @@ -27,6 +48,11 @@ - (BOOL)userNotificationCenter:(NSUserNotificationCenter *)center shouldPresentNotification:(NSUserNotification *)notification { return YES; } + +- (void)userNotificationCenter:(NSUserNotificationCenter *)center didActivateNotification:(NSUserNotification *)notification { + NSString *target = notification.userInfo[@"cchh_target"]; + cchh_emit_notification_response(target); +} @end static CCHHNotificationDelegate *cchhNotificationDelegate = nil; @@ -52,6 +78,10 @@ static void cchh_copy_error(char *buffer, uintptr_t buffer_len, NSString *messag buffer[max_len] = '\0'; } +void cchh_set_notification_response_callback(CCHHNotificationResponseCallback callback) { + cchhNotificationResponseCallback = callback; +} + static UNUserNotificationCenter *cchh_notification_center(void) { static dispatch_once_t onceToken; static UNUserNotificationCenter *center = nil; @@ -136,7 +166,7 @@ bool cchh_request_notification_authorization(char *error_buffer, uintptr_t error } } -bool cchh_send_user_notification(const char *title, const char *body, char *error_buffer, uintptr_t error_buffer_len) { +bool cchh_send_user_notification(const char *title, const char *body, const char *target, char *error_buffer, uintptr_t error_buffer_len) { @autoreleasepool { cchh_copy_error(error_buffer, error_buffer_len, nil); @@ -155,6 +185,9 @@ bool cchh_send_user_notification(const char *title, const char *body, char *erro if (body != NULL && strlen(body) > 0) { content.body = [NSString stringWithUTF8String:body]; } + if (target != NULL && strlen(target) > 0) { + content.userInfo = @{ @"cchh_target": [NSString stringWithUTF8String:target] }; + } content.sound = [UNNotificationSound defaultSound]; NSString *identifier = [[NSUUID UUID] UUIDString]; @@ -185,6 +218,9 @@ bool cchh_send_user_notification(const char *title, const char *body, char *erro if (body != NULL && strlen(body) > 0) { notification.informativeText = [NSString stringWithUTF8String:body]; } + if (target != NULL && strlen(target) > 0) { + notification.userInfo = @{ @"cchh_target": [NSString stringWithUTF8String:target] }; + } notification.soundName = NSUserNotificationDefaultSoundName; [cchh_user_notification_center() deliverNotification:notification]; diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 6fb6fdfd..ee994247 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,7 +1,26 @@ import { AppShell } from './components/layout/AppShell' import { useScheduledTaskDesktopNotifications } from './hooks/useScheduledTaskDesktopNotifications' +import { installDesktopNotificationNavigation } from './lib/desktopNotificationNavigation' +import { useEffect } from 'react' export function App() { useScheduledTaskDesktopNotifications() + useEffect(() => { + let cleanup: (() => void) | undefined + let cancelled = false + installDesktopNotificationNavigation() + .then((fn) => { + if (cancelled) { + fn() + } else { + cleanup = fn + } + }) + .catch(() => {}) + return () => { + cancelled = true + cleanup?.() + } + }, []) return } diff --git a/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx b/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx index 5f57c1b5..02c991de 100644 --- a/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx +++ b/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx @@ -94,6 +94,52 @@ describe('useScheduledTaskDesktopNotifications', () => { dedupeKey: 'scheduled-task:run-new', title: '定时任务 Daily review', body: '失败: provider timeout', + target: { type: 'scheduled' }, + }) + }) + + it('targets the run session when a scheduled task run has a session id', async () => { + listMock.mockResolvedValue({ + tasks: [{ + id: 'task-1', + name: 'Daily review', + cron: '* * * * *', + prompt: 'review', + enabled: true, + createdAt: 1, + notification: { enabled: true, channels: ['desktop'] }, + }], + }) + getRecentRunsMock + .mockResolvedValueOnce({ runs: [] }) + .mockResolvedValueOnce({ + runs: [{ + id: 'run-new', + taskId: 'task-1', + taskName: 'Daily review', + startedAt: '2026-05-03T00:01:00.000Z', + completedAt: '2026-05-03T00:01:01.000Z', + status: 'completed', + prompt: 'review', + output: 'done', + sessionId: 'session-task-run', + }], + }) + + render() + await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1)) + + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(notifyDesktopMock).toHaveBeenCalledTimes(1)) + expect(notifyDesktopMock).toHaveBeenCalledWith({ + dedupeKey: 'scheduled-task:run-new', + title: '定时任务 Daily review', + body: '完成: done', + target: { + type: 'session', + sessionId: 'session-task-run', + title: 'Daily review', + }, }) }) diff --git a/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts b/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts index 061db96c..2cebd0c0 100644 --- a/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts +++ b/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts @@ -93,6 +93,9 @@ export function useScheduledTaskDesktopNotifications(): void { dedupeKey: `scheduled-task:${run.id}`, title: notification.title, body: notification.body, + target: run.sessionId + ? { type: 'session', sessionId: run.sessionId, title: run.taskName || run.taskId } + : { type: 'scheduled' }, }) if (sent) notifiedRunIds.add(run.id) } diff --git a/desktop/src/lib/desktopNotificationNavigation.test.ts b/desktop/src/lib/desktopNotificationNavigation.test.ts new file mode 100644 index 00000000..7b037d24 --- /dev/null +++ b/desktop/src/lib/desktopNotificationNavigation.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { openDesktopNotificationTarget } from './desktopNotificationNavigation' +import { SCHEDULED_TAB_ID, useTabStore } from '../stores/tabStore' +import { useChatStore } from '../stores/chatStore' +import { useSessionStore } from '../stores/sessionStore' + +const initialTabState = useTabStore.getInitialState() +const initialChatState = useChatStore.getInitialState() +const initialSessionState = useSessionStore.getInitialState() + +describe('desktopNotificationNavigation', () => { + beforeEach(() => { + useTabStore.setState(initialTabState, true) + useChatStore.setState(initialChatState, true) + useSessionStore.setState(initialSessionState, true) + }) + + it('opens and connects the session referenced by a notification target', () => { + const connectToSession = vi.fn() + useChatStore.setState({ connectToSession }) + + openDesktopNotificationTarget({ + type: 'session', + sessionId: 'session-1', + title: 'Build fix', + }) + + expect(useTabStore.getState().tabs).toEqual([ + { sessionId: 'session-1', title: 'Build fix', type: 'session', status: 'idle' }, + ]) + expect(useTabStore.getState().activeTabId).toBe('session-1') + expect(connectToSession).toHaveBeenCalledWith('session-1') + }) + + it('uses the known session title when the notification omits one', () => { + const connectToSession = vi.fn() + useSessionStore.setState({ + sessions: [{ + id: 'session-2', + title: 'Known Session', + createdAt: '2026-05-06T00:00:00.000Z', + modifiedAt: '2026-05-06T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + }) + useChatStore.setState({ connectToSession }) + + openDesktopNotificationTarget({ type: 'session', sessionId: 'session-2' }) + + expect(useTabStore.getState().tabs[0]).toMatchObject({ + sessionId: 'session-2', + title: 'Known Session', + type: 'session', + }) + expect(connectToSession).toHaveBeenCalledWith('session-2') + }) + + it('opens the scheduled tasks tab for scheduled notification targets', () => { + const connectToSession = vi.fn() + useChatStore.setState({ connectToSession }) + + openDesktopNotificationTarget({ type: 'scheduled' }) + + expect(useTabStore.getState().tabs).toEqual([ + { sessionId: SCHEDULED_TAB_ID, title: 'Scheduled Tasks', type: 'scheduled', status: 'idle' }, + ]) + expect(useTabStore.getState().activeTabId).toBe(SCHEDULED_TAB_ID) + expect(connectToSession).not.toHaveBeenCalled() + }) +}) diff --git a/desktop/src/lib/desktopNotificationNavigation.ts b/desktop/src/lib/desktopNotificationNavigation.ts new file mode 100644 index 00000000..bf2ad7b7 --- /dev/null +++ b/desktop/src/lib/desktopNotificationNavigation.ts @@ -0,0 +1,28 @@ +import { useChatStore } from '../stores/chatStore' +import { useSessionStore } from '../stores/sessionStore' +import { SCHEDULED_TAB_ID, useTabStore } from '../stores/tabStore' +import { + installDesktopNotificationClickListener, + type DesktopNotificationTarget, +} from './desktopNotifications' + +const SCHEDULED_TAB_TITLE = 'Scheduled Tasks' + +export function openDesktopNotificationTarget(target: DesktopNotificationTarget): void { + if (target.type === 'scheduled') { + useTabStore.getState().openTab(SCHEDULED_TAB_ID, SCHEDULED_TAB_TITLE, 'scheduled') + return + } + + const knownTitle = useSessionStore + .getState() + .sessions + .find((session) => session.id === target.sessionId) + ?.title + useTabStore.getState().openTab(target.sessionId, target.title || knownTitle || 'Session', 'session') + useChatStore.getState().connectToSession(target.sessionId) +} + +export function installDesktopNotificationNavigation(): Promise<() => void> { + return installDesktopNotificationClickListener(openDesktopNotificationTarget) +} diff --git a/desktop/src/lib/desktopNotifications.test.ts b/desktop/src/lib/desktopNotifications.test.ts index 6e0d3766..f55276c2 100644 --- a/desktop/src/lib/desktopNotifications.test.ts +++ b/desktop/src/lib/desktopNotifications.test.ts @@ -4,10 +4,14 @@ const notificationPluginMock = vi.hoisted(() => ({ isPermissionGranted: vi.fn(), requestPermission: vi.fn(), sendNotification: vi.fn(), + onAction: vi.fn(), })) const coreApiMock = vi.hoisted(() => ({ invoke: vi.fn(), })) +const eventApiMock = vi.hoisted(() => ({ + listen: vi.fn(), +})) const requestUserAttentionMock = vi.hoisted(() => vi.fn()) const windowApiMock = vi.hoisted(() => ({ requestUserAttention: requestUserAttentionMock, @@ -22,10 +26,12 @@ const windowApiMock = vi.hoisted(() => ({ vi.mock('@tauri-apps/plugin-notification', () => notificationPluginMock) vi.mock('@tauri-apps/api/core', () => coreApiMock) +vi.mock('@tauri-apps/api/event', () => eventApiMock) vi.mock('@tauri-apps/api/window', () => windowApiMock) import { getDesktopNotificationPermission, + installDesktopNotificationClickListener, notifyDesktop, requestDesktopNotificationPermission, resetDesktopNotificationsForTests, @@ -38,9 +44,11 @@ describe('desktopNotifications', () => { vi.useRealTimers() resetDesktopNotificationsForTests() coreApiMock.invoke.mockReset() + eventApiMock.listen.mockReset() notificationPluginMock.isPermissionGranted.mockReset() notificationPluginMock.requestPermission.mockReset() notificationPluginMock.sendNotification.mockReset() + notificationPluginMock.onAction.mockReset() windowApiMock.getCurrentWindow.mockClear() windowApiMock.requestUserAttention.mockReset() useSettingsStore.setState({ desktopNotificationsEnabled: true }) @@ -68,6 +76,27 @@ describe('desktopNotifications', () => { }) }) + it('passes notification targets through the Tauri plugin payload', async () => { + notificationPluginMock.isPermissionGranted.mockResolvedValue(true) + const target = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' } + + await expect(notifyDesktop({ + dedupeKey: 'permission:targeted', + title: 'Permission required', + body: 'Approve command execution', + target, + })).resolves.toBe(true) + + expect(notificationPluginMock.sendNotification).toHaveBeenCalledWith(expect.objectContaining({ + title: 'Permission required', + body: 'Approve command execution', + id: expect.any(Number), + extra: { + ccHahaTarget: JSON.stringify(target), + }, + })) + }) + it('does not request notification permission from a blocking permission prompt', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) notificationPluginMock.isPermissionGranted.mockResolvedValue(false) @@ -103,6 +132,27 @@ describe('desktopNotifications', () => { expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled() }) + it('passes notification targets through the macOS native bridge', async () => { + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: 'MacIntel', + }) + coreApiMock.invoke.mockResolvedValueOnce(true) + const target = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' } + + await expect(notifyDesktop({ + title: 'Permission required', + body: 'Approve command execution', + target, + })).resolves.toBe(true) + + expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_send_notification', { + title: 'Permission required', + body: 'Approve command execution', + target: JSON.stringify(target), + }) + }) + it('does not request macOS permission from a blocking permission prompt', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) Object.defineProperty(navigator, 'platform', { @@ -235,6 +285,49 @@ describe('desktopNotifications', () => { }) }) + it('forwards notification click targets from native and plugin listeners', async () => { + const unlistenNative = vi.fn() + const unregisterPlugin = vi.fn() + type NativeClickCallback = (event: { payload: unknown }) => void + type PluginClickCallback = (notification: unknown) => void + let nativeCallback: NativeClickCallback = () => { + throw new Error('native listener was not registered') + } + let pluginCallback: PluginClickCallback = () => { + throw new Error('plugin listener was not registered') + } + let nativeRegistered = false + let pluginRegistered = false + const sessionTarget = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' } + const scheduledTarget = { type: 'scheduled' as const } + + eventApiMock.listen.mockImplementation(async (_eventName: string, callback: (event: { payload: unknown }) => void) => { + nativeCallback = callback + nativeRegistered = true + return unlistenNative + }) + notificationPluginMock.onAction.mockImplementation(async (callback: (notification: unknown) => void) => { + pluginCallback = callback + pluginRegistered = true + return { unregister: unregisterPlugin } + }) + + const onTarget = vi.fn() + const cleanup = await installDesktopNotificationClickListener(onTarget) + + expect(nativeRegistered).toBe(true) + expect(pluginRegistered).toBe(true) + nativeCallback({ payload: { target: JSON.stringify(sessionTarget) } }) + pluginCallback({ extra: { ccHahaTarget: JSON.stringify(scheduledTarget) } }) + + expect(onTarget).toHaveBeenCalledWith(sessionTarget) + expect(onTarget).toHaveBeenCalledWith(scheduledTarget) + + cleanup() + expect(unlistenNative).toHaveBeenCalledTimes(1) + expect(unregisterPlugin).toHaveBeenCalledTimes(1) + }) + it('requests OS-level window attention for blocking prompts', async () => { const sender = vi.fn(async () => true) setNativeNotificationSenderForTests(sender) diff --git a/desktop/src/lib/desktopNotifications.ts b/desktop/src/lib/desktopNotifications.ts index c328e7f2..27015a82 100644 --- a/desktop/src/lib/desktopNotifications.ts +++ b/desktop/src/lib/desktopNotifications.ts @@ -9,16 +9,32 @@ export type DesktopNotificationOptions = { cooldownScope?: string cooldownMs?: number requestAttention?: boolean + target?: DesktopNotificationTarget } -type NativeNotificationSender = (options: { title: string; body?: string }) => Promise | boolean +export type DesktopNotificationTarget = + | { type: 'session'; sessionId: string; title?: string } + | { type: 'scheduled' } + +type NativeNotificationPayload = { + title: string + body?: string + id?: number + extra?: Record + target?: DesktopNotificationTarget +} + +type NativeNotificationSender = (options: NativeNotificationPayload) => Promise | boolean export type DesktopNotificationPermission = NotificationPermission | 'unsupported' +const TARGET_EXTRA_KEY = 'ccHahaTarget' const notifiedKeys = new Set() const pendingKeys = new Set() const lastNotificationAtByScope = new Map() const pendingCooldownScopes = new Set() +const notificationTargetById = new Map() let overrideNativeNotificationSender: NativeNotificationSender | null = null +let nextNotificationId = 1 function readBrowserNotificationPermission(): DesktopNotificationPermission { if (typeof window === 'undefined' || !('Notification' in window)) { @@ -78,12 +94,86 @@ async function invokeMacNotificationPermissionRequest(): Promise { +function isNotificationTarget(value: unknown): value is DesktopNotificationTarget { + if (!value || typeof value !== 'object') return false + const target = value as Partial + if (target.type === 'scheduled') return true + if (target.type === 'session') { + return typeof target.sessionId === 'string' && target.sessionId.length > 0 && + (target.title === undefined || typeof target.title === 'string') + } + return false +} + +function parseTargetJson(value: unknown): DesktopNotificationTarget | null { + if (typeof value !== 'string' || !value.trim()) return null + try { + const parsed = JSON.parse(value) as unknown + return isNotificationTarget(parsed) ? parsed : null + } catch { + return null + } +} + +function notificationTargetFromPayload(payload: unknown): DesktopNotificationTarget | null { + if (isNotificationTarget(payload)) return payload + const jsonTarget = parseTargetJson(payload) + if (jsonTarget) return jsonTarget + + if (!payload || typeof payload !== 'object') return null + const record = payload as Record + + const directTarget = notificationTargetFromPayload(record.target) + if (directTarget) return directTarget + + const extra = record.extra && typeof record.extra === 'object' + ? record.extra as Record + : null + const extraTarget = extra ? notificationTargetFromPayload(extra[TARGET_EXTRA_KEY]) : null + if (extraTarget) return extraTarget + + const data = record.data && typeof record.data === 'object' + ? record.data as Record + : null + const dataTarget = data ? notificationTargetFromPayload(data[TARGET_EXTRA_KEY]) : null + if (dataTarget) return dataTarget + + const id = typeof record.id === 'number' ? record.id : null + return id !== null ? notificationTargetById.get(id) ?? null : null +} + +function buildNativeNotificationPayload(options: { + title: string + body?: string + target?: DesktopNotificationTarget +}): NativeNotificationPayload { + const payload: NativeNotificationPayload = { + title: options.title, + body: options.body, + } + + if (options.target) { + const id = nextNotificationId++ + notificationTargetById.set(id, options.target) + payload.id = id + payload.extra = { [TARGET_EXTRA_KEY]: JSON.stringify(options.target) } + payload.target = options.target + } + + return payload +} + +async function sendMacNotification(options: { title: string; body?: string; target?: DesktopNotificationTarget }): Promise { if (detectPlatform() !== 'darwin') return null try { const { invoke } = await import('@tauri-apps/api/core') - const sent = await invoke('macos_send_notification', options) + const target = options.target ? JSON.stringify(options.target) : undefined + const sent = await invoke('macos_send_notification', { + title: options.title, + body: options.body, + ...(target ? { target } : {}), + }) return typeof sent === 'boolean' ? sent : false } catch (err) { if (typeof console !== 'undefined') { @@ -141,7 +231,7 @@ export async function openDesktopNotificationSettings(): Promise { } } -async function sendNativeNotification(options: { title: string; body?: string }): Promise { +async function sendNativeNotification(options: { title: string; body?: string; target?: DesktopNotificationTarget }): Promise { const macSent = await sendMacNotification(options) if (macSent !== null) return macSent @@ -154,7 +244,8 @@ async function sendNativeNotification(options: { title: string; body?: string }) return false } - sendNotification(options) + const payload = buildNativeNotificationPayload(options) + sendNotification(payload) return true } @@ -168,6 +259,66 @@ async function requestWindowAttention(): Promise { } } +async function focusCurrentWindow(): Promise { + try { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + const win = getCurrentWindow() + await (win as unknown as { show?: () => Promise | void }).show?.() + await (win as unknown as { setFocus?: () => Promise | void }).setFocus?.() + } catch { + // Best effort only: the notification target can still be opened in the UI. + } +} + +function disposePluginListener(listener: unknown): void { + if (typeof listener === 'function') { + listener() + return + } + const unregister = listener && typeof listener === 'object' + ? (listener as { unregister?: () => Promise | void }).unregister + : undefined + if (unregister) void unregister() +} + +export async function installDesktopNotificationClickListener( + onTarget: (target: DesktopNotificationTarget) => void, +): Promise<() => void> { + const cleanups: Array<() => void> = [] + const handlePayload = (payload: unknown) => { + const target = notificationTargetFromPayload(payload) + if (!target) return + void focusCurrentWindow() + onTarget(target) + } + + try { + const { listen } = await import('@tauri-apps/api/event') + const unlisten = await listen('desktop-notification-clicked', (event) => { + handlePayload(event.payload) + }) + cleanups.push(unlisten) + } catch { + // Non-Tauri browser tests and unsupported runtimes do not expose native events. + } + + try { + const { onAction } = await import('@tauri-apps/plugin-notification') as { + onAction?: (cb: (notification: unknown) => void) => Promise + } + if (onAction) { + const listener = await onAction(handlePayload) + cleanups.push(() => disposePluginListener(listener)) + } + } catch { + // The desktop plugin does not expose click actions on every platform. + } + + return () => { + for (const cleanup of cleanups.splice(0)) cleanup() + } +} + export async function notifyDesktop(options: DesktopNotificationOptions): Promise { if (!useSettingsStore.getState().desktopNotificationsEnabled) { return false @@ -197,7 +348,11 @@ export async function notifyDesktop(options: DesktopNotificationOptions): Promis const sender = overrideNativeNotificationSender ?? sendNativeNotification try { - const sent = await Promise.resolve(sender({ title: options.title, body: options.body })) + const sent = await Promise.resolve(sender({ + title: options.title, + body: options.body, + ...(options.target ? { target: options.target } : {}), + })) if (options.dedupeKey) { pendingKeys.delete(options.dedupeKey) if (sent) notifiedKeys.add(options.dedupeKey) @@ -225,7 +380,9 @@ export function resetDesktopNotificationsForTests(): void { pendingKeys.clear() lastNotificationAtByScope.clear() pendingCooldownScopes.clear() + notificationTargetById.clear() overrideNativeNotificationSender = null + nextNotificationId = 1 } export function setNativeNotificationSenderForTests(sender: NativeNotificationSender | null): void { diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 33289c0a..f20d9711 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -699,6 +699,7 @@ describe('chatStore history mapping', () => { requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: 'AskUserQuestion 请求执行,正在等待允许。', + target: { type: 'session', sessionId: TEST_SESSION_ID }, }) }) @@ -1013,6 +1014,7 @@ describe('chatStore history mapping', () => { requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: 'Open Finder and inspect a file', + target: { type: 'session', sessionId: TEST_SESSION_ID }, }) }) @@ -1189,6 +1191,7 @@ describe('chatStore history mapping', () => { cooldownScope: 'agent-completion', title: 'Claude Code Haha 已完成回复', body: '结果 修复完成 bun test 已通过', + target: { type: 'session', sessionId: TEST_SESSION_ID }, })) expect(notifyDesktopMock.mock.calls[0]?.[0].dedupeKey).toMatch( /^agent-completion:test-session-1:msg-/, diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 34c9ed60..21c432f5 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -708,6 +708,7 @@ export const useChatStore = create((set, get) => ({ body: msg.toolName ? `${msg.toolName} 请求执行,正在等待允许。` : '有一个工具请求正在等待允许。', + target: { type: 'session', sessionId }, }) update((s) => ({ pendingPermission: { @@ -743,6 +744,7 @@ export const useChatStore = create((set, get) => ({ requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: msg.request.reason || 'Computer Use 正在等待允许。', + target: { type: 'session', sessionId }, }) update(() => ({ pendingComputerUsePermission: { @@ -788,6 +790,7 @@ export const useChatStore = create((set, get) => ({ cooldownScope: 'agent-completion', title: notification.title, body: notification.body, + target: { type: 'session', sessionId }, }) } break