diff --git a/apps/desktop/src/electron/ElectronNotification.test.ts b/apps/desktop/src/electron/ElectronNotification.test.ts new file mode 100644 index 00000000000..7ab125c20b3 --- /dev/null +++ b/apps/desktop/src/electron/ElectronNotification.test.ts @@ -0,0 +1,266 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import type * as Electron from "electron"; +import { beforeEach, vi } from "vite-plus/test"; + +interface FakeNotificationEntry { + readonly options: { readonly title?: string; readonly body?: string }; + readonly listeners: Map void>; + shown: number; + closed: number; +} + +const { notificationState, FakeNotification } = vi.hoisted(() => { + const notificationState = { + supported: true, + constructorThrows: null as Error | null, + instances: [] as FakeNotificationEntry[], + }; + + class FakeNotification { + static isSupported(): boolean { + return notificationState.supported; + } + + readonly entry: FakeNotificationEntry; + + constructor(options: { title?: string; body?: string }) { + if (notificationState.constructorThrows !== null) { + throw notificationState.constructorThrows; + } + this.entry = { options, listeners: new Map(), shown: 0, closed: 0 }; + notificationState.instances.push(this.entry); + } + + on(event: string, listener: (...args: readonly unknown[]) => void): this { + this.entry.listeners.set(event, listener); + return this; + } + + show(): void { + this.entry.shown += 1; + } + + // Electron dismisses the notification and then emits `close`; the fake + // mirrors that so the service's bookkeeping is exercised. + close(): void { + this.entry.closed += 1; + this.entry.listeners.get("close")?.(); + } + } + + return { notificationState, FakeNotification }; +}); + +vi.mock("electron", () => ({ Notification: FakeNotification })); + +import { NOTIFICATION_ACTIVATED_CHANNEL } from "../ipc/channels.ts"; +import * as ElectronNotification from "./ElectronNotification.ts"; +import * as ElectronWindow from "./ElectronWindow.ts"; + +const request = { + id: "thread-1", + title: "T3 Code needs your input", + body: "Claude is waiting on your answer.", + environmentId: "local", + threadId: "thread-1", +} as const; + +const clickNotification = (index: number) => { + const entry = notificationState.instances[index]; + assert.isDefined(entry); + const click = entry.listeners.get("click"); + if (click === undefined) { + throw new Error("Expected the notification to register a click listener."); + } + click(); +}; + +const clickFirstNotification = () => clickNotification(0); + +const closeNotification = (index: number) => { + const entry = notificationState.instances[index]; + assert.isDefined(entry); + const close = entry.listeners.get("close"); + if (close === undefined) { + throw new Error("Expected the notification to register a close listener."); + } + close(); +}; + +const silentWindowLayer = Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + sendAll: () => Effect.void, +}); + +describe("ElectronNotification", () => { + beforeEach(() => { + notificationState.supported = true; + notificationState.constructorThrows = null; + notificationState.instances = []; + }); + + it.effect("shows a native notification carrying the request title and body", () => + Effect.gen(function* () { + const notifications = yield* ElectronNotification.ElectronNotification; + yield* notifications.show(request); + + assert.equal(notificationState.instances.length, 1); + assert.deepEqual(notificationState.instances[0]?.options, { + title: "T3 Code needs your input", + body: "Claude is waiting on your answer.", + }); + assert.equal(notificationState.instances[0]?.shown, 1); + }).pipe( + Effect.provide( + ElectronNotification.layer.pipe( + Layer.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + }), + ), + ), + ), + ), + ); + + it.effect("reveals the target window and broadcasts the activation on click", () => + Effect.gen(function* () { + const window = { id: 7 } as Electron.BrowserWindow; + const revealedRef = yield* Ref.make>(Option.none()); + const sent = yield* Deferred.make(); + const windowLayer = Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.some(window)), + reveal: (target) => Ref.set(revealedRef, Option.some(target)), + sendAll: (channel, ...args) => + Deferred.succeed(sent, [channel, ...args]).pipe(Effect.asVoid), + }); + + yield* Effect.gen(function* () { + const notifications = yield* ElectronNotification.ElectronNotification; + yield* notifications.show(request); + + clickFirstNotification(); + + const broadcast = yield* Deferred.await(sent); + assert.deepEqual(broadcast, [ + NOTIFICATION_ACTIVATED_CHANNEL, + { environmentId: "local", threadId: "thread-1" }, + ]); + assert.strictEqual(Option.getOrNull(yield* Ref.get(revealedRef)), window); + }).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(windowLayer)))); + }), + ); + + it.effect("broadcasts the activation even when there is no window to reveal", () => + Effect.gen(function* () { + const sent = yield* Deferred.make(); + const windowLayer = Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + sendAll: (channel, ...args) => + Deferred.succeed(sent, [channel, ...args]).pipe(Effect.asVoid), + }); + + yield* Effect.gen(function* () { + const notifications = yield* ElectronNotification.ElectronNotification; + yield* notifications.show(request); + + clickFirstNotification(); + + assert.deepEqual(yield* Deferred.await(sent), [ + NOTIFICATION_ACTIVATED_CHANNEL, + { environmentId: "local", threadId: "thread-1" }, + ]); + }).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(windowLayer)))); + }), + ); + + it.effect("no-ops when the platform does not support native notifications", () => + Effect.gen(function* () { + notificationState.supported = false; + + const notifications = yield* ElectronNotification.ElectronNotification; + yield* notifications.show(request); + + assert.equal(notificationState.instances.length, 0); + }).pipe( + Effect.provide( + ElectronNotification.layer.pipe( + Layer.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + }), + ), + ), + ), + ), + ); + + it.effect("closes the previous notification when the same id is shown again", () => + Effect.gen(function* () { + const notifications = yield* ElectronNotification.ElectronNotification; + yield* notifications.show(request); + yield* notifications.show(request); + yield* notifications.show({ ...request, id: "thread-2", threadId: "thread-2" }); + + assert.equal(notificationState.instances.length, 3); + // Only the first is superseded: the third carries a different id. + assert.equal(notificationState.instances[0]?.closed, 1); + assert.equal(notificationState.instances[1]?.closed, 0); + assert.equal(notificationState.instances[2]?.closed, 0); + }).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(silentWindowLayer)))), + ); + + it.effect("forgets a dismissed notification so it is never closed twice", () => + Effect.gen(function* () { + const notifications = yield* ElectronNotification.ElectronNotification; + yield* notifications.show(request); + + // The OS dismissal emits `close` without routing through `close()`. + closeNotification(0); + yield* notifications.show(request); + + assert.equal(notificationState.instances.length, 2); + assert.equal(notificationState.instances[0]?.closed, 0); + }).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(silentWindowLayer)))), + ); + + it.effect("forgets a clicked notification so it is never closed afterwards", () => + Effect.gen(function* () { + const notifications = yield* ElectronNotification.ElectronNotification; + yield* notifications.show(request); + + clickFirstNotification(); + yield* notifications.show(request); + + assert.equal(notificationState.instances.length, 2); + assert.equal(notificationState.instances[0]?.closed, 0); + }).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(silentWindowLayer)))), + ); + + it.effect("swallows Electron notification failures instead of failing the caller", () => + Effect.gen(function* () { + notificationState.constructorThrows = new Error("notification center unavailable"); + + const notifications = yield* ElectronNotification.ElectronNotification; + const exit = yield* Effect.exit(notifications.show(request)); + + assert.equal(exit._tag, "Success"); + assert.equal(notificationState.instances.length, 0); + }).pipe( + Effect.provide( + ElectronNotification.layer.pipe( + Layer.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + }), + ), + ), + ), + ), + ); +}); diff --git a/apps/desktop/src/electron/ElectronNotification.ts b/apps/desktop/src/electron/ElectronNotification.ts new file mode 100644 index 00000000000..178a5490e69 --- /dev/null +++ b/apps/desktop/src/electron/ElectronNotification.ts @@ -0,0 +1,113 @@ +import type { DesktopNotificationActivation, DesktopNotificationRequest } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as Electron from "electron"; + +import { NOTIFICATION_ACTIVATED_CHANNEL } from "../ipc/channels.ts"; +import * as ElectronWindow from "./ElectronWindow.ts"; + +export class ElectronNotificationShowError extends Schema.TaggedErrorClass()( + "ElectronNotificationShowError", + { + notificationId: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to show the Electron notification ${JSON.stringify(this.notificationId)}.`; + } +} + +export class ElectronNotification extends Context.Service< + ElectronNotification, + { + /** + * Raises a native OS notification. Resolves once the notification has been + * handed to the platform, and no-ops when the platform has no notification + * support. Clicking the notification reveals the current window and + * broadcasts a `DesktopNotificationActivation` to every renderer. + */ + readonly show: (request: DesktopNotificationRequest) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronNotification") {} + +export const make = Effect.gen(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + // The click listener is a bare Electron callback, so the activation effect + // has to be run against the captured context (keeps logging/tracing intact). + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + + // Live notifications keyed by `DesktopNotificationRequest.id`, which the + // contract defines as a coalescing key: showing again for the same id + // replaces the previous entry instead of stacking a second one in + // Notification Center (the web path gets this from the notification `tag`). + // Holding the JS reference also keeps the notification from being collected + // while it is on screen, which is what makes Electron's `click` reliable. + const liveNotifications = new Map(); + + const activate = Effect.fn("desktop.electron.notification.activate")(function* ( + request: DesktopNotificationRequest, + ) { + const window = yield* electronWindow.focusedMainOrFirst; + if (Option.isSome(window)) { + yield* electronWindow.reveal(window.value); + } + + const activation: DesktopNotificationActivation = { + environmentId: request.environmentId, + threadId: request.threadId, + }; + // Broadcast rather than target the revealed window: any renderer may own + // the thread, and the activation is idempotent for the ones that don't. + yield* electronWindow.sendAll(NOTIFICATION_ACTIVATED_CHANNEL, activation); + }); + + const show = Effect.fn("desktop.electron.notification.show")(function* ( + request: DesktopNotificationRequest, + ) { + const supported = yield* Effect.try({ + try: () => Electron.Notification.isSupported(), + catch: (cause) => new ElectronNotificationShowError({ notificationId: request.id, cause }), + }); + if (!supported) { + return; + } + + yield* Effect.try({ + try: () => { + const notification = new Electron.Notification({ + title: request.title, + body: request.body, + }); + const forget = () => { + if (liveNotifications.get(request.id) === notification) { + liveNotifications.delete(request.id); + } + }; + notification.on("click", () => { + forget(); + runFork(activate(request)); + }); + notification.on("close", forget); + + liveNotifications.get(request.id)?.close(); + liveNotifications.set(request.id, notification); + notification.show(); + }, + catch: (cause) => new ElectronNotificationShowError({ notificationId: request.id, cause }), + }); + }); + + return ElectronNotification.of({ + // Notifications are advisory: failing to raise one must never fail the + // renderer's invoke, so failures are logged and swallowed here. + show: (request) => show(request).pipe(Effect.catch((error) => Effect.logWarning(error))), + }); +}); + +export const layer = Layer.effect(ElectronNotification, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..65db70cfd96 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -23,6 +23,7 @@ import { issueSshWebSocketTicket, resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; +import { showNotification } from "./methods/notifications.ts"; import { checkForUpdate, downloadUpdate, @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(showNotification); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0d5c79cf55a..ea794487cf8 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,8 @@ export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const SHOW_NOTIFICATION_CHANNEL = "desktop:show-notification"; +export const NOTIFICATION_ACTIVATED_CHANNEL = "desktop:notification-activated"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; diff --git a/apps/desktop/src/ipc/methods/notifications.test.ts b/apps/desktop/src/ipc/methods/notifications.test.ts new file mode 100644 index 00000000000..2da1374bcfa --- /dev/null +++ b/apps/desktop/src/ipc/methods/notifications.test.ts @@ -0,0 +1,59 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { DesktopNotificationRequest } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import * as ElectronNotification from "../../electron/ElectronNotification.ts"; +import { SHOW_NOTIFICATION_CHANNEL } from "../channels.ts"; +import { showNotification } from "./notifications.ts"; + +const request = { + id: "thread-1", + title: "T3 Code needs your input", + body: "Claude is waiting on your answer.", + environmentId: "local", + threadId: "thread-1", +} satisfies DesktopNotificationRequest; + +describe("showNotification", () => { + it("is registered on the renderer-facing notification channel", () => { + assert.equal(showNotification.channel, SHOW_NOTIFICATION_CHANNEL); + assert.equal(SHOW_NOTIFICATION_CHANNEL, "desktop:show-notification"); + }); + + it.effect("forwards the decoded request to the native notification service", () => + Effect.gen(function* () { + const shownRef = yield* Ref.make([]); + + yield* showNotification.handler({ ...request }).pipe( + Effect.provide( + Layer.mock(ElectronNotification.ElectronNotification)({ + show: (shown) => Ref.update(shownRef, (previous) => [...previous, shown]), + }), + ), + ); + + assert.deepEqual(yield* Ref.get(shownRef), [request]); + }), + ); + + it.effect("rejects a malformed request instead of raising a notification", () => + Effect.gen(function* () { + const shownRef = yield* Ref.make([]); + + const exit = yield* Effect.exit( + showNotification.handler({ id: "thread-1" }).pipe( + Effect.provide( + Layer.mock(ElectronNotification.ElectronNotification)({ + show: (shown) => Ref.update(shownRef, (previous) => [...previous, shown]), + }), + ), + ), + ); + + assert.equal(exit._tag, "Failure"); + assert.deepEqual(yield* Ref.get(shownRef), []); + }), + ); +}); diff --git a/apps/desktop/src/ipc/methods/notifications.ts b/apps/desktop/src/ipc/methods/notifications.ts new file mode 100644 index 00000000000..cf9c066e4f1 --- /dev/null +++ b/apps/desktop/src/ipc/methods/notifications.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as ElectronNotification from "../../electron/ElectronNotification.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +// Mirrors DesktopNotificationRequest from @t3tools/contracts; the handler's +// call into ElectronNotification.show keeps the two structurally checked. +const NotificationRequestInput = Schema.Struct({ + id: Schema.String, + title: Schema.String, + body: Schema.String, + environmentId: Schema.String, + threadId: Schema.String, +}); + +export const showNotification = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SHOW_NOTIFICATION_CHANNEL, + payload: NotificationRequestInput, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.notifications.showNotification")(function* (request) { + const notifications = yield* ElectronNotification.ElectronNotification; + yield* notifications.show(request); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9795f04e8ae..f9c853db347 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -24,6 +24,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; +import * as ElectronNotification from "./electron/ElectronNotification.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; @@ -119,6 +120,9 @@ const electronLayer = Layer.mergeAll( ElectronTheme.layer, ElectronUpdater.layer, ElectronWindow.layer, + // Notifications reveal/broadcast through ElectronWindow on click, so the + // window layer feeds them here (and stays exported by the merge above). + ElectronNotification.layer.pipe(Layer.provide(ElectronWindow.layer)), DesktopIpc.layer(Electron.ipcMain), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f92cfcf2fa..145a7c2c194 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,18 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + showNotification: (request) => ipcRenderer.invoke(IpcChannels.SHOW_NOTIFICATION_CHANNEL, request), + onNotificationActivated: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, activation: unknown) => { + if (typeof activation !== "object" || activation === null) return; + listener(activation as Parameters[0]); + }; + + ipcRenderer.on(IpcChannels.NOTIFICATION_ACTIVATED_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.NOTIFICATION_ACTIVATED_CHANNEL, wrappedListener); + }; + }, getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, onWindowFullscreenStateChange: (listener) => { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f50aa8f882..8c0adc2371e 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,7 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, favorites: [], glassOpacity: 80, + notifyOnNeedsInput: true, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/web/src/components/NotificationCoordinator.tsx b/apps/web/src/components/NotificationCoordinator.tsx new file mode 100644 index 00000000000..dc855936892 --- /dev/null +++ b/apps/web/src/components/NotificationCoordinator.tsx @@ -0,0 +1,143 @@ +import { useNavigate, useParams } from "@tanstack/react-router"; +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; + +import { toastManager } from "~/components/ui/toast"; +import { useComposerDraftStore } from "~/composerDraftStore"; +import { isElectron } from "~/env"; +import { useClientSettings } from "~/hooks/useSettings"; +import { + buildAwarenessInputs, + resolveActivationRouteParams, +} from "~/notifications/coordinator.logic"; +import { + createAttentionTracker, + shouldSuppressAttentionEvent, + type AttentionEvent, +} from "~/notifications/needsAttention.logic"; +import { + canDeliverNotification, + getNotificationPermissionState, + requestWebNotificationPermission, + showAttentionNotification, + type WebNotificationPermission, +} from "~/notifications/notifier"; +import { + shouldShowPermissionPrompt, + type PermissionPromptState, +} from "~/notifications/permissionPrompt.logic"; +import { useProjects, useThreadShells } from "~/state/entities"; +import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "~/threadRoutes"; + +/** + * Announces chats that start waiting on the user, on desktop through the OS + * notification bridge and in the browser through the Web Notification API. + * + * The tracker is fed on every shell change regardless of the user's setting or + * the permission state: it edge-detects phase transitions, so skipping updates + * would leave it holding stale phases and replay old transitions the moment + * notifications are enabled. + */ +export function NotificationCoordinator() { + const navigate = useNavigate(); + const threadShells = useThreadShells(); + const projects = useProjects(); + const notifyOnNeedsInput = useClientSettings((settings) => settings.notifyOnNeedsInput); + // Resolved through the draft route too: right after a draft is promoted the + // user is still on `/draft/$draftId`, and the chat they are looking at must + // stay suppressed. + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), + [routeDraftThread, routeTarget], + ); + const [tracker] = useState(createAttentionTracker); + const promptStateRef = useRef({ promptedThisSession: false }); + + const handleActivation = useCallback( + (activation: { readonly environmentId: string; readonly threadId: string }) => { + const params = resolveActivationRouteParams(activation); + if (!params) { + return; + } + void navigate({ to: "/$environmentId/$threadId", params }); + }, + [navigate], + ); + + /** + * Nudges a browser that has never answered the permission dialog, at the one + * moment the ask is self-explanatory: a chat just started waiting. Marked as + * prompted before the toast is even answered, so an ignored or declined + * prompt is not repeated for the rest of the session. + */ + const promptForPermission = useEffectEvent((permission: WebNotificationPermission) => { + if ( + !shouldShowPermissionPrompt({ + state: promptStateRef.current, + settingEnabled: notifyOnNeedsInput, + permission, + isElectron, + }) + ) { + return; + } + promptStateRef.current = { promptedThisSession: true }; + toastManager.add({ + type: "info", + title: "Enable notifications to know when an agent needs you.", + actionProps: { + children: "Enable", + onClick: () => { + void requestWebNotificationPermission().catch(() => undefined); + }, + }, + }); + }); + + const deliverEvents = useEffectEvent((events: ReadonlyArray) => { + if (events.length === 0) { + return; + } + const permission = getNotificationPermissionState(); + if (!canDeliverNotification(permission, notifyOnNeedsInput)) { + // Dropped events are the cue for the contextual prompt: the only + // recoverable reason is a permission the user has yet to answer. + promptForPermission(permission); + return; + } + + const view = { + hasFocus: document.hasFocus(), + visible: document.visibilityState === "visible", + activeEnvironmentId: routeThreadRef?.environmentId ?? null, + activeThreadId: routeThreadRef?.threadId ?? null, + }; + + for (const event of events) { + if (shouldSuppressAttentionEvent(event, view)) { + continue; + } + showAttentionNotification(event, handleActivation); + } + }); + + useEffect(() => { + deliverEvents(tracker.update(buildAwarenessInputs(threadShells, projects))); + }, [projects, threadShells, tracker]); + + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge?.onNotificationActivated) { + return; + } + return bridge.onNotificationActivated(handleActivation); + }, [handleActivation]); + + return null; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 01eba912567..312cfc85f3b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -55,6 +55,11 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; +import { + getNotificationPermissionState, + requestWebNotificationPermission, + type WebNotificationPermission, +} from "../../notifications/notifier"; import { primaryServerObservabilityAtom, primaryServerProvidersAtom, @@ -412,6 +417,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), + ...(settings.notifyOnNeedsInput !== DEFAULT_UNIFIED_SETTINGS.notifyOnNeedsInput + ? ["Notify when an agent needs input"] + : []), ...(settings.enableProviderUpdateChecks !== DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks ? ["Provider update checks"] @@ -451,6 +459,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.automaticGitFetchInterval, settings.enableAssistantStreaming, settings.enableProviderUpdateChecks, + settings.notifyOnNeedsInput, settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, @@ -478,6 +487,7 @@ export function useSettingsRestore(onRestored?: () => void) { autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, + notifyOnNeedsInput: DEFAULT_UNIFIED_SETTINGS.notifyOnNeedsInput, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, @@ -501,6 +511,9 @@ export function GeneralSettingsPanel() { const updateSettings = useUpdatePrimarySettings(); const observability = useAtomValue(primaryServerObservabilityAtom); const serverProviders = useAtomValue(primaryServerProvidersAtom); + const [notificationPermission, setNotificationPermission] = useState( + getNotificationPermissionState, + ); const glassOpacityRatio = (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); const glassOpacitySliderStyle = { @@ -739,6 +752,51 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + notifyOnNeedsInput: DEFAULT_UNIFIED_SETTINGS.notifyOnNeedsInput, + }) + } + /> + ) : null + } + control={ + { + const enabled = Boolean(checked); + updateSettings({ notifyOnNeedsInput: enabled }); + if (!enabled) { + return; + } + const permission = getNotificationPermissionState(); + // The toggle click is the user gesture browsers require before + // they will show the permission dialog, so ask right here. + if (!isElectron && permission === "default") { + void requestWebNotificationPermission() + .then(setNotificationPermission) + .catch(() => undefined); + return; + } + setNotificationPermission(permission); + }} + aria-label="Notify when an agent needs input" + /> + } + /> + { + it("pairs each thread with its project title", () => { + const inputs = buildAwarenessInputs([thread()], [project({ title: "Checkout" })]); + + expect(inputs).toHaveLength(1); + expect(inputs[0]?.environmentId).toBe("env-1"); + expect(inputs[0]?.project.title).toBe("Checkout"); + expect(inputs[0]?.thread.id).toBe("thread-1"); + }); + + it("falls back to an empty title when the project is unknown", () => { + const inputs = buildAwarenessInputs([thread({ projectId: "missing" })], [project()]); + + expect(inputs[0]?.project.title).toBe(""); + }); + + it("does not borrow a project title from another environment", () => { + const inputs = buildAwarenessInputs( + [thread({ environmentId: "env-2" })], + [project({ environmentId: "env-1", title: "Checkout" })], + ); + + expect(inputs[0]?.project.title).toBe(""); + }); + + it("keeps one input per environment-scoped thread so the tracker sees distinct keys", () => { + const inputs = buildAwarenessInputs( + [thread(), thread(), thread({ environmentId: "env-2" }), thread({ id: "thread-2" })], + [project(), project({ environmentId: "env-2", title: "Other" })], + ); + + expect(inputs.map((input) => `${input.environmentId}::${input.thread.id}`)).toEqual([ + "env-1::thread-1", + "env-2::thread-1", + "env-1::thread-2", + ]); + }); +}); + +describe("resolveActivationRouteParams", () => { + it("returns branded route params for a well-formed activation", () => { + expect(resolveActivationRouteParams({ environmentId: "env-1", threadId: "thread-1" })).toEqual({ + environmentId: "env-1", + threadId: "thread-1", + }); + }); + + it("rejects an activation missing either id", () => { + expect(resolveActivationRouteParams({ environmentId: "", threadId: "thread-1" })).toBeNull(); + expect(resolveActivationRouteParams({ environmentId: "env-1", threadId: "" })).toBeNull(); + }); +}); diff --git a/apps/web/src/notifications/coordinator.logic.ts b/apps/web/src/notifications/coordinator.logic.ts new file mode 100644 index 00000000000..41811a7e06a --- /dev/null +++ b/apps/web/src/notifications/coordinator.logic.ts @@ -0,0 +1,69 @@ +import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import type { ProjectThreadAwarenessInput } from "@t3tools/shared/agentAwareness"; + +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import { attentionKey } from "./needsAttention.logic"; + +/** The slice of an `EnvironmentThreadShell` the awareness ladder reads. */ +export type AwarenessThreadInput = ProjectThreadAwarenessInput["thread"] & { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +}; + +/** The slice of an `EnvironmentProject` needed to title a thread's project. */ +export interface AwarenessProjectInput { + readonly environmentId: EnvironmentId; + readonly id: ProjectId; + readonly title: string; +} + +function projectKey(environmentId: EnvironmentId, projectId: ProjectId): string { + return `${environmentId}::${projectId}`; +} + +/** + * Pairs every thread shell with its project title for the attention tracker. + * + * Project titles are looked up per environment, never by bare project id: two + * environments can hand out the same id for unrelated projects. Threads are + * deduped by environment-scoped key because the tracker treats a repeated key + * within one update as two independent observations. + */ +export function buildAwarenessInputs( + threads: ReadonlyArray, + projects: ReadonlyArray, +): ReadonlyArray { + const titles = new Map(); + for (const project of projects) { + titles.set(projectKey(project.environmentId, project.id), project.title); + } + + const seen = new Set(); + const inputs: ProjectThreadAwarenessInput[] = []; + for (const thread of threads) { + const key = attentionKey(thread.environmentId, thread.id); + if (seen.has(key)) { + continue; + } + seen.add(key); + inputs.push({ + environmentId: thread.environmentId, + project: { title: titles.get(projectKey(thread.environmentId, thread.projectId)) ?? "" }, + thread, + }); + } + return inputs; +} + +/** + * Turns a notification activation's plain string ids into thread route params. + * The ids cross an IPC (or notification-click) boundary untyped, so a malformed + * payload resolves to null rather than navigating to a nonexistent route. + */ +export function resolveActivationRouteParams(activation: { + readonly environmentId: string; + readonly threadId: string; +}): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null { + const ref = resolveThreadRouteRef(activation); + return ref === null ? null : buildThreadRouteParams(ref); +} diff --git a/apps/web/src/notifications/needsAttention.logic.test.ts b/apps/web/src/notifications/needsAttention.logic.test.ts new file mode 100644 index 00000000000..75293186567 --- /dev/null +++ b/apps/web/src/notifications/needsAttention.logic.test.ts @@ -0,0 +1,339 @@ +import type { EnvironmentId, OrchestrationThreadShell, ThreadId, TurnId } from "@t3tools/contracts"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import type { ProjectThreadAwarenessInput } from "@t3tools/shared/agentAwareness"; +import { describe, expect, it } from "vite-plus/test"; + +import { + attentionKey, + createAttentionTracker, + shouldSuppressAttentionEvent, + type AttentionViewState, +} from "./needsAttention.logic"; + +const NOW = "2026-07-25T12:00:00.000Z"; + +type ThreadShell = ProjectThreadAwarenessInput["thread"]; +type SessionShell = NonNullable; +type TurnShell = NonNullable; + +function session(overrides: Partial = {}): SessionShell { + return { + threadId: "thread-1" as ThreadId, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-1" as TurnId, + lastError: null, + updatedAt: NOW, + ...overrides, + }; +} + +function turn(overrides: Partial = {}): TurnShell { + return { + turnId: "turn-1" as TurnId, + state: "running", + requestedAt: NOW, + startedAt: NOW, + completedAt: null, + assistantMessageId: null, + ...overrides, + }; +} + +interface InputOverrides { + readonly environmentId?: string; + readonly projectTitle?: string; + readonly thread?: Partial; +} + +function makeInput(overrides: InputOverrides = {}): ProjectThreadAwarenessInput { + return { + environmentId: (overrides.environmentId ?? "env-1") as EnvironmentId, + project: { title: overrides.projectTitle ?? "Proj" }, + thread: { + id: "thread-1" as ThreadId, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + session: session(), + latestTurn: null, + updatedAt: NOW, + hasPendingApprovals: false, + hasPendingUserInput: false, + ...overrides.thread, + }, + }; +} + +/** A thread the agent is actively working in. */ +const running = () => makeInput(); +/** A thread that is blocked on an approval decision. */ +const awaitingApproval = () => makeInput({ thread: { hasPendingApprovals: true } }); +/** A thread that is blocked on a user reply. */ +const awaitingInput = () => makeInput({ thread: { hasPendingUserInput: true } }); +/** A thread whose latest turn just finished. */ +const finished = () => + makeInput({ + thread: { + session: session({ status: "ready", activeTurnId: null }), + latestTurn: turn({ state: "completed", completedAt: NOW }), + }, + }); +/** A freshly created thread: born at session status `ready`, no turn yet. */ +const bornIdle = () => + makeInput({ thread: { session: session({ status: "ready", activeTurnId: null }) } }); + +describe("attentionKey", () => { + it("scopes a thread id by its environment", () => { + expect(attentionKey("env-1", "thread-1")).toBe("env-1::thread-1"); + expect(attentionKey("env-1", "thread-1")).not.toBe(attentionKey("env-2", "thread-1")); + }); +}); + +describe("createAttentionTracker", () => { + it("never fires on the first observation of a thread already waiting for approval", () => { + const tracker = createAttentionTracker(); + + // The initial snapshot replays existing state; notifying here would spam + // the user with everything that was already waiting when the app opened. + expect(tracker.update([awaitingApproval()])).toEqual([]); + }); + + it("fires waiting_for_approval when a running thread starts waiting on an approval", () => { + const tracker = createAttentionTracker(); + tracker.update([running()]); + + expect(tracker.update([awaitingApproval()])).toEqual([ + { + key: "env-1::thread-1", + kind: "waiting_for_approval", + environmentId: "env-1", + threadId: "thread-1", + title: "Approval needed", + body: "Proj · Thread", + }, + ]); + }); + + it("fires waiting_for_input when a running thread starts waiting on a reply", () => { + const tracker = createAttentionTracker(); + tracker.update([running()]); + + expect(tracker.update([awaitingInput()])).toEqual([ + { + key: "env-1::thread-1", + kind: "waiting_for_input", + environmentId: "env-1", + threadId: "thread-1", + title: "Waiting for input", + body: "Proj · Thread", + }, + ]); + }); + + it("does not re-fire while the thread stays in the same phase", () => { + const tracker = createAttentionTracker(); + tracker.update([running()]); + tracker.update([awaitingApproval()]); + + // Unrelated fields churn on every upsert; only the phase edge matters. + const reUpserted = makeInput({ + thread: { hasPendingApprovals: true, title: "Thread", updatedAt: "2026-07-25T12:00:05.000Z" }, + }); + expect(tracker.update([reUpserted])).toEqual([]); + expect(tracker.update([awaitingApproval()])).toEqual([]); + }); + + it("fires again after an approval is resolved and a new one is requested", () => { + const tracker = createAttentionTracker(); + tracker.update([running()]); + tracker.update([awaitingApproval()]); + + expect(tracker.update([running()])).toEqual([]); + + const events = tracker.update([awaitingApproval()]); + expect(events).toHaveLength(1); + expect(events[0]?.kind).toBe("waiting_for_approval"); + }); + + it("fires completed when a running thread finishes its turn", () => { + const tracker = createAttentionTracker(); + tracker.update([running()]); + + expect(tracker.update([finished()])).toEqual([ + { + key: "env-1::thread-1", + kind: "completed", + environmentId: "env-1", + threadId: "thread-1", + title: "Agent finished", + body: "Proj · Thread", + }, + ]); + }); + + it("does not fire completed for a freshly created thread that is born idle", () => { + const tracker = createAttentionTracker(); + + // Threads are born at session status `ready`, which the phase ladder + // projects as `completed` — a spurious "Done" if we notified on it. + expect(tracker.update([bornIdle()])).toEqual([]); + expect(tracker.update([bornIdle()])).toEqual([]); + }); + + it("does not fire completed when the previous phase was not running", () => { + const tracker = createAttentionTracker(); + tracker.update([running()]); + tracker.update([awaitingInput()]); + + expect(tracker.update([finished()])).toEqual([]); + }); + + it("does not fire on transitions into starting, running, failed, or an unprojectable phase", () => { + const tracker = createAttentionTracker(); + tracker.update([awaitingApproval()]); + + const starting = makeInput({ thread: { session: session({ status: "starting" }) } }); + expect(tracker.update([starting])).toEqual([]); + expect(tracker.update([running()])).toEqual([]); + + const failed = makeInput({ + thread: { session: session({ status: "error", lastError: "Provider exited." }) }, + }); + expect(tracker.update([failed])).toEqual([]); + + // No session and no turn projects to null — nothing to notify about. + const unknown = makeInput({ thread: { session: null } }); + expect(tracker.update([unknown])).toEqual([]); + }); + + it("fires when a thread leaves an unprojectable phase and starts waiting", () => { + const tracker = createAttentionTracker(); + tracker.update([makeInput({ thread: { session: null } })]); + + const events = tracker.update([awaitingApproval()]); + expect(events).toHaveLength(1); + expect(events[0]?.kind).toBe("waiting_for_approval"); + }); + + it("prunes threads missing from an update so a reappearing thread is first-seen again", () => { + const tracker = createAttentionTracker(); + tracker.update([running()]); + + expect(tracker.update([])).toEqual([]); + expect(tracker.update([awaitingApproval()])).toEqual([]); + // Still tracked from the previous call, so the next edge fires normally. + expect(tracker.update([running()])).toEqual([]); + expect(tracker.update([awaitingApproval()])).toHaveLength(1); + }); + + it("tracks the same thread id in two environments independently", () => { + const tracker = createAttentionTracker(); + const envA = { environmentId: "env-1" }; + const envB = { environmentId: "env-2", projectTitle: "Other" }; + tracker.update([makeInput(envA), makeInput(envB)]); + + const events = tracker.update([ + makeInput({ ...envA, thread: { hasPendingApprovals: true } }), + makeInput(envB), + ]); + + expect(events).toEqual([ + { + key: "env-1::thread-1", + kind: "waiting_for_approval", + environmentId: "env-1", + threadId: "thread-1", + title: "Approval needed", + body: "Proj · Thread", + }, + ]); + + expect( + tracker.update([makeInput(envA), makeInput({ ...envB, thread: finished().thread })]), + ).toEqual([ + { + key: "env-2::thread-1", + kind: "completed", + environmentId: "env-2", + threadId: "thread-1", + title: "Agent finished", + body: "Other · Thread", + }, + ]); + }); + + it("reports every thread that crossed an edge in the same update", () => { + const tracker = createAttentionTracker(); + const threadB = { thread: { id: "thread-2" as ThreadId, title: "Second" } }; + tracker.update([makeInput(), makeInput(threadB)]); + + const events = tracker.update([ + makeInput({ thread: { hasPendingApprovals: true } }), + makeInput({ thread: { ...threadB.thread, hasPendingUserInput: true } }), + ]); + + expect(events.map((event) => [event.key, event.kind])).toEqual([ + ["env-1::thread-1", "waiting_for_approval"], + ["env-1::thread-2", "waiting_for_input"], + ]); + expect(events[1]?.body).toBe("Proj · Second"); + }); + + it("keeps separate trackers isolated from each other", () => { + const first = createAttentionTracker(); + const second = createAttentionTracker(); + first.update([running()]); + + expect(second.update([awaitingApproval()])).toEqual([]); + expect(first.update([awaitingApproval()])).toHaveLength(1); + }); +}); + +describe("shouldSuppressAttentionEvent", () => { + const event = { + key: "env-1::thread-1", + kind: "waiting_for_approval", + environmentId: "env-1", + threadId: "thread-1", + title: "Approval needed", + body: "Proj · Thread", + } as const; + + const focusedOnEvent: AttentionViewState = { + hasFocus: true, + visible: true, + activeEnvironmentId: "env-1", + activeThreadId: "thread-1", + }; + + it("suppresses only when the user is already looking at that chat", () => { + expect(shouldSuppressAttentionEvent(event, focusedOnEvent)).toBe(true); + }); + + it("notifies when the window is blurred", () => { + expect(shouldSuppressAttentionEvent(event, { ...focusedOnEvent, hasFocus: false })).toBe(false); + }); + + it("notifies when the tab is hidden", () => { + expect(shouldSuppressAttentionEvent(event, { ...focusedOnEvent, visible: false })).toBe(false); + }); + + it("notifies when a different environment is on screen", () => { + expect( + shouldSuppressAttentionEvent(event, { ...focusedOnEvent, activeEnvironmentId: "env-2" }), + ).toBe(false); + expect( + shouldSuppressAttentionEvent(event, { ...focusedOnEvent, activeEnvironmentId: null }), + ).toBe(false); + }); + + it("notifies when a different chat is on screen", () => { + expect( + shouldSuppressAttentionEvent(event, { ...focusedOnEvent, activeThreadId: "thread-2" }), + ).toBe(false); + expect(shouldSuppressAttentionEvent(event, { ...focusedOnEvent, activeThreadId: null })).toBe( + false, + ); + }); +}); diff --git a/apps/web/src/notifications/needsAttention.logic.ts b/apps/web/src/notifications/needsAttention.logic.ts new file mode 100644 index 00000000000..c8338dd5ddd --- /dev/null +++ b/apps/web/src/notifications/needsAttention.logic.ts @@ -0,0 +1,127 @@ +import { + projectThreadAwareness, + type AgentAwarenessPhase, + type AgentAwarenessState, + type ProjectThreadAwarenessInput, +} from "@t3tools/shared/agentAwareness"; + +export type AttentionKind = "waiting_for_approval" | "waiting_for_input" | "completed"; + +export interface AttentionEvent { + readonly key: string; + readonly kind: AttentionKind; + readonly environmentId: string; + readonly threadId: string; + readonly title: string; + readonly body: string; +} + +export function attentionKey(environmentId: string, threadId: string): string { + return `${environmentId}::${threadId}`; +} + +export interface AttentionTracker { + /** Feed the full current set of thread inputs; returns newly-fired events. */ + update(inputs: ReadonlyArray): ReadonlyArray; +} + +/** + * Edge-detects the phases of `projectThreadAwareness` so a chat that starts + * needing the user is announced exactly once. + * + * Two rules keep it quiet. A key's first observation never fires, because the + * initial snapshot replays state that was already true before the app opened. + * And `completed` only fires out of `running`: threads are born at session + * status `ready`, which the ladder projects as `completed`, so any other + * approach announces a "Done" for work that never ran. + * + * Only observed phases can be announced: the server coalesces a burst of domain + * events into a single shell refetch (`apps/server/src/ws.ts:754-767`), so any + * phase a thread passes through inside one batch is never seen here and never + * fires. + */ +export function createAttentionTracker(): AttentionTracker { + let phases = new Map(); + + return { + update(inputs) { + const next = new Map(); + const events: AttentionEvent[] = []; + + for (const input of inputs) { + const key = attentionKey(input.environmentId, input.thread.id); + const state = projectThreadAwareness(input); + const phase = state?.phase ?? null; + const wasTracked = phases.has(key); + const previousPhase = phases.get(key) ?? null; + next.set(key, phase); + + if (!wasTracked || phase === previousPhase || state === null) { + continue; + } + const kind = attentionKindForEdge(previousPhase, state.phase); + if (kind === null) { + continue; + } + events.push({ + key, + kind, + environmentId: input.environmentId, + threadId: input.thread.id, + title: state.headline, + body: attentionBody(state), + }); + } + + // Replacing wholesale prunes keys absent from this update, so a thread + // that comes back later is first-seen again rather than firing on the + // stale phase it left behind. + phases = next; + return events; + }, + }; +} + +function attentionKindForEdge( + previousPhase: AgentAwarenessPhase | null, + phase: AgentAwarenessPhase, +): AttentionKind | null { + switch (phase) { + case "waiting_for_approval": + return "waiting_for_approval"; + case "waiting_for_input": + return "waiting_for_input"; + case "completed": + return previousPhase === "running" ? "completed" : null; + default: + return null; + } +} + +function attentionBody(state: AgentAwarenessState): string { + return `${state.projectTitle} · ${state.threadTitle}`; +} + +export interface AttentionViewState { + readonly hasFocus: boolean; + readonly visible: boolean; + readonly activeEnvironmentId: string | null; + readonly activeThreadId: string | null; +} + +/** + * True only when the user is already staring at the chat that needs them. + * Anything else — blurred window, hidden tab, a different chat on screen — + * still deserves a notification. + */ +export function shouldSuppressAttentionEvent( + event: AttentionEvent, + view: AttentionViewState, +): boolean { + return ( + view.visible && + view.hasFocus && + view.activeEnvironmentId === event.environmentId && + view.activeThreadId === event.threadId + ); +} diff --git a/apps/web/src/notifications/notifier.logic.test.ts b/apps/web/src/notifications/notifier.logic.test.ts new file mode 100644 index 00000000000..065332227ad --- /dev/null +++ b/apps/web/src/notifications/notifier.logic.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { canDeliverNotification, type WebNotificationPermission } from "./notifier.logic"; + +const PERMISSIONS: ReadonlyArray = [ + "unsupported", + "default", + "granted", + "denied", +]; + +describe("canDeliverNotification", () => { + it("delivers only when granted and the setting is enabled", () => { + expect(canDeliverNotification("granted", true)).toBe(true); + }); + + it("never delivers while the setting is off", () => { + for (const permission of PERMISSIONS) { + expect(canDeliverNotification(permission, false)).toBe(false); + } + }); + + it("never delivers without granted permission", () => { + for (const permission of PERMISSIONS.filter((value) => value !== "granted")) { + expect(canDeliverNotification(permission, true)).toBe(false); + } + }); +}); diff --git a/apps/web/src/notifications/notifier.logic.ts b/apps/web/src/notifications/notifier.logic.ts new file mode 100644 index 00000000000..5066ab4e960 --- /dev/null +++ b/apps/web/src/notifications/notifier.logic.ts @@ -0,0 +1,17 @@ +/** + * Notification permission as seen by the app, which spans two platforms: a + * desktop shell that owns the OS-level permission itself, and browsers whose + * Notification API may be absent entirely (`"unsupported"`). + */ +export type WebNotificationPermission = "unsupported" | "default" | "granted" | "denied"; + +/** + * Both gates must be open: the user opted in, and the platform will actually + * raise the notification. Anything else stays silent. + */ +export function canDeliverNotification( + permission: WebNotificationPermission, + enabled: boolean, +): boolean { + return enabled && permission === "granted"; +} diff --git a/apps/web/src/notifications/notifier.test.ts b/apps/web/src/notifications/notifier.test.ts new file mode 100644 index 00000000000..87e9c8920a4 --- /dev/null +++ b/apps/web/src/notifications/notifier.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { AttentionEvent } from "./needsAttention.logic"; +import { showAttentionNotification } from "./notifier"; + +const event: AttentionEvent = { + key: "local::thread-1", + kind: "waiting_for_input", + environmentId: "local", + threadId: "thread-1", + title: "T3 Code needs your input", + body: "Claude is waiting on your answer.", +}; + +interface ConstructedNotification { + readonly title: string; + readonly options: NotificationOptions | undefined; +} + +/** + * The unit project runs on `node`, so both `window` and the `Notification` + * constructor the shim reaches for as a free variable have to be stubbed. + */ +function stubWebNotification(constructor: unknown): void { + vi.stubGlobal("window", { Notification: constructor, focus: vi.fn() }); + vi.stubGlobal("Notification", constructor); +} + +describe("showAttentionNotification", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("routes through the desktop bridge and never touches the web API", () => { + const showNotification = vi.fn().mockResolvedValue(undefined); + const webConstructor = vi.fn(); + vi.stubGlobal("window", { desktopBridge: { showNotification }, Notification: webConstructor }); + vi.stubGlobal("Notification", webConstructor); + + showAttentionNotification(event, () => undefined); + + expect(showNotification).toHaveBeenCalledWith({ + id: "local::thread-1", + title: "T3 Code needs your input", + body: "Claude is waiting on your answer.", + environmentId: "local", + threadId: "thread-1", + }); + expect(webConstructor).not.toHaveBeenCalled(); + }); + + it("falls back to a web notification tagged with the event key", () => { + const constructed: ConstructedNotification[] = []; + class FakeNotification { + constructor(title: string, options?: NotificationOptions) { + constructed.push({ title, options }); + } + addEventListener(): void {} + close(): void {} + } + stubWebNotification(FakeNotification); + + showAttentionNotification(event, () => undefined); + + expect(constructed).toEqual([ + { + title: "T3 Code needs your input", + options: { body: "Claude is waiting on your answer.", tag: "local::thread-1" }, + }, + ]); + }); + + it("stays silent when the constructor throws, as it does on Android Chrome", () => { + const webConstructor = vi.fn(() => { + throw new TypeError("Illegal constructor"); + }); + stubWebNotification(webConstructor); + + expect(() => showAttentionNotification(event, () => undefined)).not.toThrow(); + expect(webConstructor).toHaveBeenCalledTimes(1); + }); + + it("stays silent when the platform has no Notification API at all", () => { + vi.stubGlobal("window", {}); + + expect(() => showAttentionNotification(event, () => undefined)).not.toThrow(); + }); +}); diff --git a/apps/web/src/notifications/notifier.ts b/apps/web/src/notifications/notifier.ts new file mode 100644 index 00000000000..0efcf932c9f --- /dev/null +++ b/apps/web/src/notifications/notifier.ts @@ -0,0 +1,100 @@ +import type { DesktopBridge } from "@t3tools/contracts"; + +import type { AttentionEvent } from "./needsAttention.logic"; +import type { WebNotificationPermission } from "./notifier.logic"; + +export { canDeliverNotification, type WebNotificationPermission } from "./notifier.logic"; + +type NotificationCapableBridge = DesktopBridge & { + readonly showNotification: NonNullable; +}; + +/** + * The desktop shell only when it can actually raise notifications: an older + * shell hosting a newer web bundle exposes a bridge without these members, and + * must fall back to the renderer's own Notification API. + */ +function notificationBridge(): NotificationCapableBridge | null { + if (typeof window === "undefined") { + return null; + } + const bridge = window.desktopBridge; + return bridge?.showNotification ? (bridge as NotificationCapableBridge) : null; +} + +function webNotificationSupported(): boolean { + return typeof window !== "undefined" && "Notification" in window; +} + +/** + * Desktop reports `"granted"` outright: the OS owns that permission and the + * shell never routes through the Web Notification API. + */ +export function getNotificationPermissionState(): WebNotificationPermission { + if (notificationBridge()) { + return "granted"; + } + if (!webNotificationSupported()) { + return "unsupported"; + } + return Notification.permission; +} + +export async function requestWebNotificationPermission(): Promise { + if (notificationBridge()) { + return "granted"; + } + if (!webNotificationSupported()) { + return "unsupported"; + } + return await Notification.requestPermission(); +} + +/** + * Raises one attention notification on whichever surface is available. + * + * On desktop the click travels back over IPC instead of `onActivate`, so the + * caller must also subscribe to `onNotificationActivated`. On the web path the + * event key doubles as the notification tag, which lets the browser coalesce + * repeat notifications for the same thread. + */ +export function showAttentionNotification( + event: AttentionEvent, + onActivate: (activation: { readonly environmentId: string; readonly threadId: string }) => void, +): void { + const bridge = notificationBridge(); + if (bridge) { + void bridge + .showNotification({ + id: event.key, + title: event.title, + body: event.body, + environmentId: event.environmentId, + threadId: event.threadId, + }) + .catch(() => undefined); + return; + } + + if (!webNotificationSupported()) { + return; + } + + // Android Chrome reports `"Notification" in window` and can even report + // permission `"granted"`, yet throws `TypeError: Illegal constructor` here + // because page-scope notifications need a service worker. Swallow it: the + // throw would otherwise escape the coordinator's effect, tearing down the + // React tree and dropping the rest of the event batch. + let notification: Notification; + try { + notification = new Notification(event.title, { body: event.body, tag: event.key }); + } catch { + return; + } + + notification.addEventListener("click", () => { + window.focus(); + onActivate({ environmentId: event.environmentId, threadId: event.threadId }); + notification.close(); + }); +} diff --git a/apps/web/src/notifications/permissionPrompt.logic.test.ts b/apps/web/src/notifications/permissionPrompt.logic.test.ts new file mode 100644 index 00000000000..309e88b9f34 --- /dev/null +++ b/apps/web/src/notifications/permissionPrompt.logic.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { WebNotificationPermission } from "./notifier.logic"; +import { shouldShowPermissionPrompt } from "./permissionPrompt.logic"; + +const PROMPTABLE = { + state: { promptedThisSession: false }, + settingEnabled: true, + permission: "default" as WebNotificationPermission, + isElectron: false, +} as const; + +describe("shouldShowPermissionPrompt", () => { + it("prompts a web user who left the setting on but never answered the browser", () => { + expect(shouldShowPermissionPrompt(PROMPTABLE)).toBe(true); + }); + + it("stays silent once this session already prompted", () => { + expect( + shouldShowPermissionPrompt({ ...PROMPTABLE, state: { promptedThisSession: true } }), + ).toBe(false); + }); + + it("stays silent while the setting is off", () => { + expect(shouldShowPermissionPrompt({ ...PROMPTABLE, settingEnabled: false })).toBe(false); + }); + + it("stays silent on desktop, where the OS owns the permission", () => { + expect(shouldShowPermissionPrompt({ ...PROMPTABLE, isElectron: true })).toBe(false); + }); + + it("stays silent for every permission other than an unanswered one", () => { + const otherPermissions: ReadonlyArray = [ + "granted", + "denied", + "unsupported", + ]; + for (const permission of otherPermissions) { + expect(shouldShowPermissionPrompt({ ...PROMPTABLE, permission })).toBe(false); + } + }); +}); diff --git a/apps/web/src/notifications/permissionPrompt.logic.ts b/apps/web/src/notifications/permissionPrompt.logic.ts new file mode 100644 index 00000000000..af8cf76cc8f --- /dev/null +++ b/apps/web/src/notifications/permissionPrompt.logic.ts @@ -0,0 +1,28 @@ +import type { WebNotificationPermission } from "./notifier.logic"; + +/** + * Session-scoped memory of the contextual permission nudge, so a browser that + * never answers the permission dialog is asked at most once per page load. + */ +export interface PermissionPromptState { + readonly promptedThisSession: boolean; +} + +/** + * Only an unanswered browser permission is worth nudging about: `"denied"` and + * `"unsupported"` cannot be recovered from in-page, `"granted"` needs nothing, + * and the desktop shell never routes through the Web Notification API at all. + */ +export function shouldShowPermissionPrompt(args: { + readonly state: PermissionPromptState; + readonly settingEnabled: boolean; + readonly permission: WebNotificationPermission; + readonly isElectron: boolean; +}): boolean { + return ( + args.settingEnabled && + args.permission === "default" && + !args.isElectron && + !args.state.promptedThisSession + ); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114d..b35a4794078 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,6 +17,7 @@ import { CommandPalette } from "../components/CommandPalette"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; +import { NotificationCoordinator } from "../components/NotificationCoordinator"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { Button } from "../components/ui/button"; @@ -136,6 +137,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {appShell} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 6c853ca1631..a4d84d4d830 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -956,6 +956,20 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ input: PreviewAutomationWaitForInput, }); +export interface DesktopNotificationRequest { + /** Stable per thread, used to coalesce repeat notifications for one thread. */ + readonly id: string; + readonly title: string; + readonly body: string; + readonly environmentId: string; + readonly threadId: string; +} + +export interface DesktopNotificationActivation { + readonly environmentId: string; + readonly threadId: string; +} + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; // One bootstrap per pool instance currently registered with bootstrap @@ -1014,6 +1028,20 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + /** + * Native notification surface. Optional like `preview` because an older + * desktop shell may host a newer web bundle: callers must feature-detect + * and fall back to the web Notification API when these are undefined. + */ + showNotification?: (request: DesktopNotificationRequest) => Promise; + /** + * Fires when the user clicks a notification raised via `showNotification`. + * Returns an unsubscribe function. Present only in desktop shells that + * support native notifications. + */ + onNotificationActivated?: ( + listener: (activation: DesktopNotificationActivation) => void, + ) => () => void; /** * Desktop-only preview surface. Present iff the renderer is hosted by the * Electron desktop build; web builds have `preview === undefined`. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e8eaf723e17..64dd3e35a4f 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -49,6 +49,21 @@ describe("ClientSettings glass opacity", () => { }); }); +describe("ClientSettings needs-input notifications", () => { + it("defaults needs-input notifications on for existing installs", () => { + expect(decodeClientSettings({}).notifyOnNeedsInput).toBe(true); + }); + + it("honours an explicit opt-out", () => { + expect(decodeClientSettings({ notifyOnNeedsInput: false }).notifyOnNeedsInput).toBe(false); + }); + + it("accepts the toggle in a client settings patch", () => { + expect(decodeClientSettingsPatch({}).notifyOnNeedsInput).toBeUndefined(); + expect(decodeClientSettingsPatch({ notifyOnNeedsInput: false }).notifyOnNeedsInput).toBe(false); + }); +}); + describe("ClientSettings sidebar v2", () => { it("defaults the beta off with a three-day auto-settle threshold", () => { const settings = decodeClientSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 06f7de3db67..7f981131f29 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -95,6 +95,10 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + // Notify when a thread stops and is waiting on the user (a question, an + // approval prompt, or a finished turn). Opt-out, so existing installs get + // the notifications without touching settings. + notifyOnNeedsInput: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -595,6 +599,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + notifyOnNeedsInput: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey(