Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions components/NotificationList.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
});
15 changes: 15 additions & 0 deletions components/NotificationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ function NotificationRow({ entry }: { entry: ToastHistoryEntry }) {
return (
<li
data-swipe-dismiss
// With an action the row is the only way to run it (there is no Open link), so keyboard users can focus it.
tabIndex={entry.onClick ? 0 : undefined}
className={entry.onClick ? "ui-focus-ring" : undefined}
onKeyDown={entry.onClick ? (event) => {
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
Expand Down Expand Up @@ -98,13 +106,20 @@ 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",
gap: 8,
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),
Expand Down
46 changes: 46 additions & 0 deletions components/ui/toast.click.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
8 changes: 5 additions & 3 deletions components/ui/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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: () => {
Expand Down Expand Up @@ -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 = {
Expand Down
21 changes: 17 additions & 4 deletions hooks/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -119,16 +123,25 @@ 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) => {
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") {
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);
Expand Down
6 changes: 3 additions & 3 deletions lib/notification-hub.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
]);
});

Expand Down
3 changes: 2 additions & 1 deletion lib/notification-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions lib/notification-sw.test.mjs
Original file line number Diff line number Diff line change
@@ -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: "" });
});
Loading
Loading