Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 83 additions & 20 deletions apps/buddy/electron/main/DesktopNotificationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export interface DesktopNotificationInput {
}

export interface DesktopSystemNotification {
close: () => void
onClick: (listener: () => void) => void
onClose: (listener: (reason?: 'userCanceled' | 'applicationHidden' | 'timedOut') => void) => void
onFailed: (listener: () => void) => void
show: () => void
}

Expand All @@ -28,6 +31,7 @@ export interface DesktopNotificationServiceOptions {
'notificationsEnabled' | 'notifyWhenFocused'
>
isWindowFocused: () => boolean
onError: (error: unknown) => void
openTarget: (target: DesktopNotificationTarget) => Promise<void> | void
request: DesktopRuntimeGateway['request']
}
Expand All @@ -50,19 +54,23 @@ const BODY_LABELS = {
export class DesktopNotificationService {
readonly #options: DesktopNotificationServiceOptions
readonly #shownEvents = new Set<string>()
readonly #pendingEvents = new Set<string>()
// Keep native click handlers alive independently of the selected conversation.
readonly #notifications = new Map<string, DesktopSystemNotification>()
#disposed = false

constructor(options: DesktopNotificationServiceOptions) {
this.#options = options
}

async handle(notification: { method: string, params: unknown }): Promise<void> {
if (notification.method !== 'run.event')
if (this.#disposed || notification.method !== 'run.event')
return
const event = runsRequestSchemas.runStateEvent.safeParse(notification.params)
if (!event.success)
return
const eventKey = `${event.data.runId}:${event.data.sequence}:${event.data.type}`
if (this.#shownEvents.has(eventKey))
if (this.#shownEvents.has(eventKey) || this.#pendingEvents.has(eventKey))
return
if (!shouldShowDesktopNotification({
eventType: event.data.type,
Expand All @@ -72,24 +80,79 @@ export class DesktopNotificationService {
return
}

const run = runsResponseSchemas.run.parse(
await this.#options.request('runs.get', { runId: event.data.runId }),
)
const conversation = conversationResponseSchemas.conversation.parse(
await this.#options.request('conversations.get', { conversationId: run.conversationId }),
)
const labels = BODY_LABELS[this.#options.getLanguage()]
const systemNotification = this.#options.createNotification({
body: labels[event.data.type as keyof Omit<typeof labels, 'untitled'>],
title: conversation.title?.trim() || labels.untitled,
})
systemNotification.onClick(() => {
void this.#options.openTarget({
conversationId: run.conversationId,
runId: run.id,
this.#pendingEvents.add(eventKey)
try {
const run = runsResponseSchemas.run.parse(
await this.#options.request('runs.get', { runId: event.data.runId }),
)
if (this.#disposed)
return
const conversation = conversationResponseSchemas.conversation.parse(
await this.#options.request('conversations.get', { conversationId: run.conversationId }),
)
if (this.#disposed)
return
const labels = BODY_LABELS[this.#options.getLanguage()]
const systemNotification = this.#options.createNotification({
body: labels[event.data.type as keyof Omit<typeof labels, 'untitled'>],
title: conversation.title?.trim() || labels.untitled,
})
})
systemNotification.show()
this.#shownEvents.add(eventKey)
this.#notifications.set(eventKey, systemNotification)
const release = () => {
if (this.#notifications.get(eventKey) !== systemNotification)
return false
this.#notifications.delete(eventKey)
return true
}
systemNotification.onClick(() => {
if (!release())
return
void Promise.resolve().then(() => {
if (!this.#disposed) {
return this.#options.openTarget({
conversationId: run.conversationId,
runId: run.id,
})
}
}).catch(error => this.#options.onError(error))
})
systemNotification.onClose((reason) => {
// A timed-out/hidden Windows banner can still be clicked in Action Center.
if (reason === 'userCanceled')
release()
})
systemNotification.onFailed(() => {
if (release())
this.#shownEvents.delete(eventKey)
})
this.#shownEvents.add(eventKey)
try {
systemNotification.show()
}
catch (error) {
release()
this.#shownEvents.delete(eventKey)
throw error
}
}
finally {
this.#pendingEvents.delete(eventKey)
}
}

dispose(): void {
this.#disposed = true
const notifications = [...this.#notifications.values()]
this.#notifications.clear()
this.#shownEvents.clear()
this.#pendingEvents.clear()
for (const notification of notifications) {
try {
notification.close()
}
catch (error) {
this.#options.onError(error)
}
}
}
}
205 changes: 142 additions & 63 deletions apps/buddy/electron/main/__tests__/DesktopNotificationService.spec.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,156 @@
import type { DesktopSystemNotification } from '../DesktopNotificationService'
import { describe, expect, it, vi } from 'vitest'
import { DesktopNotificationService } from '../DesktopNotificationService'

describe('desktopNotificationService', () => {
it('shows one generic conversation notification and opens its target', async () => {
let click: (() => void) | null = null
const show = vi.fn()
const openTarget = vi.fn()
const service = new DesktopNotificationService({
createNotification() {
return {
onClick(listener) {
click = listener
},
show,
}
},
getLanguage: () => 'zh-CN',
getSettings: () => ({ notificationsEnabled: true, notifyWhenFocused: false }),
isWindowFocused: () => false,
openTarget,
request: async (method) => {
if (method === 'runs.get') {
return {
approvalPolicy: 'policy',
branchId: 'branch-1',
completedAt: '2026-08-19T08:00:00.000Z',
conversationId: 'conversation-1',
errorCode: null,
executionProfile: 'workspace_write',
id: 'run-1',
modelId: 'gpt-5',
providerId: 'openai',
purpose: 'chat',
reasoningLevel: 'high',
startedAt: '2026-08-19T07:59:00.000Z',
status: 'completed',
triggeringMessageId: 'message-1',
}
}
type TestNotification = DesktopSystemNotification & {
click: () => void
dismiss: (reason?: 'userCanceled' | 'applicationHidden' | 'timedOut') => void
fail: () => void
}

function fixture() {
const notifications: TestNotification[] = []
const target = { conversationId: 'conversation-1', runId: 'run-1' }
const openTarget = vi.fn()
const onError = vi.fn()
const service = new DesktopNotificationService({
createNotification() {
let click = () => {}
let dismiss: (reason?: 'userCanceled' | 'applicationHidden' | 'timedOut') => void = () => {}
let fail = () => {}
const notification = {
close: vi.fn(),
click: () => click(),
dismiss: (reason?: 'userCanceled' | 'applicationHidden' | 'timedOut') => dismiss(reason),
fail: () => fail(),
onClick(listener) {
click = listener
},
onClose(listener) {
dismiss = listener
},
onFailed(listener) {
fail = listener
},
show: vi.fn(),
} satisfies TestNotification
notifications.push(notification)
return notification
},
getLanguage: () => 'zh-CN',
getSettings: () => ({ notificationsEnabled: true, notifyWhenFocused: false }),
isWindowFocused: () => false,
onError,
openTarget,
request: async (method) => {
if (method === 'runs.get') {
return {
activeBranchId: 'branch-1',
approvalPolicy: 'policy',
createdAt: '2026-08-19T07:58:00.000Z',
deletedAt: null,
branchId: 'branch-1',
completedAt: '2026-08-19T08:00:00.000Z',
conversationId: target.conversationId,
errorCode: null,
executionProfile: 'workspace_write',
id: 'conversation-1',
modelSelection: null,
spaceId: null,
title: '整理发布说明',
updatedAt: '2026-08-19T08:00:00.000Z',
id: target.runId,
modelId: 'gpt-5',
providerId: 'openai',
purpose: 'chat',
reasoningLevel: 'high',
startedAt: '2026-08-19T07:59:00.000Z',
status: 'completed',
triggeringMessageId: 'message-1',
}
},
})
const notification = {
method: 'run.event',
params: {
createdAt: '2026-08-19T08:00:00.000Z',
payload: { errorCode: null },
runId: 'run-1',
sequence: 8,
type: 'run.completed',
},
}
}
return {
activeBranchId: 'branch-1',
approvalPolicy: 'policy',
createdAt: '2026-08-19T07:58:00.000Z',
deletedAt: null,
executionProfile: 'workspace_write',
id: target.conversationId,
modelSelection: null,
spaceId: null,
title: '整理发布说明',
updatedAt: '2026-08-19T08:00:00.000Z',
}
},
})
const event = {
method: 'run.event',
params: {
createdAt: '2026-08-19T08:00:00.000Z',
payload: { errorCode: null },
runId: 'run-1',
sequence: 8,
type: 'run.completed',
},
}

await service.handle(notification)
await service.handle(notification)
return { service, notifications, openTarget, onError, event, target }
}

expect(show).toHaveBeenCalledOnce()
expect(click).not.toBeNull()
click!()
expect(openTarget).toHaveBeenCalledWith({
describe('desktopNotificationService', () => {
it('shows one notification for concurrent or replayed events and opens its target once', async () => {
const f = fixture()
await Promise.all([f.service.handle(f.event), f.service.handle(f.event)])
await f.service.handle(f.event)

expect(f.notifications).toHaveLength(1)
expect(f.notifications[0]!.show).toHaveBeenCalledOnce()
f.notifications[0]!.click()
f.notifications[0]!.click()
await vi.waitFor(() => expect(f.openTarget).toHaveBeenCalledOnce())
expect(f.openTarget).toHaveBeenCalledWith({
conversationId: 'conversation-1',
runId: 'run-1',
})
})

it('keeps the original target clickable after banner timeout and another conversation notification', async () => {
const f = fixture()
await f.service.handle(f.event)
f.target.conversationId = 'conversation-2'
f.target.runId = 'run-2'
await f.service.handle({ ...f.event, params: { ...f.event.params, runId: 'run-2', sequence: 9 } })
const older = f.notifications[0]!
older.dismiss('timedOut')
older.dismiss('applicationHidden')
older.click()
await vi.waitFor(() => expect(f.openTarget).toHaveBeenCalledWith({ conversationId: 'conversation-1', runId: 'run-1' }))
f.service.dispose()
expect(older.close).not.toHaveBeenCalled()
expect(f.notifications[1]!.close).toHaveBeenCalledOnce()
})

it('ignores dismissed clicks and closes retained notifications when the service stops', async () => {
const f = fixture()
await f.service.handle(f.event)
f.notifications[0]!.dismiss('userCanceled')
f.notifications[0]!.click()
await f.service.handle({ ...f.event, params: { ...f.event.params, sequence: 9 } })
f.notifications[1]!.dismiss('timedOut')
f.service.dispose()
f.service.dispose()
f.notifications[1]!.click()
await f.service.handle({ ...f.event, params: { ...f.event.params, sequence: 10 } })
await Promise.resolve()
expect(f.openTarget).not.toHaveBeenCalled()
expect(f.notifications).toHaveLength(2)
expect(f.notifications[0]!.close).not.toHaveBeenCalled()
expect(f.notifications[1]!.close).toHaveBeenCalledOnce()
})

it('releases failed deliveries for retry and reports rejected click navigation', async () => {
const f = fixture()
await f.service.handle(f.event)
f.notifications[0]!.fail()
await f.service.handle(f.event)
f.notifications[0]!.click()
const error = new Error('Window unavailable')
f.openTarget.mockRejectedValueOnce(error)
f.notifications[1]!.click()
await vi.waitFor(() => expect(f.onError).toHaveBeenCalledWith(error))
expect(f.openTarget).toHaveBeenCalledOnce()
expect(f.notifications).toHaveLength(2)
})
})
2 changes: 2 additions & 0 deletions apps/buddy/electron/main/__tests__/ipc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ describe('registerDesktopIpc', () => {
getSandboxStatus: async () => 'available',
setupSandbox: async () => 'cancelled',
getWindow: () => window,
getPendingOpenTarget: () => null,
completeOpenTarget: vi.fn(),
onConfigUpdated: vi.fn(),
openFeedbackIssue: vi.fn(),
openReleasePage: vi.fn(),
Expand Down
Loading
Loading