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
co-authored by Claude Opus 4.7 Neil
parent e49bb2a175
commit cbf2a52e5d
12 changed files with 183 additions and 19 deletions
@@ -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 />
@@ -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) => {
+38
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 }
@@ -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)