dashboard: scheduled prompts tab + creation form (closes #459)

frontend for the #444 scheduled-prompts feature. backend is
already merged (PR #454 + sibling commits): GET /api/schedules
(snapshot), POST /api/schedules (operator-direct submit), POST
/api/schedules/{id}/cancel (whole or per-target).

new chrome

- SCH3DUL3S tab in the dashboard tab strip, between SYST3M and
  the FL0W link. count pill shows the number of schedules with
  at least one still-active target.
- pane has two sections: N3W SCH3DUL3 (creation form) +
  QU3U3D SCH3DUL3S (list of cards).

creation form

- targets multi-select rendered as chip-style checkboxes;
  candidates pulled from containersState plus the special
  `operator` and `manager` recipients.
- prompt body textarea (required, non-empty trim).
- first-fire datetime-local input, defaulted to "5 minutes from
  now" so the form has a sensible pre-filled future timestamp.
- optional interval (seconds) input — blank = one-shot.
- optional description (one-liner shown on the schedule card).
- mid-typing carry: re-rendering the form preserves field values
  + checkbox state. the operator never loses what they were
  typing when the schedule list refreshes underneath them.
- POSTs SchedulePromptPayload JSON to /api/schedules; on success
  re-fetches the list to surface the new row.

schedules list

- one card per schedule, active rows first (sorted by
  next_fire_at_unix), cancelled tail dimmed.
- header: id, source chip (`operator` or `approval` — reuses
  the rebuild-queue rqe-source styling for visual consistency),
  next-fire countdown / overdue label, recurring vs one-shot
  badge, owner.
- body: prompt text in a styled <pre>-ish block with linkified
  path references.
- per-target table: target name, last fire age, last result,
  per-row cancel button.
- whole-schedule "✕ cancel all" button.
- per-target cancel posts { targets: ["name"] }; cancel-all
  posts no body (== cancel whole row).

no-SSE refresh

- the backend doesn't emit SchedulesChanged dashboard events
  yet (damocles flagged this as a follow-up PR C). list
  re-fetches on:
  - tab activation (so switching to SCH3DUL3S never lands stale)
  - cold load via refreshState
  - after every submit + cancel POST
  the operator's typical interactions all force a refresh; the
  remaining gap (worker fires while you're staring at the tab)
  is the natural argument for PR C.

files

- frontend/packages/dashboard/src/index.html — new tab + pane
  with the two sections.
- frontend/packages/dashboard/src/app.js — schedulesState
  cache, refreshSchedules, render*ScheduleNewForm, submit +
  cancel helpers, tab routing extended for `schedules`, count
  pill wired into refreshTabCounts.
- frontend/packages/dashboard/src/dashboard.css — schedule
  form + card styling + chip checkboxes + targets table. small
  .btn-inline-small helper for the per-target cancel.

validation

- npm run build --workspace=@hive/dashboard clean. app.js
  158 kb → 161 kb. CSS 41.3 kb → 43.9 kb.
- browser smoke test isn't possible from inside iris's container;
  endpoints are wire-compatible (backend types unchanged) and
  the form serialisation matches SchedulePromptPayload's JSON
  shape exactly (targets / body / first_fire_at_unix /
  interval_seconds / description).
This commit is contained in:
iris 2026-05-26 09:53:48 +02:00 committed by Mara
commit c233f8a924
3 changed files with 495 additions and 1 deletions

View file

@ -1918,6 +1918,330 @@ window.marked = marked;
return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
}
// ─── scheduled prompts (#459) ──────────────────────────────────────────
// Backend (#444) exposes `/api/schedules` (snapshot), `/api/schedules`
// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
// (whole or per-target). No SSE channel for schedule mutations yet,
// so we refresh on tab activation + after every submit/cancel POST.
// Local cache lets `refreshTabCounts` show the active count without
// re-fetching every second.
let schedulesState = [];
async function refreshSchedules() {
const listRoot = $('schedules-section');
if (!listRoot) return;
try {
const resp = await fetch('/api/schedules');
if (!resp.ok) {
listRoot.innerHTML = '';
listRoot.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
return;
}
schedulesState = await resp.json();
} catch (err) {
listRoot.innerHTML = '';
listRoot.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
return;
}
renderScheduleNewForm();
renderSchedulesList();
}
// Active = at least one target still alive (no `cancelled_at_unix`)
// AND the whole schedule isn't cancelled. Drives the tab pill count.
function activeScheduleCount() {
let n = 0;
for (const s of schedulesState) {
if (s.cancelled_at_unix) continue;
if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++;
}
return n;
}
function renderScheduleNewForm() {
const root = $('schedule-new-section');
if (!root) return;
// Preserve any field the operator was mid-typing in by reading
// current values before we re-render. Only `body` + `description`
// are big enough to feel — the others are toggle/datetime/number.
const carry = readScheduleFormCarry(root);
root.innerHTML = '';
// Targets multi-select pulls from the live containers list. The
// backend accepts any string (forward-compat for richer recipients),
// so we also surface the special `operator` and `manager` names
// as fixed options on top.
const candidateTargets = ['operator', 'manager'];
const containerNames = Array.from(containersState.values())
.map((c) => c.name)
.filter((n) => n !== 'manager' && n !== 'operator')
.sort();
for (const n of containerNames) candidateTargets.push(n);
const form_ = el('form', { class: 'schedule-new-form' });
form_.addEventListener('submit', (e) => {
e.preventDefault();
submitNewSchedule(form_);
});
const targetsLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'targets'));
const targetsBox = el('div', { class: 'schedule-targets' });
const restoredTargets = new Set(carry.targets);
for (const name of candidateTargets) {
const id_ = 'st-' + name;
const cb = el('input', {
type: 'checkbox', name: 'targets', value: name, id: id_,
});
if (restoredTargets.has(name)) cb.checked = true;
targetsBox.append(el('label', { class: 'schedule-target-chip', for: id_ },
cb, el('span', {}, name)));
}
targetsLabel.append(targetsBox);
form_.append(targetsLabel);
const bodyLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'prompt body'));
const bodyInput = el('textarea', {
name: 'body', rows: '4', required: 'required', placeholder: 'message delivered to each target at fire time',
});
bodyInput.value = carry.body;
bodyLabel.append(bodyInput);
form_.append(bodyLabel);
const firstFireLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'first fire'));
const firstFireInput = el('input', {
type: 'datetime-local', name: 'first_fire', required: 'required',
});
// Default to "5 minutes from now" so the operator has a sensible
// pre-filled value (also future-positive so worker doesn't fire
// immediately on a stale-clock accident).
firstFireInput.value = carry.first_fire
|| isoForDatetimeLocal(new Date(Date.now() + 5 * 60 * 1000));
firstFireLabel.append(firstFireInput);
form_.append(firstFireLabel);
const intervalLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'interval (seconds; blank = one-shot)'));
const intervalInput = el('input', {
type: 'number', name: 'interval', min: '1', step: '1', placeholder: 'e.g. 3600 for hourly',
});
intervalInput.value = carry.interval;
intervalLabel.append(intervalInput);
form_.append(intervalLabel);
const descLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'description (optional)'));
const descInput = el('input', {
type: 'text', name: 'description', placeholder: 'shown on the schedule card',
});
descInput.value = carry.description;
descLabel.append(descInput);
form_.append(descLabel);
const actions = el('div', { class: 'schedule-actions' });
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, ' qu3ue prompt');
actions.append(submit);
form_.append(actions);
root.append(form_);
}
function readScheduleFormCarry(root) {
return {
targets: Array.from(root.querySelectorAll('input[name="targets"]:checked')).map((i) => i.value),
body: root.querySelector('textarea[name="body"]')?.value || '',
first_fire: root.querySelector('input[name="first_fire"]')?.value || '',
interval: root.querySelector('input[name="interval"]')?.value || '',
description: root.querySelector('input[name="description"]')?.value || '',
};
}
function isoForDatetimeLocal(date) {
// `<input type="datetime-local">` expects `YYYY-MM-DDTHH:MM`
// in the user's local timezone (with no trailing Z). Build it
// by hand rather than slicing toISOString which is always UTC.
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
async function submitNewSchedule(form_) {
const fd = new FormData(form_);
const targets = fd.getAll('targets').map(String);
const body = String(fd.get('body') || '').trim();
const firstFireStr = String(fd.get('first_fire') || '');
const intervalStr = String(fd.get('interval') || '').trim();
const description = String(fd.get('description') || '').trim();
if (!targets.length) { alert('schedule must have at least one target'); return; }
if (!body) { alert('prompt body must be non-empty'); return; }
if (!firstFireStr) { alert('first fire timestamp is required'); return; }
const firstFireDate = new Date(firstFireStr);
if (Number.isNaN(firstFireDate.getTime())) { alert('first fire is not a valid datetime'); return; }
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
let interval_seconds = null;
if (intervalStr) {
const parsed = parseInt(intervalStr, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
alert('interval must be a positive integer (or blank for one-shot)');
return;
}
interval_seconds = parsed;
}
const payload = { targets, body, first_fire_at_unix };
if (interval_seconds != null) payload.interval_seconds = interval_seconds;
if (description) payload.description = description;
const submitBtn = form_.querySelector('button[type="submit"]');
const originalLabel = submitBtn ? submitBtn.innerHTML : '';
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<span class="spinner">◐</span> queueing…'; }
try {
const resp = await fetch('/api/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
return;
}
// Clear the form (form_ is about to be re-rendered fresh below).
form_.reset();
await refreshSchedules();
} catch (err) {
alert('schedule submit failed: ' + err);
} finally {
if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = originalLabel; }
}
}
function renderSchedulesList() {
const root = $('schedules-section');
if (!root) return;
root.innerHTML = '';
if (!schedulesState.length) {
root.append(el('p', { class: 'empty' }, 'no schedules queued'));
return;
}
const ul = el('ul', { class: 'schedules' });
// Active schedules first (still firing), then cancelled tail.
const sorted = schedulesState.slice().sort((a, b) => {
const aDone = a.cancelled_at_unix ? 1 : 0;
const bDone = b.cancelled_at_unix ? 1 : 0;
if (aDone !== bDone) return aDone - bDone;
return a.next_fire_at_unix - b.next_fire_at_unix;
});
for (const s of sorted) ul.append(renderScheduleCard(s));
root.append(ul);
}
function renderScheduleCard(s) {
const cancelled = !!s.cancelled_at_unix;
const li = el('li', { class: 'schedule-row' + (cancelled ? ' schedule-cancelled' : '') });
const head = el('div', { class: 'schedule-head' });
head.append(
el('span', { class: 'meta' }, '#' + s.id), ' ',
el('span', { class: 'rqe-source rqe-source-' + (s.source && s.source.kind === 'approval' ? 'approval' : 'manual') },
s.source && s.source.kind === 'approval' ? 'approval' : 'operator'),
);
if (cancelled) {
head.append(' ', el('span', { class: 'badge badge-muted', title: 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString() }, 'cancelled'));
} else {
const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000);
const dueLabel = dueIn <= 0
? `overdue ${fmtAgo(s.next_fire_at_unix)}`
: `next fire in ${fmtDuration(dueIn)}`;
head.append(' ', el('span', {
class: 'meta',
title: new Date(s.next_fire_at_unix * 1000).toISOString(),
}, dueLabel));
}
if (s.interval_seconds) {
head.append(' ', el('span',
{ class: 'badge badge-muted', title: 'recurring' },
`↻ every ${fmtDuration(s.interval_seconds)}`));
} else {
head.append(' ', el('span', { class: 'badge badge-muted', title: 'one-shot' }, 'one-shot'));
}
head.append(' ', el('span', { class: 'meta' }, '· owner ' + s.owner));
li.append(head);
if (s.description) {
li.append(el('div', { class: 'schedule-description' }, s.description));
}
const body = el('div', { class: 'schedule-body' });
appendLinkified(body, s.body);
li.append(body);
// Targets table — one row per recipient with cancel button +
// last-fire metadata.
const targets = s.targets || [];
if (targets.length) {
const table = el('table', { class: 'schedule-targets-table' });
const thead = el('thead', {}, el('tr', {},
el('th', {}, 'target'),
el('th', {}, 'last fire'),
el('th', {}, 'last result'),
el('th', {}, ''),
));
table.append(thead);
const tbody = el('tbody', {});
for (const t of targets) {
const tCancelled = !!t.cancelled_at_unix;
const tr = el('tr', { class: tCancelled ? 'schedule-target-cancelled' : '' });
tr.append(el('td', {}, t.target));
tr.append(el('td', { class: 'meta' },
t.last_fired_at_unix
? fmtAgo(t.last_fired_at_unix) + ' ago'
: '—'));
tr.append(el('td', { class: 'meta' }, t.last_result || '—'));
const actionTd = el('td', {});
if (!cancelled && !tCancelled) {
const btn = el('button', { type: 'button', class: 'btn btn-deny btn-inline-small' }, '✕');
btn.title = 'cancel just this target';
btn.addEventListener('click', () => cancelScheduleTargets(s.id, [t.target]));
actionTd.append(btn);
} else if (tCancelled) {
actionTd.append(el('span', { class: 'meta' }, '✕ cancelled'));
}
tr.append(actionTd);
tbody.append(tr);
}
table.append(tbody);
li.append(table);
}
if (!cancelled) {
const actions = el('div', { class: 'schedule-actions' });
const cancelAll = el('button', { type: 'button', class: 'btn btn-deny' }, '✕ cancel all');
cancelAll.title = 'cancel the whole schedule';
cancelAll.addEventListener('click', () => cancelScheduleAll(s.id));
actions.append(cancelAll);
li.append(actions);
}
return li;
}
async function cancelScheduleAll(id) {
if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return;
await postScheduleCancel(id, null);
}
async function cancelScheduleTargets(id, targets) {
if (!confirm(`cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`)) return;
await postScheduleCancel(id, targets);
}
async function postScheduleCancel(id, targets) {
try {
const opts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
};
if (targets) opts.body = JSON.stringify({ targets });
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/cancel', opts);
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('cancel failed: http ' + resp.status + (text ? '\n\n' + text : ''));
return;
}
await refreshSchedules();
} catch (err) {
alert('cancel failed: ' + err);
}
}
// ─── state polling ──────────────────────────────────────────────────────
let pollTimer = null;
// Sections whose innerHTML gets blown away on each refresh. If the
@ -1932,6 +2256,8 @@ window.marked = marked;
'meta-inputs-section',
'rebuild-queue-section',
'reminders-section',
'schedule-new-section',
'schedules-section',
];
// <details> sections that should survive a refresh need a stable
// `data-restore-key` attribute. snapshotOpenDetails walks managed
@ -2013,6 +2339,7 @@ window.marked = marked;
renderMetaInputs(s);
renderRebuildQueue(s);
refreshReminders();
refreshSchedules();
restoreOpenDetails(openDetails);
notifyDeltas(s);
// No periodic refresh timer. Phase 6 covers every container
@ -2107,7 +2434,7 @@ window.marked = marked;
// tab-strip link. Tab routing only applies when the tab DOM is
// present (e.g. not on the flow page itself, where these elements
// don't exist and the loop no-ops).
const TABS = ['swarm', 'call', 'system'];
const TABS = ['swarm', 'call', 'system', 'schedules'];
function activateTab(name) {
const target = TABS.includes(name) ? name : TABS[0];
for (const t of TABS) {
@ -2116,6 +2443,9 @@ window.marked = marked;
if (tab) tab.classList.toggle('active', t === target);
if (pane) pane.classList.toggle('tab-pane-active', t === target);
}
// #459: schedules pane has no SSE channel yet (PR C follow-up), so
// re-fetch on activation so the operator never lands on stale data.
if (target === 'schedules') refreshSchedules();
}
function syncTabFromHash() {
const h = (window.location.hash || '#swarm').replace(/^#/, '');
@ -2159,6 +2489,10 @@ window.marked = marked;
}
}
setTabCount('system', sysCount);
// SCH3DUL3S — count of schedules with at least one still-active
// target (whole-schedule cancellation or all-targets-cancelled
// means "not waiting on the worker"; those don't pull attention).
setTabCount('schedules', activeScheduleCount());
// FL0W pill count: lives in ./flow.js now (it has the inbox
// derived store). Dashboard tab strip's `#tab-count-flow` slot
// stays hidden by default; future plumbing could broadcast the

View file

@ -1599,6 +1599,131 @@ body.flow-shell .tabbar .tab.active.tab-link {
surface the messages via the pill/flyout instead. */
.flow-inbox-headless { display: none !important; }
/* scheduled prompts tab (#459)
Creation form at the top, list of queued schedule cards below.
Cards show: id + source + due-in + cancel-all in the header,
the prompt body, then a targets table with per-row cancel. */
.schedule-new-form {
display: flex;
flex-direction: column;
gap: 0.6em;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.8em 1em;
margin-bottom: 0.5em;
}
.schedule-field {
display: flex;
flex-direction: column;
gap: 0.2em;
}
.schedule-field-label {
color: var(--muted);
font-size: 0.85em;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.schedule-field input,
.schedule-field textarea {
font-family: inherit;
font-size: inherit;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.35em 0.5em;
}
.schedule-field textarea { resize: vertical; min-height: 4em; }
.schedule-targets {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.schedule-target-chip {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.2em 0.6em;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.85em;
cursor: pointer;
background: rgba(24, 24, 37, 0.4);
}
.schedule-target-chip:hover { border-color: var(--purple-dim); }
.schedule-target-chip:has(input:checked) {
border-color: var(--purple);
color: var(--purple);
background: rgba(203, 166, 247, 0.08);
}
.schedule-target-chip input { margin: 0; }
.schedule-actions {
display: flex;
gap: 0.5em;
justify-content: flex-end;
}
.schedules {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.schedule-row {
padding: 0.6em 0.9em;
background: rgba(24, 24, 37, 0.55);
border: 1px solid var(--border);
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 0.4em;
}
.schedule-row.schedule-cancelled { opacity: 0.55; }
.schedule-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4em;
}
.schedule-description {
color: var(--muted);
font-style: italic;
font-size: 0.95em;
}
.schedule-body {
white-space: pre-wrap;
word-break: break-word;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.5em 0.7em;
}
.schedule-targets-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
.schedule-targets-table th,
.schedule-targets-table td {
padding: 0.25em 0.6em;
text-align: left;
border-bottom: 1px solid var(--border);
}
.schedule-targets-table th {
color: var(--muted);
font-weight: normal;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.8em;
}
.schedule-targets-table tr:last-child td { border-bottom: 0; }
.schedule-target-cancelled td { opacity: 0.5; }
.btn-inline-small {
font-size: 0.7em;
padding: 0.1em 0.45em;
}
/* Selection bar (#443). Sticky-bottom strip that surfaces bulk
actions when 1 agent is selected (click the icon). Visually
echoes the flow composer's frosted-mauve treatment so the chrome

View file

@ -34,6 +34,16 @@
<span class="tab-label">◆ SYST3M ◆</span>
<span class="tab-count" id="tab-count-system" hidden></span>
</a>
<!-- SCH3DUL3S (#459): scheduled-prompts surface. List of
queued schedules + an operator-direct creation form.
Count pill mirrors the active (non-cancelled) schedule
count; hidden when zero. -->
<a class="tab" id="tab-schedules" href="#schedules" role="tab"
aria-controls="tab-pane-schedules"
data-tab="schedules">
<span class="tab-label">◆ SCH3DUL3S ◆</span>
<span class="tab-count" id="tab-count-schedules" hidden></span>
</a>
<!-- FL0W is its own page (`/flow.html`), not a tab — per
operator @ #369#issuecomment-3437 ("yes terminal can be a
@ -128,6 +138,31 @@
</div>
</section>
<!-- SCH3DUL3S (#459): scheduled prompts. operator-direct creation
form at the top (POST /api/schedules, no approval gate), live
schedules list below (GET /api/schedules) with per-target
last-fired timestamps + result, plus per-target / whole-row
cancel buttons (POST /api/schedules/{id}/cancel). #444 backend
doesn't emit a SchedulesChanged dashboard event yet, so the
list re-fetches on tab activation + after each form/cancel
submit. Live SSE wiring is the future PR C. -->
<section class="tab-pane" id="tab-pane-schedules"
role="tabpanel" aria-labelledby="tab-schedules">
<h2>◆ N3W SCH3DUL3 ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">queue a prompt to fire at a future time. operator-direct (no approval gate); recurring when an interval is set. targets are any known agent name or <code>operator</code> / <code>manager</code>.</p>
<div id="schedule-new-section">
<p class="meta">loading…</p>
</div>
<h2>◆ QU3U3D SCH3DUL3S ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">all schedules currently in the table. expand each card to see per-target firing history. cancel a single target with the row button or the whole schedule with <code>✕ cancel all</code>.</p>
<div id="schedules-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- FL0W: lives on its own page now (`/flow.html`). The
message-flow + inbox + compose DOM only exists there — when
app.js boots on this page the corresponding renderers