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:
parent
4c77eefc51
commit
7d00928c69
4 changed files with 172 additions and 2 deletions
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Reference in a new issue