From f9120cb2f119e4c164880ef9201c8481fcf4174b Mon Sep 17 00:00:00 2001 From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com> Date: Tue, 5 May 2026 01:00:16 -0700 Subject: [PATCH] Add custom desktop notification sounds (#1430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for custom desktop notification sounds * perf(notifications): cache custom sound + restart-on-play Avoids re-reading the configured audio file (up to 10MB) from disk and re-transferring it over IPC on every notification. Adds a path-only resolver so repeated dispatches with an unchanged sound skip the heavy load entirely. For burst handling, follows the VS Code AccessibilitySignalService / GNOME canberra pattern: one shared HTMLAudioElement per sound, restarted from t=0 on each play, with an in-flight guard that drops new plays while the sound is still ringing. This self-dedupes by the sound's own duration without any magic time constant — distinct sounds remain free to overlap. The Test button passes force: true so an explicit user action always plays through. Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- .../runtime-home-service.test.ts | 3 +- src/main/codex-accounts/service.test.ts | 3 +- src/main/ipc/notifications.test.ts | 131 ++++++++++++++++++ src/main/ipc/notifications.ts | 81 ++++++++++- src/main/ipc/shell.test.ts | 62 +++++++++ src/main/ipc/shell.ts | 11 ++ src/main/persistence.test.ts | 27 ++++ src/preload/api-types.ts | 3 + src/preload/index.ts | 89 +++++++++++- .../components/settings/NotificationsPane.tsx | 90 +++++++++++- .../terminal-pane/pty-connection.test.ts | 3 +- .../use-notification-dispatch.ts | 27 ++-- .../src/lib/desktop-notification-sound.ts | 19 +++ src/shared/constants.ts | 3 +- src/shared/types.ts | 29 ++++ 15 files changed, 564 insertions(+), 17 deletions(-) create mode 100644 src/main/ipc/shell.test.ts create mode 100644 src/renderer/src/lib/desktop-notification-sound.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9bb7f354f61..d24edef5f2b 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -73,7 +73,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings enabled: true, agentTaskComplete: true, terminalBell: false, - suppressWhenFocused: true + suppressWhenFocused: true, + customSoundPath: null }, promptCacheTimerEnabled: false, promptCacheTtlMs: 300_000, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 2ef8ccba978..2d78c279504 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -67,7 +67,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings enabled: true, agentTaskComplete: true, terminalBell: false, - suppressWhenFocused: true + suppressWhenFocused: true, + customSoundPath: null }, promptCacheTimerEnabled: false, promptCacheTtlMs: 300_000, diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index fbe6c508348..2d1e6c39188 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -1,5 +1,8 @@ /* eslint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' const { removeHandlerMock, @@ -62,9 +65,12 @@ import { } from './notifications' describe('registerNotificationHandlers', () => { + let tempDir: string + beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-03-28T16:00:00Z')) + tempDir = mkdtempSync(join(tmpdir(), 'orca-notification-test-')) removeHandlerMock.mockReset() handleMock.mockReset() notificationCtorMock.mockClear() @@ -77,6 +83,10 @@ describe('registerNotificationHandlers', () => { getAllWindowsMock.mockReturnValue([]) }) + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + function getDispatchHandler(): (event: unknown, args: unknown) => unknown { const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === 'notifications:dispatch') if (!call) { @@ -85,6 +95,24 @@ describe('registerNotificationHandlers', () => { return call[1] as (event: unknown, args: unknown) => unknown } + function getLoadSoundHandler(): (event: unknown) => Promise { + const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === 'notifications:loadSound') + if (!call) { + throw new Error('notifications:loadSound handler not registered') + } + return call[1] as (event: unknown) => Promise + } + + function getResolveSoundPathHandler(): (event: unknown) => unknown { + const call = handleMock.mock.calls.find( + (c: unknown[]) => c[0] === 'notifications:resolveSoundPath' + ) + if (!call) { + throw new Error('notifications:resolveSoundPath handler not registered') + } + return call[1] as (event: unknown) => unknown + } + it('registers the IPC handler', () => { registerNotificationHandlers({ getSettings: () => ({ @@ -171,6 +199,28 @@ describe('registerNotificationHandlers', () => { expect(notificationShowMock).toHaveBeenCalledTimes(1) }) + it('silences the native notification when a custom sound is configured', () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: true, + customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg' + } + }) + } as never) + + const handler = getDispatchHandler() + expect(handler({}, { source: 'test' })).toEqual({ delivered: true }) + expect(notificationCtorMock).toHaveBeenCalledWith({ + title: 'Orca notifications are on', + body: 'This is a test notification from Orca.', + silent: true + }) + }) + it('returns source-disabled when the specific source toggle is off', () => { registerNotificationHandlers({ getSettings: () => ({ @@ -242,6 +292,87 @@ describe('registerNotificationHandlers', () => { }) expect(notificationShowMock).toHaveBeenCalledTimes(1) }) + + it('loads allowed custom sound files for preload playback', async () => { + const soundPath = join(tempDir, 'sound.ogg') + writeFileSync(soundPath, Buffer.from([1, 2, 3])) + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: soundPath + } + }) + } as never) + + const handler = getLoadSoundHandler() + await expect(handler({})).resolves.toMatchObject({ + ok: true, + data: new Uint8Array([1, 2, 3]), + mimeType: 'audio/ogg' + }) + }) + + it('rejects unsupported custom sound file types', async () => { + const soundPath = join(tempDir, 'sound.txt') + writeFileSync(soundPath, 'not audio') + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: soundPath + } + }) + } as never) + + const handler = getLoadSoundHandler() + await expect(handler({})).resolves.toEqual({ + ok: false, + reason: 'unsupported-type' + }) + }) + + it('resolves the sound path without reading the file', () => { + const soundPath = join(tempDir, 'sound.ogg') + writeFileSync(soundPath, Buffer.from([1, 2, 3])) + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: soundPath + } + }) + } as never) + + const handler = getResolveSoundPathHandler() + expect(handler({})).toEqual({ ok: true, path: soundPath }) + }) + + it('rejects unsupported types from resolveSoundPath without touching the disk', () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: '/some/where/sound.txt' + } + }) + } as never) + + const handler = getResolveSoundPathHandler() + expect(handler({})).toEqual({ ok: false, reason: 'unsupported-type' }) + }) }) describe('triggerStartupNotificationRegistration', () => { diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index c8f2856e5e8..8ec5752eec7 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -1,9 +1,24 @@ import { app, BrowserWindow, Notification, ipcMain, shell } from 'electron' +import { readFile, stat } from 'node:fs/promises' +import { extname, isAbsolute, normalize } from 'node:path' import type { Store } from '../persistence' -import type { NotificationDispatchRequest, NotificationDispatchResult } from '../../shared/types' +import type { + NotificationDispatchRequest, + NotificationDispatchResult, + NotificationSoundDataResult +} from '../../shared/types' import type { OrcaRuntimeService } from '../runtime/orca-runtime' const NOTIFICATION_COOLDOWN_MS = 5000 +const MAX_NOTIFICATION_SOUND_BYTES = 10 * 1024 * 1024 +const NOTIFICATION_SOUND_MIME_BY_EXTENSION: ReadonlyMap = new Map([ + ['.ogg', 'audio/ogg'], + ['.mp3', 'audio/mpeg'], + ['.wav', 'audio/wav'], + ['.m4a', 'audio/mp4'], + ['.aac', 'audio/aac'], + ['.flac', 'audio/flac'] +]) // Why: Electron Notification objects are normal JS objects — if the only // reference is a local variable inside the ipcMain handler, the GC can @@ -90,7 +105,11 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime } } - const notification = new Notification(buildNotificationOptions(args)) + const notificationOptions = buildNotificationOptions(args) + if (settings.customSoundPath) { + notificationOptions.silent = true + } + const notification = new Notification(notificationOptions) // Why: prevent GC from collecting the notification (and its click // handler) while it's still visible in macOS Notification Center. @@ -139,6 +158,64 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime return { delivered: true } } ) + + // Why: the preload caches the decoded blob keyed by path. Returning just + // the validated path lets it skip the 10MB IPC round-trip on every dispatch + // when the user's selection hasn't changed — terminal-bell bursts can fire + // many notifications in seconds. + ipcMain.removeHandler('notifications:resolveSoundPath') + ipcMain.handle( + 'notifications:resolveSoundPath', + (): + | { ok: true; path: string } + | { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' } => { + const pathValue = store.getSettings().notifications.customSoundPath + if (!pathValue) { + return { ok: false, reason: 'missing-path' } + } + const normalizedPath = normalize(pathValue) + if (!isAbsolute(normalizedPath)) { + return { ok: false, reason: 'invalid-path' } + } + if (!NOTIFICATION_SOUND_MIME_BY_EXTENSION.has(extname(normalizedPath).toLowerCase())) { + return { ok: false, reason: 'unsupported-type' } + } + return { ok: true, path: normalizedPath } + } + ) + + ipcMain.removeHandler('notifications:loadSound') + ipcMain.handle('notifications:loadSound', async (): Promise => { + const pathValue = store.getSettings().notifications.customSoundPath + if (!pathValue) { + return { ok: false, reason: 'missing-path' } + } + + const normalizedPath = normalize(pathValue) + if (!isAbsolute(normalizedPath)) { + return { ok: false, reason: 'invalid-path' } + } + + const mimeType = NOTIFICATION_SOUND_MIME_BY_EXTENSION.get(extname(normalizedPath).toLowerCase()) + if (!mimeType) { + return { ok: false, reason: 'unsupported-type' } + } + + try { + const fileStat = await stat(normalizedPath) + if (!fileStat.isFile()) { + return { ok: false, reason: 'invalid-path' } + } + if (fileStat.size > MAX_NOTIFICATION_SOUND_BYTES) { + return { ok: false, reason: 'too-large' } + } + + const data = await readFile(normalizedPath) + return { ok: true, data: new Uint8Array(data), mimeType, path: normalizedPath } + } catch { + return { ok: false, reason: 'read-failed' } + } + }) } /** diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts new file mode 100644 index 00000000000..cd7ce56d893 --- /dev/null +++ b/src/main/ipc/shell.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, showOpenDialogMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + showOpenDialogMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + }, + shell: { + showItemInFolder: vi.fn(), + openExternal: vi.fn(), + openPath: vi.fn() + }, + dialog: { + showOpenDialog: showOpenDialogMock + } +})) + +import { registerShellHandlers } from './shell' + +describe('registerShellHandlers', () => { + beforeEach(() => { + handleMock.mockReset() + showOpenDialogMock.mockReset() + }) + + function getHandler(channel: string): (event: unknown, args?: unknown) => Promise { + registerShellHandlers() + const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === channel) + if (!call) { + throw new Error(`${channel} handler not registered`) + } + return call[1] as (event: unknown, args?: unknown) => Promise + } + + it('picks audio files with a constrained native dialog filter', async () => { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ['/Users/kaylee/Downloads/Note_block_pling.ogg'] + }) + + const handler = getHandler('shell:pickAudio') + await expect(handler({})).resolves.toBe('/Users/kaylee/Downloads/Note_block_pling.ogg') + expect(showOpenDialogMock).toHaveBeenCalledWith({ + properties: ['openFile'], + filters: [{ name: 'Audio', extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac', 'flac'] }] + }) + }) + + it('returns null when audio picking is canceled', async () => { + showOpenDialogMock.mockResolvedValue({ + canceled: true, + filePaths: [] + }) + + const handler = getHandler('shell:pickAudio') + await expect(handler({})).resolves.toBeNull() + }) +}) diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 62fc383ca5c..51dff5ae5b6 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -123,6 +123,17 @@ export function registerShellHandlers(): void { return result.filePaths[0] }) + ipcMain.handle('shell:pickAudio', async (): Promise => { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: 'Audio', extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac', 'flac'] }] + }) + if (result.canceled || result.filePaths.length === 0) { + return null + } + return result.filePaths[0] + }) + // Why: copying a picked image next to the markdown file lets us insert a // relative path (e.g. `![](image.png)`) instead of embedding base64, // keeping markdown files small and portable. diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 2bed14f0a89..09a7b43cbf4 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -79,6 +79,7 @@ describe('Store', () => { expect(settings.terminalFontWeight).toBe(500) expect(settings.rightSidebarOpenByDefault).toBe(true) expect(settings.showTasksButton).toBe(true) + expect(settings.notifications.customSoundPath).toBeNull() }) it('returns default UI state when no data file exists', async () => { @@ -147,10 +148,36 @@ describe('Store', () => { expect(store.getSettings().refreshLocalBaseRefOnWorktreeCreate).toBe(false) expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) expect(store.getSettings().showTasksButton).toBe(true) + expect(store.getSettings().notifications.customSoundPath).toBeNull() // repos should be loaded expect(store.getRepos()).toHaveLength(1) }) + it('preserves custom notification sound paths from persisted settings', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + notifications: { + customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg' + } + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().notifications).toMatchObject({ + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true, + customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg' + }) + }) + it('preserves editorAutoSaveDelayMs when set in persisted data', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 56b04fff366..94fe938322d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -44,6 +44,7 @@ import type { GetRateLimitResult, NotificationDispatchRequest, NotificationDispatchResult, + NotificationSoundResult, OrcaHooks, PersistedUIState, PRCheckDetail, @@ -720,6 +721,7 @@ export type PreloadApi = { notifications: { dispatch: (args: NotificationDispatchRequest) => Promise openSystemSettings: () => Promise + playSound: (options?: { force?: boolean }) => Promise } developerPermissions: { getStatus: () => Promise @@ -734,6 +736,7 @@ export type PreloadApi = { pathExists: (path: string) => Promise pickAttachment: () => Promise pickImage: () => Promise + pickAudio: () => Promise pickDirectory: (args: { defaultPath?: string }) => Promise copyFile: (args: { srcPath: string; destPath: string }) => Promise } diff --git a/src/preload/index.ts b/src/preload/index.ts index 4c63c877c73..3ae75bd4e22 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -21,6 +21,9 @@ import type { ListWorkItemsResult, MemorySnapshot, NotificationDispatchResult, + NotificationSoundDataResult, + NotificationSoundPathResult, + NotificationSoundResult, SearchResult } from '../shared/types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' @@ -79,6 +82,29 @@ type NativeDropResolution = // falling back to 'editor' — fail-closed behavior per design §7.1. | { target: 'rejected' } +// Why: one shared HTMLAudioElement per sound file, restarted from t=0 on each +// play, with an in-flight guard that drops new plays while the sound is still +// ringing. This mirrors VS Code's AccessibilitySignalService and GNOME's +// libcanberra: a burst of triggers self-dedupes by the sound's own duration +// (no magic time constant), while distinct sounds are still allowed to overlap. +// We also cache the decoded blob URL by path so we don't re-read 10MB from +// disk and re-transfer it over IPC on every notification. +let cachedNotificationSound: { + path: string + blobUrl: string + audio: HTMLAudioElement +} | null = null +let isNotificationSoundPlaying = false + +function disposeCachedNotificationSound(): void { + if (cachedNotificationSound) { + cachedNotificationSound.audio.pause() + cachedNotificationSound.audio.src = '' + URL.revokeObjectURL(cachedNotificationSound.blobUrl) + cachedNotificationSound = null + } +} + /** * Walk the composed event path to classify which UI surface the native OS drop * landed on, and — for file-explorer drops — extract the nearest destination @@ -843,7 +869,66 @@ const api = { notifications: { dispatch: (args: Record): Promise => ipcRenderer.invoke('notifications:dispatch', args), - openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings') + openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), + playSound: async (options?: { force?: boolean }): Promise => { + try { + // Why: drop replays while the sound is still ringing. The "test" + // button bypasses with force so the user always hears a confirmation. + if (!options?.force && isNotificationSoundPlaying) { + return { played: false, reason: 'deduped' } + } + + const resolved = (await ipcRenderer.invoke( + 'notifications:resolveSoundPath' + )) as NotificationSoundPathResult + if (!resolved.ok) { + if (cachedNotificationSound) { + disposeCachedNotificationSound() + } + return { played: false, reason: resolved.reason } + } + + let entry = cachedNotificationSound + if (!entry || entry.path !== resolved.path) { + const sound = (await ipcRenderer.invoke( + 'notifications:loadSound' + )) as NotificationSoundDataResult + if (!sound.ok) { + disposeCachedNotificationSound() + return { played: false, reason: sound.reason } + } + const arrayBuffer = new ArrayBuffer(sound.data.byteLength) + new Uint8Array(arrayBuffer).set(sound.data) + const blob = new Blob([arrayBuffer], { type: sound.mimeType }) + disposeCachedNotificationSound() + const blobUrl = URL.createObjectURL(blob) + entry = { path: sound.path, blobUrl, audio: new Audio(blobUrl) } + cachedNotificationSound = entry + } + + const audio = entry.audio + // Why: restart-from-zero on every play so a burst of triggers replays + // the sound from the start instead of stacking overlapping copies. + // Matches GNOME canberra and VS Code AccessibilitySignalService. + audio.currentTime = 0 + isNotificationSoundPlaying = true + const release = (): void => { + isNotificationSoundPlaying = false + } + audio.addEventListener('ended', release, { once: true }) + audio.addEventListener('error', release, { once: true }) + try { + await audio.play() + } catch { + release() + return { played: false, reason: 'playback-failed' } + } + return { played: true } + } catch { + isNotificationSoundPlaying = false + return { played: false, reason: 'playback-failed' } + } + } }, developerPermissions: { @@ -869,6 +954,8 @@ const api = { pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), + pickAudio: (): Promise => ipcRenderer.invoke('shell:pickAudio'), + pickDirectory: (args: { defaultPath?: string }): Promise => ipcRenderer.invoke('shell:pickDirectory', args), diff --git a/src/renderer/src/components/settings/NotificationsPane.tsx b/src/renderer/src/components/settings/NotificationsPane.tsx index ea6809a0bd5..9d292123628 100644 --- a/src/renderer/src/components/settings/NotificationsPane.tsx +++ b/src/renderer/src/components/settings/NotificationsPane.tsx @@ -1,11 +1,12 @@ -import { type ReactNode } from 'react' +import { type ReactNode, useState } from 'react' import { toast } from 'sonner' import type { GlobalSettings } from '../../../../shared/types' import { Button } from '../ui/button' import { Label } from '../ui/label' import { Separator } from '../ui/separator' -import { BellRing, Bot, Siren } from 'lucide-react' +import { BellRing, Bot, FileAudio, Siren, X } from 'lucide-react' import type { SettingsSearchEntry } from './settings-search' +import { basename } from '@/lib/path' export const NOTIFICATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { @@ -28,6 +29,11 @@ export const NOTIFICATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ description: 'Avoid notifying when Orca is focused on the active worktree.', keywords: ['notifications', 'focused', 'suppress', 'filtering'] }, + { + title: 'Custom Sound', + description: 'Choose one local audio file for all delivered desktop notifications.', + keywords: ['notifications', 'sound', 'audio', 'ogg', 'mp3', 'wav'] + }, { title: 'Send Test Notification', description: 'Trigger a sample desktop notification using the native delivery path.', @@ -45,6 +51,7 @@ export function NotificationsPane({ updateSettings }: NotificationsPaneProps): React.JSX.Element { const notificationSettings = settings.notifications + const [isPickingSound, setIsPickingSound] = useState(false) const updateNotificationSettings = (updates: Partial): void => { updateSettings({ @@ -58,10 +65,34 @@ export function NotificationsPane({ const handleSendTestNotification = async (): Promise => { const result = await window.api.notifications.dispatch({ source: 'test' }) if (result.delivered) { + // Why: the Test button must always play through, even if the user clicks + // it twice in quick succession — the in-flight dedupe is for incidental + // bursts of real notifications, not for an explicit user action. + const soundResult = notificationSettings.customSoundPath + ? await window.api.notifications.playSound({ force: true }) + : null + if (notificationSettings.customSoundPath && soundResult && !soundResult.played) { + toast.error('Custom notification sound could not be played') + return + } toast.success('Test notification sent') } } + const handleChooseSound = async (): Promise => { + setIsPickingSound(true) + try { + const soundPath = await window.api.shell.pickAudio() + if (soundPath) { + updateNotificationSettings({ customSoundPath: soundPath }) + } + } finally { + setIsPickingSound(false) + } + } + + const selectedSoundPath = notificationSettings.customSoundPath + return (
+
+
+
+ + +
+

+ One local audio file for all delivered desktop notifications. +

+
+
+
+ {selectedSoundPath ? ( +
+
{basename(selectedSoundPath)}
+
+ {selectedSoundPath} +
+
+ ) : ( +
System notification sound
+ )} +
+ + {selectedSoundPath ? ( + + ) : null} +
+
+ + + { clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined) }, notifications: { - dispatch: vi.fn() + dispatch: vi.fn().mockResolvedValue({ delivered: true }), + playSound: vi.fn().mockResolvedValue({ played: true }) } } } diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts index c04539e5386..54fd5556c8b 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { useAppStore } from '@/store' import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' +import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound' /** * Returns a stable dispatch function for terminal notifications. @@ -36,15 +37,25 @@ export function useNotificationDispatch( // itself is the source of truth for its owning repo. const worktree = getWorktreeMapFromState(state).get(worktreeId) const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null + const customSoundPath = state.settings?.notifications?.customSoundPath ?? null - void window.api.notifications.dispatch({ - source: event.source, - worktreeId, - repoLabel: repo?.displayName, - worktreeLabel: worktree?.displayName || worktree?.branch || worktreeId, - terminalTitle: event.terminalTitle, - isActiveWorktree: state.activeWorktreeId === worktreeId - }) + void window.api.notifications + .dispatch({ + source: event.source, + worktreeId, + repoLabel: repo?.displayName, + worktreeLabel: worktree?.displayName || worktree?.branch || worktreeId, + terminalTitle: event.terminalTitle, + isActiveWorktree: state.activeWorktreeId === worktreeId + }) + .then((result) => { + if (result.delivered) { + void playDesktopNotificationSound(customSoundPath) + } + }) + .catch((err) => { + console.warn('Failed to dispatch notification:', err) + }) }, [worktreeId] ) diff --git a/src/renderer/src/lib/desktop-notification-sound.ts b/src/renderer/src/lib/desktop-notification-sound.ts new file mode 100644 index 00000000000..2df1eafa5bc --- /dev/null +++ b/src/renderer/src/lib/desktop-notification-sound.ts @@ -0,0 +1,19 @@ +export async function playDesktopNotificationSound( + customSoundPath: string | null | undefined +): Promise { + if (!customSoundPath) { + return false + } + + try { + const result = await window.api.notifications.playSound() + // Why: 'deduped' is expected when bursts of notifications coalesce — not a failure. + if (!result.played && result.reason !== 'deduped') { + console.warn('Failed to play custom notification sound:', result.reason) + } + return result.played + } catch (err) { + console.warn('Failed to play custom notification sound:', err) + return false + } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 879ab9c3d8e..7a2075acb63 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -107,7 +107,8 @@ export function getDefaultNotificationSettings(): NotificationSettings { enabled: true, agentTaskComplete: true, terminalBell: false, - suppressWhenFocused: true + suppressWhenFocused: true, + customSoundPath: null } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 3e236ff6547..9b6e1601594 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -886,6 +886,7 @@ export type NotificationSettings = { agentTaskComplete: boolean terminalBell: boolean suppressWhenFocused: boolean + customSoundPath: string | null } export type CodexManagedAccount = { @@ -1263,6 +1264,34 @@ export type NotificationDispatchResult = { reason?: 'disabled' | 'source-disabled' | 'suppressed-focus' | 'cooldown' | 'not-supported' } +export type NotificationSoundResult = { + played: boolean + reason?: + | 'missing-path' + | 'invalid-path' + | 'unsupported-type' + | 'too-large' + | 'read-failed' + | 'playback-failed' + | 'deduped' +} + +export type NotificationSoundDataResult = + | { + ok: true + data: Uint8Array + mimeType: string + path: string + } + | { + ok: false + reason: Exclude + } + +export type NotificationSoundPathResult = + | { ok: true; path: string } + | { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' } + export type WorktreeCardProperty = | 'status' | 'unread'