feat: add notification sound volume slider (#2391)

* feat: add notification sound volume slider

Custom audio files vary widely in loudness; the new 0-100% slider in
Settings → Notifications lets the user lower an over-amplified sound
without re-encoding the file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: harden notification sound volume setting

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Diego Lorente del Castillo 2026-05-19 21:02:44 -07:00 committed by GitHub
parent e49bb2a175
commit cbf2a52e5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 183 additions and 19 deletions

View File

@ -81,7 +81,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true,
customSoundPath: null
customSoundPath: null,
customSoundVolume: 100
},
promptCacheTimerEnabled: false,
promptCacheTtlMs: 300_000,

View File

@ -74,7 +74,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true,
customSoundPath: null
customSoundPath: null,
customSoundVolume: 100
},
promptCacheTimerEnabled: false,
promptCacheTtlMs: 300_000,

View File

@ -227,6 +227,7 @@ describe('Store', () => {
expect(settings.floatingTerminalEnabled).toBe(true)
expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true)
expect(settings.notifications.customSoundPath).toBeNull()
expect(settings.notifications.customSoundVolume).toBe(100)
})
it('returns default UI state when no data file exists', async () => {
@ -760,10 +761,49 @@ describe('Store', () => {
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true,
customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg'
customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg',
customSoundVolume: 100
})
})
it('clamps notification custom sound volume from persisted settings', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
notifications: {
customSoundVolume: 250
}
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().notifications.customSoundVolume).toBe(100)
})
it('defaults invalid notification custom sound volume from persisted settings', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
notifications: {
customSoundVolume: Number.NaN
}
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().notifications.customSoundVolume).toBe(100)
})
it('preserves editorAutoSaveDelayMs when set in persisted data', async () => {
writeDataFile({
schemaVersion: 1,
@ -1001,6 +1041,20 @@ describe('Store', () => {
])
})
it('updateSettings deep-merges and clamps notification custom sound volume', async () => {
const store = await createStore()
const updated = store.updateSettings({
notifications: {
...store.getSettings().notifications,
customSoundVolume: -20
}
})
expect(updated.notifications.customSoundVolume).toBe(0)
expect(updated.notifications.enabled).toBe(true)
expect(updated.notifications.customSoundPath).toBeNull()
})
it('updateSettings toggles editorAutoSave', async () => {
const store = await createStore()
expect(store.getSettings().editorAutoSave).toBe(false)

View File

@ -36,6 +36,7 @@ import type {
WorktreeMeta,
WorktreeLineage,
GlobalSettings,
NotificationSettings,
OnboardingChecklistState,
OnboardingOutcome,
OnboardingState,
@ -183,6 +184,22 @@ function normalizeSortBy(sortBy: unknown): 'name' | 'smart' | 'recent' | 'repo'
return getDefaultUIState().sortBy
}
function normalizeNotificationSettings(value: unknown): NotificationSettings {
const defaults = getDefaultNotificationSettings()
const candidate =
value && typeof value === 'object' ? (value as Partial<NotificationSettings>) : {}
const rawVolume = candidate.customSoundVolume
const customSoundVolume =
typeof rawVolume === 'number' && Number.isFinite(rawVolume)
? Math.min(100, Math.max(0, rawVolume))
: defaults.customSoundVolume
return {
...defaults,
...candidate,
customSoundVolume
}
}
function normalizeAutomationRunWorkspaceDisplayName(value: string | null): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
@ -1249,10 +1266,7 @@ export class Store {
parsed.settings?.visibleTaskProviders
),
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications),
notifications: {
...getDefaultNotificationSettings(),
...parsed.settings?.notifications
},
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
voice: {
...getDefaultVoiceSettings(),
...parsed.settings?.voice
@ -2149,10 +2163,10 @@ export class Store {
this.state.settings = {
...this.state.settings,
...sanitizedUpdates,
notifications: {
notifications: normalizeNotificationSettings({
...this.state.settings.notifications,
...sanitizedUpdates.notifications
},
}),
...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {})
}
this.scheduleSave()

View File

@ -1233,7 +1233,7 @@ export type PreloadApi = {
openSystemSettings: () => Promise<void>
getPermissionStatus: () => Promise<NotificationPermissionStatusResult>
requestPermission: () => Promise<NotificationPermissionStatusResult>
playSound: (options?: { force?: boolean }) => Promise<NotificationSoundResult>
playSound: (options?: { force?: boolean; volume?: number }) => Promise<NotificationSoundResult>
}
onboarding: {
get: () => Promise<OnboardingState>

View File

@ -1315,7 +1315,10 @@ const api = {
ipcRenderer.invoke('notifications:getPermissionStatus'),
requestPermission: (): Promise<NotificationPermissionStatusResult> =>
ipcRenderer.invoke('notifications:requestPermission'),
playSound: async (options?: { force?: boolean }): Promise<NotificationSoundResult> => {
playSound: async (options?: {
force?: boolean
volume?: number
}): 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.
@ -1356,6 +1359,9 @@ const api = {
// the sound from the start instead of stacking overlapping copies.
// Matches GNOME canberra and VS Code AccessibilitySignalService.
audio.currentTime = 0
if (typeof options?.volume === 'number' && Number.isFinite(options.volume)) {
audio.volume = Math.min(1, Math.max(0, options.volume / 100))
}
isNotificationSoundPlaying = true
const release = (): void => {
isNotificationSoundPlaying = false

View File

@ -1,10 +1,11 @@
import { type ReactNode, useState } from 'react'
import { type ReactNode, useEffect, useRef, 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, FileAudio, Siren, X } from 'lucide-react'
import { Slider } from '../ui/slider'
import { BellRing, Bot, FileAudio, Siren, Volume2, X } from 'lucide-react'
import type { SettingsSearchEntry } from './settings-search'
import { basename } from '@/lib/path'
@ -35,6 +36,11 @@ export const NOTIFICATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'Choose one local audio file (MP3, WAV, OGG, M4A, AAC, or FLAC) for all delivered desktop notifications.',
keywords: ['notifications', 'sound', 'audio', 'mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac']
},
{
title: 'Notification Volume',
description: 'Playback volume for the custom notification sound.',
keywords: ['notifications', 'sound', 'volume', 'loudness']
},
{
title: 'Send Test Notification',
description: 'Trigger a sample desktop notification using the native delivery path.',
@ -71,17 +77,33 @@ export function NotificationsPane({
updateSettings
}: NotificationsPaneProps): React.JSX.Element {
const notificationSettings = settings.notifications
const notificationSettingsRef = useRef(notificationSettings)
const [isPickingSound, setIsPickingSound] = useState(false)
const updateNotificationSettings = (updates: Partial<GlobalSettings['notifications']>): void => {
updateSettings({
notifications: {
...notificationSettings,
...notificationSettingsRef.current,
...updates
}
})
}
// Why: keep dragging local and persist only on Radix's commit event. That
// avoids IPC on every tick without a debounce timer that can race settings updates.
const [volumeDraft, setVolumeDraft] = useState(notificationSettings.customSoundVolume)
useEffect(() => {
notificationSettingsRef.current = notificationSettings
setVolumeDraft(notificationSettings.customSoundVolume)
}, [notificationSettings])
const handleVolumeCommit = (value: number): void => {
if (notificationSettingsRef.current.customSoundVolume !== value) {
updateNotificationSettings({ customSoundVolume: value })
}
}
const handleSendTestNotification = async (): Promise<void> => {
// Why: Electron main cannot reliably read macOS notification authorization,
// but the renderer exposes it. Without this check, dev builds can report
@ -103,7 +125,10 @@ export function NotificationsPane({
// 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 })
? await window.api.notifications.playSound({
force: true,
volume: volumeDraft
})
: null
if (notificationSettings.customSoundPath && soundResult && !soundResult.played) {
toast.error('Custom notification sound could not be played')
@ -232,6 +257,25 @@ export function NotificationsPane({
</Button>
) : null}
</div>
{selectedSoundPath ? (
<div className="flex items-center gap-3 pt-1">
<Volume2 className="size-4 text-muted-foreground" />
<Slider
value={[volumeDraft]}
min={0}
max={100}
step={5}
disabled={!notificationSettings.enabled}
onValueChange={([value]) => setVolumeDraft(value)}
onValueCommit={([value]) => handleVolumeCommit(value)}
className="flex-1"
aria-label="Notification sound volume"
/>
<span className="w-10 text-right font-mono text-xs tabular-nums text-muted-foreground">
{volumeDraft}%
</span>
</div>
) : null}
</div>
<Separator />

View File

@ -166,6 +166,7 @@ export function dispatchTerminalNotification(
const worktree = getWorktreeMapFromState(state).get(worktreeId)
const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null
const customSoundPath = state.settings?.notifications?.customSoundPath ?? null
const customSoundVolume = state.settings?.notifications?.customSoundVolume ?? null
const agentStatus =
event.source === 'agent-task-complete' && event.paneKey
? state.agentStatusByPaneKey[event.paneKey]
@ -200,7 +201,7 @@ export function dispatchTerminalNotification(
})
.then((result) => {
if (result.delivered) {
void playDesktopNotificationSound(customSoundPath)
void playDesktopNotificationSound(customSoundPath, customSoundVolume)
}
})
.catch((err) => {

View File

@ -0,0 +1,38 @@
import * as React from 'react'
import { Slider as SliderPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
function Slider({
className,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>): React.ReactElement {
return (
<SliderPrimitive.Root
data-slot="slider"
className={cn(
'relative flex w-full touch-none select-none items-center',
'data-[disabled]:opacity-50',
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"
>
<SliderPrimitive.Range data-slot="slider-range" className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
data-slot="slider-thumb"
className={cn(
'block size-4 rounded-full border border-primary/40 bg-background shadow-sm',
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'disabled:pointer-events-none disabled:opacity-50'
)}
/>
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@ -1,12 +1,15 @@
export async function playDesktopNotificationSound(
customSoundPath: string | null | undefined
customSoundPath: string | null | undefined,
customSoundVolume?: number | null
): Promise<boolean> {
if (!customSoundPath) {
return false
}
try {
const result = await window.api.notifications.playSound()
const result = await window.api.notifications.playSound({
volume: customSoundVolume ?? undefined
})
// 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)

View File

@ -117,7 +117,8 @@ export function getDefaultNotificationSettings(): NotificationSettings {
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true,
customSoundPath: null
customSoundPath: null,
customSoundVolume: 100
}
}

View File

@ -1339,6 +1339,7 @@ export type NotificationSettings = {
terminalBell: boolean
suppressWhenFocused: boolean
customSoundPath: string | null
customSoundVolume: number
}
export type CodexManagedAccount = {