agent web UI: bulk mark-done for the todos flyout

The per-agent web UI todos flyout (loose-ends v2) had no mark-done
affordance at all — dismissing a todo was only possible via the
cancel_loose_end MCP tool, one id at a time. Add a checkbox per row,
a select-all/select-none/mark-done bulk row, and a new
POST /api/todos/mark-done handler that loops the existing single-id
MarkTodoDone request over the in-agent socket (no new wire request
type needed — the todos list is small, so N same-host round-trips is
cheap).

Fixes #2917
This commit is contained in:
iris 2026-08-01 19:11:46 +02:00 committed by mara
commit 41c1b1a3fb
4 changed files with 119 additions and 7 deletions

View file

@ -530,6 +530,12 @@ pre.diff {
.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; margin-left: 0.5em; }
.agent-inbox .inbox-from { color: var(--amber); }
.agent-inbox .inbox-sep { color: var(--muted); margin-left: 0.4em; }
/* Todos flyout per-row checkbox (bulk mark-done) sits inline before the
existing `.inbox-from` label, same row. */
.agent-inbox .todo-cb {
vertical-align: middle;
margin-right: 0.2em;
}
.agent-inbox .inbox-body {
display: block;
color: var(--fg);
@ -655,10 +661,11 @@ pre.diff {
text-decoration-color: var(--muted);
}
/* "mark all read" header row sits above the recent-messages list
in the inbox side-panel flyout. Same look as the answer-form
button (mauve hover, bg-elev background) so they read as part
of the same affordance family. */
/* Bulk-action header row: "mark all read" above the recent-messages
list in the inbox flyout, and "select all / select none / mark
done" above the list in the todos flyout same classes, shared
look (mauve hover, bg-elev background) so both read as part of
the same affordance family as the answer-form button. */
.agent-inbox .inbox-mark-all-row {
display: flex;
gap: 0.6em;

View file

@ -861,8 +861,67 @@ window.marked = marked;
return wrap;
}
/** Bulk "mark done" row for the todos flyout: select all / select none
* + a mark-done button, disabled until at least one row is checked.
* POSTs the checked ids (comma-joined into one field, same shape as
* `hive-c0re`'s meta-inputs bulk form — axum's `Form` extractor doesn't
* natively decode repeated same-name keys) to this agent's own
* `/api/todos/mark-done`, then calls `refreshTodos()` on success so the
* flyout reloads without the now-dismissed rows. `wrap` is the panel
* root bulk buttons read/toggle the checkboxes it contains. */
function buildTodosMarkDoneRow(wrap) {
const status = el('span', { class: 'inbox-mark-status' });
const selAll = el('button', { type: 'button', class: 'inbox-mark-all-btn' }, 'select all');
const selNone = el('button', { type: 'button', class: 'inbox-mark-all-btn' }, 'select none');
const markBtn = el('button', {
type: 'button', class: 'inbox-mark-all-btn', disabled: '',
}, '✓ mark done');
const checkboxes = () => Array.from(wrap.querySelectorAll('input[data-todo-id]'));
const refreshDisabled = () => {
const any = checkboxes().some((cb) => cb.checked);
if (any) markBtn.removeAttribute('disabled');
else markBtn.setAttribute('disabled', '');
};
selAll.addEventListener('click', () => {
checkboxes().forEach((cb) => { cb.checked = true; });
refreshDisabled();
});
selNone.addEventListener('click', () => {
checkboxes().forEach((cb) => { cb.checked = false; });
refreshDisabled();
});
wrap.addEventListener('change', (e) => {
if (e.target.matches('input[data-todo-id]')) refreshDisabled();
});
markBtn.addEventListener('click', () => {
const ids = checkboxes().filter((cb) => cb.checked).map((cb) => cb.dataset.todoId);
if (!ids.length) return;
status.textContent = 'marking…';
asyncBtn(markBtn, async () => {
try {
const resp = await fetch('api/todos/mark-done', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'ids=' + encodeURIComponent(ids.join(',')),
});
if (resp.ok) {
status.textContent = '✓ marked done';
refreshTodos();
} else {
status.textContent = 'failed: ' + (await resp.text());
}
} catch (err) {
status.textContent = 'failed: ' + err;
}
});
});
return el('div', { class: 'inbox-mark-all-row' }, selAll, selNone, markBtn, status);
}
/** Build the todos side-panel list. Each entry is a LooseEnd::Todo
* (subsystem, summary, source, age_seconds). */
* (id, subsystem, summary, source, age_seconds). A checkbox per row
* plus the bulk row above lets the operator dismiss several at once
* instead of one `cancel_loose_end` call at a time. */
function buildTodosList(todos) {
const wrap = el('div', { class: 'agent-inbox' });
if (!todos.length) {
@ -870,6 +929,7 @@ window.marked = marked;
'no todos — all subsystem queues are clear.'));
return wrap;
}
wrap.append(buildTodosMarkDoneRow(wrap));
const list = el('ul');
const fmtAge = (s) => {
if (s < 60) return s + 's';
@ -880,8 +940,13 @@ window.marked = marked;
for (const t of todos) {
const li = el('li');
const label = t.source ? t.subsystem + ' · ' + t.source : t.subsystem;
const cbId = 'todo-cb-' + t.id;
const cb = el('input', {
type: 'checkbox', id: cbId, class: 'todo-cb', 'data-todo-id': String(t.id),
});
li.append(
el('span', { class: 'inbox-from' }, label), ' ',
cb, ' ',
el('label', { for: cbId, class: 'inbox-from' }, label), ' ',
el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'),
el('div', { class: 'inbox-body' }, t.summary || ''),
);