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-approval-notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Add notifications when a tool needs approval, and improve notification reliability.
2 changes: 2 additions & 0 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,7 @@ function openPr(url: string): void {
:account-model="client.defaultModel.value"
:notify="client.notifyOnComplete.value"
:notify-question="client.notifyOnQuestion.value"
:notify-approval="client.notifyOnApproval.value"
:notify-permission="client.notifyPermission.value"
:sound="client.soundOnComplete.value"
:conversation-toc="client.conversationToc.value"
Expand All @@ -910,6 +911,7 @@ function openPr(url: string): void {
@set-ui-font-size="client.setUiFontSize($event)"
@set-notify="client.setNotifyOnComplete($event)"
@set-notify-question="client.setNotifyOnQuestion($event)"
@set-notify-approval="client.setNotifyOnApproval($event)"
@set-sound="client.setSoundOnComplete($event)"
@set-conversation-toc="client.setConversationToc($event)"
@update-config="handleUpdateConfig($event)"
Expand Down
15 changes: 15 additions & 0 deletions apps/kimi-web/src/components/settings/SettingsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const props = defineProps<{
notify: boolean;
/** Browser-notification-on-question (needs answer) preference. */
notifyQuestion: boolean;
/** Browser-notification-on-approval preference. */
notifyApproval: boolean;
/** OS permission state ('default' | 'granted' | 'denied') for the hint. */
notifyPermission?: string;
/** Play-a-sound-on-completion preference. */
Expand All @@ -54,6 +56,7 @@ const emit = defineEmits<{
setUiFontSize: [size: number];
setNotify: [on: boolean];
setNotifyQuestion: [on: boolean];
setNotifyApproval: [on: boolean];
setSound: [on: boolean];
setConversationToc: [on: boolean];
login: [];
Expand Down Expand Up @@ -417,6 +420,18 @@ function archiveTime(iso: string): string {
@update:model-value="emit('setNotifyQuestion', $event)"
/>
</div>
<div class="row">
<span class="rlabel">
{{ t('settings.notifyOnApproval') }}
<span v-if="notifyPermission === 'denied'" class="hint">{{ t('settings.notifyDenied') }}</span>
</span>
<Switch
:model-value="notifyApproval"
:disabled="notifyPermission === 'denied'"
:label="t('settings.notifyOnApproval')"
@update:model-value="emit('setNotifyApproval', $event)"
/>
</div>
<div class="row">
<span class="rlabel">{{ t('settings.soundOnComplete') }}</span>
<Switch
Expand Down
114 changes: 90 additions & 24 deletions apps/kimi-web/src/composables/client/useNotification.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
// apps/kimi-web/src/composables/client/useNotification.ts
// 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.
// Browser notifications for when the agent needs attention: a turn finished, a
// question waiting for an answer, or a tool needing approval. 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 user watching the session, 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.
// Why three preferences: completion notifications default on (existing
// behavior), but question and approval notifications surface request text/tool
// names and default OFF, so an existing user who only opted into completion
// alerts doesn't start receiving sensitive content on their desktop without
// explicitly opting in.

import { ref, type Ref } from 'vue';
import { i18n } from '../../i18n';
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';

export function shouldNotifyCompletion(
status: 'idle' | 'aborted',
hasPendingApproval: boolean,
hasPendingQuestion: boolean,
): boolean {
return status === 'idle' && !hasPendingApproval && !hasPendingQuestion;
}

function loadNotify(key: string, defaultOn: boolean): boolean {
const v = safeGetString(key);
return v === null ? defaultOn : v === '1';
}

const notifyOnComplete = ref(loadNotify(STORAGE_KEYS.notifyOnComplete, true));
const notifyOnQuestion = ref(loadNotify(STORAGE_KEYS.notifyOnQuestion, false));
const notifyOnApproval = ref(loadNotify(STORAGE_KEYS.notifyOnApproval, false));
const notifyPermission = ref<string>(
typeof Notification !== 'undefined' ? Notification.permission : 'denied',
);
Expand Down Expand Up @@ -61,20 +71,42 @@ function setNotifyOnQuestion(on: boolean): Promise<void> {
return setNotifyPref(notifyOnQuestion, STORAGE_KEYS.notifyOnQuestion, on);
}

export interface NotifyCompletionCtx {
/** True when the target session is the active one and the page is visible —
in which case we suppress the notification. */
isActiveAndVisible: boolean;
/** Enable/disable approval notifications. Off by default. */
function setNotifyOnApproval(on: boolean): Promise<void> {
return setNotifyPref(notifyOnApproval, STORAGE_KEYS.notifyOnApproval, on);
}

export interface NotifyBaseCtx {
/** True when the user is actually watching the target session: it is the
active session, the page is visible, and the window has focus — in which
case we suppress the notification. */
isUserWatching: boolean;
/** Session title used as the completion notification body and a question-body fallback. */
sessionTitle: string;
/** Called when the user clicks the notification (e.g. select the session). */
onClick: () => void;
}

export interface NotifyQuestionCtx extends NotifyCompletionCtx {
export interface NotifyCompletionCtx extends NotifyBaseCtx {
/** Prompt id of the finished turn; keys the dedup tag so every turn fires its
own notification while a replayed idle event for the same turn stays
collapsed. Falls back to a per-call unique tag when absent. */
promptId?: string;
}

export interface NotifyQuestionCtx extends NotifyBaseCtx {
/** Short preview of the question, used as the notification body. Falls back
to the session title, then to a generic line when empty. */
questionPreview: string;
/** Unique question request id; used to deduplicate notifications per request. */
questionId: string;
}

export interface NotifyApprovalCtx extends NotifyBaseCtx {
/** Tool call name needing approval, used as the notification body. */
toolName: string;
/** Unique approval request id; used to deduplicate notifications per request. */
approvalId: string;
}

export interface NotificationCopy {
Expand Down Expand Up @@ -111,12 +143,29 @@ export function questionNotificationCopy(
};
}

export function approvalNotificationCopy(
sessionTitle: string,
toolName: string,
): NotificationCopy {
return {
title: i18n.global.t('settings.notifyApprovalTitle'),
body: firstText(
toolName,
sessionTitle,
i18n.global.t('settings.notifyApprovalFallback'),
),
};
}

/** Shared permission gate + fire. `enabled` is the caller's per-kind preference;
`copy` 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. */
`copy` and `tag` let each kind carry its own text and a per-turn/per-request
dedup tag: repeats of the same turn or request collapse into one
notification, while distinct ones each fire (same-tag notifications replace
silently — renotify is unreliable across platforms — so the tag must change
whenever a new alert should pop). */
function maybeNotify(
enabled: boolean,
ctx: NotifyCompletionCtx,
ctx: NotifyBaseCtx,
copy: NotificationCopy,
tag: string,
): void {
Expand All @@ -135,8 +184,8 @@ function maybeNotify(
fire(ctx, copy, tag);
}

function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): void {
if (ctx.isActiveAndVisible) return;
function fire(ctx: NotifyBaseCtx, copy: NotificationCopy, tag: string): void {
if (ctx.isUserWatching) return;
try {
const n = new Notification(copy.title, { body: copy.body, tag, icon: NOTIFICATION_ICON });
n.onclick = () => {
Expand All @@ -154,35 +203,52 @@ function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): vo
}

/** Fire a completion notification for a finished session, but only when the
caller says the user isn't already looking at it. */
caller says the user isn't already looking at it. The tag carries the turn's
prompt id: same-tag notifications replace silently, so without it a stale
notification left in the notification center would swallow every later
turn's alert for that session. */
function maybeNotifyCompletion(sid: string, ctx: NotifyCompletionCtx): void {
maybeNotify(
notifyOnComplete.value,
ctx,
completionNotificationCopy(ctx.sessionTitle),
`kimi-complete-${sid}`,
`kimi-complete-${sid}-${ctx.promptId ?? Date.now()}`,
);
}

/** 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 {
function maybeNotifyQuestion(ctx: NotifyQuestionCtx): void {
maybeNotify(
notifyOnQuestion.value,
ctx,
questionNotificationCopy(ctx.sessionTitle, ctx.questionPreview),
`kimi-question-${sid}`,
`kimi-question-${ctx.questionId}`,
);
}

/** Fire a notification when a tool needs approval, but only when the user
explicitly opted into approval notifications and isn't already looking. */
function maybeNotifyApproval(ctx: NotifyApprovalCtx): void {
maybeNotify(
notifyOnApproval.value,
ctx,
approvalNotificationCopy(ctx.sessionTitle, ctx.toolName),
`kimi-approval-${ctx.approvalId}`,
);
}

export function useNotification() {
return {
notifyOnComplete,
notifyOnQuestion,
notifyOnApproval,
notifyPermission,
setNotifyOnComplete,
setNotifyOnQuestion,
setNotifyOnApproval,
maybeNotifyCompletion,
maybeNotifyQuestion,
maybeNotifyApproval,
};
}
16 changes: 13 additions & 3 deletions apps/kimi-web/src/composables/client/useSoundNotification.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// 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.
// Browser attention sound: a persisted on/off preference plus a short chime
// synthesized with the WebAudio API (no audio asset, no permission prompt).
// One chime covers every "the agent needs you" moment — a finished turn, a
// question waiting for an answer, a tool needing approval. 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
Expand Down Expand Up @@ -161,11 +163,19 @@ function maybePlayQuestionSound(): void {
playChime();
}

/** Play the attention sound when a tool needs approval, whenever the
preference is on. Same chime as completion: it means "the agent needs you". */
function maybePlayApprovalSound(): void {
if (!soundOnComplete.value) return;
playChime();
}

export function useSoundNotification() {
return {
soundOnComplete,
setSoundOnComplete,
maybePlayCompletionSound,
maybePlayQuestionSound,
maybePlayApprovalSound,
};
}
Loading
Loading