diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index acf3064be..f517317e5 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -132,6 +132,11 @@ pub(crate) struct AppSettings { pub(crate) default_access_mode: String, #[serde(default = "default_ui_scale", rename = "uiScale")] pub(crate) ui_scale: f64, + #[serde( + default = "default_notification_sounds_enabled", + rename = "notificationSoundsEnabled" + )] + pub(crate) notification_sounds_enabled: bool, } fn default_access_mode() -> String { @@ -142,12 +147,17 @@ fn default_ui_scale() -> f64 { 1.0 } +fn default_notification_sounds_enabled() -> bool { + true +} + impl Default for AppSettings { fn default() -> Self { Self { codex_bin: None, default_access_mode: "current".to_string(), ui_scale: 1.0, + notification_sounds_enabled: true, } } } @@ -162,6 +172,7 @@ mod tests { assert!(settings.codex_bin.is_none()); assert_eq!(settings.default_access_mode, "current"); assert!((settings.ui_scale - 1.0).abs() < f64::EPSILON); + assert!(settings.notification_sounds_enabled); } #[test] diff --git a/src/App.tsx b/src/App.tsx index 3ef866422..b19b4625e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,8 @@ import "./styles/settings.css"; import "./styles/compact-base.css"; import "./styles/compact-phone.css"; import "./styles/compact-tablet.css"; +import successSoundUrl from "./assets/success-notification.mp3"; +import errorSoundUrl from "./assets/error-notification.mp3"; import { WorktreePrompt } from "./components/WorktreePrompt"; import { AboutView } from "./components/AboutView"; import { SettingsView } from "./components/SettingsView"; @@ -53,7 +55,10 @@ import { useWorktreePrompt } from "./hooks/useWorktreePrompt"; import { useUiScaleShortcuts } from "./hooks/useUiScaleShortcuts"; import { useWorkspaceSelection } from "./hooks/useWorkspaceSelection"; import { useNewAgentShortcut } from "./hooks/useNewAgentShortcut"; +import { useAgentSoundNotifications } from "./hooks/useAgentSoundNotifications"; +import { useWindowFocusState } from "./hooks/useWindowFocusState"; import { useCopyThread } from "./hooks/useCopyThread"; +import { playNotificationSound } from "./utils/notificationSounds"; import type { AccessMode, DiffLineReference, QueuedMessage, WorkspaceInfo } from "./types"; function useWindowLabel() { @@ -135,6 +140,22 @@ function MainApp() { const composerInputRef = useRef(null); const updater = useUpdater({ onDebug: addDebugEntry }); + const isWindowFocused = useWindowFocusState(); + const nextTestSoundIsError = useRef(false); + + useAgentSoundNotifications({ + enabled: appSettings.notificationSoundsEnabled, + isWindowFocused, + onDebug: addDebugEntry, + }); + + const handleTestNotificationSound = useCallback(() => { + const useError = nextTestSoundIsError.current; + nextTestSoundIsError.current = !useError; + const type = useError ? "error" : "success"; + const url = useError ? errorSoundUrl : successSoundUrl; + playNotificationSound(url, type, addDebugEntry); + }, [addDebugEntry]); const { workspaces, @@ -897,6 +918,7 @@ function MainApp() { }} scaleShortcutTitle={scaleShortcutTitle} scaleShortcutText={scaleShortcutText} + onTestNotificationSound={handleTestNotificationSound} /> )} diff --git a/src/assets/error-notification.mp3 b/src/assets/error-notification.mp3 new file mode 100644 index 000000000..1b6926791 Binary files /dev/null and b/src/assets/error-notification.mp3 differ diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9bb..000000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/success-notification.mp3 b/src/assets/success-notification.mp3 new file mode 100644 index 000000000..40d5df377 Binary files /dev/null and b/src/assets/success-notification.mp3 differ diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index b16355164..35de3feb1 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -3,8 +3,8 @@ import { open } from "@tauri-apps/plugin-dialog"; import { ChevronDown, ChevronUp, - Laptop2, LayoutGrid, + SlidersHorizontal, Stethoscope, TerminalSquare, Trash2, @@ -28,6 +28,7 @@ type SettingsViewProps = { onUpdateWorkspaceCodexBin: (id: string, codexBin: string | null) => Promise; scaleShortcutTitle: string; scaleShortcutText: string; + onTestNotificationSound: () => void; }; type SettingsSection = "projects" | "display"; @@ -51,6 +52,7 @@ export function SettingsView({ onUpdateWorkspaceCodexBin, scaleShortcutTitle, scaleShortcutText, + onTestNotificationSound, }: SettingsViewProps) { const [activeSection, setActiveSection] = useState("projects"); const [codexPathDraft, setCodexPathDraft] = useState(appSettings.codexBin ?? ""); @@ -208,8 +210,8 @@ export function SettingsView({ className={`settings-nav ${activeSection === "display" ? "active" : ""}`} onClick={() => setActiveSection("display")} > - - Display + + Display & Sound +
Sounds
+
+ Control notification audio alerts. +
+
+
+
Notification sounds
+
+ Play a sound when a long-running agent finishes while the window is unfocused. +
+
+ +
+
+ +
)} {activeSection === "codex" && ( diff --git a/src/hooks/useAgentSoundNotifications.ts b/src/hooks/useAgentSoundNotifications.ts new file mode 100644 index 000000000..ed6692b5b --- /dev/null +++ b/src/hooks/useAgentSoundNotifications.ts @@ -0,0 +1,193 @@ +import { useCallback, useMemo, useRef } from "react"; +import errorSoundUrl from "../assets/error-notification.mp3"; +import successSoundUrl from "../assets/success-notification.mp3"; +import type { DebugEntry } from "../types"; +import { playNotificationSound } from "../utils/notificationSounds"; +import { useAppServerEvents } from "./useAppServerEvents"; + +const DEFAULT_MIN_DURATION_MS = 60_000; // 1 minute + +type SoundNotificationOptions = { + enabled: boolean; + isWindowFocused: boolean; + minDurationMs?: number; + onDebug?: (entry: DebugEntry) => void; +}; + +function buildThreadKey(workspaceId: string, threadId: string) { + return `${workspaceId}:${threadId}`; +} + +function buildTurnKey(workspaceId: string, turnId: string) { + return `${workspaceId}:${turnId}`; +} + +export function useAgentSoundNotifications({ + enabled, + isWindowFocused, + minDurationMs = DEFAULT_MIN_DURATION_MS, + onDebug, +}: SoundNotificationOptions) { + const turnStartById = useRef(new Map()); + const turnStartByThread = useRef(new Map()); + const lastPlayedAtByThread = useRef(new Map()); + + const playSound = useCallback( + (url: string, label: "success" | "error") => { + playNotificationSound(url, label, onDebug); + }, + [onDebug], + ); + + const consumeDuration = useCallback( + (workspaceId: string, threadId: string, turnId: string) => { + const threadKey = buildThreadKey(workspaceId, threadId); + let startedAt: number | undefined; + + if (turnId) { + const turnKey = buildTurnKey(workspaceId, turnId); + startedAt = turnStartById.current.get(turnKey); + turnStartById.current.delete(turnKey); + } + + if (startedAt === undefined) { + startedAt = turnStartByThread.current.get(threadKey); + } + + if (startedAt !== undefined) { + turnStartByThread.current.delete(threadKey); + return Date.now() - startedAt; + } + + return null; + }, + [], + ); + + const recordStartIfMissing = useCallback( + (workspaceId: string, threadId: string) => { + const threadKey = buildThreadKey(workspaceId, threadId); + if (!turnStartByThread.current.has(threadKey)) { + turnStartByThread.current.set(threadKey, Date.now()); + } + }, + [], + ); + + const shouldPlaySound = useCallback( + (durationMs: number | null, threadKey: string) => { + if (durationMs === null) { + return false; + } + if (!enabled) { + return false; + } + if (durationMs < minDurationMs) { + return false; + } + if (isWindowFocused) { + return false; + } + const lastPlayedAt = lastPlayedAtByThread.current.get(threadKey); + if (lastPlayedAt && Date.now() - lastPlayedAt < 1500) { + return false; + } + lastPlayedAtByThread.current.set(threadKey, Date.now()); + return true; + }, + [enabled, isWindowFocused, minDurationMs], + ); + + const handleTurnStarted = useCallback( + (workspaceId: string, threadId: string, turnId: string) => { + const startedAt = Date.now(); + turnStartByThread.current.set( + buildThreadKey(workspaceId, threadId), + startedAt, + ); + if (turnId) { + turnStartById.current.set(buildTurnKey(workspaceId, turnId), startedAt); + } + }, + [], + ); + + const handleTurnCompleted = useCallback( + (workspaceId: string, threadId: string, turnId: string) => { + const durationMs = consumeDuration(workspaceId, threadId, turnId); + const threadKey = buildThreadKey(workspaceId, threadId); + if (!shouldPlaySound(durationMs, threadKey)) { + return; + } + playSound(successSoundUrl, "success"); + }, + [consumeDuration, playSound, shouldPlaySound], + ); + + const handleTurnError = useCallback( + ( + workspaceId: string, + threadId: string, + turnId: string, + payload: { message: string; willRetry: boolean }, + ) => { + if (payload.willRetry) { + return; + } + const durationMs = consumeDuration(workspaceId, threadId, turnId); + const threadKey = buildThreadKey(workspaceId, threadId); + if (!shouldPlaySound(durationMs, threadKey)) { + return; + } + playSound(errorSoundUrl, "error"); + }, + [consumeDuration, playSound, shouldPlaySound], + ); + + const handleItemStarted = useCallback( + (workspaceId: string, threadId: string) => { + recordStartIfMissing(workspaceId, threadId); + }, + [recordStartIfMissing], + ); + + const handleAgentMessageDelta = useCallback( + (event: { workspaceId: string; threadId: string }) => { + recordStartIfMissing(event.workspaceId, event.threadId); + }, + [recordStartIfMissing], + ); + + const handleAgentMessageCompleted = useCallback( + (event: { workspaceId: string; threadId: string }) => { + const durationMs = consumeDuration(event.workspaceId, event.threadId, ""); + const threadKey = buildThreadKey(event.workspaceId, event.threadId); + if (!shouldPlaySound(durationMs, threadKey)) { + return; + } + playSound(successSoundUrl, "success"); + }, + [consumeDuration, playSound, shouldPlaySound], + ); + + const handlers = useMemo( + () => ({ + onTurnStarted: handleTurnStarted, + onTurnCompleted: handleTurnCompleted, + onTurnError: handleTurnError, + onItemStarted: handleItemStarted, + onAgentMessageDelta: handleAgentMessageDelta, + onAgentMessageCompleted: handleAgentMessageCompleted, + }), + [ + handleAgentMessageCompleted, + handleAgentMessageDelta, + handleItemStarted, + handleTurnCompleted, + handleTurnError, + handleTurnStarted, + ], + ); + + useAppServerEvents(handlers); +} diff --git a/src/hooks/useAppSettings.ts b/src/hooks/useAppSettings.ts index 9b56cab5c..2b11c8797 100644 --- a/src/hooks/useAppSettings.ts +++ b/src/hooks/useAppSettings.ts @@ -7,6 +7,7 @@ const defaultSettings: AppSettings = { codexBin: null, defaultAccessMode: "current", uiScale: UI_SCALE_DEFAULT, + notificationSoundsEnabled: true, }; function normalizeAppSettings(settings: AppSettings): AppSettings { diff --git a/src/hooks/useWindowFocusState.ts b/src/hooks/useWindowFocusState.ts new file mode 100644 index 000000000..65b288a63 --- /dev/null +++ b/src/hooks/useWindowFocusState.ts @@ -0,0 +1,62 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { useEffect, useState } from "react"; + +export function useWindowFocusState() { + const [isFocused, setIsFocused] = useState(() => { + if (typeof document === "undefined") { + return true; + } + return document.hasFocus(); + }); + + useEffect(() => { + let unlistenFocus: (() => void) | null = null; + let unlistenBlur: (() => void) | null = null; + + const handleFocus = () => setIsFocused(true); + const handleBlur = () => setIsFocused(false); + const handleVisibility = () => { + if (document.visibilityState === "visible") { + handleFocus(); + } else { + handleBlur(); + } + }; + + const windowHandle = getCurrentWindow(); + windowHandle + .listen("tauri://focus", handleFocus) + .then((handler) => { + unlistenFocus = handler; + }) + .catch(() => { + // Ignore; fallback listeners below cover focus changes. + }); + windowHandle + .listen("tauri://blur", handleBlur) + .then((handler) => { + unlistenBlur = handler; + }) + .catch(() => { + // Ignore; fallback listeners below cover focus changes. + }); + + window.addEventListener("focus", handleFocus); + window.addEventListener("blur", handleBlur); + document.addEventListener("visibilitychange", handleVisibility); + + return () => { + if (unlistenFocus) { + unlistenFocus(); + } + if (unlistenBlur) { + unlistenBlur(); + } + window.removeEventListener("focus", handleFocus); + window.removeEventListener("blur", handleBlur); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, []); + + return isFocused; +} diff --git a/src/styles/settings.css b/src/styles/settings.css index 1e98cc658..c36bb945c 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -202,6 +202,22 @@ margin-bottom: 16px; } +.settings-subsection-title { + margin-top: 18px; + margin-bottom: 6px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--text-muted); +} + +.settings-subsection-subtitle { + font-size: 12px; + color: var(--text-subtle); + margin-bottom: 12px; +} + .settings-projects { display: flex; flex-direction: column; @@ -330,6 +346,12 @@ margin-top: 12px; } +.settings-sound-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + .settings-scale-row > div:first-child { min-width: 0; } diff --git a/src/types.ts b/src/types.ts index c947b59ae..a5fe16552 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,6 +65,7 @@ export type AppSettings = { codexBin: string | null; defaultAccessMode: AccessMode; uiScale: number; + notificationSoundsEnabled: boolean; }; export type CodexDoctorResult = { diff --git a/src/utils/notificationSounds.ts b/src/utils/notificationSounds.ts new file mode 100644 index 000000000..bb6267825 --- /dev/null +++ b/src/utils/notificationSounds.ts @@ -0,0 +1,43 @@ +import type { DebugEntry } from "../types"; + +type DebugLogger = (entry: DebugEntry) => void; + +type SoundLabel = "success" | "error" | "test"; + +export function playNotificationSound( + url: string, + label: SoundLabel, + onDebug?: DebugLogger, +) { + try { + const audio = new Audio(url); + audio.volume = 0.05; + audio.preload = "auto"; + audio.addEventListener("error", () => { + onDebug?.({ + id: `${Date.now()}-audio-${label}-load-error`, + timestamp: Date.now(), + source: "error", + label: `audio/${label} load error`, + payload: `Failed to load audio: ${url}`, + }); + }); + void audio.play().catch((error) => { + onDebug?.({ + id: `${Date.now()}-audio-${label}-play-error`, + timestamp: Date.now(), + source: "error", + label: `audio/${label} play error`, + payload: error instanceof Error ? error.message : String(error), + }); + }); + } catch (error) { + onDebug?.({ + id: `${Date.now()}-audio-${label}-init-error`, + timestamp: Date.now(), + source: "error", + label: `audio/${label} init error`, + payload: error instanceof Error ? error.message : String(error), + }); + } +}