diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 5c1e01cb1471..53044dcf7e5d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,6 +19,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { ...DEFAULT_CLIENT_SETTINGS, notificationMode: "notifications-and-sound", + inAppNotificationsEnabled: true, appearanceContrast: 100, browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, browserDefaultZoomFactor: 1.25, diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx new file mode 100644 index 000000000000..aa5c7b67be29 --- /dev/null +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -0,0 +1,249 @@ +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"; + +const state = vi.hoisted(() => ({ + mode: "off" as ClientSettings["notificationMode"], + inApp: true, + active: { environmentId: "env-1", threadId: "other-thread" }, + focused: true, + visible: "visible", + live: true, + completedAt: null as string | null, + archivedAt: null as string | null, + input: false, + approval: false, + sessionError: false, + turnError: false, + add: vi.fn( + (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) => + "toast-1", + ), + close: vi.fn(), + navigate: vi.fn(), + sound: vi.fn(), + notification: vi.fn(function () {}), +})); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => ({ + status: state.live ? "live" : "disconnected", + snapshot: Option.some({ + threads: [ + { + id: "thread-1", + title: "Fix the login form", + archivedAt: state.archivedAt, + hasPendingUserInput: state.input, + hasPendingApprovals: state.approval, + session: state.sessionError ? { status: "error" } : null, + latestTurn: { + turnId: "turn-1", + state: state.turnError ? "error" : state.completedAt ? "completed" : "running", + completedAt: state.completedAt, + }, + }, + ], + }), + }), +})); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => state.navigate, + useParams: () => state.active, +})); +vi.mock("../hooks/useSettings", () => ({ + useClientSettings: ( + select: ( + settings: Pick, + ) => unknown, + ) => select({ notificationMode: state.mode, inAppNotificationsEnabled: state.inApp }), + getClientSettings: () => ({ notificationMode: state.mode }), +})); +vi.mock("../state/environments", () => ({ + useEnvironments: () => ({ environments: [{ environmentId: "env-1" }] }), +})); +vi.mock("../state/shell", () => ({ + environmentShell: { stateValueAtom: vi.fn() }, +})); +vi.mock("../threadNotifications", async (importOriginal) => ({ + ...(await importOriginal()), + playNotificationSound: state.sound, +})); +vi.mock("./ui/toast", () => ({ + toastManager: { add: state.add, close: state.close }, +})); + +import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator"; + +let renderer: ReactTestRenderer | undefined; + +async function render() { + await act(() => { + if (renderer) renderer.update(); + else renderer = create(); + }); +} + +async function complete() { + state.completedAt = "2026-09-13T10:00:00.000Z"; + await render(); +} + +beforeEach(() => { + vi.clearAllMocks(); + Object.assign(state, { + mode: "off", + inApp: true, + active: { environmentId: "env-1", threadId: "other-thread" }, + focused: true, + visible: "visible", + live: true, + completedAt: null, + archivedAt: null, + input: false, + approval: false, + sessionError: false, + turnError: false, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("document", { + get visibilityState() { + return state.visible; + }, + hasFocus: () => state.focused, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + vi.stubGlobal("Notification", Object.assign(state.notification, { permission: "granted" })); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("thread notifications", () => { + it("alerts once with system alerts off and opens the completed thread", async () => { + await render(); + await complete(); + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + const toast = state.add.mock.calls[0]?.[0]; + expect(toast?.title).toBe("Thread completed"); + expect(toast?.description).toBe("Fix the login form"); + toast?.actionProps.onClick(); + expect(state.close).toHaveBeenCalledWith("toast-1"); + expect(state.navigate).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { environmentId: "env-1", threadId: "thread-1" }, + }); + expect(state.notification).not.toHaveBeenCalled(); + }); + + it.each(["active", "blurred", "hidden", "archived", "disabled"])( + "does not show a completion toast for %s threads", + async (condition) => { + await render(); + if (condition === "active") state.active.threadId = "thread-1"; + if (condition === "blurred") state.focused = false; + if (condition === "hidden") state.visible = "hidden"; + if (condition === "archived") state.archivedAt = "2026-09-13T09:00:00.000Z"; + if (condition === "disabled") state.inApp = false; + await complete(); + expect(state.add).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["input", "Input needed"], + ["approval", "Approval needed"], + ["sessionError", "Thread failed"], + ["turnError", "Thread failed"], + ] as const)("uses the same %s event for in-app and desktop alerts", async (event, title) => { + state.mode = "notifications-and-sound"; + await render(); + state[event] = true; + await render(); + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.add).toHaveBeenLastCalledWith(expect.objectContaining({ title })); + expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function)); + expect(state.notification).not.toHaveBeenCalled(); + + state[event] = false; + await render(); + state.focused = false; + state[event] = true; + await render(); + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.notification).toHaveBeenCalledTimes(1); + expect(state.notification).toHaveBeenCalledWith(title, { + body: "Fix the login form", + tag: "env-1:thread-1", + silent: true, + }); + }); + + it("keeps desktop alerts when in-app notifications are disabled", async () => { + state.inApp = false; + state.mode = "notifications"; + await render(); + await complete(); + expect(state.add).not.toHaveBeenCalled(); + expect(state.notification).toHaveBeenCalledTimes(1); + state.inApp = true; + await render(); + expect(state.add).not.toHaveBeenCalled(); + }); + + it("does not replay a completion when opting in from all alerts off", async () => { + state.inApp = false; + await render(); + await complete(); + state.inApp = true; + await render(); + expect(state.add).not.toHaveBeenCalled(); + }); + + it("compares the environment as well as the thread", async () => { + state.active = { environmentId: "env-2", threadId: "thread-1" }; + await render(); + await complete(); + expect(state.add).toHaveBeenCalledTimes(1); + }); + + it("does not replay completed threads on first load or reconnect", async () => { + await complete(); + state.live = false; + await render(); + state.live = true; + await render(); + expect(state.add).not.toHaveBeenCalled(); + }); + + it("keeps sound but replaces the system popup when showing a toast", async () => { + state.mode = "notifications-and-sound"; + await render(); + await complete(); + expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function)); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.notification).not.toHaveBeenCalled(); + }); + + it("keeps system alerts when the app is in the background", async () => { + state.mode = "notifications"; + state.focused = false; + await render(); + await complete(); + expect(state.add).not.toHaveBeenCalled(); + expect(state.notification).toHaveBeenCalledWith("Thread completed", { + body: "Fix the login form", + tag: "env-1:thread-1", + silent: true, + }); + }); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index e89175a77808..4ccab5c901ce 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useParams } from "@tanstack/react-router"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { useEffect, useRef } from "react"; @@ -14,10 +14,14 @@ import { unlockNotificationAudio, } from "../threadNotifications"; import { resolveSidebarThreadStatus } from "./Sidebar.logic"; +import { toastManager } from "./ui/toast"; export function ThreadNotificationCoordinator() { const { environments } = useEnvironments(); const mode = useClientSettings((settings) => settings.notificationMode); + const inAppNotificationsEnabled = useClientSettings( + (settings) => settings.inAppNotificationsEnabled, + ); useEffect(() => { if (!hasNotificationSound(mode)) return; @@ -29,7 +33,7 @@ export function ThreadNotificationCoordinator() { }; }, [mode]); - if (mode === "off") return null; + if (mode === "off" && !inAppNotificationsEnabled) return null; return environments.map((environment) => ( settings.notificationMode); + const inAppNotificationsEnabled = useClientSettings( + (settings) => settings.inAppNotificationsEnabled, + ); const navigate = useNavigate(); - const previous = useRef(new Map()); + const { environmentId: activeEnvironmentId, threadId: activeThreadId } = useParams({ + strict: false, + }); + const previous = useRef( + new Map(), + ); useEffect(() => { if (shell.status !== "live" || Option.isNone(shell.snapshot)) { previous.current.clear(); return; } - const next = new Map(); + const next = new Map(); for (const thread of shell.snapshot.value.threads) { - const status = resolveSidebarThreadStatus(thread); + let status = resolveSidebarThreadStatus(thread); + if (status === "ready" && thread.latestTurn?.state === "error") status = "failed"; const prior = previous.current.get(thread.id); - const input = - status === "input" || status === "approval" + const attention = + status === "input" || status === "approval" || status === "failed" ? `${thread.latestTurn?.turnId ?? ""}:${status}` : null; const completedAt = Date.parse(thread.latestTurn?.completedAt ?? ""); @@ -65,20 +78,52 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen Number.isFinite(completedAt) ? completedAt : (prior?.completion ?? null); - next.set(thread.id, { input, completion }); - if (!prior || mode === "off" || thread.archivedAt !== null) continue; + next.set(thread.id, { attention, completion }); + if (!prior || thread.archivedAt !== null) continue; const kind = - input && input !== prior.input + attention && attention !== prior.attention ? "input" : completion !== null && (prior.completion === null || completion > prior.completion) ? "completion" : null; if (!kind) continue; + const title = + kind === "completion" + ? "Thread completed" + : status === "approval" + ? "Approval needed" + : status === "failed" + ? "Thread failed" + : "Input needed"; if (hasNotificationSound(mode)) { void playNotificationSound(kind, () => hasNotificationSound(getClientSettings().notificationMode), ); } + if ( + inAppNotificationsEnabled && + document.visibilityState === "visible" && + document.hasFocus() && + (activeEnvironmentId !== environmentId || activeThreadId !== thread.id) + ) { + const toastId = toastManager.add({ + type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning", + title, + description: thread.title, + data: { hideCopyButton: true }, + actionProps: { + children: "Open thread", + onClick: () => { + toastManager.close(toastId); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: thread.id }, + }); + }, + }, + }); + continue; + } if ( !hasDesktopNotifications(mode) || typeof Notification === "undefined" || @@ -86,14 +131,11 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen ) continue; try { - const notification = new Notification( - kind === "completion" - ? "Thread completed" - : status === "approval" - ? "Approval needed" - : "Input needed", - { body: thread.title, tag: `${environmentId}:${thread.id}`, silent: true }, - ); + const notification = new Notification(title, { + body: thread.title, + tag: `${environmentId}:${thread.id}`, + silent: true, + }); notification.addEventListener("click", () => { notification.close(); window.focus(); @@ -107,7 +149,15 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen } } previous.current = next; - }, [environmentId, mode, navigate, shell]); + }, [ + activeEnvironmentId, + activeThreadId, + environmentId, + inAppNotificationsEnabled, + mode, + navigate, + shell, + ]); return null; } diff --git a/apps/web/src/components/settings/NotificationSettings.tsx b/apps/web/src/components/settings/NotificationSettings.tsx index 22b4d2912302..5af5f0a85233 100644 --- a/apps/web/src/components/settings/NotificationSettings.tsx +++ b/apps/web/src/components/settings/NotificationSettings.tsx @@ -22,7 +22,7 @@ export function NotificationSettings() { {...searchableSetting("thread-notifications")} description={ permissionMessage ?? - "Alert when a thread finishes or needs input. Applies to this device while T3 Code is open." + "System alerts when a thread finishes, fails, or needs input or approval. Applies to this device while T3 Code is open." } control={