diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx index 695378034f4b..3f8d6d1db57f 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx @@ -145,10 +145,14 @@ function toV2ThreadShell(input: typeof thread) { }; } -function shell(overrides: Partial = {}) { +function shell(overrides: Partial = {}, ...others: Partial[]) { return { status: "live", - snapshot: Option.some({ threads: [toV2ThreadShell({ ...thread, ...overrides })] }), + snapshot: Option.some({ + threads: [overrides, ...others].map((override) => + toV2ThreadShell({ ...thread, ...override }), + ), + }), }; } function complete(environment = "one", completedAt = "2026-09-13T08:00:00Z") { @@ -197,16 +201,43 @@ afterEach(async () => { vi.unstubAllGlobals(); }); -it("counts notifying threads across environments, replaces repeat alerts, and clears on focus", async () => { +it("uses bounded tags across threads and environments, replaces repeat alerts, and clears on focus", async () => { + const environmentId = "fd6fff30-f90b-4ee7-9812-4cf497791b90"; + const otherEnvironmentId = "25b62992-b996-4ee3-8d82-d988840e45c1"; + const threadId = "a8395a98-a186-4fe7-9d4c-fb8ba63b7588"; + const otherThreadId = "257598ad-ae4f-4422-a073-e03f4b4f5f0d"; + const completed = (id: string, completedAt = "2026-09-13T08:00:00Z") => ({ + id, + latestTurn: { turnId: "turn", state: "completed", completedAt }, + }); + state.environmentIds = [environmentId, otherEnvironmentId]; + state.shells.set(environmentId, shell({ id: threadId }, { id: otherThreadId })); + state.shells.set(otherEnvironmentId, shell({ id: threadId })); await render(); - complete(); + state.shells.set(environmentId, shell(completed(threadId), { id: otherThreadId })); await render(); expect(state.badge).toHaveBeenLastCalledWith(1); - complete("one", "2026-09-13T08:01:00Z"); - complete("two"); + state.shells.set(environmentId, shell(completed(threadId), completed(otherThreadId))); + state.shells.set(otherEnvironmentId, shell(completed(threadId))); await render(); - expect(state.badge).toHaveBeenLastCalledWith(2); - expect(TestNotification.sent[0]!.close).toHaveBeenCalledOnce(); + expect(state.badge).toHaveBeenLastCalledWith(3); + state.shells.set( + environmentId, + shell(completed(threadId, "2026-09-13T08:01:00Z"), completed(otherThreadId)), + ); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(3); + expect(TestNotification.sent).toHaveLength(4); + const [first, otherThread, otherEnvironment, replacement] = TestNotification.sent; + for (const notification of TestNotification.sent) { + expect(notification.tag).toMatch(/^[0-9a-f]{16}$/); + } + expect(new Set([first!.tag, otherThread!.tag, otherEnvironment!.tag]).size).toBe(3); + expect(replacement!.tag).toBe(first!.tag); + expect(first!.close).toHaveBeenCalledOnce(); + expect(otherThread!.close).not.toHaveBeenCalled(); + expect(otherEnvironment!.close).not.toHaveBeenCalled(); + expect(replacement!.close).not.toHaveBeenCalled(); focused = true; window.dispatchEvent(new Event("focus")); expect(state.badge).toHaveBeenLastCalledWith(0); @@ -214,7 +245,7 @@ it("counts notifying threads across environments, replaces repeat alerts, and cl TestNotification.sent.every((notification) => notification.close.mock.calls.length > 0), ).toBe(true); focused = false; - complete("two", "2026-09-13T08:02:00Z"); + state.shells.set(otherEnvironmentId, shell(completed(threadId, "2026-09-13T08:02:00Z"))); await render(); expect(state.badge).toHaveBeenLastCalledWith(1); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx index 93de18b6b3e3..d106c67baa91 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx @@ -282,7 +282,7 @@ describe("thread notifications", () => { expect(state.notification).toHaveBeenCalledTimes(1); expect(state.notification).toHaveBeenCalledWith(title, { body: "Fix the login form", - tag: "env-1:thread-1", + tag: expect.any(String), silent: true, }); }); @@ -355,7 +355,7 @@ describe("thread notifications", () => { expect(state.add).not.toHaveBeenCalled(); expect(state.notification).toHaveBeenCalledWith("Thread completed", { body: "Fix the login form", - tag: "env-1:thread-1", + tag: expect.any(String), silent: true, }); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index fab97abeeef0..bb7fdcc29a8b 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -23,6 +23,7 @@ import { hasNotificationSound, playNotificationSound, setNotificationBadge, + threadNotificationTag, unlockNotificationAudio, } from "../threadNotifications"; import { resolveSidebarThreadStatus } from "./Sidebar.logic"; @@ -218,7 +219,7 @@ function EnvironmentNotifications({ try { const notification = new Notification(title, { body: thread.title, - tag: `${environmentId}:${thread.id}`, + tag: threadNotificationTag(environmentId, thread.id), silent: true, }); onNotification(environmentId, notification); diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 3453a2931e94..0766f977d252 100644 --- a/apps/web/src/threadNotifications.ts +++ b/apps/web/src/threadNotifications.ts @@ -1,3 +1,4 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; import completionUrl from "./assets/notification-completion.mp3"; @@ -19,6 +20,21 @@ export function hasDesktopNotifications(mode: NotificationMode) { return mode === "notifications" || mode === "notifications-and-sound"; } +const TAG_HASH_OFFSET_BASIS = 0xcbf29ce484222325n; +const TAG_HASH_PRIME = 0x100000001b3n; +const TAG_HASH_MASK = 0xffffffffffffffffn; + +/** Windows drops toast notifications with the 73-character environment/thread tag. */ +export function threadNotificationTag(environmentId: EnvironmentId, threadId: ThreadId): string { + const key = `${environmentId}:${threadId}`; + let hash = TAG_HASH_OFFSET_BASIS; + for (let index = 0; index < key.length; index += 1) { + hash ^= BigInt(key.charCodeAt(index)); + hash = (hash * TAG_HASH_PRIME) & TAG_HASH_MASK; + } + return hash.toString(16).padStart(16, "0"); +} + let originalFavicon: HTMLLinkElement | undefined; let badgeFavicon: HTMLLinkElement | undefined;