dashboard: derive operator inbox from message stream (drop snapshot field + refetch workaround)

This commit is contained in:
müde 2026-05-17 12:28:04 +02:00
parent 1340a654e7
commit fb669c17c8
3 changed files with 42 additions and 30 deletions

View file

@ -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),

View file

@ -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<ApprovalHistoryView>,
/// 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<hive_sh4re::InboxRow>,
/// 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<AppState>) -> 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<AppState>) -> axum::J
approvals,
approval_history,
meta_inputs: read_meta_inputs(),
operator_inbox,
questions,
question_history,
tombstones,

View file

@ -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.