From 23b943017ffcee690a67c8b2083e6aeac0f200e3 Mon Sep 17 00:00:00 2001 From: qer Date: Sun, 28 Jun 2026 19:24:36 +0800 Subject: [PATCH 1/5] feat(web): play a sound when a turn completes Synthesize a short chime when a session finishes a turn. Opt-in via Settings -> Notifications (off by default); the audio context is unlocked on the first user gesture so it also plays while the tab is backgrounded. --- .changeset/web-completion-sound.md | 5 + apps/kimi-web/src/App.vue | 2 + .../components/settings/SettingsDialog.vue | 16 ++ .../client/useSoundNotification.ts | 158 ++++++++++++++++++ .../src/composables/useKimiWebClient.ts | 10 ++ apps/kimi-web/src/debug/trace.ts | 15 ++ apps/kimi-web/src/i18n/locales/en/settings.ts | 1 + apps/kimi-web/src/i18n/locales/zh/settings.ts | 1 + apps/kimi-web/src/lib/storage.ts | 1 + apps/kimi-web/test/sound-notification.test.ts | 69 ++++++++ apps/kimi-web/test/storage-logic.test.ts | 1 + 11 files changed, 279 insertions(+) create mode 100644 .changeset/web-completion-sound.md create mode 100644 apps/kimi-web/src/composables/client/useSoundNotification.ts create mode 100644 apps/kimi-web/test/sound-notification.test.ts diff --git a/.changeset/web-completion-sound.md b/.changeset/web-completion-sound.md new file mode 100644 index 0000000000..946805a663 --- /dev/null +++ b/.changeset/web-completion-sound.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a completion sound to the web UI that plays when a turn finishes. Turn it on in Settings → Notifications. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index bec2ce4db6..4916b8a89f 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -821,6 +821,7 @@ function openPr(url: string): void { :account-model="client.defaultModel.value" :notify="client.notifyOnComplete.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 +831,7 @@ function openPr(url: string): void { @set-color-scheme="client.setColorScheme($event)" @set-ui-font-size="client.setUiFontSize($event)" @set-notify="client.setNotifyOnComplete($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..a822fd9e66 100644 --- a/apps/kimi-web/src/components/settings/SettingsDialog.vue +++ b/apps/kimi-web/src/components/settings/SettingsDialog.vue @@ -24,6 +24,8 @@ const props = defineProps<{ notify: 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 +43,7 @@ const emit = defineEmits<{ setColorScheme: [colorScheme: ColorScheme]; setUiFontSize: [size: number]; setNotify: [on: boolean]; + setSound: [on: boolean]; setBetaToc: [on: boolean]; login: []; logout: []; @@ -266,6 +269,19 @@ function setTab(tab: SettingsTab): void { +
+ {{ t('settings.soundOnComplete') }} + +
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..8c3d2f241c --- /dev/null +++ b/apps/kimi-web/src/composables/client/useSoundNotification.ts @@ -0,0 +1,158 @@ +// 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; + } + try { + if (ctx.state === 'suspended') { + // Last-ditch resume in case no gesture unlocked it yet (e.g. a session + // completed before the user interacted). Logs whether the browser allows it. + traceClientEvent('sound: context suspended at play time, attempting resume'); + void ctx.resume().then( + () => { + traceClientEvent('sound: resume resolved', { state: ctx.state }); + }, + (error) => { + traceClientEvent('sound: resume rejected', { error: String(error) }); + }, + ); + } + // 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(); +} + +export function useSoundNotification() { + return { + soundOnComplete, + setSoundOnComplete, + maybePlayCompletionSound, + }; +} diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index c5ffd1bc97..e9b072d57b 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, @@ -2088,6 +2090,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; @@ -2199,6 +2207,8 @@ export function useKimiWebClient() { notifyOnComplete: notification.notifyOnComplete, notifyPermission: notification.notifyPermission, setNotifyOnComplete: notification.setNotifyOnComplete, + 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..410c5c2dbd 100644 --- a/apps/kimi-web/src/i18n/locales/en/settings.ts +++ b/apps/kimi-web/src/i18n/locales/en/settings.ts @@ -10,6 +10,7 @@ export default { appearance: 'Appearance', notifications: 'Notifications', notifyOnComplete: 'Notify when a turn completes', + soundOnComplete: 'Play a sound when a turn completes', notifyDenied: 'Blocked in browser settings', notifyBody: 'Finished a turn', account: 'Account', diff --git a/apps/kimi-web/src/i18n/locales/zh/settings.ts b/apps/kimi-web/src/i18n/locales/zh/settings.ts index 194a45c324..ea5f930c24 100644 --- a/apps/kimi-web/src/i18n/locales/zh/settings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts @@ -10,6 +10,7 @@ export default { appearance: '外观', notifications: '通知', notifyOnComplete: '会话完成时通知', + soundOnComplete: '会话完成时播放提示音', notifyDenied: '已在浏览器设置中被阻止', notifyBody: '已完成一轮', account: '账户', diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts index aad5b2b86a..b2d39f4ba0 100644 --- a/apps/kimi-web/src/lib/storage.ts +++ b/apps/kimi-web/src/lib/storage.ts @@ -26,6 +26,7 @@ export const STORAGE_KEYS = { workspaceOrder: 'kimi-web.workspace-order', betaToc: 'kimi-web.beta-toc', notifyOnComplete: 'kimi-web.notify-on-complete', + soundOnComplete: 'kimi-web.sound-on-complete', inputHistory: 'kimi-web.input-history', // cross-file locale: 'kimi-locale', 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..549de029aa --- /dev/null +++ b/apps/kimi-web/test/sound-notification.test.ts @@ -0,0 +1,69 @@ +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 } = 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); + }); +}); diff --git a/apps/kimi-web/test/storage-logic.test.ts b/apps/kimi-web/test/storage-logic.test.ts index d9dbefb543..017a1a280e 100644 --- a/apps/kimi-web/test/storage-logic.test.ts +++ b/apps/kimi-web/test/storage-logic.test.ts @@ -138,6 +138,7 @@ 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.soundOnComplete).toBe('kimi-web.sound-on-complete'); expect(STORAGE_KEYS.locale).toBe('kimi-locale'); }); }); From a0e1c6df2d109098e614ba224e26740cf33db8ed Mon Sep 17 00:00:00 2001 From: qer Date: Sun, 28 Jun 2026 19:35:37 +0800 Subject: [PATCH 2/5] feat(web): notify and play a sound when a question needs an answer Reuse the existing notification/sound toggles so they also fire when the agent asks a question (the awaiting-answer state). Generalize the Settings labels to cover both cases. --- .changeset/web-completion-sound.md | 2 +- .../src/composables/client/useNotification.ts | 45 +++++++++++++------ .../client/useSoundNotification.ts | 8 ++++ .../src/composables/useKimiWebClient.ts | 31 +++++++++++++ apps/kimi-web/src/i18n/locales/en/settings.ts | 5 ++- apps/kimi-web/src/i18n/locales/zh/settings.ts | 5 ++- apps/kimi-web/test/sound-notification.test.ts | 8 +++- 7 files changed, 85 insertions(+), 19 deletions(-) diff --git a/.changeset/web-completion-sound.md b/.changeset/web-completion-sound.md index 946805a663..5bd6107eb0 100644 --- a/.changeset/web-completion-sound.md +++ b/.changeset/web-completion-sound.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add a completion sound to the web UI that plays when a turn finishes. Turn it on in Settings → Notifications. +Add a completion sound and question notifications to the web UI: a sound plays when a turn finishes, and a desktop notification plus sound fire when a question needs an answer. Turn them on in Settings → Notifications. diff --git a/apps/kimi-web/src/composables/client/useNotification.ts b/apps/kimi-web/src/composables/client/useNotification.ts index a25cad7448..542c8b0c58 100644 --- a/apps/kimi-web/src/composables/client/useNotification.ts +++ b/apps/kimi-web/src/composables/client/useNotification.ts @@ -1,9 +1,10 @@ // apps/kimi-web/src/composables/client/useNotification.ts -// Browser "turn completed" notification: the on/off preference (persisted) and +// Browser notifications for when the agent needs attention: a turn finished or +// a question is waiting for an answer. 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. +// via the ctx objects. import { ref } from 'vue'; import { i18n } from '../../i18n'; @@ -19,7 +20,7 @@ const notifyPermission = ref( typeof Notification !== 'undefined' ? Notification.permission : 'denied', ); -/** Enable/disable completion notifications. Enabling requests OS permission; +/** Enable/disable attention notifications. Enabling requests OS permission; if the user blocks it the preference stays off. */ async function setNotifyOnComplete(on: boolean): Promise { if (!on) { @@ -52,9 +53,16 @@ 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 { +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. `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(ctx: NotifyCompletionCtx, body: string, tag: string): void { if (!notifyOnComplete.value) return; if (typeof Notification === 'undefined') return; const perm = Notification.permission; @@ -63,21 +71,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 +97,25 @@ 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(ctx, i18n.global.t('settings.notifyBody'), `kimi-complete-${sid}`); +} + +/** Fire a notification when a session asks a question, but only when the + caller says the user isn't already looking at it. */ +function maybeNotifyQuestion(sid: string, ctx: NotifyQuestionCtx): void { + const body = ctx.questionPreview || i18n.global.t('settings.notifyQuestionBody'); + maybeNotify(ctx, body, `kimi-question-${sid}`); +} + export function useNotification() { return { notifyOnComplete, notifyPermission, setNotifyOnComplete, maybeNotifyCompletion, + maybeNotifyQuestion, }; } diff --git a/apps/kimi-web/src/composables/client/useSoundNotification.ts b/apps/kimi-web/src/composables/client/useSoundNotification.ts index 8c3d2f241c..265489c61a 100644 --- a/apps/kimi-web/src/composables/client/useSoundNotification.ts +++ b/apps/kimi-web/src/composables/client/useSoundNotification.ts @@ -149,10 +149,18 @@ function maybePlayCompletionSound(): void { 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 e9b072d57b..743b81b36c 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -761,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( @@ -2116,6 +2124,29 @@ function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void { } } +function onQuestionRequested(sid: string, question: AppQuestionRequest): void { + const first = question.questions[0]; + // Prefer the short header; fall back to the question text for the body. + const preview = (first?.header ?? first?.question ?? '').trim(); + + // 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 // --------------------------------------------------------------------------- diff --git a/apps/kimi-web/src/i18n/locales/en/settings.ts b/apps/kimi-web/src/i18n/locales/en/settings.ts index 410c5c2dbd..6081b8b1ca 100644 --- a/apps/kimi-web/src/i18n/locales/en/settings.ts +++ b/apps/kimi-web/src/i18n/locales/en/settings.ts @@ -9,10 +9,11 @@ export default { }, appearance: 'Appearance', notifications: 'Notifications', - notifyOnComplete: 'Notify when a turn completes', - soundOnComplete: 'Play a sound when a turn completes', + notifyOnComplete: 'Notify when a turn completes or 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 ea5f930c24..b2594fcbda 100644 --- a/apps/kimi-web/src/i18n/locales/zh/settings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts @@ -9,10 +9,11 @@ export default { }, appearance: '外观', notifications: '通知', - notifyOnComplete: '会话完成时通知', - soundOnComplete: '会话完成时播放提示音', + notifyOnComplete: '会话完成或待回答时通知', + soundOnComplete: '会话完成或待回答时播放提示音', notifyDenied: '已在浏览器设置中被阻止', notifyBody: '已完成一轮', + notifyQuestionBody: '有提问等待你回答', account: '账户', uiFontSize: '字体大小', agentDefaults: 'Agent 默认值', diff --git a/apps/kimi-web/test/sound-notification.test.ts b/apps/kimi-web/test/sound-notification.test.ts index 549de029aa..79eea7d272 100644 --- a/apps/kimi-web/test/sound-notification.test.ts +++ b/apps/kimi-web/test/sound-notification.test.ts @@ -35,7 +35,7 @@ function installStorage(storage: Storage): void { // Singleton — module-level ref + setter. Audio unlock/listeners are no-ops here // because the test env has no `window`. -const { soundOnComplete, setSoundOnComplete } = useSoundNotification(); +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; @@ -66,4 +66,10 @@ describe('useSoundNotification', () => { 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(); + }); }); From 85fd4682ccd48144fd15b566bae79600b194b1b8 Mon Sep 17 00:00:00 2001 From: qer Date: Sun, 28 Jun 2026 19:38:44 +0800 Subject: [PATCH 3/5] fix(web): don't queue the chime on a suspended audio context A suspended AudioContext has a frozen clock, so tones scheduled on it would play stale when the context later resumes (e.g. on the next click). Only schedule the chime when the context is actually running; if it is still suspended, try to unlock it for next time and skip this one. --- .../composables/client/useSoundNotification.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useSoundNotification.ts b/apps/kimi-web/src/composables/client/useSoundNotification.ts index 265489c61a..db58f6b646 100644 --- a/apps/kimi-web/src/composables/client/useSoundNotification.ts +++ b/apps/kimi-web/src/composables/client/useSoundNotification.ts @@ -117,20 +117,25 @@ function playChime(): void { traceClientEvent('sound: skipped, AudioContext unavailable'); return; } - try { + // 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') { - // Last-ditch resume in case no gesture unlocked it yet (e.g. a session - // completed before the user interacted). Logs whether the browser allows it. - traceClientEvent('sound: context suspended at play time, attempting resume'); void ctx.resume().then( () => { - traceClientEvent('sound: resume resolved', { state: ctx.state }); + 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); From 1ad2a1e496ca49204bc16068e1f780e182d24cae Mon Sep 17 00:00:00 2001 From: qer Date: Sun, 28 Jun 2026 20:09:02 +0800 Subject: [PATCH 4/5] fix(web): gate question notifications behind explicit opt-in Question notifications surface question text, so they must not fire for users who only opted into turn-completion alerts (which default on). Split question notifications into their own persisted preference that defaults off, with a separate Settings toggle. Completion notifications keep their existing default-on behavior. --- .changeset/web-completion-sound.md | 2 +- apps/kimi-web/src/App.vue | 2 + .../components/settings/SettingsDialog.vue | 20 +++++ .../src/composables/client/useNotification.ts | 70 +++++++++++------- .../src/composables/useKimiWebClient.ts | 2 + apps/kimi-web/src/i18n/locales/en/settings.ts | 3 +- apps/kimi-web/src/i18n/locales/zh/settings.ts | 3 +- apps/kimi-web/src/lib/storage.ts | 1 + apps/kimi-web/test/notification-logic.test.ts | 73 +++++++++++++++++++ apps/kimi-web/test/storage-logic.test.ts | 1 + 10 files changed, 148 insertions(+), 29 deletions(-) create mode 100644 apps/kimi-web/test/notification-logic.test.ts diff --git a/.changeset/web-completion-sound.md b/.changeset/web-completion-sound.md index 5bd6107eb0..db521bff0a 100644 --- a/.changeset/web-completion-sound.md +++ b/.changeset/web-completion-sound.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add a completion sound and question notifications to the web UI: a sound plays when a turn finishes, and a desktop notification plus sound fire when a question needs an answer. Turn them on in Settings → Notifications. +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 4916b8a89f..6b22e13092 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -820,6 +820,7 @@ 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" @@ -831,6 +832,7 @@ 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)" diff --git a/apps/kimi-web/src/components/settings/SettingsDialog.vue b/apps/kimi-web/src/components/settings/SettingsDialog.vue index a822fd9e66..9d5923111b 100644 --- a/apps/kimi-web/src/components/settings/SettingsDialog.vue +++ b/apps/kimi-web/src/components/settings/SettingsDialog.vue @@ -22,6 +22,8 @@ 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. */ @@ -43,6 +45,7 @@ const emit = defineEmits<{ setColorScheme: [colorScheme: ColorScheme]; setUiFontSize: [size: number]; setNotify: [on: boolean]; + setNotifyQuestion: [on: boolean]; setSound: [on: boolean]; setBetaToc: [on: boolean]; login: []; @@ -269,6 +272,23 @@ function setTab(tab: SettingsTab): void { +
+ + {{ t('settings.notifyOnQuestion') }} + {{ t('settings.notifyDenied') }} + + +
{{ t('settings.soundOnComplete') }}