| author | |
| committer | |
| log | 42b6bcc6f59bd0369377746b0f2ef370c275b624 |
| tree | 485814a29cb5f2bcc5cb0b57f2817928d7d07bcb |
| parent | ca2cc1339bbd1904b932c3a92d23a2c0ffe1d267 |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 367 insertions(+), 20 deletions(-)
apps/desktop/src/settings/DesktopClientSettings.test.ts+1| ... | @@ -19,6 +19,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; | ... | @@ -19,6 +19,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; |
| 19 | const clientSettings: ClientSettings = { | 19 | const clientSettings: ClientSettings = { |
| 20 | ...DEFAULT_CLIENT_SETTINGS, | 20 | ...DEFAULT_CLIENT_SETTINGS, |
| 21 | notificationMode: "notifications-and-sound", | 21 | notificationMode: "notifications-and-sound", |
| 22 | inAppNotificationsEnabled: true, | ||
| 22 | appearanceContrast: 100, | 23 | appearanceContrast: 100, |
| 23 | browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, | 24 | browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, |
| 24 | browserDefaultZoomFactor: 1.25, | 25 | browserDefaultZoomFactor: 1.25, |
apps/web/src/components/ThreadNotificationCoordinator.test.tsx created+249| ... | @@ -0,0 +1,249 @@ | ||
| 1 | import type { ClientSettings } from "@t3tools/contracts/settings"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import { act } from "react"; | ||
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | const state = vi.hoisted(() => ({ | ||
| 8 | mode: "off" as ClientSettings["notificationMode"], | ||
| 9 | inApp: true, | ||
| 10 | active: { environmentId: "env-1", threadId: "other-thread" }, | ||
| 11 | focused: true, | ||
| 12 | visible: "visible", | ||
| 13 | live: true, | ||
| 14 | completedAt: null as string | null, | ||
| 15 | archivedAt: null as string | null, | ||
| 16 | input: false, | ||
| 17 | approval: false, | ||
| 18 | sessionError: false, | ||
| 19 | turnError: false, | ||
| 20 | add: vi.fn( | ||
| 21 | (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) => | ||
| 22 | "toast-1", | ||
| 23 | ), | ||
| 24 | close: vi.fn(), | ||
| 25 | navigate: vi.fn(), | ||
| 26 | sound: vi.fn(), | ||
| 27 | notification: vi.fn(function () {}), | ||
| 28 | })); | ||
| 29 | |||
| 30 | vi.mock("@effect/atom-react", () => ({ | ||
| 31 | useAtomValue: () => ({ | ||
| 32 | status: state.live ? "live" : "disconnected", | ||
| 33 | snapshot: Option.some({ | ||
| 34 | threads: [ | ||
| 35 | { | ||
| 36 | id: "thread-1", | ||
| 37 | title: "Fix the login form", | ||
| 38 | archivedAt: state.archivedAt, | ||
| 39 | hasPendingUserInput: state.input, | ||
| 40 | hasPendingApprovals: state.approval, | ||
| 41 | session: state.sessionError ? { status: "error" } : null, | ||
| 42 | latestTurn: { | ||
| 43 | turnId: "turn-1", | ||
| 44 | state: state.turnError ? "error" : state.completedAt ? "completed" : "running", | ||
| 45 | completedAt: state.completedAt, | ||
| 46 | }, | ||
| 47 | }, | ||
| 48 | ], | ||
| 49 | }), | ||
| 50 | }), | ||
| 51 | })); | ||
| 52 | vi.mock("@tanstack/react-router", () => ({ | ||
| 53 | useNavigate: () => state.navigate, | ||
| 54 | useParams: () => state.active, | ||
| 55 | })); | ||
| 56 | vi.mock("../hooks/useSettings", () => ({ | ||
| 57 | useClientSettings: ( | ||
| 58 | select: ( | ||
| 59 | settings: Pick<ClientSettings, "notificationMode" | "inAppNotificationsEnabled">, | ||
| 60 | ) => unknown, | ||
| 61 | ) => select({ notificationMode: state.mode, inAppNotificationsEnabled: state.inApp }), | ||
| 62 | getClientSettings: () => ({ notificationMode: state.mode }), | ||
| 63 | })); | ||
| 64 | vi.mock("../state/environments", () => ({ | ||
| 65 | useEnvironments: () => ({ environments: [{ environmentId: "env-1" }] }), | ||
| 66 | })); | ||
| 67 | vi.mock("../state/shell", () => ({ | ||
| 68 | environmentShell: { stateValueAtom: vi.fn() }, | ||
| 69 | })); | ||
| 70 | vi.mock("../threadNotifications", async (importOriginal) => ({ | ||
| 71 | ...(await importOriginal<typeof import("../threadNotifications")>()), | ||
| 72 | playNotificationSound: state.sound, | ||
| 73 | })); | ||
| 74 | vi.mock("./ui/toast", () => ({ | ||
| 75 | toastManager: { add: state.add, close: state.close }, | ||
| 76 | })); | ||
| 77 | |||
| 78 | import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator"; | ||
| 79 | |||
| 80 | let renderer: ReactTestRenderer | undefined; | ||
| 81 | |||
| 82 | async function render() { | ||
| 83 | await act(() => { | ||
| 84 | if (renderer) renderer.update(<ThreadNotificationCoordinator />); | ||
| 85 | else renderer = create(<ThreadNotificationCoordinator />); | ||
| 86 | }); | ||
| 87 | } | ||
| 88 | |||
| 89 | async function complete() { | ||
| 90 | state.completedAt = "2026-09-13T10:00:00.000Z"; | ||
| 91 | await render(); | ||
| 92 | } | ||
| 93 | |||
| 94 | beforeEach(() => { | ||
| 95 | vi.clearAllMocks(); | ||
| 96 | Object.assign(state, { | ||
| 97 | mode: "off", | ||
| 98 | inApp: true, | ||
| 99 | active: { environmentId: "env-1", threadId: "other-thread" }, | ||
| 100 | focused: true, | ||
| 101 | visible: "visible", | ||
| 102 | live: true, | ||
| 103 | completedAt: null, | ||
| 104 | archivedAt: null, | ||
| 105 | input: false, | ||
| 106 | approval: false, | ||
| 107 | sessionError: false, | ||
| 108 | turnError: false, | ||
| 109 | }); | ||
| 110 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 111 | vi.stubGlobal("document", { | ||
| 112 | get visibilityState() { | ||
| 113 | return state.visible; | ||
| 114 | }, | ||
| 115 | hasFocus: () => state.focused, | ||
| 116 | addEventListener: vi.fn(), | ||
| 117 | removeEventListener: vi.fn(), | ||
| 118 | }); | ||
| 119 | vi.stubGlobal("Notification", Object.assign(state.notification, { permission: "granted" })); | ||
| 120 | }); | ||
| 121 | |||
| 122 | afterEach(async () => { | ||
| 123 | await act(() => renderer?.unmount()); | ||
| 124 | renderer = undefined; | ||
| 125 | vi.unstubAllGlobals(); | ||
| 126 | }); | ||
| 127 | |||
| 128 | describe("thread notifications", () => { | ||
| 129 | it("alerts once with system alerts off and opens the completed thread", async () => { | ||
| 130 | await render(); | ||
| 131 | await complete(); | ||
| 132 | await render(); | ||
| 133 | expect(state.add).toHaveBeenCalledTimes(1); | ||
| 134 | const toast = state.add.mock.calls[0]?.[0]; | ||
| 135 | expect(toast?.title).toBe("Thread completed"); | ||
| 136 | expect(toast?.description).toBe("Fix the login form"); | ||
| 137 | toast?.actionProps.onClick(); | ||
| 138 | expect(state.close).toHaveBeenCalledWith("toast-1"); | ||
| 139 | expect(state.navigate).toHaveBeenCalledWith({ | ||
| 140 | to: "/$environmentId/$threadId", | ||
| 141 | params: { environmentId: "env-1", threadId: "thread-1" }, | ||
| 142 | }); | ||
| 143 | expect(state.notification).not.toHaveBeenCalled(); | ||
| 144 | }); | ||
| 145 | |||
| 146 | it.each(["active", "blurred", "hidden", "archived", "disabled"])( | ||
| 147 | "does not show a completion toast for %s threads", | ||
| 148 | async (condition) => { | ||
| 149 | await render(); | ||
| 150 | if (condition === "active") state.active.threadId = "thread-1"; | ||
| 151 | if (condition === "blurred") state.focused = false; | ||
| 152 | if (condition === "hidden") state.visible = "hidden"; | ||
| 153 | if (condition === "archived") state.archivedAt = "2026-09-13T09:00:00.000Z"; | ||
| 154 | if (condition === "disabled") state.inApp = false; | ||
| 155 | await complete(); | ||
| 156 | expect(state.add).not.toHaveBeenCalled(); | ||
| 157 | }, | ||
| 158 | ); | ||
| 159 | |||
| 160 | it.each([ | ||
| 161 | ["input", "Input needed"], | ||
| 162 | ["approval", "Approval needed"], | ||
| 163 | ["sessionError", "Thread failed"], | ||
| 164 | ["turnError", "Thread failed"], | ||
| 165 | ] as const)("uses the same %s event for in-app and desktop alerts", async (event, title) => { | ||
| 166 | state.mode = "notifications-and-sound"; | ||
| 167 | await render(); | ||
| 168 | state[event] = true; | ||
| 169 | await render(); | ||
| 170 | await render(); | ||
| 171 | expect(state.add).toHaveBeenCalledTimes(1); | ||
| 172 | expect(state.add).toHaveBeenLastCalledWith(expect.objectContaining({ title })); | ||
| 173 | expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function)); | ||
| 174 | expect(state.notification).not.toHaveBeenCalled(); | ||
| 175 | |||
| 176 | state[event] = false; | ||
| 177 | await render(); | ||
| 178 | state.focused = false; | ||
| 179 | state[event] = true; | ||
| 180 | await render(); | ||
| 181 | await render(); | ||
| 182 | expect(state.add).toHaveBeenCalledTimes(1); | ||
| 183 | expect(state.notification).toHaveBeenCalledTimes(1); | ||
| 184 | expect(state.notification).toHaveBeenCalledWith(title, { | ||
| 185 | body: "Fix the login form", | ||
| 186 | tag: "env-1:thread-1", | ||
| 187 | silent: true, | ||
| 188 | }); | ||
| 189 | }); | ||
| 190 | |||
| 191 | it("keeps desktop alerts when in-app notifications are disabled", async () => { | ||
| 192 | state.inApp = false; | ||
| 193 | state.mode = "notifications"; | ||
| 194 | await render(); | ||
| 195 | await complete(); | ||
| 196 | expect(state.add).not.toHaveBeenCalled(); | ||
| 197 | expect(state.notification).toHaveBeenCalledTimes(1); | ||
| 198 | state.inApp = true; | ||
| 199 | await render(); | ||
| 200 | expect(state.add).not.toHaveBeenCalled(); | ||
| 201 | }); | ||
| 202 | |||
| 203 | it("does not replay a completion when opting in from all alerts off", async () => { | ||
| 204 | state.inApp = false; | ||
| 205 | await render(); | ||
| 206 | await complete(); | ||
| 207 | state.inApp = true; | ||
| 208 | await render(); | ||
| 209 | expect(state.add).not.toHaveBeenCalled(); | ||
| 210 | }); | ||
| 211 | |||
| 212 | it("compares the environment as well as the thread", async () => { | ||
| 213 | state.active = { environmentId: "env-2", threadId: "thread-1" }; | ||
| 214 | await render(); | ||
| 215 | await complete(); | ||
| 216 | expect(state.add).toHaveBeenCalledTimes(1); | ||
| 217 | }); | ||
| 218 | |||
| 219 | it("does not replay completed threads on first load or reconnect", async () => { | ||
| 220 | await complete(); | ||
| 221 | state.live = false; | ||
| 222 | await render(); | ||
| 223 | state.live = true; | ||
| 224 | await render(); | ||
| 225 | expect(state.add).not.toHaveBeenCalled(); | ||
| 226 | }); | ||
| 227 | |||
| 228 | it("keeps sound but replaces the system popup when showing a toast", async () => { | ||
| 229 | state.mode = "notifications-and-sound"; | ||
| 230 | await render(); | ||
| 231 | await complete(); | ||
| 232 | expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function)); | ||
| 233 | expect(state.add).toHaveBeenCalledTimes(1); | ||
| 234 | expect(state.notification).not.toHaveBeenCalled(); | ||
| 235 | }); | ||
| 236 | |||
| 237 | it("keeps system alerts when the app is in the background", async () => { | ||
| 238 | state.mode = "notifications"; | ||
| 239 | state.focused = false; | ||
| 240 | await render(); | ||
| 241 | await complete(); | ||
| 242 | expect(state.add).not.toHaveBeenCalled(); | ||
| 243 | expect(state.notification).toHaveBeenCalledWith("Thread completed", { | ||
| 244 | body: "Fix the login form", | ||
| 245 | tag: "env-1:thread-1", | ||
| 246 | silent: true, | ||
| 247 | }); | ||
| 248 | }); | ||
| 249 | }); | ||
apps/web/src/components/ThreadNotificationCoordinator.tsx+69-19| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useNavigate } from "@tanstack/react-router"; | 2 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 3 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import { useEffect, useRef } from "react"; | 5 | import { useEffect, useRef } from "react"; |
| ... | @@ -14,10 +14,14 @@ import { | ... | @@ -14,10 +14,14 @@ import { |
| 14 | unlockNotificationAudio, | 14 | unlockNotificationAudio, |
| 15 | } from "../threadNotifications"; | 15 | } from "../threadNotifications"; |
| 16 | import { resolveSidebarThreadStatus } from "./Sidebar.logic"; | 16 | import { resolveSidebarThreadStatus } from "./Sidebar.logic"; |
| 17 | import { toastManager } from "./ui/toast"; | ||
| 17 | 18 | ||
| 18 | export function ThreadNotificationCoordinator() { | 19 | export function ThreadNotificationCoordinator() { |
| 19 | const { environments } = useEnvironments(); | 20 | const { environments } = useEnvironments(); |
| 20 | const mode = useClientSettings((settings) => settings.notificationMode); | 21 | const mode = useClientSettings((settings) => settings.notificationMode); |
| 22 | const inAppNotificationsEnabled = useClientSettings( | ||
| 23 | (settings) => settings.inAppNotificationsEnabled, | ||
| 24 | ); | ||
| 21 | 25 | ||
| 22 | useEffect(() => { | 26 | useEffect(() => { |
| 23 | if (!hasNotificationSound(mode)) return; | 27 | if (!hasNotificationSound(mode)) return; |
| ... | @@ -29,7 +33,7 @@ export function ThreadNotificationCoordinator() { | ... | @@ -29,7 +33,7 @@ export function ThreadNotificationCoordinator() { |
| 29 | }; | 33 | }; |
| 30 | }, [mode]); | 34 | }, [mode]); |
| 31 | 35 | ||
| 32 | if (mode === "off") return null; | 36 | if (mode === "off" && !inAppNotificationsEnabled) return null; |
| 33 | 37 | ||
| 34 | return environments.map((environment) => ( | 38 | return environments.map((environment) => ( |
| 35 | <EnvironmentNotifications | 39 | <EnvironmentNotifications |
| ... | @@ -42,20 +46,29 @@ export function ThreadNotificationCoordinator() { | ... | @@ -42,20 +46,29 @@ export function ThreadNotificationCoordinator() { |
| 42 | function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) { | 46 | function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) { |
| 43 | const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); | 47 | const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); |
| 44 | const mode = useClientSettings((settings) => settings.notificationMode); | 48 | const mode = useClientSettings((settings) => settings.notificationMode); |
| 49 | const inAppNotificationsEnabled = useClientSettings( | ||
| 50 | (settings) => settings.inAppNotificationsEnabled, | ||
| 51 | ); | ||
| 45 | const navigate = useNavigate(); | 52 | const navigate = useNavigate(); |
| 46 | const previous = useRef(new Map<ThreadId, { input: string | null; completion: number | null }>()); | 53 | const { environmentId: activeEnvironmentId, threadId: activeThreadId } = useParams({ |
| 54 | strict: false, | ||
| 55 | }); | ||
| 56 | const previous = useRef( | ||
| 57 | new Map<ThreadId, { attention: string | null; completion: number | null }>(), | ||
| 58 | ); | ||
| 47 | 59 | ||
| 48 | useEffect(() => { | 60 | useEffect(() => { |
| 49 | if (shell.status !== "live" || Option.isNone(shell.snapshot)) { | 61 | if (shell.status !== "live" || Option.isNone(shell.snapshot)) { |
| 50 | previous.current.clear(); | 62 | previous.current.clear(); |
| 51 | return; | 63 | return; |
| 52 | } | 64 | } |
| 53 | const next = new Map<ThreadId, { input: string | null; completion: number | null }>(); | 65 | const next = new Map<ThreadId, { attention: string | null; completion: number | null }>(); |
| 54 | for (const thread of shell.snapshot.value.threads) { | 66 | for (const thread of shell.snapshot.value.threads) { |
| 55 | const status = resolveSidebarThreadStatus(thread); | 67 | let status = resolveSidebarThreadStatus(thread); |
| 68 | if (status === "ready" && thread.latestTurn?.state === "error") status = "failed"; | ||
| 56 | const prior = previous.current.get(thread.id); | 69 | const prior = previous.current.get(thread.id); |
| 57 | const input = | 70 | const attention = |
| 58 | status === "input" || status === "approval" | 71 | status === "input" || status === "approval" || status === "failed" |
| 59 | ? `${thread.latestTurn?.turnId ?? ""}:${status}` | 72 | ? `${thread.latestTurn?.turnId ?? ""}:${status}` |
| 60 | : null; | 73 | : null; |
| 61 | const completedAt = Date.parse(thread.latestTurn?.completedAt ?? ""); | 74 | const completedAt = Date.parse(thread.latestTurn?.completedAt ?? ""); |
| ... | @@ -65,20 +78,52 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen | ... | @@ -65,20 +78,52 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen |
| 65 | Number.isFinite(completedAt) | 78 | Number.isFinite(completedAt) |
| 66 | ? completedAt | 79 | ? completedAt |
| 67 | : (prior?.completion ?? null); | 80 | : (prior?.completion ?? null); |
| 68 | next.set(thread.id, { input, completion }); | 81 | next.set(thread.id, { attention, completion }); |
| 69 | if (!prior || mode === "off" || thread.archivedAt !== null) continue; | 82 | if (!prior || thread.archivedAt !== null) continue; |
| 70 | const kind = | 83 | const kind = |
| 71 | input && input !== prior.input | 84 | attention && attention !== prior.attention |
| 72 | ? "input" | 85 | ? "input" |
| 73 | : completion !== null && (prior.completion === null || completion > prior.completion) | 86 | : completion !== null && (prior.completion === null || completion > prior.completion) |
| 74 | ? "completion" | 87 | ? "completion" |
| 75 | : null; | 88 | : null; |
| 76 | if (!kind) continue; | 89 | if (!kind) continue; |
| 90 | const title = | ||
| 91 | kind === "completion" | ||
| 92 | ? "Thread completed" | ||
| 93 | : status === "approval" | ||
| 94 | ? "Approval needed" | ||
| 95 | : status === "failed" | ||
| 96 | ? "Thread failed" | ||
| 97 | : "Input needed"; | ||
| 77 | if (hasNotificationSound(mode)) { | 98 | if (hasNotificationSound(mode)) { |
| 78 | void playNotificationSound(kind, () => | 99 | void playNotificationSound(kind, () => |
| 79 | hasNotificationSound(getClientSettings().notificationMode), | 100 | hasNotificationSound(getClientSettings().notificationMode), |
| 80 | ); | 101 | ); |
| 81 | } | 102 | } |
| 103 | if ( | ||
| 104 | inAppNotificationsEnabled && | ||
| 105 | document.visibilityState === "visible" && | ||
| 106 | document.hasFocus() && | ||
| 107 | (activeEnvironmentId !== environmentId || activeThreadId !== thread.id) | ||
| 108 | ) { | ||
| 109 | const toastId = toastManager.add({ | ||
| 110 | type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning", | ||
| 111 | title, | ||
| 112 | description: thread.title, | ||
| 113 | data: { hideCopyButton: true }, | ||
| 114 | actionProps: { | ||
| 115 | children: "Open thread", | ||
| 116 | onClick: () => { | ||
| 117 | toastManager.close(toastId); | ||
| 118 | void navigate({ | ||
| 119 | to: "/$environmentId/$threadId", | ||
| 120 | params: { environmentId, threadId: thread.id }, | ||
| 121 | }); | ||
| 122 | }, | ||
| 123 | }, | ||
| 124 | }); | ||
| 125 | continue; | ||
| 126 | } | ||
| 82 | if ( | 127 | if ( |
| 83 | !hasDesktopNotifications(mode) || | 128 | !hasDesktopNotifications(mode) || |
| 84 | typeof Notification === "undefined" || | 129 | typeof Notification === "undefined" || |
| ... | @@ -86,14 +131,11 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen | ... | @@ -86,14 +131,11 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen |
| 86 | ) | 131 | ) |
| 87 | continue; | 132 | continue; |
| 88 | try { | 133 | try { |
| 89 | const notification = new Notification( | 134 | const notification = new Notification(title, { |
| 90 | kind === "completion" | 135 | body: thread.title, |
| 91 | ? "Thread completed" | 136 | tag: `${environmentId}:${thread.id}`, |
| 92 | : status === "approval" | 137 | silent: true, |
| 93 | ? "Approval needed" | 138 | }); |
| 94 | : "Input needed", | ||
| 95 | { body: thread.title, tag: `${environmentId}:${thread.id}`, silent: true }, | ||
| 96 | ); | ||
| 97 | notification.addEventListener("click", () => { | 139 | notification.addEventListener("click", () => { |
| 98 | notification.close(); | 140 | notification.close(); |
| 99 | window.focus(); | 141 | window.focus(); |
| ... | @@ -107,7 +149,15 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen | ... | @@ -107,7 +149,15 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen |
| 107 | } | 149 | } |
| 108 | } | 150 | } |
| 109 | previous.current = next; | 151 | previous.current = next; |
| 110 | }, [environmentId, mode, navigate, shell]); | 152 | }, [ |
| 153 | activeEnvironmentId, | ||
| 154 | activeThreadId, | ||
| 155 | environmentId, | ||
| 156 | inAppNotificationsEnabled, | ||
| 157 | mode, | ||
| 158 | navigate, | ||
| 159 | shell, | ||
| 160 | ]); | ||
| 111 | 161 | ||
| 112 | return null; | 162 | return null; |
| 113 | } | 163 | } |
apps/web/src/components/settings/NotificationSettings.tsx+1-1| ... | @@ -22,7 +22,7 @@ export function NotificationSettings() { | ... | @@ -22,7 +22,7 @@ export function NotificationSettings() { |
| 22 | {...searchableSetting("thread-notifications")} | 22 | {...searchableSetting("thread-notifications")} |
| 23 | description={ | 23 | description={ |
| 24 | permissionMessage ?? | 24 | permissionMessage ?? |
| 25 | "Alert when a thread finishes or needs input. Applies to this device while T3 Code is open." | 25 | "System alerts when a thread finishes, fails, or needs input or approval. Applies to this device while T3 Code is open." |
| 26 | } | 26 | } |
| 27 | control={ | 27 | control={ |
| 28 | <Select | 28 | <Select |
apps/web/src/components/settings/SettingsPanels.tsx+16| ... | @@ -525,6 +525,9 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -525,6 +525,9 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 525 | ...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode | 525 | ...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode |
| 526 | ? ["Thread notifications"] | 526 | ? ["Thread notifications"] |
| 527 | : []), | 527 | : []), |
| 528 | ...(settings.inAppNotificationsEnabled !== DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled | ||
| 529 | ? ["In-app notifications"] | ||
| 530 | : []), | ||
| 528 | ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount | 531 | ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount |
| 529 | ? ["Visible threads"] | 532 | ? ["Visible threads"] |
| 530 | : []), | 533 | : []), |
| ... | @@ -646,6 +649,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -646,6 +649,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 646 | settings.showSkillsInSlashMenu, | 649 | settings.showSkillsInSlashMenu, |
| 647 | settings.timestampFormat, | 650 | settings.timestampFormat, |
| 648 | settings.notificationMode, | 651 | settings.notificationMode, |
| 652 | settings.inAppNotificationsEnabled, | ||
| 649 | settings.wordWrap, | 653 | settings.wordWrap, |
| 650 | followSystem, | 654 | followSystem, |
| 651 | theme, | 655 | theme, |
| ... | @@ -721,6 +725,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -721,6 +725,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 721 | diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, | 725 | diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, |
| 722 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, | 726 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, |
| 723 | notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, | 727 | notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, |
| 728 | inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled, | ||
| 724 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, | 729 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, |
| 725 | diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed, | 730 | diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed, |
| 726 | diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, | 731 | diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, |
| ... | @@ -2277,6 +2282,17 @@ export function GeneralSettingsPanel() { | ... | @@ -2277,6 +2282,17 @@ export function GeneralSettingsPanel() { |
| 2277 | 2282 | ||
| 2278 | <SettingsSection id="behavior" title="Behavior"> | 2283 | <SettingsSection id="behavior" title="Behavior"> |
| 2279 | <NotificationSettings /> | 2284 | <NotificationSettings /> |
| 2285 | <SettingsRow | ||
| 2286 | {...searchableSetting("in-app-notifications")} | ||
| 2287 | description="Show a toast when another thread finishes, fails, or needs input or approval while this app has focus." | ||
| 2288 | control={ | ||
| 2289 | <Switch | ||
| 2290 | checked={settings.inAppNotificationsEnabled} | ||
| 2291 | onCheckedChange={(checked) => updateSettings({ inAppNotificationsEnabled: checked })} | ||
| 2292 | aria-label="In-app notifications" | ||
| 2293 | /> | ||
| 2294 | } | ||
| 2295 | /> | ||
| 2280 | <SettingsRow | 2296 | <SettingsRow |
| 2281 | {...searchableSetting("time-format")} | 2297 | {...searchableSetting("time-format")} |
| 2282 | description="System default follows your browser or OS clock preference." | 2298 | description="System default follows your browser or OS clock preference." |
apps/web/src/components/settings/settingsSearch.ts+6| ... | @@ -247,6 +247,12 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -247,6 +247,12 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 247 | to: "/settings/general", | 247 | to: "/settings/general", |
| 248 | searchTerms: ["notification sound alert completion input approval desktop"], | 248 | searchTerms: ["notification sound alert completion input approval desktop"], |
| 249 | }, | 249 | }, |
| 250 | { | ||
| 251 | id: "in-app-notifications", | ||
| 252 | title: "In-app notifications", | ||
| 253 | to: "/settings/general", | ||
| 254 | searchTerms: ["notification toast popup completion input approval failure"], | ||
| 255 | }, | ||
| 250 | { | 256 | { |
| 251 | id: "time-format", | 257 | id: "time-format", |
| 252 | title: "Time format", | 258 | title: "Time format", |
packages/contracts/src/settings.test.ts+23| ... | @@ -167,9 +167,32 @@ describe("ClaudeSettings auto-compaction", () => { | ... | @@ -167,9 +167,32 @@ describe("ClaudeSettings auto-compaction", () => { |
| 167 | describe("ClientSettings notifications", () => { | 167 | describe("ClientSettings notifications", () => { |
| 168 | it("requires opt-in when existing settings omit notification preferences", () => { | 168 | it("requires opt-in when existing settings omit notification preferences", () => { |
| 169 | expect(decodeClientSettings({}).notificationMode).toBe("off"); | 169 | expect(decodeClientSettings({}).notificationMode).toBe("off"); |
| 170 | expect(decodeClientSettings({}).inAppNotificationsEnabled).toBe(false); | ||
| 171 | expect(decodeClientSettingsPatch({})).not.toHaveProperty("inAppNotificationsEnabled"); | ||
| 170 | expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode"); | 172 | expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode"); |
| 171 | }); | 173 | }); |
| 172 | 174 | ||
| 175 | it.each([true, false])( | ||
| 176 | "round-trips in-app notifications set to %s", | ||
| 177 | (inAppNotificationsEnabled) => { | ||
| 178 | const settings = decodeClientSettings({ inAppNotificationsEnabled }); | ||
| 179 | expect(encodeClientSettings(settings).inAppNotificationsEnabled).toBe( | ||
| 180 | inAppNotificationsEnabled, | ||
| 181 | ); | ||
| 182 | expect( | ||
| 183 | decodeClientSettingsPatch({ inAppNotificationsEnabled }).inAppNotificationsEnabled, | ||
| 184 | ).toBe(inAppNotificationsEnabled); | ||
| 185 | }, | ||
| 186 | ); | ||
| 187 | |||
| 188 | it.each(["true", 1, null])( | ||
| 189 | "rejects an invalid in-app notification preference %s", | ||
| 190 | (inAppNotificationsEnabled) => { | ||
| 191 | expect(() => decodeClientSettings({ inAppNotificationsEnabled })).toThrow(); | ||
| 192 | expect(() => decodeClientSettingsPatch({ inAppNotificationsEnabled })).toThrow(); | ||
| 193 | }, | ||
| 194 | ); | ||
| 195 | |||
| 173 | it.each(["off", "notifications", "sound", "notifications-and-sound"])( | 196 | it.each(["off", "notifications", "sound", "notifications-and-sound"])( |
| 174 | "round-trips the %s mode", | 197 | "round-trips the %s mode", |
| 175 | (notificationMode) => { | 198 | (notificationMode) => { |
packages/contracts/src/settings.ts+2| ... | @@ -291,6 +291,7 @@ export const ClientSettingsSchema = Schema.Struct({ | ... | @@ -291,6 +291,7 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 291 | notificationMode: NotificationMode.pipe( | 291 | notificationMode: NotificationMode.pipe( |
| 292 | Schema.withDecodingDefault(Effect.succeed("off" as const)), | 292 | Schema.withDecodingDefault(Effect.succeed("off" as const)), |
| 293 | ), | 293 | ), |
| 294 | inAppNotificationsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | ||
| 294 | diffColorScheme: DiffColorScheme.pipe( | 295 | diffColorScheme: DiffColorScheme.pipe( |
| 295 | Schema.withDecodingDefault(Effect.succeed("red-green" as const)), | 296 | Schema.withDecodingDefault(Effect.succeed("red-green" as const)), |
| 296 | ), | 297 | ), |
| ... | @@ -1429,6 +1430,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; | ... | @@ -1429,6 +1430,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 1429 | 1430 | ||
| 1430 | export const ClientSettingsPatch = Schema.Struct({ | 1431 | export const ClientSettingsPatch = Schema.Struct({ |
| 1431 | notificationMode: Schema.optionalKey(NotificationMode), | 1432 | notificationMode: Schema.optionalKey(NotificationMode), |
| 1433 | inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), | ||
| 1432 | diffColorScheme: Schema.optionalKey(DiffColorScheme), | 1434 | diffColorScheme: Schema.optionalKey(DiffColorScheme), |
| 1433 | loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), | 1435 | loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), |
| 1434 | loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), | 1436 | loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), |