diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index d93f8cf..34b4ca5 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -393,6 +393,75 @@ } renderStateBadge(); } + // Open-threads section: same data the get_open_threads MCP tool + // returns. Best-effort fetch on cold load + after every turn_end + // (a turn likely answered or asked something). Silent failure + // keeps the section hidden rather than surfacing an empty banner. + let lastOpenThreadsCount = 0; + async function refreshOpenThreads() { + try { + const resp = await fetch('/api/open-threads'); + if (!resp.ok) { + renderOpenThreads([]); + return; + } + const data = await resp.json(); + renderOpenThreads(data.threads || []); + } catch (err) { + console.warn('open-threads fetch failed', err); + renderOpenThreads([]); + } + } + function renderOpenThreads(threads) { + const root = $('open-threads-section'); + const list = $('open-threads-list'); + const summary = $('open-threads-summary'); + if (!root || !list || !summary) return; + if (!threads.length) { + root.hidden = true; + lastOpenThreadsCount = 0; + return; + } + root.hidden = false; + summary.textContent = 'open threads · ' + threads.length; + list.innerHTML = ''; + // Auto-expand on first appearance of any open thread so the + // operator notices new loose ends; collapse only on operator + // click (sticky after that). + if (lastOpenThreadsCount === 0) root.open = true; + lastOpenThreadsCount = threads.length; + const fmtAge = (s) => { + if (s < 60) return s + 's'; + if (s < 3600) return Math.floor(s / 60) + 'm'; + if (s < 86400) return Math.floor(s / 3600) + 'h'; + return Math.floor(s / 86400) + 'd'; + }; + for (const t of threads) { + const li = el('li'); + if (t.kind === 'approval') { + li.append( + el('span', { class: 'inbox-from' }, '◇ approval #' + t.id), ' ', + el('span', { class: 'inbox-sep' }, t.agent + ' @ ' + (t.commit_ref || '').slice(0, 12)), ' ', + el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'), + ); + if (t.description) { + li.append(el('div', { class: 'inbox-body' }, t.description)); + } + } else if (t.kind === 'question') { + const target = t.target || 'operator'; + li.append( + el('span', { class: 'inbox-from' }, '? #' + t.id), ' ', + el('span', { class: 'inbox-sep' }, t.asker + ' → ' + target), ' ', + el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'), + el('div', { class: 'inbox-body' }, t.question || ''), + ); + } else { + li.append(el('span', { class: 'inbox-body' }, JSON.stringify(t))); + } + list.append(li); + } + } + function renderInbox(rows) { const root = $('inbox-section'); const list = $('inbox-list'); @@ -542,6 +611,10 @@ renderAliveBadge(s.status); renderModelChip(s.model); renderTokenUsage(s.token_usage); + // Open-threads aren't part of /api/state (kept on the broker + // db, fetched via the per-agent socket). Cold-load fetches + // it here; turn_end refreshes it via the renderer below. + refreshOpenThreads(); // Skip the re-render if nothing structurally changed. The most // common case is `online` polling itself — without this guard, the // operator's gets clobbered every cycle. @@ -730,6 +803,8 @@ openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1); } else { setBannerActive(false); setState('idle'); + // Likely answered/asked/scheduled something — refresh. + refreshOpenThreads(); } const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; api.row(cls, diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 204ec13..a0c86e7 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -29,6 +29,11 @@ + +
connecting…
diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 22411de..c3c093c 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -105,6 +105,7 @@ pub async fn serve( .route("/api/compact", post(post_compact)) .route("/api/model", post(post_set_model)) .route("/api/new-session", post(post_new_session)) + .route("/api/open-threads", get(api_open_threads)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = bind_with_retry(addr, "web UI").await?; @@ -231,6 +232,49 @@ struct SessionView { exit_note: Option, } +/// Proxy this agent's open-threads via the per-agent socket. The +/// web UI surfaces the result as a collapsible section in the page +/// so the operator can see at a glance what's pending against the +/// agent (questions asked by it, peer questions targeting it, +/// approvals for the manager). Same data the +/// `mcp__hyperhive__get_open_threads` tool sees from inside the +/// container. +async fn api_open_threads(State(state): State) -> Response { + let threads: Vec = match state.flavor() { + Flavor::Agent => { + match client::request::<_, hive_sh4re::AgentResponse>( + &state.socket, + &hive_sh4re::AgentRequest::GetOpenThreads, + ) + .await + { + Ok(hive_sh4re::AgentResponse::OpenThreads { threads }) => threads, + Ok(hive_sh4re::AgentResponse::Err { message }) => { + return error_response(&format!("get_open_threads: {message}")); + } + Ok(other) => return error_response(&format!("unexpected response: {other:?}")), + Err(e) => return error_response(&format!("transport: {e:#}")), + } + } + Flavor::Manager => { + match client::request::<_, hive_sh4re::ManagerResponse>( + &state.socket, + &hive_sh4re::ManagerRequest::GetOpenThreads, + ) + .await + { + Ok(hive_sh4re::ManagerResponse::OpenThreads { threads }) => threads, + Ok(hive_sh4re::ManagerResponse::Err { message }) => { + return error_response(&format!("get_open_threads: {message}")); + } + Ok(other) => return error_response(&format!("unexpected response: {other:?}")), + Err(e) => return error_response(&format!("transport: {e:#}")), + } + } + }; + axum::Json(serde_json::json!({ "threads": threads })).into_response() +} + async fn api_state(State(state): State) -> axum::Json { // Capture seq *before* any reads so the dedupe contract is // "events with seq > snapshot.seq are post-snapshot, never missed."