fix: Surface completed desktop agent replies outside the app

Desktop users already receive native notifications for permission prompts, but long-running turns can finish with only a final Markdown reply while the app is out of focus. The completion path now treats a non-empty assistant reply as a notifiable terminal event and reuses the existing desktop notification settings, dedupe, and cooldown behavior.

Constraint: Notification permission must still be controlled by the existing desktopNotificationsEnabled setting and native OS authorization.
Rejected: Send from status idle events | idle is also used by prewarm, clear, stop, and other non-reply transitions.
Rejected: Notify on every message_complete | empty completions and post-stop tail completions would create noisy false positives.
Confidence: high
Scope-risk: narrow
Directive: Keep completion notifications tied to finalized assistant text, not generic idle/status transitions.
Tested: bun run test -- src/stores/chatStore.test.ts
Tested: bun run check:desktop
Not-tested: Manual macOS notification center click-through behavior
This commit is contained in:
程序员阿江(Relakkes) 2026-05-05 20:58:05 +08:00
parent 82c454e4dc
commit 4ab2ef434e
5 changed files with 171 additions and 9 deletions

View File

@ -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.',
})
})

View File

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

View File

@ -631,10 +631,10 @@ export const zh: Record<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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 域名预检',

View File

@ -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: {

View File

@ -124,6 +124,7 @@ type ChatStore = {
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
const pendingTaskToolUseIds = new Set<string>()
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<string, PerSessionState>,
@ -729,10 +758,13 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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
}