From 4905b688bec818d93bb873def640086dccee0d35 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 22 Jul 2026 17:30:01 +0200 Subject: [PATCH] feat(#2632): switch agent page loose-ends pill to todos; remove bash-tasks pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/todos backend endpoint (connects to HIVE_AGENT_SOCKET, calls ListTodos, returns { todos: [...] } โ€” LooseEnd::Todo items). - Register route in web_ui/mod.rs. - index.html: replace loose-ends pill (๐Ÿชข) + bash-tasks pill (โš™) with a single todos pill (๐Ÿ“‹, id=todos-pill). - agent.css: add .header-pill-todos count colour (green, same as old tasks). - app.js: - refreshTodos() fetches /api/todos, drives renderTodos/buildTodosList. - refreshLooseEnds() becomes background-only (no pill); still drives reconcileAskBinds for inline ask-form wiring. - Remove refreshBashTasks / buildBashTasksList / renderBashTasks. - Cold-load and turn_end both call refreshTodos; 4s interval replaces the old bash-tasks interval. --- frontend/packages/agent/src/agent.css | 2 +- frontend/packages/agent/src/app.js | 141 ++++++++++--------------- frontend/packages/agent/src/index.html | 16 +-- hive-agent/src/web_ui/mod.rs | 1 + hive-agent/src/web_ui/stats.rs | 44 +++++++- 5 files changed, 107 insertions(+), 97 deletions(-) diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 561794c7..985e2813 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-tasks .header-pill-count { +.header-pill-todos .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 71a38068..ba5cfb01 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -130,18 +130,11 @@ window.marked = marked; buildInboxList(lastInbox)); }); } - 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)); + const todosPill = $('todos-pill'); + if (todosPill) { + todosPill.addEventListener('click', () => { + Panel.open('todos', 'todos ยท ' + lastTodos.length, + buildTodosList(lastTodos)); }); } })(); @@ -829,10 +822,26 @@ window.marked = marked; } renderStateBadge(); } - // 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. + // 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() { + try { + const resp = await fetch('api/todos'); + if (!resp.ok) { + renderTodos([]); + return; + } + const data = await resp.json(); + renderTodos(data.todos || []); + } catch (err) { + console.warn('todos fetch failed', err); + renderTodos([]); + } + } + // Loose-ends: fetched silently (background only) for reconcileAskBinds. + // Not displayed as a pill; provides the question/reminder/approval data + // the inline ask-form wiring needs. async function refreshLooseEnds() { try { const resp = await fetch('api/loose-ends'); @@ -849,6 +858,7 @@ window.marked = marked; } /** 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 = []; @@ -923,47 +933,22 @@ window.marked = marked; return wrap; } - /** 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. */ + /** Loose-ends render: background-only (no pill). Keeps lastLooseEnds + * fresh for reconcileAskBinds (inline ask-form wiring). */ 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) { + /** Build the todos side-panel list. Each entry is a LooseEnd::Todo + * (subsystem, summary, source, age_seconds). */ + function buildTodosList(todos) { const wrap = el('div', { class: 'agent-inbox' }); - if (!tasks.length) { + if (!todos.length) { wrap.append(el('p', { class: 'side-panel-empty' }, - 'no running bash tasks.')); + 'no todos โ€” all subsystem queues are clear.')); return wrap; } const list = el('ul'); @@ -973,20 +958,13 @@ window.marked = marked; if (s < 86400) return Math.floor(s / 3600) + 'h'; return Math.floor(s / 86400) + 'd'; }; - const now = Math.floor(Date.now() / 1000); - for (const t of tasks) { + for (const t of todos) { const li = el('li'); - 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); + const label = t.source ? t.subsystem + ' ยท ' + t.source : t.subsystem; li.append( - 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), + el('span', { class: 'inbox-from' }, label), ' ', + el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'), + el('div', { class: 'inbox-body' }, t.summary || ''), ); list.append(li); } @@ -994,14 +972,15 @@ window.marked = marked; return wrap; } - 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)); + /** 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)); } /** Walk `pendingAskBinds` against the latest `lastLooseEnds` @@ -1363,16 +1342,11 @@ window.marked = marked; renderModelChip(s.model); renderEffortChip(s.effort); renderTokenUsage({ ctx: s.ctx_usage, cost: s.cost_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. + // Open-threads: loose-ends (background, for reconcileAskBinds) and + // todos (displayed pill). Cold-load fetches both; turn_end refreshes + // them via the renderers 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(); + refreshTodos(); // 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. @@ -1402,12 +1376,10 @@ window.marked = marked; } } refreshState(); - // 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); + // 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); // โ”€โ”€โ”€ live event stream โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Scrolling, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS @@ -1766,8 +1738,9 @@ window.marked = marked; openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1); } else { setBannerActive(false); setState('idle'); - // Likely answered/asked/scheduled something โ€” refresh. + // Likely answered/asked/scheduled something โ€” refresh both. refreshLooseEnds(); + refreshTodos(); } 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 3e0ce46a..0e1c1d68 100644 --- a/frontend/packages/agent/src/index.html +++ b/frontend/packages/agent/src/index.html @@ -58,17 +58,11 @@ inbox 0 - -