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