Skip to content

Commit 26e7921

Browse files
elirantutiaclaude
andcommitted
fix notification click doing nothing by removing the per-session tag
c001be7 added `tag: vibeyard-session-<id>` on the assumption that macOS coalesced same-named banners. It doesn't — Electron's macOS presenter gives every banner a fresh NSUUID and ignores the tag. The tag instead took effect in Chromium, where blink uses a non-empty tag as the non-persistent notification's token, so registering a second notification for a session erased the first one's click listener while its banner was still on screen. Clicking that banner then only raised the app. Drop the tag, dismiss a session's previous banner explicitly, and use the owning project's name as the title so same-named sessions stay tellable apart. Also expose AppState.findSessionWithProject so this module stops open-coding a session lookup the class already had twice, privately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e531610 commit 26e7921

3 files changed

Lines changed: 120 additions & 60 deletions

File tree

‎src/renderer/notification-desktop.test.ts‎

Lines changed: 78 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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[] = [];
3950
let mockHasFocus = false;
4051

4152
class 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

6685
describe('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
});

‎src/renderer/notification-desktop.ts‎

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,56 +4,58 @@ import { onChange, type SessionStatus } from './session-activity.js';
44
const previousStatus = new Map<string, SessionStatus>();
55

66
// Retain live notifications so their onclick closures aren't garbage-collected
7-
// before the user clicks, and so a stale notification can be closed/replaced.
7+
// before the user clicks, and so a stale banner can be dismissed explicitly.
88
const activeNotifications = new Map<string, Notification>();
99

10-
function getSessionName(sessionId: string): string {
11-
for (const project of appState.projects) {
12-
const session = project.sessions.find(s => s.id === sessionId);
13-
if (session) return session.name;
14-
}
15-
return 'Session';
16-
}
17-
1810
function bodyForStatus(name: string, status: SessionStatus): string {
1911
if (status === 'input') return `${name} needs your input to continue`;
2012
if (status === 'completed') return `${name} has completed`;
2113
return `${name} is waiting for input`;
2214
}
2315

16+
/** Dismiss this session's live banner, if any, and forget it. */
17+
function dismiss(sessionId: string): void {
18+
activeNotifications.get(sessionId)?.close();
19+
activeNotifications.delete(sessionId);
20+
}
21+
2422
function showNotification(sessionId: string, status: SessionStatus): void {
2523
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
2624

27-
const name = getSessionName(sessionId);
28-
const notification = new Notification('Vibeyard', {
29-
body: bodyForStatus(name, status),
30-
// A per-session tag keeps notifications for different sessions distinct at
31-
// the OS level (so same-named sessions don't coalesce and misroute clicks),
32-
// while a newer notification for the same session replaces its own stale one.
33-
tag: `vibeyard-session-${sessionId}`,
25+
const found = appState.findSessionWithProject(sessionId);
26+
27+
// Dismiss this session's previous banner ourselves — macOS gives every banner
28+
// its own identifier, so nothing replaces it automatically.
29+
dismiss(sessionId);
30+
31+
// Deliberately no `tag`: Chromium uses the tag as a non-persistent
32+
// notification's id, and registering a second notification under an id it
33+
// already knows destroys the first one's click listener — while that banner
34+
// is still sitting in Notification Center. Clicking it then does nothing but
35+
// raise the app. Without a tag each notification gets its own id and listener.
36+
const notification = new Notification(found?.project.name ?? 'Vibeyard', {
37+
body: bodyForStatus(found?.session.name ?? 'Session', status),
3438
silent: true,
3539
});
3640
activeNotifications.set(sessionId, notification);
3741

3842
notification.onclick = () => {
3943
window.vibeyard.app.focus();
40-
const project = appState.projects.find(p =>
41-
p.sessions.some(s => s.id === sessionId),
42-
);
44+
// Re-resolve rather than capturing `found`: the session may have been
45+
// renamed or moved, and capturing it would pin the whole ProjectRecord
46+
// alive for as long as this retained notification lives.
47+
const project = appState.findSessionWithProject(sessionId)?.project;
4348
if (project) {
4449
appState.setActiveProject(project.id);
4550
appState.setActiveSession(project.id, sessionId);
4651
}
47-
notification.close();
48-
activeNotifications.delete(sessionId);
52+
notification.close(); // `onclose` evicts the map entry.
4953
};
5054

5155
notification.onclose = () => {
52-
// Only drop the entry if it still points at this instance — a replacement
53-
// notification for the same session must not be clobbered.
54-
if (activeNotifications.get(sessionId) === notification) {
55-
activeNotifications.delete(sessionId);
56-
}
56+
// An OS-initiated close can arrive after a replacement was registered, so
57+
// only drop the entry if it still points at this instance.
58+
if (activeNotifications.get(sessionId) === notification) activeNotifications.delete(sessionId);
5759
};
5860
}
5961

@@ -78,8 +80,7 @@ export function initNotificationDesktop(): void {
7880
const sessionId = (data as { sessionId: string })?.sessionId;
7981
if (sessionId) {
8082
previousStatus.delete(sessionId);
81-
activeNotifications.get(sessionId)?.close();
82-
activeNotifications.delete(sessionId);
83+
dismiss(sessionId);
8384
}
8485
});
8586
}

‎src/renderer/state.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,19 @@ class AppState {
127127
this.nav.prune(sessionId);
128128
}
129129

130+
/** Resolve a session id to both the session and its owning project in one scan. */
131+
findSessionWithProject(
132+
sessionId: string,
133+
): { project: ProjectRecord; session: SessionRecord } | undefined {
134+
for (const project of this.state.projects) {
135+
const session = project.sessions.find((s) => s.id === sessionId);
136+
if (session) return { project, session };
137+
}
138+
return undefined;
139+
}
140+
130141
private findProjectBySession(sessionId: string): ProjectRecord | undefined {
131-
return this.state.projects.find((p) => p.sessions.some((s) => s.id === sessionId));
142+
return this.findSessionWithProject(sessionId)?.project;
132143
}
133144

134145
navigateBack(): void {
@@ -896,11 +907,7 @@ class AppState {
896907
}
897908

898909
private findSessionById(sessionId: string): SessionRecord | undefined {
899-
for (const project of this.state.projects) {
900-
const session = project.sessions.find((s) => s.id === sessionId);
901-
if (session) return session;
902-
}
903-
return undefined;
910+
return this.findSessionWithProject(sessionId)?.session;
904911
}
905912

906913
updateSessionCost(sessionId: string, cost: CostInfo): void {

0 commit comments

Comments
 (0)