diff --git a/AGENTS.md b/AGENTS.md index 87d2004f1b..5415fb0062 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,9 @@ yarn workspaces:build # Build all workspaces - `release/X.Y.x` branches are patch lines for a shipped stable version. Fixes land on `dev` first and are cherry-picked onto the release branch. A hotfix authored directly on a release branch must be forward-ported to - `dev` immediately via a cherry-pick PR. + `dev` in the same change or immediately after (cherry-pick PR). Do not + ship a stable cut from `dev` that is missing a hotfix that already + shipped on a release line. - Never back-merge `master` or a `release/X.Y.x` branch into `dev`. - Tags are created only via `yarn release:tag` (channel-aware guard). Release builds trigger on semver tag pushes only and always produce a @@ -135,6 +137,15 @@ yarn workspaces:build # Build all workspaces semantics and traps, Fuselage geometry/timing facts, the button-dimming and SVG transform-origin pitfalls, and layout rules learned in PRs #3441/#3443. +## Desktop Notifications + +- Windows / Electron 42 can emit `reply` twice for one toast (WinRT + COM + activation paths). Guard `NOTIFICATIONS_NOTIFICATION_REPLIED` with a + per-notification `Set`; re-arm (delete the id) on `show`; never clear on + `close` — the duplicate can arrive after dismiss, and clearing there + reopens the race. If Windows replies are routed through + `Notification.handleActivation`, that path must use the same `Set`. + ## Testing - Renderer specs use `*.spec.ts` / `*.spec.tsx`. diff --git a/src/notifications/main.ts b/src/notifications/main.ts index aa44d079cc..143bc3c323 100644 --- a/src/notifications/main.ts +++ b/src/notifications/main.ts @@ -48,6 +48,7 @@ const resolveIcon = async ( const notifications = new Map(); const notificationTypes = new Map(); const notificationCategories = new Map(); +const repliedNotifications = new Set(); const createNotification = async ( id: string, @@ -79,6 +80,8 @@ const createNotification = async ( }); notification.addListener('show', () => { + repliedNotifications.delete(id); + dispatchSingle({ type: NOTIFICATIONS_NOTIFICATION_SHOWN, payload: { id }, @@ -126,6 +129,12 @@ const createNotification = async ( }); notification.addListener('reply', (_event, reply) => { + // Electron 42 on Windows can dispatch a single toast reply twice (WinRT+COM activation paths) + if (repliedNotifications.has(id)) { + return; + } + repliedNotifications.add(id); + dispatchSingle({ type: NOTIFICATIONS_NOTIFICATION_REPLIED, payload: { id, reply }, diff --git a/src/notifications/main/setup.main.spec.ts b/src/notifications/main/setup.main.spec.ts index 4862ba1ed1..e6f6dd392b 100644 --- a/src/notifications/main/setup.main.spec.ts +++ b/src/notifications/main/setup.main.spec.ts @@ -233,4 +233,68 @@ describe('notifications/main setupNotifications', () => { expect(dispatch).not.toHaveBeenCalled(); expect(notificationInstances).toHaveLength(0); }); + + const repliedCalls = () => + dispatchSingle.mock.calls.filter( + ([action]) => action.type === NOTIFICATIONS_NOTIFICATION_REPLIED + ); + + it('dispatches REPLIED exactly once when the same reply event fires twice', async () => { + await create({ + title: 'Hello', + body: 'World', + tag: 'dedup-1', + canReply: true, + }); + const n = notificationInstances[0]; + + n.emit('reply', {}, 'hi there'); + n.emit('reply', {}, 'hi there'); + + expect(repliedCalls()).toHaveLength(1); + expect(repliedCalls()[0][0].payload).toEqual({ + id: 'dedup-1', + reply: 'hi there', + }); + }); + + it('re-arms the guard after another show event, allowing a subsequent reply to dispatch', async () => { + await create({ + title: 'Hello', + body: 'World', + tag: 'dedup-2', + canReply: true, + }); + const n = notificationInstances[0]; + + n.emit('reply', {}, 'first reply'); + n.emit('show'); + n.emit('reply', {}, 'second reply'); + + expect(repliedCalls()).toHaveLength(2); + expect(repliedCalls()[1][0].payload).toEqual({ + id: 'dedup-2', + reply: 'second reply', + }); + }); + + it('does not re-arm the reply guard on close (duplicate can arrive after dismiss)', async () => { + await create({ + title: 'Hello', + body: 'World', + tag: 'dedup-3', + canReply: true, + }); + const n = notificationInstances[0]; + + n.emit('reply', {}, 'once'); + n.emit('close'); + n.emit('reply', {}, 'late duplicate'); + + expect(repliedCalls()).toHaveLength(1); + expect(repliedCalls()[0][0].payload).toEqual({ + id: 'dedup-3', + reply: 'once', + }); + }); });