diff --git a/AGENTS.md b/AGENTS.md index c0e46e705..a9f6fb2de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,10 @@ app/api/ agent/new/route.ts POST { cwd, message, toolNames?, provider?, modelId? } agent/[id]/route.ts GET state | POST any RPC command agent/[id]/events/route.ts GET SSE stream - agent/running/events/route.ts GET SSE stream of currently-running session ids + agent/running/events/route.ts GET SSE stream of currently-running session ids (+ this tab's notifications) + notifications/devices/route.ts GET VAPID key + subscribed | PUT { deviceId, prefs, subscription? } + notifications/presence/route.ts POST { clientId, deviceId, visible, sessionId, seq } + notifications/test/route.ts POST { deviceId } send a test push (502 on push-service refusal) auth/** provider list, login/logout, API keys (via RPC) cwd/validate/route.ts POST validate/select a cwd default-cwd/route.ts POST create ~/omp-cwd-YYYYMMDD @@ -104,6 +107,9 @@ lib/ pi-types.ts local structural types for agent/RPC objects project-ordering.ts pure project sort/group/activity helpers (client + tests) project-registry.ts on-disk managed-project registry (~/.omp/agent/projects.json) + notification-events.ts shared: event/prefs types, frame→event detector, renderNotification() + notification-hub.ts server: presence, device store + VAPID keys, routing, Web Push + notification-client.ts browser: device id, prefs store, service worker + push subscription rpc-manager.ts session registry + startRpcSession over RpcProcess session-reader.ts session .jsonl parsing + path cache + buildSessionContext session-resume.ts running-session list for auto-resume after a restart @@ -144,6 +150,7 @@ components/ hooks/ useAgentSession.ts messages + streaming + SSE + fork/navigate/reconciliation logic useAudio.ts completion sound + browser AudioContext unlock + useNotifications.ts presence reports, toast/system delivery, notification clicks useDragDrop.ts shared drag/drop state useIsMobile.ts responsive breakpoint hook usePrefersReducedMotion.ts OS reduce-motion preference (SMIL-safe) @@ -583,6 +590,59 @@ during the wait. - `hooks/useAudio.ts` stores the toggle in `localStorage` and reuses one `AudioContext`. - Browser autoplay policy means sound must be unlocked from a user gesture; `ChatInput` calls the unlock hook from interactive controls, and `ChatWindow` plays the tone from `onAgentEnd`. +### Notifications (`lib/notification-*.ts`, `public/sw.js`, Settings → Notifications) +- Detection runs on the server in `AgentSessionWrapper.handleFrame` (and + `handleProcessExit` for crashes), so sessions no tab watches still notify. + `createNotificationDetector()` only inspects top-level frames; subagent + activity is wrapped in `subagent_*` frames and never notifies. "Completed" + fires on `session_settled` (not `prompt_result`), so background work started + by the prompt is done too; a failed or aborted last prompt suppresses it. + Auto-approved tool confirmations return before the hook and never notify. + omp sends `retry_fallback_applied` before the `auto_retry_start` of the same + failure, so the detector holds a reason-less fallback until that frame. + Failures also arrive as omp-web's own `prompt_error` (failed `response`) and + `process_exit` frames; the detector reports one failure per run. +- A session omp moved to a new file keeps its old ids as `aliases` on the + event, so a tab still showing an old id counts as viewing it. +- Presence and streams are separate on purpose. Presence: each tab reports + `{clientId, deviceId, visible, sessionId}` to `/api/notifications/presence` + (visibility/session change, input after idling, every 30 s while present; + expires 60 s after receipt). `visible` means visible AND input within 3 min. + `sessionId` is null while full-page Settings hides the chat. Streams: the + tab's open SSE connections (`attachNotificationClient`), which reconnect and + overlap independently of presence. `clientId` is per page load, `deviceId` + per browser (`localStorage`). Reports carry a per-page `seq`; the server + drops one older than the stored report (requests can arrive out of order). + Prefs PUTs run one at a time and read the prefs when sent, for the same reason. +- Routing (`routeNotification`, unit-tested): a visible tab on the session + drops the event everywhere (presence only, so a reconnecting stream cannot + leak it); else if a visible tab with an open stream exists, `whenActive: + "toast"` devices get a toast on their active tabs only; else push (subscribed + devices) or in-page `showNotification` (tabs with an open stream). +- The server decides before pushing: iOS revokes push permission for pushes + that display nothing, so `public/sw.js` always shows what it receives. +- Notifications ride the sidebar's `/api/agent/running/events?clientId=` stream, + not a new SSE connection: browsers allow six HTTP/1.1 connections per host. + `SessionSidebar` re-dispatches them as a window event. The sidebar unmounts + while Settings is open, so that tab gets no toasts then; it also stops + counting as active, so other devices are not silenced. +- Prefs and push subscriptions are per device, in `localStorage` and mirrored to + `~/.omp/agent/omp-web/notifications.json` (mode 0600; also holds the VAPID + private key). The server reads that file on every use, never caching it: an + installed server and `npm run dev` can share the agent dir. Push text is + rendered server-side in the device's locale. Browsers with saved settings (enabled or not) sync on load; never-configured browsers do not. +- A crash notifies "Run failed" while a run is live or while background work + started by it has not settled yet (`unsettled`, cleared by `session_settled`). +- The Settings test button pushes through the server for subscribed devices + (502 with the push service's answer on failure) and shows a local + notification otherwise, or a toast where none are allowed. +- `/sw.js` and `/badge-96.png` are exempt from the password gate (`proxy.ts`): + browsers re-fetch the worker for updates without the session cookie. +- Notification clicks survive an expired sign-in: `proxy.ts` sends `/?session=…` + to `/login?next=/?session=…`, and `LoginForm` returns there (only `/?…` + targets are accepted). The worker navigates non-app windows to the session + URL; the app falls back to that URL when `/api/sessions` cannot be read. + ## omp Session File Format (v3) Location: `~/.omp/agent/sessions//_.jsonl` @@ -628,7 +688,10 @@ motion: --dur-fast (150ms) --dur-med (220ms) --dur-slow (320ms) --ease-out-warm `components/ui/` holds the shared primitives (built on `@base-ui/react`): `primitives.tsx` (Dialog/Tooltip/Collapsible), `field.tsx` (form fields + -ConfirmDialog), `toast.tsx` (`toast.success/error/info`, mounted in AppShell). +ConfirmDialog), `toast.tsx` (`toast.success/error/info`, mounted in AppShell; +the `onClick` option makes the whole card the action: a click outside its +buttons, links and expandable text, ignoring drags and text selection, or Enter +on the focused card. There is no separate action button). Icons come from `lucide-react` — do not add new inline SVGs. The command palette (`components/CommandPalette.tsx`, ⌘K/Ctrl+K) is built on `cmdk`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1739611..2f918fdf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ### Added +- Add **Settings → Notifications**: desktop and mobile notifications for a finished task (after the prompt and any background work it started are done; subagents never notify), a question or approval waiting for you, a run that failed after retries and fallbacks, and a model switch omp made on its own (for example Fable falling back to Opus after a refusal, or a usage-limit fallback). Each browser chooses its own events and whether to show an in-app toast or a system notification while you use omp-web in another tab. Nothing is shown for the session you are viewing, and clicking a notification opens its session; an in-app toast opens it from a click or tap anywhere on it, or Enter when it has keyboard focus. Over HTTPS (or on `localhost`) notifications arrive through Web Push even with no omp-web tab open, including the iPhone/iPad app added to the Home Screen; the push keys and device list live in `~/.omp/agent/omp-web/notifications.json`. Notifications are off until you turn them on. This replaces the previous completion notification for the open session, and the agent's `notify` tool now also works on Android. - Choose **Classifier** or **Vendor default** in Settings → AI Model Defaults → **Auto Thinking Source** when Reasoning is `auto`. The choice is saved in omp's native config for newly started or restarted agents. Vendor mode prefers the publisher's effort, then omp's per-model default, then normal Auto fallback. Requires an omp release containing [can1357/oh-my-pi#14114](https://github.com/can1357/oh-my-pi/pull/14114). - Show conflicting skill names and deduplicated installations when an OMP session starts, with resolution details, a **×** button that dismisses the notice for the session until the diagnostic report changes, and a persisted **Turn off** action that disables startup notices. Inspect a running session manually in **Settings → Extensions & Tools → Skills**, even when notices are off; inspection never resumes a stopped session. Requires an OMP runtime with the skill-diagnostics RPC commands; older runtimes report live diagnostics as unavailable. - Ask a side question with `/btw `: omp answers from the current conversation in a panel above the composer, even while the agent is working, and the question never enters the chat. Cancel a running answer, copy it, or ask follow-ups in the same topic. `/btw` on its own lists the session's past side questions, including ones asked in the terminal, each expandable to all its turns with **Copy** and **Follow up**. Side questions are text-only: with an attachment in the composer, `/btw` is refused and the draft is kept. An answer that omp lost (for example after its process restarted) shows as **Interrupted** instead of spinning. Requires an omp with the `btw` RPC commands; older omp shows "/btw requires a newer omp" instead of sending the text to the model. @@ -32,6 +33,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ### Fixes & Improvements +- The completion notification omp-web used to show for the open session when its tab was in the background is gone, along with its permission prompt on the first finished run. Turn on **Settings → Notifications** to get notifications again; they now cover every session, not only the open one. - Balance the composer icons on phones and narrow toolbars with a slightly smaller effort-level icon and a larger Slow-mode snail, while keeping menu icon sizes unchanged. - Keep the model picker on the left and reasoning effort, Fast, Slow, microphone, and Send/Stop controls in a right-aligned composer group, including wrapped mobile rows. - The Provider Usage panel now shows each account's email instead of omp's redacted form (`an*`); accounts without an email show as **Account N**. Only the email, plan and limits reach the browser. diff --git a/README.md b/README.md index 9c0757a79..78b928ba2 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,31 @@ available for a selected running session when notices are off. Inspection does not resume stopped sessions. Missing support or no running session is unavailable, not a clean result. +## Notifications + +Turn on notifications in **Settings → Notifications**. Each browser keeps its own +settings: which events notify (task finished, waiting for input, run failed, +model switched automatically) and what happens while you use omp-web in another +tab (an in-app toast, or always a system notification). Nothing is shown for the +session you are viewing. Clicking a notification opens its session. + +- **Push** reaches the browser even when no omp-web tab is open. It needs a + secure context (HTTPS through your reverse proxy, or `localhost`), and the + omp-web server needs outbound HTTPS access to the browser vendors' push + services. omp-web generates its push (VAPID) keys on first use and stores them + with the device list in `~/.omp/agent/omp-web/notifications.json` (mode 0600). +- On **iPhone and iPad** (iOS 16.4 or later), push works only in the app added + to the Home Screen. +- In a secure context without push support, system notifications appear while + an omp-web tab is open. Over plain HTTP on a non-localhost address, browsers + allow no system notifications: only the in-app toasts shown while you use + omp-web remain. +- Desktop browsers show notifications through the operating system's + notification center. Push delivery needs the browser process running. + +Completion notifications use omp's `prompt_result` and `session_settled` RPC +frames (omp 18.3.1 or later). + ## Quick Start **Run directly without installing:** @@ -241,7 +266,8 @@ host (KDE Plasma, and most Wayland/X11 desktops). - **Windows System Tray & Service**: Background service, tray icon, logon autostart, and Desktop/Start Menu shortcuts (Windows). - **macOS launchd Service**: LaunchAgent that starts at login, restarts on crash, and logs under `~/Library/Logs/ompweb`. - **Linux systemd Service & Tray**: User service that starts at login and restarts on crash, plus a StatusNotifierItem tray icon with service controls (KDE Plasma and compatible desktops). -- **Web-based Settings** (8 tabs): Interface & Behavior, Safety & Approvals, AI Model Defaults, API Keys & Providers, Usage, Agent & Intelligence (advisor, memory, compaction), Agents, Extensions & Tools (MCP, skills, plugins), System & Updates. +- **Notifications**: Desktop and mobile (PWA) notifications for finished tasks, questions waiting for an answer, failed runs, and automatic model switches, with Web Push when served over HTTPS. See [Notifications](#notifications). +- **Web-based Settings** (10 tabs): Interface & Behavior, Safety & Approvals, AI Model Defaults, API Keys & Providers, Usage, Agent & Intelligence (advisor, memory, compaction), Agents, Extensions & Tools (MCP, skills, plugins), Notifications, System & Updates. - **Slash Commands & Shortcuts**: Quick prompts (`/plan`, `/review`, `/fix`, `/test`, etc.), `⌘K` / `Ctrl+K` palette, and model/reasoning cycling. - **UI Themes & Localization**: Warm paper light/dark themes plus an omp.sh-inspired midnight (`omp`) theme, chat font size & interface scale, with full English, Chinese (简体中文), and Japanese (日本語) translations. diff --git a/app/api/agent/running/events/route.ts b/app/api/agent/running/events/route.ts index dcd0086ed..9216e4d7c 100644 --- a/app/api/agent/running/events/route.ts +++ b/app/api/agent/running/events/route.ts @@ -1,12 +1,17 @@ import { getExitedRpcSessions, getRunningRpcSessions, subscribeRunningSessions } from "@/lib/rpc-manager"; +import { isValidNotificationId } from "@/lib/notification-events"; +import { attachNotificationClient } from "@/lib/notification-hub"; import { subscribeSessionFileChanges } from "@/lib/session-watcher"; export const dynamic = "force-dynamic"; // GET /api/agent/running/events - SSE stream of the set of currently-running // session ids. Also carries refresh hints when a live session's file metadata -// changes, so the sidebar can show a newly-started session immediately. +// changes, so the sidebar can show a newly-started session immediately, and +// this tab's notifications when `clientId` is given (one stream per tab: +// browsers allow only six HTTP/1.1 connections per host). export async function GET(req: Request) { + const clientId = new URL(req.url).searchParams.get("clientId"); // Hoisted so the stream's cancel() (half-open disconnects that never fire // the abort signal) can release the heartbeat and the subscriber. let streamCleanup: (() => void) | null = null; @@ -18,6 +23,7 @@ export async function GET(req: Request) { let cleaned = false; let unsubscribeRunning: (() => void) | null = null; let unsubscribeFiles: (() => void) | null = null; + let detachNotifications: (() => void) | null = null; let heartbeatTimer: ReturnType | null = null; const cleanup = () => { @@ -36,6 +42,8 @@ export async function GET(req: Request) { try { unsubscribeFiles(); } catch {} unsubscribeFiles = null; } + detachNotifications?.(); + detachNotifications = null; req.signal?.removeEventListener("abort", cleanup); try { controller.close(); @@ -77,6 +85,10 @@ export async function GET(req: Request) { encode({ type: "sessions-changed", sessionIds, refreshSessionList: true }); }); + if (isValidNotificationId(clientId)) { + detachNotifications = attachNotificationClient(clientId, (message) => encode({ type: "notification", ...message })); + } + // Initial snapshot so the client renders the correct state immediately. const initialRunning = getRunningRpcSessions(); encode({ diff --git a/app/api/notifications/devices/route.ts b/app/api/notifications/devices/route.ts new file mode 100644 index 000000000..ff4410c1f --- /dev/null +++ b/app/api/notifications/devices/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { isValidNotificationId, parseNotificationPrefs } from "@/lib/notification-events"; +import { getNotificationDevice, getVapidPublicKey, parsePushSubscription, saveNotificationDevice } from "@/lib/notification-hub"; +import { isRecord } from "@/lib/type-guards"; + +export const dynamic = "force-dynamic"; + +/** GET /api/notifications/devices?deviceId= — VAPID public key and whether this device has a push subscription. */ +export async function GET(req: Request) { + const deviceId = new URL(req.url).searchParams.get("deviceId"); + const device = isValidNotificationId(deviceId) ? getNotificationDevice(deviceId) : undefined; + return NextResponse.json({ publicKey: getVapidPublicKey(), subscribed: Boolean(device?.subscription) }); +} + +/** PUT /api/notifications/devices { deviceId, prefs, subscription? } — an omitted subscription keeps the stored one. */ +export async function PUT(req: Request) { + const body: unknown = await req.json().catch(() => null); + if (!isRecord(body) || !isValidNotificationId(body.deviceId)) { + return NextResponse.json({ error: "deviceId is required", code: "invalid_device" }, { status: 400 }); + } + const subscription = body.subscription === undefined ? undefined : parsePushSubscription(body.subscription); + if (subscription === null) return NextResponse.json({ error: "Invalid push subscription", code: "invalid_subscription" }, { status: 400 }); + const device = saveNotificationDevice(body.deviceId, parseNotificationPrefs(body.prefs), subscription); + return NextResponse.json({ subscribed: Boolean(device.subscription) }); +} diff --git a/app/api/notifications/presence/route.ts b/app/api/notifications/presence/route.ts new file mode 100644 index 000000000..cc3175195 --- /dev/null +++ b/app/api/notifications/presence/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { isValidNotificationId } from "@/lib/notification-events"; +import { reportPresence } from "@/lib/notification-hub"; +import { isRecord } from "@/lib/type-guards"; + +export const dynamic = "force-dynamic"; + +/** POST /api/notifications/presence { clientId, deviceId, visible, sessionId, seq } — which session this tab shows. */ +export async function POST(req: Request) { + const body: unknown = await req.json().catch(() => null); + if ( + !isRecord(body) || !isValidNotificationId(body.clientId) || !isValidNotificationId(body.deviceId) + || typeof body.visible !== "boolean" || !Number.isSafeInteger(body.seq) + ) { + return NextResponse.json({ error: "Invalid presence report", code: "invalid_presence" }, { status: 400 }); + } + const sessionId = typeof body.sessionId === "string" && body.sessionId ? body.sessionId : null; + reportPresence({ clientId: body.clientId, deviceId: body.deviceId, visible: body.visible, sessionId, seq: Number(body.seq) }); + return new NextResponse(null, { status: 204 }); +} diff --git a/app/api/notifications/test/route.ts b/app/api/notifications/test/route.ts new file mode 100644 index 000000000..eafce070e --- /dev/null +++ b/app/api/notifications/test/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { isValidNotificationId } from "@/lib/notification-events"; +import { sendTestPush } from "@/lib/notification-hub"; +import { isRecord } from "@/lib/type-guards"; + +export const dynamic = "force-dynamic"; + +/** POST /api/notifications/test { deviceId } — one push to this device; 502 when the push service refuses it. */ +export async function POST(req: Request) { + const body: unknown = await req.json().catch(() => null); + if (!isRecord(body) || !isValidNotificationId(body.deviceId)) { + return NextResponse.json({ error: "deviceId is required", code: "invalid_device" }, { status: 400 }); + } + const result = await sendTestPush(body.deviceId); + return result.ok ? new NextResponse(null, { status: 204 }) : NextResponse.json({ error: result.error, code: "push_failed" }, { status: 502 }); +} diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 29bddd673..e4051792b 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -33,7 +33,7 @@ import { buildAtMentionText, buildFileAtMentionsText, buildFileLineMentionText } import { getInitialNavigation } from "@/lib/initial-navigation"; import { comparableProjectPath } from "@/lib/comparable-path"; import { clearDraft } from "@/lib/draft-store"; -import { showCompletionNotification } from "@/lib/browser-notifications"; +import { useNotifications } from "@/hooks/useNotifications"; import { APP_UPDATE_COMPLETED_RELOAD_MS, APP_UPDATE_POLL_MS, @@ -1330,28 +1330,34 @@ export function AppShell({ appName }: { appName: string }) { const handleAgentEnd = useCallback(() => { setRefreshKey((k) => k + 1); setExplorerRefreshKey((k) => k + 1); - if (document.visibilityState !== "hidden" || !("Notification" in window)) return; - - const targetSession = selectedSession; - const notify = () => { - showCompletionNotification( - targetSession?.name ?? translate("appShell.sessionComplete"), - translate("appShell.taskFinished"), - () => { - window.focus(); - if (targetSession) handleSelectSession(targetSession); - }, - ); - }; - if (Notification.permission === "granted") notify(); - else if (Notification.permission === "default") { - void Notification.requestPermission().then((permission) => { if (permission === "granted") notify(); }); - } else { - // "denied": the OS blocks notifications, so surface the completion as an - // in-app toast instead of leaving background completions silent. - toast.info(targetSession?.name ?? translate("appShell.sessionComplete"), translate("appShell.taskFinished")); - } - }, [handleSelectSession, selectedSession]); + }, []); + + // Notification clicks name a session id; the session list carries its cwd + // and project, which selecting needs. When the list cannot be read (the + // sign-in expired), load the session URL: sign-in carries it through. + const openSessionById = useCallback((sessionId: string) => { + window.focus(); + // On narrow screens the file panel covers the chat it is about to show. + if (isCompactOverlay) setRightPanelOpen(false); + // A full load (not router navigation) so an expired sign-in reaches proxy.ts and the login page. + const sessionUrl = new URL(`/?session=${encodeURIComponent(sessionId)}`, window.location.origin).href; + void fetch("/api/sessions") + .then((r) => { + if (!r.ok) { + window.location.assign(sessionUrl); + return; + } + return (r.json() as Promise<{ sessions: SessionInfo[] }>).then((d) => { + const session = d.sessions.find((s) => s.id === sessionId); + if (session) handleSelectSession(session); + else toast.error(translate("notifications.sessionNotFound")); + }); + }) + .catch(() => window.location.assign(sessionUrl)); + }, [handleSelectSession, isCompactOverlay]); + + // Full-page Settings hides the chat, so its session counts as not viewed. + useNotifications({ sessionId: settingsTab ? null : selectedSession?.id ?? null, locale, onOpenSession: openSessionById }); const handleAutoName = useCallback(async () => { const sessionId = selectedSession?.id; diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx index 747244f16..16f74a9fd 100644 --- a/components/LoginForm.tsx +++ b/components/LoginForm.tsx @@ -26,7 +26,9 @@ export function LoginForm() { } // Full reload so the new auth cookie is picked up by middleware // and server components — SPA navigation alone may keep stale state. - window.location.replace("/"); + // `next` (set by proxy.ts) may only point back into the app root. + const next = new URLSearchParams(window.location.search).get("next"); + window.location.replace(next?.startsWith("/?") ? next : "/"); } catch { setError(t("login.connectionFailed")); } finally { diff --git a/components/SessionSidebar.tsx b/components/SessionSidebar.tsx index c9c01ed86..1964b7aae 100644 --- a/components/SessionSidebar.tsx +++ b/components/SessionSidebar.tsx @@ -14,6 +14,7 @@ import { groupSessionsByProject, projectActivityCounts, sortManagedProjects } fr import { comparableProjectPath } from "@/lib/comparable-path"; import { Archive, ArrowLeft, ArrowRight, Check, ChevronRight, FileUp, Plus, RefreshCw, Search, Settings2, SlidersHorizontal, X } from "lucide-react"; import { publishSessionsChanged } from "@/lib/session-change-bus"; +import { NOTIFICATION_MESSAGE_EVENT, notificationClientId } from "@/lib/notification-client"; import { EMPTY_PROJECT_SET, INITIAL_RESTORE_MAX_ATTEMPTS, @@ -276,8 +277,9 @@ export const SessionSidebar = memo(function SessionSidebar({ selectedSessionId, useEffect(() => { // Live running status and session-list invalidations arrive via SSE; the - // sidebar never has to poll while an agent is working. - const source = new EventSource("/api/agent/running/events"); + // sidebar never has to poll while an agent is working. The same stream + // delivers this tab's notifications (handled by useNotifications). + const source = new EventSource(`/api/agent/running/events?clientId=${encodeURIComponent(notificationClientId)}`); source.onmessage = (e) => { try { @@ -307,6 +309,8 @@ export const SessionSidebar = memo(function SessionSidebar({ selectedSessionId, } else if (data.type === "sessions-changed") { if (data.refreshSessionList) scheduleRefresh(); publishSessionsChanged(data.sessionIds ?? []); + } else if (data.type === "notification") { + window.dispatchEvent(new CustomEvent(NOTIFICATION_MESSAGE_EVENT, { detail: data })); } } catch { // ignore malformed frames diff --git a/components/SettingsConfig.tsx b/components/SettingsConfig.tsx index 346d90ca2..a1a1b5ef5 100644 --- a/components/SettingsConfig.tsx +++ b/components/SettingsConfig.tsx @@ -18,6 +18,17 @@ import { useFontSize, type FontSizePreference } from "@/hooks/useFontSize"; import { useUiScale, type UiScalePreference } from "@/hooks/useUiScale"; import { useTouchTargets, type TouchTargetsPreference } from "@/hooks/useTouchTargets"; import { useSpeechSynthesis } from "@/hooks/useSpeechSynthesis"; +import { useNotificationPrefs } from "@/hooks/useNotifications"; +import { NOTIFICATION_TYPES, renderNotification, type NotificationType } from "@/lib/notification-events"; +import { + ensurePushSubscription, + getNotificationDeviceId, + getNotificationPrefs, + getNotificationSupport, + showSystemNotification, + updateNotificationPrefs, + type NotificationSupport, +} from "@/lib/notification-client"; const SettingsTabLoading = () => { const { t } = useI18n(); return
{t("settingsConfig.loadingSettings")}
; @@ -200,6 +211,13 @@ const SETTING_INDEX: SettingIndexEntry[] = [ { id: "agent-env", tab: "system", sectionKey: "settingsConfig.systemUpdates", labelKey: "settingsConfig.agentEnv", descKey: "settingsConfig.agentEnvDesc", fallbackSection: "System & Updates", fallbackLabel: "Agent environment variables", fallbackDesc: "Extra environment variables passed to the omp process, one KEY=VALUE per line. Applied to sessions started after saving.", scope: "UI" }, { id: "windows-service-autostart", tab: "system", sectionKey: "settingsConfig.windowsServiceTitle", labelKey: "settingsConfig.windowsServiceAutostart", descKey: "settingsConfig.windowsServiceAutostartDesc", fallbackSection: "Windows Background Service & System Tray", fallbackLabel: "Start with Windows", fallbackDesc: "Launch background service quietly in system tray when logging into Windows.", scope: "UI" }, { id: "windows-service-shortcuts", tab: "system", sectionKey: "settingsConfig.windowsServiceTitle", labelKey: "settingsConfig.windowsServiceInstallBtn", descKey: "settingsConfig.windowsServiceDesc", fallbackSection: "Windows Background Service & System Tray", fallbackLabel: "Install Service & Shortcuts", fallbackDesc: "Manage background service execution, system tray monitor, Windows logon autostart, and Desktop shortcuts.", scope: "UI" }, + // Notifications + { id: "notifications-enable", tab: "notifications", sectionKey: "notifications.title", labelKey: "notifications.enable", descKey: "notifications.enableDesc", fallbackSection: "Notifications", fallbackLabel: "Enable notifications", fallbackDesc: "Asks the browser for permission to show notifications.", scope: "UI" }, + { id: "notifications-completed", tab: "notifications", sectionKey: "notifications.title", labelKey: "notifications.typeCompleted", descKey: "notifications.typeCompletedDesc", fallbackSection: "Notifications", fallbackLabel: "Task finished", fallbackDesc: "A prompt and the background work it started are done. Subagents never notify.", scope: "UI" }, + { id: "notifications-input", tab: "notifications", sectionKey: "notifications.title", labelKey: "notifications.typeInput", descKey: "notifications.typeInputDesc", fallbackSection: "Notifications", fallbackLabel: "Waiting for input", fallbackDesc: "The agent asks a question or needs an approval.", scope: "UI" }, + { id: "notifications-error", tab: "notifications", sectionKey: "notifications.title", labelKey: "notifications.typeError", descKey: "notifications.typeErrorDesc", fallbackSection: "Notifications", fallbackLabel: "Run failed", fallbackDesc: "A run stops with an error after retries and fallbacks, or the omp process exits.", scope: "UI" }, + { id: "notifications-modelSwitch", tab: "notifications", sectionKey: "notifications.title", labelKey: "notifications.typeModelSwitch", descKey: "notifications.typeModelSwitchDesc", fallbackSection: "Notifications", fallbackLabel: "Model switched automatically", fallbackDesc: "omp switches models on its own: a fallback after a refusal, rate limit, or usage limit, or a prewalk / plan hand-off.", scope: "UI" }, + { id: "notifications-when-active", tab: "notifications", sectionKey: "notifications.title", labelKey: "notifications.whenActive", descKey: "notifications.whenActiveDesc", fallbackSection: "Notifications", fallbackLabel: "When I'm using omp-web in another tab", fallbackDesc: "Nothing is shown for the session you are viewing.", scope: "UI" }, ]; function SearchResultsList({ results, query, onSelect }: { results: SearchResult[]; query: string; onSelect: (result: SearchResult) => void }) { @@ -488,7 +506,146 @@ function AgentEnvSetting() { ); } -function NativeSetting({ label, description, scope, searchId, children }: { label: string; description: string; scope?: "UI" | "Native OMP" | "Workspace"; searchId?: string; children: ReactNode }) { +const NOTIFICATION_TYPE_LABELS: Record = { + completed: { label: "notifications.typeCompleted", desc: "notifications.typeCompletedDesc" }, + input: { label: "notifications.typeInput", desc: "notifications.typeInputDesc" }, + error: { label: "notifications.typeError", desc: "notifications.typeErrorDesc" }, + modelSwitch: { label: "notifications.typeModelSwitch", desc: "notifications.typeModelSwitchDesc" }, +}; + +const NOTIFICATION_STATUS_KEYS: Record, string> = { + insecure: "notifications.statusInsecure", + "ios-install": "notifications.statusIosInstall", + unsupported: "notifications.statusUnsupported", + denied: "notifications.statusDenied", +}; + +/** Per-device notification prefs (lib/notification-client.ts), mirrored to the server. */ +function NotificationSettingsPanel() { + const { t, locale } = useI18n(); + const prefs = useNotificationPrefs(); + const [support, setSupport] = useState(null); + const [pushActive, setPushActive] = useState(false); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let alive = true; + setSupport(getNotificationSupport()); + // Enabled: join (or redo) the app's startup repair, which is single-flight, + // so the status reflects the subscription after any repair, not before it. + const pushState: Promise = getNotificationPrefs().enabled + ? ensurePushSubscription() + : fetch(`/api/notifications/devices?deviceId=${encodeURIComponent(getNotificationDeviceId())}`) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { subscribed?: boolean } | null) => data?.subscribed === true); + pushState.then((active) => { if (alive) setPushActive(active); }).catch(() => {}); + return () => { alive = false; }; + }, []); + + const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)); + + const save = (patch: Parameters[0]) => { + void updateNotificationPrefs(patch).catch((error: unknown) => toast.error(t("notifications.saveFailed"), errorText(error))); + }; + + const setEnabled = async (enabled: boolean) => { + setBusy(true); + try { + // First await inside the click: Safari only prompts during the user gesture. + if (enabled && "Notification" in window && Notification.permission === "default") await Notification.requestPermission(); + await updateNotificationPrefs({ enabled, locale }); + if (enabled) setPushActive(await ensurePushSubscription()); + } catch (error) { + toast.error(t("notifications.enableFailed", { error: errorText(error) })); + } finally { + setSupport(getNotificationSupport()); + setBusy(false); + } + }; + + // With push, the server sends a real push; otherwise this tab shows one itself. + const sendTest = async () => { + const testNotification = renderNotification({ type: "test", sessionId: "", sessionName: "omp web" }, t); + try { + if (pushActive) { + const res = await fetch("/api/notifications/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceId: getNotificationDeviceId() }), + }); + if (!res.ok) { + const body: unknown = await res.json().catch(() => null); + // The server drops a subscription the push service reports gone; + // resubscribe so the next test (and real pushes) can reach this device. + setPushActive(await ensurePushSubscription().catch(() => false)); + throw new Error(isRecord(body) && typeof body.error === "string" ? body.error : `HTTP ${res.status}`); + } + } else if (!(await showSystemNotification(testNotification))) { + // No system notifications here (plain HTTP, permission missing): in-app toasts are what this browser gets. + toast.info(testNotification.title, testNotification.body); + return; + } + toast.success(t("notifications.testSent")); + } catch (error) { + toast.error(t("notifications.testFailed"), errorText(error)); + } + }; + + let status: string | null = null; + if (support && support !== "push" && support !== "no-push") status = t(NOTIFICATION_STATUS_KEYS[support]); + else if (support && prefs.enabled) { + if (Notification.permission !== "granted") status = t("notifications.statusPermission"); + else status = t(pushActive ? "notifications.statusPush" : "notifications.statusInPage"); + } + + return ( + <> + + void setEnabled(next)} /> + + {status && } + {NOTIFICATION_TYPES.map((type) => ( + + save({ types: { ...prefs.types, [type]: next } })} /> + + ))} + + + +
+ +
+ + ); +} + +/** `disabled` only dims the row; the child control must be disabled itself. */ +function NativeSetting({ label, description, scope, searchId, disabled, children }: { label: string; description: string; scope?: "UI" | "Native OMP" | "Workspace"; searchId?: string; disabled?: boolean; children: ReactNode }) { const { t } = useI18n(); const ref = useRef(null); const highlightId = useContext(SettingsHighlightContext); @@ -532,13 +689,14 @@ function NativeSetting({ label, description, scope, searchId, children }: { labe width: "100%", boxSizing: "border-box", marginBottom: 10, - transition: "box-shadow var(--dur-fast), border-color var(--dur-fast)", + opacity: disabled ? 0.5 : 1, + transition: "box-shadow var(--dur-fast), border-color var(--dur-fast), opacity var(--dur-fast)", ...(highlighted ? { borderColor: "var(--accent)", boxShadow: "0 0 0 2px var(--accent)" } : {}), }} >
- + {scope && ( {formatScope(scope)} @@ -1566,6 +1724,19 @@ export function SettingsConfig({ activeTab, toolCallsDefaultCollapsed, onToolCal
)} + {/* NOTIFICATIONS TAB */} + {currentTab === "notifications" && ( +
+
+

{t("notifications.title")}

+

{t("notifications.subtitle")}

+
+
+ +
+
+ )} + {/* SYSTEM & UPDATES TAB */} {currentTab === "system" && (
diff --git a/components/SettingsTabs.tsx b/components/SettingsTabs.tsx index 9ae95d7d9..7386bbe4f 100644 --- a/components/SettingsTabs.tsx +++ b/components/SettingsTabs.tsx @@ -1,7 +1,7 @@ "use client"; import { useI18n } from "@/lib/i18n"; -import { BarChart3, Bot, Cable, Cpu, KeyRound, RefreshCw, Settings2, ShieldCheck, Sparkles } from "lucide-react"; +import { BarChart3, Bell, Bot, Cable, Cpu, KeyRound, RefreshCw, Settings2, ShieldCheck, Sparkles } from "lucide-react"; import type { ComponentType, CSSProperties } from "react"; export type SettingsTab = @@ -16,6 +16,7 @@ export type SettingsTab = | "mcp" | "skills" | "plugins" + | "notifications" | "system"; export interface TabItem { @@ -35,6 +36,7 @@ export const SETTINGS_CATEGORIES: TabItem[] = [ { id: "intelligence", label: "Agent & Intelligence", description: "Advisor, memory, autolearn, compaction and retry", Icon: Sparkles }, { id: "agents", label: "Agents", description: "Task agents, model settings, and tool policy", Icon: Bot }, { id: "mcp", label: "Extensions & Tools", description: "MCP servers, managed skills, and OMP plugins", Icon: Cable }, + { id: "notifications", label: "Notifications", description: "Desktop and mobile notifications for your sessions", Icon: Bell }, { id: "system", label: "System & Updates", description: "App updates, runtime version, and active session restart", Icon: RefreshCw }, ]; diff --git a/components/ui/toast.click.test.mjs b/components/ui/toast.click.test.mjs new file mode 100644 index 000000000..e15230de0 --- /dev/null +++ b/components/ui/toast.click.test.mjs @@ -0,0 +1,105 @@ +import "../../tests/setup-dom.mjs"; +import assert from "node:assert/strict"; +import test, { afterEach, before } from "node:test"; +import React from "react"; +import { act, cleanup, render, renderHook } from "@testing-library/react/pure.js"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tryNative: false, tsconfigPaths: true }); +// Same specifiers as useNotifications' own imports: on Windows a relative path +// can load a second toast module, whose manager the rendered provider never sees. +const { ClampedDescription, ToastProvider, toast } = await jiti.import("@/components/ui/toast"); +const { useNotifications } = await jiti.import("@/hooks/useNotifications"); +const { NOTIFICATION_MESSAGE_EVENT } = await jiti.import("@/lib/notification-client"); + +before(() => { + globalThis.AbortController = window.AbortController; + window.Element.prototype.setPointerCapture = () => {}; + globalThis.fetch = async () => ({ ok: true, json: async () => ({}) }); +}); +afterEach(() => { act(() => toast.close()); cleanup(); window.getSelection()?.removeAllRanges(); }); + +function pointer(target, type, x, y, { detail = 1 } = {}) { + const event = new window.MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0, detail }); + Object.defineProperties(event, { pointerId: { value: 1 }, pointerType: { value: "mouse" } }); + act(() => { target.dispatchEvent(event); }); +} +function show(description, options = {}) { + const opened = []; + render(React.createElement(ToastProvider, null)); + act(() => { toast.info("Agent finished", description, { onClick: () => opened.push(1), ...options }); }); + return { opened, card: () => document.querySelector(".toast-card") }; +} + +test("clicking anywhere on a toast card runs its action and closes it", () => { + const { opened, card } = show("Body"); + assert.equal(card().style.cursor, "pointer"); + const title = card().querySelector(".display-serif"); + pointer(title, "pointerdown", 100, 50); + pointer(title, "click", 102, 51); + assert.equal(opened.length, 1); + assert.equal(card()?.hasAttribute("data-ending-style") ?? true, true); +}); + +test("Enter on the focused card runs its action; Enter inside it does not", () => { + const { opened, card } = show(React.createElement("button", { type: "button" }, "inner")); + const press = (target) => act(() => { target.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); }); + press(card().querySelector("button:not(.toast-close-button)")); + assert.equal(opened.length, 0); + press(card()); + assert.equal(opened.length, 1); +}); + +test("buttons, links, expandable text, drags and text selection do not trigger the card action", () => { + let pressed = 0; + const { opened, card } = show(React.createElement("span", null, + React.createElement("button", { type: "button", onClick: () => { pressed += 1; } }, "Open"), + React.createElement("a", { href: "#x" }, "link"), + React.createElement(ClampedDescription, null, "long text"))); + act(() => card().querySelector("button:not(.toast-close-button)").click()); + assert.equal(pressed, 1); + act(() => card().querySelector("a").click()); + // A press on the clamped text whose click is retargeted to the card, as pointer capture does. + pointer(card().querySelector("[aria-expanded]"), "pointerdown", 100, 50); + pointer(card(), "click", 100, 50); + // A drag that fell short of a swipe. + const title = card().querySelector(".display-serif"); + pointer(title, "pointerdown", 100, 50); + pointer(title, "click", 130, 50); + // A drag pulled back to where it started. + pointer(title, "pointerdown", 100, 50); + pointer(title, "pointermove", 160, 50); + pointer(title, "click", 101, 50); + window.getSelection().selectAllChildren(title); + act(() => title.click()); + assert.equal(opened.length, 0); +}); + +test("a toast without an action keeps the default cursor", () => { + render(React.createElement(ToastProvider, null)); + act(() => { toast.info("Saved"); }); + assert.equal(document.querySelector(".toast-card").style.cursor, ""); +}); + +test("a session notification toast opens its session from a card click; one without a session does not", () => { + const sessions = []; + render(React.createElement(ToastProvider, null)); + renderHook(() => useNotifications({ sessionId: null, locale: "en", onOpenSession: (id) => sessions.push(id) })); + // Toasts closed by earlier tests can still be leaving the shared manager, so + // each card is found by its own title (the session name). + const deliver = (sessionId, sessionName) => { + act(() => { + window.dispatchEvent(new window.CustomEvent(NOTIFICATION_MESSAGE_EVENT, { detail: { kind: "toast", event: { type: "completed", sessionId, sessionName } } })); + }); + return Array.from(document.querySelectorAll(".toast-card")).find((card) => card.querySelector(".display-serif")?.textContent === sessionName); + }; + const withSession = deliver("s1", "Fix bug"); + // The card is the only way in: no separate Open link. + assert.equal(withSession.querySelectorAll("button:not(.toast-close-button), a").length, 0); + act(() => withSession.querySelector(".display-serif").click()); + assert.deepEqual(sessions, ["s1"]); + const withoutSession = deliver("", "No session"); + assert.equal(withoutSession.style.cursor, ""); + act(() => withoutSession.querySelector(".display-serif").click()); + assert.deepEqual(sessions, ["s1"]); +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index d2dc59a48..1de94e7ac 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -12,7 +12,7 @@ */ import { Toast } from "@base-ui/react/toast"; import { AlertCircle, Check, Info, X } from "lucide-react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useIsMobile } from "@/hooks/useIsMobile"; import type React from "react"; @@ -22,6 +22,7 @@ interface ToastData { kind?: ToastKind; /** Clamp the description to 2 lines; click the description to expand it. */ clamp?: boolean; + onClick?: () => void; } interface ToastOptions { @@ -35,6 +36,11 @@ interface ToastOptions { id?: string; /** Fired when the toast closes (dismissed by the user, `toast.close`, or timeout). */ onClose?: () => void; + /** + * Runs when the card itself is clicked (anywhere but its buttons, links and + * expandable text), then closes the toast. + */ + onClick?: () => void; } const manager = Toast.createToastManager(); @@ -48,7 +54,7 @@ function add(kind: ToastKind, title: React.ReactNode, description?: React.ReactN title, description, type: kind, - data: { kind, clamp: options?.clamp }, + data: { kind, clamp: options?.clamp, onClick: options?.onClick }, ...(timeout !== undefined ? { timeout } : {}), ...(options?.onClose ? { onClose: options.onClose } : {}), }); @@ -117,6 +123,7 @@ export function ClampedDescription({ children }: { children: React.ReactNode }) function Toaster() { const { toasts } = Toast.useToastManager(); const isMobile = useIsMobile(); + const press = useRef<{ x: number; y: number; target: EventTarget | null; travel: number } | null>(null); // Clear the app chrome (topbar 36/44px + tab bar 36px) with a safe gap so // toasts never cover the header, tabs, or chat content. const topOffset = isMobile ? 88 : 80; @@ -140,8 +147,41 @@ function Toaster() { key={t.id} toast={t} className="toast-card" + onPointerDown={(event) => { + press.current = { x: event.clientX, y: event.clientY, target: event.target, travel: 0 }; + }} + onPointerMove={(event) => { + const from = press.current; + if (from) from.travel = Math.max(from.travel, Math.hypot(event.clientX - from.x, event.clientY - from.y)); + }} + onClick={t.data?.onClick ? (event) => { + const onClick = t.data?.onClick; + const from = press.current; + press.current = null; + // base-ui captures the pointer for its swipe, which retargets the + // click to the card; judge the element that was pressed instead. + const pointerClick = from && event.detail !== 0; + const target = pointerClick ? from.target : event.target; + if (!onClick || (target instanceof Element && target.closest("button, a, input, textarea, select, [aria-expanded]"))) return; + // A drag that fell short of a swipe, even one brought back to where + // it started, is not a click (detail 0: keyboard). + if (pointerClick && Math.max(from.travel, Math.hypot(event.clientX - from.x, event.clientY - from.y)) > 10) return; + // Releasing a text selection is not a request to open anything. + if (window.getSelection()?.isCollapsed === false) return; + manager.close(t.id); + onClick(); + } : undefined} + // The card is focusable (base-ui gives it tabIndex 0): Enter on it + // runs the action, which has no separate button. + onKeyDown={t.data?.onClick ? (event) => { + if (event.key !== "Enter" || event.target !== event.currentTarget) return; + event.preventDefault(); + manager.close(t.id); + t.data?.onClick?.(); + } : undefined} style={{ pointerEvents: "auto", + cursor: t.data?.onClick ? "pointer" : undefined, display: "flex", alignItems: "flex-start", gap: 8, diff --git a/docs/specs/2026-10-06-notifications-design.md b/docs/specs/2026-10-06-notifications-design.md new file mode 100644 index 000000000..116d9d60b --- /dev/null +++ b/docs/specs/2026-10-06-notifications-design.md @@ -0,0 +1,230 @@ +# Notifications: Web Push, in-page fallback, and settings + +Status: approved design, 2026-10-06; updated to match the implementation. + +## Goal + +omp-web notifies the user about session events on desktop browsers and on the +installed PWA (Android, and iOS 16.4+ when added to the Home Screen). A +Notifications tab in Settings lets each device choose which events notify and +how. Tapping a notification opens the session that produced it. No +notification is shown for a session the user is currently viewing. + +## Notification types + +| Type | Trigger frame (rpc-ui) | Body | +|---|---|---| +| `completed` | `session_settled`, unless the last `prompt_result` of that stretch had `status` `error` or `aborted` | "Task finished." | +| `input` | `extension_ui_request` with `method` `select`, `confirm`, `input`, `editor`, `ask` or `open_url` that omp-web does not auto-answer | "Waiting for your answer: " | +| `error` | `prompt_result` with `agentInvoked: true` and `status: "error"`, an asynchronous prompt failure (`response` with `success: false`), or omp child exit while a run or its background work is in progress; one notification per run | "Run failed: <error.message>" | +| `modelSwitch` | `retry_fallback_applied {from, to, reason?}`, or `notice` with `source` `prewalk` or `plan-yolo` | "Switched <from> → <to>: <reason>", or the notice text | + +The title is the session name, falling back to the cwd's last path segment. + +Rules: + +- Subagent activity never notifies. Subagent frames arrive as + `subagent_lifecycle` / `subagent_progress` / `subagent_event`; the detector + only inspects top-level frame types. +- `completed` waits for `session_settled` rather than `prompt_result`, so + background work the prompt started (async tasks, queued follow-ups) is done + too. A run woken by background work with no prompt also completes this way. +- Local slash commands (`prompt_result` with `agentInvoked: false`), aborted + runs, `auto_retry_start` and `auto_retry_end {success: true}` never notify. +- The `modelSwitch` reason is `retry_fallback_applied.reason` when present + (usage-aware fallback). omp sends a reason-less fallback before the + `auto_retry_start` of the same failure, so the detector holds it and takes + that frame's `errorMessage` (classifier refusals such as Fable falling back + to Opus, rate limits, provider failures). A fallback still held at + `auto_retry_end`, `prompt_result` or `session_settled` is sent without one. +- Every notification has tag `<sessionId>:<type>`, so a repeat replaces the + previous one instead of stacking. Notifications are not retracted when they + stop applying. + +## Architecture + +``` +omp child ──frames──▶ AgentSessionWrapper.handleFrame (lib/rpc-manager.ts) + │ createNotificationDetector() (lib/notification-events.ts) + ▼ + publishNotification() (lib/notification-hub.ts: presence-aware routing) + │ │ + {kind: toast|os} on /api/agent/running/events Web Push (web-push) + │ │ + SessionSidebar → window event → useNotifications public/sw.js +``` + +Detection and routing run on the server, so sessions that are not open in any +tab still notify. Toast and in-page messages ride the running-sessions SSE +stream the sidebar already holds (`?clientId=`): browsers allow only +six HTTP/1.1 connections per host, so a tab must not open another stream. + +### `NotificationEvent` + +```ts +interface NotificationEvent { + type: "completed" | "input" | "error" | "modelSwitch" | "test"; + sessionId: string; + sessionName: string | null; + detail?: string; // question, error text, or model-switch reason + from?: string; + to?: string; +} +``` + +`renderNotification(event, t)` turns it into `{title, body, url, tag, +sessionId}` with `url = "/?session=<encoded id>"`; details are clipped to 180 +characters. The browser renders with its own locale; the server renders pushes +in the device's stored locale. + +### Presence + +Each page load generates a `clientId`; each browser profile keeps a `deviceId` +in `localStorage`. A tab reports presence with +`POST /api/notifications/presence {clientId, deviceId, visible, sessionId, seq}` on +mount, `visibilitychange`, session switch, the first input after idling, +`pagehide` (beacon, `visible: false`), and every 30 seconds while present. +`visible` means the page is visible and had input (pointer, key, wheel, touch, +focus) in the last 3 minutes. `sessionId` is null while full-page Settings +hides the chat. A report expires 60 seconds after the server received it. +`seq` increases with every report from the page; the server ignores a report +older than the one it holds, since requests can arrive out of order. Device +PUTs run one at a time and read the current prefs when sent. + +Presence and SSE streams are tracked separately: streams reconnect and overlap +while the tab stays where it is. A tab is "reachable" while it has an open +stream, and "active" when its presence is visible and it is reachable. + +### Routing (`routeNotification`) + +For each event: + +1. If a visible tab is showing `event.sessionId`, drop the event for every + device, in every mode. This uses presence only, so a reconnecting stream + cannot leak a notification for the viewed session. A session omp moved to a + new file carries its earlier ids as `aliases`, and tabs showing any of them + count as viewing it. +2. For each device whose prefs enable notifications and the event's type: + - if any tab (on any device) is active and the device has + `whenActive: "toast"`, send `{kind: "toast"}` to that device's active + tabs; a device with no active tab stays quiet; + - otherwise send a system notification: Web Push when the device has a + subscription, else `{kind: "os"}` to each of its reachable tabs, which + show it with `registration.showNotification` (or a toast when the + browser no longer allows notifications). + +The server decides before pushing, and the service worker always displays a +push: iOS Safari revokes push permission for pushes that show nothing. + +### Web Push and storage + +- Dependency: `web-push`. +- `~/.omp/agent/omp-web/notifications.json` (mode 0600, atomic temp-file + + rename writes) holds the VAPID key pair, generated on first use, and up to + 50 devices `{deviceId, prefs, subscription?, updatedAt}`. A push endpoint + belongs to one device; saving it removes it from any other. Over the cap, + devices that are neither enabled nor subscribed are dropped first. The file + is read on every use, so omp-web instances sharing the agent directory see + each other's devices and keys. +- VAPID subject: `https://github.com/kahme247/ompweb` (Apple rejects subjects it + cannot resolve, such as `mailto:…@localhost`). +- Only HTTPS push endpoints are accepted. +- A push failing with HTTP 404 or 410 drops that subscription and keeps the + device prefs. Other failures are logged. +- Push TTL 1 hour; urgency `high` for `input` and `error`, `normal` otherwise. +- On every app load with notifications enabled and permission granted, the + browser re-subscribes if it has no subscription or one signed with a + different server key, and re-sends it to the server. + +### Service worker (`public/sw.js`) + +- Registered at `/sw.js`, scope `/`. No caching. +- `push`: `showNotification(title, {body, icon: "/icon-192.png", badge: + "/badge-96.png", tag, renotify, data: {url, sessionId}})`. +- `notificationclick`: focus an existing window. An app window (`/`) gets + `postMessage({type: "omp-open-session", sessionId})` and selects the session + in place; any other window (the sign-in page) navigates to the session URL; + with no window, `clients.openWindow(url)`. +- `/sw.js` and `/badge-96.png` are exempt from the password gate in + `proxy.ts`: browsers re-fetch the worker for update checks without the + sign-in cookie. +- Session links survive sign-in: `proxy.ts` redirects `/?session=…` without a + valid cookie to `/login?next=/?session=…`, and the sign-in form returns to + `next` when it starts with `/?`. + +### HTTP API + +| Route | Method | Purpose | +|---|---|---| +| `/api/agent/running/events?clientId=` | GET | existing SSE stream, now also `{type: "notification", kind, event}` | +| `/api/notifications/presence` | POST | presence report | +| `/api/notifications/devices?deviceId=` | GET | VAPID public key, whether this device has a subscription | +| `/api/notifications/devices` | PUT | `{deviceId, prefs, subscription?}`; an omitted subscription keeps the stored one | +| `/api/notifications/test` | POST | `{deviceId}`: a test push; 502 with the push service's answer when it fails | + +All routes sit behind the existing password gate and origin check. + +## Settings: Notifications tab + +A `SettingsTabs` category `notifications` (icon `Bell`). Contents: + +- **Enable notifications**. Turning it on requests permission (first await in + the click handler, for Safari), saves the prefs, and subscribes to push + when available. +- **Status** (alert), one of: push active; in-page only (no push support); + needs HTTPS; add to Home Screen first (iOS browser tab); blocked by the + browser; unsupported. +- **Toggles**, one per type: Task finished (on), Waiting for input (on), Run + failed (on), Model switched automatically (off). +- **When I'm using omp-web in another tab**: "Show an in-app toast" (default) + or "Always send a system notification". +- **Send test notification**: a real push for subscribed devices, else a + local notification from the page. + +```ts +interface NotificationPrefs { + enabled: boolean; // default false + types: Record<"completed" | "input" | "error" | "modelSwitch", boolean>; + whenActive: "toast" | "system"; + locale: string; // UI locale, for server-rendered pushes +} +``` + +Prefs live in `localStorage` and are sent to the server on every change, and +on app load for browsers that ever saved them (so a missed save, such as turning notifications off, is repaired). Other tabs pick changes up through the +`storage` event. Strings are in `lib/i18n` (English, Japanese, Simplified +Chinese). + +## In-app toast + +`{kind: "toast"}` shows through the existing `toast` helper (`toast.error` for +`error`, `toast.info` otherwise) with an **Open** button that selects the +session. + +## Removed or replaced + +- The completion notification in `AppShell.handleAgentEnd` and + `lib/browser-notifications.ts` (with its test). +- The `notify` host tool shows through the service worker registration when + one exists; `new Notification` remains the fallback. + +## Platform notes + +- Desktop: Web Push notifications appear in the OS notification center. + Delivery needs the browser process running; no tab needs to be open. +- Android: Chrome and the installed PWA. +- iOS/iPadOS 16.4+: push only in the Home Screen app. +- Push, service workers and the Notification API need a secure context + (`localhost` or HTTPS). Over plain HTTP on another host only in-app toasts + work. +- The server needs outbound HTTPS to the browser push services. + +## Testing + +- `lib/notification-events.test.mjs`: completion on settle (not per prompt or + subagent), slash commands and aborts silent, error once, input from blocking + dialogs only, model-switch reason precedence, prefs validation, rendering. +- `lib/notification-hub.test.mjs`: routing rules above, stale presence, HTTPS + endpoint validation, 410 drops the subscription, store file mode. +- Manual: desktop Chrome on `localhost` (push, toast, click opens the + session); Android PWA over HTTPS. diff --git a/hooks/useAgentSession-stream.ts b/hooks/useAgentSession-stream.ts index 8d9f31271..8814f3f57 100644 --- a/hooks/useAgentSession-stream.ts +++ b/hooks/useAgentSession-stream.ts @@ -331,11 +331,13 @@ export async function runHostTool( const message = str(args.message) ?? ""; if (typeof Notification !== "undefined") { try { - if (Notification.permission === "granted") { - new Notification(title, { body: message }); - } else if (Notification.permission === "default") { - const permission = await Notification.requestPermission(); - if (permission === "granted") new Notification(title, { body: message }); + const permission = Notification.permission === "default" ? await Notification.requestPermission() : Notification.permission; + if (permission === "granted") { + const options = { body: message, icon: "/icon-192.png", badge: "/badge-96.png" }; + // Chrome on Android only allows notifications through a service worker. + const registration = "serviceWorker" in navigator ? await navigator.serviceWorker.getRegistration() : undefined; + if (registration) await registration.showNotification(title, options); + else new Notification(title, options); } } catch { // Notification API blocked — the result still succeeds. diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts new file mode 100644 index 000000000..fb7038a4a --- /dev/null +++ b/hooks/useNotifications.ts @@ -0,0 +1,142 @@ +import { useEffect, useRef, useSyncExternalStore } from "react"; +import { toast } from "@/components/ui/toast"; +import { translate } from "@/lib/i18n"; +import { DEFAULT_NOTIFICATION_PREFS, renderNotification, type NotificationEvent, type RenderedNotification } from "@/lib/notification-events"; +import { + ensurePushSubscription, + getNotificationDeviceId, + getNotificationPrefs, + hasStoredNotificationPrefs, + NOTIFICATION_MESSAGE_EVENT, + notificationClientId, + OPEN_SESSION_EVENT, + showSystemNotification, + subscribeNotificationPrefs, + syncNotificationDevice, + updateNotificationPrefs, +} from "@/lib/notification-client"; + +const PRESENCE_INTERVAL_MS = 30_000; +/** Without input for this long a visible tab no longer counts as the user being there. */ +const IDLE_AFTER_MS = 3 * 60_000; +const ACTIVITY_EVENTS = ["pointerdown", "keydown", "wheel", "touchstart", "focus"] as const; +/** Orders this page's presence reports; it outlives effect re-runs, so a session switch never restarts it. */ +let presenceSeq = 0; + +/** Current per-device notification prefs (re-renders on change). */ +export function useNotificationPrefs() { + return useSyncExternalStore(subscribeNotificationPrefs, getNotificationPrefs, () => DEFAULT_NOTIFICATION_PREFS); +} + +/** + * App-level notification wiring, mounted once in AppShell: + * - tells the server which session this tab shows and whether the user is here; + * - shows toasts / system notifications the server routes to this tab; + * - opens the session when a notification is clicked; + * - keeps the server's copy of this device's prefs and push subscription current. + * + * `sessionId` is the session whose chat is on screen (null while Settings or + * another view covers it). + */ +export function useNotifications({ sessionId, locale, onOpenSession }: { sessionId: string | null; locale: string; onOpenSession: (sessionId: string) => void }) { + const openRef = useRef(onOpenSession); + useEffect(() => { + openRef.current = onOpenSession; + }, [onOpenSession]); + + // Presence: visible and recently used. Reported on every change, plus a keep-alive. + useEffect(() => { + let lastInput = Date.now(); + let reported: boolean | null = null; + const present = () => document.visibilityState === "visible" && Date.now() - lastInput < IDLE_AFTER_MS; + const report = (visible = present()) => { + reported = visible; + presenceSeq += 1; + const body = JSON.stringify({ clientId: notificationClientId, deviceId: getNotificationDeviceId(), visible, sessionId, seq: presenceSeq }); + if (!visible && navigator.sendBeacon) { + navigator.sendBeacon("/api/notifications/presence", new Blob([body], { type: "application/json" })); + return; + } + void fetch("/api/notifications/presence", { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true }).catch(() => {}); + }; + const onActivity = () => { + lastInput = Date.now(); + if (reported === false && present()) report(); + }; + const onVisibility = () => report(); + const onPageHide = () => report(false); + report(); + const timer = setInterval(() => { + // Keep-alive while present; one report when the user goes idle. + if (present() || reported) report(); + }, PRESENCE_INTERVAL_MS); + for (const name of ACTIVITY_EVENTS) window.addEventListener(name, onActivity, { passive: true, capture: true }); + document.addEventListener("visibilitychange", onVisibility); + window.addEventListener("pagehide", onPageHide); + return () => { + clearInterval(timer); + for (const name of ACTIVITY_EVENTS) window.removeEventListener(name, onActivity, { capture: true }); + document.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("pagehide", onPageHide); + }; + }, [sessionId]); + + // Pushes are written in the device's language. + useEffect(() => { + const prefs = getNotificationPrefs(); + if (prefs.enabled && prefs.locale !== locale) void updateNotificationPrefs({ locale }).catch(() => {}); + }, [locale]); + + // Startup sync for any browser with saved settings: the server may have lost + // its store, missed a save (including turning notifications off), or the + // browser its subscription. Browsers that never opened the setting stay off + // the server's device list. + useEffect(() => { + if (!hasStoredNotificationPrefs()) return; + void (async () => { + const pushActive = getNotificationPrefs().enabled && (await ensurePushSubscription().catch(() => false)); + if (!pushActive) await syncNotificationDevice(); + })().catch(() => {}); + }, []); + + // Delivery to this tab and notification clicks. + useEffect(() => { + // The whole card opens the session (click, tap, or Enter on the focused card); no separate Open link. + const showToast = (rendered: RenderedNotification, type: NotificationEvent["type"]) => { + const target = rendered.sessionId; + const show = type === "error" ? toast.error : toast.info; + show(rendered.title, rendered.body, { id: rendered.tag, onClick: target ? () => openRef.current(target) : undefined }); + }; + const onMessage = (raw: Event) => { + if (!(raw instanceof CustomEvent)) return; + // Sent by SessionSidebar from our own SSE stream. + const { kind, event }: { kind: "toast" | "os"; event: NotificationEvent } = raw.detail; + const rendered = renderNotification(event, translate); + if (kind === "toast") { + showToast(rendered, event.type); + return; + } + // Permission withdrawn or never granted: the toast is the only way left to tell the user. + void showSystemNotification(rendered).then((shown) => { + if (!shown) showToast(rendered, event.type); + }); + }; + const onOpen = (raw: Event) => { + if (raw instanceof CustomEvent && typeof raw.detail === "string" && raw.detail) openRef.current(raw.detail); + }; + const onWorkerMessage = (message: MessageEvent) => { + const data: unknown = message.data; + if (data && typeof data === "object" && "type" in data && data.type === "omp-open-session" && "sessionId" in data && typeof data.sessionId === "string") { + openRef.current(data.sessionId); + } + }; + window.addEventListener(NOTIFICATION_MESSAGE_EVENT, onMessage); + window.addEventListener(OPEN_SESSION_EVENT, onOpen); + navigator.serviceWorker?.addEventListener("message", onWorkerMessage); + return () => { + window.removeEventListener(NOTIFICATION_MESSAGE_EVENT, onMessage); + window.removeEventListener(OPEN_SESSION_EVENT, onOpen); + navigator.serviceWorker?.removeEventListener("message", onWorkerMessage); + }; + }, []); +} diff --git a/lib/browser-notifications.test.mjs b/lib/browser-notifications.test.mjs deleted file mode 100644 index d7a696318..000000000 --- a/lib/browser-notifications.test.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { createJiti } from "jiti"; - -const jiti = createJiti(import.meta.url, { tsconfigPaths: true }); -const { showCompletionNotification } = await jiti.import("./browser-notifications.ts"); - -test("opens the finished session when a completion notification is clicked", () => { - let clicked = false; - let title; - let body; - let handler; - const delivered = showCompletionNotification("Finished", "Task complete", () => { clicked = true; }, { - createNotification(nextTitle, options) { - title = nextTitle; - body = options.body; - return { close() {}, set onclick(value) { handler = value; } }; - }, - }); - - assert.equal(delivered, true); - assert.equal(title, "Finished"); - assert.equal(body, "Task complete"); - handler(); - assert.equal(clicked, true); -}); diff --git a/lib/browser-notifications.ts b/lib/browser-notifications.ts deleted file mode 100644 index 198b31c66..000000000 --- a/lib/browser-notifications.ts +++ /dev/null @@ -1,26 +0,0 @@ -interface WindowNotificationLike { - onclick: Notification["onclick"]; - close: () => void; -} - -export interface CompletionNotificationEnvironment { - createNotification: (title: string, options?: NotificationOptions) => WindowNotificationLike; -} - -export function showCompletionNotification( - title: string, - body: string, - onClick: () => void, - environment: CompletionNotificationEnvironment = { createNotification: (nextTitle, options) => new Notification(nextTitle, options) }, -): boolean { - try { - const notification = environment.createNotification(title, { body }); - notification.onclick = () => { - notification.close(); - onClick(); - }; - return true; - } catch { - return false; - } -} diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 074c3e095..40f9f9e5e 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -294,8 +294,44 @@ "appShell.darkThemes": "Dark", "appShell.themeSystem": "System (Auto)", "appShell.systemTheme": "Using system theme (click to switch to light)", - "appShell.sessionComplete": "Session complete", - "appShell.taskFinished": "Task finished.", + "notifications.completedBody": "Task finished.", + "notifications.inputBody": "Waiting for your answer: {detail}", + "notifications.inputBodyNoDetail": "Waiting for your answer.", + "notifications.errorBody": "Run failed: {detail}", + "notifications.errorBodyNoDetail": "Run failed.", + "notifications.modelSwitchBody": "Switched {from} → {to}: {detail}", + "notifications.modelSwitchBodyNoDetail": "Switched {from} → {to}.", + "notifications.testBody": "Notifications are working.", + "notifications.untitledSession": "omp session", + "notifications.sessionNotFound": "That session is no longer available.", + "notifications.title": "Notifications", + "notifications.subtitle": "Get notified about sessions you are not looking at. These settings apply to this browser only.", + "notifications.enable": "Enable notifications", + "notifications.enableDesc": "Asks the browser for permission to show notifications.", + "notifications.statusPush": "Push notifications are active, even when no omp-web tab is open.", + "notifications.statusInPage": "System notifications show while an omp-web tab is open. Push is not available in this browser.", + "notifications.statusPermission": "The browser has not allowed notifications for omp-web yet. Turn the switch off and on to ask again, or allow them in the site settings.", + "notifications.statusInsecure": "System notifications need HTTPS (or localhost). In-app toasts still work.", + "notifications.statusIosInstall": "On iPhone and iPad, add omp-web to the Home Screen and open it from there first.", + "notifications.statusDenied": "This browser blocks notifications for omp-web. Allow them in the site settings.", + "notifications.statusUnsupported": "This browser cannot show system notifications. In-app toasts still work.", + "notifications.typeCompleted": "Task finished", + "notifications.typeCompletedDesc": "A prompt and the background work it started are done. Subagents never notify.", + "notifications.typeInput": "Waiting for input", + "notifications.typeInputDesc": "The agent asks a question or needs an approval.", + "notifications.typeError": "Run failed", + "notifications.typeErrorDesc": "A run stops with an error after retries and fallbacks, or the omp process exits.", + "notifications.typeModelSwitch": "Model switched automatically", + "notifications.typeModelSwitchDesc": "omp switches models on its own: a fallback after a refusal, rate limit, or usage limit, or a prewalk / plan hand-off.", + "notifications.whenActive": "When I'm using omp-web in another tab", + "notifications.whenActiveDesc": "Nothing is shown for the session you are viewing.", + "notifications.whenActiveToast": "Show an in-app toast", + "notifications.whenActiveSystem": "Always send a system notification", + "notifications.test": "Send test notification", + "notifications.testSent": "Test notification sent.", + "notifications.testFailed": "Could not send the test notification.", + "notifications.enableFailed": "Could not enable push notifications: {error}", + "notifications.saveFailed": "Could not save the notification settings.", "appShell.system": "System", "appShell.systemPrompt": "System prompt", "appShell.systemPromptEmpty": "System prompt is empty (tools are disabled)", @@ -1229,6 +1265,8 @@ "settingsTabs.mcp.description": "MCP servers, managed skills, and OMP plugins", "settingsTabs.system.label": "System & Updates", "settingsTabs.system.description": "App updates, runtime version, and active session restart", + "settingsTabs.notifications.label": "Notifications", + "settingsTabs.notifications.description": "Desktop and mobile notifications for your sessions", "settingsTabs.attentionRequired": "Attention required", "settingsTabs.updateAvailable": "Update available", "settingsConfig.loadingSettings": "Loading settings…", diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index 8408e7047..eb667b0b9 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -281,8 +281,44 @@ "appShell.darkThemes": "ダーク", "appShell.themeSystem": "システム(自動)", "appShell.systemTheme": "システムテーマを使用中(クリックでライトに切り替え)", - "appShell.sessionComplete": "セッション完了", - "appShell.taskFinished": "タスクが完了しました。", + "notifications.completedBody": "タスクが完了しました。", + "notifications.inputBody": "回答を待っています: {detail}", + "notifications.inputBodyNoDetail": "回答を待っています。", + "notifications.errorBody": "実行に失敗しました: {detail}", + "notifications.errorBodyNoDetail": "実行に失敗しました。", + "notifications.modelSwitchBody": "{from} → {to} に切り替えました: {detail}", + "notifications.modelSwitchBodyNoDetail": "{from} → {to} に切り替えました。", + "notifications.testBody": "通知は正常に動作しています。", + "notifications.untitledSession": "omp セッション", + "notifications.sessionNotFound": "そのセッションはもう利用できません。", + "notifications.title": "通知", + "notifications.subtitle": "表示していないセッションについて通知します。この設定はこのブラウザーにのみ適用されます。", + "notifications.enable": "通知を有効にする", + "notifications.enableDesc": "通知を表示する許可をブラウザーに求めます。", + "notifications.statusPush": "プッシュ通知が有効です。omp-web のタブを開いていなくても届きます。", + "notifications.statusInPage": "omp-web のタブを開いている間はシステム通知が表示されます。このブラウザーではプッシュを利用できません。", + "notifications.statusPermission": "ブラウザーはまだ omp-web の通知を許可していません。スイッチをオフにしてからオンにすると再度確認します。サイト設定で許可することもできます。", + "notifications.statusInsecure": "システム通知には HTTPS(または localhost)が必要です。アプリ内トーストは引き続き表示されます。", + "notifications.statusIosInstall": "iPhone と iPad では、まず omp-web をホーム画面に追加し、そこから開いてください。", + "notifications.statusDenied": "このブラウザーは omp-web の通知をブロックしています。サイト設定で許可してください。", + "notifications.statusUnsupported": "このブラウザーはシステム通知を表示できません。アプリ内トーストは引き続き表示されます。", + "notifications.typeCompleted": "タスク完了", + "notifications.typeCompletedDesc": "プロンプトと、それが開始したバックグラウンド作業が完了したとき。サブエージェントでは通知しません。", + "notifications.typeInput": "入力待ち", + "notifications.typeInputDesc": "エージェントが質問したり承認を求めたりしたとき。", + "notifications.typeError": "実行失敗", + "notifications.typeErrorDesc": "再試行とフォールバックの後に実行がエラーで止まったとき、または omp プロセスが終了したとき。", + "notifications.typeModelSwitch": "モデルの自動切り替え", + "notifications.typeModelSwitchDesc": "omp が自動でモデルを切り替えたとき(拒否、レート制限、利用上限によるフォールバック、または prewalk / plan の引き継ぎ)。", + "notifications.whenActive": "omp-web の別のタブを使用中のとき", + "notifications.whenActiveDesc": "表示中のセッションについては何も表示しません。", + "notifications.whenActiveToast": "アプリ内トーストを表示", + "notifications.whenActiveSystem": "常にシステム通知を送る", + "notifications.test": "テスト通知を送信", + "notifications.testSent": "テスト通知を送信しました。", + "notifications.testFailed": "テスト通知を送信できませんでした。", + "notifications.enableFailed": "プッシュ通知を有効にできませんでした: {error}", + "notifications.saveFailed": "通知設定を保存できませんでした。", "appShell.system": "システム", "appShell.systemPrompt": "システムプロンプト", "appShell.systemPromptEmpty": "システムプロンプトは空です(ツールは無効)", @@ -1192,6 +1228,8 @@ "settingsTabs.mcp.description": "MCP servers, managed skills, and OMP plugins", "settingsTabs.system.label": "System & Updates", "settingsTabs.system.description": "App updates, runtime version, and active session restart", + "settingsTabs.notifications.label": "通知", + "settingsTabs.notifications.description": "セッションのデスクトップ通知とモバイル通知", "settingsTabs.attentionRequired": "要確認", "settingsTabs.updateAvailable": "更新があります", "settingsConfig.loadingSettings": "Loading settings…", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index b6de27b5f..1044f3c35 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -281,8 +281,44 @@ "appShell.darkThemes": "深色", "appShell.themeSystem": "系统(自动)", "appShell.systemTheme": "正在使用系统主题(点击切换到浅色)", - "appShell.sessionComplete": "会话已完成", - "appShell.taskFinished": "任务已完成。", + "notifications.completedBody": "任务已完成。", + "notifications.inputBody": "正在等待你的回答:{detail}", + "notifications.inputBodyNoDetail": "正在等待你的回答。", + "notifications.errorBody": "运行失败:{detail}", + "notifications.errorBodyNoDetail": "运行失败。", + "notifications.modelSwitchBody": "已切换 {from} → {to}:{detail}", + "notifications.modelSwitchBodyNoDetail": "已切换 {from} → {to}。", + "notifications.testBody": "通知工作正常。", + "notifications.untitledSession": "omp 会话", + "notifications.sessionNotFound": "该会话已不可用。", + "notifications.title": "通知", + "notifications.subtitle": "为你当前未查看的会话发送通知。这些设置仅适用于此浏览器。", + "notifications.enable": "启用通知", + "notifications.enableDesc": "请求浏览器授予显示通知的权限。", + "notifications.statusPush": "推送通知已启用,即使没有打开 omp-web 标签页也能收到。", + "notifications.statusInPage": "打开 omp-web 标签页时会显示系统通知。此浏览器无法使用推送。", + "notifications.statusPermission": "浏览器尚未允许 omp-web 显示通知。关闭再打开开关可再次询问,也可以在网站设置中允许。", + "notifications.statusInsecure": "系统通知需要 HTTPS(或 localhost)。应用内提示仍然有效。", + "notifications.statusIosInstall": "在 iPhone 和 iPad 上,请先将 omp-web 添加到主屏幕,并从主屏幕打开。", + "notifications.statusDenied": "此浏览器已阻止 omp-web 的通知。请在网站设置中允许。", + "notifications.statusUnsupported": "此浏览器无法显示系统通知。应用内提示仍然有效。", + "notifications.typeCompleted": "任务完成", + "notifications.typeCompletedDesc": "提示及其启动的后台工作全部完成。子代理不会触发通知。", + "notifications.typeInput": "等待输入", + "notifications.typeInputDesc": "代理提出问题或需要批准。", + "notifications.typeError": "运行失败", + "notifications.typeErrorDesc": "运行在重试和回退后仍因错误停止,或 omp 进程退出。", + "notifications.typeModelSwitch": "模型自动切换", + "notifications.typeModelSwitchDesc": "omp 自行切换模型:因拒绝、速率限制或用量限制而回退,或 prewalk / plan 交接。", + "notifications.whenActive": "当我在另一个 omp-web 标签页中操作时", + "notifications.whenActiveDesc": "正在查看的会话不会显示任何通知。", + "notifications.whenActiveToast": "显示应用内提示", + "notifications.whenActiveSystem": "始终发送系统通知", + "notifications.test": "发送测试通知", + "notifications.testSent": "测试通知已发送。", + "notifications.testFailed": "无法发送测试通知。", + "notifications.enableFailed": "无法启用推送通知:{error}", + "notifications.saveFailed": "无法保存通知设置。", "appShell.system": "系统", "appShell.systemPrompt": "系统提示词", "appShell.systemPromptEmpty": "系统提示词为空(工具已禁用)", @@ -1192,6 +1228,8 @@ "settingsTabs.mcp.description": "MCP 服务端、托管技能与 OMP 插件", "settingsTabs.system.label": "系统与更新", "settingsTabs.system.description": "应用更新、运行时版本与活动会话重启", + "settingsTabs.notifications.label": "通知", + "settingsTabs.notifications.description": "会话的桌面和移动端通知", "settingsTabs.attentionRequired": "需要关注", "settingsTabs.updateAvailable": "有可用更新", "settingsConfig.loadingSettings": "正在加载设置…", diff --git a/lib/notification-client.ts b/lib/notification-client.ts new file mode 100644 index 000000000..995bbe5c2 --- /dev/null +++ b/lib/notification-client.ts @@ -0,0 +1,231 @@ +/** + * Browser side of notifications: device identity, per-device preferences, + * service worker registration, Web Push subscription, and system notifications. + * Server routing lives in lib/notification-hub.ts. + */ +import { + DEFAULT_NOTIFICATION_PREFS, + isValidNotificationId, + parseNotificationPrefs, + type NotificationPrefs, + type RenderedNotification, +} from "./notification-events"; + +const DEVICE_KEY = "omp-notify-device"; +const PREFS_KEY = "omp-notify-prefs"; + +/** crypto.randomUUID exists only in secure contexts; plain-HTTP LAN origins still need ids. */ +function randomId(): string { + return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `id${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; +} + +/** One id per page load: a duplicated tab copies sessionStorage, so it cannot hold this. */ +export const notificationClientId = randomId(); + +let deviceId: string | null = null; + +/** Stable per browser profile: shared by all tabs of this browser. */ +export function getNotificationDeviceId(): string { + if (deviceId) return deviceId; + try { + const stored = localStorage.getItem(DEVICE_KEY); + deviceId = isValidNotificationId(stored) ? stored : randomId(); + if (deviceId !== stored) localStorage.setItem(DEVICE_KEY, deviceId); + } catch { + deviceId ??= randomId(); + } + return deviceId; +} + +// ---------------------------------------------------------------- prefs store + +const listeners = new Set<() => void>(); +let prefs: NotificationPrefs | null = null; + +export function getNotificationPrefs(): NotificationPrefs { + if (prefs) return prefs; + try { + const stored = localStorage.getItem(PREFS_KEY); + prefs = stored ? parseNotificationPrefs(JSON.parse(stored)) : DEFAULT_NOTIFICATION_PREFS; + } catch { + prefs = DEFAULT_NOTIFICATION_PREFS; + } + return prefs; +} + +/** Whether this browser ever saved notification settings (never-enabled browsers stay off the server). */ +export function hasStoredNotificationPrefs(): boolean { + try { + return localStorage.getItem(PREFS_KEY) !== null; + } catch { + return false; + } +} + +export function subscribeNotificationPrefs(listener: () => void): () => void { + // Another tab of this browser changed the prefs: drop the cached copy. + const onStorage = (event: StorageEvent) => { + if (event.key !== PREFS_KEY) return; + prefs = null; + listener(); + }; + listeners.add(listener); + window.addEventListener("storage", onStorage); + return () => { + listeners.delete(listener); + window.removeEventListener("storage", onStorage); + }; +} + +/** Save locally, then mirror to the server (which routes pushes while no tab is open). */ +export async function updateNotificationPrefs(patch: Partial<NotificationPrefs>): Promise<void> { + prefs = null; // merge into the latest stored copy, not a stale one from before another tab's change + prefs = { ...getNotificationPrefs(), ...patch }; + try { + localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); + } catch { + // storage unavailable: prefs still apply for this page and on the server + } + listeners.forEach((listener) => listener()); + await syncNotificationDevice(); +} + +let syncQueue: Promise<unknown> = Promise.resolve(); + +/** + * Send prefs (and optionally a new push subscription) to the server. + * Returns whether the server holds a push subscription for this device. + * Requests run one at a time and read the prefs when they start, so a slow + * older request can never land after a newer one with stale prefs. + */ +export function syncNotificationDevice(subscription?: PushSubscriptionJSON): Promise<boolean> { + const request = syncQueue.catch(() => {}).then(async () => { + const response = await fetch("/api/notifications/devices", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceId: getNotificationDeviceId(), prefs: getNotificationPrefs(), subscription }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body: unknown = await response.json(); + return typeof body === "object" && body !== null && "subscribed" in body && body.subscribed === true; + }); + syncQueue = request; + return request; +} + +// ---------------------------------------------------------------- support + +export type NotificationSupport = "unsupported" | "insecure" | "ios-install" | "denied" | "no-push" | "push"; + +function isIosBrowserTab(): boolean { + const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + const standalone = window.matchMedia("(display-mode: standalone)").matches || ("standalone" in navigator && navigator.standalone === true); + return ios && !standalone; +} + +/** What this browser can do for system notifications. In-app toasts work everywhere. */ +export function getNotificationSupport(): NotificationSupport { + if (!window.isSecureContext) return "insecure"; + if (isIosBrowserTab()) return "ios-install"; + if (!("Notification" in window) || !("serviceWorker" in navigator)) return "unsupported"; + if (Notification.permission === "denied") return "denied"; + return "PushManager" in window ? "push" : "no-push"; +} + +// ---------------------------------------------------------------- service worker + push + +/** Registers the worker and resolves once one is active (subscribing needs an active worker). */ +export async function registerNotificationWorker(): Promise<ServiceWorkerRegistration | null> { + if (!("serviceWorker" in navigator) || !window.isSecureContext) return null; + try { + await navigator.serviceWorker.register("/sw.js", { scope: "/" }); + return await navigator.serviceWorker.ready; + } catch { + return null; + } +} + +function base64UrlToBytes(value: string): Uint8Array<ArrayBuffer> { + const base64 = (value + "=".repeat((4 - (value.length % 4)) % 4)).replace(/-/g, "+").replace(/_/g, "/"); + return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); +} + +function sameKey(key: ArrayBuffer | null | undefined, expected: Uint8Array): boolean { + if (!key) return false; + const actual = new Uint8Array(key); + return actual.length === expected.length && actual.every((byte, index) => byte === expected[index]); +} + +let ensuring: Promise<boolean> | null = null; + +/** + * Make sure this device has a working push subscription for the server's + * VAPID key and the server knows it. Needs notification permission already + * granted. Returns whether push is active. Single-flight: two overlapping runs + * would each replace the other's subscription and store a dead one. + */ +export function ensurePushSubscription(): Promise<boolean> { + ensuring ??= subscribeForPush().finally(() => { + ensuring = null; + }); + return ensuring; +} + +async function subscribeForPush(): Promise<boolean> { + if (getNotificationSupport() !== "push" || Notification.permission !== "granted") return false; + const registration = await registerNotificationWorker(); + if (!registration) return false; + const response = await fetch(`/api/notifications/devices?deviceId=${encodeURIComponent(getNotificationDeviceId())}`); + if (!response.ok) return false; + const { publicKey, subscribed }: { publicKey: string; subscribed: boolean } = await response.json(); + const applicationServerKey = base64UrlToBytes(publicKey); + let subscription = await registration.pushManager.getSubscription(); + // Replace a subscription signed for other server keys, or one the server + // dropped after the push service reported it gone (re-sending it would loop). + if (subscription && (!sameKey(subscription.options.applicationServerKey, applicationServerKey) || !subscribed)) { + await subscription.unsubscribe().catch(() => false); + subscription = null; + } + subscription ??= await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }); + return syncNotificationDevice(subscription.toJSON()); +} + +/** Window event (detail: session id) asking the app to open a notification's session. */ +export const OPEN_SESSION_EVENT = "omp-open-session"; + +/** Window event (detail: `{kind, event}` from the server) for a notification delivered to this tab. */ +export const NOTIFICATION_MESSAGE_EVENT = "omp-notification"; + +/** + * Show a system notification the same way the service worker shows pushes. + * Returns false when the browser does not allow it, so the caller can fall back. + */ +export async function showSystemNotification(notification: RenderedNotification): Promise<boolean> { + if (!("Notification" in window) || Notification.permission !== "granted") return false; + const options: NotificationOptions = { + body: notification.body, + icon: "/icon-192.png", + badge: "/badge-96.png", + tag: notification.tag, + data: { url: notification.url, sessionId: notification.sessionId }, + }; + try { + const registration = "serviceWorker" in navigator ? await navigator.serviceWorker.getRegistration() : undefined; + if (registration) { + await registration.showNotification(notification.title, options); + return true; + } + // No worker: Chrome on Android throws here, desktop browsers show it. + const fallback = new Notification(notification.title, options); + fallback.onclick = () => { + fallback.close(); + window.focus(); + window.dispatchEvent(new CustomEvent(OPEN_SESSION_EVENT, { detail: notification.sessionId })); + }; + return true; + } catch { + return false; + } +} diff --git a/lib/notification-events.test.mjs b/lib/notification-events.test.mjs new file mode 100644 index 000000000..440480bc6 --- /dev/null +++ b/lib/notification-events.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { tsconfigPaths: true }); +const { createNotificationDetector, parseNotificationPrefs, renderNotification } = await jiti.import("./notification-events.ts"); + +const session = { sessionId: "s1", sessionName: "Refactor auth" }; + +function run(frames) { + const detect = createNotificationDetector(); + return frames.flatMap((frame) => detect(frame, session)); +} + +const summary = (events) => events.map((event) => [event.type, event.detail]); + +test("completion fires when the session settles, not per prompt or per subagent", () => { + const events = run([ + { type: "agent_start" }, + { type: "subagent_lifecycle", payload: { id: "a", status: "completed" } }, + { type: "subagent_event", payload: { id: "a", event: { type: "agent_end", isTerminal: true } } }, + { type: "agent_end", isTerminal: false }, + { type: "prompt_result", agentInvoked: true, status: "completed", sessionSettled: false }, + { type: "session_settled" }, + ]); + assert.deepEqual(events, [{ sessionId: "s1", sessionName: "Refactor auth", type: "completed" }]); +}); + +test("local slash commands and aborted runs do not notify", () => { + assert.deepEqual(run([{ type: "prompt_result", agentInvoked: false, status: "error", error: { message: "unknown command" } }]), []); + assert.deepEqual(run([{ type: "prompt_result", agentInvoked: true, status: "aborted" }, { type: "session_settled" }]), []); +}); + +test("a run failure reported by omp-web and again by omp notifies once; a later crash adds nothing", () => { + const events = run([ + { type: "prompt_error", errorMessage: "usage reserve is fail-closed" }, + { type: "prompt_result", agentInvoked: true, status: "error", error: { message: "usage reserve is fail-closed" } }, + { type: "process_exit", detail: "code 1" }, + { type: "session_settled" }, + ]); + assert.deepEqual(summary(events), [["error", "usage reserve is fail-closed"]]); +}); + +test("a failed run notifies once as an error, not again as completed", () => { + const events = run([ + { type: "prompt_result", agentInvoked: true, status: "error", error: { message: "Retry budget exhausted" } }, + { type: "session_settled" }, + ]); + assert.deepEqual(summary(events), [["error", "Retry budget exhausted"]]); +}); + +test("after a failed stretch, a background wake with no prompt completes normally", () => { + const events = run([ + { type: "prompt_result", agentInvoked: true, status: "error", error: { message: "boom" } }, + { type: "session_settled" }, + { type: "agent_start" }, + { type: "agent_end", isTerminal: true }, + { type: "session_settled" }, + ]); + assert.deepEqual(events.map((event) => event.type), ["error", "completed"]); +}); + +test("every blocking dialog method asks for input; non-blocking UI frames do not", () => { + const blocking = ["select", "confirm", "input", "editor", "open_url"].map((method) => ({ type: "extension_ui_request", id: method, method, title: `${method}?` })); + const events = run([ + ...blocking, + { type: "extension_ui_request", id: "ask", method: "ask", questions: [{ id: "q", question: "Ship it?", options: [] }] }, + { type: "extension_ui_request", id: "n", method: "notify", message: "fyi" }, + { type: "extension_ui_request", id: "s", method: "setStatus", statusKey: "k" }, + { type: "extension_ui_request", id: "p", method: "__proto__", title: "x" }, + ]); + assert.deepEqual(summary(events), [ + ["input", "select?"], ["input", "confirm?"], ["input", "input?"], ["input", "editor?"], ["input", "open_url?"], ["input", "Ship it?"], + ]); +}); + +test("a fallback takes its reason from the retry error omp sends right after it", () => { + // omp's order: retry_fallback_applied, then auto_retry_start for the same failure. + const events = run([ + { type: "auto_retry_start", attempt: 1, errorMessage: "429 rate limit, retry in 2s" }, + { type: "auto_retry_end", success: true }, + { type: "retry_fallback_applied", from: "anthropic/fable", to: "anthropic/opus", role: "default" }, + { type: "auto_retry_start", attempt: 2, errorMessage: "Request refused by the safety classifier" }, + ]); + assert.deepEqual(events.map((event) => [event.from, event.to, event.detail]), [["anthropic/fable", "anthropic/opus", "Request refused by the safety classifier"]]); +}); + +test("an explicit fallback reason wins; a fallback with no following retry has none", () => { + const events = run([ + { type: "retry_fallback_applied", from: "a/x", to: "b/y", reason: "usage reserve reached" }, + { type: "auto_retry_start", errorMessage: "ignored for the explicit reason" }, + { type: "retry_fallback_applied", from: "a/x", to: "b/y" }, + { type: "prompt_result", agentInvoked: true, status: "completed" }, + { type: "retry_fallback_applied", from: "a/x" }, + ]); + assert.deepEqual(summary(events), [["modelSwitch", "usage reserve reached"], ["modelSwitch", undefined]]); +}); + +test("prewalk and plan hand-offs notify as model switches; other notices do not", () => { + const events = run([ + { type: "notice", level: "info", source: "prewalk", message: "Prewalk: switched to openai/gpt-5 after first edit call." }, + { type: "notice", level: "info", source: "plan-yolo", message: "Plan-yolo: plan approved, switched to openai/gpt-5." }, + { type: "notice", level: "error", source: "session-persistence", message: "disk full" }, + ]); + assert.deepEqual(events.map((event) => event.type), ["modelSwitch", "modelSwitch"]); +}); + +test("untrusted prefs fall back to defaults field by field", () => { + assert.deepEqual(parseNotificationPrefs({ enabled: true, types: { input: false, bogus: true }, whenActive: "loud", locale: "<script>" }), { + enabled: true, + types: { completed: true, input: false, error: true, modelSwitch: false }, + whenActive: "toast", + locale: "en", + }); +}); + +test("rendered notifications open their session and clip long details", () => { + const t = (key, vars) => `${key}${vars ? JSON.stringify(vars) : ""}`; + const rendered = renderNotification({ type: "error", sessionId: "a b", sessionName: null, detail: "x".repeat(500) }, t); + assert.equal(rendered.url, "/?session=a%20b"); + assert.equal(rendered.title, "notifications.untitledSession"); + assert.equal(rendered.tag, "a b:error"); + assert.ok(rendered.body.length < 260, rendered.body); +}); + +test("a pasted overlong session name is clipped so push payloads stay small", () => { + const rendered = renderNotification({ type: "completed", sessionId: "s1", sessionName: "y".repeat(60_000) }, (key) => key); + assert.equal(rendered.title.length, 100); + assert.ok(rendered.title.endsWith("…")); + assert.equal(renderNotification({ type: "completed", sessionId: "s1", sessionName: "Fix bug" }, (key) => key).title, "Fix bug"); +}); diff --git a/lib/notification-events.ts b/lib/notification-events.ts new file mode 100644 index 000000000..06baef73f --- /dev/null +++ b/lib/notification-events.ts @@ -0,0 +1,215 @@ +import { isRecord } from "./type-guards"; + +/** + * Notification events shared by the server (detection, routing, push) and the + * browser (in-app toasts, in-page system notifications, settings). + */ + +export const NOTIFICATION_TYPES = ["completed", "input", "error", "modelSwitch"] as const; +export type NotificationType = (typeof NOTIFICATION_TYPES)[number]; + +export interface NotificationEvent { + /** `test` is only sent by the settings "Send test notification" button. */ + type: NotificationType | "test"; + sessionId: string; + sessionName: string | null; + /** Question, error text, or model-switch reason. */ + detail?: string; + from?: string; + to?: string; + /** Earlier ids of this conversation (omp moved it to a new file); tabs may still show one. */ + aliases?: string[]; +} + +export interface NotificationPrefs { + enabled: boolean; + types: Record<NotificationType, boolean>; + /** While the user is active in another omp-web tab: in-app toast or system notification. */ + whenActive: "toast" | "system"; + /** UI locale, so pushes are written in the device's language. */ + locale: string; +} + +export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { + enabled: false, + types: { completed: true, input: true, error: true, modelSwitch: false }, + whenActive: "toast", + locale: "en", +}; + +/** Validate untrusted prefs (request bodies, localStorage); unknown fields fall back to defaults. */ +export function parseNotificationPrefs(value: unknown): NotificationPrefs { + const raw = value && typeof value === "object" ? (value as Record<string, unknown>) : {}; + const rawTypes = raw.types && typeof raw.types === "object" ? (raw.types as Record<string, unknown>) : {}; + const types = { ...DEFAULT_NOTIFICATION_PREFS.types }; + for (const type of NOTIFICATION_TYPES) { + if (typeof rawTypes[type] === "boolean") types[type] = rawTypes[type] as boolean; + } + return { + enabled: raw.enabled === true, + types, + whenActive: raw.whenActive === "system" ? "system" : "toast", + locale: typeof raw.locale === "string" && /^[A-Za-z-]{2,10}$/.test(raw.locale) ? raw.locale : "en", + }; +} + +export interface RenderedNotification { + title: string; + body: string; + url: string; + tag: string; + sessionId: string; +} + +type Translate = (key: string, vars?: Record<string, string | number>) => string; + +const MAX_DETAIL = 180; +/** Session names can be pasted at any length; push payloads are capped near 4 KB. */ +const MAX_TITLE = 100; + +function clip(text: string, max = MAX_DETAIL): string { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; +} + +/** Title, body, click target, and replace-tag for one event in the caller's language. */ +export function renderNotification(event: NotificationEvent, t: Translate): RenderedNotification { + const detail = event.detail ? clip(event.detail) : ""; + let body: string; + switch (event.type) { + case "completed": + body = t("notifications.completedBody"); + break; + case "input": + body = detail ? t("notifications.inputBody", { detail }) : t("notifications.inputBodyNoDetail"); + break; + case "error": + body = detail ? t("notifications.errorBody", { detail }) : t("notifications.errorBodyNoDetail"); + break; + case "modelSwitch": + if (event.from && event.to) { + body = detail + ? t("notifications.modelSwitchBody", { from: event.from, to: event.to, detail }) + : t("notifications.modelSwitchBodyNoDetail", { from: event.from, to: event.to }); + } else { + body = detail; + } + break; + case "test": + body = t("notifications.testBody"); + break; + } + return { + title: (event.sessionName && clip(event.sessionName, MAX_TITLE)) || t("notifications.untitledSession"), + body, + url: event.sessionId ? `/?session=${encodeURIComponent(event.sessionId)}` : "/", + tag: `${event.sessionId}:${event.type}`, + sessionId: event.sessionId, + }; +} + +const MODEL_SWITCH_NOTICE_SOURCES = new Set(["prewalk", "plan-yolo"]); + +/** Extension UI methods that block the agent until the user answers (rpc-manager replays these too). */ +export const BLOCKING_UI_METHODS = new Set(["select", "confirm", "input", "editor", "ask", "open_url"]); + +const NOTIFICATION_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/; + +/** Client and device ids: generated by the browser, checked by the server. */ +export function isValidNotificationId(value: unknown): value is string { + return typeof value === "string" && NOTIFICATION_ID_PATTERN.test(value); +} + +const str = (value: unknown): string | undefined => (typeof value === "string" && value.trim() ? value : undefined); + +type Detect = (frame: Record<string, unknown>, session: { sessionId: string; sessionName: string | null }) => NotificationEvent[]; + +/** + * Per-session detector: feed it every top-level rpc-ui frame in order. Subagent + * activity arrives wrapped in `subagent_*` frames and is never inspected. + * + * "completed" fires on `session_settled` (the run and any background work it + * started are done), unless the last prompt in that stretch failed or was + * aborted. Errors fire on the failing `prompt_result` itself. + * + * omp announces a retry fallback (`retry_fallback_applied`) before the + * `auto_retry_start` carrying the error that caused it, so a fallback without + * its own `reason` waits for that frame and takes its error as the reason. + */ +export function createNotificationDetector(): Detect { + let lastOutcome: string | null = null; + let heldFallback: NotificationEvent | null = null; + /** omp-web already reported this run's failure (from a failed `response`); its `prompt_result` must not repeat it. */ + let failureReported = false; + + const releaseFallback = (detail?: string): NotificationEvent[] => { + const held = heldFallback; + heldFallback = null; + return held ? [{ ...held, detail }] : []; + }; + + return (frame, { sessionId, sessionName }) => { + const base = { sessionId, sessionName }; + switch (frame.type) { + case "agent_start": + failureReported = false; + return []; + // omp-web's own frames: an async prompt failure (`response` with + // success: false) and the omp child exiting mid-run. + case "prompt_error": + case "process_exit": { + const events = releaseFallback(); + lastOutcome = "error"; + if (failureReported) return events; + failureReported = true; + return [...events, { ...base, type: "error", detail: str(frame.errorMessage) ?? str(frame.detail) }]; + } + case "auto_retry_start": + return releaseFallback(str(frame.errorMessage)); + case "retry_fallback_applied": { + const from = str(frame.from); + const to = str(frame.to); + if (!from || !to) return []; + const event: NotificationEvent = { ...base, type: "modelSwitch", from, to }; + const reason = str(frame.reason); + if (reason) return [...releaseFallback(), { ...event, detail: reason }]; + const earlier = releaseFallback(); + heldFallback = event; + return earlier; + } + case "notice": { + const source = str(frame.source); + const message = str(frame.message); + if (!source || !message || !MODEL_SWITCH_NOTICE_SOURCES.has(source)) return []; + return [{ ...base, type: "modelSwitch", detail: message }]; + } + case "prompt_result": { + const events = releaseFallback(); + if (frame.agentInvoked !== true) return events; + lastOutcome = str(frame.status) ?? null; + if (lastOutcome !== "error" || failureReported) return events; + failureReported = true; + return [...events, { ...base, type: "error", detail: isRecord(frame.error) ? str(frame.error.message) : undefined }]; + } + case "auto_retry_end": + return releaseFallback(); + case "session_settled": { + const events = releaseFallback(); + const outcome = lastOutcome; + lastOutcome = null; + failureReported = false; + if (outcome === "error" || outcome === "aborted") return events; + return [...events, { ...base, type: "completed" }]; + } + case "extension_ui_request": { + // Only reached for requests that wait on the user (omp-web answers + // auto-approved ones before they are emitted). + if (typeof frame.method !== "string" || !BLOCKING_UI_METHODS.has(frame.method)) return []; + const firstQuestion = Array.isArray(frame.questions) && isRecord(frame.questions[0]) ? str(frame.questions[0].question) : undefined; + return [{ ...base, type: "input", detail: str(frame.title) ?? firstQuestion ?? str(frame.message) ?? str(frame.instructions) }]; + } + default: + return []; + } + }; +} diff --git a/lib/notification-hub.test.mjs b/lib/notification-hub.test.mjs new file mode 100644 index 000000000..e6f471ae7 --- /dev/null +++ b/lib/notification-hub.test.mjs @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { tsconfigPaths: true }); +const hub = await jiti.import("./notification-hub.ts"); +const { DEFAULT_NOTIFICATION_PREFS } = await jiti.import("./notification-events.ts"); +const webpush = (await jiti.import("web-push")).default; + +const event = { type: "completed", sessionId: "s1", sessionName: "Session" }; +const prefs = (patch = {}) => ({ ...DEFAULT_NOTIFICATION_PREFS, enabled: true, ...patch }); +const tab = (clientId, deviceId, patch = {}) => ({ clientId, deviceId, visible: false, sessionId: null, lastSeen: 0, ...patch }); +const device = (deviceId, patch = {}) => ({ deviceId, prefs: prefs(), updatedAt: 0, ...patch }); +const subscription = { endpoint: "https://push.example/abc", keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) } }; +const all = (...ids) => new Set(ids); + +test("nothing is sent anywhere while a visible tab shows the session, even while its stream reconnects", () => { + const presence = [tab("viewer", "desk", { visible: true, sessionId: "s1" }), tab("phone-tab", "phone")]; + const devices = [device("desk"), device("phone", { prefs: prefs({ whenActive: "system" }), subscription })]; + assert.deepEqual(hub.routeNotification(event, presence, all("phone-tab"), devices), []); +}); + +test("active in another session: toast on the active tab only, toast-mode devices elsewhere stay quiet", () => { + const presence = [tab("desk-active", "desk", { visible: true, sessionId: "other" }), tab("desk-hidden", "desk"), tab("laptop-tab", "laptop")]; + const devices = [device("desk"), device("laptop")]; + assert.deepEqual(hub.routeNotification(event, presence, all("desk-active", "desk-hidden", "laptop-tab"), devices), [{ kind: "toast", clientId: "desk-active" }]); +}); + +test("a visible tab that cannot receive toasts (stream closed) does not silence other devices", () => { + const presence = [tab("settings-open", "desk", { visible: true })]; + const devices = [device("desk"), device("phone", { subscription })]; + assert.deepEqual(hub.routeNotification(event, presence, all(), devices), [{ kind: "push", deviceId: "phone" }]); +}); + +test("'always send a system notification' devices still get one while the user is active elsewhere", () => { + const presence = [tab("desk-active", "desk", { visible: true, sessionId: "other" })]; + const devices = [device("desk", { prefs: prefs({ whenActive: "system" }) }), device("phone", { prefs: prefs({ whenActive: "system" }), subscription })]; + assert.deepEqual(hub.routeNotification(event, presence, all("desk-active"), devices), [ + { kind: "os", clientId: "desk-active" }, + { kind: "push", deviceId: "phone" }, + ]); +}); + +test("nobody active: push to subscribed devices, in-page to reachable tabs of unsubscribed ones", () => { + const presence = [tab("desk-tab", "desk"), tab("phone-tab", "phone"), tab("beacon-only", "laptop")]; + const devices = [device("desk"), device("phone", { subscription }), device("laptop")]; + assert.deepEqual(hub.routeNotification(event, presence, all("desk-tab", "phone-tab"), devices), [ + { kind: "os", clientId: "desk-tab" }, + { kind: "push", deviceId: "phone" }, + ]); +}); + +test("a tab still showing a moved session's old id counts as viewing it", () => { + const presence = [tab("viewer", "desk", { visible: true, sessionId: "old-id" })]; + const moved = { ...event, sessionId: "new-id", aliases: ["old-id"] }; + assert.deepEqual(hub.routeNotification(moved, presence, all("viewer"), [device("desk", { subscription })]), []); +}); + +test("disabled devices and disabled types get nothing", () => { + const presence = [tab("tab", "desk")]; + assert.deepEqual(hub.routeNotification(event, presence, all("tab"), [device("desk", { prefs: prefs({ enabled: false }) })]), []); + assert.deepEqual(hub.routeNotification(event, presence, all("tab"), [device("desk", { prefs: prefs({ types: { ...DEFAULT_NOTIFICATION_PREFS.types, completed: false } }) })]), []); +}); + +test("push subscriptions must use an HTTPS endpoint and real key sizes", () => { + assert.equal(hub.parsePushSubscription({ ...subscription, endpoint: "http://127.0.0.1:8080/x" }), null); + assert.equal(hub.parsePushSubscription({ ...subscription, keys: { p256dh: "not-a-real-key", auth: "x" } }), null); + assert.deepEqual(hub.parsePushSubscription(subscription), subscription); +}); + +function isolatedStore(t) { + const dir = mkdtempSync(join(tmpdir(), "omp-notify-")); + const previousDir = process.env.PI_CODING_AGENT_DIR; + const previousSend = webpush.sendNotification; + const previousHub = globalThis.__ompNotificationHub; + process.env.PI_CODING_AGENT_DIR = dir; + globalThis.__ompNotificationHub = undefined; + t.after(() => { + webpush.sendNotification = previousSend; + globalThis.__ompNotificationHub = previousHub; + if (previousDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousDir; + rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +test("another omp-web instance sharing the agent dir sees the same keys and devices; the store is owner-only", (t) => { + const dir = isolatedStore(t); + const file = join(dir, "omp-web", "notifications.json"); + hub.saveNotificationDevice("phone-device", prefs({ locale: "ja" }), subscription); + const publicKey = hub.getVapidPublicKey(); + // Windows has no POSIX permission bits to check. + if (process.platform !== "win32") assert.equal(statSync(file).mode & 0o077, 0); + + // The other instance registers a device by rewriting the shared file. + const shared = JSON.parse(readFileSync(file, "utf8")); + shared.devices.push({ deviceId: "laptop-device", prefs: prefs(), updatedAt: 1 }); + writeFileSync(file, JSON.stringify(shared)); + + assert.equal(hub.getVapidPublicKey(), publicKey); + assert.equal(hub.getNotificationDevice("laptop-device").prefs.enabled, true); + hub.saveNotificationDevice("phone-device", prefs({ locale: "en" })); + assert.ok(hub.getNotificationDevice("laptop-device"), "saving one device keeps the other instance's devices"); + assert.deepEqual(hub.getNotificationDevice("phone-device").subscription, subscription); +}); + +test("a tab stays reachable when a stale stream of the same tab attaches late and then closes", (t) => { + isolatedStore(t); + hub.saveNotificationDevice("desk-0001", prefs()); + hub.reportPresence({ clientId: "tab-0001", deviceId: "desk-0001", visible: false, sessionId: null, seq: 1 }); + + // React strict mode / reconnects: the cancelled request reaches the server + // after the live one, then its abort fires. + const received = []; + const detachLive = hub.attachNotificationClient("tab-0001", () => received.push("live")); + const detachStale = hub.attachNotificationClient("tab-0001", () => received.push("stale")); + detachStale(); + hub.publishNotification(event); + assert.deepEqual(received, ["live"]); + + detachLive(); + hub.publishNotification(event); + assert.deepEqual(received, ["live"]); +}); + +test("a presence report that arrives after a newer one from the same tab is ignored", (t) => { + isolatedStore(t); + hub.saveNotificationDevice("desk-0001", prefs({ whenActive: "system" })); + const received = []; + hub.attachNotificationClient("tab-0001", () => received.push("tab")); + // The tab moved from s1 to s2, but the s1 report lands last. + hub.reportPresence({ clientId: "tab-0001", deviceId: "desk-0001", visible: true, sessionId: "s2", seq: 2 }); + hub.reportPresence({ clientId: "tab-0001", deviceId: "desk-0001", visible: true, sessionId: "s1", seq: 1 }); + hub.publishNotification(event); + assert.deepEqual(received, ["tab"], "s1 is no longer on screen, so its notification goes out"); +}); + +test("pushes are written in the device's language with urgency by type", async (t) => { + isolatedStore(t); + const sent = []; + webpush.sendNotification = async (_subscription, payload, options) => { + sent.push({ payload: JSON.parse(payload), urgency: options.urgency, ttl: options.TTL }); + }; + hub.saveNotificationDevice("phone-device", prefs({ locale: "ja" }), subscription); + hub.publishNotification(event); + hub.publishNotification({ type: "input", sessionId: "s2", sessionName: "Deploy", detail: "Ship it?" }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(sent.map(({ payload, urgency, ttl }) => [payload.body, payload.url, urgency, ttl]), [ + ["タスクが完了しました。", "/?session=s1", "normal", 3600], + ["回答を待っています: Ship it?", "/?session=s2", "high", 3600], + ]); +}); + +for (const status of [404, 410]) { + test(`a push service ${status} removes the subscription but keeps the device prefs`, async (t) => { + isolatedStore(t); + webpush.sendNotification = async () => { + throw new webpush.WebPushError("Gone", status, {}, "", subscription.endpoint); + }; + hub.saveNotificationDevice("phone-device", prefs(), subscription); + assert.deepEqual(await hub.sendTestPush("phone-device"), { ok: false, error: `push service answered ${status}` }); + + const stored = hub.getNotificationDevice("phone-device"); + assert.equal(stored.subscription, undefined); + assert.equal(stored.prefs.enabled, true); + }); +} diff --git a/lib/notification-hub.ts b/lib/notification-hub.ts new file mode 100644 index 000000000..6f1f2bd3a --- /dev/null +++ b/lib/notification-hub.ts @@ -0,0 +1,307 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; +import { dirname, resolve } from "path"; +import webpush, { type PushSubscription } from "web-push"; +import en from "./i18n/locales/en.json"; +import ja from "./i18n/locales/ja.json"; +import zhCN from "./i18n/locales/zh-CN.json"; +import { getAgentDir } from "./omp/paths"; +import { + isValidNotificationId, + parseNotificationPrefs, + renderNotification, + type NotificationEvent, + type NotificationPrefs, + type RenderedNotification, +} from "./notification-events"; +import { isRecord } from "./type-guards"; + +/** + * Server side of notifications: presence of open tabs, per-device preferences + * and Web Push subscriptions, and routing each event to an in-app toast, an + * in-page system notification, or a push. + */ + +/** A tab counts as present this long after its last presence report. */ +export const PRESENCE_TTL_MS = 60_000; +const MAX_DEVICES = 50; +const PUSH_TTL_SECONDS = 3600; +const PUSH_TIMEOUT_MS = 10_000; +// Apple's push service rejects VAPID subjects it cannot resolve (e.g. localhost mail). +const VAPID_SUBJECT = "https://github.com/kahme247/ompweb"; + +/** What a tab last reported. Written only by presence reports, pruned by TTL. */ +export interface NotificationPresence { + clientId: string; + deviceId: string; + visible: boolean; + sessionId: string | null; + /** Per-tab report counter: requests can arrive out of order, and only the newest counts. */ + seq: number; + lastSeen: number; +} + +export interface NotificationDevice { + deviceId: string; + prefs: NotificationPrefs; + subscription?: PushSubscription; + updatedAt: number; +} + +export type NotificationMessage = { kind: "toast" | "os"; event: NotificationEvent }; + +type Send = (message: NotificationMessage) => void; + +export type Delivery = + | { kind: "toast" | "os"; clientId: string } + | { kind: "push"; deviceId: string }; + +interface StoreFile { + vapid?: { publicKey: string; privateKey: string }; + devices: NotificationDevice[]; +} + +interface HubState { + presence: Map<string, NotificationPresence>; + /** Open SSE streams per tab. Independent of presence: streams reconnect, overlap, and close while the tab stays put. */ + streams: Map<string, Set<Send>>; +} + +declare global { + var __ompNotificationHub: HubState | undefined; +} + +function hub(): HubState { + return (globalThis.__ompNotificationHub ??= { presence: new Map(), streams: new Map() }); +} + +// p256dh is an uncompressed P-256 point (65 bytes), auth a 16-byte secret; both base64url. +const P256DH_PATTERN = /^[A-Za-z0-9_-]{86,88}={0,2}$/; +const AUTH_PATTERN = /^[A-Za-z0-9_-]{21,24}={0,2}$/; + +/** Validate an untrusted PushSubscription JSON; only HTTPS push endpoints are accepted. */ +export function parsePushSubscription(value: unknown): PushSubscription | null { + if (!isRecord(value) || !isRecord(value.keys)) return null; + const { endpoint } = value; + const { p256dh, auth } = value.keys; + if (typeof endpoint !== "string" || endpoint.length > 2048) return null; + if (typeof p256dh !== "string" || !P256DH_PATTERN.test(p256dh) || typeof auth !== "string" || !AUTH_PATTERN.test(auth)) return null; + try { + if (new URL(endpoint).protocol !== "https:") return null; + } catch { + return null; + } + return { endpoint, keys: { p256dh, auth } }; +} + +// ---------------------------------------------------------------- store + +function storePath(): string { + return resolve(getAgentDir(), "omp-web", "notifications.json"); +} + +/** + * Read from disk on every use, never cached: an installed server and a dev + * server can share the agent directory, and each must see the other's devices + * and keys. ponytail: read-modify-write without a lock; two instances saving in + * the same millisecond can lose one update (the tab re-syncs on its next load). + */ +function loadStore(): StoreFile { + let store: StoreFile = { devices: [] }; + try { + if (existsSync(storePath())) { + const parsed: unknown = JSON.parse(readFileSync(storePath(), "utf8")); + if (isRecord(parsed)) { + const { vapid } = parsed; + store = { + vapid: isRecord(vapid) && typeof vapid.publicKey === "string" && typeof vapid.privateKey === "string" ? { publicKey: vapid.publicKey, privateKey: vapid.privateKey } : undefined, + devices: (Array.isArray(parsed.devices) ? parsed.devices : []).flatMap((device: unknown) => { + if (!isRecord(device) || !isValidNotificationId(device.deviceId)) return []; + const subscription = parsePushSubscription(device.subscription) ?? undefined; + return [{ deviceId: device.deviceId, prefs: parseNotificationPrefs(device.prefs), subscription, updatedAt: Number(device.updatedAt) || 0 }]; + }), + }; + } + } + } catch { + // A corrupt file only loses device prefs; tabs re-register on load. + } + return store; +} + +/** Atomic write (temp file + rename), owner-only: the file holds the VAPID private key. */ +function saveStore(store: StoreFile): void { + const path = storePath(); + mkdirSync(dirname(path), { recursive: true }); + const temp = `${path}.tmp-${process.pid}-${Date.now()}`; + try { + writeFileSync(temp, `${JSON.stringify(store, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + renameSync(temp, path); + } finally { + try { + if (existsSync(temp)) rmSync(temp); + } catch { + // ignore cleanup failures + } + } +} + +function vapidKeys(): { publicKey: string; privateKey: string } { + const store = loadStore(); + if (!store.vapid) { + store.vapid = webpush.generateVAPIDKeys(); + saveStore(store); + } + return store.vapid; +} + +export function getVapidPublicKey(): string { + return vapidKeys().publicKey; +} + +export function getNotificationDevice(deviceId: string): NotificationDevice | undefined { + return loadStore().devices.find((device) => device.deviceId === deviceId); +} + +/** Store a device's prefs, and its push subscription when given (otherwise the stored one is kept). */ +export function saveNotificationDevice(deviceId: string, prefs: NotificationPrefs, subscription?: PushSubscription): NotificationDevice { + const store = loadStore(); + const existing = store.devices.find((device) => device.deviceId === deviceId); + const device: NotificationDevice = { deviceId, prefs, subscription: subscription ?? existing?.subscription, updatedAt: Date.now() }; + // A push endpoint belongs to one browser; drop it from any other device id. + const others = store.devices.filter((entry) => entry.deviceId !== deviceId && !(device.subscription && entry.subscription?.endpoint === device.subscription.endpoint)); + // Over the cap, forget devices that would never be notified before ones that would. + const useful = (entry: NotificationDevice) => (entry.prefs.enabled || entry.subscription ? 1 : 0); + store.devices = [device, ...others].sort((a, b) => useful(b) - useful(a) || b.updatedAt - a.updatedAt).slice(0, MAX_DEVICES); + saveStore(store); + return device; +} + +function dropSubscription(deviceId: string, endpoint: string): void { + const store = loadStore(); + const device = store.devices.find((entry) => entry.deviceId === deviceId); + if (!device?.subscription || device.subscription.endpoint !== endpoint) return; + device.subscription = undefined; + saveStore(store); +} + +// ---------------------------------------------------------------- presence and streams + +function livePresence(now: number): NotificationPresence[] { + const { presence } = hub(); + for (const [id, entry] of presence) { + if (now - entry.lastSeen > PRESENCE_TTL_MS) presence.delete(id); + } + return [...presence.values()]; +} + +export function reportPresence(report: Omit<NotificationPresence, "lastSeen">): void { + const { presence } = hub(); + const current = presence.get(report.clientId); + if (current && current.seq > report.seq) return; + presence.set(report.clientId, { ...report, lastSeen: Date.now() }); +} + +/** Attach a tab's SSE stream. Returns the detach callback. */ +export function attachNotificationClient(clientId: string, send: Send): () => void { + const { streams } = hub(); + const own = streams.get(clientId) ?? new Set(); + own.add(send); + streams.set(clientId, own); + return () => { + own.delete(send); + if (own.size === 0 && streams.get(clientId) === own) streams.delete(clientId); + }; +} + +// ---------------------------------------------------------------- routing + +/** + * Decide where one event goes. `reachable` holds the tabs with an open stream. + * 1. A visible tab is showing the session: nothing, for every device. + * 2. The user is active in some reachable tab and the device prefers toasts: + * toast to that device's active tabs only (other devices stay quiet). + * 3. Otherwise a system notification: push when the device has a + * subscription, else in-page on each of its reachable tabs. + */ +export function routeNotification(event: NotificationEvent, presence: NotificationPresence[], reachable: ReadonlySet<string>, devices: NotificationDevice[]): Delivery[] { + if (event.type === "test") return []; + const viewedAs = new Set([event.sessionId, ...(event.aliases ?? [])]); + if (presence.some((tab) => tab.visible && tab.sessionId !== null && viewedAs.has(tab.sessionId))) return []; + const active = presence.filter((tab) => tab.visible && reachable.has(tab.clientId)); + const deliveries: Delivery[] = []; + for (const device of devices) { + if (!device.prefs.enabled || !device.prefs.types[event.type]) continue; + if (active.length > 0 && device.prefs.whenActive === "toast") { + for (const tab of active) if (tab.deviceId === device.deviceId) deliveries.push({ kind: "toast", clientId: tab.clientId }); + } else if (device.subscription) { + deliveries.push({ kind: "push", deviceId: device.deviceId }); + } else { + for (const tab of presence) if (tab.deviceId === device.deviceId && reachable.has(tab.clientId)) deliveries.push({ kind: "os", clientId: tab.clientId }); + } + } + return deliveries; +} + +const dictionaries: Record<string, Record<string, string>> = { en, ja, "zh-CN": zhCN }; + +function renderFor(event: NotificationEvent, locale: string): RenderedNotification { + const dictionary = Object.hasOwn(dictionaries, locale) ? dictionaries[locale] : dictionaries.en; + return renderNotification(event, (key, vars) => { + const template = dictionary[key] ?? dictionaries.en[key] ?? key; + return vars ? template.replace(/\{(\w+)\}/g, (match, name: string) => (name in vars ? String(vars[name]) : match)) : template; + }); +} + +/** Never throws: a failed push must not break delivery to anyone else. */ +async function sendPush(device: NotificationDevice, event: NotificationEvent): Promise<{ ok: true } | { ok: false; error: string }> { + const subscription = device.subscription; + if (!subscription) return { ok: false, error: "no push subscription" }; + try { + const { publicKey, privateKey } = vapidKeys(); + await webpush.sendNotification(subscription, JSON.stringify(renderFor(event, device.prefs.locale)), { + TTL: PUSH_TTL_SECONDS, + urgency: event.type === "input" || event.type === "error" ? "high" : "normal", + timeout: PUSH_TIMEOUT_MS, + vapidDetails: { subject: VAPID_SUBJECT, publicKey, privateKey }, + }); + return { ok: true }; + } catch (error) { + const status = error instanceof webpush.WebPushError ? error.statusCode : undefined; + try { + // 404/410: the browser unsubscribed or the subscription expired. + if (status === 404 || status === 410) dropSubscription(device.deviceId, subscription.endpoint); + } catch { + // A store write failure only delays the cleanup to the next push. + } + const message = status ? `push service answered ${status}` : String(error); + console.warn("[notifications] push failed:", message); + return { ok: false, error: message }; + } +} + +export function publishNotification(event: NotificationEvent): void { + const state = hub(); + const devices = loadStore().devices; + const reachable = new Set(state.streams.keys()); + for (const delivery of routeNotification(event, livePresence(Date.now()), reachable, devices)) { + if (delivery.kind === "push") { + const device = devices.find((entry) => entry.deviceId === delivery.deviceId); + if (device) void sendPush(device, event); + continue; + } + for (const send of state.streams.get(delivery.clientId) ?? []) { + try { + send({ kind: delivery.kind, event }); + } catch { + // A closing stream must not break delivery to the others. + } + } + } +} + +/** Settings "Send test notification" for a device with push: reports whether the push service accepted it. */ +export async function sendTestPush(deviceId: string): Promise<{ ok: true } | { ok: false; error: string }> { + const device = getNotificationDevice(deviceId); + if (!device) return { ok: false, error: "unknown device" }; + return sendPush(device, { type: "test", sessionId: "", sessionName: "omp web" }); +} diff --git a/lib/rpc-manager.test.mjs b/lib/rpc-manager.test.mjs index 88b64dd09..c4be13aa1 100644 --- a/lib/rpc-manager.test.mjs +++ b/lib/rpc-manager.test.mjs @@ -616,6 +616,113 @@ test("a resumed session crash is retained before its initial handshake", async ( await wrapper.destroyAndWait(); }); +/** A wrapper whose omp frames the test drives, with one reachable tab collecting notifications. */ +async function notifyingSession(t) { + const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const previousHub = globalThis.__ompNotificationHub; + const previousExited = globalThis.__ompExitedSessions; + const previousDir = process.env.PI_CODING_AGENT_DIR; + const dir = mkdtempSync(join(tmpdir(), "omp-notify-rpc-")); + process.env.PI_CODING_AGENT_DIR = dir; + mkdirSync(join(dir, "omp-web")); + writeFileSync(join(dir, "omp-web", "notifications.json"), JSON.stringify({ + devices: [{ + deviceId: "desk-0001", + prefs: { enabled: true, types: { completed: true, input: true, error: true, modelSwitch: true }, whenActive: "toast", locale: "en" }, + updatedAt: 0, + }], + })); + globalThis.__ompExitedSessions = new Map(); + const delivered = []; + globalThis.__ompNotificationHub = { + presence: new Map([["tab-0001", { clientId: "tab-0001", deviceId: "desk-0001", visible: false, sessionId: null, lastSeen: Date.now() }]]), + streams: new Map([["tab-0001", new Set([(message) => delivered.push([message.event.type, message.event.detail])])]]), + }; + let feed; + /** What omp's get_state answers; tests change it to simulate session moves and switches. */ + const ompState = { sessionId: "notify-session", sessionFile: "/tmp/notify-session.jsonl", isStreaming: false, isCompacting: false }; + const fakeProc = { + isAlive: true, + waitReady: async () => ({ type: "ready", protocolVersion: 2 }), + negotiateProtocol: async () => {}, + onFrame: (handler) => { feed = handler; return () => {}; }, + sendCommand: async (command) => command.type === "get_state" ? { ...ompState } : {}, + sendFrame: () => {}, + dispose: async () => {}, + }; + const wrapper = new SnapshotWrapper(fakeProc, process.cwd()); + wrapper.start(); + await wrapper.waitUntilReady(); + t.after(async () => { + await wrapper.destroyAndWait(); + globalThis.__ompNotificationHub = previousHub; + globalThis.__ompExitedSessions = previousExited; + if (previousDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousDir; + rmSync(dir, { recursive: true, force: true }); + }); + return { wrapper, feed, delivered, ompState }; +} + +test("after a moved session switches to another conversation, a tab left on the old id no longer silences it", async (t) => { + const { wrapper, feed, delivered, ompState } = await notifyingSession(t); + // omp moves the conversation to a new file (session-persistence notice). + Object.assign(ompState, { sessionId: "moved-session", sessionFile: "/tmp/moved-session.jsonl" }); + feed({ type: "notice", level: "warning", source: "session-persistence", message: "moved" }); + await new Promise((resolve) => setImmediate(resolve)); + // Then the user switches the wrapper to an unrelated session. + Object.assign(ompState, { sessionId: "other-session", sessionFile: "/tmp/other-session.jsonl" }); + await wrapper.refreshIdentityAfterSessionChange(); + + globalThis.__ompNotificationHub.presence.set("tab-0001", { + clientId: "tab-0001", deviceId: "desk-0001", visible: true, sessionId: "notify-session", seq: 1, lastSeen: Date.now(), + }); + feed({ type: "agent_start" }); + feed({ type: "session_settled" }); + assert.deepEqual(delivered.filter(([type]) => type === "completed"), [["completed", undefined]]); +}); + +test("omp frames reach the user as notifications: settled run, pending question", async (t) => { + const { feed, delivered } = await notifyingSession(t); + feed({ type: "agent_start" }); + feed({ type: "agent_end", isTerminal: true, messages: [] }); + feed({ type: "prompt_result", agentInvoked: true, status: "completed", sessionSettled: true }); + assert.deepEqual(delivered, [], "completion waits for session_settled"); + feed({ type: "session_settled" }); + feed({ type: "extension_ui_request", id: "q1", method: "confirm", title: "Delete the branch?", message: "" }); + assert.deepEqual(delivered, [["completed", undefined], ["input", "Delete the branch?"]]); +}); + +test("a crash during a run notifies the user once", async (t) => { + const { wrapper, feed, delivered } = await notifyingSession(t); + feed({ type: "agent_start" }); + wrapper.handleProcessExit({ code: 1, signal: null, stderrTail: "out of memory" }); + assert.deepEqual(delivered, [["error", "out of memory"]]); +}); + +test("a crash after the turn ended but before background work settled still notifies", async (t) => { + const { wrapper, feed, delivered } = await notifyingSession(t); + feed({ type: "agent_start" }); + feed({ type: "agent_end", isTerminal: true, messages: [] }); + feed({ type: "prompt_result", agentInvoked: true, status: "completed", sessionSettled: false }); + wrapper.handleProcessExit({ code: 1, signal: null, stderrTail: "killed" }); + assert.deepEqual(delivered, [["error", "killed"]]); +}); + +test("an asynchronous prompt failure (failed response) notifies the user", async (t) => { + const { feed, delivered } = await notifyingSession(t); + feed({ type: "response", id: "p1", command: "prompt", success: false, error: "No API key for provider" }); + assert.deepEqual(delivered, [["error", "No API key for provider"]]); +}); + +test("a crash of an idle session does not notify", async (t) => { + const { wrapper, delivered } = await notifyingSession(t); + wrapper.handleProcessExit({ code: 1, signal: null, stderrTail: "out of memory" }); + assert.deepEqual(delivered, []); +}); + test("buildSessionSpawnArgs maps tool presets to spawn flags", async () => { const { createJiti } = await import("jiti"); const jiti = createJiti(import.meta.url); diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 9bc9a42f7..633526dfb 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -20,6 +20,8 @@ import { PRESET_FULL } from "./tool-presets"; import { comparableProjectPath } from "./comparable-path"; import { samePath } from "./paths"; import { isRecord } from "./type-guards"; +import { BLOCKING_UI_METHODS, createNotificationDetector } from "./notification-events"; +import { publishNotification } from "./notification-hub"; import { parseSkillDiagnosticsSnapshot, type SkillDiagnosticsSnapshot } from "./skill-diagnostics"; import { isReservedLaunchArg, loadProjectRegistry } from "./project-registry"; import type { @@ -108,10 +110,6 @@ export class WebRpcError extends Error { } } -// Extension UI methods that stay pending until the client answers (replayed to -// newly-attached SSE listeners so dialogs survive reconnects). -const PENDING_UI_METHODS = new Set(["select", "confirm", "input", "editor", "ask", "open_url"]); - /** Shape check for an ask-dialog `answers` payload; omp validates the content. */ function isAskAnswers(value: unknown): boolean { return Array.isArray(value) && value.every((answer: unknown) => @@ -329,6 +327,11 @@ export class AgentSessionWrapper { * the same conversation moving to a sibling file, not a session switch. */ private sessionMovePending = false; private unsubscribeFrames: (() => void) | null = null; + private readonly detectNotification = createNotificationDetector(); + /** Agent activity since the last `session_settled`: background work may still run after the turn ended. */ + private unsettled = false; + /** Ids this conversation had before omp moved it to a new file. */ + private readonly movedFromIds: string[] = []; private initPromise: Promise<void> | null = null; private restarting = false; private mcpListWaiter: { resolve: (text: string) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> } | null = null; @@ -552,6 +555,8 @@ export class AgentSessionWrapper { this.awaitingAgentStart = false; this.awaitingAgentStartDeadline = 0; this.continuationGraceUntil = 0; + // A different conversation now: the old one's ids must not suppress its notifications. + this.movedFromIds.length = 0; } this._sessionId = state.sessionId; this._sessionFile = state.sessionFile ?? ""; @@ -563,6 +568,8 @@ export class AgentSessionWrapper { if (moved) { this.sessionMovePending = false; if (oldId !== this._sessionId) { + // Tabs still showing the old id are still viewing this conversation. + this.movedFromIds.push(oldId); // Keep the old id routable: other tabs still address it, and a fresh // `--resume` of the old file would fork again (its owner lives on). this.onIdentityChangeCallback?.(oldId, this._sessionId, { keepOldId: true }); @@ -658,9 +665,22 @@ export class AgentSessionWrapper { // Terminal agent_end so a client mid-stream stops spinning immediately // instead of waiting for the reconcile poll. if (this.streaming || this.promptRunning) this.emit({ type: "agent_end", isTerminal: true, messages: [] }); + if (this.streaming || this.promptRunning || this.unsettled) { + this.publishNotifications({ type: "process_exit", detail: detail || status }); + } this.destroy(); } + private publishNotifications(frame: Record<string, unknown>): void { + const session = { + sessionId: this._sessionId, + sessionName: this._sessionName || this.cwd.split(/[\\/]/).filter(Boolean).pop() || null, + }; + for (const notification of this.detectNotification(frame, session)) { + publishNotification(this.movedFromIds.length ? { ...notification, aliases: this.movedFromIds } : notification); + } + } + private handleFrame(frame: RpcFrame): void { this.resetIdleTimer(); const event = frame; @@ -681,6 +701,7 @@ export class AgentSessionWrapper { break; } case "agent_start": + this.unsettled = true; this.promptRunning = true; this.streaming = true; this.awaitingAgentStart = false; @@ -713,6 +734,9 @@ export class AgentSessionWrapper { this.continuationGraceUntil = Date.now() + NON_TERMINAL_CONTINUATION_GRACE_MS; } break; + case "session_settled": + this.unsettled = false; + break; case "prompt_result": // Local-only prompt (builtin/extension slash command) — no agent run. this.promptRunning = false; @@ -767,7 +791,9 @@ export class AgentSessionWrapper { this.promptRunning = false; this.awaitingAgentStart = false; this.awaitingAgentStartDeadline = 0; - this.emit({ type: "prompt_error", errorMessage: detail, error: event.error, command: event.command }); + const promptError = { type: "prompt_error", errorMessage: detail, error: event.error, command: event.command }; + this.publishNotifications(promptError); + this.emit(promptError); notifyRunningChange(); return; } @@ -855,6 +881,7 @@ export class AgentSessionWrapper { } } + this.publishNotifications(event); this.emit(event); notifyRunningChange({ refreshSessionList }); } @@ -895,7 +922,9 @@ export class AgentSessionWrapper { this.proc.sendFrame({ type: "extension_ui_response", id, confirmed: true }); return true; } - if (PENDING_UI_METHODS.has(method)) { + // Blocking dialogs stay pending until the client answers (replayed to + // newly-attached SSE listeners so dialogs survive reconnects). + if (BLOCKING_UI_METHODS.has(method)) { this.forgetPendingUiRequest(id); const timeout = typeof event.timeout === "number" ? event.timeout : undefined; if (timeout && timeout > 0) { diff --git a/package-lock.json b/package-lock.json index 3d209ff19..b8b06e522 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "undici": "8.10.1", + "web-push": "^3.6.7", "yaml": "^2" }, "bin": { @@ -48,6 +49,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", + "@types/web-push": "^3.6.4", "eslint": "^9", "eslint-config-next": "16.3.4", "jsdom": "^29.1.1", @@ -3026,6 +3028,16 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", @@ -3592,6 +3604,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -3853,6 +3874,18 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -4028,6 +4061,12 @@ "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", "license": "MIT" }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -4088,6 +4127,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -5253,6 +5298,15 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.408", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.408.tgz", @@ -7050,6 +7104,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -7066,6 +7129,19 @@ "npm": ">=1.3.7" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -7936,6 +8012,27 @@ "setimmediate": "^1.0.5" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/katex": { "version": "0.16.47", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", @@ -9449,6 +9546,12 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -9469,7 +9572,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12397,6 +12499,25 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index 73f7c9307..5118a9ad1 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "undici": "8.10.1", + "web-push": "^3.6.7", "yaml": "^2", "jiti": "^2.7.0" }, @@ -91,6 +92,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", + "@types/web-push": "^3.6.4", "eslint": "^9", "eslint-config-next": "16.3.4", "jsdom": "^29.1.1", diff --git a/proxy.ts b/proxy.ts index 6f339353a..499da6b05 100644 --- a/proxy.ts +++ b/proxy.ts @@ -22,11 +22,16 @@ export function proxy(request: NextRequest) { if (pathname.startsWith("/api/")) { return NextResponse.json({ error: "Password required", code: "password_required" }, { status: 401 }); } - return NextResponse.redirect(new URL("/login", request.url)); + const login = new URL("/login", request.url); + // Keep a deep link to a session (e.g. a notification click) across sign-in. + if (pathname === "/" && request.nextUrl.search) login.searchParams.set("next", `/${request.nextUrl.search}`); + return NextResponse.redirect(login); } // The sign-in screen still needs its Next.js JavaScript and CSS before a // session exists; these and the ordinary browser icons are public assets, // not workspace data. /api/manifest stays authenticated: its link includes // credentials and its embedded installation icons need no separate request. -export const config = { matcher: "/((?!_next/static|_next/image|favicon\\.ico|icon\\.svg|icon\\.png|icon-192\\.png).*)" }; +// The service worker script holds no data either, and the browser re-fetches +// it for update checks even after the sign-in cookie expired. +export const config = { matcher: "/((?!_next/static|_next/image|favicon\\.ico|icon\\.svg|icon\\.png|icon-192\\.png|badge-96\\.png|sw\\.js).*)" }; diff --git a/public/badge-96.png b/public/badge-96.png new file mode 100644 index 000000000..527ba5193 Binary files /dev/null and b/public/badge-96.png differ diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 000000000..c79c988b1 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,52 @@ +// omp-web service worker: shows notifications and opens their session on click. +// It does not cache anything; the app stays online-only. + +self.addEventListener("install", () => self.skipWaiting()); +self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); + +self.addEventListener("push", (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + // Unreadable payload: still show something (iOS revokes silent pushes). + } + event.waitUntil( + self.registration.showNotification(data.title || "omp web", { + body: data.body || "", + icon: "/icon-192.png", + badge: "/badge-96.png", + tag: data.tag || undefined, + renotify: Boolean(data.tag), + data: { url: data.url || "/", sessionId: data.sessionId || "" }, + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const { url = "/", sessionId = "" } = event.notification.data || {}; + event.waitUntil( + (async () => { + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + const client = windows.find((candidate) => candidate.focused) || windows.find((candidate) => candidate.visibilityState === "visible") || windows[0]; + if (client) { + try { + await client.focus(); + // The app selects the session in place, keeping its live state. Any + // other page (the sign-in screen after the cookie expired) loads the + // session URL, which the sign-in screen carries through as `next`. + if (new URL(client.url).pathname === "/") { + if (sessionId) client.postMessage({ type: "omp-open-session", sessionId }); + } else { + await client.navigate(url); + } + return; + } catch { + // focus() or navigate() can be refused; open a window instead. + } + } + await self.clients.openWindow(url); + })(), + ); +});