diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a2b27bb1e9..99c2f866f5 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -2,6 +2,7 @@ import { useEffect, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import ThreadSidebar from "./Sidebar"; +import { useLifecycleNotifications } from "../hooks/useLifecycleNotifications"; import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; @@ -10,6 +11,7 @@ const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + useLifecycleNotifications(); useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b7fde0c5f6..a48055c3da 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -47,6 +47,7 @@ import { resolveAppModelSelectionState, } from "../../modelSelection"; import { ensureNativeApi, readNativeApi } from "../../nativeApi"; +import { requestLocalNotificationPermission } from "../../lib/localNotifications"; import { useStore } from "../../store"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "../../lib/utils"; @@ -463,6 +464,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ["Diff line wrapping"] : []), + ...(settings.attentionNotifications !== DEFAULT_UNIFIED_SETTINGS.attentionNotifications + ? ["Attention notifications"] + : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -483,6 +487,7 @@ export function useSettingsRestore(onRestored?: () => void) { isGitWritingModelDirty, settings.confirmThreadArchive, settings.confirmThreadDelete, + settings.attentionNotifications, settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, @@ -865,6 +870,54 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + attentionNotifications: DEFAULT_UNIFIED_SETTINGS.attentionNotifications, + }) + } + /> + ) : null + } + control={ + { + const enabled = Boolean(checked); + if (!enabled) { + updateSettings({ attentionNotifications: false }); + return; + } + + const permission = await requestLocalNotificationPermission(); + if (permission === "granted") { + updateSettings({ attentionNotifications: true }); + return; + } + + toastManager.add({ + type: "warning", + title: + permission === "unsupported" + ? "Notifications unavailable" + : "Notifications blocked", + description: + permission === "unsupported" + ? "This environment does not support local notifications." + : "Allow notifications for T3 Code in your browser or OS settings to enable this feature.", + }); + }} + aria-label="Enable local attention notifications" + /> + } + /> + ; + turnId?: string; +}): OrchestrationThreadActivity { + return { + id: EventId.makeUnsafe(overrides.id ?? crypto.randomUUID()), + createdAt: overrides.createdAt ?? "2026-03-30T10:00:00.000Z", + kind: overrides.kind ?? "tool.started", + summary: overrides.summary ?? "Tool call", + tone: overrides.tone ?? "tool", + payload: overrides.payload ?? {}, + turnId: overrides.turnId ? TurnId.makeUnsafe(overrides.turnId) : null, + }; +} + +function makeThread( + overrides: Partial< + Parameters[0]["nextThreads"][number] + > = {}, +) { + return { + id: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-1"), + title: "Review auth flow", + activities: [], + latestTurn: null, + session: null, + ...overrides, + }; +} + +const projects = [{ id: ProjectId.makeUnsafe("project-1"), name: "t3code" }]; + +describe("collectLifecycleNotifications", () => { + it("emits a completion notification when a turn newly settles", () => { + const notifications = collectLifecycleNotifications({ + previousThreads: [ + makeThread({ + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "running", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + session: { + provider: "codex", + status: "running", + createdAt: "2026-03-30T10:00:00.000Z", + updatedAt: "2026-03-30T10:00:02.000Z", + orchestrationStatus: "running", + activeTurnId: TurnId.makeUnsafe("turn-1"), + }, + }), + ], + nextThreads: [ + makeThread({ + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: "2026-03-30T10:00:05.000Z", + assistantMessageId: null, + }, + session: { + provider: "codex", + status: "ready", + createdAt: "2026-03-30T10:00:00.000Z", + updatedAt: "2026-03-30T10:00:05.000Z", + orchestrationStatus: "ready", + activeTurnId: undefined, + }, + }), + ], + projects, + }); + + expect(notifications).toEqual([ + { + id: "turn-completed:thread-1:turn-1:2026-03-30T10:00:05.000Z", + kind: "turn-completed", + title: "Turn completed", + body: "Agent finished work in t3code · Review auth flow.", + threadId: ThreadId.makeUnsafe("thread-1"), + }, + ]); + }); + + it("emits an attention notification for a newly requested user input", () => { + const notifications = collectLifecycleNotifications({ + previousThreads: [makeThread()], + nextThreads: [ + makeThread({ + activities: [ + makeActivity({ + id: "evt-user-input", + kind: "user-input.requested", + summary: "Need clarification", + tone: "approval", + payload: { + requestId: "req-user-1", + questions: [ + { + id: "q-1", + header: "Clarify", + question: "Which branch should I use?", + options: [{ label: "main", description: "Use main" }], + }, + ], + }, + }), + ], + }), + ], + projects, + }); + + expect(notifications[0]).toMatchObject({ + id: "user-input:thread-1:req-user-1", + kind: "user-input-requested", + title: "Input needed", + body: "Agent is waiting for your input in t3code · Review auth flow.", + }); + }); + + it("emits an attention notification for a newly requested approval", () => { + const notifications = collectLifecycleNotifications({ + previousThreads: [makeThread()], + nextThreads: [ + makeThread({ + activities: [ + makeActivity({ + id: "evt-approval", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { + requestId: "req-approval-1", + requestKind: "command", + detail: "bun run lint", + }, + }), + ], + }), + ], + projects, + }); + + expect(notifications[0]).toMatchObject({ + id: "approval:thread-1:req-approval-1", + kind: "approval-requested", + title: "Approval needed", + body: "Agent needs approval in t3code · Review auth flow.", + }); + }); + + it("does not duplicate a completion that was already observed", () => { + const completedThread = makeThread({ + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: "2026-03-30T10:00:05.000Z", + assistantMessageId: null, + }, + session: { + provider: "codex", + status: "ready", + createdAt: "2026-03-30T10:00:00.000Z", + updatedAt: "2026-03-30T10:00:05.000Z", + orchestrationStatus: "ready", + activeTurnId: undefined, + }, + }); + + const notifications = collectLifecycleNotifications({ + previousThreads: [completedThread], + nextThreads: [completedThread], + projects, + }); + + expect(notifications).toEqual([]); + }); + + it("prioritizes pending attention requests over completion notifications", () => { + const notifications = collectLifecycleNotifications({ + previousThreads: [ + makeThread({ + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "running", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + session: { + provider: "codex", + status: "running", + createdAt: "2026-03-30T10:00:00.000Z", + updatedAt: "2026-03-30T10:00:02.000Z", + orchestrationStatus: "running", + activeTurnId: TurnId.makeUnsafe("turn-1"), + }, + }), + ], + nextThreads: [ + makeThread({ + activities: [ + makeActivity({ + id: "evt-approval", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { + requestId: "req-approval-1", + requestKind: "command", + detail: "bun run lint", + }, + }), + ], + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-03-30T10:00:00.000Z", + startedAt: "2026-03-30T10:00:01.000Z", + completedAt: "2026-03-30T10:00:05.000Z", + assistantMessageId: null, + }, + session: { + provider: "codex", + status: "ready", + createdAt: "2026-03-30T10:00:00.000Z", + updatedAt: "2026-03-30T10:00:05.000Z", + orchestrationStatus: "ready", + activeTurnId: undefined, + }, + }), + ], + projects, + }); + + expect(notifications).toEqual([ + { + id: "approval:thread-1:req-approval-1", + kind: "approval-requested", + title: "Approval needed", + body: "Agent needs approval in t3code · Review auth flow.", + threadId: ThreadId.makeUnsafe("thread-1"), + }, + ]); + }); +}); diff --git a/apps/web/src/hooks/useLifecycleNotifications.ts b/apps/web/src/hooks/useLifecycleNotifications.ts new file mode 100644 index 0000000000..d410cdaeae --- /dev/null +++ b/apps/web/src/hooks/useLifecycleNotifications.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef } from "react"; +import { useStore } from "../store"; +import { useSettings } from "./useSettings"; +import { sendLocalNotification } from "../lib/localNotifications"; +import { + cloneThreadSnapshot, + collectLifecycleNotifications, + type NotificationThreadSnapshot, +} from "../lifecycleNotifications"; + +function isAppInForeground(): boolean { + if (typeof document === "undefined") { + return true; + } + return document.visibilityState === "visible" && document.hasFocus(); +} + +export function useLifecycleNotifications(): void { + const threads = useStore((store) => store.threads); + const projects = useStore((store) => store.projects); + const settings = useSettings(); + const attentionNotifications = settings.attentionNotifications; + const previousThreadsRef = useRef([]); + const initializedRef = useRef(false); + + useEffect(() => { + const nextThreadSnapshot = threads.map((thread) => cloneThreadSnapshot(thread)); + + if (!initializedRef.current) { + previousThreadsRef.current = nextThreadSnapshot; + initializedRef.current = true; + return; + } + + if (!attentionNotifications) { + previousThreadsRef.current = nextThreadSnapshot; + return; + } + + const notifications = collectLifecycleNotifications({ + previousThreads: previousThreadsRef.current, + nextThreads: nextThreadSnapshot, + projects, + }); + previousThreadsRef.current = nextThreadSnapshot; + + if (notifications.length === 0 || isAppInForeground()) { + return; + } + + for (const notification of notifications) { + sendLocalNotification({ + title: notification.title, + body: notification.body, + tag: notification.id, + }); + } + }, [attentionNotifications, projects, threads]); +} diff --git a/apps/web/src/lib/localNotifications.ts b/apps/web/src/lib/localNotifications.ts new file mode 100644 index 0000000000..33890f69ee --- /dev/null +++ b/apps/web/src/lib/localNotifications.ts @@ -0,0 +1,44 @@ +export interface LocalNotificationInput { + title: string; + body: string; + tag?: string; +} + +export type LocalNotificationPermissionState = NotificationPermission | "unsupported"; + +export function isLocalNotificationSupported(): boolean { + return typeof Notification !== "undefined"; +} + +export async function requestLocalNotificationPermission(): Promise { + if (!isLocalNotificationSupported()) { + return "unsupported"; + } + if (Notification.permission === "granted" || Notification.permission === "denied") { + return Notification.permission; + } + try { + return await Notification.requestPermission(); + } catch { + return "denied"; + } +} + +export function sendLocalNotification(input: LocalNotificationInput): boolean { + if (!isLocalNotificationSupported() || Notification.permission !== "granted") { + return false; + } + + try { + const notification = new Notification(input.title, { + body: input.body, + ...(input.tag ? { tag: input.tag } : {}), + }); + globalThis.setTimeout(() => { + notification.close(); + }, 10_000); + return true; + } catch { + return false; + } +} diff --git a/apps/web/src/lifecycleNotifications.ts b/apps/web/src/lifecycleNotifications.ts new file mode 100644 index 0000000000..544a104f34 --- /dev/null +++ b/apps/web/src/lifecycleNotifications.ts @@ -0,0 +1,126 @@ +import { type Project, type Thread } from "./types"; +import { + derivePendingApprovals, + derivePendingUserInputs, + isLatestTurnSettled, +} from "./session-logic"; + +export type NotificationThreadSnapshot = Pick< + Thread, + "id" | "projectId" | "title" | "activities" | "latestTurn" | "session" +>; +export type NotificationProjectSnapshot = Pick; + +export interface LifecycleNotificationEvent { + id: string; + kind: "approval-requested" | "turn-completed" | "user-input-requested"; + title: string; + body: string; + threadId: Thread["id"]; +} + +function formatThreadTarget( + thread: NotificationThreadSnapshot, + projectName: string | undefined, +): string { + return projectName ? `${projectName} · ${thread.title}` : thread.title; +} + +export function cloneThreadSnapshot( + thread: NotificationThreadSnapshot, +): NotificationThreadSnapshot { + return { + ...thread, + activities: thread.activities.map((activity) => ({ ...activity })), + latestTurn: thread.latestTurn ? { ...thread.latestTurn } : null, + session: thread.session ? { ...thread.session } : null, + }; +} + +export function collectLifecycleNotifications(input: { + previousThreads: ReadonlyArray; + nextThreads: ReadonlyArray; + projects: ReadonlyArray; +}): LifecycleNotificationEvent[] { + const previousByThreadId = new Map( + input.previousThreads.map((thread) => [thread.id, thread] as const), + ); + const projectNameById = new Map( + input.projects.map((project) => [project.id, project.name] as const), + ); + const notifications: LifecycleNotificationEvent[] = []; + + for (const thread of input.nextThreads) { + const previousThread = previousByThreadId.get(thread.id) ?? null; + const threadTarget = formatThreadTarget(thread, projectNameById.get(thread.projectId)); + + const previousPendingApprovals = new Set( + derivePendingApprovals(previousThread?.activities ?? []).map( + (approval) => approval.requestId, + ), + ); + const newApprovals = derivePendingApprovals(thread.activities).filter( + (approval) => !previousPendingApprovals.has(approval.requestId), + ); + for (const approval of newApprovals) { + notifications.push({ + id: `approval:${thread.id}:${approval.requestId}`, + kind: "approval-requested", + title: "Approval needed", + body: `Agent needs approval in ${threadTarget}.`, + threadId: thread.id, + }); + } + + const previousPendingUserInputs = new Set( + derivePendingUserInputs(previousThread?.activities ?? []).map((request) => request.requestId), + ); + const newUserInputs = derivePendingUserInputs(thread.activities).filter( + (request) => !previousPendingUserInputs.has(request.requestId), + ); + for (const request of newUserInputs) { + notifications.push({ + id: `user-input:${thread.id}:${request.requestId}`, + kind: "user-input-requested", + title: "Input needed", + body: `Agent is waiting for your input in ${threadTarget}.`, + threadId: thread.id, + }); + } + + if (newApprovals.length > 0 || newUserInputs.length > 0) { + continue; + } + + const latestTurn = thread.latestTurn; + if ( + !latestTurn || + latestTurn.state !== "completed" || + !latestTurn.completedAt || + !isLatestTurnSettled(latestTurn, thread.session) + ) { + continue; + } + + const previousLatestTurn = previousThread?.latestTurn ?? null; + const previousCompletion = + previousLatestTurn?.turnId === latestTurn.turnId ? previousLatestTurn.completedAt : null; + const previousSettled = previousLatestTurn + ? isLatestTurnSettled(previousLatestTurn, previousThread?.session ?? null) + : false; + + if (previousSettled && previousCompletion === latestTurn.completedAt) { + continue; + } + + notifications.push({ + id: `turn-completed:${thread.id}:${latestTurn.turnId}:${latestTurn.completedAt}`, + kind: "turn-completed", + title: "Turn completed", + body: `Agent finished work in ${threadTarget}.`, + threadId: thread.id, + }); + } + + return notifications; +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 51fe683f99..2b353af0f8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -24,6 +24,7 @@ export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; export const ClientSettingsSchema = Schema.Struct({ + attentionNotifications: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),