diff --git a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx index 2046a3fc5aa0..12e79d4a26dc 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx @@ -11,6 +11,7 @@ const state = vi.hoisted(() => ({ active: { environmentId: "env-1", threadId: "other-thread" }, focused: true, visible: "visible", + platform: "MacIntel", live: true, completedAt: null as string | null, archivedAt: null as string | null, @@ -160,6 +161,7 @@ function setup() { active: { environmentId: "env-1", threadId: "other-thread" }, focused: true, visible: "visible", + platform: "MacIntel", live: true, completedAt: null, archivedAt: null, @@ -180,6 +182,9 @@ function setup() { .mockImplementation(() => state.visible as DocumentVisibilityState), ); stack.use(vi.spyOn(document, "hasFocus").mockImplementation(() => state.focused)); + stack.use( + vi.spyOn(Navigator.prototype, "platform", "get").mockImplementation(() => state.platform), + ); stack.use( stubGlobal("Notification", Object.assign(state.notification, { permission: "granted" })), ); @@ -290,6 +295,7 @@ describe("thread notifications", () => { body: "Fix the login form", tag: expect.any(String), silent: true, + requireInteraction: false, }); }); @@ -370,9 +376,37 @@ describe("thread notifications", () => { body: "Fix the login form", tag: expect.any(String), silent: true, + requireInteraction: false, }); }); + it("keeps Windows completion alerts on screen so a click still reaches the app", async () => { + await using app = setup(); + state.mode = "notifications"; + state.focused = false; + state.platform = "Win32"; + await app.render(); + await app.complete(); + expect(state.notification).toHaveBeenCalledWith( + "Thread completed", + expect.objectContaining({ requireInteraction: true }), + ); + }); + + it("lets Windows attention alerts time out since another client may resolve them", async () => { + await using app = setup(); + state.mode = "notifications"; + state.focused = false; + state.platform = "Win32"; + await app.render(); + state.input = true; + await app.render(); + expect(state.notification).toHaveBeenCalledWith( + "Input needed", + expect.objectContaining({ requireInteraction: false }), + ); + }); + it("reveals the desktop window and opens the thread when a system alert is clicked", async () => { await using app = setup(); const revealWindow = vi.fn(() => Promise.resolve()); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 9e4e7d79150c..0a09d7362d29 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -16,6 +16,7 @@ import { import { useEffect, useRef } from "react"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; +import { isWindowsPlatform } from "../lib/utils"; import { useEnvironmentIds } from "../state/environments"; import { environmentShell } from "../state/shell"; import { @@ -221,6 +222,10 @@ function EnvironmentNotifications({ body: thread.title, tag: threadNotificationTag(environmentId, thread.id), silent: true, + // Windows drops clicks once a toast times out into Notification Center, so keep + // completions on screen until clicked or cleared on focus. Attention alerts still time + // out because another client can resolve them while this window stays unfocused. + requireInteraction: kind === "completion" && isWindowsPlatform(navigator.platform), }); onNotification(environmentId, notification); notification.addEventListener("click", () => {