Skip to content
Draft
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
Binary file added apps/array/assets/sounds/danilo.mp3
Binary file not shown.
Binary file added apps/array/assets/sounds/guitar.mp3
Binary file not shown.
Binary file added apps/array/assets/sounds/revi.mp3
Binary file not shown.
4 changes: 4 additions & 0 deletions apps/array/src/renderer/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,6 +15,9 @@ function App() {
initializePostHog();
}, []);

// Handle session events (completion sounds, etc.)
useSessionEventHandlers();

useEffect(() => {
initializeOAuth().finally(() => setIsLoading(false));
}, [initializeOAuth]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -464,7 +465,18 @@ export const useSessionStore = create<SessionStore>((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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -58,9 +60,11 @@ export function SettingsView() {
autoRunTasks,
defaultRunMode,
createPR,
completionSound,
setAutoRunTasks,
setDefaultRunMode,
setCreatePR,
setCompletionSound,
} = useSettingsStore();
const terminalLayoutMode = useTerminalLayoutStore(
(state) => state.terminalLayoutMode,
Expand Down Expand Up @@ -174,6 +178,54 @@ export function SettingsView() {

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

{/* Notifications Section */}
<Flex direction="column" gap="3">
<Heading size="3">Notifications</Heading>
<Card>
<Flex direction="column" gap="4">
<Flex direction="column" gap="2">
<Text size="1" weight="medium">
Completion sound
</Text>
<Flex gap="2" align="center">
<Select.Root
value={completionSound}
onValueChange={(value) =>
setCompletionSound(value as CompletionSound)
}
size="1"
>
<Select.Trigger style={{ flex: 1 }} />
<Select.Content>
<Select.Item value="none">None</Select.Item>
<Select.Item value="guitar">Guitar solo</Select.Item>
<Select.Item value="danilo">I'm ready</Select.Item>
<Select.Item value="revi">Cute noise</Select.Item>
</Select.Content>
</Select.Root>
{completionSound !== "none" && (
<Button
variant="soft"
size="1"
onClick={() => {
const audio = new Audio(sounds[completionSound]);
audio.play();
}}
>
Test
</Button>
)}
</Flex>
<Text size="1" color="gray">
Play a sound when a task completes
</Text>
</Flex>
</Flex>
</Card>
</Flex>

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

{/* Task Execution Section */}
<Flex direction="column" gap="3">
<Heading size="3">Task execution</Heading>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,13 +13,15 @@ interface SettingsStore {
lastUsedLocalWorkspaceMode: LocalWorkspaceMode;
lastUsedWorkspaceMode: WorkspaceMode;
createPR: boolean;
completionSound: CompletionSound;

setAutoRunTasks: (autoRun: boolean) => void;
setDefaultRunMode: (mode: DefaultRunMode) => void;
setLastUsedRunMode: (mode: "local" | "cloud") => void;
setLastUsedLocalWorkspaceMode: (mode: LocalWorkspaceMode) => void;
setLastUsedWorkspaceMode: (mode: WorkspaceMode) => void;
setCreatePR: (createPR: boolean) => void;
setCompletionSound: (sound: CompletionSound) => void;
}

export const useSettingsStore = create<SettingsStore>()(
Expand All @@ -30,6 +33,7 @@ export const useSettingsStore = create<SettingsStore>()(
lastUsedLocalWorkspaceMode: "worktree",
lastUsedWorkspaceMode: "worktree",
createPR: true,
completionSound: "none",

setAutoRunTasks: (autoRun) => set({ autoRunTasks: autoRun }),
setDefaultRunMode: (mode) => set({ defaultRunMode: mode }),
Expand All @@ -38,6 +42,7 @@ export const useSettingsStore = create<SettingsStore>()(
set({ lastUsedLocalWorkspaceMode: mode }),
setLastUsedWorkspaceMode: (mode) => set({ lastUsedWorkspaceMode: mode }),
setCreatePR: (createPR) => set({ createPR }),
setCompletionSound: (sound) => set({ completionSound: sound }),
}),
{
name: "settings-storage",
Expand Down
45 changes: 45 additions & 0 deletions apps/array/src/renderer/hooks/useSessionEventHandlers.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLAudioElement | null>(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]);
}
48 changes: 48 additions & 0 deletions apps/array/src/renderer/lib/sessionEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
type SessionEventType = "prompt:complete";

interface PromptCompleteEvent {
taskId: string;
taskRunId: string;
stopReason: string;
}

type SessionEventPayload = {
"prompt:complete": PromptCompleteEvent;
};

type SessionEventListener<T extends SessionEventType> = (
payload: SessionEventPayload[T],
) => void;

class SessionEventEmitter {
private listeners: Map<SessionEventType, Set<SessionEventListener<never>>> =
new Map();

on<T extends SessionEventType>(
event: T,
listener: SessionEventListener<T>,
): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)?.add(listener as SessionEventListener<never>);

// Return unsubscribe function
return () => {
this.listeners
.get(event)
?.delete(listener as SessionEventListener<never>);
};
}

emit<T extends SessionEventType>(
event: T,
payload: SessionEventPayload[T],
): void {
this.listeners.get(event)?.forEach((listener) => {
listener(payload as never);
});
}
}

export const sessionEvents = new SessionEventEmitter();
11 changes: 11 additions & 0 deletions apps/array/src/renderer/lib/sounds.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions apps/array/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
/// <reference types="vite/client" />

declare module "*.mp3" {
const src: string;
export default src;
}

interface ImportMetaEnv {
readonly DEV: boolean;
readonly PROD: boolean;
Expand Down