From e570ac0e152f044174bbbce43d4c7ff8ee01647d Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:58:50 +0800 Subject: [PATCH] feat(web): a notification centre of every daemon's notifications A bell in the sidebar's top row with the unread count, a menu of the history newest first (kind, daemon, flow and what it said, how long ago) with Mark read and Clear, a card at the bottom right for each one heard on the stream, and -- while the page is hidden or unfocused -- the browser's Notification or the macOS app's `notify` command. Clicking any of them reads it and opens the epic going or the epic that ended. Built on the global /api/notifications endpoints; where the server has none, the bell is hidden. Co-Authored-By: Claude Opus 5.5 --- docs/user/web.md | 17 ++ src/hmz/web/static/dom.js | 1 + src/hmz/web/static/notifications.js | 198 +++++++++++++++ src/hmz/web/static/sidebar.js | 2 + src/hmz/web/static/style.css | 108 +++++++++ tests/integration/doubles_web.py | 4 + .../test_web_notification_centre.py | 226 ++++++++++++++++++ 7 files changed, 556 insertions(+) create mode 100644 src/hmz/web/static/notifications.js create mode 100644 tests/integration/test_web_notification_centre.py diff --git a/docs/user/web.md b/docs/user/web.md index 7b3598a5..9884b040 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -73,6 +73,23 @@ the sidebar is a drawer behind the button in the corner. Every view is an address: copy it, keep it, or open it in another tab. +### Notifications {#notifications} + +The bell at the top of the sidebar gathers what needs you, from every daemon at once rather +than only the one shown: an epic **Done**, **Failed** or **Stopped**, a **Question** a flow put +to a person, and a **Chat turn** -- a `chat` waiting for your next line. Its count is how many +you have not read. Its menu lists them newest first, each with its daemon (**Local**, or the +daemon's name), its flow and what it said, and how long ago; **Mark read** reads them all and +**Clear** empties the list. Click one to read it and open it: a question or a chat turn opens the +epic going in its directory, and an ended epic opens that epic. + +Each one heard while the page is open also shows a card at the bottom right for a few seconds; +click it to open what it is about. While the page is hidden or another window is in front, the +system shows it too -- the browser's own notification, once you have let the page send them, +or in [the app](/user/desktop) the system's own -- and clicking it brings the page back on it. +Which kinds are notified, and whether the system shows them, is set under **Settings → +Notifications**. + **Right-click** anything for what can be done to it: a folder in the sidebar (new epic here, its flows, search it, copy its path, settings), an epic (open, its log, resume, copy its link or task, export, stop), an agent or a session, a node on the graph, a flow or flowverse card, a search diff --git a/src/hmz/web/static/dom.js b/src/hmz/web/static/dom.js index 2f767c3d..e3cf55f5 100644 --- a/src/hmz/web/static/dom.js +++ b/src/hmz/web/static/dom.js @@ -250,6 +250,7 @@ const ICONS = { home: ['M3 11l9-8 9 8', 'M5 10v10h14V10'], copy: ['M9 9h11v11H9Z', 'M5 15H4V4h11v1'], open: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'], + bell: ['M6 16v-5a6 6 0 0 1 12 0v5l2 2H4Z', 'M10 21h4'], } /** An icon, drawn in the colour of the text around it and said by nothing: its button says it. */ diff --git a/src/hmz/web/static/notifications.js b/src/hmz/web/static/notifications.js new file mode 100644 index 00000000..1a7ecd19 --- /dev/null +++ b/src/hmz/web/static/notifications.js @@ -0,0 +1,198 @@ +// The notifications, of every daemon at once rather than the one shown: a bell with how many are +// unread, a menu of them newest first, a card for each as it comes, and -- while the page is not +// looked at -- one the system shows, by the macOS app or by the browser. Each opens what it is +// about: the epic going, for a question or a chat's next turn, or the epic that ended. + +import { get, post } from './api.js' +import { ago, fill, h, headline, icon, iconButton } from './dom.js' +import { inApp, menu, section } from './shell.js' + +/** What each kind is called, and the mark it is drawn with. */ +const KINDS = { + done: ['Done', '✔', 'done'], + failed: ['Failed', '✕', 'failed'], + stopped: ['Stopped', '■', 'stopped'], + asked: ['Question', '?', 'running'], + listening: ['Chat turn', '›', 'running'], +} + +/** How long a card stays, and how many stand at once. */ +const SHOWN = 6000 +const CARDS = 3 + +/** The notifications held, newest first. */ +let items = [] +let button = null +let count = null +let drawMenu = null +let cards = null + +/** The bell, made once and the feed started with it: hidden while the server has no notifications. */ +export function bell() { + if (button) return button + count = h('span', { class: 'notify-count', 'data-hmz': 'notification-count', hidden: true }) + button = h('button', { class: 'icon-btn notify-bell', type: 'button', 'aria-label': 'Notifications', title: 'Notifications', 'data-hmz': 'notifications', hidden: true }, icon('bell'), count) + menu(button, (close) => { + const list = h('div', { class: 'notify-list' }) + drawMenu = () => drawList(list, close) + drawMenu() + const read = h('button', { class: 'btn quiet small', type: 'button', 'data-hmz': 'notifications-read', onclick: () => markRead(null) }, 'Mark read') + const clear = h('button', { class: 'btn quiet small', type: 'button', 'data-hmz': 'notifications-clear', onclick: clearAll }, 'Clear') + return [h('div', { class: 'notify-menu', 'data-hmz': 'notification-menu' }, h('div', { class: 'notify-head' }, section('Notifications'), read, clear), list)] + }, { label: 'Notifications' }) + start() + return button +} + +/** How long a failed read waits before the next, which doubles up to five minutes. */ +let wait = 10000 +/** What was heard since the list was last asked for, which an older answer must not drop. */ +let fresh = new Map() +/** Whether the system is let show them, as the settings said when last read. */ +let settings = Promise.resolve(null) + +async function start() { + try { + await reread() + } catch (error) { + // A server without notifications has no bell, nor a browser not let in; else it is asked again. + if (error.status !== 404 && error.status !== 401) { + setTimeout(start, wait) + wait = Math.min(wait * 2, 300000) + } + return + } + button.hidden = false + readSettings() + const stream = new EventSource('/api/notifications/stream') + stream.addEventListener('notification', (event) => heard(JSON.parse(event.data))) + // Read again whenever the stream opens, for what came before it did, and as the page is + // looked at again, for what another tab read or cleared; the settings as it is left. + stream.addEventListener('open', () => reread().catch(() => {})) + document.addEventListener('visibilitychange', () => (document.hidden ? readSettings() : reread().catch(() => {}))) + addEventListener('blur', readSettings) +} + +async function reread() { + fresh = new Map() + const answer = await get('/api/notifications') + const known = new Set((answer.items || []).map((one) => one.id)) + items = [...[...fresh.values()].reverse().filter((one) => !known.has(one.id)), ...(answer.items || [])] + draw() +} + +function readSettings() { + settings = get('/api/notifications/settings').catch(() => null) +} + +function heard(item) { + fresh.set(item.id, item) + if (items.some((one) => one.id === item.id)) return + items.unshift(item) + draw() + card(item) + system(item) +} + +function draw() { + const unread = items.filter((one) => !one.read).length + count.hidden = !unread + count.textContent = unread > 99 ? '99+' : String(unread) + if (drawMenu && button.getAttribute('aria-expanded') === 'true') drawMenu() +} + +function drawList(list, close) { + if (!items.length) return fill(list, h('p', { class: 'notify-none' }, 'Nothing yet')) + fill( + list, + items.map((item) => + h( + 'button', + { class: ['choice', 'notify-item', !item.read && 'notify-unread'], type: 'button', role: 'menuitem', 'data-hmz': 'notification', onclick: () => (close(), opens(item)) }, + mark(item), + h('span', { class: 'choice-text' }, h('span', { class: 'choice-label' }, `${kindOf(item)} · ${daemonOf(item)}`), h('span', { class: 'choice-about' }, about(item)), h('span', { class: 'notify-when' }, ago(item.at))), + ), + ), + ) +} + +function mark(item) { + const [, glyph, tone] = KINDS[item.kind] || ['', '·', 'idle'] + return h('span', { class: `choice-mark tone-${tone}`, 'aria-hidden': 'true' }, h('span', { class: 'glyph' }, glyph)) +} + +const kindOf = (item) => (KINDS[item.kind] || [item.kind])[0] +const daemonOf = (item) => (!item.daemon || item.daemon === 'local' ? 'Local' : item.daemon) +const about = (item) => [item.flow, item.text || (item.task ? headline(item.task) : item.epic)].filter(Boolean).join(' · ') + +/** Where a notification opens: the epic going for what waits on a person, else the epic itself. */ +export function addressOf(item) { + const at = !item.daemon || item.daemon === 'local' ? '#/' : `#/@${encodeURIComponent(item.daemon)}/` + if ((item.kind === 'asked' || item.kind === 'listening' || !item.epic) && item.workspace) return `${at}live?${new URLSearchParams({ workspace: item.workspace })}` + return item.epic ? `${at}epics/${encodeURIComponent(item.epic)}` : `${at}live` +} + +function opens(item) { + if (items.some((one) => one.id === item.id && !one.read)) markRead([item.id]) + location.hash = addressOf(item) +} + +async function markRead(ids) { + for (const one of items) if (!ids || ids.includes(one.id)) one.read = true + draw() + try { + await post('/api/notifications/read', ids ? { ids } : { all: true }) + } catch { + // Read here alone, until the list is read again. + } +} + +async function clearAll() { + items = [] + draw() + try { + await post('/api/notifications/clear') + } catch { + // Cleared here; read back as they are when the page is looked at again. + } +} + +/** A card for a notification as it comes, at the foot of the window, gone after a moment. */ +function card(item) { + if (!cards) { + cards = h('div', { class: 'notify-toasts', 'data-hmz': 'notification-toasts', role: 'status', 'aria-live': 'polite' }) + document.body.append(cards) + } + const shut = iconButton('close', 'Dismiss', { class: 'notify-shut' }) + const one = h( + 'div', + { class: 'notify-card', 'data-hmz': 'notification-toast' }, + h('button', { class: 'notify-open', type: 'button', onclick: () => (one.remove(), opens(item)) }, mark(item), h('span', { class: 'choice-text' }, h('span', { class: 'choice-label' }, `${kindOf(item)} · ${daemonOf(item)}`), h('span', { class: 'choice-about' }, about(item)))), + shut, + ) + shut.addEventListener('click', () => one.remove()) + cards.prepend(one) + while (cards.children.length > CARDS) cards.lastChild.remove() + setTimeout(() => one.remove(), SHOWN) +} + +/** One the system shows, while the page is not looked at and the settings let it. */ +async function system(item) { + if (!document.hidden && document.hasFocus()) return + const said = await settings + if (!said || !said.system) return + const title = `${kindOf(item)} · ${daemonOf(item)}` + const body = about(item) + const address = addressOf(item) + if (inApp) { + window.__TAURI_INTERNALS__?.invoke('notify', { title, body, address })?.catch?.(() => {}) + return + } + if (!('Notification' in window) || Notification.permission !== 'granted') return + const shown = new Notification(title, { body, tag: String(item.id) }) + shown.onclick = () => { + window.focus() + opens(item) + shown.close() + } +} diff --git a/src/hmz/web/static/sidebar.js b/src/hmz/web/static/sidebar.js index ce77d3bf..e803f8ef 100644 --- a/src/hmz/web/static/sidebar.js +++ b/src/hmz/web/static/sidebar.js @@ -9,6 +9,7 @@ import { pick } from './importing.js' import { copy, menuOn, toast } from './ui.js' import { ago, fill, flowName, h, headline, icon, iconButton, when } from './dom.js' import { sessionsOf } from './epic.js' +import { bell } from './notifications.js' import { nameOf } from './places.js' import { choice, dragged, keep, kept, menu, section, settleSidebar, toggleSidebar } from './shell.js' @@ -61,6 +62,7 @@ export function mountSidebar(root, context, where) { 'div', { class: 'side-top' }, h('a', { class: 'brand', href: '#/new', 'aria-label': 'humanize, a new epic' }, h('img', { src: '/mark.svg', alt: '', width: '24', height: '24' }), h('strong', {}, 'humanize')), + bell(), iconButton('sidebar', 'Hide sidebar', { class: 'side-fold', onclick: () => toggleSidebar(false), 'aria-controls': 'sidebar' }), ), ), diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index fecebe47..b4231590 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3845,3 +3845,111 @@ input[aria-invalid='true'] { -webkit-user-select: none; user-select: none; } + +/* notifications */ + +.notify-bell { + position: relative; + margin-left: auto; +} + +.notify-count { + position: absolute; + top: 1px; + right: 0; + min-width: 16px; + padding: 0 4px; + background: var(--pink); + color: var(--on-fill); + font: 700 10px/16px var(--mono); + text-align: center; + pointer-events: none; +} + +.notify-menu { + width: min(340px, calc(100vw - 32px)); +} + +.notify-head { + display: flex; + align-items: center; + gap: 4px; +} + +.notify-head .popover-head { + flex: 1; +} + +.notify-item .glyph, +.notify-open .glyph { + color: var(--tone); +} + +.notify-item.notify-unread .choice-label::after { + content: ''; + display: inline-block; + width: 7px; + height: 7px; + margin-left: 7px; + vertical-align: 1px; + background: var(--cyan); +} + +.notify-when, +.notify-none { + font: 11px var(--mono); + color: var(--ink-3); +} + +.notify-none { + margin: 0; + padding: 10px 8px; +} + +.notify-toasts { + position: fixed; + z-index: 75; + right: 16px; + bottom: 16px; + display: grid; + gap: 8px; + width: min(340px, calc(100vw - 32px)); +} + +.notify-toasts:empty { + display: none; +} + +.notify-card { + display: flex; + align-items: flex-start; + background: var(--raised); + border: 2px solid var(--ink); + box-shadow: 4px 4px 0 var(--pink); + animation: notify-in 0.18s ease-out; +} + +.notify-open { + display: flex; + flex: 1; + min-width: 0; + gap: 8px; + padding: 10px; + border: 0; + background: none; + color: var(--ink); + font: inherit; + text-align: left; + cursor: pointer; +} + +.notify-open:hover { + background: var(--hover); +} + +@keyframes notify-in { + from { + transform: translateY(8px); + opacity: 0; + } +} diff --git a/tests/integration/doubles_web.py b/tests/integration/doubles_web.py index 5caa1673..c7c46ba0 100644 --- a/tests/integration/doubles_web.py +++ b/tests/integration/doubles_web.py @@ -85,6 +85,7 @@ def opened( dark: bool = False, user_agent: str | None = None, init: str | None = None, + routed: Callable[[Page], object] | None = None, ) -> Generator[Page]: """The page, in a browser let in by the address, until the block ends. @@ -96,6 +97,7 @@ def opened( dark: Whether the system the browser runs on asks for dark colours. user_agent: What the browser says it is, where not its own: as the macOS app's window. init: A script run in the page before any of its own. + routed: Told the page before it is opened, to answer some of its requests itself. Yields: The page, drawn and with its sidebar up. @@ -120,6 +122,8 @@ def opened( if init: context.add_init_script(init) page = context.new_page() + if routed: + routed(page) failed: list[str] = [] page.on("pageerror", lambda error: failed.append(str(error))) # A refusal is the server answering, which the page says where it happened; what diff --git a/tests/integration/test_web_notification_centre.py b/tests/integration/test_web_notification_centre.py new file mode 100644 index 00000000..f2f218ae --- /dev/null +++ b/tests/integration/test_web_notification_centre.py @@ -0,0 +1,226 @@ +"""`hmz web`'s notification centre: the bell, its menu, the cards, and the system's own. + +The global notifications are the local server's, of every daemon; here they are answered by +the browser itself, routed before the page asks, so the page is tested alone: a bell with how +many are unread, a menu of them newest first that marks them read and clears them, a card for +each one heard on the stream, and -- while the page is not looked at -- one the system shows, +by the browser's `Notification` or by the macOS app. Each in Chromium and WebKit. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +import pytest +from playwright.sync_api import expect + +from tests.integration.doubles_web import ENGINES, opened, served + +if TYPE_CHECKING: + from playwright.sync_api import Page, Route + +pytestmark = pytest.mark.parametrize("engine", ENGINES) + +#: What the macOS app's window says it is. +_APP = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15" + " (KHTML, like Gecko) humanize-desktop/0.0.0" +) + +#: A page that is never looked at, which the browser's and the app's notifications are stubbed in. +_AWAY = """ +Object.defineProperty(document, 'hidden', { get: () => true }) +document.hasFocus = () => false +window.shown = [] +window.Notification = class { + static permission = 'granted' + constructor(title, options) { window.shown.push({ title, ...options }) } + close() {} +} +window.__TAURI_INTERNALS__ = { + invoke: (command, args) => { window.shown.push({ command, ...args }); return Promise.resolve() }, +} +""" + + +def _item(id_: int, kind: str, **more: Any) -> dict[str, Any]: + return { + "id": id_, + "kind": kind, + "daemon": "local", + "workspace": "/w", + "epic": f"e{id_}", + "flow": "chat", + "task": "Fix it", + "role": "", + "text": "", + "at": "2026-10-12T10:00:00Z", + "read": False, + **more, + } + + +@dataclass +class Feed: + """The server's notifications, as the page is answered them, and what it was sent.""" + + items: list[dict[str, Any]] = field(default_factory=list[dict[str, Any]]) + heard: list[dict[str, Any]] = field(default_factory=list[dict[str, Any]]) + system: bool = True + sent: list[tuple[str, Any]] = field(default_factory=list[tuple[str, Any]]) + asked: int = 0 + + def routes(self, page: Page) -> None: + page.route("**/api/notifications", self._list) + page.route("**/api/notifications/stream", self._stream) + page.route("**/api/notifications/settings", self._settings) + page.route("**/api/notifications/read", self._written) + page.route("**/api/notifications/clear", self._written) + + def _list(self, route: Route) -> None: + unread = sum(not one["read"] for one in self.items) + route.fulfill(json={"items": self.items, "unread": unread}) + + def _stream(self, route: Route) -> None: + # What is heard is told once, and kept with the rest from then on, as the server does. + said = "retry: 60000\n\n" + "".join( + f"event: notification\nid: {one['id']}\ndata: {json.dumps(one)}\n\n" + for one in self.heard + ) + self.items[:0] = reversed(self.heard) + self.heard = [] + route.fulfill( + status=200, + headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"}, + body=said, + ) + + def _settings(self, route: Route) -> None: + self.asked += 1 + kinds = dict.fromkeys(("done", "failed", "stopped", "asked", "listening"), True) + route.fulfill(json={"kinds": kinds, "system": self.system}) + + def _written(self, route: Route) -> None: + request = route.request + what = request.url.rsplit("/", 1)[1] + said: dict[str, Any] = request.post_data_json or {} + self.sent.append((what, said)) + if what == "clear": + self.items = [] + for one in self.items: + one["read"] = ( + one["read"] or said.get("all") or one["id"] in said.get("ids", []) + ) + route.fulfill(json={}) + + +def test_the_bell_counts_the_unread_and_its_menu_reads_and_clears_them( + engine: str, +) -> None: + feed = Feed( + items=[ + _item(2, "asked", text="Which branch?"), + _item(1, "done", daemon="devbox", read=True), + ] + ) + with served() as site, opened(site, engine=engine, routed=feed.routes) as page: + bell = page.locator("[data-hmz=notifications]") + expect(bell).to_be_visible() + expect(bell.locator("[data-hmz=notification-count]")).to_have_text("1") + + bell.click() + menu = page.locator("[data-hmz=notification-menu]") + expect(menu.locator("[data-hmz=notification] .choice-label")).to_have_text( + ["Question · Local", "Done · devbox"] + ) + expect(menu.locator("[data-hmz=notification]").first).to_contain_text( + "Which branch?" + ) + expect(menu.locator(".notify-unread")).to_have_count(1) + + menu.locator("[data-hmz=notifications-read]").click() + expect(menu.locator(".notify-unread")).to_have_count(0) + expect(bell.locator("[data-hmz=notification-count]")).to_be_hidden() + assert feed.sent == [("read", {"all": True})] + + menu.locator("[data-hmz=notifications-clear]").click() + expect(menu.locator("[data-hmz=notification]")).to_have_count(0) + expect(menu).to_contain_text("Nothing yet") + assert feed.sent[-1] == ("clear", {}) + + +def test_an_item_picked_is_read_and_opens_what_it_is_about(engine: str) -> None: + feed = Feed(items=[_item(3, "listening", daemon="devbox", workspace="/w/app")]) + with served() as site, opened(site, engine=engine, routed=feed.routes) as page: + page.locator("[data-hmz=notifications]").click() + page.locator("[data-hmz=notification]").click() + expect(page.locator("[data-hmz=notification-menu]")).to_have_count(0) + assert page.evaluate("location.hash") == "#/@devbox/live?workspace=%2Fw%2Fapp" + assert feed.sent == [("read", {"ids": [3]})] + + +def test_a_notification_heard_is_a_card_that_opens_it(engine: str) -> None: + feed = Feed(heard=[_item(4, "failed", daemon="devbox", epic="big-fix")]) + with served() as site, opened(site, engine=engine, routed=feed.routes) as page: + card = page.locator( + "[data-hmz=notification-toasts] [data-hmz=notification-toast]" + ) + expect(card).to_have_count(1) + expect(card).to_contain_text("Failed · devbox") + expect(page.locator("[data-hmz=notification-count]")).to_have_text("1") + card.locator(".notify-open").click() + assert page.evaluate("location.hash") == "#/@devbox/epics/big-fix" + expect(card).to_have_count(0) + expect(page.locator("[data-hmz=notification-count]")).to_be_hidden() + + +def test_there_is_no_bell_where_the_server_has_no_notifications(engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + expect(page.locator("[data-hmz=sidebar] .brand")).to_be_visible() + expect(page.locator("[data-hmz=notifications]")).to_be_hidden() + + +@pytest.mark.parametrize("system", [True, False]) +def test_the_browser_shows_one_while_the_page_is_not_looked_at( + engine: str, system: bool +) -> None: + feed = Feed(heard=[_item(5, "done", text="All green")], system=system) + with ( + served() as site, + opened(site, engine=engine, init=_AWAY, routed=feed.routes) as page, + ): + expect(page.locator("[data-hmz=notification-toast]")).to_have_count(1) + if system: + page.wait_for_function("window.shown.length > 0") + assert page.evaluate("window.shown") == [ + {"title": "Done · Local", "body": "chat · All green", "tag": "5"} + ] + else: + # The settings are read before the stream is opened: once they are answered, the + # page has had all it needs to show one, and must have shown none. + while not feed.asked: + page.wait_for_timeout(50) + page.wait_for_timeout(500) + assert page.evaluate("window.shown") == [] + + +def test_the_app_shows_one_through_its_own_notifications(engine: str) -> None: + feed = Feed(heard=[_item(6, "asked", text="Ship it?")]) + with ( + served() as site, + opened( + site, engine=engine, user_agent=_APP, init=_AWAY, routed=feed.routes + ) as page, + ): + expect(page.locator("[data-hmz=notification-toast]")).to_have_count(1) + page.wait_for_function("window.shown.length > 0") + assert page.evaluate("window.shown") == [ + { + "command": "notify", + "title": "Question · Local", + "body": "chat · Ship it?", + "address": "#/live?workspace=%2Fw", + } + ]