@@ -14,7 +14,7 @@ const getTaskPromptSummary = (task: HistoryItem) =>
1414 buildPromptNotificationSummary ( task . promptOptimized , task . promptOriginal , task . prompt ) ;
1515
1616const shouldNotifyForStatus = ( status : FinalTaskStatus , notifyOnFailure : boolean ) => {
17- if ( status === 'failed' ) return notifyOnFailure ;
17+ if ( status === 'failed' || status === 'partial' ) return notifyOnFailure ;
1818 return true ;
1919} ;
2020
@@ -54,6 +54,81 @@ const getNotificationCopy = (task: HistoryItem, t: (key: string, params?: Record
5454 } ;
5555} ;
5656
57+ const getTaskSignature = ( task : HistoryItem ) => `${ task . status } :${ task . updatedAt || task . createdAt } ` ;
58+
59+ const getTaskNotificationPayload = (
60+ tasks : HistoryItem [ ] ,
61+ t : ( key : string , params ?: Record < string , unknown > ) => string
62+ ) => {
63+ const [ firstTask ] = tasks ;
64+ const copy = getNotificationCopy ( firstTask , t ) ;
65+ if ( tasks . length === 1 ) {
66+ return copy ;
67+ }
68+
69+ const promptSummary = getTaskPromptSummary ( firstTask ) ;
70+ const additionalCount = tasks . length - 1 ;
71+ return {
72+ title : t ( `notifications.status.${ firstTask . status } .batchTitle` , { count : tasks . length } ) ,
73+ body : promptSummary
74+ ? t ( `notifications.status.${ firstTask . status } .batchBodyWithPrompt` , {
75+ prompt : promptSummary ,
76+ count : tasks . length ,
77+ additionalCount
78+ } )
79+ : t ( `notifications.status.${ firstTask . status } .batchBody` , {
80+ count : tasks . length ,
81+ additionalCount
82+ } )
83+ } ;
84+ } ;
85+
86+ export async function ensureNotificationPermission (
87+ t : ( key : string , params ?: Record < string , unknown > ) => string ,
88+ options ?: { showDeniedToast ?: boolean ; source ?: 'startup' | 'settings' | 'test' | 'task' }
89+ ) {
90+ const showDeniedToast = options ?. showDeniedToast ?? true ;
91+ const source = options ?. source || 'startup' ;
92+
93+ const { isPermissionGranted, requestPermission } = await import ( '@tauri-apps/plugin-notification' ) ;
94+ let permissionGranted = await isPermissionGranted ( ) ;
95+ console . info ( '[notification] permission state' , { source, permissionGranted } ) ;
96+ if ( ! permissionGranted ) {
97+ const permission = await requestPermission ( ) ;
98+ console . info ( '[notification] requestPermission result' , { source, permission } ) ;
99+ permissionGranted = permission === 'granted' ;
100+ }
101+
102+ if ( ! permissionGranted && showDeniedToast ) {
103+ toast . info ( t ( 'settings.notifications.permissionDenied' ) ) ;
104+ }
105+
106+ return permissionGranted ;
107+ }
108+
109+ const getWindowVisibilitySnapshot = async ( ) => {
110+ const visibilityState = typeof document !== 'undefined' ? document . visibilityState : 'visible' ;
111+ let focused = true ;
112+ let visible = true ;
113+
114+ try {
115+ const { getCurrentWindow } = await import ( '@tauri-apps/api/window' ) ;
116+ const appWindow = getCurrentWindow ( ) ;
117+ [ focused , visible ] = await Promise . all ( [
118+ appWindow . isFocused ( ) . catch ( ( ) => false ) ,
119+ appWindow . isVisible ( ) . catch ( ( ) => true )
120+ ] ) ;
121+ } catch ( error ) {
122+ console . warn ( '[notification] window visibility probe failed' , error ) ;
123+ }
124+
125+ return {
126+ visibilityState,
127+ focused,
128+ visible
129+ } ;
130+ } ;
131+
57132export function useGenerationNotifications ( ) {
58133 const { t } = useTranslation ( ) ;
59134 const items = useHistoryStore ( ( s ) => s . items ) ;
@@ -67,18 +142,6 @@ export function useGenerationNotifications() {
67142 const permissionRequestedRef = useRef ( false ) ;
68143 const permissionDeniedToastShownRef = useRef ( false ) ;
69144
70- useEffect ( ( ) => {
71- const nextMap = new Map < string , string > ( ) ;
72- for ( const task of items ) {
73- if ( task . status !== 'completed' && task . status !== 'failed' && task . status !== 'partial' ) {
74- continue ;
75- }
76- const signature = `${ task . status } :${ task . updatedAt || task . createdAt } ` ;
77- nextMap . set ( task . id , signature ) ;
78- }
79- lastStatusSignatureRef . current = nextMap ;
80- } , [ items ] ) ;
81-
82145 useEffect ( ( ) => {
83146 if ( enableSystemNotifications && ! previousEnabledRef . current ) {
84147 enableBaselineRef . current = Date . now ( ) ;
@@ -101,14 +164,10 @@ export function useGenerationNotifications() {
101164
102165 const ensurePermission = async ( ) => {
103166 try {
104- const { isPermissionGranted, requestPermission } = await import ( '@tauri-apps/plugin-notification' ) ;
105- let permissionGranted = await isPermissionGranted ( ) ;
106- console . info ( '[notification] initial permission state' , { permissionGranted } ) ;
107- if ( ! permissionGranted ) {
108- const permission = await requestPermission ( ) ;
109- console . info ( '[notification] requestPermission result' , { permission } ) ;
110- permissionGranted = permission === 'granted' ;
111- }
167+ const permissionGranted = await ensureNotificationPermission ( t , {
168+ showDeniedToast : false ,
169+ source : 'startup'
170+ } ) ;
112171 permissionRequestedRef . current = true ;
113172 if ( ! permissionGranted ) {
114173 if ( ! permissionDeniedToastShownRef . current ) {
@@ -128,6 +187,7 @@ export function useGenerationNotifications() {
128187 if ( ! enableSystemNotifications ) return ;
129188 if ( ! isTauriRuntime ( ) ) return ;
130189
190+ const signatureSnapshot = new Map ( lastStatusSignatureRef . current ) ;
131191 const finalTasks = [ ...items ]
132192 . filter ( ( task ) => task . status === 'completed' || task . status === 'failed' || task . status === 'partial' )
133193 . sort ( ( a , b ) => {
@@ -142,50 +202,111 @@ export function useGenerationNotifications() {
142202 return false ;
143203 }
144204
145- const currentSignature = ` ${ task . status } : ${ task . updatedAt || task . createdAt } ` ;
146- const previousSignature = lastStatusSignatureRef . current . get ( task . id ) ;
205+ const currentSignature = getTaskSignature ( task ) ;
206+ const previousSignature = signatureSnapshot . get ( task . id ) ;
147207 return currentSignature !== previousSignature ;
148208 } ) ;
149209 if ( ! finalTasks . length ) return ;
150210
151211 const notify = async ( ) => {
152212 try {
213+ const visibility = await getWindowVisibilitySnapshot ( ) ;
214+ let { visibilityState, focused, visible } = visibility ;
215+
153216 if ( notifyOnlyWhenBackground ) {
154- const isVisible = typeof document !== 'undefined' ? document . visibilityState === 'visible' : true ;
155- const { getCurrentWindow } = await import ( '@tauri-apps/api/window' ) ;
156- const appWindow = getCurrentWindow ( ) ;
157- const [ focused , visible ] = await Promise . all ( [
158- appWindow . isFocused ( ) . catch ( ( ) => false ) ,
159- appWindow . isVisible ( ) . catch ( ( ) => true )
160- ] ) ;
217+ const isVisible = visibilityState === 'visible' ;
161218 if ( isVisible && focused && visible ) {
219+ console . info ( '[notification] skipped: app visible in foreground' , {
220+ taskIds : finalTasks . map ( ( task ) => task . id ) ,
221+ statuses : finalTasks . map ( ( task ) => task . status ) ,
222+ visibilityState,
223+ focused,
224+ visible
225+ } ) ;
162226 return ;
163227 }
164228 }
165229
166- const { isPermissionGranted, requestPermission, sendNotification } = await import ( '@tauri-apps/plugin-notification' ) ;
167- let permissionGranted = await isPermissionGranted ( ) ;
230+ const { sendNotification } = await import ( '@tauri-apps/plugin-notification' ) ;
231+ const permissionGranted = await ensureNotificationPermission ( t , {
232+ showDeniedToast : false ,
233+ source : 'task'
234+ } ) ;
168235 if ( ! permissionGranted ) {
169- const permission = await requestPermission ( ) ;
170- permissionGranted = permission === 'granted' ;
236+ console . info ( '[notification] skipped: permission not granted' , {
237+ taskIds : finalTasks . map ( ( task ) => task . id ) ,
238+ statuses : finalTasks . map ( ( task ) => task . status )
239+ } ) ;
240+ return ;
171241 }
172- if ( ! permissionGranted ) return ;
173242
174- for ( const task of finalTasks ) {
243+ const tasksToNotify = finalTasks . filter ( ( task ) => {
175244 const finalStatus = task . status as FinalTaskStatus ;
176- if ( ! shouldNotifyForStatus ( finalStatus , notifyOnFailure ) ) continue ;
245+ if ( ! shouldNotifyForStatus ( finalStatus , notifyOnFailure ) ) {
246+ signatureSnapshot . set ( task . id , getTaskSignature ( task ) ) ;
247+ console . info ( '[notification] skipped: failure notifications disabled' , {
248+ taskId : task . id ,
249+ status : task . status
250+ } ) ;
251+ return false ;
252+ }
177253
178254 const signature = `${ task . id } :${ finalStatus } :${ task . updatedAt || task . createdAt } ` ;
179- if ( notifiedRef . current . has ( signature ) ) continue ;
255+ if ( notifiedRef . current . has ( signature ) ) {
256+ signatureSnapshot . set ( task . id , getTaskSignature ( task ) ) ;
257+ console . info ( '[notification] skipped: already notified' , {
258+ taskId : task . id ,
259+ status : task . status
260+ } ) ;
261+ return false ;
262+ }
263+
264+ return true ;
265+ } ) ;
266+
267+ if ( ! tasksToNotify . length ) {
268+ lastStatusSignatureRef . current = signatureSnapshot ;
269+ return ;
270+ }
180271
181- const copy = getNotificationCopy ( task , t ) ;
272+ const groupedTasks = new Map < FinalTaskStatus , HistoryItem [ ] > ( ) ;
273+ for ( const task of tasksToNotify ) {
274+ const status = task . status as FinalTaskStatus ;
275+ const list = groupedTasks . get ( status ) || [ ] ;
276+ list . push ( task ) ;
277+ groupedTasks . set ( status , list ) ;
278+ }
279+
280+ const statusOrder : FinalTaskStatus [ ] = [ 'failed' , 'partial' , 'completed' ] ;
281+ for ( const status of statusOrder ) {
282+ const tasks = groupedTasks . get ( status ) ;
283+ if ( ! tasks ?. length ) continue ;
284+
285+ const payload = getTaskNotificationPayload ( tasks , t ) ;
182286 await sendNotification ( {
183- title : copy . title ,
184- body : copy . body
287+ title : payload . title ,
288+ body : payload . body
185289 } ) ;
186- notifiedRef . current . add ( signature ) ;
187- lastStatusSignatureRef . current . set ( task . id , `${ task . status } :${ task . updatedAt || task . createdAt } ` ) ;
290+ console . info ( '[notification] task notification sent' , {
291+ taskIds : tasks . map ( ( task ) => task . id ) ,
292+ status,
293+ count : tasks . length ,
294+ title : payload . title ,
295+ body : payload . body ,
296+ notifyOnlyWhenBackground,
297+ visibilityState,
298+ focused,
299+ visible
300+ } ) ;
301+
302+ for ( const task of tasks ) {
303+ const signature = `${ task . id } :${ status } :${ task . updatedAt || task . createdAt } ` ;
304+ notifiedRef . current . add ( signature ) ;
305+ signatureSnapshot . set ( task . id , getTaskSignature ( task ) ) ;
306+ }
188307 }
308+
309+ lastStatusSignatureRef . current = signatureSnapshot ;
189310 } catch ( error ) {
190311 console . warn ( '[notification] send failed' , error ) ;
191312 }
@@ -197,24 +318,28 @@ export function useGenerationNotifications() {
197318
198319export async function sendTestSystemNotification ( t : ( key : string , params ?: Record < string , unknown > ) => string ) {
199320 try {
200- const { isPermissionGranted, requestPermission, sendNotification } = await import ( '@tauri-apps/plugin-notification' ) ;
201- let permissionGranted = await isPermissionGranted ( ) ;
202- console . info ( '[notification] test permission state' , { permissionGranted } ) ;
203- if ( ! permissionGranted ) {
204- const permission = await requestPermission ( ) ;
205- console . info ( '[notification] test requestPermission result' , { permission } ) ;
206- permissionGranted = permission === 'granted' ;
207- }
321+ const { sendNotification } = await import ( '@tauri-apps/plugin-notification' ) ;
322+ const permissionGranted = await ensureNotificationPermission ( t , {
323+ showDeniedToast : true ,
324+ source : 'test'
325+ } ) ;
208326 if ( ! permissionGranted ) {
209- toast . info ( t ( 'settings.notifications.permissionDenied' ) ) ;
210327 return ;
211328 }
212329
330+ const visibility = await getWindowVisibilitySnapshot ( ) ;
331+ const title = t ( 'notifications.status.completed.title' ) ;
332+ const body = t ( 'settings.notifications.testProbeBody' ) ;
333+
213334 await sendNotification ( {
214- title : t ( 'notifications.status.completed.title' ) ,
215- body : 'Notification permission probe'
335+ title,
336+ body
337+ } ) ;
338+ console . info ( '[notification] test notification sent' , {
339+ title,
340+ body,
341+ ...visibility
216342 } ) ;
217- console . info ( '[notification] test notification sent' ) ;
218343 toast . success ( t ( 'settings.notifications.testSent' ) ) ;
219344 } catch ( error ) {
220345 console . error ( '[notification] test notification failed' , error ) ;
0 commit comments