diff --git a/apps/array/assets/sounds/danilo.mp3 b/apps/array/assets/sounds/danilo.mp3 new file mode 100644 index 00000000..c10b0d1d Binary files /dev/null and b/apps/array/assets/sounds/danilo.mp3 differ diff --git a/apps/array/assets/sounds/guitar.mp3 b/apps/array/assets/sounds/guitar.mp3 new file mode 100644 index 00000000..29f5cab3 Binary files /dev/null and b/apps/array/assets/sounds/guitar.mp3 differ diff --git a/apps/array/assets/sounds/revi.mp3 b/apps/array/assets/sounds/revi.mp3 new file mode 100644 index 00000000..6fd9ab4f Binary files /dev/null and b/apps/array/assets/sounds/revi.mp3 differ diff --git a/apps/array/src/renderer/App.tsx b/apps/array/src/renderer/App.tsx index a4066a19..7cca59e7 100644 --- a/apps/array/src/renderer/App.tsx +++ b/apps/array/src/renderer/App.tsx @@ -1,6 +1,7 @@ import { MainLayout } from "@components/MainLayout"; import { AuthScreen } from "@features/auth/components/AuthScreen"; import { useAuthStore } from "@features/auth/stores/authStore"; +import { useSessionEventHandlers } from "@hooks/useSessionEventHandlers"; import { Flex, Spinner, Text } from "@radix-ui/themes"; import { initializePostHog } from "@renderer/lib/analytics"; import { useEffect, useState } from "react"; @@ -14,6 +15,9 @@ function App() { initializePostHog(); }, []); + // Handle session events (completion sounds, etc.) + useSessionEventHandlers(); + useEffect(() => { initializeOAuth().finally(() => setIsLoading(false)); }, [initializeOAuth]); diff --git a/apps/array/src/renderer/features/sessions/stores/sessionStore.ts b/apps/array/src/renderer/features/sessions/stores/sessionStore.ts index 1018c815..79d04102 100644 --- a/apps/array/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/array/src/renderer/features/sessions/stores/sessionStore.ts @@ -4,6 +4,7 @@ import type { } from "@agentclientprotocol/sdk"; import { useAuthStore } from "@features/auth/stores/authStore"; import { logger } from "@renderer/lib/logger"; +import { sessionEvents } from "@renderer/lib/sessionEvents"; import type { Task } from "@shared/types"; import { create } from "zustand"; import { getCloudUrlFromRegion } from "@/constants/oauth"; @@ -464,7 +465,18 @@ export const useSessionStore = create((set, get) => ({ })); try { - return await window.electronAPI.agentPrompt(session.taskRunId, blocks); + const result = await window.electronAPI.agentPrompt( + session.taskRunId, + blocks, + ); + + sessionEvents.emit("prompt:complete", { + taskId, + taskRunId: session.taskRunId, + stopReason: result.stopReason, + }); + + return result; } finally { set((state) => ({ sessions: { diff --git a/apps/array/src/renderer/features/settings/components/SettingsView.tsx b/apps/array/src/renderer/features/settings/components/SettingsView.tsx index 3f178abe..aaacf201 100644 --- a/apps/array/src/renderer/features/settings/components/SettingsView.tsx +++ b/apps/array/src/renderer/features/settings/components/SettingsView.tsx @@ -1,6 +1,7 @@ import { useAuthStore } from "@features/auth/stores/authStore"; import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; import { + type CompletionSound, type DefaultRunMode, useSettingsStore, } from "@features/settings/stores/settingsStore"; @@ -21,6 +22,7 @@ import { } from "@radix-ui/themes"; import { clearApplicationStorage } from "@renderer/lib/clearStorage"; import { logger } from "@renderer/lib/logger"; +import { sounds } from "@renderer/lib/sounds"; import type { CloudRegion } from "@shared/types/oauth"; import { useSettingsStore as useTerminalLayoutStore } from "@stores/settingsStore"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -58,9 +60,11 @@ export function SettingsView() { autoRunTasks, defaultRunMode, createPR, + completionSound, setAutoRunTasks, setDefaultRunMode, setCreatePR, + setCompletionSound, } = useSettingsStore(); const terminalLayoutMode = useTerminalLayoutStore( (state) => state.terminalLayoutMode, @@ -174,6 +178,54 @@ export function SettingsView() { + {/* Notifications Section */} + + Notifications + + + + + Completion sound + + + + setCompletionSound(value as CompletionSound) + } + size="1" + > + + + None + Guitar solo + I'm ready + Cute noise + + + {completionSound !== "none" && ( + + )} + + + Play a sound when a task completes + + + + + + + + {/* Task Execution Section */} Task execution diff --git a/apps/array/src/renderer/features/settings/stores/settingsStore.ts b/apps/array/src/renderer/features/settings/stores/settingsStore.ts index 3330ead0..1433960a 100644 --- a/apps/array/src/renderer/features/settings/stores/settingsStore.ts +++ b/apps/array/src/renderer/features/settings/stores/settingsStore.ts @@ -4,6 +4,7 @@ import { persist } from "zustand/middleware"; export type DefaultRunMode = "local" | "cloud" | "last_used"; export type LocalWorkspaceMode = "worktree" | "root"; +export type CompletionSound = "none" | "guitar" | "danilo" | "revi"; interface SettingsStore { autoRunTasks: boolean; @@ -12,6 +13,7 @@ interface SettingsStore { lastUsedLocalWorkspaceMode: LocalWorkspaceMode; lastUsedWorkspaceMode: WorkspaceMode; createPR: boolean; + completionSound: CompletionSound; setAutoRunTasks: (autoRun: boolean) => void; setDefaultRunMode: (mode: DefaultRunMode) => void; @@ -19,6 +21,7 @@ interface SettingsStore { setLastUsedLocalWorkspaceMode: (mode: LocalWorkspaceMode) => void; setLastUsedWorkspaceMode: (mode: WorkspaceMode) => void; setCreatePR: (createPR: boolean) => void; + setCompletionSound: (sound: CompletionSound) => void; } export const useSettingsStore = create()( @@ -30,6 +33,7 @@ export const useSettingsStore = create()( lastUsedLocalWorkspaceMode: "worktree", lastUsedWorkspaceMode: "worktree", createPR: true, + completionSound: "none", setAutoRunTasks: (autoRun) => set({ autoRunTasks: autoRun }), setDefaultRunMode: (mode) => set({ defaultRunMode: mode }), @@ -38,6 +42,7 @@ export const useSettingsStore = create()( set({ lastUsedLocalWorkspaceMode: mode }), setLastUsedWorkspaceMode: (mode) => set({ lastUsedWorkspaceMode: mode }), setCreatePR: (createPR) => set({ createPR }), + setCompletionSound: (sound) => set({ completionSound: sound }), }), { name: "settings-storage", diff --git a/apps/array/src/renderer/hooks/useSessionEventHandlers.ts b/apps/array/src/renderer/hooks/useSessionEventHandlers.ts new file mode 100644 index 00000000..7a92ad29 --- /dev/null +++ b/apps/array/src/renderer/hooks/useSessionEventHandlers.ts @@ -0,0 +1,45 @@ +import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { sessionEvents } from "@renderer/lib/sessionEvents"; +import { sounds } from "@renderer/lib/sounds"; +import { useEffect, useRef } from "react"; + +/** + * Hook that subscribes to session events and handles side effects. + * Should be mounted once at the app level. + */ +export function useSessionEventHandlers() { + const completionSound = useSettingsStore((state) => state.completionSound); + const audioRef = useRef(null); + + useEffect(() => { + const unsubscribe = sessionEvents.on( + "prompt:complete", + ({ stopReason }) => { + if (stopReason !== "end_turn") return; + if (completionSound === "none") return; + + // Stop any currently playing sound + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + } + + const soundUrl = sounds[completionSound]; + const audio = new Audio(soundUrl); + audioRef.current = audio; + audio.play().catch(() => { + // Ignore autoplay errors + }); + }, + ); + + return () => { + unsubscribe(); + // Cleanup audio on unmount + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + }; + }, [completionSound]); +} diff --git a/apps/array/src/renderer/lib/sessionEvents.ts b/apps/array/src/renderer/lib/sessionEvents.ts new file mode 100644 index 00000000..9bd88a7a --- /dev/null +++ b/apps/array/src/renderer/lib/sessionEvents.ts @@ -0,0 +1,48 @@ +type SessionEventType = "prompt:complete"; + +interface PromptCompleteEvent { + taskId: string; + taskRunId: string; + stopReason: string; +} + +type SessionEventPayload = { + "prompt:complete": PromptCompleteEvent; +}; + +type SessionEventListener = ( + payload: SessionEventPayload[T], +) => void; + +class SessionEventEmitter { + private listeners: Map>> = + new Map(); + + on( + event: T, + listener: SessionEventListener, + ): () => void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)?.add(listener as SessionEventListener); + + // Return unsubscribe function + return () => { + this.listeners + .get(event) + ?.delete(listener as SessionEventListener); + }; + } + + emit( + event: T, + payload: SessionEventPayload[T], + ): void { + this.listeners.get(event)?.forEach((listener) => { + listener(payload as never); + }); + } +} + +export const sessionEvents = new SessionEventEmitter(); diff --git a/apps/array/src/renderer/lib/sounds.ts b/apps/array/src/renderer/lib/sounds.ts new file mode 100644 index 00000000..a5912b7d --- /dev/null +++ b/apps/array/src/renderer/lib/sounds.ts @@ -0,0 +1,11 @@ +import daniloSound from "../../../assets/sounds/danilo.mp3"; +import guitarSound from "../../../assets/sounds/guitar.mp3"; +import reviSound from "../../../assets/sounds/revi.mp3"; + +export const sounds = { + guitar: guitarSound, + danilo: daniloSound, + revi: reviSound, +} as const; + +export type SoundName = keyof typeof sounds; diff --git a/apps/array/src/vite-env.d.ts b/apps/array/src/vite-env.d.ts index 41c10685..00c8bbf2 100644 --- a/apps/array/src/vite-env.d.ts +++ b/apps/array/src/vite-env.d.ts @@ -1,5 +1,10 @@ /// +declare module "*.mp3" { + const src: string; + export default src; +} + interface ImportMetaEnv { readonly DEV: boolean; readonly PROD: boolean;