From fb669c17c8356048d306264c039761a9a06d7775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 12:28:04 +0200 Subject: [PATCH] dashboard: derive operator inbox from message stream (drop snapshot field + refetch workaround) --- hive-c0re/assets/app.js | 47 ++++++++++++++++++++++------------- hive-c0re/src/dashboard.rs | 15 +++-------- hive-fr0nt/assets/terminal.js | 10 +++++++- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index d9eafbf..6e5558c 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -118,20 +118,20 @@ // 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. + // that arrived while the page is open. The inbox no longer + // needs seeding here: it's derived from the broker stream which + // does its own per-event notification on live arrival, and + // history-replayed events are silent by virtue of `fromHistory`. 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; } @@ -148,14 +148,6 @@ NOTIF.show('◆ manager asks', q.question.slice(0, 120), 'hyperhive:question:' + q.id); } - // 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 ──────────────────────────────────────────────────────── @@ -605,16 +597,30 @@ } } - function renderInbox(s) { + // ─── operator inbox (derived from the broker message stream) ─────────── + // No longer shipped on `/api/state.operator_inbox`. The dashboard + // terminal's HiveTerminal feeds this via `onAnyEvent` — backfill from + // `/messages/history` populates on load, live SSE keeps it current. + // Newest-first to match the previous behaviour. + const INBOX_LIMIT = 50; + const operatorInbox = []; + function inboxAppendFromEvent(ev) { + if (ev.kind !== 'sent' || ev.to !== 'operator') return false; + operatorInbox.unshift({ from: ev.from, body: ev.body, at: ev.at }); + if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT; + return true; + } + function renderInbox() { const root = $('inbox-section'); + if (!root) return; root.innerHTML = ''; - if (!s.operator_inbox || !s.operator_inbox.length) { + if (!operatorInbox.length) { root.append(el('p', { class: 'empty' }, 'no messages')); return; } const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); const ul = el('ul', { class: 'inbox' }); - for (const m of s.operator_inbox) { + for (const m of operatorInbox) { const li = el('li'); li.append( el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', @@ -932,7 +938,7 @@ renderContainers(s); renderTombstones(s); renderQuestions(s); - renderInbox(s); + renderInbox(); renderApprovals(s); renderMetaInputs(s); restoreOpenDetails(openDetails); @@ -994,10 +1000,17 @@ sent: (ev, api) => renderMsg(ev, api, '→'), delivered: (ev, api) => renderMsg(ev, api, '✓'), }, + // Both history backfill and live frames flow through here, so the + // inbox section ends up populated correctly on first paint and + // updated thereafter — no /api/state refetch needed for inbox + // freshness (which used to be the workaround for the + // double-render bug). + onAnyEvent: (ev /* , { fromHistory } */) => { + if (inboxAppendFromEvent(ev)) renderInbox(); + }, onLiveEvent: (ev) => { pulseBanner(); if (ev.kind === 'sent' && ev.to === 'operator') { - refreshState(); NOTIF.show( '◆ ' + ev.from + ' → operator', String(ev.body || '').slice(0, 200), diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 22ebcbd..d324137 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -161,10 +161,6 @@ struct StateSnapshot { /// Last 30 resolved approvals (approved / denied / failed), newest- /// first. Drives the "history" tab on the approvals section. approval_history: Vec, - /// Latest messages addressed to `operator` — surfaces agent replies - /// asynchronously so the operator can see them without watching the - /// live panel during a turn. - operator_inbox: Vec, /// Pending operator questions (currently only from the manager). /// `ask_operator` returns immediately with the id; on `/answer-question` /// we mark the row answered and fire `HelperEvent::OperatorAnswered` @@ -323,13 +319,9 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J let tombstones = build_tombstone_views(&state.coord, &containers, &transient_snapshot); let port_conflicts = build_port_conflicts(&containers); - let operator_inbox = log_default( - "broker.recent_for(operator)", - state - .coord - .broker - .recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50), - ); + // operator_inbox used to be served here as a 50-row array; the + // dashboard now derives it client-side from the message stream + // (terminal backfill + live SSE), so the snapshot stops shipping it. let questions = log_default("questions.pending", state.coord.questions.pending()); let question_history = log_default("questions.recent_answered", state.coord.questions.recent_answered(20)); @@ -344,7 +336,6 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J approvals, approval_history, meta_inputs: read_meta_inputs(), - operator_inbox, questions, question_history, tombstones, diff --git a/hive-fr0nt/assets/terminal.js b/hive-fr0nt/assets/terminal.js index 11dc051..56f7dc6 100644 --- a/hive-fr0nt/assets/terminal.js +++ b/hive-fr0nt/assets/terminal.js @@ -14,7 +14,11 @@ // delivered: (ev, api) => api.row('msgrow delivered', ...), // _default: (ev, api) => api.row('note', JSON.stringify(ev)), // }, -// onLiveEvent: (ev) => { /* side effects: notifications, state pokes */ }, +// onLiveEvent: (ev) => { /* live-only side effects (notif, state pokes) */ }, +// onAnyEvent: (ev, { fromHistory }) => { /* runs for every event in +// both backfill replay and live — use for derived views that need +// the full picture (e.g. a per-recipient inbox built from broker +// events) */ }, // onBackfillDone: (count) => { /* one-shot after history replay */ }, // pillAnchor: document.getElementById('msgflow').parentElement, // }); @@ -164,6 +168,10 @@ console.error('terminal renderer threw', ev, err); row('note', '[render err] ' + (err && err.message ? err.message : err)); } + if (opts.onAnyEvent) { + try { opts.onAnyEvent(ev, { fromHistory }); } + catch (err) { console.error('onAnyEvent threw', err); } + } } // Subscribe → buffer → fetch history → dedupe → apply.