Skip to content
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
4 changes: 2 additions & 2 deletions apps/web/src/components/ThreadNotificationCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
hasNotificationSound,
playNotificationSound,
setNotificationBadge,
threadNotificationTag,
notificationTag,
unlockNotificationAudio,
} from "../threadNotifications";
import { resolveSidebarThreadStatus, type SidebarThreadStatus } from "./Sidebar.logic";
Expand Down Expand Up @@ -196,7 +196,7 @@ export function startThreadNotifications(
try {
const notification = new Notification(title, {
body: thread.title,
tag: threadNotificationTag(environmentId, thread.id),
tag: notificationTag(`${environmentId}:${thread.id}`),
silent: true,
// Windows drops clicks after a toast moves into Notification Center.
requireInteraction: kind === "completion" && isWindowsPlatform(navigator.platform),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import {
type PullRequestRef,
type PullRequestReviewGuideResult,
} from "@t3tools/contracts";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";

import {
__resetClientSettingsPersistenceForTests,
__setClientSettingsForTests,
} from "~/hooks/useSettings";
import { guideRunKey, useGuideRunStore } from "./guideRunStore";
import { startGuideRunNotifications } from "./PullRequestGuideRunCoordinator";

Expand Down Expand Up @@ -120,3 +125,51 @@ it("does not report the previous run's failure when a Running answer starts a wa
expect(useGuideRunStore.getState().watches[key]).toBe(undefined);
stop();
});

it("gives each finished guide's notification its own tag within the Windows toast limit", () => {
using _cleanup = {
[Symbol.dispose]: () => {
vi.unstubAllGlobals();
__resetClientSettingsPersistenceForTests();
},
};
const tags: string[] = [];
vi.stubGlobal("document", { visibilityState: "hidden", hasFocus: () => false });
vi.stubGlobal(
"Notification",
class {
static permission = "granted";
constructor(_title: string, options: NotificationOptions) {
tags.push(options.tag!);
}
addEventListener() {}
},
);
__setClientSettingsForTests({ ...DEFAULT_CLIENT_SETTINGS, notificationMode: "notifications" });
const registry = AtomRegistry.make();
const stop = startGuideRunNotifications(registry, { navigate: vi.fn() } as unknown as AnyRouter);
const uuidEnvironmentId = EnvironmentId.make("fd6fff30-f90b-4ee7-9812-4cf497791b90");
const written = {
guide: { generatedAt: "2026-10-01T00:00:00.000Z" },
run: null,
} as unknown as PullRequestReviewGuideResult;
for (const number of [1234, 1235]) {
registry.set(guideAtom(`${uuidEnvironmentId}:${number}`), AsyncResult.success(written));
useGuideRunStore.getState().watch({
environmentId: uuidEnvironmentId,
reference: {
projectId: ProjectId.make("a8395a98-a186-4fe7-9d4c-fb8ba63b7588"),
repository: "pingdotgg/t3code",
number,
},
title: null,
previousGuideAt: null,
previousFailedAt: null,
since: 0,
});
}
expect(tags).toHaveLength(2);
for (const tag of tags) expect(tag.length).toBeLessThanOrEqual(64);
expect(new Set(tags).size).toBe(2);
stop();
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { observedPullRequestTitle, pullRequestEnvironment } from "~/state/pullRe
import {
hasDesktopNotifications,
hasNotificationSound,
notificationTag,
playNotificationSound,
} from "~/threadNotifications";

Expand Down Expand Up @@ -145,7 +146,11 @@ function watchGuideRun(
)
return;
try {
const notification = new Notification(title, { body, tag: `guide:${runKey}`, silent: true });
const notification = new Notification(title, {
body,
tag: notificationTag(`guide:${runKey}`),
silent: true,
});
notification.addEventListener("click", () => {
notification.close();
window.focus();
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/threadNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import type { ClientSettings } from "@t3tools/contracts/settings";

import completionUrl from "./assets/notification-completion.mp3";
Expand All @@ -24,9 +23,8 @@ 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}`;
/** Windows drops toast notifications whose tag is longer than 64 characters. */
export function notificationTag(key: string): string {
let hash = TAG_HASH_OFFSET_BASIS;
for (let index = 0; index < key.length; index += 1) {
hash ^= BigInt(key.charCodeAt(index));
Expand Down
Loading