feat(web): add opt-in notification sound for waiting-for-input (#1689)

* feat(web): add opt-in notification sound for waiting-for-input

Play a configurable per-device sound when a session starts waiting for
user input. Sounds are synthesized with the Web Audio API (no binary
assets), follow the same suppression rules as desktop notifications,
and work independently of the browser Notification permission.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): drop stale sound plays when AudioContext resume is delayed

Scheduling notes on a suspended context queued every pending alert and
played them stacked once the user finally interacted. Schedule only
after resume() fulfills and drop plays older than 2s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): harden notification sound playback edge cases

Catch AudioContext construction failures so a broken audio stack cannot
abort the WS handler before the desktop notification is shown, route
interrupted (iOS) contexts through the resume path, and coalesce plays
queued while the context is not running so only one pending cue fires.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): make sound test stubs constructible and refine resume queue

vi.fn with an arrow implementation is not constructible, so the
AudioContext stubs silently misbehaved on CI's Node runtime. Also keep
the newest pending play instead of dropping later requests, recheck the
preference before a resumed waiting-for-input cue fires, and validate
picker values with the existing type guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-07-15 10:27:23 +01:00
committed by GitHub
parent 1a1ece3a8d
commit c81b60a06d
6 changed files with 639 additions and 5 deletions
@@ -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<SoundPreferences>(getSoundPreferences);
const update = (next: SoundPreferences) => {
setPrefs(next);
setSoundPreferences(next);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-base font-medium">Notification Sound</div>
<p className="text-sm text-muted-foreground">
Play a sound on this device when an agent needs your input.
</p>
</div>
<Switch
checked={prefs.enabled}
onCheckedChange={(enabled) => update({ ...prefs, enabled })}
aria-label="Enable notification sound"
className="cursor-pointer"
/>
</div>
{prefs.enabled && (
<div className="flex items-center gap-2">
<Select
value={prefs.presetId}
onValueChange={(presetId) => {
if (!isSoundPresetId(presetId)) return;
update({ ...prefs, presetId });
playSoundPreset(presetId);
}}
>
<SelectTrigger className="w-44 cursor-pointer" aria-label="Notification sound">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOUND_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id} className="cursor-pointer">
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="cursor-pointer"
aria-label="Preview sound"
onClick={() => playSoundPreset(prefs.presetId)}
>
<IconVolume className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Preview sound</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
);
}
@@ -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}
/>
<Separator className="my-4" />
<NotificationSoundSection />
<Separator className="my-4" />
<ExternalProvidersSection
appriseAvailable={appriseAvailable}
appriseProviders={appriseProviders}
+269
View File
@@ -0,0 +1,269 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
function makeFakeAudioContext() {
const oscillator = {
type: "",
frequency: { value: 0 },
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
};
const gain = {
gain: {
setValueAtTime: vi.fn(),
linearRampToValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
};
const ctx = {
currentTime: 0,
state: "running",
destination: {},
resume: vi.fn().mockResolvedValue(undefined),
createOscillator: vi.fn(() => 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<void>((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);
});
});
+164
View File
@@ -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);
}
@@ -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<AppState> = {}) {
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<AppState>;
}
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<AppState>) {
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<AppState>);
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<AppState>);
getHandler(store)(makeMessage());
expect(playWaitingForInputSound).not.toHaveBeenCalled();
expect(notificationMock).not.toHaveBeenCalled();
});
});
+13 -5
View File
@@ -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<AppState>): 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<AppState>): 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);
},
};
}