diff --git a/.changeset/web-completion-sound.md b/.changeset/web-completion-sound.md new file mode 100644 index 0000000000..db521bff0a --- /dev/null +++ b/.changeset/web-completion-sound.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a completion sound and question notifications to the web UI, with separate Settings toggles for completion notifications, question notifications, and sound. Question notifications default off so question text only reaches your desktop after you opt in. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index bec2ce4db6..6b22e13092 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -820,7 +820,9 @@ function openPr(url: string): void { :auth-ready="client.authReady.value" :account-model="client.defaultModel.value" :notify="client.notifyOnComplete.value" + :notify-question="client.notifyOnQuestion.value" :notify-permission="client.notifyPermission.value" + :sound="client.soundOnComplete.value" :beta-toc="client.betaToc.value" :config="client.config.value" :models="client.models.value" @@ -830,6 +832,8 @@ function openPr(url: string): void { @set-color-scheme="client.setColorScheme($event)" @set-ui-font-size="client.setUiFontSize($event)" @set-notify="client.setNotifyOnComplete($event)" + @set-notify-question="client.setNotifyOnQuestion($event)" + @set-sound="client.setSoundOnComplete($event)" @set-beta-toc="client.setBetaToc($event)" @update-config="handleUpdateConfig($event)" @login="() => { showSettings = false; openLogin(); }" diff --git a/apps/kimi-web/src/components/settings/SettingsDialog.vue b/apps/kimi-web/src/components/settings/SettingsDialog.vue index 61167e2767..9d5923111b 100644 --- a/apps/kimi-web/src/components/settings/SettingsDialog.vue +++ b/apps/kimi-web/src/components/settings/SettingsDialog.vue @@ -22,8 +22,12 @@ const props = defineProps<{ accountModel?: string | null; /** Browser-notification-on-completion preference. */ notify: boolean; + /** Browser-notification-on-question (needs answer) preference. */ + notifyQuestion: boolean; /** OS permission state ('default' | 'granted' | 'denied') for the hint. */ notifyPermission?: string; + /** Play-a-sound-on-completion preference. */ + sound: boolean; /** Beta conversation TOC (proportional, viewport, hover tooltip). */ betaToc?: boolean; /** Global daemon config from GET /api/v1/config. Secrets are redacted server-side. */ @@ -41,6 +45,8 @@ const emit = defineEmits<{ setColorScheme: [colorScheme: ColorScheme]; setUiFontSize: [size: number]; setNotify: [on: boolean]; + setNotifyQuestion: [on: boolean]; + setSound: [on: boolean]; setBetaToc: [on: boolean]; login: []; logout: []; @@ -266,6 +272,36 @@ function setTab(tab: SettingsTab): void { +
+ + {{ t('settings.notifyOnQuestion') }} + {{ t('settings.notifyDenied') }} + + +
+
+ {{ t('settings.soundOnComplete') }} + +
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'); }); });