feat(dashboard): operator inbox with mark-as-read on Y3R C4LL

Agents that `send(to: "operator")` were easy to miss — they only
surfaced on the FL0W firehose with no read-state (#1469). Surface them
on the Y3R C4LL ("things waiting on you") tab as a proper inbox.

Backend:
- broker: `unread_for_recipient(recipient, limit)` — unacked messages
  for a recipient, newest-first. Mirrors `mark_all_read`'s filter
  EXACTLY (`recipient = ?1 AND acked_at IS NULL`, no `delivered_at`
  condition) so everything listed is exactly what mark-read clears —
  operator rows never get `delivered_at` set (no agent-socket recv).
- dashboard: `GET /api/operator-inbox` → `{ messages: [...] }` (id,
  from, body, at, in_reply_to, validated file_refs). Mark-read reuses
  the existing `POST /api/agent/operator/mark-all-read` (the route
  format-validates the name; "operator" passes; `mark_all_read`
  already acks `to="operator"` rows).

Frontend (Y3R C4LL):
- New ◆ 1NB0X ◆ section listing unread messages (sender · time · body,
  path-linkified) + a "✓ mark all read" button.
- Cold-loaded on page load + on tab activation; appended live from the
  broker `sent` stream (deduped on row id); cleared on mark-all-read.
- Unread count folds into the Y3R C4LL tab pill + the browser-title
  `(N)` prefix, so messages are visible from any tab.

Removing the now-redundant FL0W operator-inbox UI is a clean follow-up
(deferred to avoid a flow.js conflict with the in-flight #1473).
Backend (broker + route) is host-side — @damocles to review per plan.

Closes #1469.
This commit is contained in:
iris 2026-06-06 12:03:34 +02:00 committed by mara
commit 7d00928c69
4 changed files with 172 additions and 2 deletions

View file

@ -153,6 +153,16 @@
can decide without leaving the pane. -->
<section class="tab-pane" id="tab-pane-call"
role="tabpanel" aria-labelledby="tab-call">
<!-- 1NB0X: unread agent→operator messages (#1469). Fetched on
tab activation + cold load, appended live from the broker
stream, cleared via "mark all read". Count folds into the
Y3R C4LL pill so messages aren't missed. -->
<h2>◆ 1NB0X ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="operator-inbox-section">
<p class="meta">loading…</p>
</div>
<h2>◆ P3NDING APPR0VALS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="approvals-section">

View file

@ -3970,6 +3970,10 @@ window.marked = marked;
}
}
refreshState();
// Cold-load the operator inbox (#1469) so the Y3R C4LL pill + browser
// title reflect unread agent→operator messages immediately, before the
// operator opens the tab. Live updates arrive via the broker stream.
refreshOperatorInbox();
NOTIF.bind();
Panel.bind();
@ -4017,6 +4021,12 @@ window.marked = marked;
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
// Broker `sent` frames aren't mutation events, but the operator
// inbox (#1469) cares about ones addressed to "operator".
if (ev.kind === 'sent' && ev.to === 'operator') {
operatorInboxAppendFromEvent(ev);
return;
}
const h = MUTATION_HANDLERS[ev.kind];
if (!h) return; // broker rows + future kinds — dashboard doesn't care
try { h(ev); }
@ -4076,6 +4086,7 @@ window.marked = marked;
}
// ST4TS: hive-wide rollup is a pull (no SSE) — fetch on activation.
if (target === 'stats') { refreshHiveStats(); }
if (target === 'call') { refreshOperatorInbox(); }
// SYST3M C0NT41N3R L04D: live cgroup poll only while the tab is
// open (cpu needs a short two-sample read each refresh).
if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); }
@ -4484,6 +4495,72 @@ window.marked = marked;
// stores so SSE-driven updates flow through without extra plumbing.
// Set `hidden` when the count is zero so the pill doesn't draw
// attention to an empty room.
// ─── operator inbox (#1469) — unread agent→operator messages ────────────
// The Y3R C4LL tab surfaces messages agents `send(to: "operator")` so
// the operator stops missing them. Unread = broker rows to "operator"
// with `acked_at IS NULL`; cold-loaded from `/api/operator-inbox`,
// appended live from the broker `sent` stream, and cleared via the
// existing per-recipient ack (`POST /api/agent/operator/mark-all-read`).
// Count folds into the Y3R C4LL pill + browser-title prefix.
let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first
async function refreshOperatorInbox() {
try {
const r = await fetch('/api/operator-inbox');
if (r.ok) {
const data = await r.json();
operatorInbox = Array.isArray(data.messages) ? data.messages : [];
}
} catch { /* keep prior list on transient failure */ }
renderOperatorInbox();
refreshTabCounts();
}
function renderOperatorInbox() {
const root = $('operator-inbox-section');
if (!root) return;
root.replaceChildren();
if (!operatorInbox.length) {
root.append(el('p', { class: 'meta' }, 'no unread messages'));
return;
}
const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' },
`✓ mark all read (${operatorInbox.length})`);
mark.addEventListener('click', markOperatorInboxRead);
root.append(el('div', { class: 'inbox-toolbar' }, mark));
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const ul = el('ul', { class: 'inbox' });
for (const m of operatorInbox) {
const body = el('span', { class: 'msg-body' });
appendLinkified(body, m.body, m.file_refs);
ul.append(el('li', {},
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
el('span', { class: 'msg-from' }, m.from), ' ',
el('span', { class: 'msg-sep' }, '→ '),
body,
));
}
root.append(ul);
}
async function markOperatorInboxRead() {
try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); }
catch { /* best-effort; the next refresh reconciles */ }
operatorInbox = [];
renderOperatorInbox();
refreshTabCounts();
}
// Live append from the broker stream — a `sent` frame addressed to
// "operator". De-dupes on broker row id so a history/live overlap or
// a refresh racing the stream doesn't double-list.
function operatorInboxAppendFromEvent(ev) {
if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return;
operatorInbox.unshift({
id: ev.id, from: ev.from, body: ev.body, at: ev.at,
file_refs: ev.file_refs || [],
});
if (operatorInbox.length > 100) operatorInbox.length = 100;
renderOperatorInbox();
refreshTabCounts();
}
function setTabCount(tab, n) {
const el_ = $('tab-count-' + tab);
if (!el_) return;
@ -4501,10 +4578,12 @@ window.marked = marked;
if (c.needs_update) swarm++;
}
setTabCount('swarm', swarm);
// Y3R C4LL — pending approvals + operator-targeted questions.
// Y3R C4LL — pending approvals + operator-targeted questions +
// unread agent→operator messages (#1469).
const callCount =
(approvalsState?.pending?.length ?? 0) +
(questionsState?.pending?.length ?? 0);
(questionsState?.pending?.length ?? 0) +
operatorInbox.length;
setTabCount('call', callCount);
// Browser tab title prefix — lets the operator see the pending
// call count without switching to the window. Strips any existing