diff --git a/TODO.md b/TODO.md index fde2021..1965d77 100644 --- a/TODO.md +++ b/TODO.md @@ -44,29 +44,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## 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 override from the terminal. Depends on the model-override work diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 3f9aff8..6bce65d 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -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' ? '→' : '✓'; diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index d1a1841..0245ac6 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -190,6 +190,32 @@ a:hover { 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 { color: var(--amber); font-size: 0.85em; diff --git a/hive-c0re/assets/index.html b/hive-c0re/assets/index.html index 26e7513..4abf1dd 100644 --- a/hive-c0re/assets/index.html +++ b/hive-c0re/assets/index.html @@ -10,6 +10,13 @@ ░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░ +
+ + + + +
+

◆ C0NTAINERS ◆

══════════════════════════════════════════════════════════════