From eb5629a28c8b46fdd16191080a4b981b78a86656 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Thu, 8 Oct 2026 14:59:37 +0000 Subject: [PATCH 1/3] fix(notifications): list OS-shown notifications and open their session from the Notifications tab --- AGENTS.md | 6 ++++-- CHANGELOG.md | 1 + components/NotificationList.test.mjs | 15 +++++++++++++++ components/NotificationList.tsx | 15 +++++++++++++++ components/ui/toast.click.test.mjs | 24 ++++++++++++++++++++++++ components/ui/toast.tsx | 8 +++++--- hooks/useNotifications.ts | 11 ++++++++--- 7 files changed, 72 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1bbd39515..a969c4430 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -738,8 +738,10 @@ motion: --dur-fast (150ms) --dur-med (220ms) --dur-slow (320ms) --ease-out-warm `primitives.tsx` (Dialog/Tooltip/Collapsible), `field.tsx` (form fields + ConfirmDialog), `toast.tsx` (`toast.success/error/info`, mounted in AppShell; every toast also lands in `toastHistory`, the last 100 kept in memory for the -right panel's Notifications tab; OS notifications call `toastHistory.record()` -so they appear there too). Unread entries badge the right-panel toggle, which +right panel's Notifications tab, with its `onClick`; OS-shown notifications are +recorded with `toastHistory.record()` (also with the session `onClick`) so they +appear there too). An entry with an `onClick` runs it on click or Enter, like +the toast card. Unread entries badge the right-panel toggle, which then opens the Notifications tab; entries become read when the user leaves that tab or uses Mark all as read. Toasts and Notifications entries dismiss on a sideways touch/pen swipe (base-ui's toast swipe; `NotificationRow` for the diff --git a/CHANGELOG.md b/CHANGELOG.md index 281295861..971a1720f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - Submitting a message while the agent is working now queues it by default instead of steering, in Settings → Interface & Behavior → **Message during active run**: steering a long tool or shell command could abort the turn mid-task and left the new message undelivered. A previously saved choice is kept; only an empty setting changes meaning. Sending during omp-web's own `!!` shell command no longer fails with "Cannot send a prompt while a shell command is running": the message is handed to omp's queue instead of aborting anything, the optimistic bubble is withdrawn, and a notice says it was queued behind the running command. - Keep omp-web's own secrets out of every child process it starts, not only the `omp` session processes: plugin install and upgrade, skill install/update/search, update checks and the self-update worker, agent unpack, and the Git commands the file panel runs. A session environment no longer contains the web password hash, so `env` inside an agent no longer prints anything that grants access to this server. `OMP_WEB_*` values that are configuration rather than secrets (the `omp` binary path, port, package dir) keep reaching the processes that need them. - Sign web sessions with a per-installation random key (created on first start at `~/.omp/agent/omp-web/web-auth-secret.json`, mode `0600`) mixed with the password hash, instead of the hash itself. Anyone who reads the hash out of a service environment file can no longer forge a session cookie, and changing the password still signs every browser out. +- The **Notifications** tab now lists notifications that appeared as system notifications too, not only in-app toasts. Click or tap an entry from a session, or press Enter on it, to open that session; its dismiss button still only removes it. - Word-completion ghost text in a scrolled composer now appears right after the caret. When the draft had text after the caret, it used to be drawn lines further down. - Show a single plus sign on the **Add provider** buttons in Models settings, and none in the Add provider dialog title. The translated label no longer repeats the button's **+** icon. - Links in a Markdown file preview no longer navigate omp-web away. A relative link the file panel cannot open, such as one in a Markdown file outside the workspace, shows as plain text with its target on hover; web links open in a new tab. An image the panel cannot load shows its alt text instead of a broken image. diff --git a/components/NotificationList.test.mjs b/components/NotificationList.test.mjs index 9d91398c5..9f3969663 100644 --- a/components/NotificationList.test.mjs +++ b/components/NotificationList.test.mjs @@ -82,3 +82,18 @@ test("the click ending a drag is swallowed; taps and keyboard clicks still reach pointer(description(), "click", 101, 50); assert.equal(description().getAttribute("aria-expanded"), "false"); }); + +test("an entry with an action runs it on click or Enter, but not from its controls", () => { + const opened = []; + act(() => { toastHistory.record("info", "Agent finished", "Body", { id: "n2", onClick: () => opened.push(1) }); }); + const view = render(React.createElement(NotificationList)); + const row = view.container.querySelector("li"); + act(() => view.container.querySelector(".display-serif").click()); + assert.equal(row.tabIndex, 0); + act(() => { row.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); }); + assert.equal(opened.length, 2); + // The dismiss button removes the entry without running its action. + act(() => view.container.querySelector("button").click()); + assert.equal(opened.length, 2); + assert.equal(toastHistory.get().length, 0); +}); diff --git a/components/NotificationList.tsx b/components/NotificationList.tsx index eed856f02..ae47689b8 100644 --- a/components/NotificationList.tsx +++ b/components/NotificationList.tsx @@ -52,6 +52,14 @@ function NotificationRow({ entry }: { entry: ToastHistoryEntry }) { return (
  • { + if (event.key !== "Enter" || event.target !== event.currentTarget) return; + event.preventDefault(); + entry.onClick?.(); + } : undefined} onPointerDown={(event) => { clickGuard.onPointerDown(event); // A second finger never takes over a swipe in progress; any other @@ -98,6 +106,12 @@ function NotificationRow({ entry }: { entry: ToastHistoryEntry }) { setOffset(0); }} onClickCapture={clickGuard.onClickCapture} + // Like the toast card: a click anywhere but the controls and expandable text opens it. + onClick={entry.onClick ? (event) => { + if (event.target instanceof Element && event.target.closest("button, a, input, textarea, select, [aria-expanded]")) return; + if (window.getSelection()?.isCollapsed === false) return; + entry.onClick?.(); + } : undefined} style={{ display: "flex", alignItems: "flex-start", @@ -105,6 +119,7 @@ function NotificationRow({ entry }: { entry: ToastHistoryEntry }) { padding: 8, borderRadius: "var(--radius-control)", background: entry.read ? undefined : "var(--bg-subtle)", + cursor: entry.onClick ? "pointer" : undefined, touchAction: "pan-y", transform: leaving ? `translateX(${leaving * 110}%)` : offset ? `translateX(${offset}px)` : undefined, opacity: leaving ? 0 : 1 - Math.min(Math.abs(offset) / 400, 0.5), diff --git a/components/ui/toast.click.test.mjs b/components/ui/toast.click.test.mjs index e15230de0..d39a8d9d4 100644 --- a/components/ui/toast.click.test.mjs +++ b/components/ui/toast.click.test.mjs @@ -103,3 +103,27 @@ test("a session notification toast opens its session from a card click; one with act(() => withoutSession.querySelector(".display-serif").click()); assert.deepEqual(sessions, ["s1"]); }); + +test("a notification the OS showed is listed in history and opens its session from there", async () => { + const sessions = []; + const { toastHistory } = await jiti.import("@/components/ui/toast"); + const created = []; + // Bare `Notification` in the code under test resolves to the global, not window. + globalThis.Notification = window.Notification = class { static permission = "granted"; constructor(title) { created.push(title); } }; + try { + renderHook(() => useNotifications({ sessionId: null, locale: "en", onOpenSession: (id) => sessions.push(id) })); + await act(async () => { + window.dispatchEvent(new window.CustomEvent(NOTIFICATION_MESSAGE_EVENT, { detail: { kind: "os", event: { type: "completed", sessionId: "s9", sessionName: "Shown by OS" } } })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.deepEqual(created, ["Shown by OS"]); + const entry = toastHistory.get().find((e) => e.title === "Shown by OS"); + assert.ok(entry, "listed in the Notifications tab"); + entry.onClick(); + assert.deepEqual(sessions, ["s9"]); + } finally { + delete window.Notification; + delete globalThis.Notification; + toastHistory.clear(); + } +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index c0769de21..d0a9c69f4 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -61,6 +61,8 @@ export interface ToastHistoryEntry { title: React.ReactNode; description?: React.ReactNode; clamp?: boolean; + /** The toast's card action, run by clicking the entry. */ + onClick?: () => void; at: number; read: boolean; } @@ -82,13 +84,13 @@ export const toastHistory = { }, get: () => history, /** Add an entry without showing a toast, e.g. for a notification already delivered by the OS. */ - record(kind: ToastKind, title: React.ReactNode, description?: React.ReactNode, options?: { id?: string; clamp?: boolean }) { + record(kind: ToastKind, title: React.ReactNode, description?: React.ReactNode, options?: { id?: string; clamp?: boolean; onClick?: () => void }) { const id = options?.id ?? `recorded-${++recordedCount}`; // A reused id replaces its toast on screen, so it replaces its history entry // too. It keeps its read state: re-announcing the same notice (e.g. an // update toast on every tab focus) must not re-badge it. const read = history.some((e) => e.id === id && e.read); - const entry: ToastHistoryEntry = { id, kind, title, description, clamp: options?.clamp, at: Date.now(), read }; + const entry: ToastHistoryEntry = { id, kind, title, description, clamp: options?.clamp, onClick: options?.onClick, at: Date.now(), read }; setHistory([entry, ...history.filter((e) => e.id !== id)].slice(0, TOAST_HISTORY_LIMIT)); }, markAllRead: () => { @@ -119,7 +121,7 @@ function add(kind: ToastKind, title: React.ReactNode, description?: React.ReactN timeout, ...(options?.onClose ? { onClose: options.onClose } : {}), }); - toastHistory.record(kind, title, description, { id, clamp: options?.clamp }); + toastHistory.record(kind, title, description, { id, clamp: options?.clamp, onClick: options?.onClick }); return id; } export const toast = { diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index fb7038a4a..79081e451 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useSyncExternalStore } from "react"; -import { toast } from "@/components/ui/toast"; +import { toast, toastHistory } from "@/components/ui/toast"; import { translate } from "@/lib/i18n"; import { DEFAULT_NOTIFICATION_PREFS, renderNotification, type NotificationEvent, type RenderedNotification } from "@/lib/notification-events"; import { @@ -102,10 +102,13 @@ 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 openFor = (rendered: RenderedNotification) => { const target = rendered.sessionId; + return target ? () => openRef.current(target) : undefined; + }; + const showToast = (rendered: RenderedNotification, type: NotificationEvent["type"]) => { const show = type === "error" ? toast.error : toast.info; - show(rendered.title, rendered.body, { id: rendered.tag, onClick: target ? () => openRef.current(target) : undefined }); + show(rendered.title, rendered.body, { id: rendered.tag, onClick: openFor(rendered) }); }; const onMessage = (raw: Event) => { if (!(raw instanceof CustomEvent)) return; @@ -119,6 +122,8 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session // 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); + // Shown by the OS: log it so the Notifications tab still lists it. + else toastHistory.record(event.type === "error" ? "error" : "info", rendered.title, rendered.body, { id: rendered.tag, onClick: openFor(rendered) }); }); }; const onOpen = (raw: Event) => { From 04e99c2eae7108819ff89b8038d83dd5b7b331ac Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Thu, 8 Oct 2026 15:03:47 +0000 Subject: [PATCH 2/3] test(notifications): pin the row's Enter, expand and selection guards and the OS kind --- CHANGELOG.md | 2 +- components/NotificationList.test.mjs | 23 +++++++++++++++++++++-- components/ui/toast.click.test.mjs | 3 ++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 971a1720f..8a3667844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - Submitting a message while the agent is working now queues it by default instead of steering, in Settings → Interface & Behavior → **Message during active run**: steering a long tool or shell command could abort the turn mid-task and left the new message undelivered. A previously saved choice is kept; only an empty setting changes meaning. Sending during omp-web's own `!!` shell command no longer fails with "Cannot send a prompt while a shell command is running": the message is handed to omp's queue instead of aborting anything, the optimistic bubble is withdrawn, and a notice says it was queued behind the running command. - Keep omp-web's own secrets out of every child process it starts, not only the `omp` session processes: plugin install and upgrade, skill install/update/search, update checks and the self-update worker, agent unpack, and the Git commands the file panel runs. A session environment no longer contains the web password hash, so `env` inside an agent no longer prints anything that grants access to this server. `OMP_WEB_*` values that are configuration rather than secrets (the `omp` binary path, port, package dir) keep reaching the processes that need them. - Sign web sessions with a per-installation random key (created on first start at `~/.omp/agent/omp-web/web-auth-secret.json`, mode `0600`) mixed with the password hash, instead of the hash itself. Anyone who reads the hash out of a service environment file can no longer forge a session cookie, and changing the password still signs every browser out. -- The **Notifications** tab now lists notifications that appeared as system notifications too, not only in-app toasts. Click or tap an entry from a session, or press Enter on it, to open that session; its dismiss button still only removes it. +- The **Notifications** tab now lists notifications that appeared as system notifications too, not only in-app toasts. Click or tap an entry for a session, or press Enter on it, to open that session; its dismiss button still only removes it. - Word-completion ghost text in a scrolled composer now appears right after the caret. When the draft had text after the caret, it used to be drawn lines further down. - Show a single plus sign on the **Add provider** buttons in Models settings, and none in the Add provider dialog title. The translated label no longer repeats the button's **+** icon. - Links in a Markdown file preview no longer navigate omp-web away. A relative link the file panel cannot open, such as one in a Markdown file outside the workspace, shows as plain text with its target on hover; web links open in a new tab. An image the panel cannot load shows its alt text instead of a broken image. diff --git a/components/NotificationList.test.mjs b/components/NotificationList.test.mjs index 9f3969663..66bf6e81c 100644 --- a/components/NotificationList.test.mjs +++ b/components/NotificationList.test.mjs @@ -85,15 +85,34 @@ test("the click ending a drag is swallowed; taps and keyboard clicks still reach test("an entry with an action runs it on click or Enter, but not from its controls", () => { const opened = []; - act(() => { toastHistory.record("info", "Agent finished", "Body", { id: "n2", onClick: () => opened.push(1) }); }); + act(() => { toastHistory.record("info", "Agent finished", "A long description", { id: "n2", clamp: true, onClick: () => opened.push(1) }); }); const view = render(React.createElement(NotificationList)); const row = view.container.querySelector("li"); + const enter = (target) => act(() => { target.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); }); act(() => view.container.querySelector(".display-serif").click()); + assert.equal(opened.length, 1); assert.equal(row.tabIndex, 0); - act(() => { row.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); }); + assert.equal(row.style.cursor, "pointer"); + enter(row); + assert.equal(opened.length, 2); + // Expandable text expands instead; a released text selection and Enter on the dismiss button do nothing. + const description = view.container.querySelector("[aria-expanded]"); + act(() => description.click()); + assert.equal(description.getAttribute("aria-expanded"), "true"); + window.getSelection().selectAllChildren(view.container.querySelector(".display-serif")); + act(() => view.container.querySelector(".display-serif").click()); + window.getSelection().removeAllRanges(); + enter(view.container.querySelector("button")); assert.equal(opened.length, 2); // The dismiss button removes the entry without running its action. act(() => view.container.querySelector("button").click()); assert.equal(opened.length, 2); assert.equal(toastHistory.get().length, 0); }); + +test("an entry without an action is not focusable or clickable", () => { + act(() => { toastHistory.record("info", "Saved", "Body", { id: "n3" }); }); + const row = render(React.createElement(NotificationList)).container.querySelector("li"); + assert.equal(row.hasAttribute("tabindex"), false); + assert.equal(row.style.cursor, ""); +}); diff --git a/components/ui/toast.click.test.mjs b/components/ui/toast.click.test.mjs index d39a8d9d4..8ea268ed8 100644 --- a/components/ui/toast.click.test.mjs +++ b/components/ui/toast.click.test.mjs @@ -113,12 +113,13 @@ test("a notification the OS showed is listed in history and opens its session fr try { renderHook(() => useNotifications({ sessionId: null, locale: "en", onOpenSession: (id) => sessions.push(id) })); await act(async () => { - window.dispatchEvent(new window.CustomEvent(NOTIFICATION_MESSAGE_EVENT, { detail: { kind: "os", event: { type: "completed", sessionId: "s9", sessionName: "Shown by OS" } } })); + window.dispatchEvent(new window.CustomEvent(NOTIFICATION_MESSAGE_EVENT, { detail: { kind: "os", event: { type: "error", sessionId: "s9", sessionName: "Shown by OS", detail: "boom" } } })); await new Promise((resolve) => setTimeout(resolve, 0)); }); assert.deepEqual(created, ["Shown by OS"]); const entry = toastHistory.get().find((e) => e.title === "Shown by OS"); assert.ok(entry, "listed in the Notifications tab"); + assert.equal(entry.kind, "error"); entry.onClick(); assert.deepEqual(sessions, ["s9"]); } finally { From f45b7d915e65c3980553fff67ff471b3878d8351 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Thu, 8 Oct 2026 15:18:52 +0000 Subject: [PATCH 3/3] fix(notifications): list Web Push notifications shown while a tab is open --- AGENTS.md | 8 ++++-- CHANGELOG.md | 2 +- components/ui/toast.click.test.mjs | 21 ++++++++++++++ hooks/useNotifications.ts | 10 ++++++- lib/notification-hub.test.mjs | 6 ++-- lib/notification-hub.ts | 3 +- lib/notification-sw.test.mjs | 45 ++++++++++++++++++++++++++++++ public/sw.js | 28 +++++++++++++------ 8 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 lib/notification-sw.test.mjs diff --git a/AGENTS.md b/AGENTS.md index a969c4430..1eebfc27b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -738,9 +738,11 @@ motion: --dur-fast (150ms) --dur-med (220ms) --dur-slow (320ms) --ease-out-warm `primitives.tsx` (Dialog/Tooltip/Collapsible), `field.tsx` (form fields + ConfirmDialog), `toast.tsx` (`toast.success/error/info`, mounted in AppShell; every toast also lands in `toastHistory`, the last 100 kept in memory for the -right panel's Notifications tab, with its `onClick`; OS-shown notifications are -recorded with `toastHistory.record()` (also with the session `onClick`) so they -appear there too). An entry with an `onClick` runs it on click or Enter, like +right panel's Notifications tab, with its `onClick`. System notifications are +recorded with `toastHistory.record()` (also with the session `onClick`): those +the page shows itself in `useNotifications`, and Web Pushes when `public/sw.js` +posts `omp-notification-shown` to open tabs; a push with no tab open is not +listed). An entry with an `onClick` runs it on click or Enter, like the toast card. Unread entries badge the right-panel toggle, which then opens the Notifications tab; entries become read when the user leaves that tab or uses Mark all as read. Toasts and Notifications entries dismiss on diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3667844..e5a6f5b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - Submitting a message while the agent is working now queues it by default instead of steering, in Settings → Interface & Behavior → **Message during active run**: steering a long tool or shell command could abort the turn mid-task and left the new message undelivered. A previously saved choice is kept; only an empty setting changes meaning. Sending during omp-web's own `!!` shell command no longer fails with "Cannot send a prompt while a shell command is running": the message is handed to omp's queue instead of aborting anything, the optimistic bubble is withdrawn, and a notice says it was queued behind the running command. - Keep omp-web's own secrets out of every child process it starts, not only the `omp` session processes: plugin install and upgrade, skill install/update/search, update checks and the self-update worker, agent unpack, and the Git commands the file panel runs. A session environment no longer contains the web password hash, so `env` inside an agent no longer prints anything that grants access to this server. `OMP_WEB_*` values that are configuration rather than secrets (the `omp` binary path, port, package dir) keep reaching the processes that need them. - Sign web sessions with a per-installation random key (created on first start at `~/.omp/agent/omp-web/web-auth-secret.json`, mode `0600`) mixed with the password hash, instead of the hash itself. Anyone who reads the hash out of a service environment file can no longer forge a session cookie, and changing the password still signs every browser out. -- The **Notifications** tab now lists notifications that appeared as system notifications too, not only in-app toasts. Click or tap an entry for a session, or press Enter on it, to open that session; its dismiss button still only removes it. +- The **Notifications** tab now lists notifications that appeared as system notifications too, not only in-app toasts, including Web Push notifications that arrive while an omp-web tab is open. Click or tap an entry for a session, or press Enter on it, to open that session; its dismiss button still only removes it. The list is still kept per tab in memory, so pushes that arrive with no omp-web tab open are not listed. - Word-completion ghost text in a scrolled composer now appears right after the caret. When the draft had text after the caret, it used to be drawn lines further down. - Show a single plus sign on the **Add provider** buttons in Models settings, and none in the Add provider dialog title. The translated label no longer repeats the button's **+** icon. - Links in a Markdown file preview no longer navigate omp-web away. A relative link the file panel cannot open, such as one in a Markdown file outside the workspace, shows as plain text with its target on hover; web links open in a new tab. An image the panel cannot load shows its alt text instead of a broken image. diff --git a/components/ui/toast.click.test.mjs b/components/ui/toast.click.test.mjs index 8ea268ed8..cd2023b50 100644 --- a/components/ui/toast.click.test.mjs +++ b/components/ui/toast.click.test.mjs @@ -128,3 +128,24 @@ test("a notification the OS showed is listed in history and opens its session fr toastHistory.clear(); } }); + +test("a push the service worker showed is listed in history and opens its session; other messages are ignored", async () => { + const sessions = []; + const { toastHistory } = await jiti.import("@/components/ui/toast"); + const worker = new window.EventTarget(); + Object.defineProperty(window.navigator, "serviceWorker", { configurable: true, value: worker }); + try { + renderHook(() => useNotifications({ sessionId: null, locale: "en", onOpenSession: (id) => sessions.push(id) })); + const post = (data) => act(() => { worker.dispatchEvent(new window.MessageEvent("message", { data })); }); + post({ type: "omp-notification-shown", notification: { type: "error", title: "Pushed", body: "Run failed", tag: "s7:error", sessionId: "s7" } }); + post({ type: "omp-notification-shown", notification: { title: 42 } }); + post("junk"); + const entries = toastHistory.get(); + assert.deepEqual(entries.map((e) => [e.id, e.kind, e.title]), [["s7:error", "error", "Pushed"]]); + entries[0].onClick(); + assert.deepEqual(sessions, ["s7"]); + } finally { + delete window.navigator.serviceWorker; + toastHistory.clear(); + } +}); diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index 79081e451..96505abfc 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useSyncExternalStore } from "react"; import { toast, toastHistory } from "@/components/ui/toast"; import { translate } from "@/lib/i18n"; import { DEFAULT_NOTIFICATION_PREFS, renderNotification, type NotificationEvent, type RenderedNotification } from "@/lib/notification-events"; +import { isRecord } from "@/lib/type-guards"; import { ensurePushSubscription, getNotificationDeviceId, @@ -131,9 +132,16 @@ export function useNotifications({ sessionId, locale, onOpenSession }: { session }; 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") { + if (!isRecord(data)) return; + if (data.type === "omp-open-session" && typeof data.sessionId === "string") { openRef.current(data.sessionId); + return; } + // A push the service worker showed while this tab was open (subscribed browsers get pushes, not "os" frames). + const shown = data.notification; + if (data.type !== "omp-notification-shown" || !isRecord(shown) || typeof shown.title !== "string" || typeof shown.body !== "string" || typeof shown.tag !== "string" || typeof shown.sessionId !== "string") return; + const target = shown.sessionId; + toastHistory.record(shown.type === "error" ? "error" : "info", shown.title, shown.body, { id: shown.tag || undefined, onClick: target ? () => openRef.current(target) : undefined }); }; window.addEventListener(NOTIFICATION_MESSAGE_EVENT, onMessage); window.addEventListener(OPEN_SESSION_EVENT, onOpen); diff --git a/lib/notification-hub.test.mjs b/lib/notification-hub.test.mjs index e6f471ae7..ec7b1a08a 100644 --- a/lib/notification-hub.test.mjs +++ b/lib/notification-hub.test.mjs @@ -150,9 +150,9 @@ test("pushes are written in the device's language with urgency by type", async ( hub.publishNotification({ type: "input", sessionId: "s2", sessionName: "Deploy", detail: "Ship it?" }); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(sent.map(({ payload, urgency, ttl }) => [payload.body, payload.url, urgency, ttl]), [ - ["タスクが完了しました。", "/?session=s1", "normal", 3600], - ["回答を待っています: Ship it?", "/?session=s2", "high", 3600], + assert.deepEqual(sent.map(({ payload, urgency, ttl }) => [payload.body, payload.url, payload.type, urgency, ttl]), [ + ["タスクが完了しました。", "/?session=s1", "completed", "normal", 3600], + ["回答を待っています: Ship it?", "/?session=s2", "input", "high", 3600], ]); }); diff --git a/lib/notification-hub.ts b/lib/notification-hub.ts index 6f1f2bd3a..53b99b6fc 100644 --- a/lib/notification-hub.ts +++ b/lib/notification-hub.ts @@ -258,7 +258,8 @@ async function sendPush(device: NotificationDevice, event: NotificationEvent): P 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)), { + // `type` lets the service worker tell open tabs which kind of entry to list. + await webpush.sendNotification(subscription, JSON.stringify({ ...renderFor(event, device.prefs.locale), type: event.type }), { TTL: PUSH_TTL_SECONDS, urgency: event.type === "input" || event.type === "error" ? "high" : "normal", timeout: PUSH_TIMEOUT_MS, diff --git a/lib/notification-sw.test.mjs b/lib/notification-sw.test.mjs new file mode 100644 index 000000000..860510e36 --- /dev/null +++ b/lib/notification-sw.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +// Runs public/sw.js against a fake worker scope: the push handler is the only +// way a subscribed browser learns about a notification, so it must tell open tabs. +// Messages are cloned as postMessage does, which also drops the vm realm. +function loadWorker(windows) { + const listeners = {}; + const shown = []; + const self = { + addEventListener: (type, listener) => { listeners[type] = listener; }, + skipWaiting() {}, + registration: { showNotification: async (title, options) => { shown.push({ title, options }); } }, + clients: { claim: async () => {}, matchAll: async () => windows }, + }; + vm.runInNewContext(readFileSync(new URL("../public/sw.js", import.meta.url), "utf8"), { self, URL }); + return { + shown, + push: async (payload) => { + let done; + listeners.push({ data: payload === undefined ? null : { json: () => payload }, waitUntil: (promise) => { done = promise; } }); + await done; + }, + }; +} + +test("a shown push is reported to every open tab with its kind, text and session", async () => { + const messages = []; + const tab = (name) => ({ postMessage: (message) => messages.push([name, structuredClone(message)]) }); + const worker = loadWorker([tab("a"), tab("b")]); + await worker.push({ type: "error", title: "Fix bug", body: "Run failed", tag: "s1:error", sessionId: "s1", url: "/?session=s1" }); + assert.equal(worker.shown[0].title, "Fix bug"); + const notification = { type: "error", title: "Fix bug", body: "Run failed", tag: "s1:error", sessionId: "s1" }; + assert.deepEqual(messages, [["a", { type: "omp-notification-shown", notification }], ["b", { type: "omp-notification-shown", notification }]]); +}); + +test("an unreadable push still shows a notification and reports it as info", async () => { + const messages = []; + const worker = loadWorker([{ postMessage: (message) => messages.push(structuredClone(message)) }]); + await worker.push(undefined); + assert.equal(worker.shown[0].title, "omp web"); + assert.deepEqual(messages[0].notification, { type: "info", title: "omp web", body: "", tag: "", sessionId: "" }); +}); diff --git a/public/sw.js b/public/sw.js index c79c988b1..8fecfa4f0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -11,15 +11,27 @@ self.addEventListener("push", (event) => { } catch { // Unreadable payload: still show something (iOS revokes silent pushes). } + const shown = { + type: data.type === "error" ? "error" : "info", + title: data.title || "omp web", + body: data.body || "", + tag: data.tag || "", + sessionId: data.sessionId || "", + }; 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 || "" }, - }), + (async () => { + await self.registration.showNotification(shown.title, { + body: shown.body, + icon: "/icon-192.png", + badge: "/badge-96.png", + tag: shown.tag || undefined, + renotify: Boolean(shown.tag), + data: { url: data.url || "/", sessionId: shown.sessionId }, + }); + // Open tabs list it in their Notifications tab, like a notification the page showed itself. + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + for (const client of windows) client.postMessage({ type: "omp-notification-shown", notification: shown }); + })(), ); });