diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx index f3afc97b21..6538a04af1 100644 --- a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx @@ -29,6 +29,7 @@ import { useDashboard } from "modules/dashboard/useDashboard"; import type { FC } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; +import { ChimeButton } from "../ChimeButton"; import { WebPushButton } from "../WebPushButton"; interface DiffStatsBadgeProps { @@ -265,7 +266,8 @@ export const AgentDetailTopBar: FC = ({ )} - + + {" "}
{ + beforeEach(() => { + localStorage.clear(); + }); + + it("defaults to true when nothing is stored", () => { + expect(getChimeEnabled()).toBe(true); + }); + + it("returns true when stored as 'true'", () => { + localStorage.setItem("agents.chime-on-completion", "true"); + expect(getChimeEnabled()).toBe(true); + }); + + it("returns false when stored as 'false'", () => { + localStorage.setItem("agents.chime-on-completion", "false"); + expect(getChimeEnabled()).toBe(false); + }); + + it("setChimeEnabled persists the value", () => { + setChimeEnabled(false); + expect(localStorage.getItem("agents.chime-on-completion")).toBe("false"); + expect(getChimeEnabled()).toBe(false); + + setChimeEnabled(true); + expect(localStorage.getItem("agents.chime-on-completion")).toBe("true"); + expect(getChimeEnabled()).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// maybePlayChime +// --------------------------------------------------------------------------- + +describe("maybePlayChime", () => { + let playSpy: ReturnType; + + beforeEach(() => { + localStorage.clear(); + playSpy = vi + .spyOn(HTMLMediaElement.prototype, "play") + .mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // -- Chime SHOULD play -- + + it("chimes on running → waiting when viewing a different chat", () => { + vi.spyOn(document, "hidden", "get").mockReturnValue(false); + maybePlayChime("running", "waiting", "chat-1", "chat-2"); + expect(playSpy).toHaveBeenCalledTimes(1); + }); + + it("chimes on running → pending when viewing a different chat", () => { + vi.spyOn(document, "hidden", "get").mockReturnValue(false); + maybePlayChime("running", "pending", "chat-1", "chat-2"); + expect(playSpy).toHaveBeenCalledTimes(1); + }); + + it("chimes on pending → waiting (watchChats skips running)", () => { + vi.spyOn(document, "hidden", "get").mockReturnValue(false); + maybePlayChime("pending", "waiting", "chat-1", "chat-2"); + expect(playSpy).toHaveBeenCalledTimes(1); + }); + + it("chimes on running → waiting when tab is hidden (same chat)", () => { + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + maybePlayChime("running", "waiting", "chat-1", "chat-1"); + expect(playSpy).toHaveBeenCalledTimes(1); + }); + + it("chimes on running → waiting when tab is hidden (no active chat)", () => { + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + maybePlayChime("running", "waiting", "chat-1", undefined); + expect(playSpy).toHaveBeenCalledTimes(1); + }); + + // -- Chime should NOT play -- + + it("does NOT chime when viewing the finishing chat on a visible tab", () => { + vi.spyOn(document, "hidden", "get").mockReturnValue(false); + maybePlayChime("running", "waiting", "chat-1", "chat-1"); + expect(playSpy).not.toHaveBeenCalled(); + }); + + it("does NOT chime when preference is disabled", () => { + setChimeEnabled(false); + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + maybePlayChime("running", "waiting", "chat-1", "chat-2"); + expect(playSpy).not.toHaveBeenCalled(); + }); + + it("does NOT chime on running → error", () => { + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + maybePlayChime("running", "error", "chat-1", "chat-2"); + expect(playSpy).not.toHaveBeenCalled(); + }); + + it("does NOT chime on waiting → running (wrong direction)", () => { + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + maybePlayChime("waiting", "running", "chat-1", "chat-2"); + expect(playSpy).not.toHaveBeenCalled(); + }); + + it("does NOT chime when previous status is undefined", () => { + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + maybePlayChime(undefined, "waiting", "chat-1", "chat-2"); + expect(playSpy).not.toHaveBeenCalled(); + }); + + it("does NOT chime when status has not changed", () => { + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + maybePlayChime("running", "running", "chat-1", "chat-2"); + expect(playSpy).not.toHaveBeenCalled(); + }); + + it("does NOT chime on error → waiting", () => { + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + maybePlayChime("error", "waiting", "chat-1", "chat-2"); + expect(playSpy).not.toHaveBeenCalled(); + }); + + it("does NOT chime on pending → pending (no change)", () => { + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + maybePlayChime("pending", "pending", "chat-1", "chat-2"); + expect(playSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/site/src/pages/AgentsPage/AgentDetail/useAgentChime.ts b/site/src/pages/AgentsPage/AgentDetail/useAgentChime.ts new file mode 100644 index 0000000000..c2339c13d0 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/useAgentChime.ts @@ -0,0 +1,94 @@ +const CHIME_PREFERENCE_KEY = "agents.chime-on-completion"; + +export function getChimeEnabled(): boolean { + try { + const stored = localStorage.getItem(CHIME_PREFERENCE_KEY); + // Default to enabled when no preference has been saved. + return stored === null ? true : stored === "true"; + } catch { + return true; + } +} + +export function setChimeEnabled(enabled: boolean): void { + try { + localStorage.setItem(CHIME_PREFERENCE_KEY, String(enabled)); + } catch { + // Silently ignore storage errors (e.g. private browsing + // quota exceeded). + } +} + +/** + * Play the completion chime audio file. The file is a short, + * warm two-tone bell sound shipped as a static asset. + * + * A single Audio element is reused across calls so the browser + * only fetches the file once. + */ +let chimeAudio: HTMLAudioElement | null = null; + +function playChime(): void { + try { + if (!chimeAudio) { + chimeAudio = new Audio("/chime.wav"); + chimeAudio.volume = 0.5; + } + // Reset to the start in case a previous play hasn't + // finished yet. + chimeAudio.currentTime = 0; + void chimeAudio.play(); + } catch { + // Silently ignore playback errors (e.g. autoplay policy + // blocks, missing file, etc.). + } +} + +/** + * Check whether a chat status transition should trigger a chime + * and play it if so. A chime fires when a chat reaches a + * terminal state ("waiting" or "error") from a non-terminal + * state, meaning the agent just finished work. The previous + * status may be "running" (seen via the per-chat WebSocket) or + * "pending" (when only the watchChats WebSocket is active and + * the intermediate "running" status was never pushed to the + * chat list). The chime is suppressed when the chat is + * currently visible to the user. + */ +export function maybePlayChime( + prevStatus: string | undefined, + nextStatus: string, + chatID: string, + activeChatID: string | undefined, +): void { + if (prevStatus === nextStatus) { + return; + } + + // Terminal states that indicate the agent finished. + const isTerminal = nextStatus === "waiting" || nextStatus === "pending"; + if (!isTerminal) { + return; + } + + // Only chime when transitioning from a non-terminal state. + // "running" is the expected previous state, but "pending" can + // appear when the watchChats WebSocket skips the intermediate + // "running" status (it only publishes the final state change). + const wasActive = prevStatus === "running" || prevStatus === "pending"; + if (!wasActive) { + return; + } + + // Skip when the user is looking at this exact chat. + const isViewingThisChat = !document.hidden && chatID === activeChatID; + if (isViewingThisChat) { + return; + } + + if (!getChimeEnabled()) { + return; + } + + playChime(); +} diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 529fab36c8..9b8955e790 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -45,6 +45,7 @@ import { toast } from "sonner"; import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; import { AgentChatInput } from "./AgentChatInput"; +import { maybePlayChime } from "./AgentDetail/useAgentChime"; import { AgentsSidebar } from "./AgentsSidebar"; import { ConfigureAgentsDialog } from "./ConfigureAgentsDialog"; import { @@ -353,6 +354,12 @@ const AgentsPage: FC = () => { navigate("/agents"); }; + // Track the active chat ID in a ref so the watchChats + // WebSocket handler can read it without re-subscribing on + // every navigation. + const activeChatIDRef = useRef(agentId); + activeChatIDRef.current = agentId; + useEffect(() => { const ws = watchChats(); ws.addEventListener("open", () => { @@ -375,6 +382,21 @@ const AgentsPage: FC = () => { const chatEvent = sse.data; const updatedChat = chatEvent.chat; + // Read the previous status from the query cache, which + // is synchronously updated by both the per-chat WebSocket + // (via updateSidebarChat) and this handler. This avoids + // the async-lag of a useEffect-based status map. + const currentChats = queryClient.getQueryData(chatsKey); + const prevStatus = currentChats?.find( + (c) => c.id === updatedChat.id, + )?.status; + maybePlayChime( + prevStatus, + updatedChat.status, + updatedChat.id, + activeChatIDRef.current, + ); + if (chatEvent.kind === "deleted") { queryClient.setQueryData( chatsKey, @@ -447,7 +469,9 @@ const AgentsPage: FC = () => { }, ); }); - return () => ws.close(); + return () => { + ws.close(); + }; }, [queryClient]); useEffect(() => { diff --git a/site/src/pages/AgentsPage/ChimeButton.tsx b/site/src/pages/AgentsPage/ChimeButton.tsx new file mode 100644 index 0000000000..982d18fca1 --- /dev/null +++ b/site/src/pages/AgentsPage/ChimeButton.tsx @@ -0,0 +1,41 @@ +import { Button } from "components/Button/Button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { Volume2Icon, VolumeOffIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { getChimeEnabled, setChimeEnabled } from "./AgentDetail/useAgentChime"; + +export const ChimeButton: FC = () => { + const [enabled, setEnabled] = useState(getChimeEnabled); + + const handleClick = () => { + const next = !enabled; + setEnabled(next); + setChimeEnabled(next); + }; + + return ( + + + + + + {enabled ? "Disable completion sound" : "Enable completion sound"} + + + ); +}; diff --git a/site/static/chime.wav b/site/static/chime.wav new file mode 100644 index 0000000000..0e74704bce Binary files /dev/null and b/site/static/chime.wav differ