From 786d22217e962dce05f3334cdb7b4a59b539b774 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 22 Jul 2026 12:44:05 +0200 Subject: [PATCH] fix(notifications): dedupe repeated presentation Generated-By: PostHog Code Task-Id: 6c21c18f-a45b-405e-8122-eedee7ddd085 --- .../notifications/notifications.test.ts | 13 ++++++++++ .../features/notifications/notifications.ts | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts index 603b0bcff3..cac8806e6f 100644 --- a/packages/ui/src/features/notifications/notifications.test.ts +++ b/packages/ui/src/features/notifications/notifications.test.ts @@ -225,6 +225,19 @@ describe("notifyError", () => { }); describe("sound", () => { + it("presents an immediately repeated notification only once", () => { + const { bus, play } = makeBus({ + hasFocus: true, + activeTarget: taskTarget(OTHER_TASK_ID), + }); + + bus.notifyPromptComplete("My task", "end_turn", TASK_ID); + bus.notifyPromptComplete("My task", "end_turn", TASK_ID); + + expect(toastMock.success).toHaveBeenCalledTimes(1); + expect(play).toHaveBeenCalledTimes(1); + }); + it("plays on the toast tier too (not just native)", () => { const { bus, play } = makeBus({ hasFocus: true, diff --git a/packages/ui/src/features/notifications/notifications.ts b/packages/ui/src/features/notifications/notifications.ts index f1256ec238..cc2037c69b 100644 --- a/packages/ui/src/features/notifications/notifications.ts +++ b/packages/ui/src/features/notifications/notifications.ts @@ -21,6 +21,7 @@ import { import { routeNotification } from "./routeNotification"; const MAX_TITLE_LENGTH = 50; +const DUPLICATE_WINDOW_MS = 1_000; // In-app toast presentation for the focused-but-elsewhere tier. Only levels that // support an action link are allowed (the bus derives the action from `target`). @@ -56,6 +57,8 @@ export interface NotificationDescriptor { // only appears while the app is focused). @injectable() export class NotificationBus { + private readonly recentNotifications = new Map(); + constructor( @inject(NOTIFICATIONS_SERVICE) private readonly notifications: INotifications, @@ -72,6 +75,7 @@ export class NotificationBus { notificationTarget: descriptor.target, }); if (channel === "suppress") return; + if (this.isDuplicate(descriptor)) return; const settings = this.settings.get(); const playbackRate = @@ -164,6 +168,28 @@ export class NotificationBus { }); } + private isDuplicate(descriptor: NotificationDescriptor): boolean { + const key = JSON.stringify({ + title: descriptor.title, + body: descriptor.body, + target: descriptor.target, + toast: descriptor.toast, + silent: descriptor.silent, + soundDurationMs: descriptor.soundDurationMs, + }); + const now = Date.now(); + const previous = this.recentNotifications.get(key); + this.recentNotifications.set(key, now); + + for (const [recentKey, timestamp] of this.recentNotifications) { + if (now - timestamp > DUPLICATE_WINDOW_MS) { + this.recentNotifications.delete(recentKey); + } + } + + return previous !== undefined && now - previous <= DUPLICATE_WINDOW_MS; + } + private deriveAction( descriptor: NotificationDescriptor, ): { label: string; onClick: () => void } | undefined {