dashboard: fold schedule creation into the table view (closes #564)

The N3W SCH3DUL3 vertical form is gone. The schedules table now
carries an always-visible inline create row at the bottom — fill the
cells, click + to POST /api/schedules, the new schedule appears
above on the next refresh.

Per-column inputs match the display semantics:
- `next` → `<input type="datetime-local">` (defaults to now+5min)
- `every` → 4 mini d/h/m/s number inputs (blank/all-zero = one-shot)
- `body` → `<textarea rows=1>` expanding to 4em on focus-within
  so multi-line prompts still fit (closes mara's "prompt may still be
  multi-line" requirement)
- per-agent columns → checkbox (whole cell clickable via padded label)
- actions → + submit + ⌫ reset (clears the carry without POSTing)

`newScheduleCarry` module-scope object survives the paintAtomic
re-render so mid-typed values stick across schedule-list refreshes
(same pattern as `scheduleEditCarry` from #474).

H2 collapses from "N3W SCH3DUL3" + "QU3U3D SCH3DUL3S" down to a
single "SCH3DUL3S" since the section now does both. Empty-state
branch on `renderSchedulesList` is gone — the table always renders
because the create row is always there.

`#schedule-new-section` div + `renderScheduleNewForm` /
`readScheduleFormCarry` / `submitNewSchedule` and the
`.schedule-new-form` CSS selector all removed. The edit-form path
(#474 `renderScheduleEditForm` colspan'd row on `✎` toggle) is
unchanged — "edit mode is inline in the table" already shipped
there, this PR just adds the same mental model for create.

Validations match the old form: empty targets / empty body /
invalid datetime / non-integer interval each surface an `alert()`.
The + button shows a spinner while the POST is in flight, then
clears the carry + triggers `refreshSchedules()`.
This commit is contained in:
iris 2026-05-29 19:57:14 +02:00
commit 0deca50352
3 changed files with 341 additions and 157 deletions

View file

@ -1693,7 +1693,6 @@ body.flow-shell .tabbar .tab.active.tab-link {
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,
.schedule-edit-form {
display: flex;
flex-direction: column;
@ -1924,6 +1923,109 @@ body.flow-shell .tabbar .tab.active.tab-link {
padding: 0.1em 0.45em;
}
/* #564 inline create row at the bottom of the schedules table.
Cells host inputs directly so the operator can fill + click to
queue a new schedule without leaving the table view. Slightly
different background tone so it reads as "this isn't a schedule
yet, it's the create form". */
.schedules-table-create-row td {
background: rgba(38, 32, 60, 0.25);
border-top: 2px solid var(--border);
vertical-align: top;
padding: 0.4em 0.5em;
}
.schedules-table-create-cell {
padding: 0.35em 0.4em;
}
.schedules-table-inline-input,
.schedules-table-inline-textarea,
.schedules-table-inline-num {
background: var(--bg);
border: 1px solid var(--border);
color: var(--fg);
font-family: ui-monospace, 'JetBrains Mono', monospace;
font-size: 0.85em;
padding: 0.2em 0.4em;
border-radius: 2px;
}
.schedules-table-inline-input:focus,
.schedules-table-inline-textarea:focus,
.schedules-table-inline-num:focus {
border-color: var(--purple);
outline: none;
}
.schedules-table-inline-textarea {
display: block;
width: 100%;
min-height: 1.6em;
resize: vertical;
/* Single-line by default; bumps to 4em on focus so a multi-line
prompt has room without the row taking permanent vertical space.
:focus-within so resizing-up sticks while focus stays in the row. */
}
.schedules-table-create-body:focus-within .schedules-table-inline-textarea {
min-height: 4em;
}
.schedules-table-inline-desc {
display: block;
width: 100%;
margin-top: 0.25em;
}
.schedules-table-inline-datetime {
width: 100%;
font-size: 0.78em;
}
.schedules-table-inline-interval {
display: flex;
align-items: center;
gap: 0.1em;
white-space: nowrap;
}
.schedules-table-inline-num {
width: 2.4em;
text-align: right;
font-variant-numeric: tabular-nums;
/* hide native number spinners they steal too much width at the
2.4em column size and the d/h/m/s buttons aren't useful with no
min/max range hint anyway. */
-moz-appearance: textfield;
appearance: textfield;
}
.schedules-table-inline-num::-webkit-outer-spin-button,
.schedules-table-inline-num::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.schedules-table-inline-unit {
color: var(--muted);
font-size: 0.7em;
margin-right: 0.15em;
}
.schedules-table-inline-check {
width: 16px;
height: 16px;
cursor: pointer;
margin: 0;
}
.schedules-table-inline-check-lbl {
display: inline-flex;
cursor: pointer;
padding: 0.4em 0.6em;
/* Pad out the label so the entire cell is a click target. */
}
.schedules-table-create-submit {
font-size: 1em;
padding: 0.15em 0.55em;
color: var(--green);
border-color: var(--green);
}
.schedules-table-create-reset {
font-size: 0.85em;
padding: 0.15em 0.45em;
color: var(--muted);
border-color: var(--border);
}
/* 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

@ -133,26 +133,21 @@
</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. -->
<!-- SCH3DUL3S (#459, #564): scheduled prompts. Creation + edit
are folded into the same table now (#564) — empty bottom row
is the create form (fill cells, click ), inline-edit-row
expands on the `✎` toggle for existing schedules. Live
schedules list driven by GET /api/schedules; POST /api/schedules
to create, PATCH /api/schedules/{id} to edit, POST
/api/schedules/{id}/cancel for per-target / whole-row cancel.
#444 backend doesn't emit SchedulesChanged dashboard event yet,
so the list re-fetches on tab activation + after each submit /
cancel. 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>
<h2>◆ SCH3DUL3S</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>
<p class="meta">all schedules currently in the table. fill the bottom row to queue a new schedule (operator-direct, no approval gate; recurring when an interval is set). 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>

View file

@ -2028,7 +2028,6 @@ window.marked = marked;
});
return;
}
renderScheduleNewForm();
renderSchedulesList();
}
// Active = at least one target still alive (no `cancelled_at_unix`)
@ -2203,97 +2202,50 @@ window.marked = marked;
return scheduleField('targets', box);
}
function renderScheduleNewForm() {
const liveRoot = $('schedule-new-section');
if (!liveRoot) 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.
// Read from the live root BEFORE the swap so we see current
// values (the fragment we build into is fresh).
const carry = readScheduleFormCarry(liveRoot);
paintAtomic(liveRoot, (root) => {
const form_ = el('form', { class: 'schedule-new-form' });
form_.addEventListener('submit', (e) => {
e.preventDefault();
submitNewSchedule(form_);
});
form_.append(buildTargetChips({
idPrefix: 'st-',
fieldName: 'targets',
checked: new Set(carry.targets),
}));
const bodyInput = el('textarea', {
name: 'body', rows: '4', required: 'required', placeholder: 'message delivered to each target at fire time',
});
bodyInput.value = carry.body;
form_.append(scheduleField('prompt body', bodyInput));
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));
form_.append(scheduleField('first fire', firstFireInput));
// Interval composer (#466). See `buildIntervalComposer` below —
// shared between the new-schedule form and the edit-schedule form
// (#474) so the same chip/d/h/m/s widget works for both flows.
const intervalCx = buildIntervalComposer({
label: 'interval (blank / all-zero = one-shot)',
namePrefix: 'interval_',
initialSeconds: 0,
});
form_.append(intervalCx.wrapper);
// Restore from carry. Prefer the new split fields; fall back to
// legacy single seconds value if the page was reloaded mid-edit
// against an older bundle.
if (carry.interval_d || carry.interval_h
|| carry.interval_m || carry.interval_s) {
intervalCx.setParts({
d: carry.interval_d, h: carry.interval_h,
m: carry.interval_m, s: carry.interval_s,
});
} else if (carry.interval) {
const legacy = parseInt(carry.interval, 10);
if (Number.isFinite(legacy) && legacy > 0) intervalCx.fillFromSeconds(legacy);
}
intervalCx.updatePreview();
const descInput = el('input', {
type: 'text', name: 'description', placeholder: 'shown on the schedule card',
});
descInput.value = carry.description;
form_.append(scheduleField('description (optional)', descInput));
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_);
}); // paintAtomic
}
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 || '',
// #466: interval is now split into d/h/m/s sub-fields. Keep the
// legacy `interval` key in case an older bundle is in the page.
interval: root.querySelector('input[name="interval"]')?.value || '',
interval_d: root.querySelector('input[name="interval_d"]')?.value || '',
interval_h: root.querySelector('input[name="interval_h"]')?.value || '',
interval_m: root.querySelector('input[name="interval_m"]')?.value || '',
interval_s: root.querySelector('input[name="interval_s"]')?.value || '',
description: root.querySelector('input[name="description"]')?.value || '',
// #564: inline-create state — survives paintAtomic re-renders. We
// refresh the schedules table on tab activate + after every mutation,
// and each refresh rebuilds the DOM via `paintAtomic`. The bottom
// create row's inputs would lose mid-typing values without this
// carry. `readNewScheduleCarryFromDOM` is called BEFORE every
// re-render so the carry sees the latest user input; the row
// builders then pre-fill from `newScheduleCarry`.
const newScheduleCarry = {
targets: new Set(),
body: '',
description: '',
first_fire: '',
interval_d: '',
interval_h: '',
interval_m: '',
interval_s: '',
};
function readNewScheduleCarryFromDOM(tr) {
if (!tr) return;
const get = (sel) => tr.querySelector(sel);
const checked = Array.from(tr.querySelectorAll('input[name="new_targets"]:checked'))
.map((i) => i.value);
newScheduleCarry.targets = new Set(checked);
const setIfDefined = (key, sel) => {
const el_ = get(sel);
if (el_) newScheduleCarry[key] = el_.value || '';
};
setIfDefined('body', 'textarea[name="new_body"]');
setIfDefined('description', 'input[name="new_description"]');
setIfDefined('first_fire', 'input[name="new_first_fire"]');
setIfDefined('interval_d', 'input[name="new_interval_d"]');
setIfDefined('interval_h', 'input[name="new_interval_h"]');
setIfDefined('interval_m', 'input[name="new_interval_m"]');
setIfDefined('interval_s', 'input[name="new_interval_s"]');
}
function resetNewScheduleCarry() {
newScheduleCarry.targets = new Set();
newScheduleCarry.body = '';
newScheduleCarry.description = '';
newScheduleCarry.first_fire = '';
newScheduleCarry.interval_d = '';
newScheduleCarry.interval_h = '';
newScheduleCarry.interval_m = '';
newScheduleCarry.interval_s = '';
}
function isoForDatetimeLocal(date) {
// `<input type="datetime-local">` expects `YYYY-MM-DDTHH:MM`
@ -2303,53 +2255,6 @@ window.marked = marked;
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 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);
// Interval composer (#466): total seconds from d/h/m/s sub-fields.
// `0` means one-shot at the wire level (backend expects null).
const intervalTotal = intervalSecondsFromFormData(fd, 'interval_');
if (Number.isNaN(intervalTotal)) {
alert('interval fields must be non-negative integers (or blank for one-shot)');
return;
}
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
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; }
}
}
// #535: schedules render as a single table — one row per schedule,
// attribute columns + one ✓/✕ column per agent (tilted 45° header
// so a row of agents takes ~28px each instead of full word width),
@ -2358,11 +2263,12 @@ window.marked = marked;
function renderSchedulesList() {
const liveRoot = $('schedules-section');
if (!liveRoot) return;
// #564: capture any mid-typed inline-create state BEFORE
// paintAtomic blows the row away so a refresh doesn't yank the
// operator's half-filled form. The carry is replayed by
// `renderInlineCreateRow` below.
readNewScheduleCarryFromDOM(liveRoot.querySelector('.schedules-table-create-row'));
paintAtomic(liveRoot, (root) => {
if (!schedulesState.length) {
root.append(el('p', { class: 'empty' }, 'no schedules queued'));
return;
}
const agents = schedulesTableAgentSet();
const table = el('table', { class: 'schedules-table' });
table.append(renderSchedulesTableHead(agents));
@ -2379,10 +2285,192 @@ window.marked = marked;
tbody.append(renderScheduleEditRow(s, agents));
}
}
// #564: always-visible inline create row at the bottom of the
// table — fill cells + click to POST. Folds the old
// `#schedule-new-section` form into the same surface as the
// schedules list so creation and display share one mental model.
tbody.append(renderInlineCreateRow(agents));
table.append(tbody);
root.append(table);
});
}
// #564: inline create row. Each column carries an input matching
// its display semantics (datetime-local for next-fire, mini d/h/m/s
// number inputs for every, textarea for body, checkbox per agent
// column for targets). Submitting POSTs `/api/schedules` and clears
// the carry on success; the next `refreshSchedules` redraws the
// table with the new schedule above.
function renderInlineCreateRow(agents) {
const tr = el('tr', { class: 'schedules-table-create-row' });
tr.append(el('td', { class: 'meta schedules-table-id' }, 'new'));
tr.append(el('td', { class: 'meta' }, '—'));
const nextInput = el('input', {
type: 'datetime-local',
name: 'new_first_fire',
class: 'schedules-table-inline-input schedules-table-inline-datetime',
required: 'required',
title: 'first fire time (defaults to 5 minutes from now)',
});
// Default: 5 minutes from now so a stale-clock or quick-submit
// accident doesn't fire immediately on `now()`.
nextInput.value = newScheduleCarry.first_fire
|| isoForDatetimeLocal(new Date(Date.now() + 5 * 60 * 1000));
tr.append(el('td', { class: 'schedules-table-create-cell' }, nextInput));
const intervalRow = el('div', {
class: 'schedules-table-inline-interval',
title: 'recurring every D days H hours M minutes S seconds (all blank / zero = one-shot)',
});
const mkUnit = (suffix, unit) => {
const inp = el('input', {
type: 'number',
name: 'new_interval_' + suffix,
min: '0',
step: '1',
placeholder: '0',
class: 'schedules-table-inline-num',
'aria-label': 'interval ' + suffix,
});
inp.value = newScheduleCarry['interval_' + suffix] || '';
intervalRow.append(inp, el('span', { class: 'schedules-table-inline-unit' }, unit));
};
mkUnit('d', 'd');
mkUnit('h', 'h');
mkUnit('m', 'm');
mkUnit('s', 's');
tr.append(el('td', { class: 'schedules-table-create-cell' }, intervalRow));
tr.append(el('td', { class: 'meta' }, 'operator'));
const bodyTa = el('textarea', {
name: 'new_body',
rows: '1',
required: 'required',
placeholder: 'prompt body (required, multi-line ok)',
class: 'schedules-table-inline-textarea',
});
bodyTa.value = newScheduleCarry.body;
const descInput = el('input', {
type: 'text',
name: 'new_description',
placeholder: 'description (optional)',
class: 'schedules-table-inline-input schedules-table-inline-desc',
});
descInput.value = newScheduleCarry.description;
tr.append(el('td', { class: 'schedules-table-create-cell schedules-table-create-body' },
bodyTa, descInput));
// Per-agent target checkboxes — one cell per agent column. Wrap
// each checkbox in a label so the whole cell area is clickable.
for (const a of agents) {
const td = el('td', { class: 'schedules-table-check schedules-table-create-cell' });
const id_ = 'new-target-' + a;
const cb = el('input', {
type: 'checkbox',
name: 'new_targets',
value: a,
id: id_,
class: 'schedules-table-inline-check',
});
if (newScheduleCarry.targets.has(a)) cb.checked = true;
const lbl = el('label', {
for: id_,
class: 'schedules-table-inline-check-lbl',
title: 'tick to target ' + a,
}, cb);
td.append(lbl);
tr.append(td);
}
// Actions cell — submit button + reset.
const actionsCell = el('td', { class: 'schedules-table-actions' });
const submitBtn = el('button', {
type: 'button',
class: 'btn btn-spawn btn-inline-small schedules-table-create-submit',
title: 'queue this new schedule',
}, '');
submitBtn.addEventListener('click', () => submitNewScheduleInline(tr, submitBtn));
const resetBtn = el('button', {
type: 'button',
class: 'btn btn-inline-small schedules-table-create-reset',
title: 'clear all fields',
}, '⌫');
resetBtn.addEventListener('click', () => {
resetNewScheduleCarry();
renderSchedulesList();
});
actionsCell.append(submitBtn, resetBtn);
tr.append(actionsCell);
return tr;
}
async function submitNewScheduleInline(tr, submitBtn) {
const targets = Array.from(tr.querySelectorAll('input[name="new_targets"]:checked'))
.map((i) => i.value);
const body = String(tr.querySelector('textarea[name="new_body"]')?.value || '').trim();
const description = String(tr.querySelector('input[name="new_description"]')?.value || '').trim();
const firstFireStr = String(tr.querySelector('input[name="new_first_fire"]')?.value || '');
if (!targets.length) {
alert('schedule must have at least one target — tick at least one agent column');
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);
// Parse the d/h/m/s parts inline — we don't use FormData since
// the inline row isn't wrapped in a <form>. Mirrors
// `intervalSecondsFromFormData` semantics: blank/0 = one-shot,
// anything non-integer or negative → NaN → alert.
const part = (suffix, mult) => {
const raw = String(tr.querySelector(`input[name="new_interval_${suffix}"]`)?.value || '').trim();
if (!raw) return 0;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) return NaN;
return n * mult;
};
const intervalTotal = part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1);
if (Number.isNaN(intervalTotal)) {
alert('interval fields must be non-negative integers (or blank for one-shot)');
return;
}
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
const payload = { targets, body, first_fire_at_unix };
if (interval_seconds != null) payload.interval_seconds = interval_seconds;
if (description) payload.description = description;
const originalLabel = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner">◐</span>';
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;
}
// Reset carry so the next render shows an empty row.
resetNewScheduleCarry();
await refreshSchedules();
} catch (err) {
alert('schedule submit failed: ' + err);
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalLabel;
}
}
// The set of agent columns in the schedules table: operator + manager
// first, then live containers (sorted), then any extra names that
// appear as a schedule target but aren't in the live container list
@ -2874,7 +2962,6 @@ 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