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:
parent
e49bb2a175
commit
cbf2a52e5d
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -117,7 +117,8 @@ export function getDefaultNotificationSettings(): NotificationSettings {
|
|||
agentTaskComplete: true,
|
||||
terminalBell: false,
|
||||
suppressWhenFocused: true,
|
||||
customSoundPath: null
|
||||
customSoundPath: null,
|
||||
customSoundVolume: 100
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1339,6 +1339,7 @@ export type NotificationSettings = {
|
|||
terminalBell: boolean
|
||||
suppressWhenFocused: boolean
|
||||
customSoundPath: string | null
|
||||
customSoundVolume: number
|
||||
}
|
||||
|
||||
export type CodexManagedAccount = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue