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