diff --git a/AGENTS.md b/AGENTS.md index 1bbd39515..1eebfc27b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -738,8 +738,12 @@ 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`. 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 a sideways touch/pen swipe (base-ui's toast swipe; `NotificationRow` for the diff --git a/CHANGELOG.md b/CHANGELOG.md index 524b6f076..bed9f8d25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - 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. - On Windows, changing or removing the web password hash with `setx` now takes effect at the next server restart. The tray (native and PowerShell) and the background service read `OMP_WEB_PASSWORD_HASH` from the user, then system, environment each time they start the server, instead of preferring the copy they captured at logon, so an old password no longer keeps working after you rotate it. The native tray previously did not pass the hash on at all. +- 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/NotificationList.test.mjs b/components/NotificationList.test.mjs index 9d91398c5..66bf6e81c 100644 --- a/components/NotificationList.test.mjs +++ b/components/NotificationList.test.mjs @@ -82,3 +82,37 @@ 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", "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); + 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/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..cd2023b50 100644 --- a/components/ui/toast.click.test.mjs +++ b/components/ui/toast.click.test.mjs @@ -103,3 +103,49 @@ 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: "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 { + delete window.Notification; + delete globalThis.Notification; + 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/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..96505abfc 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -1,7 +1,8 @@ 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 { isRecord } from "@/lib/type-guards"; import { ensurePushSubscription, getNotificationDeviceId, @@ -102,10 +103,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 +123,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) => { @@ -126,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 }); + })(), ); });