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 @@