operator inbox view on dashboard; agent ui doesn't clobber typing

This commit is contained in:
müde 2026-05-15 17:23:53 +02:00
parent 070b237d03
commit 06ea0cf283
9 changed files with 132 additions and 12 deletions

View file

@ -161,27 +161,44 @@
}
let headerSet = false;
let lastStatus = null;
let lastOutputLen = -1;
let pollTimer = null;
async function refreshState() {
try {
const resp = await fetch('/api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
const root = $('status');
root.innerHTML = '';
if (s.status === 'online') renderOnline(s.label, root);
else if (s.status === 'needs_login_idle') renderNeedsLoginIdle(root);
else if (s.status === 'needs_login_in_progress') renderLoginInProgress(s.session || {}, root);
// Skip the re-render if nothing structurally changed. The most
// common case is `online` polling itself — without this guard, the
// operator's <input value> gets clobbered every cycle.
const outLen = s.session?.output?.length ?? -1;
const dirty =
s.status !== lastStatus ||
(s.status === 'needs_login_in_progress' && outLen !== lastOutputLen);
if (dirty) {
const root = $('status');
root.innerHTML = '';
if (s.status === 'online') renderOnline(s.label, root);
else if (s.status === 'needs_login_idle') renderNeedsLoginIdle(root);
else if (s.status === 'needs_login_in_progress') renderLoginInProgress(s.session || {}, root);
lastStatus = s.status;
lastOutputLen = outLen;
}
// Only poll while a login is in flight — otherwise SSE turn_end
// events trigger a refresh, and the operator can type into the
// send form without it getting cleared every few seconds.
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
if (s.status === 'needs_login_in_progress') {
pollTimer = setTimeout(refreshState, 1500);
}
} catch (err) {
console.error('refreshState failed', err);
pollTimer = setTimeout(refreshState, 5000);
}
}
refreshState();
// Mid-login refresh on a short interval so the output buffer updates.
setInterval(() => {
// Cheap; api/state is small. Could subscribe to SSE state events later.
refreshState();
}, 3000);
// ─── live event stream ──────────────────────────────────────────────────
(function() {

View file

@ -165,6 +165,28 @@
root.append(ul);
}
function renderInbox(s) {
const root = $('inbox-section');
root.innerHTML = '';
if (!s.operator_inbox || !s.operator_inbox.length) {
root.append(el('p', { class: 'empty' }, '▓ no messages ▓'));
return;
}
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const ul = el('ul', { class: 'inbox' });
for (const m of s.operator_inbox) {
const li = el('li');
li.append(
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
el('span', { class: 'msg-from' }, m.from), ' ',
el('span', { class: 'msg-sep' }, '→ '),
el('span', { class: 'msg-body' }, m.body),
);
ul.append(li);
}
root.append(ul);
}
function renderApprovals(s) {
const root = $('approvals-section');
root.innerHTML = '';
@ -223,6 +245,7 @@
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
renderContainers(s);
renderInbox(s);
renderApprovals(s);
// Auto-refresh while a spawn is in flight; otherwise back off.
const next = s.transients.length ? 2000 : 0;
@ -246,6 +269,8 @@
es.onmessage = (e) => {
let m;
try { m = JSON.parse(e.data); } catch { return; }
// Live-update the inbox when claude sends to operator.
if (m.kind === 'sent' && m.to === 'operator') refreshState();
const row = document.createElement('div');
row.className = 'msgrow ' + m.kind;
const kind = m.kind === 'sent' ? '→' : '✓';

View file

@ -184,6 +184,26 @@ summary:hover { color: var(--purple); }
.diff .diff-hunk { color: var(--cyan); }
.diff .diff-file { color: var(--purple); font-weight: bold; }
.diff .diff-ctx { color: var(--fg); }
.inbox {
background: var(--bg-elev);
border: 1px solid var(--border);
padding: 0.5em 0.8em;
max-height: 24em;
overflow-y: auto;
}
.inbox li {
padding: 0.25em 0;
border-bottom: 1px solid var(--border);
display: grid;
grid-template-columns: auto auto auto 1fr;
gap: 0.5em;
align-items: baseline;
}
.inbox li:last-child { border-bottom: 0; }
.inbox .msg-ts { color: var(--muted); font-size: 0.85em; }
.inbox .msg-from { color: var(--amber); }
.inbox .msg-sep { color: var(--muted); }
.inbox .msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
.msgflow {
background: var(--bg-elev);
border: 1px solid var(--border);

View file

@ -16,6 +16,12 @@
<p class="meta">loading…</p>
</div>
<h2>◆ 0PER4T0R 1NB0X ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="inbox-section">
<p class="meta">loading…</p>
</div>
<h2>◆ P3NDING APPR0VALS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="approvals-section">

View file

@ -112,7 +112,7 @@ async fn dispatch(req: &AgentRequest, agent: &str, broker: &Broker) -> AgentResp
},
},
AgentRequest::OperatorMsg { body } => match broker.send(&Message {
from: "operator".to_owned(),
from: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
to: agent.to_owned(),
body: body.clone(),
}) {

View file

@ -28,6 +28,16 @@ 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 {
@ -86,6 +96,32 @@ impl Broker {
Ok(())
}
/// Latest `limit` messages addressed to `recipient`, newest-first.
/// Includes delivered + undelivered alike — used for the operator
/// inbox view on the dashboard. Caller decides what to show.
pub fn recent_for(&self, recipient: &str, limit: u64) -> Result<Vec<InboxRow>> {
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, body, sent_at
FROM messages
WHERE recipient = ?1
ORDER BY id DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![recipient, limit_i], |row| {
Ok(InboxRow {
id: row.get(0)?,
from: row.get(1)?,
body: row.get(2)?,
at: row.get(3)?,
})
})?;
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

@ -92,6 +92,10 @@ struct StateSnapshot {
containers: Vec<ContainerView>,
transients: Vec<TransientView>,
approvals: Vec<ApprovalView>,
/// 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>,
}
#[derive(Serialize)]
@ -217,6 +221,12 @@ async fn api_state(
approval_views.push(view);
}
let operator_inbox = state
.coord
.broker
.recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50)
.unwrap_or_default();
axum::Json(StateSnapshot {
hostname,
manager_port: MANAGER_PORT,
@ -224,6 +234,7 @@ async fn api_state(
containers,
transients,
approvals: approval_views,
operator_inbox,
})
}

View file

@ -84,7 +84,7 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
},
},
ManagerRequest::OperatorMsg { body } => match coord.broker.send(&Message {
from: "operator".to_owned(),
from: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
to: MANAGER_AGENT.to_owned(),
body: body.clone(),
}) {

View file

@ -182,6 +182,11 @@ pub enum AgentResponse {
/// Logical name the broker uses for the manager.
pub const MANAGER_AGENT: &str = "manager";
/// Logical name the broker uses for the human operator. Messages with
/// `to = OPERATOR_RECIPIENT` accumulate in sqlite and surface on the
/// dashboard's inbox view — they are never `recv`'d by an agent harness.
pub const OPERATOR_RECIPIENT: &str = "operator";
/// Sender hive-c0re uses for events it pushes into the manager's inbox.
/// Manager harness recognises this and parses the body as a `HelperEvent`.
pub const SYSTEM_SENDER: &str = "system";