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 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; }
|
||||
// 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() {
|
||||
|
|
|
|||
|
|
@ -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' ? '→' : '✓';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue