mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add completion chime for agent tasks (#22608)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user