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,