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

@ -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<Vec<MessageEvent>> {
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::<rusqlite::Result<Vec<_>>>()
.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.

View file

@ -67,6 +67,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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<AppState>) -> 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<AppState>) -> Response {
const INBOX_LIMIT: u64 = 100;
match state.coord.broker.unread_for_recipient("operator", INBOX_LIMIT) {
Ok(messages) => {
let items: Vec<serde_json::Value> = 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<String>,