Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}
}
}
Expand All @@ -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]
Expand Down
22 changes: 22 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -135,6 +140,22 @@ function MainApp() {
const composerInputRef = useRef<HTMLTextAreaElement | null>(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,
Expand Down Expand Up @@ -897,6 +918,7 @@ function MainApp() {
}}
scaleShortcutTitle={scaleShortcutTitle}
scaleShortcutText={scaleShortcutText}
onTestNotificationSound={handleTestNotificationSound}
/>
)}
</div>
Expand Down
Binary file added src/assets/error-notification.mp3
Binary file not shown.
1 change: 0 additions & 1 deletion src/assets/react.svg

This file was deleted.

Binary file added src/assets/success-notification.mp3
Binary file not shown.
48 changes: 44 additions & 4 deletions src/components/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { open } from "@tauri-apps/plugin-dialog";
import {
ChevronDown,
ChevronUp,
Laptop2,
LayoutGrid,
SlidersHorizontal,
Stethoscope,
TerminalSquare,
Trash2,
Expand All @@ -28,6 +28,7 @@ type SettingsViewProps = {
onUpdateWorkspaceCodexBin: (id: string, codexBin: string | null) => Promise<void>;
scaleShortcutTitle: string;
scaleShortcutText: string;
onTestNotificationSound: () => void;
};

type SettingsSection = "projects" | "display";
Expand All @@ -51,6 +52,7 @@ export function SettingsView({
onUpdateWorkspaceCodexBin,
scaleShortcutTitle,
scaleShortcutText,
onTestNotificationSound,
}: SettingsViewProps) {
const [activeSection, setActiveSection] = useState<CodexSection>("projects");
const [codexPathDraft, setCodexPathDraft] = useState(appSettings.codexBin ?? "");
Expand Down Expand Up @@ -208,8 +210,8 @@ export function SettingsView({
className={`settings-nav ${activeSection === "display" ? "active" : ""}`}
onClick={() => setActiveSection("display")}
>
<Laptop2 aria-hidden />
Display
<SlidersHorizontal aria-hidden />
Display &amp; Sound
</button>
<button
type="button"
Expand Down Expand Up @@ -272,8 +274,12 @@ export function SettingsView({
)}
{activeSection === "display" && (
<section className="settings-section">
<div className="settings-section-title">Display</div>
<div className="settings-section-title">Display &amp; Sound</div>
<div className="settings-section-subtitle">
Tune visuals and audio alerts to your preferences.
</div>
<div className="settings-subsection-title">Display</div>
<div className="settings-subsection-subtitle">
Adjust how the window renders backgrounds and effects.
</div>
<div className="settings-toggle-row">
Expand Down Expand Up @@ -332,6 +338,40 @@ export function SettingsView({
</button>
</div>
</div>
<div className="settings-subsection-title">Sounds</div>
<div className="settings-subsection-subtitle">
Control notification audio alerts.
</div>
<div className="settings-toggle-row">
<div>
<div className="settings-toggle-title">Notification sounds</div>
<div className="settings-toggle-subtitle">
Play a sound when a long-running agent finishes while the window is unfocused.
</div>
</div>
<button
type="button"
className={`settings-toggle ${appSettings.notificationSoundsEnabled ? "on" : ""}`}
onClick={() =>
void onUpdateAppSettings({
...appSettings,
notificationSoundsEnabled: !appSettings.notificationSoundsEnabled,
})
}
aria-pressed={appSettings.notificationSoundsEnabled}
>
<span className="settings-toggle-knob" />
</button>
</div>
<div className="settings-sound-actions">
<button
type="button"
className="ghost settings-button-compact"
onClick={onTestNotificationSound}
>
Test sound
</button>
</div>
</section>
)}
{activeSection === "codex" && (
Expand Down
193 changes: 193 additions & 0 deletions src/hooks/useAgentSoundNotifications.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>());
const turnStartByThread = useRef(new Map<string, number>());
const lastPlayedAtByThread = useRef(new Map<string, number>());

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);
}
1 change: 1 addition & 0 deletions src/hooks/useAppSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const defaultSettings: AppSettings = {
codexBin: null,
defaultAccessMode: "current",
uiScale: UI_SCALE_DEFAULT,
notificationSoundsEnabled: true,
};

function normalizeAppSettings(settings: AppSettings): AppSettings {
Expand Down
Loading