feat: route desktop notifications to target sessions

Desktop notifications now carry a narrow target payload so clicking a permission, completion, or scheduled-task notification can reopen the matching tab and reconnect the session. macOS uses the native notification bridge for tap callbacks, while the shared desktop notification layer also accepts plugin action payloads where the platform exposes them.

Constraint: Notification clicks need to activate existing desktop tabs without adding a new navigation state channel.
Rejected: Store only notification ids and infer the active session later | stale notifications would point at the wrong session after tab changes.
Confidence: high
Scope-risk: moderate
Directive: Keep notification targets serializable and versioned through desktopNotifications before adding new target types.
Tested: bun run quality:pr with ALLOW_CLI_CORE_CHANGE=1; desktop unit tests; server tests; native sidecar build and cargo check; docs build.
Not-tested: Packaged Windows toast activation runtime; current fallback depends on Tauri notification action delivery.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-06 14:00:32 +08:00
parent 578a967c5a
commit b8e335f7bd
11 changed files with 541 additions and 11 deletions

View File

@ -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<extern "C" fn(*const c_char)>,
);
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct NotificationClickPayload {
target: Option<String>,
}
static NOTIFICATION_APP_HANDLE: OnceLock<Mutex<Option<AppHandle>>> = 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<String>) -> Result<bool, String> {
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<String>,
target: Option<String>,
) -> Result<bool, String> {
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<String, String> {
Ok("unsupported".to_string())
}
@ -146,7 +204,13 @@ mod macos_notifications {
Ok("unsupported".to_string())
}
pub fn send_notification(_title: String, _body: Option<String>) -> Result<bool, String> {
pub fn install_click_handler(_app: AppHandle) {}
pub fn send_notification(
_title: String,
_body: Option<String>,
_target: Option<String>,
) -> Result<bool, String> {
Ok(false)
}
}
@ -741,8 +805,12 @@ fn macos_request_notification_permission() -> Result<String, String> {
}
#[tauri::command]
fn macos_send_notification(title: String, body: Option<String>) -> Result<bool, String> {
macos_notifications::send_notification(title, body)
fn macos_send_notification(
title: String,
body: Option<String>,
target: Option<String>,
) -> Result<bool, String> {
macos_notifications::send_notification(title, body, target)
}
fn decode_terminal_output(pending: &mut Vec<u8>, 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::<ServerState>();

View File

@ -5,9 +5,22 @@
#include <stdint.h>
#include <string.h>
typedef void (*CCHHNotificationResponseCallback)(const char *target_json);
@interface CCHHNotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
@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 <NSUserNotificationCenterDelegate>
@ -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];

View File

@ -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 <AppShell />
}

View File

@ -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(<Harness />)
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',
},
})
})

View File

@ -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)
}

View File

@ -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()
})
})

View File

@ -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)
}

View File

@ -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)

View File

@ -9,16 +9,32 @@ export type DesktopNotificationOptions = {
cooldownScope?: string
cooldownMs?: number
requestAttention?: boolean
target?: DesktopNotificationTarget
}
type NativeNotificationSender = (options: { title: string; body?: string }) => Promise<boolean> | boolean
export type DesktopNotificationTarget =
| { type: 'session'; sessionId: string; title?: string }
| { type: 'scheduled' }
type NativeNotificationPayload = {
title: string
body?: string
id?: number
extra?: Record<string, unknown>
target?: DesktopNotificationTarget
}
type NativeNotificationSender = (options: NativeNotificationPayload) => Promise<boolean> | boolean
export type DesktopNotificationPermission = NotificationPermission | 'unsupported'
const TARGET_EXTRA_KEY = 'ccHahaTarget'
const notifiedKeys = new Set<string>()
const pendingKeys = new Set<string>()
const lastNotificationAtByScope = new Map<string, number>()
const pendingCooldownScopes = new Set<string>()
const notificationTargetById = new Map<number, DesktopNotificationTarget>()
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<DesktopNotifica
}
}
async function sendMacNotification(options: { title: string; body?: string }): Promise<boolean | null> {
function isNotificationTarget(value: unknown): value is DesktopNotificationTarget {
if (!value || typeof value !== 'object') return false
const target = value as Partial<DesktopNotificationTarget>
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<string, unknown>
const directTarget = notificationTargetFromPayload(record.target)
if (directTarget) return directTarget
const extra = record.extra && typeof record.extra === 'object'
? record.extra as Record<string, unknown>
: 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<string, unknown>
: 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<boolean | null> {
if (detectPlatform() !== 'darwin') return null
try {
const { invoke } = await import('@tauri-apps/api/core')
const sent = await invoke<boolean>('macos_send_notification', options)
const target = options.target ? JSON.stringify(options.target) : undefined
const sent = await invoke<boolean>('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<boolean> {
}
}
async function sendNativeNotification(options: { title: string; body?: string }): Promise<boolean> {
async function sendNativeNotification(options: { title: string; body?: string; target?: DesktopNotificationTarget }): Promise<boolean> {
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<boolean> {
}
}
async function focusCurrentWindow(): Promise<void> {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
const win = getCurrentWindow()
await (win as unknown as { show?: () => Promise<void> | void }).show?.()
await (win as unknown as { setFocus?: () => Promise<void> | 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> | 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<unknown>('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<unknown>
}
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<boolean> {
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 {

View File

@ -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-/,

View File

@ -708,6 +708,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
body: msg.toolName
? `${msg.toolName} 请求执行,正在等待允许。`
: '有一个工具请求正在等待允许。',
target: { type: 'session', sessionId },
})
update((s) => ({
pendingPermission: {
@ -743,6 +744,7 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((set, get) => ({
cooldownScope: 'agent-completion',
title: notification.title,
body: notification.body,
target: { type: 'session', sessionId },
})
}
break