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:
müde 2026-05-17 23:53:40 +02:00
parent 087a5366fb
commit 378e8bf9df
3 changed files with 124 additions and 0 deletions

View file

@ -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 <input value> 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,

View file

@ -29,6 +29,11 @@
<ul id="inbox-list"></ul>
</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 id="live" class="live terminal"><div class="meta">connecting…</div></div>
<div id="term-input" class="term-input"></div>