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:
parent
ff2721cc0f
commit
41c1b1a3fb
4 changed files with 119 additions and 7 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 || ''),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue