feat(#2632): switch agent page loose-ends pill to todos; remove bash-tasks pill

- 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.
This commit is contained in:
iris 2026-07-22 17:30:01 +02:00 committed by mara
commit 4905b688be
5 changed files with 107 additions and 97 deletions

View file

@ -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);
}

View file

@ -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 `<details>` 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 <input value> 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,

View file

@ -58,17 +58,11 @@
<span class="header-pill-label">inbox</span>
<span class="header-pill-count" id="inbox-count">0</span>
</button>
<button type="button" id="loose-ends-pill" class="header-pill header-pill-loose" hidden
title="open loose-ends flyout">
<span class="header-pill-icon" aria-hidden="true">🪢</span>
<span class="header-pill-label">loose ends</span>
<span class="header-pill-count" id="loose-ends-count">0</span>
</button>
<button type="button" id="bash-tasks-pill" class="header-pill header-pill-tasks" hidden
title="open running bash-tasks flyout">
<span class="header-pill-icon" aria-hidden="true"></span>
<span class="header-pill-label">tasks</span>
<span class="header-pill-count" id="bash-tasks-count">0</span>
<button type="button" id="todos-pill" class="header-pill header-pill-todos" hidden
title="open todos flyout">
<span class="header-pill-icon" aria-hidden="true">📋</span>
<span class="header-pill-label">todos</span>
<span class="header-pill-count" id="todos-count">0</span>
</button>
<button type="button" id="overflow-btn" class="overflow-btn"
aria-haspopup="menu" aria-expanded="false"

View file

@ -117,6 +117,7 @@ pub async fn serve(
.route("/api/logout", post(auth::post_logout))
.route("/api/loose-ends", get(stats::api_loose_ends))
.route("/api/bash-tasks", get(stats::api_bash_tasks))
.route("/api/todos", get(stats::api_todos))
.route("/api/stats", get(stats::api_stats))
.route("/screen/ws", get(screen::screen_ws))
.route("/icon", get(screen::serve_icon));

View file

@ -1,4 +1,4 @@
//! Stats + loose-ends + bash-tasks read endpoints.
//! Stats + loose-ends + bash-tasks + todos read endpoints.
use axum::extract::State;
use axum::http::StatusCode;
@ -130,3 +130,45 @@ pub(super) async fn api_bash_tasks() -> Response {
.unwrap_or_default();
axum::Json(serde_json::json!({ "tasks": tasks })).into_response()
}
/// `GET /api/todos` — snapshot of this agent's local todos (loose-ends v2).
///
/// Connects to the in-agent harness socket (`HIVE_AGENT_SOCKET`) and calls
/// `ListTodos`. Returns `{ "todos": [...] }` where each entry is a
/// `LooseEnd::Todo` (subsystem, key, summary, source, `age_seconds`). Returns
/// an empty array when the socket is unavailable — best-effort, same
/// silent-failure contract as `/api/bash-tasks`.
pub(super) async fn api_todos() -> Response {
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
use tokio::net::UnixStream;
let socket_path = match std::env::var_os("HIVE_AGENT_SOCKET") {
Some(p) => std::path::PathBuf::from(p),
None => return axum::Json(serde_json::json!({ "todos": [] })).into_response(),
};
if !socket_path.exists() {
return axum::Json(serde_json::json!({ "todos": [] })).into_response();
}
let todos = tokio::time::timeout(std::time::Duration::from_secs(3), async move {
let mut stream = UnixStream::connect(&socket_path).await?;
let req = hive_agent_sock::Request::ListTodos { subsystem: None };
let mut line = serde_json::to_string(&req)?;
line.push('\n');
stream.write_all(line.as_bytes()).await?;
stream.flush().await?;
let mut lines = BufReader::new(stream).lines();
let resp_line = lines
.next_line()
.await?
.ok_or_else(|| anyhow::anyhow!("agent socket closed without response"))?;
let resp: hive_agent_sock::Response = serde_json::from_str(&resp_line)?;
anyhow::Ok(match resp {
hive_agent_sock::Response::LooseEnds { loose_ends } => loose_ends,
_ => Vec::new(),
})
})
.await
.unwrap_or_else(|_| Err(anyhow::anyhow!("timeout")))
.unwrap_or_default();
axum::Json(serde_json::json!({ "todos": todos })).into_response()
}