Skip to content

Commit 5c88d55

Browse files
feat: add configurable completion sounds
Add optional notification sounds that play when agent tasks complete. Users can choose from three sounds (guitar solo, "I'm ready", cute noise) or disable sounds entirely (default). - Add completion sound setting to settings store - Add Notifications section to Settings page with sound picker and test button - Create event emitter pattern for session events (clean separation of concerns) - Create useSessionEventHandlers hook for handling side effects at app level - Import sounds as ES modules for proper Vite asset handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 38af55b commit 5c88d55

File tree

11 files changed

+183
-1
lines changed

11 files changed

+183
-1
lines changed
23.1 KB
Binary file not shown.
91.7 KB
Binary file not shown.

apps/array/assets/sounds/revi.mp3

102 KB
Binary file not shown.

apps/array/src/renderer/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { MainLayout } from "@components/MainLayout";
22
import { AuthScreen } from "@features/auth/components/AuthScreen";
33
import { useAuthStore } from "@features/auth/stores/authStore";
4+
import { useSessionEventHandlers } from "@hooks/useSessionEventHandlers";
45
import { Flex, Spinner, Text } from "@radix-ui/themes";
56
import { initializePostHog } from "@renderer/lib/analytics";
67
import { useEffect, useState } from "react";
@@ -14,6 +15,9 @@ function App() {
1415
initializePostHog();
1516
}, []);
1617

18+
// Handle session events (completion sounds, etc.)
19+
useSessionEventHandlers();
20+
1721
useEffect(() => {
1822
initializeOAuth().finally(() => setIsLoading(false));
1923
}, [initializeOAuth]);

apps/array/src/renderer/features/sessions/stores/sessionStore.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
} from "@agentclientprotocol/sdk";
55
import { useAuthStore } from "@features/auth/stores/authStore";
66
import { logger } from "@renderer/lib/logger";
7+
import { sessionEvents } from "@renderer/lib/sessionEvents";
78
import type { Task } from "@shared/types";
89
import { create } from "zustand";
910
import { getCloudUrlFromRegion } from "@/constants/oauth";
@@ -464,7 +465,18 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
464465
}));
465466

466467
try {
467-
return await window.electronAPI.agentPrompt(session.taskRunId, blocks);
468+
const result = await window.electronAPI.agentPrompt(
469+
session.taskRunId,
470+
blocks,
471+
);
472+
473+
sessionEvents.emit("prompt:complete", {
474+
taskId,
475+
taskRunId: session.taskRunId,
476+
stopReason: result.stopReason,
477+
});
478+
479+
return result;
468480
} finally {
469481
set((state) => ({
470482
sessions: {

apps/array/src/renderer/features/settings/components/SettingsView.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useAuthStore } from "@features/auth/stores/authStore";
22
import { FolderPicker } from "@features/folder-picker/components/FolderPicker";
33
import {
4+
type CompletionSound,
45
type DefaultRunMode,
56
useSettingsStore,
67
} from "@features/settings/stores/settingsStore";
@@ -21,6 +22,7 @@ import {
2122
} from "@radix-ui/themes";
2223
import { clearApplicationStorage } from "@renderer/lib/clearStorage";
2324
import { logger } from "@renderer/lib/logger";
25+
import { sounds } from "@renderer/lib/sounds";
2426
import type { CloudRegion } from "@shared/types/oauth";
2527
import { useSettingsStore as useTerminalLayoutStore } from "@stores/settingsStore";
2628
import { useMutation, useQuery } from "@tanstack/react-query";
@@ -58,9 +60,11 @@ export function SettingsView() {
5860
autoRunTasks,
5961
defaultRunMode,
6062
createPR,
63+
completionSound,
6164
setAutoRunTasks,
6265
setDefaultRunMode,
6366
setCreatePR,
67+
setCompletionSound,
6468
} = useSettingsStore();
6569
const terminalLayoutMode = useTerminalLayoutStore(
6670
(state) => state.terminalLayoutMode,
@@ -174,6 +178,54 @@ export function SettingsView() {
174178

175179
<Box className="border-gray-6 border-t" />
176180

181+
{/* Notifications Section */}
182+
<Flex direction="column" gap="3">
183+
<Heading size="3">Notifications</Heading>
184+
<Card>
185+
<Flex direction="column" gap="4">
186+
<Flex direction="column" gap="2">
187+
<Text size="1" weight="medium">
188+
Completion sound
189+
</Text>
190+
<Flex gap="2" align="center">
191+
<Select.Root
192+
value={completionSound}
193+
onValueChange={(value) =>
194+
setCompletionSound(value as CompletionSound)
195+
}
196+
size="1"
197+
>
198+
<Select.Trigger style={{ flex: 1 }} />
199+
<Select.Content>
200+
<Select.Item value="none">None</Select.Item>
201+
<Select.Item value="guitar">Guitar solo</Select.Item>
202+
<Select.Item value="danilo">I'm ready</Select.Item>
203+
<Select.Item value="revi">Cute noise</Select.Item>
204+
</Select.Content>
205+
</Select.Root>
206+
{completionSound !== "none" && (
207+
<Button
208+
variant="soft"
209+
size="1"
210+
onClick={() => {
211+
const audio = new Audio(sounds[completionSound]);
212+
audio.play();
213+
}}
214+
>
215+
Test
216+
</Button>
217+
)}
218+
</Flex>
219+
<Text size="1" color="gray">
220+
Play a sound when a task completes
221+
</Text>
222+
</Flex>
223+
</Flex>
224+
</Card>
225+
</Flex>
226+
227+
<Box className="border-gray-6 border-t" />
228+
177229
{/* Task Execution Section */}
178230
<Flex direction="column" gap="3">
179231
<Heading size="3">Task execution</Heading>

apps/array/src/renderer/features/settings/stores/settingsStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { persist } from "zustand/middleware";
44

55
export type DefaultRunMode = "local" | "cloud" | "last_used";
66
export type LocalWorkspaceMode = "worktree" | "root";
7+
export type CompletionSound = "none" | "guitar" | "danilo" | "revi";
78

89
interface SettingsStore {
910
autoRunTasks: boolean;
@@ -12,13 +13,15 @@ interface SettingsStore {
1213
lastUsedLocalWorkspaceMode: LocalWorkspaceMode;
1314
lastUsedWorkspaceMode: WorkspaceMode;
1415
createPR: boolean;
16+
completionSound: CompletionSound;
1517

1618
setAutoRunTasks: (autoRun: boolean) => void;
1719
setDefaultRunMode: (mode: DefaultRunMode) => void;
1820
setLastUsedRunMode: (mode: "local" | "cloud") => void;
1921
setLastUsedLocalWorkspaceMode: (mode: LocalWorkspaceMode) => void;
2022
setLastUsedWorkspaceMode: (mode: WorkspaceMode) => void;
2123
setCreatePR: (createPR: boolean) => void;
24+
setCompletionSound: (sound: CompletionSound) => void;
2225
}
2326

2427
export const useSettingsStore = create<SettingsStore>()(
@@ -30,6 +33,7 @@ export const useSettingsStore = create<SettingsStore>()(
3033
lastUsedLocalWorkspaceMode: "worktree",
3134
lastUsedWorkspaceMode: "worktree",
3235
createPR: true,
36+
completionSound: "none",
3337

3438
setAutoRunTasks: (autoRun) => set({ autoRunTasks: autoRun }),
3539
setDefaultRunMode: (mode) => set({ defaultRunMode: mode }),
@@ -38,6 +42,7 @@ export const useSettingsStore = create<SettingsStore>()(
3842
set({ lastUsedLocalWorkspaceMode: mode }),
3943
setLastUsedWorkspaceMode: (mode) => set({ lastUsedWorkspaceMode: mode }),
4044
setCreatePR: (createPR) => set({ createPR }),
45+
setCompletionSound: (sound) => set({ completionSound: sound }),
4146
}),
4247
{
4348
name: "settings-storage",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useSettingsStore } from "@features/settings/stores/settingsStore";
2+
import { sessionEvents } from "@renderer/lib/sessionEvents";
3+
import { sounds } from "@renderer/lib/sounds";
4+
import { useEffect, useRef } from "react";
5+
6+
/**
7+
* Hook that subscribes to session events and handles side effects.
8+
* Should be mounted once at the app level.
9+
*/
10+
export function useSessionEventHandlers() {
11+
const completionSound = useSettingsStore((state) => state.completionSound);
12+
const audioRef = useRef<HTMLAudioElement | null>(null);
13+
14+
useEffect(() => {
15+
const unsubscribe = sessionEvents.on(
16+
"prompt:complete",
17+
({ stopReason }) => {
18+
if (stopReason !== "end_turn") return;
19+
if (completionSound === "none") return;
20+
21+
// Stop any currently playing sound
22+
if (audioRef.current) {
23+
audioRef.current.pause();
24+
audioRef.current.currentTime = 0;
25+
}
26+
27+
const soundUrl = sounds[completionSound];
28+
const audio = new Audio(soundUrl);
29+
audioRef.current = audio;
30+
audio.play().catch(() => {
31+
// Ignore autoplay errors
32+
});
33+
},
34+
);
35+
36+
return () => {
37+
unsubscribe();
38+
// Cleanup audio on unmount
39+
if (audioRef.current) {
40+
audioRef.current.pause();
41+
audioRef.current = null;
42+
}
43+
};
44+
}, [completionSound]);
45+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
type SessionEventType = "prompt:complete";
2+
3+
interface PromptCompleteEvent {
4+
taskId: string;
5+
taskRunId: string;
6+
stopReason: string;
7+
}
8+
9+
type SessionEventPayload = {
10+
"prompt:complete": PromptCompleteEvent;
11+
};
12+
13+
type SessionEventListener<T extends SessionEventType> = (
14+
payload: SessionEventPayload[T],
15+
) => void;
16+
17+
class SessionEventEmitter {
18+
private listeners: Map<SessionEventType, Set<SessionEventListener<never>>> =
19+
new Map();
20+
21+
on<T extends SessionEventType>(
22+
event: T,
23+
listener: SessionEventListener<T>,
24+
): () => void {
25+
if (!this.listeners.has(event)) {
26+
this.listeners.set(event, new Set());
27+
}
28+
this.listeners.get(event)?.add(listener as SessionEventListener<never>);
29+
30+
// Return unsubscribe function
31+
return () => {
32+
this.listeners
33+
.get(event)
34+
?.delete(listener as SessionEventListener<never>);
35+
};
36+
}
37+
38+
emit<T extends SessionEventType>(
39+
event: T,
40+
payload: SessionEventPayload[T],
41+
): void {
42+
this.listeners.get(event)?.forEach((listener) => {
43+
listener(payload as never);
44+
});
45+
}
46+
}
47+
48+
export const sessionEvents = new SessionEventEmitter();
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import daniloSound from "../../../assets/sounds/danilo.mp3";
2+
import guitarSound from "../../../assets/sounds/guitar.mp3";
3+
import reviSound from "../../../assets/sounds/revi.mp3";
4+
5+
export const sounds = {
6+
guitar: guitarSound,
7+
danilo: daniloSound,
8+
revi: reviSound,
9+
} as const;
10+
11+
export type SoundName = keyof typeof sounds;

0 commit comments

Comments
 (0)