diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 2b9d7007..79f32625 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -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; diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 729e4b06..b53ecaeb 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -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 || ''), ); diff --git a/hive-agent/src/web_ui/actions.rs b/hive-agent/src/web_ui/actions.rs index de060f92..52cb4e75 100644 --- a/hive-agent/src/web_ui/actions.rs +++ b/hive-agent/src/web_ui/actions.rs @@ -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) -> Response { + let ids: Vec = form + .ids + .split(',') + .filter_map(|s| s.trim().parse::().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() +} diff --git a/hive-agent/src/web_ui/mod.rs b/hive-agent/src/web_ui/mod.rs index 5b06e15d..4693c361 100644 --- a/hive-agent/src/web_ui/mod.rs +++ b/hive-agent/src/web_ui/mod.rs @@ -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));