operator inbox view on dashboard; agent ui doesn't clobber typing
This commit is contained in:
parent
070b237d03
commit
06ea0cf283
9 changed files with 132 additions and 12 deletions
|
|
@ -161,27 +161,44 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let headerSet = false;
|
let headerSet = false;
|
||||||
|
let lastStatus = null;
|
||||||
|
let lastOutputLen = -1;
|
||||||
|
let pollTimer = null;
|
||||||
async function refreshState() {
|
async function refreshState() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/state');
|
const resp = await fetch('/api/state');
|
||||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||||
const s = await resp.json();
|
const s = await resp.json();
|
||||||
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
|
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
|
||||||
|
// 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');
|
const root = $('status');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
if (s.status === 'online') renderOnline(s.label, root);
|
if (s.status === 'online') renderOnline(s.label, root);
|
||||||
else if (s.status === 'needs_login_idle') renderNeedsLoginIdle(root);
|
else if (s.status === 'needs_login_idle') renderNeedsLoginIdle(root);
|
||||||
else if (s.status === 'needs_login_in_progress') renderLoginInProgress(s.session || {}, 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) {
|
} catch (err) {
|
||||||
console.error('refreshState failed', err);
|
console.error('refreshState failed', err);
|
||||||
|
pollTimer = setTimeout(refreshState, 5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshState();
|
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 ──────────────────────────────────────────────────
|
// ─── live event stream ──────────────────────────────────────────────────
|
||||||
(function() {
|
(function() {
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,28 @@
|
||||||
root.append(ul);
|
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) {
|
function renderApprovals(s) {
|
||||||
const root = $('approvals-section');
|
const root = $('approvals-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
@ -223,6 +245,7 @@
|
||||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||||
const s = await resp.json();
|
const s = await resp.json();
|
||||||
renderContainers(s);
|
renderContainers(s);
|
||||||
|
renderInbox(s);
|
||||||
renderApprovals(s);
|
renderApprovals(s);
|
||||||
// Auto-refresh while a spawn is in flight; otherwise back off.
|
// Auto-refresh while a spawn is in flight; otherwise back off.
|
||||||
const next = s.transients.length ? 2000 : 0;
|
const next = s.transients.length ? 2000 : 0;
|
||||||
|
|
@ -246,6 +269,8 @@
|
||||||
es.onmessage = (e) => {
|
es.onmessage = (e) => {
|
||||||
let m;
|
let m;
|
||||||
try { m = JSON.parse(e.data); } catch { return; }
|
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');
|
const row = document.createElement('div');
|
||||||
row.className = 'msgrow ' + m.kind;
|
row.className = 'msgrow ' + m.kind;
|
||||||
const kind = m.kind === 'sent' ? '→' : '✓';
|
const kind = m.kind === 'sent' ? '→' : '✓';
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,26 @@ summary:hover { color: var(--purple); }
|
||||||
.diff .diff-hunk { color: var(--cyan); }
|
.diff .diff-hunk { color: var(--cyan); }
|
||||||
.diff .diff-file { color: var(--purple); font-weight: bold; }
|
.diff .diff-file { color: var(--purple); font-weight: bold; }
|
||||||
.diff .diff-ctx { color: var(--fg); }
|
.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 {
|
.msgflow {
|
||||||
background: var(--bg-elev);
|
background: var(--bg-elev);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2>◆ 0PER4T0R 1NB0X ◆</h2>
|
||||||
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
|
<div id="inbox-section">
|
||||||
|
<p class="meta">loading…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>◆ P3NDING APPR0VALS ◆</h2>
|
<h2>◆ P3NDING APPR0VALS ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<div id="approvals-section">
|
<div id="approvals-section">
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ async fn dispatch(req: &AgentRequest, agent: &str, broker: &Broker) -> AgentResp
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AgentRequest::OperatorMsg { body } => match broker.send(&Message {
|
AgentRequest::OperatorMsg { body } => match broker.send(&Message {
|
||||||
from: "operator".to_owned(),
|
from: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
||||||
to: agent.to_owned(),
|
to: agent.to_owned(),
|
||||||
body: body.clone(),
|
body: body.clone(),
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// may drop events past this; we send a `lagged` notice in their stream.
|
||||||
const EVENT_CHANNEL: usize = 256;
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||||
pub enum MessageEvent {
|
pub enum MessageEvent {
|
||||||
|
|
@ -86,6 +96,32 @@ impl Broker {
|
||||||
Ok(())
|
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
|
/// Number of undelivered messages addressed to `recipient`. Non-mutating
|
||||||
/// — used by the harness to surface "N unread" in tool-result status
|
/// — used by the harness to surface "N unread" in tool-result status
|
||||||
/// lines without popping the queue.
|
/// lines without popping the queue.
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,10 @@ struct StateSnapshot {
|
||||||
containers: Vec<ContainerView>,
|
containers: Vec<ContainerView>,
|
||||||
transients: Vec<TransientView>,
|
transients: Vec<TransientView>,
|
||||||
approvals: Vec<ApprovalView>,
|
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)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -217,6 +221,12 @@ async fn api_state(
|
||||||
approval_views.push(view);
|
approval_views.push(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let operator_inbox = state
|
||||||
|
.coord
|
||||||
|
.broker
|
||||||
|
.recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
axum::Json(StateSnapshot {
|
axum::Json(StateSnapshot {
|
||||||
hostname,
|
hostname,
|
||||||
manager_port: MANAGER_PORT,
|
manager_port: MANAGER_PORT,
|
||||||
|
|
@ -224,6 +234,7 @@ async fn api_state(
|
||||||
containers,
|
containers,
|
||||||
transients,
|
transients,
|
||||||
approvals: approval_views,
|
approvals: approval_views,
|
||||||
|
operator_inbox,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ManagerRequest::OperatorMsg { body } => match coord.broker.send(&Message {
|
ManagerRequest::OperatorMsg { body } => match coord.broker.send(&Message {
|
||||||
from: "operator".to_owned(),
|
from: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
||||||
to: MANAGER_AGENT.to_owned(),
|
to: MANAGER_AGENT.to_owned(),
|
||||||
body: body.clone(),
|
body: body.clone(),
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,11 @@ pub enum AgentResponse {
|
||||||
/// Logical name the broker uses for the manager.
|
/// Logical name the broker uses for the manager.
|
||||||
pub const MANAGER_AGENT: &str = "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.
|
/// Sender hive-c0re uses for events it pushes into the manager's inbox.
|
||||||
/// Manager harness recognises this and parses the body as a `HelperEvent`.
|
/// Manager harness recognises this and parses the body as a `HelperEvent`.
|
||||||
pub const SYSTEM_SENDER: &str = "system";
|
pub const SYSTEM_SENDER: &str = "system";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue