agent: "mark all read" button on the inbox flyout (#559 frontend half)

Pairs with damocles PR #566 (broker primitive + dashboard
`POST /api/agent/{name}/mark-all-read` route). The agent's
per-container inbox side-panel now gets a header row with a
`✓ mark all read` button that:

- confirms via a one-line dialog (the action is destructive: any
  pending broker message for this agent is acked, the harness
  won't receive a wake-prompt for them)
- POSTs to the host dashboard (cross-origin, same pattern as the
  existing operator-answer flow on this page)
- surfaces `{ marked: N }` in an inline status pill, then triggers
  a `refreshState` so any state-derived surfaces re-read fresh
- stays out of the way when the inbox is empty (only renders above
  a non-empty rows list)

Note: `recent_for` returns the most-recent-N messages regardless of
ack state, so clicking does NOT visually empty the rows list. The
status pill ("✓ marked N as read") is the operator-facing
confirmation; the next `turn_start` will show `0 unread` in its
badge. Tooltip on the button calls this out so the operator isn't
surprised the row list stays put.

CSS mirrors the existing answer-form button family (mauve hover on
bg-elev background) so it reads as a peer affordance, with a
border-bottom separating it from the message list.
This commit is contained in:
iris 2026-05-29 13:11:19 +02:00 committed by Mara
commit fe2f9c2396
2 changed files with 100 additions and 0 deletions

View file

@ -522,6 +522,42 @@ pre.diff {
.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; }
.agent-inbox .answer-status { color: var(--muted); align-self: center; }
/* #559: "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. */
.agent-inbox .inbox-mark-all-row {
display: flex;
gap: 0.6em;
align-items: center;
padding: 0.4em 0.2em 0.6em;
margin-bottom: 0.5em;
border-bottom: 1px solid var(--border);
}
.agent-inbox .inbox-mark-all-btn {
font-family: inherit;
font-size: inherit;
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.3em 0.7em;
cursor: pointer;
white-space: nowrap;
}
.agent-inbox .inbox-mark-all-btn:hover:not(:disabled) {
border-color: var(--purple);
color: var(--purple);
}
.agent-inbox .inbox-mark-all-btn:disabled {
opacity: 0.5;
cursor: default;
}
.agent-inbox .inbox-mark-status {
color: var(--muted);
font-size: 0.9em;
}
.last-turn {
color: var(--muted);
font-size: 0.8em;

View file

@ -706,6 +706,58 @@ window.marked = marked;
return wrap;
}
/** #559: "mark all read" affordance for the agent's inbox flyout.
* Returns a DOM row containing a button + an inline status pill.
* POSTs to the host dashboard's `/api/agent/{name}/mark-all-read`
* (damocles PR #566) and surfaces the `{ marked: N }` count back to
* the operator. Re-runs `onCleared` on success so the caller can
* refresh whatever state it owns. */
function buildInboxMarkAllRow(label, onCleared) {
const status = el('span', { class: 'inbox-mark-status' });
const btn = el('button', {
type: 'button',
class: 'inbox-mark-all-btn',
title: 'mark every queued message for this agent as read — '
+ 'drains the host broker\'s pending + delivered-unacked rows. '
+ 'history shown here is the most-recent-N regardless of state, '
+ 'so the list itself stays visible.',
}, '✓ mark all read');
btn.addEventListener('click', async () => {
if (!dashboardBase) {
status.textContent = 'dashboard url unknown';
return;
}
if (!label) {
status.textContent = 'agent label unknown';
return;
}
if (!window.confirm(
`mark every queued message for ${label} as read? `
+ `the message history shown stays; only the unread queue is drained.`
)) return;
btn.disabled = true;
status.textContent = 'clearing…';
try {
const resp = await fetch(
dashboardBase + 'api/agent/' + encodeURIComponent(label) + '/mark-all-read',
{ method: 'POST' });
if (resp.ok) {
const data = await resp.json().catch(() => ({}));
const n = Number(data.marked) || 0;
status.textContent = '✓ marked ' + n + ' as read';
if (typeof onCleared === 'function') onCleared();
} else {
status.textContent = 'failed: http ' + resp.status;
btn.disabled = false;
}
} catch (err) {
status.textContent = 'failed: ' + err;
btn.disabled = false;
}
});
return el('div', { class: 'inbox-mark-all-row' }, btn, status);
}
function buildInboxList(rows) {
const wrap = el('div', { class: 'agent-inbox' });
if (!rows.length) {
@ -713,6 +765,18 @@ window.marked = marked;
'inbox empty.'));
return wrap;
}
// #559: "mark all read" header row drains the host broker's
// pending + delivered-unacked rows for this agent (damocles PR
// #566). Visible rows here are the most-recent-N regardless of
// ack state, so the list itself doesn't visually empty on click —
// the status pill confirms the drain count, and the next
// turn_start's "unread" badge will read zero.
wrap.append(buildInboxMarkAllRow(currentLabel, () => {
// Refresh state so any UI surface that DOES depend on
// delivery state (eg future per-status filters) picks up
// the new shape.
refreshState();
}));
const list = el('ul');
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19);
for (const m of rows) {