From 17ade3a88c14fa47f91c5d385ff3bb633dddf8db Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 11:09:04 +0000 Subject: [PATCH 01/18] docs: notifications design spec --- docs/specs/2026-10-06-notifications-design.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/specs/2026-10-06-notifications-design.md 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..c88ab6645 --- /dev/null +++ b/docs/specs/2026-10-06-notifications-design.md @@ -0,0 +1,255 @@ +# Notifications: Web Push, in-page fallback, and settings + +Status: approved design, 2026-10-06. + +## 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 new +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) | Title | Body | +|---|---|---|---| +| `completed` | `prompt_result` with `status: "completed"` and `agentInvoked: true` | session name | "Task finished" | +| `input` | new `extension_ui_request` whose `method` is in `PENDING_UI_METHODS` (`select`, `confirm`, `input`, `editor`, `ask`, `open_url`) and that omp-web does not auto-answer | session name | "Waiting for your answer: " | +| `error` | `prompt_result` with `status: "error"`, or omp child exit while the session was running | session name | "Run failed: <error.message>" | +| `modelSwitch` | `retry_fallback_applied {from, to, reason?}`, or `notice` with `source` `prewalk` or `plan-yolo` | session name | "Switched <from> → <to>: <reason>" | + +Rules: + +- Subagent activity never notifies. Subagent frames arrive as + `subagent_lifecycle` / `subagent_progress` / `subagent_event`; the detector + only inspects top-level frame types and never recurses into + `subagent_event.payload`. +- `prompt_result` with `status: "aborted"` never notifies. +- `auto_retry_start` and `auto_retry_end {success: true}` never notify. +- The `modelSwitch` reason comes from `retry_fallback_applied.reason` when + present (usage-aware fallback). Otherwise it is the `errorMessage` of the + most recent `auto_retry_start` in the same session, which covers classifier + refusals (for example Fable refusing and falling back to Opus) and + rate-limit or provider failures. With neither, the body omits the reason. +- A notification closes itself when it no longer applies only where cheap: + an `input` notification uses tag `<sessionId>:input`, and the in-app toast + for it is dismissed when the request is answered or cancelled. OS + notifications are not retracted. + +## Architecture + +``` +omp child ──frames──▶ AgentSessionWrapper.handleFrame (lib/rpc-manager.ts) + │ + ▼ + lib/notifications/detect.ts (frame → NotificationEvent | null) + │ + ▼ + lib/notifications/hub.ts (presence-aware routing) + │ │ │ + in-app toast SSE in-page OS SSE Web Push (web-push) + └──────┬───────┘ │ + ▼ ▼ + /api/notifications/events (per client) public/sw.js +``` + +Detection and routing run on the server, so sessions that are not open in any +tab still notify. + +### `NotificationEvent` + +```ts +interface NotificationEvent { + type: "completed" | "input" | "error" | "modelSwitch"; + sessionId: string; + title: string; + body: string; + url: string; // "/?session=<encoded id>" + tag: string; // "<sessionId>:<type>" +} +``` + +### Detection (`lib/notifications/detect.ts`) + +A pure function `detectNotification(frame, state)` returns a +`NotificationEvent` or `null`. `state` holds per-session memory the detector +needs (last `auto_retry_start.errorMessage`, session display name). The +wrapper calls it from `handleFrame` after its own state updates, and also from +`handleProcessExit` when a running session's child dies. Auto-answered tool +approval requests (`tools.approval.extension === "allow"`) do not produce an +`input` event. + +### Presence (`lib/notifications/presence.ts`) + +Each tab generates a `clientId` (stored in `sessionStorage`) and keeps one +`EventSource` to `/api/notifications/events?clientId=<id>`. It reports +presence with `POST /api/notifications/presence`: + +```ts +{ clientId: string; visible: boolean; focused: boolean; sessionId: string | null } +``` + +The tab sends it on `visibilitychange`, `focus`, `blur`, session switch, and +every 30 seconds while visible. The server drops an entry 60 seconds after the +last report and also when the client's SSE stream closes. "Active" means +`visible === true`. + +### Routing (`lib/notifications/hub.ts`) + +For each event, per device preferences decide whether the type is enabled at +all; then: + +1. If any active client is viewing `event.sessionId`, drop the event. This + rule holds in every mode. +2. If any client is active and that client's device has + `whenActive: "toast"`, send `{kind: "toast", event}` over that client's SSE + stream. Only active clients receive toasts. +3. Otherwise deliver an OS notification: + - to every push subscription whose preferences enable the type, via Web + Push; + - over SSE as `{kind: "os", event}` to every connected client of a device + with no push subscription. The client shows it with + `registration.showNotification`. + +With `whenActive: "system"`, rule 2 is skipped for that device and it +receives the OS notification even while the user is active in another tab. +Rule 1 still applies. + +A device is identified by a `deviceId` in `localStorage`, sent with presence +reports and with the push subscription. Several tabs of the same browser share +one device. + +The server decides before pushing. A push is only sent when it must display, +because iOS Safari revokes push permission for pushes that show nothing. + +### Web Push (`lib/notifications/push.ts`) + +- Dependency: `web-push`. +- VAPID keys are generated on first use and stored at + `~/.omp/agent/omp-web/vapid.json` with mode `0600`. The subject is + `mailto:omp-web@localhost` unless `OMP_WEB_VAPID_SUBJECT` is set. +- Subscriptions are stored at `~/.omp/agent/omp-web/push-subscriptions.json` + as `{deviceId, subscription, prefs, updatedAt}[]`. Writes are atomic + (temp file + rename), matching `lib/project-registry.ts`. +- A send that fails with HTTP 404 or 410 deletes that subscription. Other + failures are logged and ignored. +- Payload: the `NotificationEvent` JSON. Urgency `high` for `input` and + `error`, `normal` otherwise. TTL 1 hour. + +### Service worker (`public/sw.js`) + +- Registered from the client at `/sw.js` with scope `/`. Served same-origin, + which the current CSP allows. `proxy.ts` must let `/sw.js` through without + the auth cookie, like the manifest and icons. +- `push`: `self.registration.showNotification(title, {body, icon: + "/icon-192.png", badge: "/badge-96.png", tag, renotify: true, data: {url}})`. +- `notificationclick`: close the notification. Focus an existing window + client of the app and `navigate` it to `data.url`; if none exists, + `clients.openWindow(data.url)`. +- In-page `{kind: "os"}` events use the same `showNotification` call, so the + look and the click behaviour match push notifications. + +`/badge-96.png` is a new monochrome icon, required for Android's status bar. + +### HTTP API + +| Route | Method | Purpose | +|---|---|---| +| `/api/notifications/events` | GET | SSE stream of `{kind: "toast" \| "os", event}` for one client | +| `/api/notifications/presence` | POST | presence report | +| `/api/notifications/subscription` | GET | VAPID public key and this device's stored prefs | +| `/api/notifications/subscription` | PUT | store or update `{deviceId, subscription?, prefs}` | +| `/api/notifications/subscription` | DELETE | remove this device's subscription | +| `/api/notifications/test` | POST | send a test notification to this device through its configured path | + +All routes sit behind the existing password gate. + +## Settings: Notifications tab + +A new `SettingsTabs` category `notifications` (icon `Bell` from +`lucide-react`). Contents: + +- **Enable notifications** (master switch). Turning it on requests + permission, registers the service worker, and subscribes to push when + `PushManager` exists and the page is a secure context. +- **Status line**, one of: + - "Push notifications active" + - "In-page only: push needs HTTPS" + - "In-page only: this browser does not support push" + - "Blocked by the browser" + - "On iPhone, add omp-web to the Home Screen first" +- **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** button. + +Preferences are per device: + +```ts +interface NotificationPrefs { + enabled: boolean; + types: Record<"completed" | "input" | "error" | "modelSwitch", boolean>; + whenActive: "toast" | "system"; +} +``` + +They are kept in `localStorage` and sent to the server with every +subscription `PUT`, so the server can filter pushes when no tab is open. + +All new strings go through `lib/i18n` with English fallbacks, like the other +settings rows. + +## In-app toast + +`{kind: "toast"}` events show through the existing `toast` helper with an +"Open" action that selects the session, like a notification click. + +## Removed or replaced + +- `handleAgentEnd`'s notification block in `components/AppShell.tsx` and + `lib/browser-notifications.ts` (with its test) are removed; the new + pipeline covers completion. +- The `notify` host tool in `hooks/useAgentSession-stream.ts` switches from + `new Notification` to `registration.showNotification`, falling back to + `new Notification` only when no service worker registration exists. Chrome + on Android throws on `new Notification`. + +## Platform notes + +- Desktop: Web Push notifications appear as native OS notifications + (Windows notification center, macOS Notification Center, Linux + notification daemon). Push delivery needs the browser process running; no + tab needs to be open. +- Android: works in Chrome and the installed PWA. +- iOS/iPadOS 16.4+: push works only for the PWA added to the Home Screen. +- Push and service workers need a secure context: `localhost`, or HTTPS + through the reverse proxy for any other host. Without it, the in-page path + still works while a tab is open. +- The server needs outbound HTTPS to the browser push services. + +## Testing + +Unit tests (node test runner, existing `.test.mjs` conventions): + +- `detect`: subagent frames ignored; `prompt_result` completed / aborted / + error mapping; `agentInvoked: false` ignored; `input` from a pending UI + request and not from an auto-answered approval; `modelSwitch` reason from + `reason`, then from the last `auto_retry_start`, then omitted. +- `hub` routing: viewing the session drops the event in both modes; active + elsewhere with `toast` sends a toast only to active clients; `system` mode + sends an OS notification; nobody active sends push to subscribed devices + and in-page OS events to unsubscribed ones; disabled type sends nothing. +- `push`: a 410 response removes the subscription. + +Manual verification: desktop Chrome on `localhost` (push, toast, click opens +the session), Android PWA over HTTPS. + +## Documentation + +- `AGENTS.md`: a "Notifications" section under Key Design Decisions, and the + new files in the File Map. +- `README.md`: notifications setup, HTTPS requirement, iOS Home Screen + requirement. +- `CHANGELOG.md` entry. From 8962653fc141aaf6d98599f58c82f5fd337892f7 Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Tue, 6 Oct 2026 11:36:20 +0000 Subject: [PATCH 02/18] feat: desktop and mobile notifications with Web Push Detect finished runs, pending questions, failed runs and automatic model switches on the server, route them by tab presence and per-device prefs, and deliver them as in-app toasts, in-page system notifications, or Web Push through a new service worker. Adds Settings -> Notifications and replaces the old foreground-only completion notification. --- AGENTS.md | 35 +- CHANGELOG.md | 1 + README.md | 28 +- app/api/agent/running/events/route.ts | 15 +- app/api/notifications/devices/route.ts | 33 ++ app/api/notifications/presence/route.ts | 16 + app/api/notifications/test/route.ts | 14 + app/globals.css | 11 + components/AppShell.tsx | 41 +-- components/SessionSidebar.tsx | 8 +- components/SettingsConfig.tsx | 145 ++++++++ components/SettingsTabs.tsx | 4 +- docs/specs/2026-10-06-notifications-design.md | 301 +++++++---------- hooks/useAgentSession-stream.ts | 12 +- hooks/useNotifications.ts | 131 ++++++++ lib/browser-notifications.test.mjs | 26 -- lib/browser-notifications.ts | 26 -- lib/i18n/locales/en.json | 41 ++- lib/i18n/locales/ja.json | 41 ++- lib/i18n/locales/zh-CN.json | 41 ++- lib/notification-client.ts | 186 ++++++++++ lib/notification-events.test.mjs | 94 ++++++ lib/notification-events.ts | 170 ++++++++++ lib/notification-hub.test.mjs | 120 +++++++ lib/notification-hub.ts | 317 ++++++++++++++++++ lib/rpc-manager.ts | 14 +- package-lock.json | 123 ++++++- package.json | 2 + proxy.ts | 4 +- public/badge-96.png | Bin 0 -> 260 bytes public/sw.js | 42 +++ 31 files changed, 1772 insertions(+), 270 deletions(-) create mode 100644 app/api/notifications/devices/route.ts create mode 100644 app/api/notifications/presence/route.ts create mode 100644 app/api/notifications/test/route.ts create mode 100644 hooks/useNotifications.ts delete mode 100644 lib/browser-notifications.test.mjs delete mode 100644 lib/browser-notifications.ts create mode 100644 lib/notification-client.ts create mode 100644 lib/notification-events.test.mjs create mode 100644 lib/notification-events.ts create mode 100644 lib/notification-hub.test.mjs create mode 100644 lib/notification-hub.ts create mode 100644 public/badge-96.png create mode 100644 public/sw.js diff --git a/AGENTS.md b/AGENTS.md index c0e46e705..594b77b14 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 } + notifications/test/route.ts POST send a test notification to this device 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,32 @@ 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. +- Presence: each tab reports `{clientId, deviceId, visible, sessionId}` to + `/api/notifications/presence` (on visibility/session change, every 30 s while + visible; expires after 60 s). `clientId` is per page load, `deviceId` is per + browser (`localStorage`). Routing (`routeNotification`, unit-tested): a visible + tab on the session drops the event everywhere; else if any tab is active, + `whenActive: "toast"` devices get a toast on their active tabs only; else push + (subscribed devices) or in-page `showNotification` (connected tabs). +- 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` stream + (`?clientId&deviceId`), not a new SSE connection: browsers allow six HTTP/1.1 + connections per host. `SessionSidebar` re-dispatches them as a window event. +- 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). Push text is rendered server-side in the device's locale. +- `/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. + ## omp Session File Format (v3) Location: `~/.omp/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl` diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1739611..fdcf2664e 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. 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 <question>`: 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. 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..1b6ee3ab8 100644 --- a/app/api/agent/running/events/route.ts +++ b/app/api/agent/running/events/route.ts @@ -1,12 +1,18 @@ import { getExitedRpcSessions, getRunningRpcSessions, subscribeRunningSessions } from "@/lib/rpc-manager"; +import { attachNotificationClient, isValidNotificationId } 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`/`deviceId` are given (one stream +// per tab: browsers allow only six HTTP/1.1 connections per host). export async function GET(req: Request) { + const params = new URL(req.url).searchParams; + const clientId = params.get("clientId"); + const deviceId = params.get("deviceId"); // 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 +24,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<typeof setInterval> | null = null; const cleanup = () => { @@ -36,6 +43,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 +86,10 @@ export async function GET(req: Request) { encode({ type: "sessions-changed", sessionIds, refreshSessionList: true }); }); + if (isValidNotificationId(clientId) && isValidNotificationId(deviceId)) { + detachNotifications = attachNotificationClient(clientId, deviceId, (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..90f54f4c8 --- /dev/null +++ b/app/api/notifications/devices/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import type { PushSubscription } from "web-push"; +import { parseNotificationPrefs } from "@/lib/notification-events"; +import { getNotificationDevice, getVapidPublicKey, isValidNotificationId, 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? } + * `subscription`: PushSubscription JSON to store, `null` to remove, omitted to keep. + */ +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 }); + } + let subscription: PushSubscription | null | undefined; + if (body.subscription === null) subscription = null; + else if (body.subscription !== undefined) { + subscription = parsePushSubscription(body.subscription); + if (!subscription) 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..3c6a4ca95 --- /dev/null +++ b/app/api/notifications/presence/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { isValidNotificationId, reportPresence } from "@/lib/notification-hub"; +import { isRecord } from "@/lib/type-guards"; + +export const dynamic = "force-dynamic"; + +/** POST /api/notifications/presence { clientId, deviceId, visible, sessionId } — 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") { + 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 }); + 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..47f7dc3df --- /dev/null +++ b/app/api/notifications/test/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { isValidNotificationId, sendTestNotification } from "@/lib/notification-hub"; +import { isRecord } from "@/lib/type-guards"; + +export const dynamic = "force-dynamic"; + +/** POST /api/notifications/test { deviceId, clientId } — one system notification to this device. */ +export async function POST(req: Request) { + const body: unknown = await req.json().catch(() => null); + if (!isRecord(body) || !isValidNotificationId(body.deviceId) || !isValidNotificationId(body.clientId)) { + return NextResponse.json({ error: "deviceId and clientId are required", code: "invalid_device" }, { status: 400 }); + } + return NextResponse.json({ via: await sendTestNotification(body.deviceId, body.clientId) }); +} diff --git a/app/globals.css b/app/globals.css index 403544de2..b47862e02 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3254,6 +3254,17 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { box-shadow: var(--shadow-modal) !important; transform: translateY(-2px); } +.notification-toast-open { + margin-left: 8px; + padding: 0; + border: 0; + background: none; + color: var(--accent-strong); + font: inherit; + font-weight: 600; + text-decoration: underline; + cursor: pointer; +} .live-status-dot { position: relative; diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 29bddd673..875a43eb3 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,23 @@ 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. + const openSessionById = useCallback((sessionId: string) => { + window.focus(); + void fetch("/api/sessions") + .then((r) => (r.ok ? (r.json() as Promise<{ sessions: SessionInfo[] }>) : null)) + .then((d) => { + const session = d?.sessions.find((s) => s.id === sessionId); + if (session) handleSelectSession(session); + else toast.error(translate("notifications.sessionNotFound")); + }) + .catch(() => toast.error(translate("notifications.sessionNotFound"))); + }, [handleSelectSession]); + + useNotifications({ sessionId: selectedSession?.id ?? null, locale, onOpenSession: openSessionById }); const handleAutoName = useCallback(async () => { const sessionId = selectedSession?.id; diff --git a/components/SessionSidebar.tsx b/components/SessionSidebar.tsx index c9c01ed86..808f92646 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, notificationStreamQuery } 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?${notificationStreamQuery()}`); 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..8851edfeb 100644 --- a/components/SettingsConfig.tsx +++ b/components/SettingsConfig.tsx @@ -18,6 +18,16 @@ 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, type NotificationType } from "@/lib/notification-events"; +import { + ensurePushSubscription, + getNotificationDeviceId, + getNotificationSupport, + notificationClientId, + updateNotificationPrefs, + type NotificationSupport, +} from "@/lib/notification-client"; const SettingsTabLoading = () => { const { t } = useI18n(); return <div role="status" style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-muted)", fontSize: "var(--text-sm)" }}>{t("settingsConfig.loadingSettings")}</div>; @@ -200,6 +210,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 falls back to another model, for example after a refusal or a usage limit.", 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,6 +505,121 @@ function AgentEnvSetting() { ); } +const NOTIFICATION_TYPE_LABELS: Record<NotificationType, { label: string; desc: string }> = { + 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<Exclude<NotificationSupport, "push" | "no-push">, 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 } = useI18n(); + const prefs = useNotificationPrefs(); + const [support, setSupport] = useState<NotificationSupport | null>(null); + const [pushActive, setPushActive] = useState(false); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let alive = true; + setSupport(getNotificationSupport()); + fetch(`/api/notifications/devices?deviceId=${encodeURIComponent(getNotificationDeviceId())}`) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { subscribed?: boolean } | null) => { if (alive) setPushActive(data?.subscribed === true); }) + .catch(() => {}); + return () => { alive = false; }; + }, []); + + const save = (patch: Parameters<typeof updateNotificationPrefs>[0]) => { + void updateNotificationPrefs(patch).catch((error: unknown) => toast.error(t("notifications.enableFailed", { error: error instanceof Error ? error.message : String(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 }); + if (enabled) setPushActive(await ensurePushSubscription()); + } catch (error) { + toast.error(t("notifications.enableFailed", { error: error instanceof Error ? error.message : String(error) })); + } finally { + setSupport(getNotificationSupport()); + setBusy(false); + } + }; + + const sendTest = async () => { + try { + const res = await fetch("/api/notifications/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceId: getNotificationDeviceId(), clientId: notificationClientId }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + toast.success(t("notifications.testSent")); + } catch (error) { + toast.error(t("notifications.testFailed"), error instanceof Error ? error.message : String(error)); + } + }; + + let status: string | null = null; + if (support && support !== "push" && support !== "no-push") status = t(NOTIFICATION_STATUS_KEYS[support]); + else if (support && prefs.enabled) status = t(pushActive ? "notifications.statusPush" : "notifications.statusInPage"); + + return ( + <> + <NativeSetting searchId="notifications-enable" scope="UI" label={t("notifications.enable")} description={t("notifications.enableDesc")}> + <ToggleSwitch checked={prefs.enabled} disabled={busy} onChange={(next) => void setEnabled(next)} /> + </NativeSetting> + {status && <Alert variant={support === "push" || support === "no-push" ? "info" : "warning"} description={status} />} + {NOTIFICATION_TYPES.map((type) => ( + <NativeSetting key={type} searchId={`notifications-${type}`} scope="UI" label={t(NOTIFICATION_TYPE_LABELS[type].label)} description={t(NOTIFICATION_TYPE_LABELS[type].desc)}> + <ToggleSwitch checked={prefs.types[type]} disabled={!prefs.enabled} onChange={(next) => save({ types: { ...prefs.types, [type]: next } })} /> + </NativeSetting> + ))} + <NativeSetting searchId="notifications-when-active" scope="UI" label={t("notifications.whenActive")} description={t("notifications.whenActiveDesc")}> + <select + style={nativeSelectStyle} + value={prefs.whenActive} + disabled={!prefs.enabled} + onChange={(event) => save({ whenActive: event.target.value === "system" ? "system" : "toast" })} + > + <option value="toast" style={nativeOptionStyle}>{t("notifications.whenActiveToast")}</option> + <option value="system" style={nativeOptionStyle}>{t("notifications.whenActiveSystem")}</option> + </select> + </NativeSetting> + <div> + <button + type="button" + onClick={() => void sendTest()} + disabled={!prefs.enabled} + style={{ + padding: "6px 14px", + border: "1px solid var(--accent-strong)", + borderRadius: "var(--radius-control)", + background: "var(--accent-strong)", + color: "var(--on-accent)", + cursor: prefs.enabled ? "pointer" : "not-allowed", + fontSize: 12, + fontWeight: 600, + opacity: prefs.enabled ? 1 : 0.6, + }} + > + {t("notifications.test")} + </button> + </div> + </> + ); +} + function NativeSetting({ label, description, scope, searchId, children }: { label: string; description: string; scope?: "UI" | "Native OMP" | "Workspace"; searchId?: string; children: ReactNode }) { const { t } = useI18n(); const ref = useRef<HTMLDivElement>(null); @@ -1566,6 +1698,19 @@ export function SettingsConfig({ activeTab, toolCallsDefaultCollapsed, onToolCal </div> )} + {/* NOTIFICATIONS TAB */} + {currentTab === "notifications" && ( + <div role="tabpanel" id="settings-panel-notifications" aria-labelledby="settings-tab-notifications" className="settings-panel-inner" style={{ padding: isMobile ? "16px 14px 32px" : "32px 24px 64px", gap: 16 }}> + <div style={{ marginBottom: 4 }}> + <h2 className="display-serif" style={{ fontSize: "var(--text-2xl)", fontWeight: 600, margin: 0, color: "var(--text)", letterSpacing: "-0.01em" }}>{t("notifications.title")}</h2> + <p className="settings-content-subtitle" style={{ margin: "4px 0 16px", fontSize: "var(--text-md)", color: "var(--text-muted)", lineHeight: 1.45 }}>{t("notifications.subtitle")}</p> + </div> + <div style={{ display: "flex", flexDirection: "column", gap: 10, width: "100%" }}> + <NotificationSettingsPanel /> + </div> + </div> + )} + {/* SYSTEM & UPDATES TAB */} {currentTab === "system" && ( <div role="tabpanel" id="settings-panel-system" aria-labelledby="settings-tab-system" className="settings-panel-inner" style={{ padding: isMobile ? "16px 14px 32px" : "32px 24px 64px", display: "flex", flexDirection: "column", gap: 18 }}> 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/docs/specs/2026-10-06-notifications-design.md b/docs/specs/2026-10-06-notifications-design.md index c88ab6645..05e4fd435 100644 --- a/docs/specs/2026-10-06-notifications-design.md +++ b/docs/specs/2026-10-06-notifications-design.md @@ -1,255 +1,206 @@ # Notifications: Web Push, in-page fallback, and settings -Status: approved design, 2026-10-06. +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 new +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) | Title | Body | -|---|---|---|---| -| `completed` | `prompt_result` with `status: "completed"` and `agentInvoked: true` | session name | "Task finished" | -| `input` | new `extension_ui_request` whose `method` is in `PENDING_UI_METHODS` (`select`, `confirm`, `input`, `editor`, `ask`, `open_url`) and that omp-web does not auto-answer | session name | "Waiting for your answer: <title or first question>" | -| `error` | `prompt_result` with `status: "error"`, or omp child exit while the session was running | session name | "Run failed: <error.message>" | -| `modelSwitch` | `retry_fallback_applied {from, to, reason?}`, or `notice` with `source` `prewalk` or `plan-yolo` | session name | "Switched <from> → <to>: <reason>" | +| 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: <title or first question>" | +| `error` | `prompt_result` with `agentInvoked: true` and `status: "error"`, or omp child exit while the session was running | "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 and never recurses into - `subagent_event.payload`. -- `prompt_result` with `status: "aborted"` never notifies. -- `auto_retry_start` and `auto_retry_end {success: true}` never notify. -- The `modelSwitch` reason comes from `retry_fallback_applied.reason` when - present (usage-aware fallback). Otherwise it is the `errorMessage` of the - most recent `auto_retry_start` in the same session, which covers classifier - refusals (for example Fable refusing and falling back to Opus) and - rate-limit or provider failures. With neither, the body omits the reason. -- A notification closes itself when it no longer applies only where cheap: - an `input` notification uses tag `<sessionId>:input`, and the in-app toast - for it is dismissed when the request is answered or cancelled. OS - notifications are not retracted. + 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), else the `errorMessage` of the most recent + `auto_retry_start` in the same stretch (classifier refusals such as Fable + falling back to Opus, rate limits, provider failures), else omitted. +- 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) - │ - ▼ - lib/notifications/detect.ts (frame → NotificationEvent | null) - │ + │ createNotificationDetector() (lib/notification-events.ts) ▼ - lib/notifications/hub.ts (presence-aware routing) - │ │ │ - in-app toast SSE in-page OS SSE Web Push (web-push) - └──────┬───────┘ │ - ▼ ▼ - /api/notifications/events (per client) public/sw.js + 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. +tab still notify. Toast and in-page messages ride the running-sessions SSE +stream the sidebar already holds (`?clientId=&deviceId=`): 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"; + type: "completed" | "input" | "error" | "modelSwitch" | "test"; sessionId: string; - title: string; - body: string; - url: string; // "/?session=<encoded id>" - tag: string; // "<sessionId>:<type>" + sessionName: string | null; + detail?: string; // question, error text, or model-switch reason + from?: string; + to?: string; } ``` -### Detection (`lib/notifications/detect.ts`) - -A pure function `detectNotification(frame, state)` returns a -`NotificationEvent` or `null`. `state` holds per-session memory the detector -needs (last `auto_retry_start.errorMessage`, session display name). The -wrapper calls it from `handleFrame` after its own state updates, and also from -`handleProcessExit` when a running session's child dies. Auto-answered tool -approval requests (`tools.approval.extension === "allow"`) do not produce an -`input` event. - -### Presence (`lib/notifications/presence.ts`) - -Each tab generates a `clientId` (stored in `sessionStorage`) and keeps one -`EventSource` to `/api/notifications/events?clientId=<id>`. It reports -presence with `POST /api/notifications/presence`: - -```ts -{ clientId: string; visible: boolean; focused: boolean; sessionId: string | null } -``` - -The tab sends it on `visibilitychange`, `focus`, `blur`, session switch, and -every 30 seconds while visible. The server drops an entry 60 seconds after the -last report and also when the client's SSE stream closes. "Active" means -`visible === true`. +`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. -### Routing (`lib/notifications/hub.ts`) +### Presence -For each event, per device preferences decide whether the type is enabled at -all; then: +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}` on +mount, `visibilitychange`, session switch, `pagehide` (beacon, `visible: +false`), and every 30 seconds while visible. A report expires 60 seconds after +it was sent; a tab's entry is removed when its SSE stream closes. "Active" +means visible with a fresh report. -1. If any active client is viewing `event.sessionId`, drop the event. This - rule holds in every mode. -2. If any client is active and that client's device has - `whenActive: "toast"`, send `{kind: "toast", event}` over that client's SSE - stream. Only active clients receive toasts. -3. Otherwise deliver an OS notification: - - to every push subscription whose preferences enable the type, via Web - Push; - - over SSE as `{kind: "os", event}` to every connected client of a device - with no push subscription. The client shows it with - `registration.showNotification`. +### Routing (`routeNotification`) -With `whenActive: "system"`, rule 2 is skipped for that device and it -receives the OS notification even while the user is active in another tab. -Rule 1 still applies. +For each event: -A device is identified by a `deviceId` in `localStorage`, sent with presence -reports and with the push subscription. Several tabs of the same browser share -one device. +1. If an active tab is showing `event.sessionId`, drop the event for every + device, in every mode. +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 connected tabs, which + show it with `registration.showNotification`. -The server decides before pushing. A push is only sent when it must display, -because iOS Safari revokes push permission for pushes that show nothing. +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 (`lib/notifications/push.ts`) +### Web Push and storage - Dependency: `web-push`. -- VAPID keys are generated on first use and stored at - `~/.omp/agent/omp-web/vapid.json` with mode `0600`. The subject is - `mailto:omp-web@localhost` unless `OMP_WEB_VAPID_SUBJECT` is set. -- Subscriptions are stored at `~/.omp/agent/omp-web/push-subscriptions.json` - as `{deviceId, subscription, prefs, updatedAt}[]`. Writes are atomic - (temp file + rename), matching `lib/project-registry.ts`. -- A send that fails with HTTP 404 or 410 deletes that subscription. Other - failures are logged and ignored. -- Payload: the `NotificationEvent` JSON. Urgency `high` for `input` and - `error`, `normal` otherwise. TTL 1 hour. +- `~/.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. +- 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 from the client at `/sw.js` with scope `/`. Served same-origin, - which the current CSP allows. `proxy.ts` must let `/sw.js` through without - the auth cookie, like the manifest and icons. -- `push`: `self.registration.showNotification(title, {body, icon: - "/icon-192.png", badge: "/badge-96.png", tag, renotify: true, data: {url}})`. -- `notificationclick`: close the notification. Focus an existing window - client of the app and `navigate` it to `data.url`; if none exists, - `clients.openWindow(data.url)`. -- In-page `{kind: "os"}` events use the same `showNotification` call, so the - look and the click behaviour match push notifications. - -`/badge-96.png` is a new monochrome icon, required for Android's status bar. +- 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 app window and `postMessage({type: + "omp-open-session", sessionId})` to it, so the app selects the session in + place; 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. ### HTTP API | Route | Method | Purpose | |---|---|---| -| `/api/notifications/events` | GET | SSE stream of `{kind: "toast" \| "os", event}` for one client | +| `/api/agent/running/events?clientId=&deviceId=` | GET | existing SSE stream, now also `{type: "notification", kind, event}` | | `/api/notifications/presence` | POST | presence report | -| `/api/notifications/subscription` | GET | VAPID public key and this device's stored prefs | -| `/api/notifications/subscription` | PUT | store or update `{deviceId, subscription?, prefs}` | -| `/api/notifications/subscription` | DELETE | remove this device's subscription | -| `/api/notifications/test` | POST | send a test notification to this device through its configured path | +| `/api/notifications/devices?deviceId=` | GET | VAPID public key, whether this device has a subscription | +| `/api/notifications/devices` | PUT | `{deviceId, prefs, subscription?}`; `subscription: null` removes it | +| `/api/notifications/test` | POST | `{deviceId, clientId}`: a system notification to this device, ignoring presence | -All routes sit behind the existing password gate. +All routes sit behind the existing password gate and origin check. ## Settings: Notifications tab -A new `SettingsTabs` category `notifications` (icon `Bell` from -`lucide-react`). Contents: - -- **Enable notifications** (master switch). Turning it on requests - permission, registers the service worker, and subscribes to push when - `PushManager` exists and the page is a secure context. -- **Status line**, one of: - - "Push notifications active" - - "In-page only: push needs HTTPS" - - "In-page only: this browser does not support push" - - "Blocked by the browser" - - "On iPhone, add omp-web to the Home Screen first" -- **Toggles**, one per type: Task finished (on), Waiting for input (on), - Run failed (on), Model switched automatically (off). +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** button. - -Preferences are per device: +- **Send test notification**. ```ts interface NotificationPrefs { - enabled: boolean; + enabled: boolean; // default false types: Record<"completed" | "input" | "error" | "modelSwitch", boolean>; whenActive: "toast" | "system"; + locale: string; // UI locale, for server-rendered pushes } ``` -They are kept in `localStorage` and sent to the server with every -subscription `PUT`, so the server can filter pushes when no tab is open. - -All new strings go through `lib/i18n` with English fallbacks, like the other -settings rows. +Prefs live in `localStorage` and are sent to the server on every change and +on app load. Strings are in `lib/i18n` (English, Japanese, Simplified Chinese). ## In-app toast -`{kind: "toast"}` events show through the existing `toast` helper with an -"Open" action that selects the session, like a notification click. +`{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 -- `handleAgentEnd`'s notification block in `components/AppShell.tsx` and - `lib/browser-notifications.ts` (with its test) are removed; the new - pipeline covers completion. -- The `notify` host tool in `hooks/useAgentSession-stream.ts` switches from - `new Notification` to `registration.showNotification`, falling back to - `new Notification` only when no service worker registration exists. Chrome - on Android throws on `new Notification`. +- 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 as native OS notifications - (Windows notification center, macOS Notification Center, Linux - notification daemon). Push delivery needs the browser process running; no - tab needs to be open. -- Android: works in Chrome and the installed PWA. -- iOS/iPadOS 16.4+: push works only for the PWA added to the Home Screen. -- Push and service workers need a secure context: `localhost`, or HTTPS - through the reverse proxy for any other host. Without it, the in-page path - still works while a tab is open. +- 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 -Unit tests (node test runner, existing `.test.mjs` conventions): - -- `detect`: subagent frames ignored; `prompt_result` completed / aborted / - error mapping; `agentInvoked: false` ignored; `input` from a pending UI - request and not from an auto-answered approval; `modelSwitch` reason from - `reason`, then from the last `auto_retry_start`, then omitted. -- `hub` routing: viewing the session drops the event in both modes; active - elsewhere with `toast` sends a toast only to active clients; `system` mode - sends an OS notification; nobody active sends push to subscribed devices - and in-page OS events to unsubscribed ones; disabled type sends nothing. -- `push`: a 410 response removes the subscription. - -Manual verification: desktop Chrome on `localhost` (push, toast, click opens -the session), Android PWA over HTTPS. - -## Documentation - -- `AGENTS.md`: a "Notifications" section under Key Design Decisions, and the - new files in the File Map. -- `README.md`: notifications setup, HTTPS requirement, iOS Home Screen - requirement. -- `CHANGELOG.md` entry. +- `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..9493a43dd --- /dev/null +++ b/hooks/useNotifications.ts @@ -0,0 +1,131 @@ +import { createElement, useEffect, useRef, useSyncExternalStore } from "react"; +import { toast } from "@/components/ui/toast"; +import { translate } from "@/lib/i18n"; +import { DEFAULT_NOTIFICATION_PREFS, renderNotification, type NotificationEvent } from "@/lib/notification-events"; +import { + ensurePushSubscription, + getNotificationDeviceId, + getNotificationPrefs, + NOTIFICATION_MESSAGE_EVENT, + notificationClientId, + OPEN_SESSION_EVENT, + registerNotificationWorker, + showSystemNotification, + subscribeNotificationPrefs, + syncNotificationDevice, + updateNotificationPrefs, +} from "@/lib/notification-client"; + +const PRESENCE_INTERVAL_MS = 30_000; + +/** 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 it is visible; + * - 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. + */ +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: every change of visibility or session, plus a keep-alive. + useEffect(() => { + const report = (visible = document.visibilityState === "visible") => { + const body = JSON.stringify({ clientId: notificationClientId, deviceId: getNotificationDeviceId(), visible, sessionId }); + 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 onVisibility = () => report(); + const onPageHide = () => report(false); + report(); + const timer = setInterval(() => { + if (document.visibilityState === "visible") report(); + }, PRESENCE_INTERVAL_MS); + document.addEventListener("visibilitychange", onVisibility); + window.addEventListener("pagehide", onPageHide); + return () => { + clearInterval(timer); + document.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("pagehide", onPageHide); + }; + }, [sessionId]); + + // Pushes are written in the device's language. + useEffect(() => { + if (getNotificationPrefs().locale !== locale) void updateNotificationPrefs({ locale }).catch(() => {}); + }, [locale]); + + // Startup sync: the server may have lost its store, or the browser its subscription. + useEffect(() => { + void (async () => { + if (getNotificationPrefs().enabled) { + await registerNotificationWorker(); + if (await ensurePushSubscription().catch(() => false)) return; + } + await syncNotificationDevice(); + })().catch(() => {}); + }, []); + + // Delivery to this tab and notification clicks. + useEffect(() => { + 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 === "os") { + void showSystemNotification(rendered); + return; + } + const description = createElement( + "span", + null, + rendered.body, + rendered.sessionId + ? createElement( + "button", + { + type: "button", + className: "notification-toast-open", + onClick: () => { + toast.close(rendered.tag); + openRef.current(rendered.sessionId); + }, + }, + translate("notifications.open"), + ) + : null, + ); + const show = event.type === "error" ? toast.error : toast.info; + show(rendered.title, description, { id: rendered.tag }); + }; + 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..ea3ab8a98 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -294,8 +294,43 @@ "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.open": "Open", + "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. This browser does not support push.", + "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 falls back to another model, for example after a refusal or a usage limit.", + "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}", "appShell.system": "System", "appShell.systemPrompt": "System prompt", "appShell.systemPromptEmpty": "System prompt is empty (tools are disabled)", @@ -1229,6 +1264,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..b163182e1 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -281,8 +281,43 @@ "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.open": "開く", + "notifications.sessionNotFound": "そのセッションはもう利用できません。", + "notifications.title": "通知", + "notifications.subtitle": "表示していないセッションについて通知します。この設定はこのブラウザーにのみ適用されます。", + "notifications.enable": "通知を有効にする", + "notifications.enableDesc": "通知を表示する許可をブラウザーに求めます。", + "notifications.statusPush": "プッシュ通知が有効です。omp-web のタブを開いていなくても届きます。", + "notifications.statusInPage": "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 が別のモデルにフォールバックしたとき。", + "notifications.whenActive": "omp-web の別のタブを使用中のとき", + "notifications.whenActiveDesc": "表示中のセッションについては何も表示しません。", + "notifications.whenActiveToast": "アプリ内トーストを表示", + "notifications.whenActiveSystem": "常にシステム通知を送る", + "notifications.test": "テスト通知を送信", + "notifications.testSent": "テスト通知を送信しました。", + "notifications.testFailed": "テスト通知を送信できませんでした。", + "notifications.enableFailed": "プッシュ通知を有効にできませんでした: {error}", "appShell.system": "システム", "appShell.systemPrompt": "システムプロンプト", "appShell.systemPromptEmpty": "システムプロンプトは空です(ツールは無効)", @@ -1192,6 +1227,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..f7bffbcb4 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -281,8 +281,43 @@ "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.open": "打开", + "notifications.sessionNotFound": "该会话已不可用。", + "notifications.title": "通知", + "notifications.subtitle": "为你当前未查看的会话发送通知。这些设置仅适用于此浏览器。", + "notifications.enable": "启用通知", + "notifications.enableDesc": "请求浏览器授予显示通知的权限。", + "notifications.statusPush": "推送通知已启用,即使没有打开 omp-web 标签页也能收到。", + "notifications.statusInPage": "打开 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 回退到另一个模型,例如因拒绝或用量限制。", + "notifications.whenActive": "当我在另一个 omp-web 标签页中操作时", + "notifications.whenActiveDesc": "正在查看的会话不会显示任何通知。", + "notifications.whenActiveToast": "显示应用内提示", + "notifications.whenActiveSystem": "始终发送系统通知", + "notifications.test": "发送测试通知", + "notifications.testSent": "测试通知已发送。", + "notifications.testFailed": "无法发送测试通知。", + "notifications.enableFailed": "无法启用推送通知:{error}", "appShell.system": "系统", "appShell.systemPrompt": "系统提示词", "appShell.systemPromptEmpty": "系统提示词为空(工具已禁用)", @@ -1192,6 +1227,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..81651557a --- /dev/null +++ b/lib/notification-client.ts @@ -0,0 +1,186 @@ +/** + * 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, + parseNotificationPrefs, + type NotificationPrefs, + type RenderedNotification, +} from "./notification-events"; + +const DEVICE_KEY = "omp-notify-device"; +const PREFS_KEY = "omp-notify-prefs"; + +/** One id per page load: a duplicated tab copies sessionStorage, so it cannot hold this. */ +export const notificationClientId = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `c${Date.now()}${Math.random().toString(36).slice(2)}`; + +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 { + deviceId = localStorage.getItem(DEVICE_KEY); + if (!deviceId || !/^[A-Za-z0-9_-]{8,64}$/.test(deviceId)) { + deviceId = crypto.randomUUID(); + localStorage.setItem(DEVICE_KEY, deviceId); + } + } catch { + deviceId ??= crypto.randomUUID(); + } + return deviceId; +} + +/** Query string that attaches this tab to notification delivery on the running-sessions SSE stream. */ +export function notificationStreamQuery(): string { + return `clientId=${encodeURIComponent(notificationClientId)}&deviceId=${encodeURIComponent(getNotificationDeviceId())}`; +} + +// ---------------------------------------------------------------- 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; +} + +export function subscribeNotificationPrefs(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** 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 = { ...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(); +} + +/** + * Send prefs (and optionally a push subscription change) to the server. + * Returns whether the server holds a push subscription for this device. + */ +export async function syncNotificationDevice(subscription?: PushSubscriptionJSON | null): Promise<boolean> { + const response = await fetch("/api/notifications/devices", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceId: getNotificationDeviceId(), prefs: getNotificationPrefs(), ...(subscription !== undefined ? { subscription } : {}) }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = (await response.json()) as { subscribed?: boolean }; + return body.subscribed === true; +} + +// ---------------------------------------------------------------- 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 + +export async function registerNotificationWorker(): Promise<ServiceWorkerRegistration | null> { + if (!("serviceWorker" in navigator) || !window.isSecureContext) return null; + try { + return await navigator.serviceWorker.register("/sw.js", { scope: "/" }); + } 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]); +} + +/** + * Make sure this device has a push subscription for the server's VAPID key + * and the server knows it. Needs notification permission already granted. + * Returns whether push is active. + */ +export async function ensurePushSubscription(): 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 } = (await response.json()) as { publicKey: string }; + const applicationServerKey = base64UrlToBytes(publicKey); + let subscription = await registration.pushManager.getSubscription(); + // A server that lost its keys signs with new ones; the old subscription would be rejected. + if (subscription && !sameKey(subscription.options.applicationServerKey, applicationServerKey)) { + await subscription.unsubscribe().catch(() => false); + subscription = null; + } + subscription ??= await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }); + return syncNotificationDevice(subscription.toJSON()); +} + +/** Show a system notification the same way the service worker shows pushes. */ +export async function showSystemNotification(notification: RenderedNotification): Promise<void> { + if (!("Notification" in window) || Notification.permission !== "granted") return; + const options: NotificationOptions = { + body: notification.body, + icon: "/icon-192.png", + badge: "/badge-96.png", + tag: notification.tag, + data: { url: notification.url, sessionId: notification.sessionId }, + }; + const registration = "serviceWorker" in navigator ? await navigator.serviceWorker.getRegistration() : undefined; + if (registration) { + await registration.showNotification(notification.title, options); + return; + } + // No worker (registration failed or unsupported): Chrome on Android throws + // here, desktop browsers show it. + try { + const fallback = new Notification(notification.title, options); + fallback.onclick = () => { + fallback.close(); + window.focus(); + window.dispatchEvent(new CustomEvent(OPEN_SESSION_EVENT, { detail: notification.sessionId })); + }; + } catch { + // blocked: nothing else to do + } +} + +/** 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"; diff --git a/lib/notification-events.test.mjs b/lib/notification-events.test.mjs new file mode 100644 index 000000000..b37e17f45 --- /dev/null +++ b/lib/notification-events.test.mjs @@ -0,0 +1,94 @@ +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.map((frame) => detect(frame, session)).filter(Boolean); +} + +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: "completed" }]), []); + assert.deepEqual(run([{ type: "prompt_result", agentInvoked: true, status: "aborted" }, { type: "session_settled" }]), []); +}); + +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", retryable: true } }, + { type: "session_settled" }, + ]); + assert.deepEqual(events.map((event) => [event.type, event.detail]), [["error", "Retry budget exhausted"]]); +}); + +test("the next stretch after a failure can complete normally", () => { + const events = run([ + { type: "prompt_result", agentInvoked: true, status: "error", error: { message: "boom" } }, + { type: "session_settled" }, + { type: "prompt_result", agentInvoked: true, status: "completed" }, + { type: "session_settled" }, + ]); + assert.deepEqual(events.map((event) => event.type), ["error", "completed"]); +}); + +test("blocking dialogs notify with their question; non-blocking UI frames do not", () => { + const events = run([ + { type: "extension_ui_request", id: "1", method: "select", title: "Which database?", options: ["a"] }, + { type: "extension_ui_request", id: "2", method: "ask", questions: [{ id: "q", question: "Ship it?", options: [] }] }, + { type: "extension_ui_request", id: "3", method: "notify", message: "fyi" }, + { type: "extension_ui_request", id: "4", method: "setStatus", statusKey: "k" }, + ]); + assert.deepEqual(events.map((event) => [event.type, event.detail]), [["input", "Which database?"], ["input", "Ship it?"]]); +}); + +test("model switch reason: explicit reason, else the preceding retry error, else none", () => { + const events = run([ + { type: "retry_fallback_applied", from: "anthropic/fable", to: "anthropic/opus", role: "default", reason: "usage reserve reached" }, + { type: "auto_retry_start", attempt: 1, errorMessage: "Request refused by the safety classifier" }, + { type: "retry_fallback_applied", from: "anthropic/fable", to: "anthropic/opus", role: "default" }, + { type: "session_settled" }, + { type: "retry_fallback_applied", from: "anthropic/fable", to: "anthropic/opus", role: "default" }, + { type: "notice", level: "info", source: "prewalk", message: "Prewalk: switched to openai/gpt-5 after first edit call." }, + { type: "notice", level: "error", source: "session-persistence", message: "disk full" }, + ]).filter((event) => event.type === "modelSwitch"); + assert.deepEqual(events.map((event) => event.detail), [ + "usage reserve reached", + "Request refused by the safety classifier", + undefined, + "Prewalk: switched to openai/gpt-5 after first edit call.", + ]); +}); + +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); +}); diff --git a/lib/notification-events.ts b/lib/notification-events.ts new file mode 100644 index 000000000..03e12b427 --- /dev/null +++ b/lib/notification-events.ts @@ -0,0 +1,170 @@ +/** + * 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; +} + +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; + +function clip(text: string): string { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length > MAX_DETAIL ? `${flat.slice(0, MAX_DETAIL - 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 || t("notifications.untitledSession"), + body, + url: event.sessionId ? `/?session=${encodeURIComponent(event.sessionId)}` : "/", + tag: `${event.sessionId}:${event.type}`, + sessionId: event.sessionId, + }; +} + +const MODEL_SWITCH_NOTICE_SOURCES: Record<string, true> = { prewalk: true, "plan-yolo": true }; + +/** Extension UI methods that block the agent until the user answers. */ +const INPUT_METHODS: Record<string, true> = { select: true, confirm: true, input: true, editor: true, ask: true, open_url: true }; + +type Frame = Record<string, unknown> & { type?: unknown }; + +const str = (value: unknown): string | undefined => (typeof value === "string" && value.trim() ? value : undefined); + +/** + * 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. + */ +export function createNotificationDetector(): (frame: Frame, session: { sessionId: string; sessionName: string | null }) => NotificationEvent | null { + let lastOutcome: string | null = null; + let lastRetryError: string | undefined; + + return (frame, { sessionId, sessionName }) => { + const base = { sessionId, sessionName }; + switch (frame.type) { + case "auto_retry_start": + lastRetryError = str(frame.errorMessage); + return null; + case "retry_fallback_applied": { + const from = str(frame.from); + const to = str(frame.to); + if (!from || !to) return null; + return { ...base, type: "modelSwitch", from, to, detail: str(frame.reason) ?? lastRetryError }; + } + case "notice": { + const source = str(frame.source); + const message = str(frame.message); + if (!source || !message || MODEL_SWITCH_NOTICE_SOURCES[source] !== true) return null; + return { ...base, type: "modelSwitch", detail: message }; + } + case "prompt_result": { + if (frame.agentInvoked !== true) return null; + lastOutcome = str(frame.status) ?? null; + if (lastOutcome !== "error") return null; + const error = frame.error && typeof frame.error === "object" ? (frame.error as Record<string, unknown>) : {}; + return { ...base, type: "error", detail: str(error.message) }; + } + case "session_settled": { + const outcome = lastOutcome; + lastOutcome = null; + lastRetryError = undefined; + if (outcome === "error" || outcome === "aborted") return null; + return { ...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" || INPUT_METHODS[frame.method] !== true) return null; + const questions = Array.isArray(frame.questions) ? frame.questions : []; + const firstQuestion = questions[0] && typeof questions[0] === "object" ? str((questions[0] as Record<string, unknown>).question) : undefined; + return { ...base, type: "input", detail: str(frame.title) ?? firstQuestion ?? str(frame.message) ?? str(frame.instructions) }; + } + default: + return null; + } + }; +} diff --git a/lib/notification-hub.test.mjs b/lib/notification-hub.test.mjs new file mode 100644 index 000000000..d9d4a7ed1 --- /dev/null +++ b/lib/notification-hub.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, statSync } 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 NOW = 1_000_000; +const event = { type: "completed", sessionId: "s1", sessionName: "Session" }; +const prefs = (patch = {}) => ({ ...DEFAULT_NOTIFICATION_PREFS, enabled: true, ...patch }); +const client = (clientId, deviceId, patch = {}) => ({ clientId, deviceId, visible: false, sessionId: null, lastSeen: NOW, send: () => {}, ...patch }); +const device = (deviceId, patch = {}) => ({ deviceId, prefs: prefs(), updatedAt: 0, ...patch }); +const subscription = { endpoint: "https://push.example/abc", keys: { p256dh: "p", auth: "a" } }; + +test("nothing is sent anywhere while a visible tab shows the session, in either mode", () => { + const clients = [client("viewer", "desk", { visible: true, sessionId: "s1" }), client("phone-tab", "phone")]; + const devices = [device("desk"), device("phone", { prefs: prefs({ whenActive: "system" }), subscription })]; + assert.deepEqual(hub.routeNotification(event, clients, devices, NOW), []); +}); + +test("active in another session: toast on the active tab only, toast-mode devices elsewhere stay quiet", () => { + const clients = [ + client("desk-active", "desk", { visible: true, sessionId: "other" }), + client("desk-hidden", "desk"), + client("laptop-tab", "laptop"), + ]; + const devices = [device("desk"), device("laptop")]; + assert.deepEqual(hub.routeNotification(event, clients, devices, NOW), [{ kind: "toast", clientId: "desk-active" }]); +}); + +test("'always send a system notification' devices still get one while the user is active elsewhere", () => { + const clients = [client("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, clients, devices, NOW), [ + { kind: "os", clientId: "desk-active" }, + { kind: "push", deviceId: "phone" }, + ]); +}); + +test("nobody active: push to subscribed devices, in-page to connected unsubscribed ones", () => { + const clients = [client("desk-tab", "desk"), client("phone-tab", "phone"), client("beacon-only", "laptop", { send: undefined })]; + const devices = [device("desk"), device("phone", { subscription }), device("laptop")]; + assert.deepEqual(hub.routeNotification(event, clients, devices, NOW), [ + { kind: "os", clientId: "desk-tab" }, + { kind: "push", deviceId: "phone" }, + ]); +}); + +test("stale presence does not count as active", () => { + const clients = [client("old", "desk", { visible: true, sessionId: "s1", lastSeen: NOW - hub.PRESENCE_TTL_MS - 1 })]; + assert.deepEqual(hub.routeNotification(event, clients, [device("desk")], NOW), [{ kind: "os", clientId: "old" }]); +}); + +test("disabled devices and disabled types get nothing", () => { + const clients = [client("tab", "desk")]; + assert.deepEqual(hub.routeNotification(event, clients, [device("desk", { prefs: prefs({ enabled: false }) })], NOW), []); + assert.deepEqual(hub.routeNotification(event, clients, [device("desk", { prefs: prefs({ types: { ...DEFAULT_NOTIFICATION_PREFS.types, completed: false } }) })], NOW), []); +}); + +test("push subscriptions must use an HTTPS endpoint", () => { + assert.equal(hub.parsePushSubscription({ ...subscription, endpoint: "http://127.0.0.1:8080/x" }), null); + assert.equal(hub.parsePushSubscription({ endpoint: subscription.endpoint, keys: {} }), null); + assert.deepEqual(hub.parsePushSubscription(subscription), subscription); +}); + +test("a tab stays reachable when a stale stream of the same tab attaches late and then closes", (t) => { + const previousHub = globalThis.__ompNotificationHub; + globalThis.__ompNotificationHub = { clients: new Map(), streams: new Map(), store: { devices: [] } }; + t.after(() => { globalThis.__ompNotificationHub = previousHub; }); + + // 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", "desk-0001", () => received.push("live")); + const detachStale = hub.attachNotificationClient("tab-0001", "desk-0001", () => received.push("stale")); + detachStale(); + globalThis.__ompNotificationHub.clients.get("tab-0001").send({ kind: "toast", event }); + assert.deepEqual(received, ["live"]); + + detachLive(); + assert.equal(globalThis.__ompNotificationHub.clients.has("tab-0001"), false); +}); + +test("a push service 410 removes the subscription but keeps the device prefs", async (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 }); + }); + + let sent = 0; + webpush.sendNotification = async () => { + sent += 1; + throw new webpush.WebPushError("Gone", 410, {}, "", subscription.endpoint); + }; + hub.saveNotificationDevice("phone-device", prefs(), subscription); + // The store holds the VAPID private key: owner-only. + assert.equal(statSync(join(dir, "omp-web", "notifications.json")).mode & 0o077, 0); + + hub.publishNotification(event); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(sent, 1); + 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..5b5f5777d --- /dev/null +++ b/lib/notification-hub.ts @@ -0,0 +1,317 @@ +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 { + parseNotificationPrefs, + renderNotification, + type NotificationEvent, + type NotificationPrefs, + type RenderedNotification, +} from "./notification-events"; + +/** + * 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; +// Apple's push service rejects VAPID subjects it cannot resolve (e.g. localhost mail). +const VAPID_SUBJECT = "https://github.com/kahme247/ompweb"; + +export interface NotificationClient { + clientId: string; + deviceId: string; + visible: boolean; + sessionId: string | null; + lastSeen: number; + /** Set while the tab holds its notification SSE stream. */ + send?: (message: NotificationMessage) => void; +} + +export interface NotificationDevice { + deviceId: string; + prefs: NotificationPrefs; + subscription?: PushSubscription; + updatedAt: number; +} + +export type NotificationMessage = { kind: "toast" | "os"; event: NotificationEvent }; + +export type Delivery = + | { kind: "toast" | "os"; clientId: string } + | { kind: "push"; deviceId: string }; + +interface StoreFile { + vapid?: { publicKey: string; privateKey: string }; + devices: NotificationDevice[]; +} + +interface HubState { + clients: Map<string, NotificationClient>; + /** Open SSE streams per client: a remount or reconnect can overlap the old stream's close. */ + streams: Map<string, Set<(message: NotificationMessage) => void>>; + store: StoreFile | null; +} + +declare global { + var __ompNotificationHub: HubState | undefined; +} + +function hub(): HubState { + return (globalThis.__ompNotificationHub ??= { clients: new Map(), streams: new Map(), store: null }); +} + +const ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/; + +export function isValidNotificationId(value: unknown): value is string { + return typeof value === "string" && ID_PATTERN.test(value); +} + +/** Validate an untrusted PushSubscription JSON; only HTTPS push endpoints are accepted. */ +export function parsePushSubscription(value: unknown): PushSubscription | null { + if (!value || typeof value !== "object") return null; + const raw = value as Record<string, unknown>; + const keys = raw.keys && typeof raw.keys === "object" ? (raw.keys as Record<string, unknown>) : {}; + if (typeof raw.endpoint !== "string" || typeof keys.p256dh !== "string" || typeof keys.auth !== "string") return null; + try { + if (new URL(raw.endpoint).protocol !== "https:") return null; + } catch { + return null; + } + return { endpoint: raw.endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } }; +} + +// ---------------------------------------------------------------- store + +function storePath(): string { + return resolve(getAgentDir(), "omp-web", "notifications.json"); +} + +function loadStore(): StoreFile { + const state = hub(); + if (state.store) return state.store; + let store: StoreFile = { devices: [] }; + try { + if (existsSync(storePath())) { + const parsed = JSON.parse(readFileSync(storePath(), "utf8")) as Partial<StoreFile>; + const devices = Array.isArray(parsed.devices) ? parsed.devices : []; + store = { + vapid: parsed.vapid && typeof parsed.vapid.publicKey === "string" && typeof parsed.vapid.privateKey === "string" ? parsed.vapid : undefined, + devices: devices.flatMap((device) => { + if (!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. + } + state.store = store; + 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. `subscription`: a value replaces the stored one, + * `null` removes it, `undefined` keeps it. + */ +export function saveNotificationDevice(deviceId: string, prefs: NotificationPrefs, subscription: PushSubscription | null | undefined): NotificationDevice { + const store = loadStore(); + const existing = store.devices.find((device) => device.deviceId === deviceId); + const device: NotificationDevice = { + deviceId, + prefs, + subscription: subscription === undefined ? existing?.subscription : subscription ?? undefined, + 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)); + store.devices = [device, ...others].sort((a, b) => 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 + +function liveClients(now: number): NotificationClient[] { + const { clients } = hub(); + for (const [id, client] of clients) { + if (!client.send && now - client.lastSeen > PRESENCE_TTL_MS) clients.delete(id); + } + return [...clients.values()]; +} + +export function reportPresence(report: { clientId: string; deviceId: string; visible: boolean; sessionId: string | null }): void { + const { clients } = hub(); + const existing = clients.get(report.clientId); + clients.set(report.clientId, { ...existing, ...report, lastSeen: Date.now() }); +} + +/** Attach a tab's SSE stream. Returns the detach callback. */ +export function attachNotificationClient(clientId: string, deviceId: string, send: (message: NotificationMessage) => void): () => void { + const state = hub(); + const streams = state.streams.get(clientId) ?? new Set(); + streams.add(send); + state.streams.set(clientId, streams); + const existing = state.clients.get(clientId); + state.clients.set(clientId, { + clientId, + deviceId, + visible: existing?.visible ?? false, + sessionId: existing?.sessionId ?? null, + lastSeen: existing?.lastSeen ?? 0, + send: (message) => streams.forEach((stream) => stream(message)), + }); + return () => { + streams.delete(send); + // The tab is gone once its last stream closes; its presence goes with it. + if (streams.size === 0 && state.streams.get(clientId) === streams) { + state.streams.delete(clientId); + state.clients.delete(clientId); + } + }; +} + +// ---------------------------------------------------------------- routing + +function isActive(client: NotificationClient, now: number): boolean { + return client.visible && now - client.lastSeen <= PRESENCE_TTL_MS; +} + +/** + * Decide where one event goes. + * 1. A visible tab is showing the session: nothing, for every device. + * 2. The user is active in some 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 connected tabs. + */ +export function routeNotification(event: NotificationEvent, clients: NotificationClient[], devices: NotificationDevice[], now: number): Delivery[] { + if (event.type === "test") return []; + if (clients.some((client) => isActive(client, now) && client.sessionId === event.sessionId)) return []; + const anyActive = clients.some((client) => isActive(client, now)); + const deliveries: Delivery[] = []; + for (const device of devices) { + if (!device.prefs.enabled || !device.prefs.types[event.type]) continue; + const own = clients.filter((client) => client.deviceId === device.deviceId && client.send); + if (anyActive && device.prefs.whenActive === "toast") { + for (const client of own) if (isActive(client, now)) deliveries.push({ kind: "toast", clientId: client.clientId }); + } else if (device.subscription) { + deliveries.push({ kind: "push", deviceId: device.deviceId }); + } else { + for (const client of own) deliveries.push({ kind: "os", clientId: client.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; + }); +} + +async function sendPush(device: NotificationDevice, event: NotificationEvent): Promise<void> { + const subscription = device.subscription; + if (!subscription) return; + const { publicKey, privateKey } = vapidKeys(); + try { + await webpush.sendNotification(subscription, JSON.stringify(renderFor(event, device.prefs.locale)), { + TTL: PUSH_TTL_SECONDS, + urgency: event.type === "input" || event.type === "error" ? "high" : "normal", + vapidDetails: { subject: VAPID_SUBJECT, publicKey, privateKey }, + }); + } catch (error) { + const status = error instanceof webpush.WebPushError ? error.statusCode : undefined; + // 404/410: the browser unsubscribed or the subscription expired. + if (status === 404 || status === 410) dropSubscription(device.deviceId, subscription.endpoint); + else console.warn("[notifications] push failed:", status ?? String(error)); + } +} + +function sendToClient(clientId: string, message: NotificationMessage): void { + try { + hub().clients.get(clientId)?.send?.(message); + } catch { + // A closing stream must not break delivery to the others. + } +} + +export function publishNotification(event: NotificationEvent): void { + const now = Date.now(); + const devices = loadStore().devices; + for (const delivery of routeNotification(event, liveClients(now), devices, now)) { + if (delivery.kind === "push") { + const device = devices.find((entry) => entry.deviceId === delivery.deviceId); + if (device) void sendPush(device, event); + } else { + sendToClient(delivery.clientId, { kind: delivery.kind, event }); + } + } +} + +/** Settings "Send test notification": the requesting device's system path, ignoring presence. */ +export async function sendTestNotification(deviceId: string, clientId: string): Promise<"push" | "os"> { + const event: NotificationEvent = { type: "test", sessionId: "", sessionName: "omp web" }; + const device = getNotificationDevice(deviceId); + if (device?.subscription) { + await sendPush(device, event); + return "push"; + } + sendToClient(clientId, { kind: "os", event }); + return "os"; +} diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 9bc9a42f7..f58d95648 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 { createNotificationDetector } from "./notification-events"; +import { publishNotification } from "./notification-hub"; import { parseSkillDiagnosticsSnapshot, type SkillDiagnosticsSnapshot } from "./skill-diagnostics"; import { isReservedLaunchArg, loadProjectRegistry } from "./project-registry"; import type { @@ -329,6 +331,7 @@ 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(); 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; @@ -657,10 +660,17 @@ 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.emit({ type: "agent_end", isTerminal: true, messages: [] }); + publishNotification({ type: "error", sessionId: this._sessionId, sessionName: this.notificationSessionName(), detail: detail || status }); + } this.destroy(); } + private notificationSessionName(): string | null { + return this._sessionName || this.cwd.split(/[\\/]/).filter(Boolean).pop() || null; + } + private handleFrame(frame: RpcFrame): void { this.resetIdleTimer(); const event = frame; @@ -855,6 +865,8 @@ export class AgentSessionWrapper { } } + const notification = this.detectNotification(event, { sessionId: this._sessionId, sessionName: this.notificationSessionName() }); + if (notification) publishNotification(notification); this.emit(event); notifyRunningChange({ refreshSessionList }); } 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..2b5378ec1 100644 --- a/proxy.ts +++ b/proxy.ts @@ -29,4 +29,6 @@ export function proxy(request: NextRequest) { // 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 0000000000000000000000000000000000000000..527ba5193db309557ccfb9decd09f9a925be5015 GIT binary patch literal 260 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9GG!XV7ZFl&wkP;i^4 zi(^Q|t+zKf3Nk40FmHUbzvPAHr9yVM{SDkx*G*FKgrHkly^KKBSOF))^2WUT-S#<H uWl0x|XI#?E&A`COpuoVuf<^lQX9n)YO;@MhDre&ZNqf5bxvX<aXaWEyu_*cg literal 0 HcmV?d00001 diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 000000000..6cb7e85b4 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,42 @@ +// 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) { + await client.focus(); + // The page selects the session in place, keeping its live state. + if (sessionId) client.postMessage({ type: "omp-open-session", sessionId }); + return; + } + await self.clients.openWindow(url); + })(), + ); +}); From 93e6b8299b4abe5454c5617cfd6d75f0a664e524 Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Tue, 6 Oct 2026 12:07:14 +0000 Subject: [PATCH 03/18] fix(notifications): address review findings - Guard crypto.randomUUID so plain-HTTP LAN origins keep working. - Split presence from SSE streams: reconnects no longer leak notifications for the viewed session, and a tab with Settings open no longer silences other devices. - Count a tab as present only with recent input; Settings hides its session. - Take the fallback reason from the retry error omp sends after the fallback. - Report real push results from the test button; validate subscription keys; time out pushes; never let a push failure throw. - Re-subscribe when the server dropped the subscription; single-flight it. - Sync prefs across tabs; sync on load only for enabled devices. - Fall back to a toast when system notifications are not allowed. - Test the rpc-manager wiring and the cases the review listed. --- AGENTS.md | 38 +++- CHANGELOG.md | 1 + app/api/agent/running/events/route.ts | 15 +- app/api/notifications/devices/route.ts | 18 +- app/api/notifications/presence/route.ts | 3 +- app/api/notifications/test/route.ts | 12 +- components/AppShell.tsx | 3 +- components/SessionSidebar.tsx | 4 +- components/SettingsConfig.tsx | 43 ++-- docs/specs/2026-10-06-notifications-design.md | 48 ++-- hooks/useNotifications.ts | 66 ++++-- lib/i18n/locales/en.json | 6 +- lib/i18n/locales/ja.json | 6 +- lib/i18n/locales/zh-CN.json | 6 +- lib/notification-client.ts | 114 ++++++---- lib/notification-events.test.mjs | 66 ++++-- lib/notification-events.ts | 72 ++++-- lib/notification-hub.test.mjs | 120 ++++++---- lib/notification-hub.ts | 206 ++++++++---------- lib/rpc-manager.test.mjs | 64 ++++++ lib/rpc-manager.ts | 15 +- public/sw.js | 12 +- 22 files changed, 575 insertions(+), 363 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 594b77b14..015564b55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ app/api/ 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 } - notifications/test/route.ts POST send a test notification to this device + 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 @@ -598,21 +598,35 @@ during the wait. 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. -- Presence: each tab reports `{clientId, deviceId, visible, sessionId}` to - `/api/notifications/presence` (on visibility/session change, every 30 s while - visible; expires after 60 s). `clientId` is per page load, `deviceId` is per - browser (`localStorage`). Routing (`routeNotification`, unit-tested): a visible - tab on the session drops the event everywhere; else if any tab is active, - `whenActive: "toast"` devices get a toast on their active tabs only; else push - (subscribed devices) or in-page `showNotification` (connected tabs). + 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. +- 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`). +- 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` stream - (`?clientId&deviceId`), not a new SSE connection: browsers allow six HTTP/1.1 - connections per host. `SessionSidebar` re-dispatches them as a window event. +- 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). Push text is rendered server-side in the device's locale. + private key). Push text is rendered server-side in the device's locale. Only + enabled devices sync on load. +- 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. - `/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. diff --git a/CHANGELOG.md b/CHANGELOG.md index fdcf2664e..0ed7114a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,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/app/api/agent/running/events/route.ts b/app/api/agent/running/events/route.ts index 1b6ee3ab8..9216e4d7c 100644 --- a/app/api/agent/running/events/route.ts +++ b/app/api/agent/running/events/route.ts @@ -1,5 +1,6 @@ import { getExitedRpcSessions, getRunningRpcSessions, subscribeRunningSessions } from "@/lib/rpc-manager"; -import { attachNotificationClient, isValidNotificationId } from "@/lib/notification-hub"; +import { isValidNotificationId } from "@/lib/notification-events"; +import { attachNotificationClient } from "@/lib/notification-hub"; import { subscribeSessionFileChanges } from "@/lib/session-watcher"; export const dynamic = "force-dynamic"; @@ -7,12 +8,10 @@ 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, and -// this tab's notifications when `clientId`/`deviceId` are given (one stream -// per tab: browsers allow only six HTTP/1.1 connections per host). +// 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 params = new URL(req.url).searchParams; - const clientId = params.get("clientId"); - const deviceId = params.get("deviceId"); + 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; @@ -86,8 +85,8 @@ export async function GET(req: Request) { encode({ type: "sessions-changed", sessionIds, refreshSessionList: true }); }); - if (isValidNotificationId(clientId) && isValidNotificationId(deviceId)) { - detachNotifications = attachNotificationClient(clientId, deviceId, (message) => encode({ type: "notification", ...message })); + if (isValidNotificationId(clientId)) { + detachNotifications = attachNotificationClient(clientId, (message) => encode({ type: "notification", ...message })); } // Initial snapshot so the client renders the correct state immediately. diff --git a/app/api/notifications/devices/route.ts b/app/api/notifications/devices/route.ts index 90f54f4c8..ff4410c1f 100644 --- a/app/api/notifications/devices/route.ts +++ b/app/api/notifications/devices/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; -import type { PushSubscription } from "web-push"; -import { parseNotificationPrefs } from "@/lib/notification-events"; -import { getNotificationDevice, getVapidPublicKey, isValidNotificationId, parsePushSubscription, saveNotificationDevice } from "@/lib/notification-hub"; +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"; @@ -13,21 +12,14 @@ export async function GET(req: Request) { return NextResponse.json({ publicKey: getVapidPublicKey(), subscribed: Boolean(device?.subscription) }); } -/** - * PUT /api/notifications/devices { deviceId, prefs, subscription? } - * `subscription`: PushSubscription JSON to store, `null` to remove, omitted to keep. - */ +/** 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 }); } - let subscription: PushSubscription | null | undefined; - if (body.subscription === null) subscription = null; - else if (body.subscription !== undefined) { - subscription = parsePushSubscription(body.subscription); - if (!subscription) return NextResponse.json({ error: "Invalid push subscription", code: "invalid_subscription" }, { 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 index 3c6a4ca95..224eb8687 100644 --- a/app/api/notifications/presence/route.ts +++ b/app/api/notifications/presence/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; -import { isValidNotificationId, reportPresence } from "@/lib/notification-hub"; +import { isValidNotificationId } from "@/lib/notification-events"; +import { reportPresence } from "@/lib/notification-hub"; import { isRecord } from "@/lib/type-guards"; export const dynamic = "force-dynamic"; diff --git a/app/api/notifications/test/route.ts b/app/api/notifications/test/route.ts index 47f7dc3df..eafce070e 100644 --- a/app/api/notifications/test/route.ts +++ b/app/api/notifications/test/route.ts @@ -1,14 +1,16 @@ import { NextResponse } from "next/server"; -import { isValidNotificationId, sendTestNotification } from "@/lib/notification-hub"; +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, clientId } — one system notification to this device. */ +/** 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) || !isValidNotificationId(body.clientId)) { - return NextResponse.json({ error: "deviceId and clientId are required", code: "invalid_device" }, { status: 400 }); + if (!isRecord(body) || !isValidNotificationId(body.deviceId)) { + return NextResponse.json({ error: "deviceId is required", code: "invalid_device" }, { status: 400 }); } - return NextResponse.json({ via: await sendTestNotification(body.deviceId, body.clientId) }); + 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 875a43eb3..ce6f5dc5c 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -1346,7 +1346,8 @@ export function AppShell({ appName }: { appName: string }) { .catch(() => toast.error(translate("notifications.sessionNotFound"))); }, [handleSelectSession]); - useNotifications({ sessionId: selectedSession?.id ?? null, locale, onOpenSession: openSessionById }); + // 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/SessionSidebar.tsx b/components/SessionSidebar.tsx index 808f92646..1964b7aae 100644 --- a/components/SessionSidebar.tsx +++ b/components/SessionSidebar.tsx @@ -14,7 +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, notificationStreamQuery } from "@/lib/notification-client"; +import { NOTIFICATION_MESSAGE_EVENT, notificationClientId } from "@/lib/notification-client"; import { EMPTY_PROJECT_SET, INITIAL_RESTORE_MAX_ATTEMPTS, @@ -279,7 +279,7 @@ export const SessionSidebar = memo(function SessionSidebar({ selectedSessionId, // Live running status and session-list invalidations arrive via SSE; the // 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?${notificationStreamQuery()}`); + const source = new EventSource(`/api/agent/running/events?clientId=${encodeURIComponent(notificationClientId)}`); source.onmessage = (e) => { try { diff --git a/components/SettingsConfig.tsx b/components/SettingsConfig.tsx index 8851edfeb..babc89b50 100644 --- a/components/SettingsConfig.tsx +++ b/components/SettingsConfig.tsx @@ -19,12 +19,12 @@ 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, type NotificationType } from "@/lib/notification-events"; +import { NOTIFICATION_TYPES, renderNotification, type NotificationType } from "@/lib/notification-events"; import { ensurePushSubscription, getNotificationDeviceId, getNotificationSupport, - notificationClientId, + showSystemNotification, updateNotificationPrefs, type NotificationSupport, } from "@/lib/notification-client"; @@ -215,7 +215,7 @@ const SETTING_INDEX: SettingIndexEntry[] = [ { 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 falls back to another model, for example after a refusal or a usage limit.", 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" }, ]; @@ -521,7 +521,7 @@ const NOTIFICATION_STATUS_KEYS: Record<Exclude<NotificationSupport, "push" | "no /** Per-device notification prefs (lib/notification-client.ts), mirrored to the server. */ function NotificationSettingsPanel() { - const { t } = useI18n(); + const { t, locale } = useI18n(); const prefs = useNotificationPrefs(); const [support, setSupport] = useState<NotificationSupport | null>(null); const [pushActive, setPushActive] = useState(false); @@ -537,8 +537,10 @@ function NotificationSettingsPanel() { return () => { alive = false; }; }, []); + const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)); + const save = (patch: Parameters<typeof updateNotificationPrefs>[0]) => { - void updateNotificationPrefs(patch).catch((error: unknown) => toast.error(t("notifications.enableFailed", { error: error instanceof Error ? error.message : String(error) }))); + void updateNotificationPrefs(patch).catch((error: unknown) => toast.error(t("notifications.saveFailed"), errorText(error))); }; const setEnabled = async (enabled: boolean) => { @@ -546,33 +548,44 @@ function NotificationSettingsPanel() { 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 }); + await updateNotificationPrefs({ enabled, locale }); if (enabled) setPushActive(await ensurePushSubscription()); } catch (error) { - toast.error(t("notifications.enableFailed", { error: error instanceof Error ? error.message : String(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 () => { try { - const res = await fetch("/api/notifications/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ deviceId: getNotificationDeviceId(), clientId: notificationClientId }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + 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); + throw new Error(isRecord(body) && typeof body.error === "string" ? body.error : `HTTP ${res.status}`); + } + } else if (!(await showSystemNotification(renderNotification({ type: "test", sessionId: "", sessionName: "omp web" }, t)))) { + throw new Error(t("notifications.statusPermission")); + } toast.success(t("notifications.testSent")); } catch (error) { - toast.error(t("notifications.testFailed"), error instanceof Error ? error.message : String(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) status = t(pushActive ? "notifications.statusPush" : "notifications.statusInPage"); + else if (support && prefs.enabled) { + if (Notification.permission !== "granted") status = t("notifications.statusPermission"); + else status = t(pushActive ? "notifications.statusPush" : "notifications.statusInPage"); + } return ( <> diff --git a/docs/specs/2026-10-06-notifications-design.md b/docs/specs/2026-10-06-notifications-design.md index 05e4fd435..a061a7e04 100644 --- a/docs/specs/2026-10-06-notifications-design.md +++ b/docs/specs/2026-10-06-notifications-design.md @@ -32,9 +32,11 @@ Rules: - 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), else the `errorMessage` of the most recent - `auto_retry_start` in the same stretch (classifier refusals such as Fable - falling back to Opus, rate limits, provider failures), else omitted. + (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. @@ -54,7 +56,7 @@ omp child ──frames──▶ AgentSessionWrapper.handleFrame (lib/rpc-manager 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=&deviceId=`): browsers allow only +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` @@ -80,24 +82,31 @@ in the device's stored locale. 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}` on -mount, `visibilitychange`, session switch, `pagehide` (beacon, `visible: -false`), and every 30 seconds while visible. A report expires 60 seconds after -it was sent; a tab's entry is removed when its SSE stream closes. "Active" -means visible with a fresh report. +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. + +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 an active tab is showing `event.sessionId`, drop the event for every - device, in every mode. +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. 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 connected tabs, which - show it with `registration.showNotification`. + 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. @@ -135,11 +144,11 @@ push: iOS Safari revokes push permission for pushes that show nothing. | Route | Method | Purpose | |---|---|---| -| `/api/agent/running/events?clientId=&deviceId=` | GET | existing SSE stream, now also `{type: "notification", kind, event}` | +| `/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?}`; `subscription: null` removes it | -| `/api/notifications/test` | POST | `{deviceId, clientId}`: a system notification to this device, ignoring presence | +| `/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. @@ -157,7 +166,8 @@ A `SettingsTabs` category `notifications` (icon `Bell`). Contents: 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**. +- **Send test notification**: a real push for subscribed devices, else a + local notification from the page. ```ts interface NotificationPrefs { @@ -168,8 +178,10 @@ interface NotificationPrefs { } ``` -Prefs live in `localStorage` and are sent to the server on every change and -on app load. Strings are in `lib/i18n` (English, Japanese, Simplified Chinese). +Prefs live in `localStorage` and are sent to the server on every change, and +on app load for enabled devices. Other tabs pick changes up through the +`storage` event. Strings are in `lib/i18n` (English, Japanese, Simplified +Chinese). ## In-app toast diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index 9493a43dd..8a548aa3b 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -1,7 +1,7 @@ import { createElement, useEffect, useRef, useSyncExternalStore } from "react"; import { toast } from "@/components/ui/toast"; import { translate } from "@/lib/i18n"; -import { DEFAULT_NOTIFICATION_PREFS, renderNotification, type NotificationEvent } from "@/lib/notification-events"; +import { DEFAULT_NOTIFICATION_PREFS, renderNotification, type NotificationEvent, type RenderedNotification } from "@/lib/notification-events"; import { ensurePushSubscription, getNotificationDeviceId, @@ -9,7 +9,6 @@ import { NOTIFICATION_MESSAGE_EVENT, notificationClientId, OPEN_SESSION_EVENT, - registerNotificationWorker, showSystemNotification, subscribeNotificationPrefs, syncNotificationDevice, @@ -17,6 +16,9 @@ import { } 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; /** Current per-device notification prefs (re-renders on change). */ export function useNotificationPrefs() { @@ -25,10 +27,13 @@ export function useNotificationPrefs() { /** * App-level notification wiring, mounted once in AppShell: - * - tells the server which session this tab shows and whether it is visible; + * - 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); @@ -36,9 +41,13 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session openRef.current = onOpenSession; }, [onOpenSession]); - // Presence: every change of visibility or session, plus a keep-alive. + // Presence: visible and recently used. Reported on every change, plus a keep-alive. useEffect(() => { - const report = (visible = document.visibilityState === "visible") => { + 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; const body = JSON.stringify({ clientId: notificationClientId, deviceId: getNotificationDeviceId(), visible, sessionId }); if (!visible && navigator.sendBeacon) { navigator.sendBeacon("/api/notifications/presence", new Blob([body], { type: "application/json" })); @@ -46,16 +55,23 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session } 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(() => { - if (document.visibilityState === "visible") report(); + // 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); }; @@ -63,31 +79,21 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session // Pushes are written in the device's language. useEffect(() => { - if (getNotificationPrefs().locale !== locale) void updateNotificationPrefs({ locale }).catch(() => {}); + const prefs = getNotificationPrefs(); + if (prefs.enabled && prefs.locale !== locale) void updateNotificationPrefs({ locale }).catch(() => {}); }, [locale]); - // Startup sync: the server may have lost its store, or the browser its subscription. + // Startup sync for enabled devices: the server may have lost its store, or the browser its subscription. useEffect(() => { + if (!getNotificationPrefs().enabled) return; void (async () => { - if (getNotificationPrefs().enabled) { - await registerNotificationWorker(); - if (await ensurePushSubscription().catch(() => false)) return; - } - await syncNotificationDevice(); + if (!(await ensurePushSubscription().catch(() => false))) await syncNotificationDevice(); })().catch(() => {}); }, []); // Delivery to this tab and notification clicks. useEffect(() => { - 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 === "os") { - void showSystemNotification(rendered); - return; - } + const showToast = (rendered: RenderedNotification, type: NotificationEvent["type"]) => { const description = createElement( "span", null, @@ -107,9 +113,23 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session ) : null, ); - const show = event.type === "error" ? toast.error : toast.info; + const show = type === "error" ? toast.error : toast.info; show(rendered.title, description, { id: rendered.tag }); }; + 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); }; diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index ea3ab8a98..e19ae41c9 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -310,7 +310,8 @@ "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. This browser does not support push.", + "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.", @@ -322,7 +323,7 @@ "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 falls back to another model, for example after a refusal or a usage limit.", + "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", @@ -331,6 +332,7 @@ "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)", diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index b163182e1..ca27f2b39 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -297,7 +297,8 @@ "notifications.enable": "通知を有効にする", "notifications.enableDesc": "通知を表示する許可をブラウザーに求めます。", "notifications.statusPush": "プッシュ通知が有効です。omp-web のタブを開いていなくても届きます。", - "notifications.statusInPage": "omp-web のタブを開いている間はシステム通知が表示されます。このブラウザーはプッシュに対応していません。", + "notifications.statusInPage": "omp-web のタブを開いている間はシステム通知が表示されます。このブラウザーではプッシュを利用できません。", + "notifications.statusPermission": "ブラウザーはまだ omp-web の通知を許可していません。スイッチをオフにしてからオンにすると再度確認します。サイト設定で許可することもできます。", "notifications.statusInsecure": "システム通知には HTTPS(または localhost)が必要です。アプリ内トーストは引き続き表示されます。", "notifications.statusIosInstall": "iPhone と iPad では、まず omp-web をホーム画面に追加し、そこから開いてください。", "notifications.statusDenied": "このブラウザーは omp-web の通知をブロックしています。サイト設定で許可してください。", @@ -309,7 +310,7 @@ "notifications.typeError": "実行失敗", "notifications.typeErrorDesc": "再試行とフォールバックの後に実行がエラーで止まったとき、または omp プロセスが終了したとき。", "notifications.typeModelSwitch": "モデルの自動切り替え", - "notifications.typeModelSwitchDesc": "拒否や利用上限などにより、omp が別のモデルにフォールバックしたとき。", + "notifications.typeModelSwitchDesc": "omp が自動でモデルを切り替えたとき(拒否、レート制限、利用上限によるフォールバック、または prewalk / plan の引き継ぎ)。", "notifications.whenActive": "omp-web の別のタブを使用中のとき", "notifications.whenActiveDesc": "表示中のセッションについては何も表示しません。", "notifications.whenActiveToast": "アプリ内トーストを表示", @@ -318,6 +319,7 @@ "notifications.testSent": "テスト通知を送信しました。", "notifications.testFailed": "テスト通知を送信できませんでした。", "notifications.enableFailed": "プッシュ通知を有効にできませんでした: {error}", + "notifications.saveFailed": "通知設定を保存できませんでした。", "appShell.system": "システム", "appShell.systemPrompt": "システムプロンプト", "appShell.systemPromptEmpty": "システムプロンプトは空です(ツールは無効)", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index f7bffbcb4..2f8ca8c47 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -297,7 +297,8 @@ "notifications.enable": "启用通知", "notifications.enableDesc": "请求浏览器授予显示通知的权限。", "notifications.statusPush": "推送通知已启用,即使没有打开 omp-web 标签页也能收到。", - "notifications.statusInPage": "打开 omp-web 标签页时会显示系统通知。此浏览器不支持推送。", + "notifications.statusInPage": "打开 omp-web 标签页时会显示系统通知。此浏览器无法使用推送。", + "notifications.statusPermission": "浏览器尚未允许 omp-web 显示通知。关闭再打开开关可再次询问,也可以在网站设置中允许。", "notifications.statusInsecure": "系统通知需要 HTTPS(或 localhost)。应用内提示仍然有效。", "notifications.statusIosInstall": "在 iPhone 和 iPad 上,请先将 omp-web 添加到主屏幕,并从主屏幕打开。", "notifications.statusDenied": "此浏览器已阻止 omp-web 的通知。请在网站设置中允许。", @@ -309,7 +310,7 @@ "notifications.typeError": "运行失败", "notifications.typeErrorDesc": "运行在重试和回退后仍因错误停止,或 omp 进程退出。", "notifications.typeModelSwitch": "模型自动切换", - "notifications.typeModelSwitchDesc": "omp 回退到另一个模型,例如因拒绝或用量限制。", + "notifications.typeModelSwitchDesc": "omp 自行切换模型:因拒绝、速率限制或用量限制而回退,或 prewalk / plan 交接。", "notifications.whenActive": "当我在另一个 omp-web 标签页中操作时", "notifications.whenActiveDesc": "正在查看的会话不会显示任何通知。", "notifications.whenActiveToast": "显示应用内提示", @@ -318,6 +319,7 @@ "notifications.testSent": "测试通知已发送。", "notifications.testFailed": "无法发送测试通知。", "notifications.enableFailed": "无法启用推送通知:{error}", + "notifications.saveFailed": "无法保存通知设置。", "appShell.system": "系统", "appShell.systemPrompt": "系统提示词", "appShell.systemPromptEmpty": "系统提示词为空(工具已禁用)", diff --git a/lib/notification-client.ts b/lib/notification-client.ts index 81651557a..63481152e 100644 --- a/lib/notification-client.ts +++ b/lib/notification-client.ts @@ -5,6 +5,7 @@ */ import { DEFAULT_NOTIFICATION_PREFS, + isValidNotificationId, parseNotificationPrefs, type NotificationPrefs, type RenderedNotification, @@ -13,8 +14,15 @@ import { 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 = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `c${Date.now()}${Math.random().toString(36).slice(2)}`; +export const notificationClientId = randomId(); let deviceId: string | null = null; @@ -22,22 +30,15 @@ let deviceId: string | null = null; export function getNotificationDeviceId(): string { if (deviceId) return deviceId; try { - deviceId = localStorage.getItem(DEVICE_KEY); - if (!deviceId || !/^[A-Za-z0-9_-]{8,64}$/.test(deviceId)) { - deviceId = crypto.randomUUID(); - localStorage.setItem(DEVICE_KEY, deviceId); - } + const stored = localStorage.getItem(DEVICE_KEY); + deviceId = isValidNotificationId(stored) ? stored : randomId(); + if (deviceId !== stored) localStorage.setItem(DEVICE_KEY, deviceId); } catch { - deviceId ??= crypto.randomUUID(); + deviceId ??= randomId(); } return deviceId; } -/** Query string that attaches this tab to notification delivery on the running-sessions SSE stream. */ -export function notificationStreamQuery(): string { - return `clientId=${encodeURIComponent(notificationClientId)}&deviceId=${encodeURIComponent(getNotificationDeviceId())}`; -} - // ---------------------------------------------------------------- prefs store const listeners = new Set<() => void>(); @@ -55,12 +56,23 @@ export function getNotificationPrefs(): NotificationPrefs { } 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); - return () => listeners.delete(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)); @@ -72,18 +84,18 @@ export async function updateNotificationPrefs(patch: Partial<NotificationPrefs>) } /** - * Send prefs (and optionally a push subscription change) to the server. + * Send prefs (and optionally a new push subscription) to the server. * Returns whether the server holds a push subscription for this device. */ -export async function syncNotificationDevice(subscription?: PushSubscriptionJSON | null): Promise<boolean> { +export async function syncNotificationDevice(subscription?: PushSubscriptionJSON): Promise<boolean> { const response = await fetch("/api/notifications/devices", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ deviceId: getNotificationDeviceId(), prefs: getNotificationPrefs(), ...(subscription !== undefined ? { subscription } : {}) }), + body: JSON.stringify({ deviceId: getNotificationDeviceId(), prefs: getNotificationPrefs(), subscription }), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const body = (await response.json()) as { subscribed?: boolean }; - return body.subscribed === true; + const body: unknown = await response.json(); + return typeof body === "object" && body !== null && "subscribed" in body && body.subscribed === true; } // ---------------------------------------------------------------- support @@ -107,10 +119,12 @@ export function getNotificationSupport(): NotificationSupport { // ---------------------------------------------------------------- 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 { - return await navigator.serviceWorker.register("/sw.js", { scope: "/" }); + await navigator.serviceWorker.register("/sw.js", { scope: "/" }); + return await navigator.serviceWorker.ready; } catch { return null; } @@ -127,22 +141,33 @@ function sameKey(key: ArrayBuffer | null | undefined, expected: Uint8Array): boo return actual.length === expected.length && actual.every((byte, index) => byte === expected[index]); } +let ensuring: Promise<boolean> | null = null; + /** - * Make sure this device has a push subscription for the server's VAPID key - * and the server knows it. Needs notification permission already granted. - * Returns whether push is active. + * 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 async function ensurePushSubscription(): Promise<boolean> { +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 } = (await response.json()) as { publicKey: string }; + const { publicKey, subscribed }: { publicKey: string; subscribed: boolean } = await response.json(); const applicationServerKey = base64UrlToBytes(publicKey); let subscription = await registration.pushManager.getSubscription(); - // A server that lost its keys signs with new ones; the old subscription would be rejected. - if (subscription && !sameKey(subscription.options.applicationServerKey, applicationServerKey)) { + // 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; } @@ -150,9 +175,18 @@ export async function ensurePushSubscription(): Promise<boolean> { return syncNotificationDevice(subscription.toJSON()); } -/** Show a system notification the same way the service worker shows pushes. */ -export async function showSystemNotification(notification: RenderedNotification): Promise<void> { - if (!("Notification" in window) || Notification.permission !== "granted") return; +/** 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", @@ -160,27 +194,21 @@ export async function showSystemNotification(notification: RenderedNotification) tag: notification.tag, data: { url: notification.url, sessionId: notification.sessionId }, }; - const registration = "serviceWorker" in navigator ? await navigator.serviceWorker.getRegistration() : undefined; - if (registration) { - await registration.showNotification(notification.title, options); - return; - } - // No worker (registration failed or unsupported): Chrome on Android throws - // here, desktop browsers show it. 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 { - // blocked: nothing else to do + return false; } } - -/** 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"; diff --git a/lib/notification-events.test.mjs b/lib/notification-events.test.mjs index b37e17f45..dcbf297f5 100644 --- a/lib/notification-events.test.mjs +++ b/lib/notification-events.test.mjs @@ -9,9 +9,11 @@ const session = { sessionId: "s1", sessionName: "Refactor auth" }; function run(frames) { const detect = createNotificationDetector(); - return frames.map((frame) => detect(frame, session)).filter(Boolean); + 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" }, @@ -25,54 +27,72 @@ test("completion fires when the session settles, not per prompt or per subagent" }); test("local slash commands and aborted runs do not notify", () => { - assert.deepEqual(run([{ type: "prompt_result", agentInvoked: false, status: "completed" }]), []); + 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 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", retryable: true } }, + { type: "prompt_result", agentInvoked: true, status: "error", error: { message: "Retry budget exhausted" } }, { type: "session_settled" }, ]); - assert.deepEqual(events.map((event) => [event.type, event.detail]), [["error", "Retry budget exhausted"]]); + assert.deepEqual(summary(events), [["error", "Retry budget exhausted"]]); }); -test("the next stretch after a failure can complete normally", () => { +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: "prompt_result", agentInvoked: true, status: "completed" }, + { type: "agent_start" }, + { type: "agent_end", isTerminal: true }, { type: "session_settled" }, ]); assert.deepEqual(events.map((event) => event.type), ["error", "completed"]); }); -test("blocking dialogs notify with their question; non-blocking UI frames do not", () => { +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([ - { type: "extension_ui_request", id: "1", method: "select", title: "Which database?", options: ["a"] }, - { type: "extension_ui_request", id: "2", method: "ask", questions: [{ id: "q", question: "Ship it?", options: [] }] }, - { type: "extension_ui_request", id: "3", method: "notify", message: "fyi" }, - { type: "extension_ui_request", id: "4", method: "setStatus", statusKey: "k" }, + ...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?"], ]); - assert.deepEqual(events.map((event) => [event.type, event.detail]), [["input", "Which database?"], ["input", "Ship it?"]]); }); -test("model switch reason: explicit reason, else the preceding retry error, else none", () => { +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: "retry_fallback_applied", from: "anthropic/fable", to: "anthropic/opus", role: "default", reason: "usage reserve reached" }, - { type: "auto_retry_start", attempt: 1, errorMessage: "Request refused by the safety classifier" }, - { type: "retry_fallback_applied", from: "anthropic/fable", to: "anthropic/opus", role: "default" }, - { type: "session_settled" }, + { 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" }, - ]).filter((event) => event.type === "modelSwitch"); - assert.deepEqual(events.map((event) => event.detail), [ - "usage reserve reached", - "Request refused by the safety classifier", - undefined, - "Prewalk: switched to openai/gpt-5 after first edit call.", ]); + assert.deepEqual(events.map((event) => event.type), ["modelSwitch", "modelSwitch"]); }); test("untrusted prefs fall back to defaults field by field", () => { diff --git a/lib/notification-events.ts b/lib/notification-events.ts index 03e12b427..841a8dd02 100644 --- a/lib/notification-events.ts +++ b/lib/notification-events.ts @@ -1,3 +1,5 @@ +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). @@ -102,15 +104,22 @@ export function renderNotification(event: NotificationEvent, t: Translate): Rend }; } -const MODEL_SWITCH_NOTICE_SOURCES: Record<string, true> = { prewalk: true, "plan-yolo": true }; +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"]); -/** Extension UI methods that block the agent until the user answers. */ -const INPUT_METHODS: Record<string, true> = { select: true, confirm: true, input: true, editor: true, ask: true, open_url: true }; +const NOTIFICATION_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/; -type Frame = Record<string, unknown> & { type?: unknown }; +/** 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. @@ -118,53 +127,68 @@ const str = (value: unknown): string | undefined => (typeof value === "string" & * "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(): (frame: Frame, session: { sessionId: string; sessionName: string | null }) => NotificationEvent | null { +export function createNotificationDetector(): Detect { let lastOutcome: string | null = null; - let lastRetryError: string | undefined; + let heldFallback: NotificationEvent | null = null; + + 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 "auto_retry_start": - lastRetryError = str(frame.errorMessage); - return null; + return releaseFallback(str(frame.errorMessage)); case "retry_fallback_applied": { const from = str(frame.from); const to = str(frame.to); - if (!from || !to) return null; - return { ...base, type: "modelSwitch", from, to, detail: str(frame.reason) ?? lastRetryError }; + 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[source] !== true) return null; - return { ...base, type: "modelSwitch", detail: message }; + if (!source || !message || !MODEL_SWITCH_NOTICE_SOURCES.has(source)) return []; + return [{ ...base, type: "modelSwitch", detail: message }]; } case "prompt_result": { - if (frame.agentInvoked !== true) return null; + const events = releaseFallback(); + if (frame.agentInvoked !== true) return events; lastOutcome = str(frame.status) ?? null; - if (lastOutcome !== "error") return null; - const error = frame.error && typeof frame.error === "object" ? (frame.error as Record<string, unknown>) : {}; - return { ...base, type: "error", detail: str(error.message) }; + if (lastOutcome !== "error") return events; + 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; - lastRetryError = undefined; - if (outcome === "error" || outcome === "aborted") return null; - return { ...base, type: "completed" }; + 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" || INPUT_METHODS[frame.method] !== true) return null; - const questions = Array.isArray(frame.questions) ? frame.questions : []; - const firstQuestion = questions[0] && typeof questions[0] === "object" ? str((questions[0] as Record<string, unknown>).question) : undefined; - return { ...base, type: "input", detail: str(frame.title) ?? firstQuestion ?? str(frame.message) ?? str(frame.instructions) }; + 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 null; + return []; } }; } diff --git a/lib/notification-hub.test.mjs b/lib/notification-hub.test.mjs index d9d4a7ed1..d31b8bb73 100644 --- a/lib/notification-hub.test.mjs +++ b/lib/notification-hub.test.mjs @@ -10,83 +10,85 @@ 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 NOW = 1_000_000; const event = { type: "completed", sessionId: "s1", sessionName: "Session" }; const prefs = (patch = {}) => ({ ...DEFAULT_NOTIFICATION_PREFS, enabled: true, ...patch }); -const client = (clientId, deviceId, patch = {}) => ({ clientId, deviceId, visible: false, sessionId: null, lastSeen: NOW, send: () => {}, ...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: "p", auth: "a" } }; +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, in either mode", () => { - const clients = [client("viewer", "desk", { visible: true, sessionId: "s1" }), client("phone-tab", "phone")]; +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, clients, devices, NOW), []); + 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 clients = [ - client("desk-active", "desk", { visible: true, sessionId: "other" }), - client("desk-hidden", "desk"), - client("laptop-tab", "laptop"), - ]; + 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, clients, devices, NOW), [{ kind: "toast", clientId: "desk-active" }]); + 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 clients = [client("desk-active", "desk", { visible: true, sessionId: "other" })]; + 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, clients, devices, NOW), [ + 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 connected unsubscribed ones", () => { - const clients = [client("desk-tab", "desk"), client("phone-tab", "phone"), client("beacon-only", "laptop", { send: undefined })]; +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, clients, devices, NOW), [ + assert.deepEqual(hub.routeNotification(event, presence, all("desk-tab", "phone-tab"), devices), [ { kind: "os", clientId: "desk-tab" }, { kind: "push", deviceId: "phone" }, ]); }); -test("stale presence does not count as active", () => { - const clients = [client("old", "desk", { visible: true, sessionId: "s1", lastSeen: NOW - hub.PRESENCE_TTL_MS - 1 })]; - assert.deepEqual(hub.routeNotification(event, clients, [device("desk")], NOW), [{ kind: "os", clientId: "old" }]); -}); - test("disabled devices and disabled types get nothing", () => { - const clients = [client("tab", "desk")]; - assert.deepEqual(hub.routeNotification(event, clients, [device("desk", { prefs: prefs({ enabled: false }) })], NOW), []); - assert.deepEqual(hub.routeNotification(event, clients, [device("desk", { prefs: prefs({ types: { ...DEFAULT_NOTIFICATION_PREFS.types, completed: false } }) })], NOW), []); + 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", () => { +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({ endpoint: subscription.endpoint, keys: {} }), null); + assert.equal(hub.parsePushSubscription({ ...subscription, keys: { p256dh: "not-a-real-key", auth: "x" } }), null); assert.deepEqual(hub.parsePushSubscription(subscription), subscription); }); test("a tab stays reachable when a stale stream of the same tab attaches late and then closes", (t) => { const previousHub = globalThis.__ompNotificationHub; - globalThis.__ompNotificationHub = { clients: new Map(), streams: new Map(), store: { devices: [] } }; + const received = []; + globalThis.__ompNotificationHub = { + presence: new Map([["tab-0001", tab("tab-0001", "desk-0001", { lastSeen: Date.now() })]]), + streams: new Map(), + store: { devices: [device("desk-0001")] }, + }; t.after(() => { globalThis.__ompNotificationHub = previousHub; }); // 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", "desk-0001", () => received.push("live")); - const detachStale = hub.attachNotificationClient("tab-0001", "desk-0001", () => received.push("stale")); + const detachLive = hub.attachNotificationClient("tab-0001", () => received.push("live")); + const detachStale = hub.attachNotificationClient("tab-0001", () => received.push("stale")); detachStale(); - globalThis.__ompNotificationHub.clients.get("tab-0001").send({ kind: "toast", event }); + hub.publishNotification(event); assert.deepEqual(received, ["live"]); detachLive(); - assert.equal(globalThis.__ompNotificationHub.clients.has("tab-0001"), false); + hub.publishNotification(event); + assert.deepEqual(received, ["live"]); }); -test("a push service 410 removes the subscription but keeps the device prefs", async (t) => { +function isolatedStore(t) { const dir = mkdtempSync(join(tmpdir(), "omp-notify-")); const previousDir = process.env.PI_CODING_AGENT_DIR; const previousSend = webpush.sendNotification; @@ -100,21 +102,49 @@ test("a push service 410 removes the subscription but keeps the device prefs", a else process.env.PI_CODING_AGENT_DIR = previousDir; rmSync(dir, { recursive: true, force: true }); }); + return dir; +} - let sent = 0; - webpush.sendNotification = async () => { - sent += 1; - throw new webpush.WebPushError("Gone", 410, {}, "", subscription.endpoint); - }; - hub.saveNotificationDevice("phone-device", prefs(), subscription); - // The store holds the VAPID private key: owner-only. +test("devices and push keys survive a server restart; the store is owner-only", (t) => { + const dir = isolatedStore(t); + hub.saveNotificationDevice("phone-device", prefs({ locale: "ja" }), subscription); + const publicKey = hub.getVapidPublicKey(); assert.equal(statSync(join(dir, "omp-web", "notifications.json")).mode & 0o077, 0); + globalThis.__ompNotificationHub = undefined; + assert.equal(hub.getVapidPublicKey(), publicKey); + assert.deepEqual(hub.getNotificationDevice("phone-device").subscription, subscription); + assert.equal(hub.getNotificationDevice("phone-device").prefs.locale, "ja"); +}); + +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.equal(sent, 1); - const stored = hub.getNotificationDevice("phone-device"); - assert.equal(stored.subscription, undefined); - assert.equal(stored.prefs.enabled, true); + 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 index 5b5f5777d..aadbfd349 100644 --- a/lib/notification-hub.ts +++ b/lib/notification-hub.ts @@ -6,12 +6,14 @@ 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 @@ -23,17 +25,17 @@ import { 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"; -export interface NotificationClient { +/** 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; lastSeen: number; - /** Set while the tab holds its notification SSE stream. */ - send?: (message: NotificationMessage) => void; } export interface NotificationDevice { @@ -45,6 +47,8 @@ export interface NotificationDevice { 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 }; @@ -55,9 +59,9 @@ interface StoreFile { } interface HubState { - clients: Map<string, NotificationClient>; - /** Open SSE streams per client: a remount or reconnect can overlap the old stream's close. */ - streams: Map<string, Set<(message: NotificationMessage) => void>>; + 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>>; store: StoreFile | null; } @@ -66,27 +70,26 @@ declare global { } function hub(): HubState { - return (globalThis.__ompNotificationHub ??= { clients: new Map(), streams: new Map(), store: null }); + return (globalThis.__ompNotificationHub ??= { presence: new Map(), streams: new Map(), store: null }); } -const ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/; - -export function isValidNotificationId(value: unknown): value is string { - return typeof value === "string" && ID_PATTERN.test(value); -} +// 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 (!value || typeof value !== "object") return null; - const raw = value as Record<string, unknown>; - const keys = raw.keys && typeof raw.keys === "object" ? (raw.keys as Record<string, unknown>) : {}; - if (typeof raw.endpoint !== "string" || typeof keys.p256dh !== "string" || typeof keys.auth !== "string") return 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(raw.endpoint).protocol !== "https:") return null; + if (new URL(endpoint).protocol !== "https:") return null; } catch { return null; } - return { endpoint: raw.endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } }; + return { endpoint, keys: { p256dh, auth } }; } // ---------------------------------------------------------------- store @@ -101,16 +104,18 @@ function loadStore(): StoreFile { let store: StoreFile = { devices: [] }; try { if (existsSync(storePath())) { - const parsed = JSON.parse(readFileSync(storePath(), "utf8")) as Partial<StoreFile>; - const devices = Array.isArray(parsed.devices) ? parsed.devices : []; - store = { - vapid: parsed.vapid && typeof parsed.vapid.publicKey === "string" && typeof parsed.vapid.privateKey === "string" ? parsed.vapid : undefined, - devices: devices.flatMap((device) => { - if (!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 }]; - }), - }; + 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. @@ -153,22 +158,16 @@ export function getNotificationDevice(deviceId: string): NotificationDevice | un return loadStore().devices.find((device) => device.deviceId === deviceId); } -/** - * Store a device's prefs. `subscription`: a value replaces the stored one, - * `null` removes it, `undefined` keeps it. - */ -export function saveNotificationDevice(deviceId: string, prefs: NotificationPrefs, subscription: PushSubscription | null | undefined): NotificationDevice { +/** 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 === undefined ? existing?.subscription : subscription ?? undefined, - updatedAt: Date.now(), - }; + 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)); - store.devices = [device, ...others].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_DEVICES); + // 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; } @@ -181,75 +180,55 @@ function dropSubscription(deviceId: string, endpoint: string): void { saveStore(store); } -// ---------------------------------------------------------------- presence +// ---------------------------------------------------------------- presence and streams -function liveClients(now: number): NotificationClient[] { - const { clients } = hub(); - for (const [id, client] of clients) { - if (!client.send && now - client.lastSeen > PRESENCE_TTL_MS) clients.delete(id); +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 [...clients.values()]; + return [...presence.values()]; } -export function reportPresence(report: { clientId: string; deviceId: string; visible: boolean; sessionId: string | null }): void { - const { clients } = hub(); - const existing = clients.get(report.clientId); - clients.set(report.clientId, { ...existing, ...report, lastSeen: Date.now() }); +export function reportPresence(report: Omit<NotificationPresence, "lastSeen">): void { + hub().presence.set(report.clientId, { ...report, lastSeen: Date.now() }); } /** Attach a tab's SSE stream. Returns the detach callback. */ -export function attachNotificationClient(clientId: string, deviceId: string, send: (message: NotificationMessage) => void): () => void { - const state = hub(); - const streams = state.streams.get(clientId) ?? new Set(); - streams.add(send); - state.streams.set(clientId, streams); - const existing = state.clients.get(clientId); - state.clients.set(clientId, { - clientId, - deviceId, - visible: existing?.visible ?? false, - sessionId: existing?.sessionId ?? null, - lastSeen: existing?.lastSeen ?? 0, - send: (message) => streams.forEach((stream) => stream(message)), - }); +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 () => { - streams.delete(send); - // The tab is gone once its last stream closes; its presence goes with it. - if (streams.size === 0 && state.streams.get(clientId) === streams) { - state.streams.delete(clientId); - state.clients.delete(clientId); - } + own.delete(send); + if (own.size === 0 && streams.get(clientId) === own) streams.delete(clientId); }; } // ---------------------------------------------------------------- routing -function isActive(client: NotificationClient, now: number): boolean { - return client.visible && now - client.lastSeen <= PRESENCE_TTL_MS; -} - /** - * Decide where one event goes. + * 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 tab and the device prefers toasts: toast to - * that device's active tabs only (other devices stay quiet). + * 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 connected tabs. + * subscription, else in-page on each of its reachable tabs. */ -export function routeNotification(event: NotificationEvent, clients: NotificationClient[], devices: NotificationDevice[], now: number): Delivery[] { +export function routeNotification(event: NotificationEvent, presence: NotificationPresence[], reachable: ReadonlySet<string>, devices: NotificationDevice[]): Delivery[] { if (event.type === "test") return []; - if (clients.some((client) => isActive(client, now) && client.sessionId === event.sessionId)) return []; - const anyActive = clients.some((client) => isActive(client, now)); + if (presence.some((tab) => tab.visible && tab.sessionId === event.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; - const own = clients.filter((client) => client.deviceId === device.deviceId && client.send); - if (anyActive && device.prefs.whenActive === "toast") { - for (const client of own) if (isActive(client, now)) deliveries.push({ kind: "toast", clientId: client.clientId }); + 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 client of own) deliveries.push({ kind: "os", clientId: client.clientId }); + for (const tab of presence) if (tab.deviceId === device.deviceId && reachable.has(tab.clientId)) deliveries.push({ kind: "os", clientId: tab.clientId }); } } return deliveries; @@ -265,53 +244,56 @@ function renderFor(event: NotificationEvent, locale: string): RenderedNotificati }); } -async function sendPush(device: NotificationDevice, event: NotificationEvent): Promise<void> { +/** 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; - const { publicKey, privateKey } = vapidKeys(); + 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; - // 404/410: the browser unsubscribed or the subscription expired. - if (status === 404 || status === 410) dropSubscription(device.deviceId, subscription.endpoint); - else console.warn("[notifications] push failed:", status ?? String(error)); - } -} - -function sendToClient(clientId: string, message: NotificationMessage): void { - try { - hub().clients.get(clientId)?.send?.(message); - } catch { - // A closing stream must not break delivery to the others. + 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 now = Date.now(); + const state = hub(); const devices = loadStore().devices; - for (const delivery of routeNotification(event, liveClients(now), devices, now)) { + 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); - } else { - sendToClient(delivery.clientId, { kind: delivery.kind, 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": the requesting device's system path, ignoring presence. */ -export async function sendTestNotification(deviceId: string, clientId: string): Promise<"push" | "os"> { - const event: NotificationEvent = { type: "test", sessionId: "", sessionName: "omp web" }; +/** 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?.subscription) { - await sendPush(device, event); - return "push"; - } - sendToClient(clientId, { kind: "os", event }); - return "os"; + 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..76b6c6a66 100644 --- a/lib/rpc-manager.test.mjs +++ b/lib/rpc-manager.test.mjs @@ -616,6 +616,70 @@ 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 previousHub = globalThis.__ompNotificationHub; + const previousExited = globalThis.__ompExitedSessions; + 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])])]]), + store: { + devices: [{ + deviceId: "desk-0001", + prefs: { enabled: true, types: { completed: true, input: true, error: true, modelSwitch: true }, whenActive: "toast", locale: "en" }, + updatedAt: 0, + }], + }, + }; + let feed; + const fakeProc = { + isAlive: true, + waitReady: async () => ({ type: "ready", protocolVersion: 2 }), + negotiateProtocol: async () => {}, + onFrame: (handler) => { feed = handler; return () => {}; }, + sendCommand: async (command) => command.type === "get_state" + ? { sessionId: "notify-session", sessionFile: "/tmp/notify-session.jsonl", isStreaming: false, isCompacting: false } + : {}, + 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; + }); + return { wrapper, feed, delivered }; +} + +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 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 f58d95648..ed415e4f6 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -20,7 +20,7 @@ import { PRESET_FULL } from "./tool-presets"; import { comparableProjectPath } from "./comparable-path"; import { samePath } from "./paths"; import { isRecord } from "./type-guards"; -import { createNotificationDetector } from "./notification-events"; +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"; @@ -110,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) => @@ -865,8 +861,9 @@ export class AgentSessionWrapper { } } - const notification = this.detectNotification(event, { sessionId: this._sessionId, sessionName: this.notificationSessionName() }); - if (notification) publishNotification(notification); + for (const notification of this.detectNotification(event, { sessionId: this._sessionId, sessionName: this.notificationSessionName() })) { + publishNotification(notification); + } this.emit(event); notifyRunningChange({ refreshSessionList }); } @@ -907,7 +904,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/public/sw.js b/public/sw.js index 6cb7e85b4..53cafe6ef 100644 --- a/public/sw.js +++ b/public/sw.js @@ -31,10 +31,14 @@ self.addEventListener("notificationclick", (event) => { 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) { - await client.focus(); - // The page selects the session in place, keeping its live state. - if (sessionId) client.postMessage({ type: "omp-open-session", sessionId }); - return; + try { + await client.focus(); + // The page selects the session in place, keeping its live state. + if (sessionId) client.postMessage({ type: "omp-open-session", sessionId }); + return; + } catch { + // focus() can be refused; open a window instead. + } } await self.clients.openWindow(url); })(), From 9d5e854a988b8ea9f288471840a62ac740720075 Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Tue, 6 Oct 2026 12:12:57 +0000 Subject: [PATCH 04/18] test(notifications): skip POSIX mode check on Windows --- lib/notification-hub.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/notification-hub.test.mjs b/lib/notification-hub.test.mjs index d31b8bb73..3dab0568b 100644 --- a/lib/notification-hub.test.mjs +++ b/lib/notification-hub.test.mjs @@ -109,7 +109,8 @@ test("devices and push keys survive a server restart; the store is owner-only", const dir = isolatedStore(t); hub.saveNotificationDevice("phone-device", prefs({ locale: "ja" }), subscription); const publicKey = hub.getVapidPublicKey(); - assert.equal(statSync(join(dir, "omp-web", "notifications.json")).mode & 0o077, 0); + // Windows has no POSIX permission bits to check. + if (process.platform !== "win32") assert.equal(statSync(join(dir, "omp-web", "notifications.json")).mode & 0o077, 0); globalThis.__ompNotificationHub = undefined; assert.equal(hub.getVapidPublicKey(), publicKey); From 2e5098deac359e27a3006d0b13452c55d34ffaf6 Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Tue, 6 Oct 2026 12:41:23 +0000 Subject: [PATCH 05/18] fix(notifications): address Codex and CodeRabbit review - Notify a crash while background work started by the run is unsettled. - Read the notification store from disk on every use so omp-web instances sharing the agent directory see each other's devices and keys. - Keep notification clicks through an expired sign-in: proxy.ts carries /?session= to /login as next, the login form returns there, and the worker navigates non-app windows. - Show the test notification as a toast where system notifications are not allowed. --- AGENTS.md | 13 +++- components/AppShell.tsx | 22 ++++--- components/LoginForm.tsx | 4 +- components/SettingsConfig.tsx | 7 ++- docs/specs/2026-10-06-notifications-design.md | 15 +++-- lib/notification-hub.test.mjs | 59 ++++++++++--------- lib/notification-hub.ts | 12 ++-- lib/rpc-manager.test.mjs | 33 ++++++++--- lib/rpc-manager.ts | 10 +++- proxy.ts | 5 +- public/sw.js | 12 +++- 11 files changed, 129 insertions(+), 63 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 015564b55..0a3468338 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -622,13 +622,20 @@ during the wait. 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). Push text is rendered server-side in the device's locale. Only - enabled devices sync on load. + 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. Only enabled devices sync on load. +- 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. + 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) diff --git a/components/AppShell.tsx b/components/AppShell.tsx index ce6f5dc5c..a28b55880 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -1333,17 +1333,25 @@ export function AppShell({ appName }: { appName: string }) { }, []); // Notification clicks name a session id; the session list carries its cwd - // and project, which selecting needs. + // 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(); + // 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) => (r.ok ? (r.json() as Promise<{ sessions: SessionInfo[] }>) : null)) - .then((d) => { - const session = d?.sessions.find((s) => s.id === sessionId); - if (session) handleSelectSession(session); - else toast.error(translate("notifications.sessionNotFound")); + .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(() => toast.error(translate("notifications.sessionNotFound"))); + .catch(() => window.location.assign(sessionUrl)); }, [handleSelectSession]); // Full-page Settings hides the chat, so its session counts as not viewed. 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/SettingsConfig.tsx b/components/SettingsConfig.tsx index babc89b50..376cd3462 100644 --- a/components/SettingsConfig.tsx +++ b/components/SettingsConfig.tsx @@ -560,6 +560,7 @@ function NotificationSettingsPanel() { // 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", { @@ -571,8 +572,10 @@ function NotificationSettingsPanel() { const body: unknown = await res.json().catch(() => null); throw new Error(isRecord(body) && typeof body.error === "string" ? body.error : `HTTP ${res.status}`); } - } else if (!(await showSystemNotification(renderNotification({ type: "test", sessionId: "", sessionName: "omp web" }, t)))) { - throw new Error(t("notifications.statusPermission")); + } 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) { diff --git a/docs/specs/2026-10-06-notifications-design.md b/docs/specs/2026-10-06-notifications-design.md index a061a7e04..0d5c7fa66 100644 --- a/docs/specs/2026-10-06-notifications-design.md +++ b/docs/specs/2026-10-06-notifications-design.md @@ -117,7 +117,10 @@ push: iOS Safari revokes push permission for pushes that show nothing. - `~/.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. + 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. @@ -133,12 +136,16 @@ push: iOS Safari revokes push permission for pushes that show nothing. - 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 app window and `postMessage({type: - "omp-open-session", sessionId})` to it, so the app selects the session in - place; with no window, `clients.openWindow(url)`. +- `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 diff --git a/lib/notification-hub.test.mjs b/lib/notification-hub.test.mjs index 3dab0568b..a078a11a6 100644 --- a/lib/notification-hub.test.mjs +++ b/lib/notification-hub.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -65,29 +65,6 @@ test("push subscriptions must use an HTTPS endpoint and real key sizes", () => { assert.deepEqual(hub.parsePushSubscription(subscription), subscription); }); -test("a tab stays reachable when a stale stream of the same tab attaches late and then closes", (t) => { - const previousHub = globalThis.__ompNotificationHub; - const received = []; - globalThis.__ompNotificationHub = { - presence: new Map([["tab-0001", tab("tab-0001", "desk-0001", { lastSeen: Date.now() })]]), - streams: new Map(), - store: { devices: [device("desk-0001")] }, - }; - t.after(() => { globalThis.__ompNotificationHub = previousHub; }); - - // React strict mode / reconnects: the cancelled request reaches the server - // after the live one, then its abort fires. - 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"]); -}); - function isolatedStore(t) { const dir = mkdtempSync(join(tmpdir(), "omp-notify-")); const previousDir = process.env.PI_CODING_AGENT_DIR; @@ -105,17 +82,43 @@ function isolatedStore(t) { return dir; } -test("devices and push keys survive a server restart; the store is owner-only", (t) => { +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(join(dir, "omp-web", "notifications.json")).mode & 0o077, 0); + 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)); - globalThis.__ompNotificationHub = undefined; 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); - assert.equal(hub.getNotificationDevice("phone-device").prefs.locale, "ja"); +}); + +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 }); + + // 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("pushes are written in the device's language with urgency by type", async (t) => { diff --git a/lib/notification-hub.ts b/lib/notification-hub.ts index aadbfd349..9a238bc24 100644 --- a/lib/notification-hub.ts +++ b/lib/notification-hub.ts @@ -62,7 +62,6 @@ 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>>; - store: StoreFile | null; } declare global { @@ -70,7 +69,7 @@ declare global { } function hub(): HubState { - return (globalThis.__ompNotificationHub ??= { presence: new Map(), streams: new Map(), store: null }); + 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. @@ -98,9 +97,13 @@ 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 { - const state = hub(); - if (state.store) return state.store; let store: StoreFile = { devices: [] }; try { if (existsSync(storePath())) { @@ -120,7 +123,6 @@ function loadStore(): StoreFile { } catch { // A corrupt file only loses device prefs; tabs re-register on load. } - state.store = store; return store; } diff --git a/lib/rpc-manager.test.mjs b/lib/rpc-manager.test.mjs index 76b6c6a66..0669baa9e 100644 --- a/lib/rpc-manager.test.mjs +++ b/lib/rpc-manager.test.mjs @@ -618,20 +618,27 @@ test("a resumed session crash is retained before its initial handshake", async ( /** 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])])]]), - store: { - devices: [{ - deviceId: "desk-0001", - prefs: { enabled: true, types: { completed: true, input: true, error: true, modelSwitch: true }, whenActive: "toast", locale: "en" }, - updatedAt: 0, - }], - }, }; let feed; const fakeProc = { @@ -652,6 +659,9 @@ async function notifyingSession(t) { 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 }; } @@ -674,6 +684,15 @@ test("a crash during a run notifies the user once", async (t) => { 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("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" }); diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index ed415e4f6..a6ff04b43 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -328,6 +328,8 @@ export class AgentSessionWrapper { 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; 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; @@ -656,8 +658,8 @@ 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.emit({ type: "agent_end", isTerminal: true, messages: [] }); + if (this.streaming || this.promptRunning || this.unsettled) { publishNotification({ type: "error", sessionId: this._sessionId, sessionName: this.notificationSessionName(), detail: detail || status }); } this.destroy(); @@ -687,6 +689,7 @@ export class AgentSessionWrapper { break; } case "agent_start": + this.unsettled = true; this.promptRunning = true; this.streaming = true; this.awaitingAgentStart = false; @@ -719,6 +722,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; diff --git a/proxy.ts b/proxy.ts index 2b5378ec1..499da6b05 100644 --- a/proxy.ts +++ b/proxy.ts @@ -22,7 +22,10 @@ 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 diff --git a/public/sw.js b/public/sw.js index 53cafe6ef..c79c988b1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -33,11 +33,17 @@ self.addEventListener("notificationclick", (event) => { if (client) { try { await client.focus(); - // The page selects the session in place, keeping its live state. - if (sessionId) client.postMessage({ type: "omp-open-session", sessionId }); + // 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() can be refused; open a window instead. + // focus() or navigate() can be refused; open a window instead. } } await self.clients.openWindow(url); From 6ca2871ed478af44493863c8fd0103a37240244d Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Tue, 6 Oct 2026 12:53:11 +0000 Subject: [PATCH 06/18] fix(notifications): notify failed prompt responses; honor moved-session ids - Feed omp-web's prompt_error (async prompt failure) and process exits through the detector, reporting one failure per run. - Carry a moved session's earlier ids so tabs still showing an old id count as viewing it. --- AGENTS.md | 4 ++++ docs/specs/2026-10-06-notifications-design.md | 6 +++-- lib/notification-events.test.mjs | 10 ++++++++ lib/notification-events.ts | 21 +++++++++++++++- lib/notification-hub.test.mjs | 6 +++++ lib/notification-hub.ts | 3 ++- lib/rpc-manager.test.mjs | 6 +++++ lib/rpc-manager.ts | 24 +++++++++++++------ 8 files changed, 69 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a3468338..d7ce138d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -600,6 +600,10 @@ during the wait. 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; diff --git a/docs/specs/2026-10-06-notifications-design.md b/docs/specs/2026-10-06-notifications-design.md index 0d5c7fa66..819cbbe4b 100644 --- a/docs/specs/2026-10-06-notifications-design.md +++ b/docs/specs/2026-10-06-notifications-design.md @@ -16,7 +16,7 @@ notification is shown for a session the user is currently viewing. |---|---|---| | `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: <title or first question>" | -| `error` | `prompt_result` with `agentInvoked: true` and `status: "error"`, or omp child exit while the session was running | "Run failed: <error.message>" | +| `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. @@ -98,7 +98,9 @@ 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. + 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 diff --git a/lib/notification-events.test.mjs b/lib/notification-events.test.mjs index dcbf297f5..254967f31 100644 --- a/lib/notification-events.test.mjs +++ b/lib/notification-events.test.mjs @@ -31,6 +31,16 @@ test("local slash commands and aborted runs do not notify", () => { 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" } }, diff --git a/lib/notification-events.ts b/lib/notification-events.ts index 841a8dd02..77e66bdfc 100644 --- a/lib/notification-events.ts +++ b/lib/notification-events.ts @@ -17,6 +17,8 @@ export interface NotificationEvent { 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 { @@ -135,6 +137,8 @@ type Detect = (frame: Record<string, unknown>, session: { sessionId: string; ses 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; @@ -145,6 +149,19 @@ export function createNotificationDetector(): Detect { 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": { @@ -168,7 +185,8 @@ export function createNotificationDetector(): Detect { const events = releaseFallback(); if (frame.agentInvoked !== true) return events; lastOutcome = str(frame.status) ?? null; - if (lastOutcome !== "error") return events; + 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": @@ -177,6 +195,7 @@ export function createNotificationDetector(): Detect { const events = releaseFallback(); const outcome = lastOutcome; lastOutcome = null; + failureReported = false; if (outcome === "error" || outcome === "aborted") return events; return [...events, { ...base, type: "completed" }]; } diff --git a/lib/notification-hub.test.mjs b/lib/notification-hub.test.mjs index a078a11a6..2ddeac708 100644 --- a/lib/notification-hub.test.mjs +++ b/lib/notification-hub.test.mjs @@ -53,6 +53,12 @@ test("nobody active: push to subscribed devices, in-page to reachable tabs of un ]); }); +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 }) })]), []); diff --git a/lib/notification-hub.ts b/lib/notification-hub.ts index 9a238bc24..4de88fc84 100644 --- a/lib/notification-hub.ts +++ b/lib/notification-hub.ts @@ -220,7 +220,8 @@ export function attachNotificationClient(clientId: string, send: Send): () => vo */ export function routeNotification(event: NotificationEvent, presence: NotificationPresence[], reachable: ReadonlySet<string>, devices: NotificationDevice[]): Delivery[] { if (event.type === "test") return []; - if (presence.some((tab) => tab.visible && tab.sessionId === event.sessionId)) 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) { diff --git a/lib/rpc-manager.test.mjs b/lib/rpc-manager.test.mjs index 0669baa9e..e93c3f501 100644 --- a/lib/rpc-manager.test.mjs +++ b/lib/rpc-manager.test.mjs @@ -693,6 +693,12 @@ test("a crash after the turn ended but before background work settled still noti 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" }); diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index a6ff04b43..b7cc2a5bb 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -330,6 +330,8 @@ export class AgentSessionWrapper { 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; @@ -564,6 +566,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 }); @@ -660,13 +664,19 @@ export class AgentSessionWrapper { // 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) { - publishNotification({ type: "error", sessionId: this._sessionId, sessionName: this.notificationSessionName(), detail: detail || status }); + this.publishNotifications({ type: "process_exit", detail: detail || status }); } this.destroy(); } - private notificationSessionName(): string | null { - return this._sessionName || this.cwd.split(/[\\/]/).filter(Boolean).pop() || null; + 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 { @@ -779,7 +789,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; } @@ -867,9 +879,7 @@ export class AgentSessionWrapper { } } - for (const notification of this.detectNotification(event, { sessionId: this._sessionId, sessionName: this.notificationSessionName() })) { - publishNotification(notification); - } + this.publishNotifications(event); this.emit(event); notifyRunningChange({ refreshSessionList }); } From 1cb4641f02d11034634ab5195aac08897d67a6f6 Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Tue, 6 Oct 2026 21:54:46 +0000 Subject: [PATCH 07/18] fix(notifications): drop out-of-order presence reports; serialize prefs sync - Presence reports carry a per-page seq; the server ignores older ones. - Device PUTs run one at a time and read the prefs when sent. --- AGENTS.md | 6 +++-- app/api/notifications/presence/route.ts | 9 ++++--- docs/specs/2026-10-06-notifications-design.md | 5 +++- hooks/useNotifications.ts | 5 +++- lib/notification-client.ts | 24 ++++++++++++------- lib/notification-hub.test.mjs | 14 ++++++++++- lib/notification-hub.ts | 7 +++++- 7 files changed, 53 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d7ce138d9..abb92c98a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ app/api/ agent/[id]/events/route.ts GET SSE stream 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 } + 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 @@ -611,7 +611,9 @@ during the wait. `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`). + 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: diff --git a/app/api/notifications/presence/route.ts b/app/api/notifications/presence/route.ts index 224eb8687..cc3175195 100644 --- a/app/api/notifications/presence/route.ts +++ b/app/api/notifications/presence/route.ts @@ -5,13 +5,16 @@ import { isRecord } from "@/lib/type-guards"; export const dynamic = "force-dynamic"; -/** POST /api/notifications/presence { clientId, deviceId, visible, sessionId } — which session this tab shows. */ +/** 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") { + 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 }); + reportPresence({ clientId: body.clientId, deviceId: body.deviceId, visible: body.visible, sessionId, seq: Number(body.seq) }); return new NextResponse(null, { status: 204 }); } diff --git a/docs/specs/2026-10-06-notifications-design.md b/docs/specs/2026-10-06-notifications-design.md index 819cbbe4b..2cd1a05ef 100644 --- a/docs/specs/2026-10-06-notifications-design.md +++ b/docs/specs/2026-10-06-notifications-design.md @@ -81,12 +81,15 @@ in the device's stored locale. 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}` on +`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 diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index 8a548aa3b..27536ac14 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -19,6 +19,8 @@ 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() { @@ -48,7 +50,8 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session const present = () => document.visibilityState === "visible" && Date.now() - lastInput < IDLE_AFTER_MS; const report = (visible = present()) => { reported = visible; - const body = JSON.stringify({ clientId: notificationClientId, deviceId: getNotificationDeviceId(), visible, sessionId }); + 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; diff --git a/lib/notification-client.ts b/lib/notification-client.ts index 63481152e..c329cd7fa 100644 --- a/lib/notification-client.ts +++ b/lib/notification-client.ts @@ -83,19 +83,27 @@ export async function updateNotificationPrefs(patch: Partial<NotificationPrefs>) 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 async function syncNotificationDevice(subscription?: PushSubscriptionJSON): Promise<boolean> { - const response = await fetch("/api/notifications/devices", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ deviceId: getNotificationDeviceId(), prefs: getNotificationPrefs(), subscription }), +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; }); - 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 diff --git a/lib/notification-hub.test.mjs b/lib/notification-hub.test.mjs index 2ddeac708..e6f471ae7 100644 --- a/lib/notification-hub.test.mjs +++ b/lib/notification-hub.test.mjs @@ -111,7 +111,7 @@ test("another omp-web instance sharing the agent dir sees the same keys and devi 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 }); + 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. @@ -127,6 +127,18 @@ test("a tab stays reachable when a stale stream of the same tab attaches late an 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 = []; diff --git a/lib/notification-hub.ts b/lib/notification-hub.ts index 4de88fc84..6f1f2bd3a 100644 --- a/lib/notification-hub.ts +++ b/lib/notification-hub.ts @@ -35,6 +35,8 @@ export interface NotificationPresence { 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; } @@ -193,7 +195,10 @@ function livePresence(now: number): NotificationPresence[] { } export function reportPresence(report: Omit<NotificationPresence, "lastSeen">): void { - hub().presence.set(report.clientId, { ...report, lastSeen: Date.now() }); + 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. */ From 02891b8b3914d2ffc156c5db5ee0f9c34bb35d58 Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Tue, 6 Oct 2026 22:28:58 +0000 Subject: [PATCH 08/18] fix(notifications): resync saved prefs on load even when disabled A failed save that turned notifications off left the server pushing. Browsers that ever saved settings now sync on every load; browsers that never opened the setting still stay off the device list. --- AGENTS.md | 2 +- docs/specs/2026-10-06-notifications-design.md | 2 +- hooks/useNotifications.ts | 11 ++++++++--- lib/notification-client.ts | 9 +++++++++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index abb92c98a..c90612ef2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -630,7 +630,7 @@ during the wait. `~/.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. Only enabled devices sync on load. + 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 diff --git a/docs/specs/2026-10-06-notifications-design.md b/docs/specs/2026-10-06-notifications-design.md index 2cd1a05ef..116d9d60b 100644 --- a/docs/specs/2026-10-06-notifications-design.md +++ b/docs/specs/2026-10-06-notifications-design.md @@ -191,7 +191,7 @@ interface NotificationPrefs { ``` Prefs live in `localStorage` and are sent to the server on every change, and -on app load for enabled devices. Other tabs pick changes up through the +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). diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index 27536ac14..071073527 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -6,6 +6,7 @@ import { ensurePushSubscription, getNotificationDeviceId, getNotificationPrefs, + hasStoredNotificationPrefs, NOTIFICATION_MESSAGE_EVENT, notificationClientId, OPEN_SESSION_EVENT, @@ -86,11 +87,15 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session if (prefs.enabled && prefs.locale !== locale) void updateNotificationPrefs({ locale }).catch(() => {}); }, [locale]); - // Startup sync for enabled devices: the server may have lost its store, or the browser its subscription. + // 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 (!getNotificationPrefs().enabled) return; + if (!hasStoredNotificationPrefs()) return; void (async () => { - if (!(await ensurePushSubscription().catch(() => false))) await syncNotificationDevice(); + const pushActive = getNotificationPrefs().enabled && (await ensurePushSubscription().catch(() => false)); + if (!pushActive) await syncNotificationDevice(); })().catch(() => {}); }, []); diff --git a/lib/notification-client.ts b/lib/notification-client.ts index c329cd7fa..995bbe5c2 100644 --- a/lib/notification-client.ts +++ b/lib/notification-client.ts @@ -55,6 +55,15 @@ export function getNotificationPrefs(): NotificationPrefs { 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) => { From 5aefbb7c4c41a51e5aa2935b1474d2ebca04bd4d Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Tue, 6 Oct 2026 23:04:48 +0000 Subject: [PATCH 09/18] fix(notifications): clear moved-session aliases on a real switch; show repaired push status - A genuine session switch drops the previous conversation's moved-from ids, so a tab left on one of them no longer silences the new session. - Settings joins the single-flight push repair for enabled browsers, so its status and test button reflect the subscription after the repair. --- components/SettingsConfig.tsx | 13 +++++++++---- lib/rpc-manager.test.mjs | 26 ++++++++++++++++++++++---- lib/rpc-manager.ts | 2 ++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/components/SettingsConfig.tsx b/components/SettingsConfig.tsx index 376cd3462..2bed7615a 100644 --- a/components/SettingsConfig.tsx +++ b/components/SettingsConfig.tsx @@ -23,6 +23,7 @@ import { NOTIFICATION_TYPES, renderNotification, type NotificationType } from "@ import { ensurePushSubscription, getNotificationDeviceId, + getNotificationPrefs, getNotificationSupport, showSystemNotification, updateNotificationPrefs, @@ -530,10 +531,14 @@ function NotificationSettingsPanel() { useEffect(() => { let alive = true; setSupport(getNotificationSupport()); - fetch(`/api/notifications/devices?deviceId=${encodeURIComponent(getNotificationDeviceId())}`) - .then((res) => (res.ok ? res.json() : null)) - .then((data: { subscribed?: boolean } | null) => { if (alive) setPushActive(data?.subscribed === true); }) - .catch(() => {}); + // 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<boolean> = 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; }; }, []); diff --git a/lib/rpc-manager.test.mjs b/lib/rpc-manager.test.mjs index e93c3f501..c4be13aa1 100644 --- a/lib/rpc-manager.test.mjs +++ b/lib/rpc-manager.test.mjs @@ -641,14 +641,14 @@ async function notifyingSession(t) { 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" - ? { sessionId: "notify-session", sessionFile: "/tmp/notify-session.jsonl", isStreaming: false, isCompacting: false } - : {}, + sendCommand: async (command) => command.type === "get_state" ? { ...ompState } : {}, sendFrame: () => {}, dispose: async () => {}, }; @@ -663,9 +663,27 @@ async function notifyingSession(t) { else process.env.PI_CODING_AGENT_DIR = previousDir; rmSync(dir, { recursive: true, force: true }); }); - return { wrapper, feed, delivered }; + 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" }); diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index b7cc2a5bb..633526dfb 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -555,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 ?? ""; From 02ab6e63c2bfa207ab3be4acb334a30eab1f69c8 Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Wed, 7 Oct 2026 06:18:45 +0000 Subject: [PATCH 10/18] fix(notifications): dim notification settings while notifications are off --- components/SettingsConfig.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/components/SettingsConfig.tsx b/components/SettingsConfig.tsx index 2bed7615a..13ebc1e16 100644 --- a/components/SettingsConfig.tsx +++ b/components/SettingsConfig.tsx @@ -602,11 +602,11 @@ function NotificationSettingsPanel() { </NativeSetting> {status && <Alert variant={support === "push" || support === "no-push" ? "info" : "warning"} description={status} />} {NOTIFICATION_TYPES.map((type) => ( - <NativeSetting key={type} searchId={`notifications-${type}`} scope="UI" label={t(NOTIFICATION_TYPE_LABELS[type].label)} description={t(NOTIFICATION_TYPE_LABELS[type].desc)}> + <NativeSetting key={type} searchId={`notifications-${type}`} scope="UI" disabled={!prefs.enabled} label={t(NOTIFICATION_TYPE_LABELS[type].label)} description={t(NOTIFICATION_TYPE_LABELS[type].desc)}> <ToggleSwitch checked={prefs.types[type]} disabled={!prefs.enabled} onChange={(next) => save({ types: { ...prefs.types, [type]: next } })} /> </NativeSetting> ))} - <NativeSetting searchId="notifications-when-active" scope="UI" label={t("notifications.whenActive")} description={t("notifications.whenActiveDesc")}> + <NativeSetting searchId="notifications-when-active" scope="UI" disabled={!prefs.enabled} label={t("notifications.whenActive")} description={t("notifications.whenActiveDesc")}> <select style={nativeSelectStyle} value={prefs.whenActive} @@ -641,7 +641,8 @@ function NotificationSettingsPanel() { ); } -function NativeSetting({ label, description, scope, searchId, children }: { label: string; description: string; scope?: "UI" | "Native OMP" | "Workspace"; searchId?: string; children: ReactNode }) { +/** `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<HTMLDivElement>(null); const highlightId = useContext(SettingsHighlightContext); @@ -685,13 +686,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)" } : {}), }} > <div className="settings-card-text"> <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}> - <label id={labelId} htmlFor={settingId} className="settings-card-title" style={{ cursor: "pointer" }}>{label}</label> + <label id={labelId} htmlFor={settingId} className="settings-card-title" style={{ cursor: disabled ? "default" : "pointer" }}>{label}</label> {scope && ( <span style={chipStyle}> {formatScope(scope)} From b2b1afd997f81a07db18c7bfaea1aa63f09aca70 Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Wed, 7 Oct 2026 13:00:37 +0000 Subject: [PATCH 11/18] feat(notifications): open the session from a click anywhere on its toast --- CHANGELOG.md | 2 +- components/ui/toast.click.test.mjs | 37 ++++++++++++++++++++++++++++++ components/ui/toast.tsx | 17 +++++++++++++- hooks/useNotifications.ts | 7 ++++-- 4 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 components/ui/toast.click.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed7114a5..703c7b720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +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. 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. +- 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; for an in-app toast, a click anywhere on it works, not just on **Open**. 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 <question>`: 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. diff --git a/components/ui/toast.click.test.mjs b/components/ui/toast.click.test.mjs new file mode 100644 index 000000000..e0fc5fb3c --- /dev/null +++ b/components/ui/toast.click.test.mjs @@ -0,0 +1,37 @@ +import "../../tests/setup-dom.mjs"; +import assert from "node:assert/strict"; +import test, { afterEach } from "node:test"; +import React from "react"; +import { act, cleanup, render } from "@testing-library/react/pure.js"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tryNative: false, tsconfigPaths: true }); +const { ToastProvider, toast } = await jiti.import("./toast.tsx"); +afterEach(() => { act(() => toast.close()); cleanup(); window.getSelection()?.removeAllRanges(); }); + +function show(onButton) { + const opened = []; + render(React.createElement(ToastProvider, null)); + act(() => { + toast.info("Agent finished", React.createElement("button", { type: "button", onClick: onButton }, "Open"), { onClick: () => opened.push(1) }); + }); + return { opened, card: () => document.querySelector(".toast-card") }; +} + +test("clicking anywhere on a toast card runs its action and closes it", () => { + const { opened, card } = show(); + act(() => card().querySelector(".display-serif").click()); + assert.equal(opened.length, 1); + assert.equal(card()?.hasAttribute("data-ending-style") ?? true, true); +}); + +test("the card's own buttons and a text selection do not trigger the card action", () => { + let pressed = 0; + const { opened, card } = show(() => { pressed += 1; }); + act(() => card().querySelector("button:not(.toast-close-button)").click()); + assert.equal(pressed, 1); + const title = card().querySelector(".display-serif"); + window.getSelection().selectAllChildren(title); + act(() => title.click()); + assert.equal(opened.length, 0); +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index d2dc59a48..dadbfd01a 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -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<ToastData>(); @@ -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 } : {}), }); @@ -140,8 +146,17 @@ function Toaster() { key={t.id} toast={t} className="toast-card" + onClick={t.data?.onClick ? (event) => { + const onClick = t.data?.onClick; + if (!onClick || (event.target instanceof Element && event.target.closest("button, a, input, textarea, select, [aria-expanded]"))) return; + // Releasing a text selection is not a request to open anything. + if (window.getSelection()?.isCollapsed === false) return; + manager.close(t.id); + onClick(); + } : undefined} style={{ pointerEvents: "auto", + cursor: t.data?.onClick ? "pointer" : undefined, display: "flex", alignItems: "flex-start", gap: 8, diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index 071073527..63f66bd6e 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -102,6 +102,8 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session // Delivery to this tab and notification clicks. useEffect(() => { const showToast = (rendered: RenderedNotification, type: NotificationEvent["type"]) => { + const { sessionId } = rendered; + const open = sessionId ? () => openRef.current(sessionId) : undefined; const description = createElement( "span", null, @@ -112,9 +114,10 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session { type: "button", className: "notification-toast-open", + // The Open button stays as the keyboard and screen-reader path; a click anywhere on the card does the same. onClick: () => { toast.close(rendered.tag); - openRef.current(rendered.sessionId); + open?.(); }, }, translate("notifications.open"), @@ -122,7 +125,7 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session : null, ); const show = type === "error" ? toast.error : toast.info; - show(rendered.title, description, { id: rendered.tag }); + show(rendered.title, description, { id: rendered.tag, onClick: open }); }; const onMessage = (raw: Event) => { if (!(raw instanceof CustomEvent)) return; From 77cbc25a6e903522a72ce780050733344c475c49 Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Wed, 7 Oct 2026 13:18:05 +0000 Subject: [PATCH 12/18] fix(notifications): ignore drags and pressed controls when a toast card is clicked --- AGENTS.md | 4 +- components/ui/toast.click.test.mjs | 70 +++++++++++++++++++++++++----- components/ui/toast.tsx | 16 ++++++- hooks/useNotifications.ts | 8 ++-- 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c90612ef2..2e3d7fc70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -688,7 +688,9 @@ 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 a click target outside its +buttons, links and expandable text, ignoring drags and text selection). 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/components/ui/toast.click.test.mjs b/components/ui/toast.click.test.mjs index e0fc5fb3c..98f6c637c 100644 --- a/components/ui/toast.click.test.mjs +++ b/components/ui/toast.click.test.mjs @@ -1,37 +1,85 @@ import "../../tests/setup-dom.mjs"; import assert from "node:assert/strict"; -import test, { afterEach } from "node:test"; +import test, { afterEach, before } from "node:test"; import React from "react"; -import { act, cleanup, render } from "@testing-library/react/pure.js"; +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 }); -const { ToastProvider, toast } = await jiti.import("./toast.tsx"); +const { ClampedDescription, ToastProvider, toast } = await jiti.import("./toast.tsx"); +const { useNotifications } = await jiti.import("../../hooks/useNotifications.ts"); +const { NOTIFICATION_MESSAGE_EVENT } = await jiti.import("../../lib/notification-client.ts"); + +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 show(onButton) { +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", React.createElement("button", { type: "button", onClick: onButton }, "Open"), { onClick: () => opened.push(1) }); - }); + 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(); - act(() => card().querySelector(".display-serif").click()); + 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("the card's own buttons and a text selection do not trigger the card action", () => { +test("buttons, links, expandable text, drags and text selection do not trigger the card action", () => { let pressed = 0; - const { opened, card } = show(() => { pressed += 1; }); + 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); 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) })); + const deliver = (sessionId) => act(() => { + window.dispatchEvent(new window.CustomEvent(NOTIFICATION_MESSAGE_EVENT, { detail: { kind: "toast", event: { type: "completed", sessionId, sessionName: "Fix bug" } } })); + }); + deliver("s1"); + act(() => document.querySelector(".toast-card .display-serif").click()); + assert.deepEqual(sessions, ["s1"]); + act(() => toast.close()); + deliver(""); + const card = document.querySelector(".toast-card:not([data-ending-style])"); + assert.equal(card.querySelector(".notification-toast-open"), null); + act(() => card.querySelector(".display-serif").click()); + assert.deepEqual(sessions, ["s1"]); +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index dadbfd01a..c6efa14c7 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"; @@ -123,6 +123,7 @@ export function ClampedDescription({ children }: { children: React.ReactNode }) function Toaster() { const { toasts } = Toast.useToastManager<ToastData>(); const isMobile = useIsMobile(); + const press = useRef<{ x: number; y: number; target: EventTarget | null } | 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; @@ -146,9 +147,20 @@ function Toaster() { key={t.id} toast={t} className="toast-card" + onPointerDown={(event) => { + press.current = { x: event.clientX, y: event.clientY, target: event.target }; + }} onClick={t.data?.onClick ? (event) => { const onClick = t.data?.onClick; - if (!onClick || (event.target instanceof Element && event.target.closest("button, a, input, textarea, select, [aria-expanded]"))) return; + 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 is not a click (detail 0: keyboard). + if (pointerClick && 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); diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index 63f66bd6e..51402d9c3 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -102,13 +102,13 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session // Delivery to this tab and notification clicks. useEffect(() => { const showToast = (rendered: RenderedNotification, type: NotificationEvent["type"]) => { - const { sessionId } = rendered; - const open = sessionId ? () => openRef.current(sessionId) : undefined; + const target = rendered.sessionId; + const open = target ? () => openRef.current(target) : undefined; const description = createElement( "span", null, rendered.body, - rendered.sessionId + open ? createElement( "button", { @@ -117,7 +117,7 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session // The Open button stays as the keyboard and screen-reader path; a click anywhere on the card does the same. onClick: () => { toast.close(rendered.tag); - open?.(); + open(); }, }, translate("notifications.open"), From a5899f0db54f0e51d2f572c4a080b71cb76ced43 Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Wed, 7 Oct 2026 13:31:41 +0000 Subject: [PATCH 13/18] fix(notifications): close the overlaid file panel when a notification opens a session --- components/AppShell.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/AppShell.tsx b/components/AppShell.tsx index a28b55880..e4051792b 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -1337,6 +1337,8 @@ export function AppShell({ appName }: { appName: string }) { // 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") @@ -1352,7 +1354,7 @@ export function AppShell({ appName }: { appName: string }) { }); }) .catch(() => window.location.assign(sessionUrl)); - }, [handleSelectSession]); + }, [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 }); From f0f9ab3e356c8b46ff10278d9fe77f55d81ad8e8 Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Wed, 7 Oct 2026 13:42:24 +0000 Subject: [PATCH 14/18] test(notifications): find each notification toast by its title --- components/ui/toast.click.test.mjs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/components/ui/toast.click.test.mjs b/components/ui/toast.click.test.mjs index 98f6c637c..c79c7dc29 100644 --- a/components/ui/toast.click.test.mjs +++ b/components/ui/toast.click.test.mjs @@ -70,16 +70,19 @@ test("a session notification toast opens its session from a card click; one with const sessions = []; render(React.createElement(ToastProvider, null)); renderHook(() => useNotifications({ sessionId: null, locale: "en", onOpenSession: (id) => sessions.push(id) })); - const deliver = (sessionId) => act(() => { - window.dispatchEvent(new window.CustomEvent(NOTIFICATION_MESSAGE_EVENT, { detail: { kind: "toast", event: { type: "completed", sessionId, sessionName: "Fix bug" } } })); - }); - deliver("s1"); - act(() => document.querySelector(".toast-card .display-serif").click()); + // 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"); + act(() => withSession.querySelector(".display-serif").click()); assert.deepEqual(sessions, ["s1"]); - act(() => toast.close()); - deliver(""); - const card = document.querySelector(".toast-card:not([data-ending-style])"); - assert.equal(card.querySelector(".notification-toast-open"), null); - act(() => card.querySelector(".display-serif").click()); + const withoutSession = deliver("", "No session"); + assert.equal(withoutSession.querySelector(".notification-toast-open"), null); + act(() => withoutSession.querySelector(".display-serif").click()); assert.deepEqual(sessions, ["s1"]); }); From 03c88f75b6b5055b43d3415ba79ca08c409badaf Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Wed, 7 Oct 2026 13:48:34 +0000 Subject: [PATCH 15/18] test(notifications): load the toast module the hook uses, so Windows sees one manager --- components/ui/toast.click.test.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/components/ui/toast.click.test.mjs b/components/ui/toast.click.test.mjs index c79c7dc29..a26b227a3 100644 --- a/components/ui/toast.click.test.mjs +++ b/components/ui/toast.click.test.mjs @@ -6,9 +6,11 @@ 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 }); -const { ClampedDescription, ToastProvider, toast } = await jiti.import("./toast.tsx"); -const { useNotifications } = await jiti.import("../../hooks/useNotifications.ts"); -const { NOTIFICATION_MESSAGE_EVENT } = await jiti.import("../../lib/notification-client.ts"); +// 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; From 49550352e886e2cc661a755cb90bb14c828c677d Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Wed, 7 Oct 2026 17:51:05 +0000 Subject: [PATCH 16/18] fix(notifications): clip long session names in pushes and resubscribe after an expired test push --- components/SettingsConfig.tsx | 3 +++ lib/notification-events.test.mjs | 7 +++++++ lib/notification-events.ts | 8 +++++--- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/components/SettingsConfig.tsx b/components/SettingsConfig.tsx index 13ebc1e16..a1a1b5ef5 100644 --- a/components/SettingsConfig.tsx +++ b/components/SettingsConfig.tsx @@ -575,6 +575,9 @@ function NotificationSettingsPanel() { }); 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))) { diff --git a/lib/notification-events.test.mjs b/lib/notification-events.test.mjs index 254967f31..440480bc6 100644 --- a/lib/notification-events.test.mjs +++ b/lib/notification-events.test.mjs @@ -122,3 +122,10 @@ test("rendered notifications open their session and clip long details", () => { 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 index 77e66bdfc..06baef73f 100644 --- a/lib/notification-events.ts +++ b/lib/notification-events.ts @@ -64,10 +64,12 @@ export interface RenderedNotification { 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): string { +function clip(text: string, max = MAX_DETAIL): string { const flat = text.replace(/\s+/g, " ").trim(); - return flat.length > MAX_DETAIL ? `${flat.slice(0, MAX_DETAIL - 1)}…` : flat; + 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. */ @@ -98,7 +100,7 @@ export function renderNotification(event: NotificationEvent, t: Translate): Rend break; } return { - title: event.sessionName || t("notifications.untitledSession"), + title: (event.sessionName && clip(event.sessionName, MAX_TITLE)) || t("notifications.untitledSession"), body, url: event.sessionId ? `/?session=${encodeURIComponent(event.sessionId)}` : "/", tag: `${event.sessionId}:${event.type}`, From 8c66efd63c7627e7cbe9df558f228e5025ffba4d Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Wed, 7 Oct 2026 19:32:43 +0000 Subject: [PATCH 17/18] fix(notifications): a drag brought back to its start does not open the session --- components/ui/toast.click.test.mjs | 4 ++++ components/ui/toast.tsx | 13 +++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/components/ui/toast.click.test.mjs b/components/ui/toast.click.test.mjs index a26b227a3..4bc4b8d89 100644 --- a/components/ui/toast.click.test.mjs +++ b/components/ui/toast.click.test.mjs @@ -57,6 +57,10 @@ test("buttons, links, expandable text, drags and text selection do not trigger t 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); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index c6efa14c7..718f4f482 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -123,7 +123,7 @@ export function ClampedDescription({ children }: { children: React.ReactNode }) function Toaster() { const { toasts } = Toast.useToastManager<ToastData>(); const isMobile = useIsMobile(); - const press = useRef<{ x: number; y: number; target: EventTarget | null } | null>(null); + 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; @@ -148,7 +148,11 @@ function Toaster() { toast={t} className="toast-card" onPointerDown={(event) => { - press.current = { x: event.clientX, y: event.clientY, target: event.target }; + 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; @@ -159,8 +163,9 @@ function Toaster() { 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 is not a click (detail 0: keyboard). - if (pointerClick && Math.hypot(event.clientX - from.x, event.clientY - from.y) > 10) 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); From 8f99c19ac66d48f4068f51218adf160f4983bb77 Mon Sep 17 00:00:00 2001 From: Andre Brait <andrebrait@gmail.com> Date: Wed, 7 Oct 2026 20:41:56 +0000 Subject: [PATCH 18/18] feat(notifications): drop the Open link; the toast card itself opens the session --- AGENTS.md | 5 +++-- CHANGELOG.md | 2 +- app/globals.css | 11 ----------- components/ui/toast.click.test.mjs | 13 ++++++++++++- components/ui/toast.tsx | 8 ++++++++ hooks/useNotifications.ts | 26 +++----------------------- lib/i18n/locales/en.json | 1 - lib/i18n/locales/ja.json | 1 - lib/i18n/locales/zh-CN.json | 1 - 9 files changed, 27 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2e3d7fc70..a9f6fb2de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -689,8 +689,9 @@ 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; -the `onClick` option makes the whole card a click target outside its -buttons, links and expandable text, ignoring drags and text selection). +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 703c7b720..2f918fdf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +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; for an in-app toast, a click anywhere on it works, not just on **Open**. 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. +- 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 <question>`: 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. diff --git a/app/globals.css b/app/globals.css index b47862e02..403544de2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3254,17 +3254,6 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { box-shadow: var(--shadow-modal) !important; transform: translateY(-2px); } -.notification-toast-open { - margin-left: 8px; - padding: 0; - border: 0; - background: none; - color: var(--accent-strong); - font: inherit; - font-weight: 600; - text-decoration: underline; - cursor: pointer; -} .live-status-dot { position: relative; diff --git a/components/ui/toast.click.test.mjs b/components/ui/toast.click.test.mjs index 4bc4b8d89..e15230de0 100644 --- a/components/ui/toast.click.test.mjs +++ b/components/ui/toast.click.test.mjs @@ -41,6 +41,15 @@ test("clicking anywhere on a toast card runs its action and closes it", () => { 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, @@ -85,10 +94,12 @@ test("a session notification toast opens its session from a card click; one with 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.querySelector(".notification-toast-open"), null); + 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 718f4f482..1de94e7ac 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -171,6 +171,14 @@ function Toaster() { 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, diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index 51402d9c3..fb7038a4a 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -1,4 +1,4 @@ -import { createElement, useEffect, useRef, useSyncExternalStore } from "react"; +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"; @@ -101,31 +101,11 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session // 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 open = target ? () => openRef.current(target) : undefined; - const description = createElement( - "span", - null, - rendered.body, - open - ? createElement( - "button", - { - type: "button", - className: "notification-toast-open", - // The Open button stays as the keyboard and screen-reader path; a click anywhere on the card does the same. - onClick: () => { - toast.close(rendered.tag); - open(); - }, - }, - translate("notifications.open"), - ) - : null, - ); const show = type === "error" ? toast.error : toast.info; - show(rendered.title, description, { id: rendered.tag, onClick: open }); + show(rendered.title, rendered.body, { id: rendered.tag, onClick: target ? () => openRef.current(target) : undefined }); }; const onMessage = (raw: Event) => { if (!(raw instanceof CustomEvent)) return; diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index e19ae41c9..40f9f9e5e 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -303,7 +303,6 @@ "notifications.modelSwitchBodyNoDetail": "Switched {from} → {to}.", "notifications.testBody": "Notifications are working.", "notifications.untitledSession": "omp session", - "notifications.open": "Open", "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.", diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index ca27f2b39..eb667b0b9 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -290,7 +290,6 @@ "notifications.modelSwitchBodyNoDetail": "{from} → {to} に切り替えました。", "notifications.testBody": "通知は正常に動作しています。", "notifications.untitledSession": "omp セッション", - "notifications.open": "開く", "notifications.sessionNotFound": "そのセッションはもう利用できません。", "notifications.title": "通知", "notifications.subtitle": "表示していないセッションについて通知します。この設定はこのブラウザーにのみ適用されます。", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 2f8ca8c47..1044f3c35 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -290,7 +290,6 @@ "notifications.modelSwitchBodyNoDetail": "已切换 {from} → {to}。", "notifications.testBody": "通知工作正常。", "notifications.untitledSession": "omp 会话", - "notifications.open": "打开", "notifications.sessionNotFound": "该会话已不可用。", "notifications.title": "通知", "notifications.subtitle": "为你当前未查看的会话发送通知。这些设置仅适用于此浏览器。",