dashboard: browser notifications for operator-bound events

three signals fire OS notifications:
- new approval lands in the queue (per id, via /api/state delta)
- new ask_operator question queued (per id)
- broker message sent to operator (live via SSE)

first /api/state render after page load seeds the 'seen' sets
without firing — only items that arrive while the page is open
count. controls in a row under the banner: 🔔 enable
notifications (calls requestPermission, hides on grant), 🔕 mute /
🔔 unmute toggle (localStorage-backed so operator can silence
without revoking the permission), inline status text when blocked
or unsupported.

notification tag='hyperhive' collapses rapid bursts; onclick
focuses the dashboard tab. requires secure context (HTTPS or
localhost) — on other origins the API is unavailable and the
controls hide themselves.

todo: entry dropped.
This commit is contained in:
müde 2026-05-15 21:10:20 +02:00
parent a67aada7c9
commit 237b215c55
4 changed files with 148 additions and 25 deletions

23
TODO.md
View file

@ -44,29 +44,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in
## UI / UX ## UI / UX
- **Browser notifications for operator-bound events.** Dashboard
pings the OS notification center when (a) a new approval lands
in the queue, (b) a new `ask_operator` question is queued, (c) a
broker message is sent `to: "operator"`. All three data sources
are already in `/api/state` + `/messages/stream` so this is
pure frontend. Sketch:
1. Small "🔔 enable notifications" button somewhere (header
or near the inbox section). Clicks call
`Notification.requestPermission()`. Hide once granted.
2. Track last-seen counts in the JS app
(`approvals.length`, `questions.length`). On
`refreshState`, if the count went up, fire
`new Notification(...)` per new item.
3. SSE handler for `messages/stream` fires a notification on
`kind === 'sent' && to === 'operator'` (already triggers
`refreshState`; just adds a notify call alongside).
4. Notification body links back to the dashboard (`onclick →
window.focus()` + section anchor).
Caveats: Notification API requires a secure context (HTTPS or
localhost). Most operators access via LAN / Tailscale — works
fine for localhost forwards, otherwise needs a TLS cert in the
module. Persist a per-browser "muted" toggle in localStorage so
the operator can silence without revoking permission.
- **Terminal: `/model` slash command.** Operator-typeable model - **Terminal: `/model` slash command.** Operator-typeable model
override from the terminal. Depends on the model-override work override from the terminal. Depends on the model-override work

View file

@ -34,6 +34,113 @@
return f; return f;
}; };
// ─── browser notifications ──────────────────────────────────────────────
// Fires OS notifications on three operator-bound signals:
// - new approval landed in the queue
// - new operator question queued (ask_operator)
// - broker message sent `to: "operator"`
// permission grant is per-browser; a localStorage "muted" toggle lets
// the operator silence without revoking. Secure-context only (HTTPS /
// localhost) — on other origins the API is unavailable and we hide
// the controls.
const NOTIF = (() => {
const supported = typeof Notification !== 'undefined';
const MUTED_KEY = 'hyperhive.notify.muted';
const isMuted = () => localStorage.getItem(MUTED_KEY) === '1';
const setMuted = (v) => v
? localStorage.setItem(MUTED_KEY, '1')
: localStorage.removeItem(MUTED_KEY);
function renderControls() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
const status = $('notif-status');
if (!enable || !mute || !unmute || !status) return;
if (!supported) {
enable.hidden = mute.hidden = unmute.hidden = true;
status.hidden = false;
status.textContent = 'notifications unsupported in this browser';
return;
}
const perm = Notification.permission;
enable.hidden = perm === 'granted';
mute.hidden = perm !== 'granted' || isMuted();
unmute.hidden = perm !== 'granted' || !isMuted();
status.hidden = perm !== 'denied';
if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings';
}
function bind() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
if (!supported || !enable || !mute || !unmute) return;
enable.addEventListener('click', async () => {
await Notification.requestPermission();
renderControls();
});
mute.addEventListener('click', () => { setMuted(true); renderControls(); });
unmute.addEventListener('click', () => { setMuted(false); renderControls(); });
renderControls();
}
function show(title, body) {
if (!supported || Notification.permission !== 'granted' || isMuted()) return;
try {
const n = new Notification(title, {
body,
tag: 'hyperhive', // collapse rapid bursts
icon: '/static/dashboard.css', // any same-origin asset works as a favicon stand-in
});
n.onclick = () => { window.focus(); n.close(); };
} catch (err) {
console.warn('notification show failed', err);
}
}
return { bind, show, renderControls };
})();
// Track which items we've already notified about so a re-render
// doesn't re-fire for the same row. Keyed by stable ids; reset only
// when the page reloads.
const seenApprovals = new Set();
const seenQuestions = new Set();
const seenInboxIds = new Set();
let seededNotify = false;
function notifyDeltas(s) {
const approvals = s.approvals || [];
const questions = s.questions || [];
const inbox = s.operator_inbox || [];
if (!seededNotify) {
// First render after page load — fill the "seen" sets without
// firing notifications. We only want to notify on NEW items
// that arrived while the page is open.
for (const a of approvals) seenApprovals.add(a.id);
for (const q of questions) seenQuestions.add(q.id);
for (const m of inbox) seenInboxIds.add(m.id);
seededNotify = true;
return;
}
for (const a of approvals) {
if (seenApprovals.has(a.id)) continue;
seenApprovals.add(a.id);
const verb = a.kind === 'spawn' ? 'spawn approval' : 'config commit';
NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`);
}
for (const q of questions) {
if (seenQuestions.has(q.id)) continue;
seenQuestions.add(q.id);
NOTIF.show('◆ manager asks', q.question.slice(0, 120));
}
// operator_inbox: only notify on truly new ids — sse already
// handles single-message notifications, but if the operator
// missed an SSE event (page reloaded), this catches up.
for (const m of inbox) {
if (seenInboxIds.has(m.id)) continue;
seenInboxIds.add(m.id);
// suppress here; SSE path handles the live notification.
}
}
// ─── async forms ──────────────────────────────────────────────────────── // ─── async forms ────────────────────────────────────────────────────────
document.addEventListener('submit', async (e) => { document.addEventListener('submit', async (e) => {
const f = e.target; const f = e.target;
@ -477,6 +584,7 @@
renderQuestions(s); renderQuestions(s);
renderInbox(s); renderInbox(s);
renderApprovals(s); renderApprovals(s);
notifyDeltas(s);
// Auto-refresh: fast (2s) while a spawn or a per-container // Auto-refresh: fast (2s) while a spawn or a per-container
// action is in flight, otherwise heartbeat (5s) so newly-queued // action is in flight, otherwise heartbeat (5s) so newly-queued
// approvals from the manager show up without the operator // approvals from the manager show up without the operator
@ -493,6 +601,7 @@
} }
} }
refreshState(); refreshState();
NOTIF.bind();
// ─── message flow SSE ─────────────────────────────────────────────────── // ─── message flow SSE ───────────────────────────────────────────────────
(() => { (() => {
@ -517,8 +626,12 @@
let m; let m;
try { m = JSON.parse(e.data); } catch { return; } try { m = JSON.parse(e.data); } catch { return; }
pulseBanner(); pulseBanner();
// Live-update the inbox when claude sends to operator. // Live-update the inbox when claude sends to operator + ping
if (m.kind === 'sent' && m.to === 'operator') refreshState(); // the OS notification center.
if (m.kind === 'sent' && m.to === 'operator') {
refreshState();
NOTIF.show('◆ ' + m.from + ' → operator', String(m.body || '').slice(0, 200));
}
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'msgrow ' + m.kind; row.className = 'msgrow ' + m.kind;
const kind = m.kind === 'sent' ? '→' : '✓'; const kind = m.kind === 'sent' ? '→' : '✓';

View file

@ -190,6 +190,32 @@ a:hover {
word-break: normal; word-break: normal;
} }
/* Notification controls sit between the banner and the
containers section. Hidden by JS when notifications are
unsupported, denied, or already in the right state. */
.notif-row {
display: flex;
gap: 0.5em;
align-items: center;
margin: 0.5em 0;
font-size: 0.85em;
}
.btn-notif {
font-family: inherit;
font-size: 0.85em;
background: transparent;
color: var(--cyan);
border: 1px solid var(--cyan);
padding: 0.2em 0.7em;
border-radius: 999px;
cursor: pointer;
text-shadow: 0 0 4px currentColor;
}
.btn-notif:hover {
background: rgba(137, 220, 235, 0.1);
box-shadow: 0 0 10px -2px currentColor;
}
.pending-state { .pending-state {
color: var(--amber); color: var(--amber);
font-size: 0.85em; font-size: 0.85em;

View file

@ -10,6 +10,13 @@
░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░ ░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░
</pre> </pre>
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
<span id="notif-status" class="meta" hidden></span>
</div>
<h2>◆ C0NTAINERS ◆</h2> <h2>◆ C0NTAINERS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div> <div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="containers-section"> <div id="containers-section">