agent-ui: running bash-tasks pill + flyout on the agent page

Adds a 'tasks' header pill (hidden at zero, like inbox/loose-ends) that
opens a side-panel flyout listing the agent's in-flight bash tasks from
GET /api/bash-tasks. Each row shows status (running/queued), task id,
elapsed time, and a truncated single-line command preview. Polled on the
same /api/state cycle as loose-ends (tasks complete async between turns, so
the count stays live); clicking the pill opens the flyout. Snapshot-only
v1 — SSE live-push is a possible follow-up.
This commit is contained in:
iris 2026-06-19 12:42:46 +02:00 committed by mara
commit 4ce919a19f
3 changed files with 94 additions and 0 deletions

View file

@ -135,6 +135,13 @@ window.marked = marked;
buildLooseEndsList(lastLooseEnds));
});
}
const bashPill = $('bash-tasks-pill');
if (bashPill) {
bashPill.addEventListener('click', () => {
Panel.open('bash-tasks', 'tasks · ' + lastBashTasks.length,
buildBashTasksList(lastBashTasks));
});
}
})();
// ─── state rendering ────────────────────────────────────────────────────
@ -909,6 +916,71 @@ window.marked = marked;
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 (!tasks.length) {
wrap.append(el('p', { class: 'side-panel-empty' },
'no running bash tasks.'));
return wrap;
}
const list = el('ul');
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';
};
const now = Math.floor(Date.now() / 1000);
for (const t of tasks) {
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);
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),
);
list.append(li);
}
wrap.append(list);
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));
}
/** Walk `pendingAskBinds` against the latest `lastLooseEnds`
* snapshot, pair unbound slots with the first unclaimed pending
* operator-bound question whose text matches, and flip already-
@ -1282,6 +1354,10 @@ window.marked = marked;
// db, fetched via the per-agent socket). Cold-load fetches
// it here; turn_end refreshes it via the renderer below.
refreshLooseEnds();
// Running bash tasks live in the in-container bash-tasks dir (not
// /api/state); same poll cadence as loose-ends. They complete async
// between turns, so polling on each state cycle keeps the count live.
refreshBashTasks();
// 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.