dashboard: derive operator inbox from message stream (drop snapshot field + refetch workaround)
This commit is contained in:
parent
1340a654e7
commit
fb669c17c8
3 changed files with 42 additions and 30 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue