diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 985e2813..561794c7 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -338,7 +338,7 @@ h2, h3 { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); } -.header-pill-todos .header-pill-count { +.header-pill-tasks .header-pill-count { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); } diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index ed41ed00..71a38068 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -130,11 +130,18 @@ window.marked = marked; buildInboxList(lastInbox)); }); } - const todosPill = $('todos-pill'); - if (todosPill) { - todosPill.addEventListener('click', () => { - Panel.open('todos', 'todos · ' + lastTodos.length, - buildTodosList(lastTodos)); + const loosePill = $('loose-ends-pill'); + if (loosePill) { + loosePill.addEventListener('click', () => { + Panel.open('loose-ends', 'loose ends · ' + lastLooseEnds.length, + buildLooseEndsList(lastLooseEnds)); + }); + } + const bashPill = $('bash-tasks-pill'); + if (bashPill) { + bashPill.addEventListener('click', () => { + Panel.open('bash-tasks', 'tasks · ' + lastBashTasks.length, + buildBashTasksList(lastBashTasks)); }); } })(); @@ -822,26 +829,26 @@ window.marked = marked; } renderStateBadge(); } - // Todos section: in-agent todos (loose-ends v2) pushed by subsystems - // (matrix, forge, bash). Best-effort fetch on cold load + after every - // turn_end. Silent failure keeps the pill at zero. - async function refreshTodos() { + // Loose-ends section: same data the get_loose_ends MCP tool + // returns. Best-effort fetch on cold load + after every turn_end + // (a turn likely answered or asked something). Silent failure + // keeps the pill count at zero rather than surfacing a stale chrome. + async function refreshLooseEnds() { try { - const resp = await fetch('api/todos'); + const resp = await fetch('api/loose-ends'); if (!resp.ok) { - renderTodos([]); + renderLooseEnds([]); return; } const data = await resp.json(); - renderTodos(data.todos || []); + renderLooseEnds(data.loose_ends || []); } catch (err) { - console.warn('todos fetch failed', err); - renderTodos([]); + console.warn('loose-ends fetch failed', err); + renderLooseEnds([]); } } /** Latest snapshot kept in module state so the pill click handler * has fresh data to render into the panel without re-fetching. */ - let lastTodos = []; let lastLooseEnds = []; let lastInbox = []; @@ -916,13 +923,47 @@ window.marked = marked; return wrap; } - /** Build the todos side-panel list. Each entry is a LooseEnd::Todo - * (subsystem, summary, source, age_seconds). */ - function buildTodosList(todos) { + /** Pill-count + open-panel-refresh wiring for loose-ends. The legacy + * in-page `
` block is gone — operator clicks the header + * pill to surface the list in the side panel. */ + function renderLooseEnds(threads) { + lastLooseEnds = threads; + const pill = $('loose-ends-pill'); + const count = $('loose-ends-count'); + if (count) count.textContent = threads.length; + if (pill) pill.hidden = threads.length === 0; + Panel.refresh('loose-ends', 'loose ends · ' + threads.length, + buildLooseEndsList(threads)); + // Wire inline answer forms into any `ask → operator` rows + // waiting on a broker-assigned question id. + reconcileAskBinds(); + } + + // Running bash tasks: `GET /api/bash-tasks` returns the in-flight + // (Pending/Running) TaskFiles from the in-container bash-tasks dir. Same + // best-effort, silent-failure contract as loose-ends — a fetch miss keeps + // the pill at zero rather than surfacing stale chrome. + let lastBashTasks = []; + async function refreshBashTasks() { + try { + const resp = await fetch('api/bash-tasks'); + if (!resp.ok) { + renderBashTasks([]); + return; + } + const data = await resp.json(); + renderBashTasks(data.tasks || []); + } catch (err) { + console.warn('bash-tasks fetch failed', err); + renderBashTasks([]); + } + } + + function buildBashTasksList(tasks) { const wrap = el('div', { class: 'agent-inbox' }); - if (!todos.length) { + if (!tasks.length) { wrap.append(el('p', { class: 'side-panel-empty' }, - 'no todos — all subsystem queues are clear.')); + 'no running bash tasks.')); return wrap; } const list = el('ul'); @@ -932,13 +973,20 @@ window.marked = marked; if (s < 86400) return Math.floor(s / 3600) + 'h'; return Math.floor(s / 86400) + 'd'; }; - for (const t of todos) { + const now = Math.floor(Date.now() / 1000); + for (const t of tasks) { const li = el('li'); - const label = t.source ? t.subsystem + ' · ' + t.source : t.subsystem; + const running = t.status === 'running'; + // Elapsed since the task started (running) or was queued (pending). + const since = running ? (t.started_at || t.created_at || now) : (t.created_at || now); + const elapsed = Math.max(0, now - since); + // Single-line, truncated command preview (the cmd can be multi-line). + const cmdPreview = (t.cmd || '').replace(/\s+/g, ' ').trim().slice(0, 100); li.append( - el('span', { class: 'inbox-from' }, label), ' ', - el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'), - el('div', { class: 'inbox-body' }, t.summary || ''), + el('span', { class: 'bash-task-status bash-task-' + t.status }, running ? '▶ running' : '◷ queued'), ' ', + el('span', { class: 'inbox-from' }, t.id), ' ', + el('span', { class: 'inbox-ts' }, (running ? '' : 'queued ') + fmtAge(elapsed) + (running ? ' elapsed' : '')), + el('div', { class: 'inbox-body bash-task-cmd' }, cmdPreview), ); list.append(li); } @@ -946,15 +994,14 @@ window.marked = marked; return wrap; } - /** Pill-count + open-panel-refresh wiring for todos. */ - function renderTodos(todos) { - lastTodos = todos; - const pill = $('todos-pill'); - const count = $('todos-count'); - if (count) count.textContent = todos.length; - if (pill) pill.hidden = todos.length === 0; - Panel.refresh('todos', 'todos · ' + todos.length, - buildTodosList(todos)); + function renderBashTasks(tasks) { + lastBashTasks = tasks; + const pill = $('bash-tasks-pill'); + const count = $('bash-tasks-count'); + if (count) count.textContent = tasks.length; + if (pill) pill.hidden = tasks.length === 0; + Panel.refresh('bash-tasks', 'tasks · ' + tasks.length, + buildBashTasksList(tasks)); } /** Walk `pendingAskBinds` against the latest `lastLooseEnds` @@ -1019,6 +1066,7 @@ window.marked = marked; }); if (resp.ok) { status.textContent = 'answered ✓'; + refreshLooseEnds(); } else { status.textContent = 'failed: ' + (await resp.text()); } @@ -1315,8 +1363,16 @@ window.marked = marked; renderModelChip(s.model); renderEffortChip(s.effort); renderTokenUsage({ ctx: s.ctx_usage, cost: s.cost_usage }); - // Todos pill: cold-load populate; turn_end refreshes via renderTodos. - refreshTodos(); + // 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. + refreshLooseEnds(); + // Cold-load populate of the running-bash-tasks pill. Tasks complete + // asynchronously between turns (independent of turn_end SSE) and + // /api/state isn't polled while online, so a dedicated interval (set + // up next to the initial refreshState() below) keeps the count live; + // this call just fills it immediately on first paint. + refreshBashTasks(); // 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. @@ -1346,10 +1402,12 @@ window.marked = marked; } } refreshState(); - // Keep the todos pill live. Todos change asynchronously (matrix syncs, - // bash task starts/completions) independent of turn_end SSE, so poll - // the snapshot endpoint on a light interval. Fails silently when offline. - setInterval(refreshTodos, 4000); + // Keep the running-bash-tasks pill live. Unlike loose-ends (refreshed on + // turn_end SSE), bash tasks start + finish asynchronously between turns and + // /api/state isn't polled while online — so poll the cheap snapshot endpoint + // on a light interval. Fails silently (renders zero) when offline. v1 is + // polling; an SSE push for task state could replace this later. + setInterval(refreshBashTasks, 4000); // ─── live event stream ────────────────────────────────────────────────── // Scrolling, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS @@ -1447,7 +1505,11 @@ window.marked = marked; slot._askQuestion = c._body; d.appendChild(slot); pendingAskBinds.push(slot); - if (api.fromHistory) reconcileAskBinds(); + // Mid-turn refresh — the standard `turn_end` refresh + // won't fire until the agent's turn finishes; we want + // the form to show up as soon as the ask lands. + if (!api.fromHistory) refreshLooseEnds(); + else reconcileAskBinds(); } } return d; @@ -1476,6 +1538,15 @@ window.marked = marked; const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null; const isMessageBearing = sourceName === 'mcp__hyperhive__recv'; // When an ask's tool_result lands the broker has just + // persisted the question with its assigned id. Refresh + // loose-ends so reconcileAskBinds finds the new entry and + // mounts the inline answer form under the rendered ask row. + // Skipped during history replay (the question's likely + // long-resolved; turn_end refresh on cold-load covers + // reconciliation). + if (sourceName === 'mcp__hyperhive__ask' && !api.fromHistory) { + refreshLooseEnds(); + } const trimmed = txt.replace(/\s+/g, ' ').trim(); const summaryBody = (() => { if (!trimmed) return '(empty)'; @@ -1695,7 +1766,8 @@ window.marked = marked; openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1); } else { setBannerActive(false); setState('idle'); - refreshTodos(); + // Likely answered/asked/scheduled something — refresh. + refreshLooseEnds(); } const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; const row = api.row(cls, diff --git a/frontend/packages/agent/src/index.html b/frontend/packages/agent/src/index.html index 0e1c1d68..3e0ce46a 100644 --- a/frontend/packages/agent/src/index.html +++ b/frontend/packages/agent/src/index.html @@ -58,11 +58,17 @@ inbox 0 - +