From 13ebd2589a2970ecd759fc90061e2e3d2d226f30 Mon Sep 17 00:00:00 2001 From: satyalyadav Date: Thu, 17 Sep 2026 11:53:15 -0700 Subject: [PATCH 1/5] fix(web): keep thread notification tags within the Windows toast limit Windows drops renderer notifications when the tag plus origin exceeds the platform toast budget, and the composite environmentId:threadId tag is 73 characters. Digest the pair into a 16-character tag so background thread alerts reach the Action Center again. Fixes #12287 --- .../ThreadNotificationCoordinator.test.tsx | 7 +++-- .../ThreadNotificationCoordinator.tsx | 21 +++++++++------ apps/web/src/threadNotifications.test.ts | 27 +++++++++++++++++++ apps/web/src/threadNotifications.ts | 21 +++++++++++++++ 4 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/threadNotifications.test.ts diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 860b6389dc12..036c7d432bc1 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -1,9 +1,12 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; import * as Option from "effect/Option"; import { act } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { threadNotificationTag } from "../threadNotifications"; + const state = vi.hoisted(() => ({ mode: "off" as ClientSettings["notificationMode"], inApp: true, @@ -187,7 +190,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: threadNotificationTag("env-1" as EnvironmentId, "thread-1" as ThreadId), silent: true, }); }); @@ -247,7 +250,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: threadNotificationTag("env-1" as EnvironmentId, "thread-1" as ThreadId), silent: true, }); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 5feda71ea8f3..4b433b520b1c 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -12,6 +12,7 @@ import { hasNotificationSound, playNotificationSound, setNotificationBadge, + threadNotificationTag, unlockNotificationAudio, } from "../threadNotifications"; import { resolveSidebarThreadStatus } from "./Sidebar.logic"; @@ -26,11 +27,14 @@ export function ThreadNotificationCoordinator() { const pending = useRef( new Map(), ); - const onNotification = useCallback((environmentId: EnvironmentId, notification: Notification) => { - pending.current.get(notification.tag)?.notification.close(); - pending.current.set(notification.tag, { environmentId, notification }); - setNotificationBadge(pending.current.size); - }, []); + const onNotification = useCallback( + (environmentId: EnvironmentId, tag: string, notification: Notification) => { + pending.current.get(tag)?.notification.close(); + pending.current.set(tag, { environmentId, notification }); + setNotificationBadge(pending.current.size); + }, + [], + ); useEffect(() => { const activeIds = new Set(environments.map(({ environmentId }) => environmentId)); @@ -86,7 +90,7 @@ function EnvironmentNotifications({ onNotification, }: { environmentId: EnvironmentId; - onNotification: (environmentId: EnvironmentId, notification: Notification) => void; + onNotification: (environmentId: EnvironmentId, tag: string, notification: Notification) => void; }) { const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); const mode = useClientSettings((settings) => settings.notificationMode); @@ -176,12 +180,13 @@ function EnvironmentNotifications({ ) continue; try { + const tag = threadNotificationTag(environmentId, thread.id); const notification = new Notification(title, { body: thread.title, - tag: `${environmentId}:${thread.id}`, + tag, silent: true, }); - onNotification(environmentId, notification); + onNotification(environmentId, tag, notification); notification.addEventListener("click", () => { notification.close(); window.focus(); diff --git a/apps/web/src/threadNotifications.test.ts b/apps/web/src/threadNotifications.test.ts new file mode 100644 index 000000000000..744aea4892c8 --- /dev/null +++ b/apps/web/src/threadNotifications.test.ts @@ -0,0 +1,27 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { threadNotificationTag } from "./threadNotifications"; + +const environmentId = "fd6fff30-f90b-4ee7-9812-4cf497791b90" as EnvironmentId; +const threadId = "a8395a98-a186-4fe7-9d4c-fb8ba63b7588" as ThreadId; + +describe("threadNotificationTag", () => { + it("stays under the platform toast tag budget", () => { + const tag = threadNotificationTag(environmentId, threadId); + expect(tag).toHaveLength(16); + expect(tag.length).toBeLessThanOrEqual(32); + }); + + it("is stable for the same environment and thread", () => { + expect(threadNotificationTag(environmentId, threadId)).toBe( + threadNotificationTag(environmentId, threadId), + ); + }); + + it("separates environments and threads", () => { + const tag = threadNotificationTag(environmentId, threadId); + expect(threadNotificationTag("env-2" as EnvironmentId, threadId)).not.toBe(tag); + expect(threadNotificationTag(environmentId, "thread-2" as ThreadId)).not.toBe(tag); + }); +}); diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 2f9d45497668..5a039fbaf9ba 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,26 @@ 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 silently drops renderer notifications when the tag plus origin + * exceeds the platform toast budget (electron/electron#40433), and the raw + * `environmentId:threadId` composite is 73 characters. Digest the pair so + * environment and thread uniqueness survive within a short 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; From 6c6ebf2827995e6f068a05fa02be60171ec3bea6 Mon Sep 17 00:00:00 2001 From: satyalyadav Date: Sun, 11 Oct 2026 15:03:34 -0700 Subject: [PATCH 2/5] fix(web): shorten thread notification tags --- ...readNotificationCoordinator.badge.test.tsx | 54 +++++++++++++++++++ .../ThreadNotificationCoordinator.test.tsx | 7 ++- .../ThreadNotificationCoordinator.tsx | 3 +- apps/web/src/threadNotifications.test.ts | 23 ++++++++ apps/web/src/threadNotifications.ts | 16 ++++++ 5 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/threadNotifications.test.ts diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx index f7f600986a0b..ad789d517bf7 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx @@ -184,7 +184,12 @@ it("counts notifying threads across environments, replaces repeat alerts, and cl complete("two"); await render(); expect(state.badge).toHaveBeenLastCalledWith(2); + expect(TestNotification.sent).toHaveLength(3); + expect(TestNotification.sent[0]!.tag).toBe(TestNotification.sent[1]!.tag); + expect(TestNotification.sent[0]!.tag).not.toBe(TestNotification.sent[2]!.tag); expect(TestNotification.sent[0]!.close).toHaveBeenCalledOnce(); + expect(TestNotification.sent[1]!.close).not.toHaveBeenCalled(); + expect(TestNotification.sent[2]!.close).not.toHaveBeenCalled(); focused = true; window.dispatchEvent(new Event("focus")); expect(state.badge).toHaveBeenLastCalledWith(0); @@ -197,6 +202,55 @@ it("counts notifying threads across environments, replaces repeat alerts, and cl expect(state.badge).toHaveBeenLastCalledWith(1); }); +function shellWithThreads(...overrides: ReadonlyArray>) { + return { + status: "live", + snapshot: Option.some({ + threads: overrides.map((override) => toV2ThreadShell({ ...thread, ...override })), + }), + }; +} + +it("uses bounded tags while replacing the same thread's alert and keeping another thread separate", async () => { + const environmentId = "fd6fff30-f90b-4ee7-9812-4cf497791b90"; + const threadId = "a8395a98-a186-4fe7-9d4c-fb8ba63b7588"; + const otherThreadId = "257598ad-ae4f-4422-a073-e03f4b4f5f0d"; + const completed = (id: string, completedAt: string) => ({ + id, + latestTurn: { turnId: id, state: "completed" as const, completedAt }, + }); + + state.environmentIds = [environmentId]; + state.shells.set(environmentId, shellWithThreads({ id: threadId }, { id: otherThreadId })); + await render(); + + state.shells.set( + environmentId, + shellWithThreads(completed(threadId, "2026-09-13T08:00:00Z"), { id: otherThreadId }), + ); + await render(); + + state.shells.set( + environmentId, + shellWithThreads( + completed(threadId, "2026-09-13T08:01:00Z"), + completed(otherThreadId, "2026-09-13T08:00:00Z"), + ), + ); + await render(); + + const [first, replacement, otherThread] = TestNotification.sent; + expect(TestNotification.sent).toHaveLength(3); + expect(first!.tag).toMatch(/^[0-9a-f]{16}$/); + expect(replacement!.tag).toBe(first!.tag); + expect(otherThread!.tag).toMatch(/^[0-9a-f]{16}$/); + expect(otherThread!.tag).not.toBe(first!.tag); + expect(first!.close).toHaveBeenCalledOnce(); + expect(replacement!.close).not.toHaveBeenCalled(); + expect(otherThread!.close).not.toHaveBeenCalled(); + expect(state.badge).toHaveBeenLastCalledWith(2); +}); + it("does not badge old completions on first load or reconnect", async () => { complete(); await render(); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 36709813ef0e..60f6581eca48 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -1,3 +1,4 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; @@ -5,6 +6,8 @@ import { act } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { threadNotificationTag } from "../threadNotifications"; + const state = vi.hoisted(() => ({ mode: "off" as ClientSettings["notificationMode"], inApp: true, @@ -246,7 +249,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: threadNotificationTag("env-1" as EnvironmentId, "thread-1" as ThreadId), silent: true, }); }); @@ -319,7 +322,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: threadNotificationTag("env-1" as EnvironmentId, "thread-1" as ThreadId), silent: true, }); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 509483d3bccd..44dd1aea14b5 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -19,6 +19,7 @@ import { hasNotificationSound, playNotificationSound, setNotificationBadge, + threadNotificationTag, unlockNotificationAudio, } from "../threadNotifications"; import { resolveSidebarThreadStatus } from "./Sidebar.logic"; @@ -215,7 +216,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.test.ts b/apps/web/src/threadNotifications.test.ts new file mode 100644 index 000000000000..004e9bef3f6f --- /dev/null +++ b/apps/web/src/threadNotifications.test.ts @@ -0,0 +1,23 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { threadNotificationTag } from "./threadNotifications"; + +const environmentId = "fd6fff30-f90b-4ee7-9812-4cf497791b90" as EnvironmentId; +const otherEnvironmentId = "25b62992-b996-4ee3-8d82-d988840e45c1" as EnvironmentId; +const threadId = "a8395a98-a186-4fe7-9d4c-fb8ba63b7588" as ThreadId; +const otherThreadId = "257598ad-ae4f-4422-a073-e03f4b4f5f0d" as ThreadId; + +describe("threadNotificationTag", () => { + it("returns a stable tag within the platform budget", () => { + const tag = threadNotificationTag(environmentId, threadId); + expect(tag).toMatch(/^[0-9a-f]{16}$/); + expect(threadNotificationTag(environmentId, threadId)).toBe(tag); + }); + + it("separates environments and threads", () => { + const tag = threadNotificationTag(environmentId, threadId); + expect(threadNotificationTag(otherEnvironmentId, threadId)).not.toBe(tag); + expect(threadNotificationTag(environmentId, otherThreadId)).not.toBe(tag); + }); +}); diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 2f9d45497668..557bdae52751 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; From 05c0fe87935ead1941f45950e6e70d149b0b1707 Mon Sep 17 00:00:00 2001 From: satyalyadav Date: Mon, 12 Oct 2026 18:10:46 -0700 Subject: [PATCH 3/5] fix(desktop): restore the window when opening thread notifications --- .../src/electron/ElectronWindow.test.ts | 20 +++++++++++++++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 ++ apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.test.ts | 25 +++++++++++++++++++ apps/desktop/src/ipc/methods/window.ts | 18 +++++++++++++ apps/desktop/src/preload.ts | 1 + ...readNotificationCoordinator.badge.test.tsx | 18 +++++++++++++ .../ThreadNotificationCoordinator.tsx | 1 + packages/contracts/src/ipc.ts | 2 ++ 9 files changed, 88 insertions(+) diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index ef94312ed6f0..fa7e35d57a8e 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -548,6 +548,26 @@ describe("ElectronWindow", () => { }).pipe(Effect.provide(layerTestFor("win32"))), ); + it.effect("restores, shows, and focuses a minimized hidden window on Windows", () => + Effect.gen(function* () { + const operations: string[] = []; + const window = { + ...makeWindowsRevealWindow(), + isMinimized: vi.fn(() => true), + isVisible: vi.fn(() => false), + restore: vi.fn(() => operations.push("restore")), + show: vi.fn(() => operations.push("show")), + focus: vi.fn(() => operations.push("focus")), + }; + const electronWindow = yield* ElectronWindow.ElectronWindow; + + yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); + + assert.deepEqual(operations, ["restore", "show", "focus"]); + assert.lengthOf(activateWindowsForegroundMock.mock.calls, 0); + }).pipe(Effect.provide(layerTestFor("win32"))), + ); + it.effect("a capture reveal on Windows uses the Win32 path only once", () => Effect.gen(function* () { const window = makeWindowsRevealWindow(); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index caed223e90e8..afef122e3ee4 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -51,6 +51,7 @@ import { pickFolder, pickProjectFavicon, pickThemeFiles, + revealMainWindow, setTheme, showContextMenu, } from "./methods/window.ts"; @@ -100,6 +101,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); yield* ipc.handle(getLocalEnvironmentBearerToken); + yield* ipc.handle(revealMainWindow); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index a6b617385b57..e56410610b06 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,4 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const REVEAL_MAIN_WINDOW_CHANNEL = "desktop:reveal-main-window"; export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index d21f3cd60505..11e2db0448ad 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -24,6 +24,7 @@ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import { getLocalEnvironmentBootstraps, @@ -31,6 +32,7 @@ import { pasteAsText, pickProjectFavicon, probeRemoteEditors, + revealMainWindow, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -344,3 +346,26 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( assert.include(editors, "webstorm"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +describe("revealMainWindow", () => { + it.effect("activates the main window only for its renderer", () => { + const activations: string[] = []; + return Effect.gen(function* () { + yield* revealMainWindow.handler(undefined, { sender: { id: 8 } }); + yield* revealMainWindow.handler(undefined); + yield* revealMainWindow.handler(undefined, { sender: { id: 7 } }); + assert.deepEqual(activations, ["activate"]); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(ElectronWindow.ElectronWindow, { + main: Effect.succeedSome({ webContents: { id: 7 } }), + } as ElectronWindow.ElectronWindow["Service"]), + Layer.succeed(DesktopWindow.DesktopWindow, { + activate: Effect.sync(() => activations.push("activate")), + } as unknown as DesktopWindow.DesktopWindow["Service"]), + ), + ), + ); + }); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 33895ffffbd1..a72486c5c3c2 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -35,6 +35,7 @@ import * as ElectronMenu from "../../electron/ElectronMenu.ts"; import * as ElectronShell from "../../electron/ElectronShell.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as Electron from "electron"; import * as MacPermissions from "../../permissions/MacPermissions.ts"; import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermission.ts"; @@ -90,6 +91,23 @@ export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ }), }); +export const revealMainWindow = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.REVEAL_MAIN_WINDOW_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.revealMainWindow")(function* (_input, event) { + const main = yield* (yield* ElectronWindow.ElectronWindow).main; + if ( + event === undefined || + Option.isNone(main) || + main.value.webContents.id !== event.sender.id + ) { + return; + } + yield* (yield* DesktopWindow.DesktopWindow).activate; + }), +}); + export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index efd49ffa3864..4943fbaf218f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -88,6 +88,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { getClientPlatform: () => clientPlatform, setNotificationBadge: (badge) => ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge), + revealMainWindow: () => ipcRenderer.invoke(IpcChannels.REVEAL_MAIN_WINDOW_CHANNEL), onNotificationBadgeClear: (listener) => { const handler = () => listener(); ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx index ad789d517bf7..987a0a17f0c1 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx @@ -338,6 +338,7 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( expect(state.badge).toHaveBeenLastCalledWith(1); const notification = TestNotification.sent[0]!; notification.dispatchEvent(new Event("click")); + expect(window.focus).toHaveBeenCalledOnce(); expect(state.navigate).toHaveBeenCalledWith({ to: "/$environmentId/$threadId", params: { environmentId: EnvironmentId.make("one"), threadId: "thread" }, @@ -349,6 +350,23 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( }, ); +it("asks the desktop app to reveal its window and still opens the notified thread", async () => { + await render(); + state.shells.set("one", shell({ latestTurn: { ...thread.latestTurn, state: "completed" } })); + await render(); + const revealMainWindow = vi.fn().mockRejectedValue(new Error("window reveal failed")); + Object.assign(window, { desktopBridge: { revealMainWindow } }); + + TestNotification.sent[0]!.dispatchEvent(new Event("click")); + await Promise.resolve(); + + expect(revealMainWindow).toHaveBeenCalledOnce(); + expect(state.navigate).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { environmentId: EnvironmentId.make("one"), threadId: "thread" }, + }); +}); + it("shows in-app alerts without adding a badge while focused", async () => { state.inApp = true; focused = true; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 44dd1aea14b5..d3856d9cd153 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -222,6 +222,7 @@ function EnvironmentNotifications({ onNotification(environmentId, notification); notification.addEventListener("click", () => { notification.close(); + void window.desktopBridge?.revealMainWindow?.().catch(() => undefined); window.focus(); void navigate({ to: "/$environmentId/$threadId", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e521c4c37271..b4c535dca73d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1121,6 +1121,8 @@ export interface DesktopBridge { getPathForFile?: (file: File) => string; /** The desktop client's OS platform, read from Electron's preload process. */ getClientPlatform?: () => string; + /** Reveals and focuses the main desktop window. */ + revealMainWindow?: () => Promise; setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise; onNotificationBadgeClear?: (listener: () => void) => () => void; onTrackpadScrollEnd?: (listener: () => void) => () => void; From 54a342009f752df764834ef30080bee3d19171e7 Mon Sep 17 00:00:00 2001 From: satyalyadav Date: Mon, 12 Oct 2026 18:29:21 -0700 Subject: [PATCH 4/5] Revert "fix(desktop): restore the window when opening thread notifications" This reverts commit 05c0fe87935ead1941f45950e6e70d149b0b1707. --- .../src/electron/ElectronWindow.test.ts | 20 --------------- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 -- apps/desktop/src/ipc/channels.ts | 1 - apps/desktop/src/ipc/methods/window.test.ts | 25 ------------------- apps/desktop/src/ipc/methods/window.ts | 18 ------------- apps/desktop/src/preload.ts | 1 - ...readNotificationCoordinator.badge.test.tsx | 18 ------------- .../ThreadNotificationCoordinator.tsx | 1 - packages/contracts/src/ipc.ts | 2 -- 9 files changed, 88 deletions(-) diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index fa7e35d57a8e..ef94312ed6f0 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -548,26 +548,6 @@ describe("ElectronWindow", () => { }).pipe(Effect.provide(layerTestFor("win32"))), ); - it.effect("restores, shows, and focuses a minimized hidden window on Windows", () => - Effect.gen(function* () { - const operations: string[] = []; - const window = { - ...makeWindowsRevealWindow(), - isMinimized: vi.fn(() => true), - isVisible: vi.fn(() => false), - restore: vi.fn(() => operations.push("restore")), - show: vi.fn(() => operations.push("show")), - focus: vi.fn(() => operations.push("focus")), - }; - const electronWindow = yield* ElectronWindow.ElectronWindow; - - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.deepEqual(operations, ["restore", "show", "focus"]); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 0); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - it.effect("a capture reveal on Windows uses the Win32 path only once", () => Effect.gen(function* () { const window = makeWindowsRevealWindow(); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index afef122e3ee4..caed223e90e8 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -51,7 +51,6 @@ import { pickFolder, pickProjectFavicon, pickThemeFiles, - revealMainWindow, setTheme, showContextMenu, } from "./methods/window.ts"; @@ -101,7 +100,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); yield* ipc.handle(getLocalEnvironmentBearerToken); - yield* ipc.handle(revealMainWindow); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index e56410610b06..a6b617385b57 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,5 +1,4 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; -export const REVEAL_MAIN_WINDOW_CHANNEL = "desktop:reveal-main-window"; export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 11e2db0448ad..d21f3cd60505 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -24,7 +24,6 @@ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import { getLocalEnvironmentBootstraps, @@ -32,7 +31,6 @@ import { pasteAsText, pickProjectFavicon, probeRemoteEditors, - revealMainWindow, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -346,26 +344,3 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( assert.include(editors, "webstorm"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - -describe("revealMainWindow", () => { - it.effect("activates the main window only for its renderer", () => { - const activations: string[] = []; - return Effect.gen(function* () { - yield* revealMainWindow.handler(undefined, { sender: { id: 8 } }); - yield* revealMainWindow.handler(undefined); - yield* revealMainWindow.handler(undefined, { sender: { id: 7 } }); - assert.deepEqual(activations, ["activate"]); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeedSome({ webContents: { id: 7 } }), - } as ElectronWindow.ElectronWindow["Service"]), - Layer.succeed(DesktopWindow.DesktopWindow, { - activate: Effect.sync(() => activations.push("activate")), - } as unknown as DesktopWindow.DesktopWindow["Service"]), - ), - ), - ); - }); -}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a72486c5c3c2..33895ffffbd1 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -35,7 +35,6 @@ import * as ElectronMenu from "../../electron/ElectronMenu.ts"; import * as ElectronShell from "../../electron/ElectronShell.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as Electron from "electron"; import * as MacPermissions from "../../permissions/MacPermissions.ts"; import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermission.ts"; @@ -91,23 +90,6 @@ export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ }), }); -export const revealMainWindow = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.REVEAL_MAIN_WINDOW_CHANNEL, - payload: Schema.Void, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.window.revealMainWindow")(function* (_input, event) { - const main = yield* (yield* ElectronWindow.ElectronWindow).main; - if ( - event === undefined || - Option.isNone(main) || - main.value.webContents.id !== event.sender.id - ) { - return; - } - yield* (yield* DesktopWindow.DesktopWindow).activate; - }), -}); - export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4943fbaf218f..efd49ffa3864 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -88,7 +88,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { getClientPlatform: () => clientPlatform, setNotificationBadge: (badge) => ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge), - revealMainWindow: () => ipcRenderer.invoke(IpcChannels.REVEAL_MAIN_WINDOW_CHANNEL), onNotificationBadgeClear: (listener) => { const handler = () => listener(); ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx index 987a0a17f0c1..ad789d517bf7 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx @@ -338,7 +338,6 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( expect(state.badge).toHaveBeenLastCalledWith(1); const notification = TestNotification.sent[0]!; notification.dispatchEvent(new Event("click")); - expect(window.focus).toHaveBeenCalledOnce(); expect(state.navigate).toHaveBeenCalledWith({ to: "/$environmentId/$threadId", params: { environmentId: EnvironmentId.make("one"), threadId: "thread" }, @@ -350,23 +349,6 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( }, ); -it("asks the desktop app to reveal its window and still opens the notified thread", async () => { - await render(); - state.shells.set("one", shell({ latestTurn: { ...thread.latestTurn, state: "completed" } })); - await render(); - const revealMainWindow = vi.fn().mockRejectedValue(new Error("window reveal failed")); - Object.assign(window, { desktopBridge: { revealMainWindow } }); - - TestNotification.sent[0]!.dispatchEvent(new Event("click")); - await Promise.resolve(); - - expect(revealMainWindow).toHaveBeenCalledOnce(); - expect(state.navigate).toHaveBeenCalledWith({ - to: "/$environmentId/$threadId", - params: { environmentId: EnvironmentId.make("one"), threadId: "thread" }, - }); -}); - it("shows in-app alerts without adding a badge while focused", async () => { state.inApp = true; focused = true; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index d3856d9cd153..44dd1aea14b5 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -222,7 +222,6 @@ function EnvironmentNotifications({ onNotification(environmentId, notification); notification.addEventListener("click", () => { notification.close(); - void window.desktopBridge?.revealMainWindow?.().catch(() => undefined); window.focus(); void navigate({ to: "/$environmentId/$threadId", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b4c535dca73d..e521c4c37271 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1121,8 +1121,6 @@ export interface DesktopBridge { getPathForFile?: (file: File) => string; /** The desktop client's OS platform, read from Electron's preload process. */ getClientPlatform?: () => string; - /** Reveals and focuses the main desktop window. */ - revealMainWindow?: () => Promise; setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise; onNotificationBadgeClear?: (listener: () => void) => () => void; onTrackpadScrollEnd?: (listener: () => void) => () => void; From e790dc063a5b6efb3decc13be9fa3563488b592d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:56:39 +0000 Subject: [PATCH 5/5] test(web): consolidate notification tag regression coverage --- ...ficationCoordinator.badge.browser.test.tsx | 101 +++++++----------- ...adNotificationCoordinator.browser.test.tsx | 7 +- apps/web/src/threadNotifications.test.ts | 23 ---- 3 files changed, 41 insertions(+), 90 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx index e811c1c644d3..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,80 +201,53 @@ afterEach(async () => { vi.unstubAllGlobals(); }); -it("counts notifying threads across environments, replaces repeat alerts, and clears on focus", async () => { - await render(); - complete(); - await render(); - expect(state.badge).toHaveBeenLastCalledWith(1); - complete("one", "2026-09-13T08:01:00Z"); - complete("two"); - await render(); - expect(state.badge).toHaveBeenLastCalledWith(2); - expect(TestNotification.sent).toHaveLength(3); - expect(TestNotification.sent[0]!.tag).toBe(TestNotification.sent[1]!.tag); - expect(TestNotification.sent[0]!.tag).not.toBe(TestNotification.sent[2]!.tag); - expect(TestNotification.sent[0]!.close).toHaveBeenCalledOnce(); - expect(TestNotification.sent[1]!.close).not.toHaveBeenCalled(); - expect(TestNotification.sent[2]!.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"); - await render(); - expect(state.badge).toHaveBeenLastCalledWith(1); -}); - -function shellWithThreads(...overrides: ReadonlyArray>) { - return { - status: "live", - snapshot: Option.some({ - threads: overrides.map((override) => toV2ThreadShell({ ...thread, ...override })), - }), - }; -} - -it("uses bounded tags while replacing the same thread's alert and keeping another thread separate", 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: string) => ({ + const completed = (id: string, completedAt = "2026-09-13T08:00:00Z") => ({ id, - latestTurn: { turnId: id, state: "completed" as const, completedAt }, + latestTurn: { turnId: "turn", state: "completed", completedAt }, }); - - state.environmentIds = [environmentId]; - state.shells.set(environmentId, shellWithThreads({ id: threadId }, { id: otherThreadId })); + state.environmentIds = [environmentId, otherEnvironmentId]; + state.shells.set(environmentId, shell({ id: threadId }, { id: otherThreadId })); + state.shells.set(otherEnvironmentId, shell({ id: threadId })); await render(); - - state.shells.set( - environmentId, - shellWithThreads(completed(threadId, "2026-09-13T08:00:00Z"), { id: otherThreadId }), - ); + state.shells.set(environmentId, shell(completed(threadId), { id: otherThreadId })); await render(); - + expect(state.badge).toHaveBeenLastCalledWith(1); + state.shells.set(environmentId, shell(completed(threadId), completed(otherThreadId))); + state.shells.set(otherEnvironmentId, shell(completed(threadId))); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(3); state.shells.set( environmentId, - shellWithThreads( - completed(threadId, "2026-09-13T08:01:00Z"), - completed(otherThreadId, "2026-09-13T08:00:00Z"), - ), + shell(completed(threadId, "2026-09-13T08:01:00Z"), completed(otherThreadId)), ); await render(); - - const [first, replacement, otherThread] = TestNotification.sent; - expect(TestNotification.sent).toHaveLength(3); - expect(first!.tag).toMatch(/^[0-9a-f]{16}$/); + 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(otherThread!.tag).toMatch(/^[0-9a-f]{16}$/); - expect(otherThread!.tag).not.toBe(first!.tag); expect(first!.close).toHaveBeenCalledOnce(); - expect(replacement!.close).not.toHaveBeenCalled(); expect(otherThread!.close).not.toHaveBeenCalled(); - expect(state.badge).toHaveBeenLastCalledWith(2); + 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; + state.shells.set(otherEnvironmentId, shell(completed(threadId, "2026-09-13T08:02:00Z"))); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(1); }); it("does not badge old completions on first load or reconnect", async () => { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx index 7f4198c135e1..d106c67baa91 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx @@ -1,4 +1,3 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; @@ -6,8 +5,6 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { threadNotificationTag } from "../threadNotifications"; - const state = vi.hoisted(() => ({ mode: "off" as ClientSettings["notificationMode"], inApp: true, @@ -285,7 +282,7 @@ describe("thread notifications", () => { expect(state.notification).toHaveBeenCalledTimes(1); expect(state.notification).toHaveBeenCalledWith(title, { body: "Fix the login form", - tag: threadNotificationTag("env-1" as EnvironmentId, "thread-1" as ThreadId), + tag: expect.any(String), silent: true, }); }); @@ -358,7 +355,7 @@ describe("thread notifications", () => { expect(state.add).not.toHaveBeenCalled(); expect(state.notification).toHaveBeenCalledWith("Thread completed", { body: "Fix the login form", - tag: threadNotificationTag("env-1" as EnvironmentId, "thread-1" as ThreadId), + tag: expect.any(String), silent: true, }); }); diff --git a/apps/web/src/threadNotifications.test.ts b/apps/web/src/threadNotifications.test.ts index c266c8b81f70..0da83eb281e4 100644 --- a/apps/web/src/threadNotifications.test.ts +++ b/apps/web/src/threadNotifications.test.ts @@ -98,26 +98,3 @@ describe("playNotificationSound", () => { expect(audio.started).toBe(1); }); }); - -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; - -import { threadNotificationTag } from "./threadNotifications"; - -const environmentId = "fd6fff30-f90b-4ee7-9812-4cf497791b90" as EnvironmentId; -const otherEnvironmentId = "25b62992-b996-4ee3-8d82-d988840e45c1" as EnvironmentId; -const threadId = "a8395a98-a186-4fe7-9d4c-fb8ba63b7588" as ThreadId; -const otherThreadId = "257598ad-ae4f-4422-a073-e03f4b4f5f0d" as ThreadId; - -describe("threadNotificationTag", () => { - it("returns a stable tag within the platform budget", () => { - const tag = threadNotificationTag(environmentId, threadId); - expect(tag).toMatch(/^[0-9a-f]{16}$/); - expect(threadNotificationTag(environmentId, threadId)).toBe(tag); - }); - - it("separates environments and threads", () => { - const tag = threadNotificationTag(environmentId, threadId); - expect(threadNotificationTag(otherEnvironmentId, threadId)).not.toBe(tag); - expect(threadNotificationTag(environmentId, otherThreadId)).not.toBe(tag); - }); -});