agent page: inbox view of last 30 messages addressed to this agent
new wire request AgentRequest::Recent { limit } / ManagerRequest::Recent
(plus matching responses with Vec<InboxRow>). 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<InboxRow> populated
via the same per-agent socket (best-effort; transport failure
returns empty). frontend renders as a collapsible <details> 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.
This commit is contained in:
parent
bd7d2d4860
commit
538e0446d7
13 changed files with 151 additions and 20 deletions
6
TODO.md
6
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 —
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@
|
|||
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
|
||||
</div>
|
||||
|
||||
<details id="inbox-section" class="agent-inbox" hidden>
|
||||
<summary>▸ <span id="inbox-summary">inbox</span></summary>
|
||||
<ul id="inbox-list"></ul>
|
||||
</details>
|
||||
|
||||
<div class="terminal-wrap">
|
||||
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
||||
<div id="term-input" class="term-input"></div>
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ pub enum SocketReply {
|
|||
Empty,
|
||||
Status(u64),
|
||||
QuestionQueued(i64),
|
||||
Recent(Vec<hive_sh4re::InboxRow>),
|
||||
}
|
||||
|
||||
impl From<hive_sh4re::AgentResponse> for SocketReply {
|
||||
|
|
@ -48,6 +49,7 @@ impl From<hive_sh4re::AgentResponse> 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<hive_sh4re::ManagerResponse> 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,10 @@ struct StateSnapshot {
|
|||
status: &'static str,
|
||||
/// Present when `status == "needs_login_in_progress"`.
|
||||
session: Option<SessionView>,
|
||||
/// 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<hive_sh4re::InboxRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -157,14 +161,47 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
|||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().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<hive_sh4re::InboxRow> {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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:#}"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<crate::broker::InboxRow>,
|
||||
operator_inbox: Vec<hive_sh4re::InboxRow>,
|
||||
/// 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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<InboxRow> },
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
@ -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<InboxRow>,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue