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:
parent
a67aada7c9
commit
237b215c55
4 changed files with 148 additions and 25 deletions
|
|
@ -34,6 +34,113 @@
|
|||
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 ────────────────────────────────────────────────────────
|
||||
document.addEventListener('submit', async (e) => {
|
||||
const f = e.target;
|
||||
|
|
@ -477,6 +584,7 @@
|
|||
renderQuestions(s);
|
||||
renderInbox(s);
|
||||
renderApprovals(s);
|
||||
notifyDeltas(s);
|
||||
// Auto-refresh: fast (2s) while a spawn or a per-container
|
||||
// action is in flight, otherwise heartbeat (5s) so newly-queued
|
||||
// approvals from the manager show up without the operator
|
||||
|
|
@ -493,6 +601,7 @@
|
|||
}
|
||||
}
|
||||
refreshState();
|
||||
NOTIF.bind();
|
||||
|
||||
// ─── message flow SSE ───────────────────────────────────────────────────
|
||||
(() => {
|
||||
|
|
@ -517,8 +626,12 @@
|
|||
let m;
|
||||
try { m = JSON.parse(e.data); } catch { return; }
|
||||
pulseBanner();
|
||||
// Live-update the inbox when claude sends to operator.
|
||||
if (m.kind === 'sent' && m.to === 'operator') refreshState();
|
||||
// Live-update the inbox when claude sends to operator + ping
|
||||
// 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');
|
||||
row.className = 'msgrow ' + m.kind;
|
||||
const kind = m.kind === 'sent' ? '→' : '✓';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue