From 538e0446d7e5bb4ef43697df4b6bbb4f4d862cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 20:32:19 +0200 Subject: [PATCH] agent page: inbox view of last 30 messages addressed to this agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new wire request AgentRequest::Recent { limit } / ManagerRequest::Recent (plus matching responses with Vec). InboxRow moved to hive-sh4re so it lives on both surfaces without an internal-to-wire conversion. host-side dispatch in agent_server / manager_server calls broker.recent_for(name, limit). per-agent web_ui /api/state grew an inbox: Vec populated via the same per-agent socket (best-effort; transport failure returns empty). frontend renders as a collapsible
section between the state row and the terminal β€” fmt timestamp / from / body in a tight grid, capped at 16em scrollable. only visible when there are rows. --- TODO.md | 6 ------ hive-ag3nt/assets/agent.css | 36 +++++++++++++++++++++++++++++++ hive-ag3nt/assets/app.js | 25 +++++++++++++++++++++ hive-ag3nt/assets/index.html | 5 +++++ hive-ag3nt/src/bin/hive-ag3nt.rs | 2 +- hive-ag3nt/src/bin/hive-m1nd.rs | 3 ++- hive-ag3nt/src/mcp.rs | 3 +++ hive-ag3nt/src/web_ui.rs | 37 ++++++++++++++++++++++++++++++++ hive-c0re/src/agent_server.rs | 6 ++++++ hive-c0re/src/broker.rs | 12 +---------- hive-c0re/src/dashboard.rs | 2 +- hive-c0re/src/manager_server.rs | 6 ++++++ hive-sh4re/src/lib.rs | 28 ++++++++++++++++++++++++ 13 files changed, 151 insertions(+), 20 deletions(-) diff --git a/TODO.md b/TODO.md index 20bb3da..1896fed 100644 --- a/TODO.md +++ b/TODO.md @@ -27,12 +27,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## UI / UX -- **Per-agent inbox view.** Show the last N messages addressed to - this agent on its page (the per-agent equivalent of the - dashboard's operator inbox). Needs a new wire request from agent - β†’ host (host has the broker; agent doesn't); reuse the broker's - `recent_for` query. Last-turn timing + dashboard back-link - already shipped. - **State badge: compacting + napping states.** Idle/thinking already ship (driven from SSE turn_start/turn_end). Add `compacting πŸ“¦` and `napping 😴` once the `/compact` trigger and `nap` tool exist β€” diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index 3624f19..1303183 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -130,6 +130,42 @@ pre.diff { align-items: center; gap: 0.6em; } +/* Per-agent inbox section β€” collapsible, dim, lives between the + state row and the terminal so the operator can peek at what + landed without scrolling through the live tail. */ +.agent-inbox { + margin: 0.4em 0; + font-size: 0.85em; + color: var(--muted); +} +.agent-inbox > summary { + cursor: pointer; + letter-spacing: 0.05em; + list-style: none; +} +.agent-inbox > summary::marker { content: ''; } +.agent-inbox[open] > summary > span::before { content: ''; } +.agent-inbox ul { + list-style: none; + padding: 0.4em 0.8em; + margin: 0.3em 0 0; + background: rgba(255, 255, 255, 0.02); + border-left: 2px solid var(--purple-dim); + max-height: 16em; + overflow-y: auto; +} +.agent-inbox li { + padding: 0.15em 0; + display: grid; + grid-template-columns: auto auto auto 1fr; + gap: 0.5em; + align-items: baseline; +} +.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; } +.agent-inbox .inbox-from { color: var(--amber); } +.agent-inbox .inbox-sep { color: var(--muted); } +.agent-inbox .inbox-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; } + .last-turn { color: var(--muted); font-size: 0.8em; diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index f56bf7f..2702e7c 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -337,6 +337,30 @@ } renderStateBadge(); } + function renderInbox(rows) { + const root = $('inbox-section'); + const list = $('inbox-list'); + const summary = $('inbox-summary'); + if (!root || !list || !summary) return; + if (!rows.length) { + root.hidden = true; + return; + } + root.hidden = false; + summary.textContent = 'inbox Β· ' + rows.length; + list.innerHTML = ''; + const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19); + for (const m of rows) { + const li = el('li'); + li.append( + el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ', + el('span', { class: 'inbox-from' }, m.from), ' ', + el('span', { class: 'inbox-sep' }, 'β†’'), ' ', + el('span', { class: 'inbox-body' }, m.body), + ); + list.append(li); + } + } function renderLastTurn(ms) { const el_ = $('last-turn'); if (!el_) return; @@ -386,6 +410,7 @@ const s = await resp.json(); if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; } renderTermInput(s.label, s.status === 'online'); + renderInbox(s.inbox || []); // Drive the state badge from the harness status. Live SSE events // override to 'thinking' / 'idle' as turns start/end; this only // kicks in for the not-online (offline) case and the initial seed. diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 5d22c30..9906176 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -19,6 +19,11 @@ + +
connecting…
diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index e1bff36..6f899e6 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -139,7 +139,7 @@ async fn serve( turn::emit_turn_end(&bus, &outcome); } Ok(AgentResponse::Empty) => {} - Ok(AgentResponse::Ok | AgentResponse::Status { .. }) => { + Ok(AgentResponse::Ok | AgentResponse::Status { .. } | AgentResponse::Recent { .. }) => { tracing::warn!("recv produced unexpected response kind"); } Ok(AgentResponse::Err { message }) => { diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index d3cbd8d..06a6170 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -139,7 +139,8 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> { Ok( ManagerResponse::Ok | ManagerResponse::Status { .. } - | ManagerResponse::QuestionQueued { .. }, + | ManagerResponse::QuestionQueued { .. } + | ManagerResponse::Recent { .. }, ) => { tracing::warn!("recv produced unexpected response kind"); } diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index d48f4d8..cf1d76b 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -38,6 +38,7 @@ pub enum SocketReply { Empty, Status(u64), QuestionQueued(i64), + Recent(Vec), } impl From for SocketReply { @@ -48,6 +49,7 @@ impl From for SocketReply { hive_sh4re::AgentResponse::Message { from, body } => Self::Message { from, body }, hive_sh4re::AgentResponse::Empty => Self::Empty, hive_sh4re::AgentResponse::Status { unread } => Self::Status(unread), + hive_sh4re::AgentResponse::Recent { rows } => Self::Recent(rows), } } } @@ -61,6 +63,7 @@ impl From for SocketReply { hive_sh4re::ManagerResponse::Empty => Self::Empty, hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread), hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id), + hive_sh4re::ManagerResponse::Recent { rows } => Self::Recent(rows), } } } diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index e0ea25f..9228857 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -124,6 +124,10 @@ struct StateSnapshot { status: &'static str, /// Present when `status == "needs_login_in_progress"`. session: Option, + /// Last N messages addressed to this agent, newest-first. Pulled + /// from the broker via the per-agent socket on each render. + /// Empty on transport failure. + inbox: Vec, } #[derive(Serialize)] @@ -157,14 +161,47 @@ async fn api_state(State(state): State) -> axum::Json { .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(7000); + let inbox = recent_inbox(&state.socket, state.flavor).await; axum::Json(StateSnapshot { label: state.label.clone(), dashboard_port, status, session: session_view, + inbox, }) } +/// Best-effort: pull the last 30 messages addressed to us via the +/// per-agent / manager socket. Empty list on any transport / decode +/// failure β€” the inbox section is decorative, not authoritative. +async fn recent_inbox(socket: &std::path::Path, flavor: Flavor) -> Vec { + const LIMIT: u64 = 30; + match flavor { + Flavor::Agent => { + match client::request::<_, hive_sh4re::AgentResponse>( + socket, + &hive_sh4re::AgentRequest::Recent { limit: LIMIT }, + ) + .await + { + Ok(hive_sh4re::AgentResponse::Recent { rows }) => rows, + _ => Vec::new(), + } + } + Flavor::Manager => { + match client::request::<_, hive_sh4re::ManagerResponse>( + socket, + &hive_sh4re::ManagerRequest::Recent { limit: LIMIT }, + ) + .await + { + Ok(hive_sh4re::ManagerResponse::Recent { rows }) => rows, + _ => Vec::new(), + } + } + } +} + // --------------------------------------------------------------------------- // Action handlers // --------------------------------------------------------------------------- diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 82329ca..1dacf46 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -121,5 +121,11 @@ async fn dispatch(req: &AgentRequest, agent: &str, broker: &Broker) -> AgentResp message: format!("{e:#}"), }, }, + AgentRequest::Recent { limit } => match broker.recent_for(agent, *limit) { + Ok(rows) => AgentResponse::Recent { rows }, + Err(e) => AgentResponse::Err { + message: format!("{e:#}"), + }, + }, } } diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs index bd6b45f..72486ba 100644 --- a/hive-c0re/src/broker.rs +++ b/hive-c0re/src/broker.rs @@ -6,7 +6,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; -use hive_sh4re::Message; +use hive_sh4re::{InboxRow, Message}; use rusqlite::{Connection, OptionalExtension, params}; use serde::Serialize; use tokio::sync::broadcast; @@ -28,16 +28,6 @@ CREATE INDEX IF NOT EXISTS idx_messages_undelivered /// may drop events past this; we send a `lagged` notice in their stream. const EVENT_CHANNEL: usize = 256; -/// One row in a `recent_for()` query β€” the broker's flat view of a -/// message addressed to a given recipient. -#[derive(Debug, Clone, Serialize)] -pub struct InboxRow { - pub id: i64, - pub from: String, - pub body: String, - pub at: i64, -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum MessageEvent { diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 0188fd7..eed8f9b 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -102,7 +102,7 @@ struct StateSnapshot { /// Latest messages addressed to `operator` β€” surfaces agent replies /// asynchronously so the operator can see them without watching the /// live panel during a turn. - operator_inbox: Vec, + operator_inbox: Vec, /// Pending operator questions (currently only from the manager). /// `ask_operator` returns immediately with the id; on `/answer-question` /// we mark the row answered and fire `HelperEvent::OperatorAnswered` diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 9e4ae18..efefd64 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -100,6 +100,12 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse message: format!("{e:#}"), }, }, + ManagerRequest::Recent { limit } => match coord.broker.recent_for(MANAGER_AGENT, *limit) { + Ok(rows) => ManagerResponse::Recent { rows }, + Err(e) => ManagerResponse::Err { + message: format!("{e:#}"), + }, + }, ManagerRequest::Recv => match coord .broker .recv_blocking(MANAGER_AGENT, MANAGER_RECV_LONG_POLL) diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index a1c3404..56eead0 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -146,6 +146,19 @@ pub struct Message { pub body: String, } +/// One row of a broker inbox query β€” what the dashboard renders in +/// its operator-inbox section and what a per-agent web UI returns +/// from a `Recent` request. Lives in `hive_sh4re` so it can travel +/// over both the dashboard's `/api/state` and the agent socket +/// without an internal-to-wire conversion. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InboxRow { + pub id: i64, + pub from: String, + pub body: String, + pub at: i64, +} + /// Requests on a per-agent socket. The agent's identity is the socket /// it came in on; `Send.from` is filled in by the server, not the client. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -163,6 +176,10 @@ pub enum AgentRequest { /// per-agent equivalent of the old dashboard T4LK form, but scoped to /// the agent whose page the operator is on. OperatorMsg { body: String }, + /// Last `limit` messages addressed to this agent, newest-first. + /// Non-mutating β€” pulls from the broker without delivering. The + /// per-agent web UI uses this to render its own inbox section. + Recent { limit: u64 }, } /// Responses on a per-agent socket. @@ -179,6 +196,8 @@ pub enum AgentResponse { Empty, /// `Status` result: how many pending messages are in this agent's inbox. Status { unread: u64 }, + /// `Recent` result: newest-first inbox rows. + Recent { rows: Vec }, } // ----------------------------------------------------------------------------- @@ -264,6 +283,11 @@ pub enum ManagerRequest { OperatorMsg { body: String, }, + /// Last `limit` messages addressed to the manager, newest-first. + /// Non-mutating; mirror of `AgentRequest::Recent`. + Recent { + limit: u64, + }, /// Submit a spawn request for the user to approve. On approval the host /// creates and starts the container. Brand-new agent names only β€” if an /// agent of the same name already exists, the approval will fail. @@ -329,4 +353,8 @@ pub enum ManagerResponse { QuestionQueued { id: i64, }, + /// `Recent` result: mirror of `AgentResponse::Recent`. + Recent { + rows: Vec, + }, }