Skip to content
Closed
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
17 changes: 17 additions & 0 deletions docs/user/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/hmz/web/static/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
198 changes: 198 additions & 0 deletions src/hmz/web/static/notifications.js
Original file line number Diff line number Diff line change
@@ -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()
}
}
2 changes: 2 additions & 0 deletions src/hmz/web/static/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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' }),
),
),
Expand Down
108 changes: 108 additions & 0 deletions src/hmz/web/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
4 changes: 4 additions & 0 deletions tests/integration/doubles_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading