feat: add desktop notification toggle to general settings

Add a default-on persisted desktop notification switch that takes effect immediately for background task results while leaving in-app notifications unchanged. Group General settings into shortcut, conversation and notification, and workspace and download cards.

Validation: 756 related tests passed, 1 skipped; typecheck and lint passed. Full suite timed out with three supervision preview failures; native OS notification delivery was not manually verified.

Release note: 通用设置新增默认开启的桌面通知开关,可随时关闭系统任务提醒并保留应用内提示;设置项按用途分组,方便查找。
This commit is contained in:
mesalogo
2026-09-28 01:43:21 +08:00
parent 8e607366ca
commit f35198df01
14 changed files with 411 additions and 121 deletions
@@ -44,6 +44,34 @@ afterEach(async () => {
})
describe('ApplicationSettingsStore', () => {
it.each([11, 12])('defaults missing desktop notifications to on in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
delete legacy.desktopNotificationsEnabled
await writeFile(filePath, JSON.stringify(legacy))
expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false })
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, desktopNotificationsEnabled: undefined }).desktopNotificationsEnabled).toBe(true)
})
it('persists and publishes desktop notification choices without resetting them on unrelated updates', async () => {
const { filePath, store } = await createStore()
expect((await store.get()).desktopNotificationsEnabled).toBe(true)
const changed = vi.fn()
store.onChanged(changed)
for (const desktopNotificationsEnabled of [false, true]) {
const saved = await store.update({ desktopNotificationsEnabled })
expect(saved.desktopNotificationsEnabled).toBe(desktopNotificationsEnabled)
expect(changed).toHaveBeenLastCalledWith(saved)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).desktopNotificationsEnabled).toBe(desktopNotificationsEnabled)
expect(JSON.parse(await readFile(filePath, 'utf8')).desktopNotificationsEnabled).toBe(desktopNotificationsEnabled)
}
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
for (const desktopNotificationsEnabled of ['false', 0, null]) {
await expect(store.update({ desktopNotificationsEnabled })).rejects.toThrow()
}
})
it('persists review pagination and batch controls without enabling supervision', async () => {
const { filePath, store } = await createStore()
const supervisionReview = { pageSize: 1, batchCharacters: 16000, batchMessages: 50, executionSeconds: 3600 }
+5
View File
@@ -250,6 +250,7 @@ export const resolveLegacyLocalToolEnvironmentPaths: LegacyLocalToolEnvironmentR
export const defaultApplicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -289,6 +290,7 @@ export class ApplicationSettingsStore {
settings: Omit<
StoredApplicationSettings,
| 'conversationHtmlRenderingEnabled'
| 'desktopNotificationsEnabled'
| 'localToolEnvironment'
| 'version'
| 'applicationNavigation'
@@ -297,6 +299,7 @@ export class ApplicationSettingsStore {
| 'magicNoteCanvasPageCount'
> & {
conversationHtmlRenderingEnabled?: boolean
desktopNotificationsEnabled?: boolean
magicNoteCanvasPageCount?: number
version: number
}
@@ -522,6 +525,7 @@ export class ApplicationSettingsStore {
const stored = await this.loadStored()
return {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
desktopNotificationsEnabled: stored.desktopNotificationsEnabled,
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment,
@@ -567,6 +571,7 @@ export class ApplicationSettingsStore {
this.warnings = []
const settings: ApplicationSettings = {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
desktopNotificationsEnabled: next.desktopNotificationsEnabled,
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment,
+59 -1
View File
@@ -34,6 +34,8 @@ import type {
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
import { AssistantDatabase } from './assistant/assistant-database'
import { ImageGenerationService } from './agent/image-generation-service'
import { ApplicationSettingsStore } from './application-settings-store'
import * as desktopNotification from './desktop-notification'
import type { AgentExecutionRequest } from './agent/runtime'
import { KnowledgeService } from './knowledge/knowledge-service'
import { KnowledgeDatabase } from './knowledge/knowledge-database'
@@ -5201,7 +5203,8 @@ describe('registerIpcHandlers agent terminal state', () => {
heartbeatEnabled?: boolean,
obsidianService?: ObsidianService,
nativeClientCoordinator?: Parameters<typeof registerIpcHandlers>[45],
nativeTerminalManager?: Parameters<typeof registerIpcHandlers>[41]
nativeTerminalManager?: Parameters<typeof registerIpcHandlers>[41],
applicationSettingsStore?: ApplicationSettingsStore
) {
const assistantDatabase = {
createTask: vi.fn(),
@@ -5438,6 +5441,7 @@ describe('registerIpcHandlers agent terminal state', () => {
args[44] = obsidianService
args[45] = nativeClientCoordinator
args[41] = nativeTerminalManager
if (applicationSettingsStore) args[15] = applicationSettingsStore
const dispose = registerIpcHandlers(...args)
return {
approvalBroker,
@@ -5483,6 +5487,60 @@ describe('registerIpcHandlers agent terminal state', () => {
senderFrame: webContents.mainFrame
})
it.each([
['conversation', 'completed'], ['conversation', 'failed'],
['channel', 'completed'], ['channel', 'failed']
] as const)('uses the latest saved desktop notification preference for %s %s results', async (origin, status) => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-notification-ipc-'))
const store = new ApplicationSettingsStore(join(directory, 'application.json'))
await store.update({ magicNotesEnabled: false })
const notify = vi.spyOn(desktopNotification, 'showDesktopNotificationWhenUnfocused').mockReturnValue(true)
let preference: 'default' | boolean | 'unreadable' = 'default'
const get = vi.spyOn(store, 'get')
const harness = createHarness({
capability: 'chat',
async *run(request: { requestId: string }) {
// Change the persisted choice after admission to detect stale startup/task snapshots.
if (typeof preference === 'boolean') await store.update({ desktopNotificationsEnabled: preference })
if (preference === 'unreadable') get.mockRejectedValueOnce(new Error('Settings unavailable'))
if (status === 'failed') throw new Error('Runtime failed')
yield { requestId: request.requestId, type: 'done' }
}
}, undefined, 'always', undefined, false, undefined, undefined, undefined, false,
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, store)
try {
for (preference of ['default', false, true, 'unreadable'] as const) {
notify.mockClear()
harness.webContents.send.mockClear()
harness.assistantDatabase.updateTaskStatus.mockClear()
if (origin === 'channel') {
await expect(channelMocks.executor!({
channel: 'wecom', eventId: crypto.randomUUID(), senderId: 'user-1',
conversationId: 'conversation-1', conversationType: 'direct',
text: 'Read only', mentioned: false, workMode: 'ask'
}, new AbortController().signal)).resolves.toMatchObject({ status })
} else {
const requestId = crypto.randomUUID()
await harness.handler!(trustedEvent(harness.webContents), {
requestId, conversationId: crypto.randomUUID(), prompt: 'Read only', workMode: 'ask'
})
await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent, expect.objectContaining({ requestId, type: status === 'completed' ? 'done' : 'error' })
))
}
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
expect.any(String), status, ...(status === 'failed' ? ['Runtime failed'] : [])
)
expect(notify).toHaveBeenCalledTimes(preference === 'default' || preference === true ? 1 : 0)
}
} finally {
await harness.dispose()
notify.mockRestore()
get.mockRestore()
await rm(directory, { recursive: true, force: true })
}
})
function createManagedSshHarness(
selectedRuntime: Record<string, unknown>
) {
+14 -4
View File
@@ -11,6 +11,7 @@ import {
dialog,
ipcMain,
shell,
type NotificationConstructorOptions,
type IpcMainInvokeEvent
} from 'electron'
import {
@@ -1043,6 +1044,15 @@ export function registerIpcHandlers(
obsidianService?: ObsidianService,
nativeClientCoordinator?: Pick<NativeClientCoordinator, 'open' | 'get' | 'stop' | 'closeOwner'>
): () => Promise<void> {
const notifyDesktop = async (options: NotificationConstructorOptions): Promise<void> => {
try {
if ((await applicationSettingsStore?.get())?.desktopNotificationsEnabled === false) return
} catch {
// A notification preference read must not change the task's terminal result.
return
}
showDesktopNotificationWhenUnfocused(window, options)
}
type ActiveRequestLease = {
controller: AbortController
isReply: boolean
@@ -2601,7 +2611,7 @@ export function registerIpcHandlers(
})
}
assistantDatabase.updateTaskStatus(taskId, 'completed')
showDesktopNotificationWhenUnfocused(window, {
await notifyDesktop({
title:
origin === 'channel'
? `${remoteContext?.channelLabel ?? '远程通道'}请求已完成`
@@ -2631,7 +2641,7 @@ export function registerIpcHandlers(
cancelled ? 'cancelled' : 'failed',
message
)
showDesktopNotificationWhenUnfocused(window, {
await notifyDesktop({
title:
origin === 'channel'
? `${remoteContext?.channelLabel ?? '远程通道'}请求失败`
@@ -4496,7 +4506,7 @@ export function registerIpcHandlers(
'completed'
)
}
showDesktopNotificationWhenUnfocused(window, {
await notifyDesktop({
title: 'GoodBuddy 任务已完成',
body: '回复已生成,可返回会话查看。'
})
@@ -4573,7 +4583,7 @@ export function registerIpcHandlers(
)
}
}
showDesktopNotificationWhenUnfocused(window, {
await notifyDesktop({
title: cancelled
? 'GoodBuddy 任务已取消'
: 'GoodBuddy 任务失败',