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();
|
||||
}
|
||||
// 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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue