Skip to content

Commit 79a4125

Browse files
liruifengvsailist
authored andcommitted
feat(kimi-web): add status-aware browser notifications (#1479)
* feat(kimi-web): add approval notification storage key and i18n copy * feat(kimi-web): add approval notification helpers and tests * feat(kimi-web): wire approval notifications and guard completion alerts * fix(kimi-web): extract shouldNotifyCompletion helper and add tests * feat(kimi-web): add approval notification settings toggle * chore(kimi-web): add changeset and tidy notification module comment - Align approval notification tag with spec (kimi-approval-${approvalId}) - Update module header to describe all three notification kinds * fix(kimi-web): make notifications fire reliably - Key completion notification tags by turn (sid + promptId) and question tags by request id, so a stale notification left in the notification center no longer swallows every follow-up alert in the same session - Suppress notifications only while the window is actually focused, not merely visible (document.hasFocus() on top of visibilityState) - Play the attention sound when a tool needs approval, matching the completion and question sounds * chore(kimi-web): simplify changeset
1 parent 4d60160 commit 79a4125

10 files changed

Lines changed: 320 additions & 48 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
web: Add notifications when a tool needs approval, and improve notification reliability.

apps/kimi-web/src/App.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,7 @@ function openPr(url: string): void {
898898
:account-model="client.defaultModel.value"
899899
:notify="client.notifyOnComplete.value"
900900
:notify-question="client.notifyOnQuestion.value"
901+
:notify-approval="client.notifyOnApproval.value"
901902
:notify-permission="client.notifyPermission.value"
902903
:sound="client.soundOnComplete.value"
903904
:conversation-toc="client.conversationToc.value"
@@ -910,6 +911,7 @@ function openPr(url: string): void {
910911
@set-ui-font-size="client.setUiFontSize($event)"
911912
@set-notify="client.setNotifyOnComplete($event)"
912913
@set-notify-question="client.setNotifyOnQuestion($event)"
914+
@set-notify-approval="client.setNotifyOnApproval($event)"
913915
@set-sound="client.setSoundOnComplete($event)"
914916
@set-conversation-toc="client.setConversationToc($event)"
915917
@update-config="handleUpdateConfig($event)"

apps/kimi-web/src/components/settings/SettingsDialog.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ const props = defineProps<{
3232
notify: boolean;
3333
/** Browser-notification-on-question (needs answer) preference. */
3434
notifyQuestion: boolean;
35+
/** Browser-notification-on-approval preference. */
36+
notifyApproval: boolean;
3537
/** OS permission state ('default' | 'granted' | 'denied') for the hint. */
3638
notifyPermission?: string;
3739
/** Play-a-sound-on-completion preference. */
@@ -54,6 +56,7 @@ const emit = defineEmits<{
5456
setUiFontSize: [size: number];
5557
setNotify: [on: boolean];
5658
setNotifyQuestion: [on: boolean];
59+
setNotifyApproval: [on: boolean];
5760
setSound: [on: boolean];
5861
setConversationToc: [on: boolean];
5962
login: [];
@@ -417,6 +420,18 @@ function archiveTime(iso: string): string {
417420
@update:model-value="emit('setNotifyQuestion', $event)"
418421
/>
419422
</div>
423+
<div class="row">
424+
<span class="rlabel">
425+
{{ t('settings.notifyOnApproval') }}
426+
<span v-if="notifyPermission === 'denied'" class="hint">{{ t('settings.notifyDenied') }}</span>
427+
</span>
428+
<Switch
429+
:model-value="notifyApproval"
430+
:disabled="notifyPermission === 'denied'"
431+
:label="t('settings.notifyOnApproval')"
432+
@update:model-value="emit('setNotifyApproval', $event)"
433+
/>
434+
</div>
420435
<div class="row">
421436
<span class="rlabel">{{ t('settings.soundOnComplete') }}</span>
422437
<Switch

apps/kimi-web/src/composables/client/useNotification.ts

Lines changed: 90 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,37 @@
11
// apps/kimi-web/src/composables/client/useNotification.ts
2-
// Browser notifications for when the agent needs attention: a turn finished or
3-
// a question is waiting for an answer. Each kind has its own on/off preference
4-
// (persisted) plus the shared OS permission + Notification API. Pure UI action
5-
// module — it never reads rawState or calls the API. The rawState-dependent
6-
// bits (is the session active & visible, its title, the click-to-select action)
7-
// are passed in by the caller via the ctx objects.
2+
// Browser notifications for when the agent needs attention: a turn finished, a
3+
// question waiting for an answer, or a tool needing approval. Each kind has its
4+
// own on/off preference (persisted) plus the shared OS permission + Notification
5+
// API. Pure UI action module — it never reads rawState or calls the API. The
6+
// rawState-dependent bits (is the user watching the session, its title, the
7+
// click-to-select action) are passed in by the caller via the ctx objects.
88
//
9-
// Why two preferences: completion notifications default on (existing behavior),
10-
// but question notifications surface question text and default OFF, so an
11-
// existing user who only opted into completion alerts doesn't start receiving
12-
// question content on their desktop without explicitly opting in.
9+
// Why three preferences: completion notifications default on (existing
10+
// behavior), but question and approval notifications surface request text/tool
11+
// names and default OFF, so an existing user who only opted into completion
12+
// alerts doesn't start receiving sensitive content on their desktop without
13+
// explicitly opting in.
1314

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

19+
export function shouldNotifyCompletion(
20+
status: 'idle' | 'aborted',
21+
hasPendingApproval: boolean,
22+
hasPendingQuestion: boolean,
23+
): boolean {
24+
return status === 'idle' && !hasPendingApproval && !hasPendingQuestion;
25+
}
26+
1827
function loadNotify(key: string, defaultOn: boolean): boolean {
1928
const v = safeGetString(key);
2029
return v === null ? defaultOn : v === '1';
2130
}
2231

2332
const notifyOnComplete = ref(loadNotify(STORAGE_KEYS.notifyOnComplete, true));
2433
const notifyOnQuestion = ref(loadNotify(STORAGE_KEYS.notifyOnQuestion, false));
34+
const notifyOnApproval = ref(loadNotify(STORAGE_KEYS.notifyOnApproval, false));
2535
const notifyPermission = ref<string>(
2636
typeof Notification !== 'undefined' ? Notification.permission : 'denied',
2737
);
@@ -61,20 +71,42 @@ function setNotifyOnQuestion(on: boolean): Promise<void> {
6171
return setNotifyPref(notifyOnQuestion, STORAGE_KEYS.notifyOnQuestion, on);
6272
}
6373

64-
export interface NotifyCompletionCtx {
65-
/** True when the target session is the active one and the page is visible —
66-
in which case we suppress the notification. */
67-
isActiveAndVisible: boolean;
74+
/** Enable/disable approval notifications. Off by default. */
75+
function setNotifyOnApproval(on: boolean): Promise<void> {
76+
return setNotifyPref(notifyOnApproval, STORAGE_KEYS.notifyOnApproval, on);
77+
}
78+
79+
export interface NotifyBaseCtx {
80+
/** True when the user is actually watching the target session: it is the
81+
active session, the page is visible, and the window has focus — in which
82+
case we suppress the notification. */
83+
isUserWatching: boolean;
6884
/** Session title used as the completion notification body and a question-body fallback. */
6985
sessionTitle: string;
7086
/** Called when the user clicks the notification (e.g. select the session). */
7187
onClick: () => void;
7288
}
7389

74-
export interface NotifyQuestionCtx extends NotifyCompletionCtx {
90+
export interface NotifyCompletionCtx extends NotifyBaseCtx {
91+
/** Prompt id of the finished turn; keys the dedup tag so every turn fires its
92+
own notification while a replayed idle event for the same turn stays
93+
collapsed. Falls back to a per-call unique tag when absent. */
94+
promptId?: string;
95+
}
96+
97+
export interface NotifyQuestionCtx extends NotifyBaseCtx {
7598
/** Short preview of the question, used as the notification body. Falls back
7699
to the session title, then to a generic line when empty. */
77100
questionPreview: string;
101+
/** Unique question request id; used to deduplicate notifications per request. */
102+
questionId: string;
103+
}
104+
105+
export interface NotifyApprovalCtx extends NotifyBaseCtx {
106+
/** Tool call name needing approval, used as the notification body. */
107+
toolName: string;
108+
/** Unique approval request id; used to deduplicate notifications per request. */
109+
approvalId: string;
78110
}
79111

80112
export interface NotificationCopy {
@@ -111,12 +143,29 @@ export function questionNotificationCopy(
111143
};
112144
}
113145

146+
export function approvalNotificationCopy(
147+
sessionTitle: string,
148+
toolName: string,
149+
): NotificationCopy {
150+
return {
151+
title: i18n.global.t('settings.notifyApprovalTitle'),
152+
body: firstText(
153+
toolName,
154+
sessionTitle,
155+
i18n.global.t('settings.notifyApprovalFallback'),
156+
),
157+
};
158+
}
159+
114160
/** Shared permission gate + fire. `enabled` is the caller's per-kind preference;
115-
`copy` and `tag` let each kind carry its own text and a per-kind dedup tag
116-
so a completion and a question don't collapse into one notification. */
161+
`copy` and `tag` let each kind carry its own text and a per-turn/per-request
162+
dedup tag: repeats of the same turn or request collapse into one
163+
notification, while distinct ones each fire (same-tag notifications replace
164+
silently — renotify is unreliable across platforms — so the tag must change
165+
whenever a new alert should pop). */
117166
function maybeNotify(
118167
enabled: boolean,
119-
ctx: NotifyCompletionCtx,
168+
ctx: NotifyBaseCtx,
120169
copy: NotificationCopy,
121170
tag: string,
122171
): void {
@@ -135,8 +184,8 @@ function maybeNotify(
135184
fire(ctx, copy, tag);
136185
}
137186

138-
function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): void {
139-
if (ctx.isActiveAndVisible) return;
187+
function fire(ctx: NotifyBaseCtx, copy: NotificationCopy, tag: string): void {
188+
if (ctx.isUserWatching) return;
140189
try {
141190
const n = new Notification(copy.title, { body: copy.body, tag, icon: NOTIFICATION_ICON });
142191
n.onclick = () => {
@@ -154,35 +203,52 @@ function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): vo
154203
}
155204

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

167219
/** Fire a notification when a session asks a question, but only when the user
168220
explicitly opted into question notifications and isn't already looking. */
169-
function maybeNotifyQuestion(sid: string, ctx: NotifyQuestionCtx): void {
221+
function maybeNotifyQuestion(ctx: NotifyQuestionCtx): void {
170222
maybeNotify(
171223
notifyOnQuestion.value,
172224
ctx,
173225
questionNotificationCopy(ctx.sessionTitle, ctx.questionPreview),
174-
`kimi-question-${sid}`,
226+
`kimi-question-${ctx.questionId}`,
227+
);
228+
}
229+
230+
/** Fire a notification when a tool needs approval, but only when the user
231+
explicitly opted into approval notifications and isn't already looking. */
232+
function maybeNotifyApproval(ctx: NotifyApprovalCtx): void {
233+
maybeNotify(
234+
notifyOnApproval.value,
235+
ctx,
236+
approvalNotificationCopy(ctx.sessionTitle, ctx.toolName),
237+
`kimi-approval-${ctx.approvalId}`,
175238
);
176239
}
177240

178241
export function useNotification() {
179242
return {
180243
notifyOnComplete,
181244
notifyOnQuestion,
245+
notifyOnApproval,
182246
notifyPermission,
183247
setNotifyOnComplete,
184248
setNotifyOnQuestion,
249+
setNotifyOnApproval,
185250
maybeNotifyCompletion,
186251
maybeNotifyQuestion,
252+
maybeNotifyApproval,
187253
};
188254
}

apps/kimi-web/src/composables/client/useSoundNotification.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// apps/kimi-web/src/composables/client/useSoundNotification.ts
2-
// Browser "turn completed" sound: a persisted on/off preference plus a short
3-
// chime synthesized with the WebAudio API (no audio asset, no permission
4-
// prompt). Pure UI action module — it never reads rawState or calls the API.
2+
// Browser attention sound: a persisted on/off preference plus a short chime
3+
// synthesized with the WebAudio API (no audio asset, no permission prompt).
4+
// One chime covers every "the agent needs you" moment — a finished turn, a
5+
// question waiting for an answer, a tool needing approval. Pure UI action
6+
// module — it never reads rawState or calls the API.
57
//
68
// Why the eager "unlock": the sound is most useful when the tab is in the
79
// background (so you hear it while doing something else). But an AudioContext
@@ -161,11 +163,19 @@ function maybePlayQuestionSound(): void {
161163
playChime();
162164
}
163165

166+
/** Play the attention sound when a tool needs approval, whenever the
167+
preference is on. Same chime as completion: it means "the agent needs you". */
168+
function maybePlayApprovalSound(): void {
169+
if (!soundOnComplete.value) return;
170+
playChime();
171+
}
172+
164173
export function useSoundNotification() {
165174
return {
166175
soundOnComplete,
167176
setSoundOnComplete,
168177
maybePlayCompletionSound,
169178
maybePlayQuestionSound,
179+
maybePlayApprovalSound,
170180
};
171181
}

0 commit comments

Comments
 (0)