From 4ea8bcf5f5bd4fccccb58177ae80087a8ab7a4d6 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:53:04 -0400 Subject: [PATCH 1/6] feat(contracts): add notifyOnNeedsInput setting and DesktopBridge notification surface Adds the `notifyOnNeedsInput` client setting (default on, opt-out) to `ClientSettingsSchema` and `ClientSettingsPatch`, plus the renderer-facing `DesktopNotificationRequest` / `DesktopNotificationActivation` types and the optional `showNotification` / `onNotificationActivated` members on `DesktopBridge`. Both bridge members are optional like `preview` so a newer web bundle can run inside an older desktop shell and feature-detect. The desktop `ClientSettings` test fixture gains the new required field so the repo still typechecks. Co-Authored-By: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 1 + packages/contracts/src/ipc.ts | 28 +++++++++++++++++++ packages/contracts/src/settings.test.ts | 15 ++++++++++ packages/contracts/src/settings.ts | 5 ++++ 4 files changed, 49 insertions(+) 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/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( From c5b52231d090a12a21b0672ded93c22b645152ec Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:00:50 -0400 Subject: [PATCH 2/6] feat(web): add pure needs-attention tracker and suppression logic Co-Authored-By: Claude Fable 5 --- .../needsAttention.logic.test.ts | 339 ++++++++++++++++++ .../src/notifications/needsAttention.logic.ts | 122 +++++++ 2 files changed, 461 insertions(+) create mode 100644 apps/web/src/notifications/needsAttention.logic.test.ts create mode 100644 apps/web/src/notifications/needsAttention.logic.ts 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..f1f08394b8c --- /dev/null +++ b/apps/web/src/notifications/needsAttention.logic.ts @@ -0,0 +1,122 @@ +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. + */ +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 + ); +} From 9a905a72c168b0a64df4c0f2b64cc942d55900a3 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:12:07 -0400 Subject: [PATCH 3/6] feat(desktop): native OS notification IPC with click-to-reveal activation Co-Authored-By: Claude Fable 5 --- .../src/electron/ElectronNotification.test.ts | 199 ++++++++++++++++++ .../src/electron/ElectronNotification.ts | 95 +++++++++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 2 + .../src/ipc/methods/notifications.test.ts | 59 ++++++ apps/desktop/src/ipc/methods/notifications.ts | 26 +++ apps/desktop/src/main.ts | 4 + apps/desktop/src/preload.ts | 12 ++ 8 files changed, 399 insertions(+) create mode 100644 apps/desktop/src/electron/ElectronNotification.test.ts create mode 100644 apps/desktop/src/electron/ElectronNotification.ts create mode 100644 apps/desktop/src/ipc/methods/notifications.test.ts create mode 100644 apps/desktop/src/ipc/methods/notifications.ts diff --git a/apps/desktop/src/electron/ElectronNotification.test.ts b/apps/desktop/src/electron/ElectronNotification.test.ts new file mode 100644 index 00000000000..b566a9ebaed --- /dev/null +++ b/apps/desktop/src/electron/ElectronNotification.test.ts @@ -0,0 +1,199 @@ +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; +} + +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 }; + 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; + } + } + + 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 clickFirstNotification = () => { + const entry = notificationState.instances[0]; + assert.isDefined(entry); + const click = entry.listeners.get("click"); + if (click === undefined) { + throw new Error("Expected the notification to register a click listener."); + } + click(); +}; + +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("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..bdb97caa8c0 --- /dev/null +++ b/apps/desktop/src/electron/ElectronNotification.ts @@ -0,0 +1,95 @@ +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); + + 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, + }); + notification.on("click", () => { + runFork(activate(request)); + }); + 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) => { From 909a0df493e2708709eb19eb1099b65c01458151 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:23:12 -0400 Subject: [PATCH 4/6] feat(web): notification coordinator with platform shim and click-to-navigate Co-Authored-By: Claude Fable 5 --- .../components/NotificationCoordinator.tsx | 89 ++++++++++++++++ .../notifications/coordinator.logic.test.ts | 100 ++++++++++++++++++ .../src/notifications/coordinator.logic.ts | 69 ++++++++++++ .../src/notifications/notifier.logic.test.ts | 28 +++++ apps/web/src/notifications/notifier.logic.ts | 17 +++ apps/web/src/notifications/notifier.ts | 89 ++++++++++++++++ apps/web/src/routes/__root.tsx | 2 + 7 files changed, 394 insertions(+) create mode 100644 apps/web/src/components/NotificationCoordinator.tsx create mode 100644 apps/web/src/notifications/coordinator.logic.test.ts create mode 100644 apps/web/src/notifications/coordinator.logic.ts create mode 100644 apps/web/src/notifications/notifier.logic.test.ts create mode 100644 apps/web/src/notifications/notifier.logic.ts create mode 100644 apps/web/src/notifications/notifier.ts diff --git a/apps/web/src/components/NotificationCoordinator.tsx b/apps/web/src/components/NotificationCoordinator.tsx new file mode 100644 index 00000000000..5bedf3a8d5b --- /dev/null +++ b/apps/web/src/components/NotificationCoordinator.tsx @@ -0,0 +1,89 @@ +import { useNavigate, useParams } from "@tanstack/react-router"; +import { useCallback, useEffect, useEffectEvent, useState } from "react"; + +import { useClientSettings } from "~/hooks/useSettings"; +import { + buildAwarenessInputs, + resolveActivationRouteParams, +} from "~/notifications/coordinator.logic"; +import { + createAttentionTracker, + shouldSuppressAttentionEvent, + type AttentionEvent, +} from "~/notifications/needsAttention.logic"; +import { + canDeliverNotification, + getNotificationPermissionState, + showAttentionNotification, +} from "~/notifications/notifier"; +import { useProjects, useThreadShells } from "~/state/entities"; +import { resolveThreadRouteRef } 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); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const [tracker] = useState(createAttentionTracker); + + const handleActivation = useCallback( + (activation: { readonly environmentId: string; readonly threadId: string }) => { + const params = resolveActivationRouteParams(activation); + if (!params) { + return; + } + void navigate({ to: "/$environmentId/$threadId", params }); + }, + [navigate], + ); + + const deliverEvents = useEffectEvent((events: ReadonlyArray) => { + if (events.length === 0) { + return; + } + if (!canDeliverNotification(getNotificationPermissionState(), notifyOnNeedsInput)) { + 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/notifications/coordinator.logic.test.ts b/apps/web/src/notifications/coordinator.logic.test.ts new file mode 100644 index 00000000000..0454bb4ba55 --- /dev/null +++ b/apps/web/src/notifications/coordinator.logic.test.ts @@ -0,0 +1,100 @@ +import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildAwarenessInputs, + resolveActivationRouteParams, + type AwarenessProjectInput, + type AwarenessThreadInput, +} from "./coordinator.logic"; + +const NOW = "2026-07-25T12:00:00.000Z"; + +function thread( + overrides: { + readonly environmentId?: string; + readonly projectId?: string; + readonly id?: string; + } = {}, +): AwarenessThreadInput { + return { + environmentId: (overrides.environmentId ?? "env-1") as EnvironmentId, + projectId: (overrides.projectId ?? "project-1") as ProjectId, + id: (overrides.id ?? "thread-1") as ThreadId, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + session: null, + latestTurn: null, + updatedAt: NOW, + hasPendingApprovals: false, + hasPendingUserInput: false, + }; +} + +function project( + overrides: { + readonly environmentId?: string; + readonly id?: string; + readonly title?: string; + } = {}, +): AwarenessProjectInput { + return { + environmentId: (overrides.environmentId ?? "env-1") as EnvironmentId, + id: (overrides.id ?? "project-1") as ProjectId, + title: overrides.title ?? "Proj", + }; +} + +describe("buildAwarenessInputs", () => { + 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/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.ts b/apps/web/src/notifications/notifier.ts new file mode 100644 index 00000000000..80ca8f92608 --- /dev/null +++ b/apps/web/src/notifications/notifier.ts @@ -0,0 +1,89 @@ +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; + } + + const notification = new Notification(event.title, { body: event.body, tag: event.key }); + notification.addEventListener("click", () => { + window.focus(); + onActivate({ environmentId: event.environmentId, threadId: event.threadId }); + notification.close(); + }); +} 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} From 56c81469eae6c5757f3c11aefa877ca3da650bde Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:33:02 -0400 Subject: [PATCH 5/6] feat(web): needs-input notification setting and web permission UX Co-Authored-By: Claude Fable 5 --- .../components/NotificationCoordinator.tsx | 47 ++++++++++++++- .../components/settings/SettingsPanels.tsx | 58 +++++++++++++++++++ .../permissionPrompt.logic.test.ts | 42 ++++++++++++++ .../notifications/permissionPrompt.logic.ts | 28 +++++++++ 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/notifications/permissionPrompt.logic.test.ts create mode 100644 apps/web/src/notifications/permissionPrompt.logic.ts diff --git a/apps/web/src/components/NotificationCoordinator.tsx b/apps/web/src/components/NotificationCoordinator.tsx index 5bedf3a8d5b..b020d5d6a01 100644 --- a/apps/web/src/components/NotificationCoordinator.tsx +++ b/apps/web/src/components/NotificationCoordinator.tsx @@ -1,6 +1,8 @@ import { useNavigate, useParams } from "@tanstack/react-router"; -import { useCallback, useEffect, useEffectEvent, useState } from "react"; +import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react"; +import { toastManager } from "~/components/ui/toast"; +import { isElectron } from "~/env"; import { useClientSettings } from "~/hooks/useSettings"; import { buildAwarenessInputs, @@ -14,8 +16,14 @@ import { 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 { resolveThreadRouteRef } from "~/threadRoutes"; @@ -38,6 +46,7 @@ export function NotificationCoordinator() { select: (params) => resolveThreadRouteRef(params), }); const [tracker] = useState(createAttentionTracker); + const promptStateRef = useRef({ promptedThisSession: false }); const handleActivation = useCallback( (activation: { readonly environmentId: string; readonly threadId: string }) => { @@ -50,11 +59,45 @@ export function NotificationCoordinator() { [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; } - if (!canDeliverNotification(getNotificationPermissionState(), notifyOnNeedsInput)) { + 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; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 01eba912567..149fa1e67c6 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("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 + ); +} From d31efcf648b862dc9b123dfcde1d6ca296c4e0e0 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:05:02 -0400 Subject: [PATCH 6/6] =?UTF-8?q?fix(notifications):=20final-review=20fixes?= =?UTF-8?q?=20=E2=80=94=20web=20notification=20guard,=20desktop=20coalesci?= =?UTF-8?q?ng,=20draft-route=20suppression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard `new Notification(...)` against Android Chrome's illegal-constructor throw, coalesce desktop notifications by request id, resolve the active thread through the draft route, hide the denied hint while the toggle is off, and pin the shim's platform routing with tests. Co-Authored-By: Claude Fable 5 --- .../src/electron/ElectronNotification.test.ts | 73 ++++++++++++++- .../src/electron/ElectronNotification.ts | 18 ++++ .../components/NotificationCoordinator.tsx | 19 +++- .../components/settings/SettingsPanels.tsx | 2 +- .../src/notifications/needsAttention.logic.ts | 5 ++ apps/web/src/notifications/notifier.test.ts | 88 +++++++++++++++++++ apps/web/src/notifications/notifier.ts | 13 ++- 7 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/notifications/notifier.test.ts diff --git a/apps/desktop/src/electron/ElectronNotification.test.ts b/apps/desktop/src/electron/ElectronNotification.test.ts index b566a9ebaed..7ab125c20b3 100644 --- a/apps/desktop/src/electron/ElectronNotification.test.ts +++ b/apps/desktop/src/electron/ElectronNotification.test.ts @@ -11,6 +11,7 @@ interface FakeNotificationEntry { readonly options: { readonly title?: string; readonly body?: string }; readonly listeners: Map void>; shown: number; + closed: number; } const { notificationState, FakeNotification } = vi.hoisted(() => { @@ -31,7 +32,7 @@ const { notificationState, FakeNotification } = vi.hoisted(() => { if (notificationState.constructorThrows !== null) { throw notificationState.constructorThrows; } - this.entry = { options, listeners: new Map(), shown: 0 }; + this.entry = { options, listeners: new Map(), shown: 0, closed: 0 }; notificationState.instances.push(this.entry); } @@ -43,6 +44,13 @@ const { notificationState, FakeNotification } = vi.hoisted(() => { 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 }; @@ -62,8 +70,8 @@ const request = { threadId: "thread-1", } as const; -const clickFirstNotification = () => { - const entry = notificationState.instances[0]; +const clickNotification = (index: number) => { + const entry = notificationState.instances[index]; assert.isDefined(entry); const click = entry.listeners.get("click"); if (click === undefined) { @@ -72,6 +80,23 @@ const clickFirstNotification = () => { 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; @@ -175,6 +200,48 @@ describe("ElectronNotification", () => { ), ); + 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"); diff --git a/apps/desktop/src/electron/ElectronNotification.ts b/apps/desktop/src/electron/ElectronNotification.ts index bdb97caa8c0..178a5490e69 100644 --- a/apps/desktop/src/electron/ElectronNotification.ts +++ b/apps/desktop/src/electron/ElectronNotification.ts @@ -42,6 +42,14 @@ export const make = Effect.gen(function* () { 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, ) { @@ -76,9 +84,19 @@ export const make = Effect.gen(function* () { 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 }), diff --git a/apps/web/src/components/NotificationCoordinator.tsx b/apps/web/src/components/NotificationCoordinator.tsx index b020d5d6a01..dc855936892 100644 --- a/apps/web/src/components/NotificationCoordinator.tsx +++ b/apps/web/src/components/NotificationCoordinator.tsx @@ -1,7 +1,8 @@ import { useNavigate, useParams } from "@tanstack/react-router"; -import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react"; +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 { @@ -25,7 +26,7 @@ import { type PermissionPromptState, } from "~/notifications/permissionPrompt.logic"; import { useProjects, useThreadShells } from "~/state/entities"; -import { resolveThreadRouteRef } from "~/threadRoutes"; +import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "~/threadRoutes"; /** * Announces chats that start waiting on the user, on desktop through the OS @@ -41,10 +42,20 @@ export function NotificationCoordinator() { const threadShells = useThreadShells(); const projects = useProjects(); const notifyOnNeedsInput = useClientSettings((settings) => settings.notifyOnNeedsInput); - const routeThreadRef = useParams({ + // 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) => resolveThreadRouteRef(params), + 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 }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 149fa1e67c6..312cfc85f3b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -756,7 +756,7 @@ export function GeneralSettingsPanel() { title="Notify when an agent needs input" description="Show a notification when a chat is waiting on your approval, an answer, or its turn has finished." status={ - notificationPermission === "denied" + settings.notifyOnNeedsInput && notificationPermission === "denied" ? "Notifications are blocked by your browser. Allow them for this site to receive alerts." : null } diff --git a/apps/web/src/notifications/needsAttention.logic.ts b/apps/web/src/notifications/needsAttention.logic.ts index f1f08394b8c..c8338dd5ddd 100644 --- a/apps/web/src/notifications/needsAttention.logic.ts +++ b/apps/web/src/notifications/needsAttention.logic.ts @@ -34,6 +34,11 @@ export interface AttentionTracker { * 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(); 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 index 80ca8f92608..0efcf932c9f 100644 --- a/apps/web/src/notifications/notifier.ts +++ b/apps/web/src/notifications/notifier.ts @@ -80,7 +80,18 @@ export function showAttentionNotification( return; } - const notification = new Notification(event.title, { body: event.body, tag: event.key }); + // 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 });