diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index 7f55eb38..b12d7a14 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -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) { + // `` 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 = ' 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', ]; //
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 diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index b76e9f53..1016ad0f 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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 diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 94582573..315b35b6 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -34,6 +34,16 @@ ◆ SYST3M ◆ + + + ◆ SCH3DUL3S ◆ + + +
+

◆ N3W SCH3DUL3 ◆

+
══════════════════════════════════════════════════════════════
+

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 operator / manager.

+
+

loading…

+
+ +

◆ QU3U3D SCH3DUL3S ◆

+
══════════════════════════════════════════════════════════════
+

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 ✕ cancel all.

+
+

loading…

+
+
+