Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,14 @@ function toV2ThreadShell(input: typeof thread) {
};
}

function shell(overrides: Partial<typeof thread> = {}) {
function shell(overrides: Partial<typeof thread> = {}, ...others: Partial<typeof thread>[]) {
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") {
Expand Down Expand Up @@ -197,24 +201,51 @@ 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);
expect(
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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
Expand Down Expand Up @@ -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,
});
});
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/ThreadNotificationCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
hasNotificationSound,
playNotificationSound,
setNotificationBadge,
threadNotificationTag,
unlockNotificationAudio,
} from "../threadNotifications";
import { resolveSidebarThreadStatus } from "./Sidebar.logic";
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/threadNotifications.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;

Expand Down
Loading