From 7d00928c69ad1504e07dbc279b027e065d798b6d Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 6 Jun 2026 12:03:34 +0200 Subject: [PATCH] feat(dashboard): operator inbox with mark-as-read on Y3R C4LL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/packages/dashboard/src/index.html | 10 +++ frontend/packages/dashboard/src/tabs.js | 83 +++++++++++++++++++++- hive-c0re/src/broker.rs | 38 ++++++++++ hive-c0re/src/dashboard.rs | 43 +++++++++++ 4 files changed, 172 insertions(+), 2 deletions(-) diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 46a64527..d5a43b27 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -153,6 +153,16 @@ can decide without leaving the pane. -->
+ +

◆ 1NB0X ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+

◆ P3NDING APPR0VALS ◆

══════════════════════════════════════════════════════════════
diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index b37bc7cb..ca11e39a 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -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 diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs index 77e2bb31..d8619c96 100644 --- a/hive-c0re/src/broker.rs +++ b/hive-c0re/src/broker.rs @@ -271,6 +271,44 @@ impl Broker { .map_err(Into::into) } + /// Unacknowledged messages addressed to `recipient`, newest-first. + /// Backs the dashboard's operator inbox (#1469): the operator never + /// `recv`s over an agent socket, so messages to `"operator"` sit in + /// the broker with `acked_at IS NULL` until the operator hits "mark + /// all read" (which calls [`Broker::mark_all_read`]). This read + /// mirrors that filter EXACTLY — `recipient = ?1 AND acked_at IS + /// NULL`, with no `delivered_at` condition — so everything listed + /// here is precisely what `mark_all_read` will clear (operator rows + /// may never get `delivered_at` set). Returned as + /// [`MessageEvent::Sent`] so the dashboard reuses its live renderer. + /// + /// # Errors + /// + /// Returns `Err` if the `SQLite` prepare or query fails. + pub fn unread_for_recipient(&self, recipient: &str, limit: u64) -> Result> { + let conn = self.conn.lock().unwrap(); + let limit_i = i64::try_from(limit.min(i64::MAX as u64)).unwrap_or(i64::MAX); + let mut stmt = conn.prepare( + "SELECT id, sender, recipient, body, sent_at, in_reply_to + FROM messages + WHERE recipient = ?1 AND acked_at IS NULL + ORDER BY id DESC + LIMIT ?2", + )?; + let rows = stmt.query_map(params![recipient, limit_i], |row| { + Ok(MessageEvent::Sent { + id: row.get(0)?, + from: row.get(1)?, + to: row.get(2)?, + body: row.get(3)?, + at: row.get(4)?, + in_reply_to: row.get(5)?, + }) + })?; + rows.collect::>>() + .map_err(Into::into) + } + /// Number of undelivered messages addressed to `recipient`. Non-mutating /// — used by the harness to surface "N unread" in tool-result status /// lines without popping the queue. diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 1ca26b7b..bc899b71 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -67,6 +67,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/api/approval-diff/{id}", get(get_approval_diff)) .route("/api/state-file", get(get_state_file)) .route("/api/reminders", get(api_reminders)) + .route("/api/operator-inbox", get(api_operator_inbox)) .route("/api/stats-hive", get(api_stats_hive)) .route("/api/container-resources", get(api_container_resources)) .route("/api/build-logs", get(get_build_logs_all)) @@ -1736,6 +1737,48 @@ async fn api_reminders(State(state): State) -> Response { } } +/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox +/// (#1469). Returns messages addressed to `"operator"` that haven't been +/// acked yet (the operator clears them via the existing +/// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped +/// tokens are validated so the client renders file links like the +/// terminal does. Shape: `{ "messages": [{ id, from, body, at, +/// in_reply_to, file_refs }] }`. +async fn api_operator_inbox(State(state): State) -> Response { + const INBOX_LIMIT: u64 = 100; + match state.coord.broker.unread_for_recipient("operator", INBOX_LIMIT) { + Ok(messages) => { + let items: Vec = messages + .into_iter() + .filter_map(|m| match m { + crate::broker::MessageEvent::Sent { + id, + from, + body, + at, + in_reply_to, + .. + } => { + let file_refs = scan_validated_paths(&body); + Some(serde_json::json!({ + "id": id, + "from": from, + "body": body, + "at": at, + "in_reply_to": in_reply_to, + "file_refs": file_refs, + })) + } + crate::broker::MessageEvent::Delivered { .. } + | crate::broker::MessageEvent::Ping { .. } => None, + }) + .collect(); + axum::Json(serde_json::json!({ "messages": items })).into_response() + } + Err(e) => error_response(&format!("operator-inbox failed: {e:#}")), + } +} + #[derive(Deserialize)] struct StatsHiveQuery { window: Option,