mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
82c454e4dc
commit
4ab2ef434e
@ -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.',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 域名预检',
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user