diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index d820830af..3e87e753e 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -81,7 +81,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings agentTaskComplete: true, terminalBell: false, suppressWhenFocused: true, - customSoundPath: null + customSoundPath: null, + customSoundVolume: 100 }, promptCacheTimerEnabled: false, promptCacheTtlMs: 300_000, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index f59ff778b..7f9ea5c52 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -74,7 +74,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings agentTaskComplete: true, terminalBell: false, suppressWhenFocused: true, - customSoundPath: null + customSoundPath: null, + customSoundVolume: 100 }, promptCacheTimerEnabled: false, promptCacheTtlMs: 300_000, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index ac4b7ffd4..12e86376c 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 8f5e4a1f2..bf7cc9af0 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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) : {} + 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() diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 6ec72735a..c6b5c1b01 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1233,7 +1233,7 @@ export type PreloadApi = { openSystemSettings: () => Promise getPermissionStatus: () => Promise requestPermission: () => Promise - playSound: (options?: { force?: boolean }) => Promise + playSound: (options?: { force?: boolean; volume?: number }) => Promise } onboarding: { get: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 987c41296..5cd6805f9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1315,7 +1315,10 @@ const api = { ipcRenderer.invoke('notifications:getPermissionStatus'), requestPermission: (): Promise => ipcRenderer.invoke('notifications:requestPermission'), - playSound: async (options?: { force?: boolean }): Promise => { + playSound: async (options?: { + force?: boolean + volume?: number + }): Promise => { 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 diff --git a/src/renderer/src/components/settings/NotificationsPane.tsx b/src/renderer/src/components/settings/NotificationsPane.tsx index 8b89abdce..cb4023f57 100644 --- a/src/renderer/src/components/settings/NotificationsPane.tsx +++ b/src/renderer/src/components/settings/NotificationsPane.tsx @@ -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): 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 => { // 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({ ) : null} + {selectedSoundPath ? ( +
+ + setVolumeDraft(value)} + onValueCommit={([value]) => handleVolumeCommit(value)} + className="flex-1" + aria-label="Notification sound volume" + /> + + {volumeDraft}% + +
+ ) : null} 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 44a52e1ef..225ca6c7b 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -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) => { diff --git a/src/renderer/src/components/ui/slider.tsx b/src/renderer/src/components/ui/slider.tsx new file mode 100644 index 000000000..f6cab7f2b --- /dev/null +++ b/src/renderer/src/components/ui/slider.tsx @@ -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): React.ReactElement { + return ( + + + + + + + ) +} + +export { Slider } diff --git a/src/renderer/src/lib/desktop-notification-sound.ts b/src/renderer/src/lib/desktop-notification-sound.ts index 2df1eafa5..b96e46ecf 100644 --- a/src/renderer/src/lib/desktop-notification-sound.ts +++ b/src/renderer/src/lib/desktop-notification-sound.ts @@ -1,12 +1,15 @@ export async function playDesktopNotificationSound( - customSoundPath: string | null | undefined + customSoundPath: string | null | undefined, + customSoundVolume?: number | null ): Promise { 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) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f7bc13c8a..160756fcf 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -117,7 +117,8 @@ export function getDefaultNotificationSettings(): NotificationSettings { agentTaskComplete: true, terminalBell: false, suppressWhenFocused: true, - customSoundPath: null + customSoundPath: null, + customSoundVolume: 100 } } diff --git a/src/shared/types.ts b/src/shared/types.ts index b02bbf0dc..b594bd8f5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1339,6 +1339,7 @@ export type NotificationSettings = { terminalBell: boolean suppressWhenFocused: boolean customSoundPath: string | null + customSoundVolume: number } export type CodexManagedAccount = {