diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 141c2894..7bb5bc71 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -218,7 +218,7 @@ describe('Settings > General tab', () => { }) expect(desktopNotificationsMock.notifyDesktop).toHaveBeenCalledWith({ title: 'Claude Code Haha notifications are enabled', - body: 'Permission prompts will now use macOS notifications.', + body: 'Permission prompts and completed agent replies will now use macOS notifications.', }) }) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index cf776d92..64fbe7cb 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -629,10 +629,10 @@ export const en = { 'settings.general.thinkingEnabled': 'Enable thinking mode', 'settings.general.thinkingHint': 'Turn this off to start new sessions with --thinking disabled; useful for DeepSeek V4 Flash/Pro and other non-thinking workflows.', 'settings.general.notificationsTitle': 'System Notifications', - 'settings.general.notificationsDescription': 'Use native OS notifications for permission prompts and scheduled task results.', + 'settings.general.notificationsDescription': 'Use native OS notifications for permission prompts, agent replies, and scheduled task results.', 'settings.general.notificationsEnabled': 'Enable system notifications', 'settings.general.notificationsHintOn': 'When enabled, the app will request notification permission and use the OS notification center.', - 'settings.general.notificationsHintOff': 'When disabled, permission prompts and scheduled tasks will not send desktop notifications.', + 'settings.general.notificationsHintOff': 'When disabled, permission prompts, agent replies, and scheduled tasks will not send desktop notifications.', 'settings.general.notificationsStatus': 'Permission', 'settings.general.notificationsStatusGranted': 'Granted', 'settings.general.notificationsStatusDenied': 'Blocked by system settings', @@ -641,7 +641,7 @@ export const en = { 'settings.general.notificationsAuthorize': 'Authorize', 'settings.general.notificationsOpenSettings': 'Open Settings', 'settings.general.notificationsTestTitle': 'Claude Code Haha notifications are enabled', - 'settings.general.notificationsTestBody': 'Permission prompts will now use macOS notifications.', + 'settings.general.notificationsTestBody': 'Permission prompts and completed agent replies will now use macOS notifications.', 'settings.general.webFetchPreflightTitle': 'WebFetch Preflight', 'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.', 'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 587abd1c..54121d8c 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -631,10 +631,10 @@ export const zh: Record = { 'settings.general.thinkingEnabled': '启用思考模式', 'settings.general.thinkingHint': '关闭后会以 --thinking disabled 启动新会话;适合 DeepSeek V4 Flash/Pro 等需要非思考模式的模型。', 'settings.general.notificationsTitle': '系统通知', - 'settings.general.notificationsDescription': '使用操作系统原生通知提醒授权确认和定时任务结果。', + 'settings.general.notificationsDescription': '使用操作系统原生通知提醒授权确认、Agent 回复完成和定时任务结果。', 'settings.general.notificationsEnabled': '启用系统通知', 'settings.general.notificationsHintOn': '开启后会请求系统通知权限,并通过系统通知中心提醒。', - 'settings.general.notificationsHintOff': '关闭后,授权确认和定时任务都不会发送桌面通知。', + 'settings.general.notificationsHintOff': '关闭后,授权确认、Agent 回复和定时任务都不会发送桌面通知。', 'settings.general.notificationsStatus': '权限', 'settings.general.notificationsStatusGranted': '已授权', 'settings.general.notificationsStatusDenied': '已被系统设置阻止', @@ -643,7 +643,7 @@ export const zh: Record = { 'settings.general.notificationsAuthorize': '授权通知', 'settings.general.notificationsOpenSettings': '打开系统设置', 'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已启用', - 'settings.general.notificationsTestBody': '后续授权确认会通过 macOS 系统通知提醒。', + 'settings.general.notificationsTestBody': '后续授权确认和 Agent 回复完成都会通过 macOS 系统通知提醒。', 'settings.general.webFetchPreflightTitle': 'WebFetch 预检', 'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。', 'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检', diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 31853243..33289c0a 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -1143,6 +1143,125 @@ describe('chatStore history mapping', () => { vi.useRealTimers() }) + it('sends a desktop notification when the agent finishes a markdown reply', () => { + vi.useFakeTimers() + + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: { + messages: [ + { id: 'user-1', type: 'user_text', content: '总结一下', timestamp: Date.now() }, + ], + chatState: 'streaming', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_start', + blockType: 'text', + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_delta', + text: '## 结果\n\n- **修复完成**\n- `bun test` 已通过', + }) + vi.advanceTimersByTime(60) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'message_complete', + usage: { input_tokens: 1, output_tokens: 2 }, + }) + + expect(notifyDesktopMock).toHaveBeenCalledWith(expect.objectContaining({ + cooldownScope: 'agent-completion', + title: 'Claude Code Haha 已完成回复', + body: '结果 修复完成 bun test 已通过', + })) + expect(notifyDesktopMock.mock.calls[0]?.[0].dedupeKey).toMatch( + /^agent-completion:test-session-1:msg-/, + ) + + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + it('does not notify when completion has no assistant text', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: { + messages: [], + chatState: 'thinking', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'message_complete', + usage: { input_tokens: 1, output_tokens: 0 }, + }) + + expect(notifyDesktopMock).not.toHaveBeenCalled() + }) + + it('does not notify when a completion arrives after the session is already idle', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '用户已停止后的残余文本', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'message_complete', + usage: { input_tokens: 1, output_tokens: 1 }, + }) + + expect(notifyDesktopMock).not.toHaveBeenCalled() + }) + it('sends Computer Use approval payloads back over websocket', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 5aacb2a0..34c9ed60 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -124,6 +124,7 @@ type ChatStore = { const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) const pendingTaskToolUseIds = new Set() +const AGENT_COMPLETION_NOTIFICATION_PREVIEW_CHARS = 160 let msgCounter = 0 const nextId = () => `msg-${++msgCounter}-${Date.now()}` @@ -172,6 +173,34 @@ function appendAssistantTextMessage( ] } +function normalizeNotificationPreview(content: string): string { + return content + .replace(/```[\s\S]*?```/g, ' code block ') + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/[*_~>#-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function buildAgentCompletionNotification( + sessionId: string, + messages: UIMessage[], + text: string, +): { title: string; body: string; dedupeKey: string } | null { + const preview = normalizeNotificationPreview(text) + if (!preview) return null + + const lastAssistant = [...messages].reverse().find((message) => message.type === 'assistant_text') + const suffix = preview.length > AGENT_COMPLETION_NOTIFICATION_PREVIEW_CHARS ? '...' : '' + return { + title: 'Claude Code Haha 已完成回复', + body: preview.slice(0, AGENT_COMPLETION_NOTIFICATION_PREVIEW_CHARS) + suffix, + dedupeKey: `agent-completion:${sessionId}:${lastAssistant?.id ?? Date.now()}`, + } +} + /** Helper: immutably update a specific session within the sessions record */ function updateSessionIn( sessions: Record, @@ -729,10 +758,13 @@ export const useChatStore = create((set, get) => ({ case 'message_complete': { const session = get().sessions[sessionId] if (!session) break + const wasAgentRunning = session.chatState !== 'idle' const text = `${session.streamingText}${consumePendingDelta()}` + let completionMessages = session.messages if (text.trim()) { - update((s) => ({ - messages: appendAssistantTextMessage(s.messages, text, Date.now()), + completionMessages = appendAssistantTextMessage(session.messages, text, Date.now()) + update(() => ({ + messages: completionMessages, streamingText: '', })) } else if (text !== session.streamingText) { @@ -747,6 +779,17 @@ export const useChatStore = create((set, get) => ({ pendingComputerUsePermission: null, elapsedTimer: null, })) + const notification = wasAgentRunning + ? buildAgentCompletionNotification(sessionId, completionMessages, text) + : null + if (notification) { + void notifyDesktop({ + dedupeKey: notification.dedupeKey, + cooldownScope: 'agent-completion', + title: notification.title, + body: notification.body, + }) + } break }