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
1415import { ref , type Ref } from 'vue' ;
1516import { i18n } from '../../i18n' ;
1617import { 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+
1827function loadNotify ( key : string , defaultOn : boolean ) : boolean {
1928 const v = safeGetString ( key ) ;
2029 return v === null ? defaultOn : v === '1' ;
2130}
2231
2332const notifyOnComplete = ref ( loadNotify ( STORAGE_KEYS . notifyOnComplete , true ) ) ;
2433const notifyOnQuestion = ref ( loadNotify ( STORAGE_KEYS . notifyOnQuestion , false ) ) ;
34+ const notifyOnApproval = ref ( loadNotify ( STORAGE_KEYS . notifyOnApproval , false ) ) ;
2535const 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
80112export 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). */
117166function 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. */
158210function 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
178241export 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}
0 commit comments