feat(site): add completion chime for agent tasks (#22608)

This commit is contained in:
Danielle Maywood
2026-03-04 16:23:31 +00:00
committed by GitHub
parent 17d214b4a4
commit 474e80b646
6 changed files with 305 additions and 2 deletions
@@ -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<AgentDetailTopBarProps> = ({
)}
</DropdownMenuContent>
</DropdownMenu>
<WebPushButton />
<ChimeButton />
<WebPushButton />{" "}
</div>
<div className="flex items-center [&_span]:!rounded-full [&_span]:!size-8 [&_span]:!text-xs">
<UserDropdown
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getChimeEnabled,
maybePlayChime,
setChimeEnabled,
} from "./useAgentChime";
// ---------------------------------------------------------------------------
// Preference helpers
// ---------------------------------------------------------------------------
describe("getChimeEnabled / setChimeEnabled", () => {
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<typeof vi.fn>;
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();
});
});
@@ -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();
}
+25 -1
View File
@@ -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<TypesGen.Chat[]>(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(() => {
+41
View File
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="subtle"
size="icon"
onClick={handleClick}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
>
{enabled ? (
<Volume2Icon className="text-content-success" />
) : (
<VolumeOffIcon className="text-content-secondary" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{enabled ? "Disable completion sound" : "Enable completion sound"}
</TooltipContent>
</Tooltip>
);
};
Binary file not shown.