agent ui: open-threads section (questions + approvals pending)
new /api/open-threads endpoint on hive-ag3nt proxies the agent's own GetOpenThreads RPC (manager flavour proxies the hive-wide ManagerRequest::GetOpenThreads). same data the mcp__hyperhive__get_open_threads tool sees from inside claude. frontend renders a collapsible <details> section above the terminal, listing each pending row (approval / question) with asker → target, age, and free-form body. auto-expands on the first appearance of any open thread; sticky after that. refreshed on cold load + after every turn_end (turns are when threads land or resolve).
This commit is contained in:
parent
087a5366fb
commit
378e8bf9df
3 changed files with 124 additions and 0 deletions
|
|
@ -393,6 +393,75 @@
|
||||||
}
|
}
|
||||||
renderStateBadge();
|
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) {
|
function renderInbox(rows) {
|
||||||
const root = $('inbox-section');
|
const root = $('inbox-section');
|
||||||
const list = $('inbox-list');
|
const list = $('inbox-list');
|
||||||
|
|
@ -542,6 +611,10 @@
|
||||||
renderAliveBadge(s.status);
|
renderAliveBadge(s.status);
|
||||||
renderModelChip(s.model);
|
renderModelChip(s.model);
|
||||||
renderTokenUsage(s.token_usage);
|
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
|
// Skip the re-render if nothing structurally changed. The most
|
||||||
// common case is `online` polling itself — without this guard, the
|
// common case is `online` polling itself — without this guard, the
|
||||||
// operator's <input value> gets clobbered every cycle.
|
// operator's <input value> gets clobbered every cycle.
|
||||||
|
|
@ -730,6 +803,8 @@
|
||||||
openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1);
|
openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1);
|
||||||
} else {
|
} else {
|
||||||
setBannerActive(false); setState('idle');
|
setBannerActive(false); setState('idle');
|
||||||
|
// Likely answered/asked/scheduled something — refresh.
|
||||||
|
refreshOpenThreads();
|
||||||
}
|
}
|
||||||
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
||||||
api.row(cls,
|
api.row(cls,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@
|
||||||
<ul id="inbox-list"></ul>
|
<ul id="inbox-list"></ul>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details id="open-threads-section" class="agent-inbox" hidden>
|
||||||
|
<summary>▸ <span id="open-threads-summary">open threads</span></summary>
|
||||||
|
<ul id="open-threads-list"></ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="terminal-wrap">
|
<div class="terminal-wrap">
|
||||||
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
||||||
<div id="term-input" class="term-input"></div>
|
<div id="term-input" class="term-input"></div>
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ pub async fn serve(
|
||||||
.route("/api/compact", post(post_compact))
|
.route("/api/compact", post(post_compact))
|
||||||
.route("/api/model", post(post_set_model))
|
.route("/api/model", post(post_set_model))
|
||||||
.route("/api/new-session", post(post_new_session))
|
.route("/api/new-session", post(post_new_session))
|
||||||
|
.route("/api/open-threads", get(api_open_threads))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
let listener = bind_with_retry(addr, "web UI").await?;
|
let listener = bind_with_retry(addr, "web UI").await?;
|
||||||
|
|
@ -231,6 +232,49 @@ struct SessionView {
|
||||||
exit_note: Option<String>,
|
exit_note: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<AppState>) -> Response {
|
||||||
|
let threads: Vec<hive_sh4re::OpenThread> = 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<AppState>) -> axum::Json<StateSnapshot> {
|
async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||||
// Capture seq *before* any reads so the dedupe contract is
|
// Capture seq *before* any reads so the dedupe contract is
|
||||||
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue