diff --git a/apps/kimi-web/src/composables/client/useNotification.ts b/apps/kimi-web/src/composables/client/useNotification.ts
index a25cad7448..29b6b3034d 100644
--- a/apps/kimi-web/src/composables/client/useNotification.ts
+++ b/apps/kimi-web/src/composables/client/useNotification.ts
@@ -1,30 +1,37 @@
// apps/kimi-web/src/composables/client/useNotification.ts
-// Browser "turn completed" notification: the on/off preference (persisted) and
-// the OS permission + Notification API. Pure UI action module — it never reads
-// rawState or calls the API. The rawState-dependent bits (is the session active
-// & visible, its title, the click-to-select action) are passed in by the caller
-// via NotifyCompletionCtx.
+// Browser notifications for when the agent needs attention: a turn finished or
+// a question is waiting for an answer. Each kind has its own on/off preference
+// (persisted) plus the shared OS permission + Notification API. Pure UI action
+// module — it never reads rawState or calls the API. The rawState-dependent
+// bits (is the session active & visible, its title, the click-to-select action)
+// are passed in by the caller via the ctx objects.
+//
+// Why two preferences: completion notifications default on (existing behavior),
+// but question notifications surface question text and default OFF, so an
+// existing user who only opted into completion alerts doesn't start receiving
+// question content on their desktop without explicitly opting in.
-import { ref } from 'vue';
+import { ref, type Ref } from 'vue';
import { i18n } from '../../i18n';
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';
-function loadNotify(): boolean {
- const v = safeGetString(STORAGE_KEYS.notifyOnComplete);
- return v === null ? true : v === '1';
+function loadNotify(key: string, defaultOn: boolean): boolean {
+ const v = safeGetString(key);
+ return v === null ? defaultOn : v === '1';
}
-const notifyOnComplete = ref(loadNotify());
+const notifyOnComplete = ref(loadNotify(STORAGE_KEYS.notifyOnComplete, true));
+const notifyOnQuestion = ref(loadNotify(STORAGE_KEYS.notifyOnQuestion, false));
const notifyPermission = ref(
typeof Notification !== 'undefined' ? Notification.permission : 'denied',
);
-/** Enable/disable completion notifications. Enabling requests OS permission;
- if the user blocks it the preference stays off. */
-async function setNotifyOnComplete(on: boolean): Promise {
+/** Shared setter: disabling is instant; enabling requests OS permission first
+ and stays off if the user blocks it. */
+async function setNotifyPref(pref: Ref, key: string, on: boolean): Promise {
if (!on) {
- notifyOnComplete.value = false;
- safeSetString(STORAGE_KEYS.notifyOnComplete, '0');
+ pref.value = false;
+ safeSetString(key, '0');
return;
}
if (typeof Notification === 'undefined') return;
@@ -38,8 +45,18 @@ async function setNotifyOnComplete(on: boolean): Promise {
}
notifyPermission.value = perm;
if (perm !== 'granted') return; // blocked — leave the toggle off
- notifyOnComplete.value = true;
- safeSetString(STORAGE_KEYS.notifyOnComplete, '1');
+ pref.value = true;
+ safeSetString(key, '1');
+}
+
+/** Enable/disable turn-completion notifications. */
+function setNotifyOnComplete(on: boolean): Promise {
+ return setNotifyPref(notifyOnComplete, STORAGE_KEYS.notifyOnComplete, on);
+}
+
+/** Enable/disable question (needs-answer) notifications. Off by default. */
+function setNotifyOnQuestion(on: boolean): Promise {
+ return setNotifyPref(notifyOnQuestion, STORAGE_KEYS.notifyOnQuestion, on);
}
export interface NotifyCompletionCtx {
@@ -52,10 +69,17 @@ export interface NotifyCompletionCtx {
onClick: () => void;
}
-/** Fire a completion notification for a finished session, but only when the
- caller says the user isn't already looking at it. */
-function maybeNotifyCompletion(sid: string, ctx: NotifyCompletionCtx): void {
- if (!notifyOnComplete.value) return;
+export interface NotifyQuestionCtx extends NotifyCompletionCtx {
+ /** Short preview of the question, used as the notification body. Falls back
+ to a generic line when empty. */
+ questionPreview: string;
+}
+
+/** Shared permission gate + fire. `enabled` is the caller's per-kind preference;
+ `body` and `tag` let each kind carry its own text and a per-kind dedup tag
+ so a completion and a question don't collapse into one notification. */
+function maybeNotify(enabled: boolean, ctx: NotifyCompletionCtx, body: string, tag: string): void {
+ if (!enabled) return;
if (typeof Notification === 'undefined') return;
const perm = Notification.permission;
if (perm === 'denied') return;
@@ -63,21 +87,18 @@ function maybeNotifyCompletion(sid: string, ctx: NotifyCompletionCtx): void {
// Request permission asynchronously; if granted, fire the notification.
void Notification.requestPermission().then((p) => {
notifyPermission.value = p;
- if (p === 'granted') fire(sid, ctx);
+ if (p === 'granted') fire(ctx, body, tag);
});
return;
}
- fire(sid, ctx);
+ fire(ctx, body, tag);
}
-function fire(sid: string, ctx: NotifyCompletionCtx): void {
+function fire(ctx: NotifyCompletionCtx, body: string, tag: string): void {
if (ctx.isActiveAndVisible) return;
const title = ctx.sessionTitle.trim() || 'Kimi Code';
try {
- const n = new Notification(title, {
- body: i18n.global.t('settings.notifyBody'),
- tag: `kimi-complete-${sid}`,
- });
+ const n = new Notification(title, { body, tag });
n.onclick = () => {
try {
window.focus();
@@ -92,11 +113,27 @@ function fire(sid: string, ctx: NotifyCompletionCtx): void {
}
}
+/** Fire a completion notification for a finished session, but only when the
+ caller says the user isn't already looking at it. */
+function maybeNotifyCompletion(sid: string, ctx: NotifyCompletionCtx): void {
+ maybeNotify(notifyOnComplete.value, ctx, i18n.global.t('settings.notifyBody'), `kimi-complete-${sid}`);
+}
+
+/** Fire a notification when a session asks a question, but only when the user
+ explicitly opted into question notifications and isn't already looking. */
+function maybeNotifyQuestion(sid: string, ctx: NotifyQuestionCtx): void {
+ const body = ctx.questionPreview || i18n.global.t('settings.notifyQuestionBody');
+ maybeNotify(notifyOnQuestion.value, ctx, body, `kimi-question-${sid}`);
+}
+
export function useNotification() {
return {
notifyOnComplete,
+ notifyOnQuestion,
notifyPermission,
setNotifyOnComplete,
+ setNotifyOnQuestion,
maybeNotifyCompletion,
+ maybeNotifyQuestion,
};
}
diff --git a/apps/kimi-web/src/composables/client/useSoundNotification.ts b/apps/kimi-web/src/composables/client/useSoundNotification.ts
new file mode 100644
index 0000000000..db58f6b646
--- /dev/null
+++ b/apps/kimi-web/src/composables/client/useSoundNotification.ts
@@ -0,0 +1,171 @@
+// apps/kimi-web/src/composables/client/useSoundNotification.ts
+// Browser "turn completed" sound: a persisted on/off preference plus a short
+// chime synthesized with the WebAudio API (no audio asset, no permission
+// prompt). Pure UI action module — it never reads rawState or calls the API.
+//
+// Why the eager "unlock": the sound is most useful when the tab is in the
+// background (so you hear it while doing something else). But an AudioContext
+// created/resumed outside a user gesture is left suspended by the browser's
+// autoplay policy, and a suspended context in a background tab stays silent.
+// So we create + resume the context on the first user gesture (and again when
+// the toggle is switched on, which is itself a gesture). Once running, the
+// context keeps producing sound even when the tab is later backgrounded.
+//
+// Diagnostics: with tracing on (?debug=1) the key steps are recorded into the
+// troubleshooting log (Settings → Advanced → Export log), so a user can report
+// exactly why a sound did or didn't play.
+
+import { ref } from 'vue';
+import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';
+import { traceClientEvent } from '../../debug/trace';
+
+function loadSound(): boolean {
+ // Off by default — a completion sound is easy to opt into via Settings, and
+ // an unexpected chime is more surprising than a missing one.
+ return safeGetString(STORAGE_KEYS.soundOnComplete) === '1';
+}
+
+const soundOnComplete = ref(loadSound());
+
+type AudioContextCtor = new () => AudioContext;
+
+function getAudioContextCtor(): AudioContextCtor | undefined {
+ if (typeof window === 'undefined') return undefined;
+ const w = window as Window & { webkitAudioContext?: AudioContextCtor };
+ return window.AudioContext ?? w.webkitAudioContext;
+}
+
+let audioCtx: AudioContext | null = null;
+
+function getAudioContext(): AudioContext | null {
+ const Ctor = getAudioContextCtor();
+ if (!Ctor) return null;
+ if (audioCtx === null) {
+ try {
+ audioCtx = new Ctor();
+ } catch {
+ return null;
+ }
+ }
+ return audioCtx;
+}
+
+/** Create/resume the AudioContext. Must be called from (or after) a user
+ gesture for the browser's autoplay policy to allow it. No-op when the
+ preference is off or audio is unavailable. */
+function ensureAudioUnlocked(): void {
+ if (!soundOnComplete.value) return;
+ const ctx = getAudioContext();
+ if (ctx === null) return;
+ if (ctx.state === 'suspended') {
+ void ctx.resume().then(
+ () => {
+ traceClientEvent('sound: audio context resumed', { state: ctx.state });
+ },
+ (error) => {
+ traceClientEvent('sound: audio context resume rejected', { error: String(error) });
+ },
+ );
+ }
+}
+
+let unlockInstalled = false;
+
+/** Register once: on the first pointer/key gesture, unlock audio so a later
+ completion (even in a background tab) can play. */
+function installGestureUnlock(): void {
+ if (unlockInstalled || typeof window === 'undefined') return;
+ unlockInstalled = true;
+ const handler = (): void => {
+ ensureAudioUnlocked();
+ };
+ // capture so we still run if a component calls stopPropagation.
+ window.addEventListener('pointerdown', handler, { capture: true });
+ window.addEventListener('keydown', handler, { capture: true });
+}
+
+installGestureUnlock();
+
+/** Enable/disable the completion sound. Persisted across reloads. Enabling also
+ unlocks audio immediately, because the toggle click is a user gesture. */
+function setSoundOnComplete(on: boolean): void {
+ soundOnComplete.value = on;
+ safeSetString(STORAGE_KEYS.soundOnComplete, on ? '1' : '0');
+ if (on) ensureAudioUnlocked();
+}
+
+function tone(ctx: AudioContext, freq: number, start: number, duration: number, peak: number): void {
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.type = 'sine';
+ osc.frequency.value = freq;
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+ const t0 = ctx.currentTime + start;
+ // Exponential ramps can't target 0, so use a tiny floor to fade in/out
+ // without the click you get from an abrupt start/stop.
+ gain.gain.setValueAtTime(0.0001, t0);
+ gain.gain.exponentialRampToValueAtTime(peak, t0 + 0.01);
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration);
+ osc.start(t0);
+ osc.stop(t0 + duration + 0.02);
+}
+
+function playChime(): void {
+ const ctx = getAudioContext();
+ if (ctx === null) {
+ traceClientEvent('sound: skipped, AudioContext unavailable');
+ return;
+ }
+ // Never queue tones on a suspended context: its clock is frozen, so a chime
+ // scheduled now would play stale when the context later resumes (e.g. on the
+ // next click) rather than at completion time. If it isn't running yet, try to
+ // unlock it for next time and skip this one.
+ if (ctx.state !== 'running') {
+ traceClientEvent('sound: skipped, context not running', { state: ctx.state });
+ if (ctx.state === 'suspended') {
+ void ctx.resume().then(
+ () => {
+ traceClientEvent('sound: context resumed for next time', { state: ctx.state });
+ },
+ (error) => {
+ traceClientEvent('sound: resume rejected', { error: String(error) });
+ },
+ );
+ }
+ return;
+ }
+ try {
+ // A short two-note "ding": a soft lower note followed by a brighter one.
+ tone(ctx, 880, 0, 0.16, 0.18);
+ tone(ctx, 1320, 0.1, 0.22, 0.16);
+ traceClientEvent('sound: chime scheduled', { state: ctx.state });
+ } catch (error) {
+ traceClientEvent('sound: failed to play', { error: String(error) });
+ }
+}
+
+/** Play the completion sound for a finished session, whenever the preference
+ is on. We intentionally do NOT suppress it while the tab is visible: a
+ completion sound is only useful if it also reaches a backgrounded tab, and
+ users who don't want it can turn the toggle off. */
+function maybePlayCompletionSound(): void {
+ if (!soundOnComplete.value) return;
+ playChime();
+}
+
+/** Play the attention sound when a session asks a question, whenever the
+ preference is on. Same chime as completion: it means "the agent needs you". */
+function maybePlayQuestionSound(): void {
+ if (!soundOnComplete.value) return;
+ playChime();
+}
+
+export function useSoundNotification() {
+ return {
+ soundOnComplete,
+ setSoundOnComplete,
+ maybePlayCompletionSound,
+ maybePlayQuestionSound,
+ };
+}
diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts
index c5ffd1bc97..e3833cd32d 100644
--- a/apps/kimi-web/src/composables/useKimiWebClient.ts
+++ b/apps/kimi-web/src/composables/useKimiWebClient.ts
@@ -21,6 +21,7 @@ import {
import { createEventBatcher, isRenderEvent } from './client/eventBatcher';
import { useAppearance } from './client/useAppearance';
import { useNotification } from './client/useNotification';
+import { useSoundNotification } from './client/useSoundNotification';
import { useTaskPoller } from './client/useTaskPoller';
import { useModelProviderState } from './client/useModelProviderState';
import { useSideChat } from './client/useSideChat';
@@ -28,6 +29,7 @@ import { useWorkspaceState } from './client/useWorkspaceState';
const appearance = useAppearance();
const notification = useNotification();
+const sound = useSoundNotification();
import type {
AppEvent,
AppApprovalRequest,
@@ -759,6 +761,14 @@ function processEvent(appEvent: AppEvent, meta: { sessionId: string; seq: number
) {
onSessionIdle(appEvent.sessionId, appEvent.status);
}
+
+ // The agent asked a question and is waiting for an answer — surface it so
+ // the user comes back. Hooked on the request event (fires once per new
+ // question, and not for questions restored from a snapshot) rather than the
+ // awaitingQuestion status flip, which can arrive in any order relative to it.
+ if (appEvent.type === 'questionRequested') {
+ onQuestionRequested(appEvent.sessionId, appEvent.question);
+ }
}
const enqueueEvent = createEventBatcher(
@@ -2088,6 +2098,12 @@ function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void {
},
});
+ // Completion sound — only for real completions (aborted/cancelled turns stay
+ // silent). Plays regardless of visibility so it also reaches a backgrounded tab.
+ if (status === 'idle') {
+ sound.maybePlayCompletionSound();
+ }
+
const queue = rawState.queuedBySession[sid] ?? [];
if (queue.length === 0) return;
@@ -2108,6 +2124,34 @@ function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void {
}
}
+function onQuestionRequested(sid: string, question: AppQuestionRequest): void {
+ const first = question.questions[0];
+ // Lead with the actionable question text; keep the short header as context
+ // when both are present so the desktop notification actually says what is
+ // being asked (e.g. "Storage: Which database?").
+ const header = first?.header?.trim() ?? '';
+ const questionText = first?.question?.trim() ?? '';
+ const preview =
+ header && questionText ? `${header}: ${questionText}` : questionText || header;
+
+ // Browser notification when the user isn't watching this session.
+ notification.maybeNotifyQuestion(sid, {
+ isActiveAndVisible:
+ sid === rawState.activeSessionId &&
+ typeof document !== 'undefined' &&
+ document.visibilityState === 'visible',
+ sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
+ questionPreview: preview,
+ onClick: () => {
+ void workspaceState.selectSession(sid);
+ },
+ });
+
+ // Attention sound — plays regardless of visibility so it also reaches a
+ // backgrounded tab (same as the completion sound).
+ sound.maybePlayQuestionSound();
+}
+
// ---------------------------------------------------------------------------
// Composable return
// ---------------------------------------------------------------------------
@@ -2197,8 +2241,12 @@ export function useKimiWebClient() {
accent: appearance.accent,
setAccent: appearance.setAccent,
notifyOnComplete: notification.notifyOnComplete,
+ notifyOnQuestion: notification.notifyOnQuestion,
notifyPermission: notification.notifyPermission,
setNotifyOnComplete: notification.setNotifyOnComplete,
+ setNotifyOnQuestion: notification.setNotifyOnQuestion,
+ soundOnComplete: sound.soundOnComplete,
+ setSoundOnComplete: sound.setSoundOnComplete,
onboarded,
setOnboarded,
diff --git a/apps/kimi-web/src/debug/trace.ts b/apps/kimi-web/src/debug/trace.ts
index 925dc70fd4..faca04c5ce 100644
--- a/apps/kimi-web/src/debug/trace.ts
+++ b/apps/kimi-web/src/debug/trace.ts
@@ -324,6 +324,21 @@ function traceClientLog(level: ClientLogLevel, label: string, detail?: unknown):
});
}
+/** Record a client-side diagnostic event (e.g. a feature's internal state, such
+ as audio playback) into the troubleshooting log. No-op unless tracing is
+ enabled (?debug=1 or the debug localStorage flag), so production use pays
+ only a boolean check. Prefer this over raw console.* for diagnostics that
+ should surface in the exported log. */
+export function traceClientEvent(label: string, detail?: unknown): void {
+ if (!isTraceEnabled()) return;
+ push({
+ source: 'client',
+ kind: 'client:event',
+ label: `· ${label}`,
+ detail: detailOf(detail),
+ });
+}
+
let clientCaptureInstalled = false;
/** Wire up window error + console.error/warn capture into the trace buffer. */
diff --git a/apps/kimi-web/src/i18n/locales/en/settings.ts b/apps/kimi-web/src/i18n/locales/en/settings.ts
index d806314c38..bccf9447db 100644
--- a/apps/kimi-web/src/i18n/locales/en/settings.ts
+++ b/apps/kimi-web/src/i18n/locales/en/settings.ts
@@ -10,8 +10,11 @@ export default {
appearance: 'Appearance',
notifications: 'Notifications',
notifyOnComplete: 'Notify when a turn completes',
+ notifyOnQuestion: 'Notify when a question needs an answer',
+ soundOnComplete: 'Play a sound when a turn completes or needs an answer',
notifyDenied: 'Blocked in browser settings',
notifyBody: 'Finished a turn',
+ notifyQuestionBody: 'A question is waiting for your answer',
account: 'Account',
uiFontSize: 'Font size',
agentDefaults: 'Agent defaults',
diff --git a/apps/kimi-web/src/i18n/locales/zh/settings.ts b/apps/kimi-web/src/i18n/locales/zh/settings.ts
index 194a45c324..9947d739c1 100644
--- a/apps/kimi-web/src/i18n/locales/zh/settings.ts
+++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts
@@ -10,8 +10,11 @@ export default {
appearance: '外观',
notifications: '通知',
notifyOnComplete: '会话完成时通知',
+ notifyOnQuestion: '待回答时通知',
+ soundOnComplete: '会话完成或待回答时播放提示音',
notifyDenied: '已在浏览器设置中被阻止',
notifyBody: '已完成一轮',
+ notifyQuestionBody: '有提问等待你回答',
account: '账户',
uiFontSize: '字体大小',
agentDefaults: 'Agent 默认值',
diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts
index aad5b2b86a..cd7556b59c 100644
--- a/apps/kimi-web/src/lib/storage.ts
+++ b/apps/kimi-web/src/lib/storage.ts
@@ -26,6 +26,8 @@ export const STORAGE_KEYS = {
workspaceOrder: 'kimi-web.workspace-order',
betaToc: 'kimi-web.beta-toc',
notifyOnComplete: 'kimi-web.notify-on-complete',
+ notifyOnQuestion: 'kimi-web.notify-on-question',
+ soundOnComplete: 'kimi-web.sound-on-complete',
inputHistory: 'kimi-web.input-history',
// cross-file
locale: 'kimi-locale',
diff --git a/apps/kimi-web/test/notification-logic.test.ts b/apps/kimi-web/test/notification-logic.test.ts
new file mode 100644
index 0000000000..73a8619f6d
--- /dev/null
+++ b/apps/kimi-web/test/notification-logic.test.ts
@@ -0,0 +1,73 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { STORAGE_KEYS, safeGetString } from '../src/lib/storage';
+import { useNotification } from '../src/composables/client/useNotification';
+
+function createMemoryStorage(): Storage {
+ const data = new Map();
+ return {
+ get length() {
+ return data.size;
+ },
+ clear() {
+ data.clear();
+ },
+ getItem(key: string) {
+ return data.get(key) ?? null;
+ },
+ key(index: number) {
+ return Array.from(data.keys()).at(index) ?? null;
+ },
+ removeItem(key: string) {
+ data.delete(key);
+ },
+ setItem(key: string, value: string) {
+ data.set(key, value);
+ },
+ };
+}
+
+function installStorage(storage: Storage): void {
+ Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ value: storage,
+ });
+}
+
+// Singleton — module-level refs + setters. The OS Notification API is absent in
+// the test env, so the *enable* path is a no-op; the disable path and the
+// load-from-storage defaults are what we exercise here.
+const { notifyOnComplete, notifyOnQuestion, setNotifyOnComplete, setNotifyOnQuestion } = useNotification();
+// Captured at import (before beforeEach touches the refs), so these reflect the
+// load-from-storage defaults when nothing has been stored yet.
+const importedCompleteDefault = notifyOnComplete.value;
+const importedQuestionDefault = notifyOnQuestion.value;
+
+describe('useNotification preferences', () => {
+ beforeEach(() => {
+ installStorage(createMemoryStorage());
+ });
+
+ afterEach(() => {
+ installStorage(createMemoryStorage());
+ });
+
+ it('completion notifications default to on', () => {
+ expect(importedCompleteDefault).toBe(true);
+ });
+
+ it('question notifications default to off so question text stays behind an explicit opt-in', () => {
+ expect(importedQuestionDefault).toBe(false);
+ });
+
+ it('disabling question notifications persists "0" and updates the ref', () => {
+ void setNotifyOnQuestion(false);
+ expect(notifyOnQuestion.value).toBe(false);
+ expect(safeGetString(STORAGE_KEYS.notifyOnQuestion)).toBe('0');
+ });
+
+ it('disabling completion notifications persists "0" and updates the ref', () => {
+ void setNotifyOnComplete(false);
+ expect(notifyOnComplete.value).toBe(false);
+ expect(safeGetString(STORAGE_KEYS.notifyOnComplete)).toBe('0');
+ });
+});
diff --git a/apps/kimi-web/test/sound-notification.test.ts b/apps/kimi-web/test/sound-notification.test.ts
new file mode 100644
index 0000000000..79eea7d272
--- /dev/null
+++ b/apps/kimi-web/test/sound-notification.test.ts
@@ -0,0 +1,75 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { STORAGE_KEYS, safeGetString } from '../src/lib/storage';
+import { useSoundNotification } from '../src/composables/client/useSoundNotification';
+
+function createMemoryStorage(): Storage {
+ const data = new Map();
+ return {
+ get length() {
+ return data.size;
+ },
+ clear() {
+ data.clear();
+ },
+ getItem(key: string) {
+ return data.get(key) ?? null;
+ },
+ key(index: number) {
+ return Array.from(data.keys()).at(index) ?? null;
+ },
+ removeItem(key: string) {
+ data.delete(key);
+ },
+ setItem(key: string, value: string) {
+ data.set(key, value);
+ },
+ };
+}
+
+function installStorage(storage: Storage): void {
+ Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ value: storage,
+ });
+}
+
+// Singleton — module-level ref + setter. Audio unlock/listeners are no-ops here
+// because the test env has no `window`.
+const { soundOnComplete, setSoundOnComplete, maybePlayQuestionSound } = useSoundNotification();
+// Captured at import (before beforeEach resets the ref), so this reflects the
+// load-from-storage default when nothing has been stored yet.
+const importedDefault = soundOnComplete.value;
+
+describe('useSoundNotification', () => {
+ beforeEach(() => {
+ installStorage(createMemoryStorage());
+ setSoundOnComplete(true); // reset the shared singleton to a known state
+ });
+
+ afterEach(() => {
+ installStorage(createMemoryStorage());
+ });
+
+ it('persists "0" and updates the ref when disabled', () => {
+ setSoundOnComplete(false);
+ expect(soundOnComplete.value).toBe(false);
+ expect(safeGetString(STORAGE_KEYS.soundOnComplete)).toBe('0');
+ });
+
+ it('persists "1" and updates the ref when re-enabled', () => {
+ setSoundOnComplete(false);
+ setSoundOnComplete(true);
+ expect(soundOnComplete.value).toBe(true);
+ expect(safeGetString(STORAGE_KEYS.soundOnComplete)).toBe('1');
+ });
+
+ it('defaults to off when nothing is stored', () => {
+ expect(importedDefault).toBe(false);
+ });
+
+ it('maybePlayQuestionSound is a no-op without throwing when audio is unavailable', () => {
+ expect(() => {
+ maybePlayQuestionSound();
+ }).not.toThrow();
+ });
+});
diff --git a/apps/kimi-web/test/storage-logic.test.ts b/apps/kimi-web/test/storage-logic.test.ts
index d9dbefb543..6dc7dab751 100644
--- a/apps/kimi-web/test/storage-logic.test.ts
+++ b/apps/kimi-web/test/storage-logic.test.ts
@@ -138,6 +138,8 @@ describe('STORAGE_KEYS', () => {
expect(STORAGE_KEYS.theme).toBe('kimi-web.theme');
expect(STORAGE_KEYS.activeWorkspace).toBe('kimi-active-workspace');
expect(STORAGE_KEYS.notifyOnComplete).toBe('kimi-web.notify-on-complete');
+ expect(STORAGE_KEYS.notifyOnQuestion).toBe('kimi-web.notify-on-question');
+ expect(STORAGE_KEYS.soundOnComplete).toBe('kimi-web.sound-on-complete');
expect(STORAGE_KEYS.locale).toBe('kimi-locale');
});
});