dashboard: queued reminders surface
new 'qu3u3d r3m1nd3rs' section between approvals and operator
inbox. lists every pending reminder with agent, due-relative
timestamp, body, payload path (path-linkified), and a cancel
button. drives off a new /api/reminders endpoint and a
POST /cancel-reminder/{id} that hard-deletes the row.
failure surface (last_error / attempt_count + retry) deferred —
needs a sqlite migration; tracked in TODO.md.
This commit is contained in:
parent
cb71a07300
commit
1db6b8ffed
6 changed files with 183 additions and 4 deletions
|
|
@ -1240,6 +1240,79 @@
|
|||
return s.length <= n ? s : s.slice(0, n - 1) + '…';
|
||||
}
|
||||
|
||||
// ─── reminders ──────────────────────────────────────────────────────────
|
||||
// Reminders aren't part of /api/state (separate sqlite table, separate
|
||||
// mutation cadence). Refresh fires alongside refreshState() so a
|
||||
// cancel POST or a cold load both reflect within the same tick. A
|
||||
// periodic poll isn't necessary — new reminders are queued by the
|
||||
// agents themselves and the operator already sees them next time
|
||||
// they interact with the page.
|
||||
async function refreshReminders() {
|
||||
const root = $('reminders-section');
|
||||
if (!root) return;
|
||||
try {
|
||||
const resp = await fetch('/api/reminders');
|
||||
if (!resp.ok) {
|
||||
root.innerHTML = '';
|
||||
root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
|
||||
return;
|
||||
}
|
||||
const rows = await resp.json();
|
||||
renderReminders(rows);
|
||||
} catch (err) {
|
||||
root.innerHTML = '';
|
||||
root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
|
||||
}
|
||||
}
|
||||
function renderReminders(rows) {
|
||||
const root = $('reminders-section');
|
||||
if (!root) return;
|
||||
root.innerHTML = '';
|
||||
if (!rows.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no queued reminders'));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'reminders' });
|
||||
for (const r of rows) {
|
||||
const li = el('li', { class: 'reminder-row' });
|
||||
const dueIn = r.due_at - Math.floor(Date.now() / 1000);
|
||||
const dueLabel = dueIn <= 0
|
||||
? `overdue ${fmtAgo(r.due_at)}`
|
||||
: `in ${fmtDuration(dueIn)}`;
|
||||
const head = el('div', { class: 'reminder-head' },
|
||||
el('span', { class: 'agent' }, r.agent), ' ',
|
||||
el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel),
|
||||
' ',
|
||||
el('span', { class: 'meta' }, `· id ${r.id}`),
|
||||
);
|
||||
if (r.file_path) {
|
||||
head.append(' ', el('span', { class: 'meta' }, '· payload → '));
|
||||
appendLinkified(head, r.file_path);
|
||||
}
|
||||
const body = el('div', { class: 'reminder-body' });
|
||||
const previews = appendLinkified(body, r.message);
|
||||
li.append(head, body);
|
||||
for (const d of previews) li.appendChild(d);
|
||||
// Cancel form omits `data-no-refresh` — the resulting refreshState
|
||||
// re-fires refreshReminders so the row drops on its own.
|
||||
const cancelForm = el('form', {
|
||||
method: 'POST', action: '/cancel-reminder/' + r.id,
|
||||
class: 'inline', 'data-async': '',
|
||||
'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`,
|
||||
});
|
||||
cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L'));
|
||||
li.append(cancelForm);
|
||||
ul.append(li);
|
||||
}
|
||||
root.append(ul);
|
||||
}
|
||||
function fmtDuration(secs) {
|
||||
if (secs < 60) return secs + 's';
|
||||
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
|
||||
if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
|
||||
return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
|
||||
}
|
||||
|
||||
// ─── state polling ──────────────────────────────────────────────────────
|
||||
let pollTimer = null;
|
||||
// Sections whose innerHTML gets blown away on each refresh. If the
|
||||
|
|
@ -1252,6 +1325,7 @@
|
|||
'inbox-section',
|
||||
'approvals-section',
|
||||
'meta-inputs-section',
|
||||
'reminders-section',
|
||||
];
|
||||
// <details> sections that should survive a refresh need a stable
|
||||
// `data-restore-key` attribute. snapshotOpenDetails walks managed
|
||||
|
|
@ -1326,6 +1400,7 @@
|
|||
syncApprovalsFromSnapshot(s);
|
||||
renderApprovals();
|
||||
renderMetaInputs(s);
|
||||
refreshReminders();
|
||||
restoreOpenDetails(openDetails);
|
||||
notifyDeltas(s);
|
||||
// No periodic refresh timer. Phase 6 covers every container
|
||||
|
|
|
|||
|
|
@ -450,6 +450,27 @@ summary:hover { color: var(--purple); }
|
|||
0%, 100% { box-shadow: 0 0 12px -4px rgba(250, 179, 135, 0.55); }
|
||||
50% { box-shadow: 0 0 22px -2px rgba(250, 179, 135, 0.95); }
|
||||
}
|
||||
/* Reminders list — rendered from /api/reminders, separate from the
|
||||
main /api/state snapshot. Each row stacks identity, head meta,
|
||||
body, and a small cancel form. */
|
||||
.reminders {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.reminder-row {
|
||||
padding: 0.4em 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.reminder-row:last-child { border-bottom: 0; }
|
||||
.reminder-head { font-size: 0.9em; }
|
||||
.reminder-body {
|
||||
color: var(--fg);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
|
||||
/* Path linkification — agents drop pointer strings into messages
|
||||
constantly; clicking the anchor expands a sibling <details> that
|
||||
lazy-loads from /api/state-file. */
|
||||
|
|
|
|||
|
|
@ -45,6 +45,13 @@
|
|||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.</p>
|
||||
<div id="reminders-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<h2>◆ P3NDING APPR0VALS ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<div id="approvals-section">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue