Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/ui/src/features/notifications/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions packages/ui/src/features/notifications/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -56,6 +57,8 @@ export interface NotificationDescriptor {
// only appears while the app is focused).
@injectable()
export class NotificationBus {
private readonly recentNotifications = new Map<string, number>();

constructor(
@inject(NOTIFICATIONS_SERVICE)
private readonly notifications: INotifications,
Expand All @@ -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 =
Expand Down Expand Up @@ -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 {
Expand Down
Loading