diff --git a/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx index 4b7280e6c4..5af5c7235c 100644 --- a/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx @@ -13,6 +13,7 @@ import { DurationField } from "./components/DurationField/DurationField"; import { SectionHeader } from "./components/SectionHeader"; import { TextPreviewDialog } from "./components/TextPreviewDialog"; import { UserCompactionThresholdSettings } from "./components/UserCompactionThresholdSettings"; +import { getKylesophyEnabled, setKylesophyEnabled } from "./utils/chime"; const textareaMaxHeight = 240; const textareaBaseClassName = @@ -124,6 +125,7 @@ export const AgentSettingsBehaviorPageView: FC< const [isUserPromptOverflowing, setIsUserPromptOverflowing] = useState(false); const [isSystemPromptOverflowing, setIsSystemPromptOverflowing] = useState(false); + const [kylesophyEnabled, setKylesophyLocal] = useState(getKylesophyEnabled); // ── Derived state ── const hasLoadedSystemPrompt = systemPromptData !== undefined; @@ -510,6 +512,26 @@ export const AgentSettingsBehaviorPageView: FC< )} +
+ {/* ── Kyleosophy toggle (always visible) ── */} +
+

+ Kyleosophy +

+
+

+ Replace the standard completion chime. IYKYK. +

+ { + setKylesophyEnabled(checked); + setKylesophyLocal(checked); + }} + aria-label="Enable Kyleosophy" + /> +
+
{showDefaultPromptPreview && ( { }); }); +// --------------------------------------------------------------------------- +// Kyleosophy preference helpers +// --------------------------------------------------------------------------- + +describe("getKylesophyEnabled / setKylesophyEnabled", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("defaults to false when nothing is stored", () => { + expect(getKylesophyEnabled()).toBe(false); + }); + + it("returns true when stored as 'true'", () => { + localStorage.setItem("agents.kyleosophy", "true"); + expect(getKylesophyEnabled()).toBe(true); + }); + + it("returns false when stored as 'false'", () => { + localStorage.setItem("agents.kyleosophy", "false"); + expect(getKylesophyEnabled()).toBe(false); + }); + + it("setKylesophyEnabled persists the value", () => { + setKylesophyEnabled(false); + expect(localStorage.getItem("agents.kyleosophy")).toBe("false"); + expect(getKylesophyEnabled()).toBe(false); + + setKylesophyEnabled(true); + expect(localStorage.getItem("agents.kyleosophy")).toBe("true"); + expect(getKylesophyEnabled()).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // maybePlayChime // --------------------------------------------------------------------------- @@ -235,4 +272,44 @@ describe("maybePlayChime", () => { // Should play immediately without needing to advance timers. expect(playSpy).toHaveBeenCalledTimes(1); }); + + // -- Kyleosophy sound selection -- + + it("uses a kyleosophy sound when kyleosophy is enabled", async () => { + setKylesophyEnabled(true); + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + // Pin the random selection so the test is deterministic. + vi.spyOn(Math, "random").mockReturnValue(0.5); + + const audioSpy = vi.spyOn(globalThis, "Audio" as never); + + await triggerAndSettle("running", "waiting", "chat-1", "chat-2"); + expect(playSpy).toHaveBeenCalledTimes(1); + expect(audioSpy).toHaveBeenCalledTimes(1); + + const url = (audioSpy as unknown as ReturnType).mock + .calls[0][0] as string; + // Math.floor(0.5 * 8) = 4 → "/chime_5.mp3" + expect(url).toBe("/chime_5.mp3"); + expect(KYLEOSOPHY_SOUNDS).toContain(url); + }); + + it("uses default chime.mp3 when kyleosophy is disabled", async () => { + setKylesophyEnabled(false); + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + + // Force a fresh Audio element by spying on the constructor + // before any call in this test. The previous test left + // lastSoundUrl pointing at a kyleosophy URL, so switching + // back to /chime.mp3 will always trigger a new Audio(). + const audioSpy = vi.spyOn(globalThis, "Audio" as never); + + await triggerAndSettle("running", "waiting", "chat-1", "chat-2"); + expect(playSpy).toHaveBeenCalledTimes(1); + expect(audioSpy).toHaveBeenCalledTimes(1); + + const url = (audioSpy as unknown as ReturnType).mock + .calls[0][0] as string; + expect(url).toBe("/chime.mp3"); + }); }); diff --git a/site/src/pages/AgentsPage/utils/chime.ts b/site/src/pages/AgentsPage/utils/chime.ts index 1e365b0b71..f0339055a0 100644 --- a/site/src/pages/AgentsPage/utils/chime.ts +++ b/site/src/pages/AgentsPage/utils/chime.ts @@ -1,4 +1,5 @@ const CHIME_PREFERENCE_KEY = "agents.chime-on-completion"; +const KYLEOSOPHY_PREFERENCE_KEY = "agents.kyleosophy"; export function getChimeEnabled(): boolean { try { @@ -19,20 +20,55 @@ export function setChimeEnabled(enabled: boolean): void { } } +export function getKylesophyEnabled(): boolean { + try { + const stored = localStorage.getItem(KYLEOSOPHY_PREFERENCE_KEY); + return stored === null ? false : stored === "true"; + } catch { + return false; + } +} + +export function setKylesophyEnabled(enabled: boolean): void { + try { + localStorage.setItem(KYLEOSOPHY_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. + * Alternative completion sounds for Kyleosophy mode. All are + * shipped as static assets alongside chime.mp3. + */ +export const KYLEOSOPHY_SOUNDS: readonly string[] = [ + "/chime_1.mp3", // absolutely massive + "/chime_2.mp3", // dope + "/chime_3.mp3", // great + "/chime_4.mp3", // oh god + "/chime_5.mp3", // okay + "/chime_6.mp3", // open up a pr + "/chime_7.mp3", // sweet + "/chime_8.mp3", // yep +]; + +/** + * Play a completion sound. When Kyleosophy is enabled a random + * voice clip is selected; otherwise the default bell chime is + * used. The Audio element is cached and reused when the sound + * URL hasn't changed between calls. */ let chimeAudio: HTMLAudioElement | null = null; +let lastSoundUrl: string | null = null; -function playChimeAudio(): void { +function playChimeAudio(soundUrl = "/chime.mp3"): void { try { - if (!chimeAudio) { - chimeAudio = new Audio("/chime.mp3"); + if (!chimeAudio || soundUrl !== lastSoundUrl) { + chimeAudio?.pause(); + chimeAudio = new Audio(soundUrl); chimeAudio.volume = 0.5; + lastSoundUrl = soundUrl; } // Reset to the start in case a previous play hasn't // finished yet. @@ -75,9 +111,9 @@ export const LOCK_HOLD_MS = 2000; * Falls back to playing immediately when the Web Locks API is * not available (preserving the original single-tab behavior). */ -function playChime(chatID: string): void { +function playChime(chatID: string, soundUrl?: string): void { if (typeof navigator === "undefined" || !navigator.locks) { - playChimeAudio(); + playChimeAudio(soundUrl); return; } @@ -93,7 +129,7 @@ function playChime(chatID: string): void { return; } - playChimeAudio(); + playChimeAudio(soundUrl); // Hold the lock briefly so that tabs receiving the // WebSocket event a bit later will see the lock as @@ -149,5 +185,9 @@ export function maybePlayChime( return; } - playChime(chatID); + const soundUrl = getKylesophyEnabled() + ? KYLEOSOPHY_SOUNDS[Math.floor(Math.random() * KYLEOSOPHY_SOUNDS.length)] + : undefined; + + playChime(chatID, soundUrl); } diff --git a/site/static/chime_1.mp3 b/site/static/chime_1.mp3 new file mode 100644 index 0000000000..67a11ec2ae Binary files /dev/null and b/site/static/chime_1.mp3 differ diff --git a/site/static/chime_2.mp3 b/site/static/chime_2.mp3 new file mode 100644 index 0000000000..fc0a277631 Binary files /dev/null and b/site/static/chime_2.mp3 differ diff --git a/site/static/chime_3.mp3 b/site/static/chime_3.mp3 new file mode 100644 index 0000000000..578215df88 Binary files /dev/null and b/site/static/chime_3.mp3 differ diff --git a/site/static/chime_4.mp3 b/site/static/chime_4.mp3 new file mode 100644 index 0000000000..455f668e67 Binary files /dev/null and b/site/static/chime_4.mp3 differ diff --git a/site/static/chime_5.mp3 b/site/static/chime_5.mp3 new file mode 100644 index 0000000000..abeb3453c6 Binary files /dev/null and b/site/static/chime_5.mp3 differ diff --git a/site/static/chime_6.mp3 b/site/static/chime_6.mp3 new file mode 100644 index 0000000000..719d51ad00 Binary files /dev/null and b/site/static/chime_6.mp3 differ diff --git a/site/static/chime_7.mp3 b/site/static/chime_7.mp3 new file mode 100644 index 0000000000..b3bb8267a3 Binary files /dev/null and b/site/static/chime_7.mp3 differ diff --git a/site/static/chime_8.mp3 b/site/static/chime_8.mp3 new file mode 100644 index 0000000000..a32a482013 Binary files /dev/null and b/site/static/chime_8.mp3 differ