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 || ''),
);

View file

@ -1,4 +1,5 @@
//! Operator action POST handlers (send, cancel, compact, model, effort, reset).
//! Operator action POST handlers (send, cancel, compact, model, effort,
//! reset, todos mark-done).
use axum::{
Form,
@ -162,3 +163,41 @@ pub(super) async fn post_set_effort(
tracing::info!(%level, "operator set effort");
(axum::http::StatusCode::OK, "ok").into_response()
}
#[derive(Deserialize)]
pub(super) struct MarkTodosDoneForm {
/// Comma-separated todo ids. Same "one field, JS joins the checked
/// boxes" shape as `hive-c0re`'s `meta_inputs::MetaUpdateForm` — axum's
/// `Form` extractor doesn't natively decode repeated same-name keys.
ids: String,
}
/// `POST /api/todos/mark-done` — dismiss one or more of this agent's own
/// todos (loose-ends v2) from the todos flyout. Loops a `MarkTodoDone` call
/// per id over the in-agent socket rather than adding a new bulk request to
/// `hive-agent-sock`: the todos list is small (single-digit rows most of the
/// time), so N same-host socket round-trips isn't a real cost, and it keeps
/// the wire protocol's `Request` enum — already used by the `cancel_loose_end`
/// MCP tool — unchanged. Unknown/already-acked ids just don't add to the
/// `acked` count (same "acking twice is not a new action" semantics as the
/// single-id path); a request with no ids or where every id fails to parse
/// is rejected as a client error rather than silently acking nothing.
pub(super) async fn post_mark_todos_done(Form(form): Form<MarkTodosDoneForm>) -> Response {
let ids: Vec<i64> = form
.ids
.split(',')
.filter_map(|s| s.trim().parse::<i64>().ok())
.collect();
if ids.is_empty() {
return error_response(StatusCode::BAD_REQUEST, "mark-done: no todo ids selected");
}
let mut acked = 0u64;
for id in ids {
if let Some(hive_agent_sock::Response::Acked { count }) =
crate::todo_server::dial(&hive_agent_sock::Request::MarkTodoDone { id }).await
{
acked += count;
}
}
axum::Json(serde_json::json!({ "acked": acked })).into_response()
}

View file

@ -116,6 +116,7 @@ pub async fn serve(
.route("/api/new-session", post(actions::post_new_session))
.route("/api/logout", post(auth::post_logout))
.route("/api/todos", get(stats::api_todos))
.route("/api/todos/mark-done", post(actions::post_mark_todos_done))
.route("/api/stats", get(stats::api_stats))
.route("/screen/ws", get(screen::screen_ws))
.route("/icon", get(screen::serve_icon));