diff --git a/apps/web/components/settings/notification-sound-section.tsx b/apps/web/components/settings/notification-sound-section.tsx new file mode 100644 index 0000000000..05e183773b --- /dev/null +++ b/apps/web/components/settings/notification-sound-section.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; +import { IconVolume } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Switch } from "@kandev/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { + getSoundPreferences, + isSoundPresetId, + playSoundPreset, + setSoundPreferences, + SOUND_PRESETS, + type SoundPreferences, +} from "@/lib/notifications/sound"; + +export function NotificationSoundSection() { + const [prefs, setPrefs] = useState(getSoundPreferences); + + const update = (next: SoundPreferences) => { + setPrefs(next); + setSoundPreferences(next); + }; + + return ( +
+
+
+
Notification Sound
+

+ Play a sound on this device when an agent needs your input. +

+
+ update({ ...prefs, enabled })} + aria-label="Enable notification sound" + className="cursor-pointer" + /> +
+ {prefs.enabled && ( +
+ + + + + + + Preview sound + + +
+ )} +
+ ); +} diff --git a/apps/web/components/settings/notifications-settings.tsx b/apps/web/components/settings/notifications-settings.tsx index 802707f069..f5e280d8c5 100644 --- a/apps/web/components/settings/notifications-settings.tsx +++ b/apps/web/components/settings/notifications-settings.tsx @@ -8,6 +8,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover- import { Input } from "@kandev/ui/input"; import { Separator } from "@kandev/ui/separator"; import { Textarea } from "@kandev/ui/textarea"; +import { NotificationSoundSection } from "@/components/settings/notification-sound-section"; import { SettingsPageTemplate } from "@/components/settings/settings-page-template"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { DEFAULT_NOTIFICATION_EVENTS, EVENT_LABELS } from "@/lib/notifications/events"; @@ -497,6 +498,8 @@ export function NotificationsSettings() { onTestNotification={actions.handleTestNotification} /> + + oscillator), + createGain: vi.fn(() => gain), + }; + return { ctx, oscillator, gain }; +} + +async function loadSoundModule() { + return import("./sound"); +} + +// The stub is invoked with `new`, so the implementation must be a real +// `function` — an arrow implementation is not constructible. +function stubAudioContextCtor(implementation: () => unknown) { + const ctor = vi.fn(function (this: unknown) { + return implementation(); + }); + vi.stubGlobal("AudioContext", ctor); + return ctor; +} + +describe("sound preferences", () => { + beforeEach(() => { + vi.resetModules(); + window.localStorage.clear(); + }); + + it("defaults to disabled with the plim preset", async () => { + const { getSoundPreferences } = await loadSoundModule(); + expect(getSoundPreferences()).toEqual({ enabled: false, presetId: "plim" }); + }); + + it("round-trips preferences through localStorage", async () => { + const { getSoundPreferences, setSoundPreferences } = await loadSoundModule(); + setSoundPreferences({ enabled: true, presetId: "chime" }); + expect(getSoundPreferences()).toEqual({ enabled: true, presetId: "chime" }); + }); + + it("falls back to the default preset for unknown stored ids", async () => { + const { getSoundPreferences } = await loadSoundModule(); + window.localStorage.setItem( + "kandev.notifications.sound", + JSON.stringify({ enabled: true, presetId: "airhorn" }), + ); + expect(getSoundPreferences()).toEqual({ enabled: true, presetId: "plim" }); + }); + + it("ignores corrupted stored values", async () => { + const { getSoundPreferences } = await loadSoundModule(); + window.localStorage.setItem("kandev.notifications.sound", "not-json"); + expect(getSoundPreferences()).toEqual({ enabled: false, presetId: "plim" }); + }); +}); + +describe("playSoundPreset", () => { + beforeEach(() => { + vi.resetModules(); + window.localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("schedules one oscillator per note of the preset", async () => { + const { ctx } = makeFakeAudioContext(); + stubAudioContextCtor(() => ctx); + const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule(); + + playSoundPreset("chime"); + + const chime = SOUND_PRESETS.find((p) => p.id === "chime")!; + expect(ctx.createOscillator).toHaveBeenCalledTimes(chime.notes.length); + }); + + it("falls back to the first preset for unknown ids", async () => { + const { ctx } = makeFakeAudioContext(); + stubAudioContextCtor(() => ctx); + const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule(); + + playSoundPreset("nope"); + + expect(ctx.createOscillator).toHaveBeenCalledTimes(SOUND_PRESETS[0].notes.length); + }); + + it("does not throw when AudioContext construction fails", async () => { + stubAudioContextCtor(() => { + throw new Error("audio stack broken"); + }); + const { playSoundPreset } = await loadSoundModule(); + + expect(() => playSoundPreset("plim")).not.toThrow(); + }); + + it("reuses a single AudioContext across plays", async () => { + const { ctx } = makeFakeAudioContext(); + const ctor = stubAudioContextCtor(() => ctx); + const { playSoundPreset } = await loadSoundModule(); + + playSoundPreset("plim"); + playSoundPreset("ding"); + + expect(ctor).toHaveBeenCalledTimes(1); + }); + + it("is a no-op when AudioContext is unavailable", async () => { + const { playSoundPreset } = await loadSoundModule(); + expect(() => playSoundPreset("plim")).not.toThrow(); + }); +}); + +describe("playSoundPreset with a non-running context", () => { + beforeEach(() => { + vi.resetModules(); + window.localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("plays after a suspended context resumes promptly", async () => { + const { ctx } = makeFakeAudioContext(); + ctx.state = "suspended"; + stubAudioContextCtor(() => ctx); + const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule(); + + playSoundPreset("plim"); + expect(ctx.resume).toHaveBeenCalled(); + expect(ctx.createOscillator).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const plim = SOUND_PRESETS.find((p) => p.id === "plim")!; + expect(ctx.createOscillator).toHaveBeenCalledTimes(plim.notes.length); + }); + + it("drops the play when a suspended context resumes too late", async () => { + const { ctx } = makeFakeAudioContext(); + ctx.state = "suspended"; + let resolveResume!: () => void; + ctx.resume = vi.fn(() => new Promise((resolve) => (resolveResume = resolve))); + stubAudioContextCtor(() => ctx); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(0); + const { playSoundPreset } = await loadSoundModule(); + + playSoundPreset("plim"); + nowSpy.mockReturnValue(60_000); + resolveResume(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(ctx.createOscillator).not.toHaveBeenCalled(); + }); + + it("coalesces plays queued while the context is not running", async () => { + const { ctx } = makeFakeAudioContext(); + ctx.state = "suspended"; + stubAudioContextCtor(() => ctx); + const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule(); + + playSoundPreset("plim"); + playSoundPreset("plim"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(ctx.resume).toHaveBeenCalledTimes(1); + const plim = SOUND_PRESETS.find((p) => p.id === "plim")!; + expect(ctx.createOscillator).toHaveBeenCalledTimes(plim.notes.length); + }); + + it("keeps only the newest request while a resume is in flight", async () => { + const { ctx } = makeFakeAudioContext(); + ctx.state = "suspended"; + stubAudioContextCtor(() => ctx); + const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule(); + + playSoundPreset("plim"); + playSoundPreset("ding"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(ctx.resume).toHaveBeenCalledTimes(1); + const ding = SOUND_PRESETS.find((p) => p.id === "ding")!; + expect(ctx.createOscillator).toHaveBeenCalledTimes(ding.notes.length); + }); + + it("resumes an interrupted context before playing", async () => { + const { ctx } = makeFakeAudioContext(); + ctx.state = "interrupted"; + stubAudioContextCtor(() => ctx); + const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule(); + + playSoundPreset("ding"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const ding = SOUND_PRESETS.find((p) => p.id === "ding")!; + expect(ctx.createOscillator).toHaveBeenCalledTimes(ding.notes.length); + }); +}); + +describe("playWaitingForInputSound", () => { + beforeEach(() => { + vi.resetModules(); + window.localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not play a resumed cue when sound was disabled while waiting", async () => { + const { ctx } = makeFakeAudioContext(); + ctx.state = "suspended"; + stubAudioContextCtor(() => ctx); + const { playWaitingForInputSound, setSoundPreferences } = await loadSoundModule(); + setSoundPreferences({ enabled: true, presetId: "plim" }); + + playWaitingForInputSound(); + setSoundPreferences({ enabled: false, presetId: "plim" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(ctx.resume).toHaveBeenCalled(); + expect(ctx.createOscillator).not.toHaveBeenCalled(); + }); + + it("does not play when sound is disabled (default)", async () => { + const { ctx } = makeFakeAudioContext(); + stubAudioContextCtor(() => ctx); + const { playWaitingForInputSound } = await loadSoundModule(); + + playWaitingForInputSound(); + + expect(ctx.createOscillator).not.toHaveBeenCalled(); + }); + + it("plays the configured preset when enabled", async () => { + const { ctx } = makeFakeAudioContext(); + stubAudioContextCtor(() => ctx); + const { playWaitingForInputSound, setSoundPreferences, SOUND_PRESETS } = + await loadSoundModule(); + setSoundPreferences({ enabled: true, presetId: "ding" }); + + playWaitingForInputSound(); + + const ding = SOUND_PRESETS.find((p) => p.id === "ding")!; + expect(ctx.createOscillator).toHaveBeenCalledTimes(ding.notes.length); + }); +}); diff --git a/apps/web/lib/notifications/sound.ts b/apps/web/lib/notifications/sound.ts new file mode 100644 index 0000000000..0af2ccd489 --- /dev/null +++ b/apps/web/lib/notifications/sound.ts @@ -0,0 +1,164 @@ +import { getLocalStorage, setLocalStorage } from "@/lib/local-storage"; + +// Sounds are synthesized with the Web Audio API instead of shipping binary +// audio assets: presets stay tiny, diffable, and testable in jsdom. +export type SoundNote = { + frequency: number; + startMs: number; + durationMs: number; +}; + +export type SoundPresetId = "plim" | "chime" | "ding" | "pop"; + +export type SoundPreset = { + id: SoundPresetId; + label: string; + notes: SoundNote[]; +}; + +export const SOUND_PRESETS: SoundPreset[] = [ + { + id: "plim", + label: "Plim", + notes: [ + { frequency: 880, startMs: 0, durationMs: 220 }, + { frequency: 1318.51, startMs: 90, durationMs: 420 }, + ], + }, + { + id: "chime", + label: "Chime", + notes: [ + { frequency: 523.25, startMs: 0, durationMs: 260 }, + { frequency: 659.25, startMs: 110, durationMs: 260 }, + { frequency: 783.99, startMs: 220, durationMs: 460 }, + ], + }, + { + id: "ding", + label: "Ding", + notes: [{ frequency: 987.77, startMs: 0, durationMs: 600 }], + }, + { + id: "pop", + label: "Pop", + notes: [{ frequency: 329.63, startMs: 0, durationMs: 140 }], + }, +]; + +export const DEFAULT_SOUND_PRESET_ID: SoundPresetId = "plim"; + +export type SoundPreferences = { + enabled: boolean; + presetId: SoundPresetId; +}; + +const SOUND_PREFS_KEY = "kandev.notifications.sound"; + +export function isSoundPresetId(value: unknown): value is SoundPresetId { + return SOUND_PRESETS.some((preset) => preset.id === value); +} + +export function getSoundPreferences(): SoundPreferences { + const raw = getLocalStorage<{ enabled?: boolean; presetId?: string } | null>( + SOUND_PREFS_KEY, + null, + ); + return { + enabled: raw?.enabled === true, + presetId: isSoundPresetId(raw?.presetId) ? raw.presetId : DEFAULT_SOUND_PRESET_ID, + }; +} + +export function setSoundPreferences(prefs: SoundPreferences): void { + setLocalStorage(SOUND_PREFS_KEY, prefs); +} + +const PEAK_GAIN = 0.12; +const ATTACK_SECONDS = 0.01; + +let sharedContext: AudioContext | null = null; + +function getSharedAudioContext(): AudioContext | null { + if (typeof window === "undefined" || typeof window.AudioContext !== "function") return null; + try { + sharedContext ??= new window.AudioContext(); + } catch { + return null; + } + return sharedContext; +} + +function scheduleNote(ctx: AudioContext, note: SoundNote): void { + const start = ctx.currentTime + note.startMs / 1000; + const end = start + note.durationMs / 1000; + const oscillator = ctx.createOscillator(); + const gain = ctx.createGain(); + oscillator.type = "sine"; + oscillator.frequency.value = note.frequency; + gain.gain.setValueAtTime(0, start); + gain.gain.linearRampToValueAtTime(PEAK_GAIN, start + ATTACK_SECONDS); + gain.gain.exponentialRampToValueAtTime(0.001, end); + oscillator.connect(gain); + gain.connect(ctx.destination); + oscillator.start(start); + oscillator.stop(end); +} + +// A suspended context (autoplay policy, no user gesture yet) resumes only when the +// user eventually interacts. Scheduling before resume() fulfills would queue every +// pending alert and play them stacked at that later moment — so at most one play is +// kept pending (newer requests replace it), scheduled after resume() fulfills, and +// dropped as stale if that takes longer than this. +const STALE_PLAY_TIMEOUT_MS = 2000; + +type PendingPlay = { preset: SoundPreset; requestedAt: number; stillWanted: () => boolean }; + +let pendingPlay: PendingPlay | null = null; + +function schedulePreset(ctx: AudioContext, preset: SoundPreset): void { + try { + for (const note of preset.notes) scheduleNote(ctx, note); + } catch { + // Ignore playback failures. + } +} + +/** Best-effort playback: autoplay policy or a broken audio stack must never break the app. */ +export function playSoundPreset(presetId: string, stillWanted: () => boolean = () => true): void { + const preset = SOUND_PRESETS.find((p) => p.id === presetId) ?? SOUND_PRESETS[0]; + const ctx = getSharedAudioContext(); + if (!ctx) return; + if (ctx.state === "running") { + schedulePreset(ctx, preset); + return; + } + // Suspended (autoplay policy) or interrupted (iOS backgrounding): resume first. + const resumeInFlight = pendingPlay !== null; + pendingPlay = { preset, requestedAt: Date.now(), stillWanted }; + if (resumeInFlight) return; + try { + ctx + .resume() + .then(() => { + const pending = pendingPlay; + pendingPlay = null; + if (!pending) return; + if (Date.now() - pending.requestedAt < STALE_PLAY_TIMEOUT_MS && pending.stillWanted()) { + schedulePreset(ctx, pending.preset); + } + }) + .catch(() => { + pendingPlay = null; + }); + } catch { + pendingPlay = null; + } +} + +/** Play the configured sound if the user opted in on this device. */ +export function playWaitingForInputSound(): void { + const prefs = getSoundPreferences(); + if (!prefs.enabled) return; + playSoundPreset(prefs.presetId, () => getSoundPreferences().enabled); +} diff --git a/apps/web/lib/ws/handlers/notifications.test.ts b/apps/web/lib/ws/handlers/notifications.test.ts new file mode 100644 index 0000000000..e6d04bf972 --- /dev/null +++ b/apps/web/lib/ws/handlers/notifications.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import type { BackendMessageMap } from "@/lib/types/backend"; +import { registerNotificationsHandlers } from "./notifications"; + +vi.mock("@/lib/notifications/sound", () => ({ + playWaitingForInputSound: vi.fn(), +})); + +import { playWaitingForInputSound } from "@/lib/notifications/sound"; + +const TASK_ID = "task-1"; +const SESSION_ID = "session-1"; +const OTHER_TASK_ID = "task-2"; + +function makeStore(overrides: Partial = {}) { + const state = { + turns: { bySession: { [SESSION_ID]: [{ id: "turn-1" }] } }, + tasks: { activeTaskId: OTHER_TASK_ID }, + ...overrides, + } as unknown as AppState; + + return { getState: () => state } as unknown as StoreApi; +} + +function makeMessage(): BackendMessageMap["session.waiting_for_input"] { + return { + id: "message-1", + type: "notification", + action: "session.waiting_for_input", + payload: { + task_id: TASK_ID, + session_id: SESSION_ID, + title: "Task needs your input", + body: "An agent is waiting for your input.", + }, + }; +} + +function getHandler(store: StoreApi) { + return registerNotificationsHandlers(store)["session.waiting_for_input"]!; +} + +describe("session.waiting_for_input handler", () => { + const notificationMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal( + "Notification", + Object.assign(notificationMock, { permission: "granted" as NotificationPermission }), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("plays the sound and shows a notification when not suppressed", () => { + getHandler(makeStore())(makeMessage()); + + expect(playWaitingForInputSound).toHaveBeenCalledTimes(1); + expect(notificationMock).toHaveBeenCalledWith("Task needs your input", { + body: "An agent is waiting for your input.", + }); + }); + + it("plays the sound even when notification permission is not granted", () => { + vi.stubGlobal( + "Notification", + Object.assign(notificationMock, { permission: "denied" as NotificationPermission }), + ); + + getHandler(makeStore())(makeMessage()); + + expect(playWaitingForInputSound).toHaveBeenCalledTimes(1); + expect(notificationMock).not.toHaveBeenCalled(); + }); + + it("plays the sound when the Notification API is unavailable", () => { + vi.unstubAllGlobals(); + vi.stubGlobal("Notification", undefined); + + getHandler(makeStore())(makeMessage()); + + expect(playWaitingForInputSound).toHaveBeenCalledTimes(1); + }); + + it("suppresses both channels while the session has no completed turns", () => { + const store = makeStore({ turns: { bySession: {} } } as unknown as Partial); + + getHandler(store)(makeMessage()); + + expect(playWaitingForInputSound).not.toHaveBeenCalled(); + expect(notificationMock).not.toHaveBeenCalled(); + }); + + it("suppresses both channels while the user is viewing the task", () => { + const store = makeStore({ tasks: { activeTaskId: TASK_ID } } as unknown as Partial); + + getHandler(store)(makeMessage()); + + expect(playWaitingForInputSound).not.toHaveBeenCalled(); + expect(notificationMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/lib/ws/handlers/notifications.ts b/apps/web/lib/ws/handlers/notifications.ts index db44dea60a..9c422eb9f6 100644 --- a/apps/web/lib/ws/handlers/notifications.ts +++ b/apps/web/lib/ws/handlers/notifications.ts @@ -1,6 +1,8 @@ import type { StoreApi } from "zustand"; import { NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT } from "@/lib/notifications/events"; +import { playWaitingForInputSound } from "@/lib/notifications/sound"; import type { AppState } from "@/lib/state/store"; +import type { TaskSessionWaitingForInputPayload } from "@/lib/types/backend"; import type { WsHandlers } from "@/lib/ws/handlers/types"; /** Check whether the notification should be suppressed. */ @@ -21,11 +23,17 @@ function shouldSuppressNotification( return null; } +/** Show the desktop notification when the browser permission allows it. */ +function showDesktopNotification(payload: TaskSessionWaitingForInputPayload): void { + if (typeof Notification === "undefined" || Notification.permission !== "granted") return; + const title = payload.title || "Task needs your input"; + const body = payload.body || "An agent is waiting for your input."; + new Notification(title, { body }); +} + export function registerNotificationsHandlers(store: StoreApi): WsHandlers { return { [NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT]: (message) => { - if (typeof Notification === "undefined" || Notification.permission !== "granted") return; - const sessionId = message.payload?.session_id as string | undefined; const taskId = message.payload?.task_id as string | undefined; const state = store.getState(); @@ -33,9 +41,9 @@ export function registerNotificationsHandlers(store: StoreApi): WsHand const reason = shouldSuppressNotification(state, taskId, sessionId); if (reason) return; - const title = message.payload.title || "Task needs your input"; - const body = message.payload.body || "An agent is waiting for your input."; - new Notification(title, { body }); + // Sound is its own opt-in channel — it plays regardless of Notification permission. + playWaitingForInputSound(); + showDesktopNotification(message.payload); }, }; }