Add custom desktop notification sounds (#1430)

* 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 <help@stably.ai>

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Kaylee
2026-05-05 01:00:16 -07:00
committed by GitHub
co-authored by Orca Neil
parent f0de1437d3
commit f9120cb2f1
15 changed files with 564 additions and 17 deletions
@@ -73,7 +73,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true
suppressWhenFocused: true,
customSoundPath: null
},
promptCacheTimerEnabled: false,
promptCacheTtlMs: 300_000,
+2 -1
View File
@@ -67,7 +67,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true
suppressWhenFocused: true,
customSoundPath: null
},
promptCacheTimerEnabled: false,
promptCacheTtlMs: 300_000,
+131
View File
@@ -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<unknown> {
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<unknown>
}
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', () => {
+79 -2
View File
@@ -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<string, string> = 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<NotificationSoundDataResult> => {
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' }
}
})
}
/**
+62
View File
@@ -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<unknown> {
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<unknown>
}
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()
})
})
+11
View File
@@ -123,6 +123,17 @@ export function registerShellHandlers(): void {
return result.filePaths[0]
})
ipcMain.handle('shell:pickAudio', async (): Promise<string | null> => {
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.
+27
View File
@@ -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,
+3
View File
@@ -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<NotificationDispatchResult>
openSystemSettings: () => Promise<void>
playSound: (options?: { force?: boolean }) => Promise<NotificationSoundResult>
}
developerPermissions: {
getStatus: () => Promise<DeveloperPermissionState[]>
@@ -734,6 +736,7 @@ export type PreloadApi = {
pathExists: (path: string) => Promise<boolean>
pickAttachment: () => Promise<string | null>
pickImage: () => Promise<string | null>
pickAudio: () => Promise<string | null>
pickDirectory: (args: { defaultPath?: string }) => Promise<string | null>
copyFile: (args: { srcPath: string; destPath: string }) => Promise<void>
}
+88 -1
View File
@@ -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<string, unknown>): Promise<NotificationDispatchResult> =>
ipcRenderer.invoke('notifications:dispatch', args),
openSystemSettings: (): Promise<void> => ipcRenderer.invoke('notifications:openSystemSettings')
openSystemSettings: (): Promise<void> => ipcRenderer.invoke('notifications:openSystemSettings'),
playSound: async (options?: { force?: boolean }): Promise<NotificationSoundResult> => {
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<string | null> => ipcRenderer.invoke('shell:pickImage'),
pickAudio: (): Promise<string | null> => ipcRenderer.invoke('shell:pickAudio'),
pickDirectory: (args: { defaultPath?: string }): Promise<string | null> =>
ipcRenderer.invoke('shell:pickDirectory', args),
@@ -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<GlobalSettings['notifications']>): void => {
updateSettings({
@@ -58,10 +65,34 @@ export function NotificationsPane({
const handleSendTestNotification = async (): Promise<void> => {
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<void> => {
setIsPickingSound(true)
try {
const soundPath = await window.api.shell.pickAudio()
if (soundPath) {
updateNotificationSettings({ customSoundPath: soundPath })
}
} finally {
setIsPickingSound(false)
}
}
const selectedSoundPath = notificationSettings.customSoundPath
return (
<div className="space-y-1">
<SettingToggle
@@ -101,6 +132,61 @@ export function NotificationsPane({
<Separator />
<div className="space-y-2 px-1 py-2">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<FileAudio className="size-4" />
<Label>Custom Sound</Label>
</div>
<p className="text-xs text-muted-foreground">
One local audio file for all delivered desktop notifications.
</p>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div
className="min-h-8 min-w-0 flex-1 rounded-md border border-border/50 bg-muted/35 px-2.5 py-1.5"
title={selectedSoundPath ?? undefined}
>
{selectedSoundPath ? (
<div className="min-w-0">
<div className="truncate text-xs font-medium">{basename(selectedSoundPath)}</div>
<div className="truncate font-mono text-[11px] text-muted-foreground">
{selectedSoundPath}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">System notification sound</div>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={!notificationSettings.enabled || isPickingSound}
onClick={() => void handleChooseSound()}
className="gap-2"
>
<FileAudio className="size-3.5" />
{selectedSoundPath ? 'Change' : 'Choose'}
</Button>
{selectedSoundPath ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={!notificationSettings.enabled}
onClick={() => updateNotificationSettings({ customSoundPath: null })}
className="gap-2"
>
<X className="size-3.5" />
Clear
</Button>
) : null}
</div>
</div>
<Separator />
<SettingToggle
label="Suppress While Focused"
description="Skip notifications when the triggering worktree is already visible."
@@ -255,7 +255,8 @@ describe('connectPanePty', () => {
clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined)
},
notifications: {
dispatch: vi.fn()
dispatch: vi.fn().mockResolvedValue({ delivered: true }),
playSound: vi.fn().mockResolvedValue({ played: true })
}
}
}
@@ -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]
)
@@ -0,0 +1,19 @@
export async function playDesktopNotificationSound(
customSoundPath: string | null | undefined
): Promise<boolean> {
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
}
}
+2 -1
View File
@@ -107,7 +107,8 @@ export function getDefaultNotificationSettings(): NotificationSettings {
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true
suppressWhenFocused: true,
customSoundPath: null
}
}
+29
View File
@@ -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<NotificationSoundResult['reason'], 'playback-failed'>
}
export type NotificationSoundPathResult =
| { ok: true; path: string }
| { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' }
export type WorktreeCardProperty =
| 'status'
| 'unread'