@@ -6,11 +6,9 @@ const mockAppState = vi.hoisted(() => {
66 projects : [
77 {
88 id : 'proj-1' ,
9+ name : 'Proj One' ,
910 activeSessionId : 'active-session' ,
10- sessions : [
11- { id : 'active-session' , name : 'Active Session' } ,
12- { id : 'bg-session' , name : 'Background Session' } ,
13- ] ,
11+ sessions : [ ] as Array < { id : string ; name : string } > ,
1412 } ,
1513 ] ,
1614 preferences : {
@@ -19,9 +17,20 @@ const mockAppState = vi.hoisted(() => {
1917 get activeProject ( ) {
2018 return state . projects . find ( p => p . id === state . activeProjectId ) ;
2119 } ,
20+ findSessionWithProject ( sessionId : string ) {
21+ for ( const project of state . projects ) {
22+ const session = project . sessions . find ( s => s . id === sessionId ) ;
23+ if ( session ) return { project, session } ;
24+ }
25+ return undefined ;
26+ } ,
2227 setActiveProject : vi . fn ( ) ,
2328 setActiveSession : vi . fn ( ) ,
24- on : vi . fn ( ) ,
29+ // Capture registered appState listeners so tests can fire them.
30+ listeners : new Map < string , ( data : unknown ) => void > ( ) ,
31+ on : vi . fn ( ( event : string , cb : ( data : unknown ) => void ) => {
32+ state . listeners . set ( event , cb ) ;
33+ } ) ,
2534 } ;
2635 return state ;
2736} ) ;
@@ -35,22 +44,30 @@ import { _resetForTesting as resetDesktopNotif, initNotificationDesktop } from '
3544
3645
3746// Track Notification constructor calls
38- let notificationInstances : Array < { title : string ; options : NotificationOptions ; onclick : ( ( ) => void ) | null } > = [ ] ;
47+ let notificationInstances : MockNotification [ ] = [ ] ;
48+ // Interleaved log of construction/close events, so tests can assert ordering.
49+ let notificationEvents : string [ ] = [ ] ;
3950let mockHasFocus = false ;
4051
4152class MockNotification {
4253 static permission = 'granted' ;
4354 static requestPermission = vi . fn ( ) . mockResolvedValue ( 'granted' ) ;
4455 title : string ;
4556 options : NotificationOptions ;
57+ index : number ;
58+ closed = false ;
4659 onclick : ( ( ) => void ) | null = null ;
4760 onclose : ( ( ) => void ) | null = null ;
4861 constructor ( title : string , options : NotificationOptions = { } ) {
4962 this . title = title ;
5063 this . options = options ;
51- notificationInstances . push ( this as any ) ;
64+ this . index = notificationInstances . length ;
65+ notificationInstances . push ( this ) ;
66+ notificationEvents . push ( `create:${ this . index } ` ) ;
5267 }
5368 close ( ) : void {
69+ this . closed = true ;
70+ notificationEvents . push ( `close:${ this . index } ` ) ;
5471 this . onclose ?.( ) ;
5572 }
5673}
@@ -62,16 +79,23 @@ if (typeof globalThis.document === 'undefined') {
6279} else {
6380 vi . spyOn ( document , 'hasFocus' ) . mockImplementation ( ( ) => mockHasFocus ) ;
6481}
82+ const focusSpy = vi . fn ( ) ;
83+ ( globalThis as any ) . window = { vibeyard : { app : { focus : focusSpy } } } ;
6584
6685describe ( 'notification-desktop' , ( ) => {
6786 beforeEach ( ( ) => {
6887 resetActivity ( ) ;
6988 resetDesktopNotif ( ) ;
7089 notificationInstances = [ ] ;
90+ notificationEvents = [ ] ;
7191 mockHasFocus = false ;
7292 mockAppState . preferences . notificationsDesktop = true ;
7393 mockAppState . activeProjectId = 'proj-1' ;
7494 mockAppState . projects [ 0 ] . activeSessionId = 'active-session' ;
95+ mockAppState . projects [ 0 ] . sessions = [
96+ { id : 'active-session' , name : 'Active Session' } ,
97+ { id : 'bg-session' , name : 'Background Session' } ,
98+ ] ;
7599 mockAppState . setActiveProject . mockClear ( ) ;
76100 mockAppState . setActiveSession . mockClear ( ) ;
77101 MockNotification . permission = 'granted' ;
@@ -84,7 +108,9 @@ describe('notification-desktop', () => {
84108 setHookStatus ( 'bg-session' , 'waiting' ) ;
85109
86110 expect ( notificationInstances ) . toHaveLength ( 1 ) ;
87- expect ( notificationInstances [ 0 ] . title ) . toBe ( 'Vibeyard' ) ;
111+ // Title is the owning project, so same-named sessions in different projects
112+ // are distinguishable in the banner.
113+ expect ( notificationInstances [ 0 ] . title ) . toBe ( 'Proj One' ) ;
88114 expect ( notificationInstances [ 0 ] . options . body ) . toBe ( 'Background Session is waiting for input' ) ;
89115 expect ( notificationInstances [ 0 ] . options . silent ) . toBe ( true ) ;
90116 } ) ;
@@ -155,31 +181,44 @@ describe('notification-desktop', () => {
155181 } ) ;
156182
157183 it ( 'should focus app and switch session on notification click' , ( ) => {
158- const focusSpy = vi . fn ( ) ;
159- ( globalThis as any ) . window = {
160- focus : focusSpy ,
161- vibeyard : { app : { focus : focusSpy } } ,
162- } ;
163-
164184 initSession ( 'bg-session' ) ;
165185 setHookStatus ( 'bg-session' , 'working' ) ;
166186 setHookStatus ( 'bg-session' , 'waiting' ) ;
167187
168188 expect ( notificationInstances ) . toHaveLength ( 1 ) ;
169- expect ( notificationInstances [ 0 ] . options . tag ) . toBe ( 'vibeyard-session-bg-session' ) ;
189+ focusSpy . mockClear ( ) ;
170190 notificationInstances [ 0 ] . onclick ! ( ) ;
171191
192+ expect ( focusSpy ) . toHaveBeenCalled ( ) ;
172193 expect ( mockAppState . setActiveProject ) . toHaveBeenCalledWith ( 'proj-1' ) ;
173194 expect ( mockAppState . setActiveSession ) . toHaveBeenCalledWith ( 'proj-1' , 'bg-session' ) ;
174195 } ) ;
175196
176- it ( 'should route clicks to the correct session id when two sessions share a name' , ( ) => {
177- const focusSpy = vi . fn ( ) ;
178- ( globalThis as any ) . window = {
179- focus : focusSpy ,
180- vibeyard : { app : { focus : focusSpy } } ,
181- } ;
197+ it ( 'should not set a tag, which would destroy the previous notification\'s click listener' , ( ) => {
198+ initSession ( 'bg-session' ) ;
199+ setHookStatus ( 'bg-session' , 'working' ) ;
200+ setHookStatus ( 'bg-session' , 'waiting' ) ;
201+
202+ expect ( notificationInstances [ 0 ] . options . tag ) . toBeUndefined ( ) ;
203+ } ) ;
204+
205+ it ( 'should close the previous banner before posting a new one for the same session' , ( ) => {
206+ initSession ( 'bg-session' ) ;
207+ setHookStatus ( 'bg-session' , 'working' ) ;
208+ setHookStatus ( 'bg-session' , 'completed' ) ;
209+ setHookStatus ( 'bg-session' , 'working' ) ;
210+ setHookStatus ( 'bg-session' , 'completed' ) ;
182211
212+ expect ( notificationInstances ) . toHaveLength ( 2 ) ;
213+ // The stale banner is dismissed before the replacement is constructed, so
214+ // the user is never left with an inert notification to click.
215+ expect ( notificationEvents ) . toEqual ( [ 'create:0' , 'close:0' , 'create:1' ] ) ;
216+
217+ notificationInstances [ 1 ] . onclick ! ( ) ;
218+ expect ( mockAppState . setActiveSession ) . toHaveBeenLastCalledWith ( 'proj-1' , 'bg-session' ) ;
219+ } ) ;
220+
221+ it ( 'should route clicks to the correct session id when two sessions share a name' , ( ) => {
183222 // Two distinct sessions that happen to share the same display name.
184223 mockAppState . projects [ 0 ] . sessions . push (
185224 { id : 'dup-a' , name : 'Session 6' } as any ,
@@ -195,12 +234,11 @@ describe('notification-desktop', () => {
195234 setHookStatus ( 'dup-b' , 'completed' ) ;
196235
197236 expect ( notificationInstances ) . toHaveLength ( 2 ) ;
198- // Identical body , but distinct per-session tags keep them separate at the OS level .
237+ // Identical text , but each notification keeps its own live click listener .
199238 expect ( notificationInstances [ 0 ] . options . body ) . toBe ( 'Session 6 has completed' ) ;
200239 expect ( notificationInstances [ 1 ] . options . body ) . toBe ( 'Session 6 has completed' ) ;
201- expect ( notificationInstances [ 0 ] . options . tag ) . toBe ( 'vibeyard-session-dup-a' ) ;
202- expect ( notificationInstances [ 1 ] . options . tag ) . toBe ( 'vibeyard-session-dup-b' ) ;
203- expect ( notificationInstances [ 0 ] . options . tag ) . not . toBe ( notificationInstances [ 1 ] . options . tag ) ;
240+ // Neither banner is closed by the other — different sessions, different entries.
241+ expect ( notificationInstances . some ( n => n . closed ) ) . toBe ( false ) ;
204242
205243 // Clicking each notification focuses the id that produced it, not the other same-named session.
206244 notificationInstances [ 0 ] . onclick ! ( ) ;
@@ -210,6 +248,19 @@ describe('notification-desktop', () => {
210248 expect ( mockAppState . setActiveSession ) . toHaveBeenLastCalledWith ( 'proj-1' , 'dup-b' ) ;
211249 } ) ;
212250
251+ it ( 'should dismiss a live banner when its session is removed' , ( ) => {
252+ initSession ( 'bg-session' ) ;
253+ setHookStatus ( 'bg-session' , 'working' ) ;
254+ setHookStatus ( 'bg-session' , 'completed' ) ;
255+
256+ expect ( notificationInstances ) . toHaveLength ( 1 ) ;
257+ expect ( notificationInstances [ 0 ] . closed ) . toBe ( false ) ;
258+
259+ mockAppState . listeners . get ( 'session-removed' ) ! ( { sessionId : 'bg-session' } ) ;
260+
261+ expect ( notificationInstances [ 0 ] . closed ) . toBe ( true ) ;
262+ } ) ;
263+
213264 it ( 'should not notify when Notification permission is not granted' , ( ) => {
214265 MockNotification . permission = 'denied' ;
215266
@@ -220,12 +271,13 @@ describe('notification-desktop', () => {
220271 expect ( notificationInstances ) . toHaveLength ( 0 ) ;
221272 } ) ;
222273
223- it ( 'should fall back to "Session" name for unknown session' , ( ) => {
274+ it ( 'should fall back to "Vibeyard" / "Session" for an unknown session' , ( ) => {
224275 initSession ( 'unknown-session' ) ;
225276 setHookStatus ( 'unknown-session' , 'working' ) ;
226277 setHookStatus ( 'unknown-session' , 'waiting' ) ;
227278
228279 expect ( notificationInstances ) . toHaveLength ( 1 ) ;
280+ expect ( notificationInstances [ 0 ] . title ) . toBe ( 'Vibeyard' ) ;
229281 expect ( notificationInstances [ 0 ] . options . body ) . toBe ( 'Session is waiting for input' ) ;
230282 } ) ;
231283} ) ;
0 commit comments