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:
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
@@ -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 任务失败',
|
||||
|
||||
Reference in New Issue
Block a user