Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-completion-sound.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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(); }"
Expand Down
36 changes: 36 additions & 0 deletions apps/kimi-web/src/components/settings/SettingsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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: [];
Expand Down Expand Up @@ -266,6 +272,36 @@ function setTab(tab: SettingsTab): void {
<span class="knob" />
</button>
</div>
<div class="row">
<span class="rlabel">
{{ t('settings.notifyOnQuestion') }}
<span v-if="notifyPermission === 'denied'" class="hint">{{ t('settings.notifyDenied') }}</span>
</span>
<button
type="button"
class="switch"
role="switch"
:class="{ on: notifyQuestion }"
:aria-checked="notifyQuestion"
:disabled="notifyPermission === 'denied'"
@click="emit('setNotifyQuestion', !notifyQuestion)"
>
<span class="knob" />
</button>
</div>
<div class="row">
<span class="rlabel">{{ t('settings.soundOnComplete') }}</span>
<button
type="button"
class="switch"
role="switch"
:class="{ on: sound }"
:aria-checked="sound"
@click="emit('setSound', !sound)"
>
<span class="knob" />
</button>
</div>
</section>

<section class="sec">
Expand Down
93 changes: 65 additions & 28 deletions apps/kimi-web/src/composables/client/useNotification.ts
Original file line number Diff line number Diff line change
@@ -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<string>(
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<void> {
/** Shared setter: disabling is instant; enabling requests OS permission first
and stays off if the user blocks it. */
async function setNotifyPref(pref: Ref<boolean>, key: string, on: boolean): Promise<void> {
if (!on) {
notifyOnComplete.value = false;
safeSetString(STORAGE_KEYS.notifyOnComplete, '0');
pref.value = false;
safeSetString(key, '0');
return;
}
if (typeof Notification === 'undefined') return;
Expand All @@ -38,8 +45,18 @@ async function setNotifyOnComplete(on: boolean): Promise<void> {
}
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<void> {
return setNotifyPref(notifyOnComplete, STORAGE_KEYS.notifyOnComplete, on);
}

/** Enable/disable question (needs-answer) notifications. Off by default. */
function setNotifyOnQuestion(on: boolean): Promise<void> {
return setNotifyPref(notifyOnQuestion, STORAGE_KEYS.notifyOnQuestion, on);
}

export interface NotifyCompletionCtx {
Expand All @@ -52,32 +69,36 @@ 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;
if (perm === 'default') {
// 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();
Expand All @@ -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,
};
}
Loading
Loading