dashboard: inline edit form on schedule rows (closes #474)

Per damocles's PATCH /api/schedules/{id} backend (#475), each
non-cancelled schedule row gets a `✎ edit` button that toggles
an inline form pre-filled with current values. Editable:

- body (textarea)
- description (blank to clear)
- next-fire (datetime-local)
- interval (shared composer from #466 — all-zero flips to one-shot)

Targets stay immutable per the design call with damocles:
per-target last-result history is keyed on them; cancel + new
schedule is the documented retarget workaround. The form
surfaces the active target list as a read-only note explaining
this.

Submit semantics — the form computes a PATCH diff against the
original schedule and only includes keys for fields that
actually changed. Blank description → `null` (clear), all-zero
interval on a recurring schedule → `null` (flip to one-shot).
No-op submit (no fields changed) just closes the edit form.

Refactored the interval composer (#466) into a shared
`buildIntervalComposer({ label, namePrefix, initialSeconds })`
helper so the new-schedule and edit-schedule forms use the same
chip/d/h/m/s/preview widget. New-schedule form behavior
unchanged; edit-form input names are prefixed `edit_interval_`
to avoid FormData collisions when both happen to mount.

State survives state-poll re-renders via `editingSchedules`
(Set of ids being edited) + `scheduleEditCarry` (per-id
mid-edit values) — same pattern the new-schedule form uses
with `readScheduleFormCarry`.
This commit is contained in:
iris 2026-05-26 15:09:46 +02:00
commit dd92ef07fa
3 changed files with 356 additions and 95 deletions

View file

@ -227,10 +227,14 @@ they share enough conceptual ground to live together).
**N3W SCH3DUL3 / QU3U3D SCH3DUL3S** — operator-managed
scheduled prompts (#444 / #459). Lists every schedule with
its description, targets, body, recurrence interval, next-fire
time, and per-target last-result; a `CANC3L` button cancels
the whole schedule (`POST /api/schedules/{id}/cancel`), and
individual target chips have their own cancel links. An inline
creation form lets the operator queue a new schedule directly:
time, and per-target last-result. Per-row controls: an
`✎ edit` button opens an inline edit form (#474 — body /
description / interval / next-fire editable, targets stay
immutable; submit PATCHes `/api/schedules/{id}`), and a
`CANC3L` button cancels the whole schedule
(`POST /api/schedules/{id}/cancel`). Individual target chips
have their own cancel links. An inline creation form lets
the operator queue a new schedule directly:
targets (multi-select checkboxes drawn from live container
names + `operator` + `manager`), prompt body (textarea),
first-fire datetime-local (pre-filled to 5 minutes from now),
@ -485,6 +489,14 @@ not ours.
`{ targets, body, first_fire_at_unix, interval_seconds?, description? }`.
Agent-initiated schedules go through the approval queue instead
(manager MCP `request_schedule_prompt`).
- `PATCH /api/schedules/{id}` — partial edit (#474). JSON body
`{ body?, description?, interval_seconds?, next_fire_at_unix? }`.
Missing key = "leave alone"; explicit `null` on
`description` / `interval_seconds` clears the field (so a
recurring schedule flips to one-shot when `interval_seconds`
is sent as `null`). Targets stay immutable — cancel + new
schedule is the retarget workaround. Refuses cancelled rows;
returns the updated `WireSchedule` on success.
- `POST /api/schedules/{id}/cancel` — cancel a schedule. Body
`{ targets?: ["name", …] }` cancels just those recipients;
absent or empty body cancels the whole schedule.

View file

@ -1926,6 +1926,13 @@ window.marked = marked;
// Local cache lets `refreshTabCounts` show the active count without
// re-fetching every second.
let schedulesState = [];
// #474 — schedule ids whose inline edit form is currently open.
// The set survives across refreshSchedules() calls so a state
// poll doesn't yank the form out from under the operator. Per-id
// mid-edit carry sits in `scheduleEditCarry` so unsaved typing
// also rides the refresh.
const editingSchedules = new Set();
const scheduleEditCarry = new Map();
async function refreshSchedules() {
const listRoot = $('schedules-section');
if (!listRoot) return;
@ -1955,6 +1962,106 @@ window.marked = marked;
}
return n;
}
// Interval composer (#466) — shared between the new-schedule form
// and the edit-schedule form (#474). Builds the preset chip row +
// d/h/m/s sub-fields + live preview, and returns helpers to read
// and write the value.
//
// The composer carries no opinion on what "0 / blank" means at the
// semantic layer: it just reports the integer total. Callers decide
// whether to treat 0 as `null` (one-shot) for their own submit path.
function buildIntervalComposer({
label = 'interval (blank / all-zero = one-shot)',
namePrefix = 'interval_',
initialSeconds = 0,
} = {}) {
const wrapper = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, label));
const presets = [
['1m', 60], ['5m', 300], ['15m', 900], ['30m', 1800],
['1h', 3600], ['6h', 21600], ['12h', 43200],
['1d', 86400], ['7d', 604800],
];
const presetsRow = el('div', { class: 'schedule-interval-presets' });
for (const [presetLabel, secs] of presets) {
presetsRow.append(el('button', {
type: 'button',
class: 'btn btn-interval-preset',
'data-secs': String(secs),
}, presetLabel));
}
presetsRow.append(el('button', {
type: 'button',
class: 'btn btn-interval-preset btn-interval-oneshot',
'data-secs': '0',
}, 'one-shot'));
wrapper.append(presetsRow);
const partsRow = el('div', { class: 'schedule-interval-parts' });
const mkPart = (suffix, unit) => {
const inp = el('input', {
type: 'number', name: namePrefix + suffix, min: '0', step: '1', placeholder: '0',
class: 'schedule-interval-num',
});
partsRow.append(inp, el('span', { class: 'schedule-interval-unit' }, unit));
return inp;
};
const dInp = mkPart('d', 'd');
const hInp = mkPart('h', 'h');
const mInp = mkPart('m', 'm');
const sInp = mkPart('s', 's');
wrapper.append(partsRow);
const preview = el('div', { class: 'schedule-interval-preview', 'aria-live': 'polite' });
wrapper.append(preview);
function getSeconds() {
const n = (v) => {
const x = parseInt(v, 10);
return Number.isFinite(x) && x > 0 ? x : 0;
};
return n(dInp.value) * 86400 + n(hInp.value) * 3600
+ n(mInp.value) * 60 + n(sInp.value);
}
function fillFromSeconds(total) {
const t = Math.max(0, Math.floor(total));
const d = Math.floor(t / 86400);
const h = Math.floor((t % 86400) / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
dInp.value = d > 0 ? String(d) : '';
hInp.value = h > 0 ? String(h) : '';
mInp.value = m > 0 ? String(m) : '';
sInp.value = s > 0 ? String(s) : '';
}
function setParts({ d, h, m, s }) {
if (d !== undefined) dInp.value = d;
if (h !== undefined) hInp.value = h;
if (m !== undefined) mInp.value = m;
if (s !== undefined) sInp.value = s;
}
function updatePreview() {
const total = getSeconds();
preview.textContent = total > 0
? '↻ every ' + fmtDuration(total)
: 'one-shot (fires once at first-fire time)';
preview.classList.toggle('schedule-interval-preview-oneshot', total === 0);
}
for (const inp of [dInp, hInp, mInp, sInp]) {
inp.addEventListener('input', updatePreview);
}
for (const b of presetsRow.querySelectorAll('button[data-secs]')) {
b.addEventListener('click', () => {
fillFromSeconds(parseInt(b.getAttribute('data-secs'), 10) || 0);
updatePreview();
});
}
if (initialSeconds > 0) fillFromSeconds(initialSeconds);
updatePreview();
return { wrapper, getSeconds, fillFromSeconds, setParts, updatePreview };
}
function renderScheduleNewForm() {
const root = $('schedule-new-section');
if (!root) return;
@ -2019,104 +2126,29 @@ window.marked = marked;
firstFireLabel.append(firstFireInput);
form_.append(firstFireLabel);
// Interval composer (#466). Replaces the raw seconds input with a
// preset chip row + d/h/m/s sub-fields + a live preview, so the
// operator never has to multiply hours-to-seconds by hand. Total
// of zero (all fields blank or 0) means one-shot — same semantic
// the backend already expects when interval_seconds is null.
const intervalLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'interval (blank / all-zero = one-shot)'));
const presets = [
['1m', 60], ['5m', 300], ['15m', 900], ['30m', 1800],
['1h', 3600], ['6h', 21600], ['12h', 43200],
['1d', 86400], ['7d', 604800],
];
const presetsRow = el('div', { class: 'schedule-interval-presets' });
for (const [label, secs] of presets) {
const b = el('button', {
type: 'button',
class: 'btn btn-interval-preset',
'data-secs': String(secs),
}, label);
presetsRow.append(b);
}
const oneShotBtn = el('button', {
type: 'button',
class: 'btn btn-interval-preset btn-interval-oneshot',
'data-secs': '0',
}, 'one-shot');
presetsRow.append(oneShotBtn);
intervalLabel.append(presetsRow);
const partsRow = el('div', { class: 'schedule-interval-parts' });
const mkPart = (name, unit) => {
const inp = el('input', {
type: 'number', name, min: '0', step: '1', placeholder: '0',
class: 'schedule-interval-num',
});
partsRow.append(inp, el('span', { class: 'schedule-interval-unit' }, unit));
return inp;
};
const dInp = mkPart('interval_d', 'd');
const hInp = mkPart('interval_h', 'h');
const mInp = mkPart('interval_m', 'm');
const sInp = mkPart('interval_s', 's');
intervalLabel.append(partsRow);
const preview = el('div', { class: 'schedule-interval-preview', 'aria-live': 'polite' });
intervalLabel.append(preview);
form_.append(intervalLabel);
function readPartsSecs() {
const n = (v) => {
const x = parseInt(v, 10);
return Number.isFinite(x) && x > 0 ? x : 0;
};
return n(dInp.value) * 86400 + n(hInp.value) * 3600
+ n(mInp.value) * 60 + n(sInp.value);
}
function fillParts(total) {
const t = Math.max(0, Math.floor(total));
const d = Math.floor(t / 86400);
const h = Math.floor((t % 86400) / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
dInp.value = d > 0 ? String(d) : '';
hInp.value = h > 0 ? String(h) : '';
mInp.value = m > 0 ? String(m) : '';
sInp.value = s > 0 ? String(s) : '';
}
function updatePreview() {
const total = readPartsSecs();
preview.textContent = total > 0
? '↻ every ' + fmtDuration(total)
: 'one-shot (fires once at first-fire time)';
preview.classList.toggle('schedule-interval-preview-oneshot', total === 0);
}
for (const inp of [dInp, hInp, mInp, sInp]) {
inp.addEventListener('input', updatePreview);
}
for (const b of presetsRow.querySelectorAll('button[data-secs]')) {
b.addEventListener('click', () => {
fillParts(parseInt(b.getAttribute('data-secs'), 10) || 0);
updatePreview();
});
}
// 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) {
dInp.value = carry.interval_d;
hInp.value = carry.interval_h;
mInp.value = carry.interval_m;
sInp.value = 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) fillParts(legacy);
if (Number.isFinite(legacy) && legacy > 0) intervalCx.fillFromSeconds(legacy);
}
updatePreview();
intervalCx.updatePreview();
const descLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'description (optional)'));
@ -2313,14 +2345,212 @@ window.marked = marked;
if (!cancelled) {
const actions = el('div', { class: 'schedule-actions' });
const editBtn = el('button', { type: 'button', class: 'btn btn-edit-schedule' },
editingSchedules.has(s.id) ? '✎ close edit' : '✎ edit');
editBtn.title = 'edit body / description / interval / next-fire (targets stay immutable)';
editBtn.addEventListener('click', () => {
if (editingSchedules.has(s.id)) {
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
} else {
editingSchedules.add(s.id);
}
renderSchedulesList();
});
actions.append(editBtn);
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);
if (editingSchedules.has(s.id)) {
li.append(renderScheduleEditForm(s));
}
}
return li;
}
// #474 — inline edit form. Renders inside the schedule row when the
// row's `✎ edit` button is toggled on. Pre-filled with current
// values; submit PATCHes /api/schedules/{id}. Targets stay
// immutable (per damocles's backend; the workaround for retargeting
// is cancel + new schedule). Mid-edit field values survive a
// state-poll refresh via `scheduleEditCarry`.
function renderScheduleEditForm(s) {
const wrapper = el('div', { class: 'schedule-edit-form-wrapper' });
const form_ = el('form', { class: 'schedule-edit-form' });
form_.addEventListener('submit', (e) => {
e.preventDefault();
submitEditSchedule(s, form_);
});
const carry = scheduleEditCarry.get(s.id) || {};
const bodyLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'body'));
const bodyInput = el('textarea', { name: 'body', rows: '4', required: 'required' });
bodyInput.value = carry.body !== undefined ? carry.body : s.body;
bodyLabel.append(bodyInput);
form_.append(bodyLabel);
const descLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'description (blank to clear)'));
const descInput = el('input', { type: 'text', name: 'description' });
descInput.value = carry.description !== undefined
? carry.description
: (s.description || '');
descLabel.append(descInput);
form_.append(descLabel);
const firstFireLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'next fire'));
const firstFireInput = el('input', {
type: 'datetime-local', name: 'next_fire', required: 'required',
});
firstFireInput.value = carry.next_fire !== undefined
? carry.next_fire
: isoForDatetimeLocal(new Date(s.next_fire_at_unix * 1000));
firstFireLabel.append(firstFireInput);
form_.append(firstFireLabel);
const intervalCx = buildIntervalComposer({
label: 'interval (blank / all-zero = flip to one-shot)',
namePrefix: 'edit_interval_',
initialSeconds: 0,
});
form_.append(intervalCx.wrapper);
if (carry.interval_d !== undefined || carry.interval_h !== undefined
|| carry.interval_m !== undefined || carry.interval_s !== undefined) {
intervalCx.setParts({
d: carry.interval_d || '',
h: carry.interval_h || '',
m: carry.interval_m || '',
s: carry.interval_s || '',
});
} else if (s.interval_seconds) {
intervalCx.fillFromSeconds(s.interval_seconds);
}
intervalCx.updatePreview();
// Persist carry on every input so a refresh repaint preserves it.
form_.addEventListener('input', () => {
const fd = new FormData(form_);
scheduleEditCarry.set(s.id, {
body: String(fd.get('body') || ''),
description: String(fd.get('description') || ''),
next_fire: String(fd.get('next_fire') || ''),
interval_d: String(fd.get('edit_interval_d') || ''),
interval_h: String(fd.get('edit_interval_h') || ''),
interval_m: String(fd.get('edit_interval_m') || ''),
interval_s: String(fd.get('edit_interval_s') || ''),
});
});
// Targets read-only callout. Per #474 design: targets stay
// immutable, cancel+new is the retarget workaround.
const targetList = (s.targets || [])
.filter((t) => !t.cancelled_at_unix)
.map((t) => t.target)
.join(', ');
form_.append(el('p', { class: 'meta schedule-edit-targets-note' },
'targets (' + (targetList || 'none active') + ') are immutable — '
+ 'cancel + new schedule if you need a different recipient set.'));
const actions = el('div', { class: 'schedule-actions' });
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '✓ save changes');
const cancelEdit = el('button', { type: 'button', class: 'btn' }, 'cancel');
cancelEdit.addEventListener('click', () => {
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
renderSchedulesList();
});
actions.append(submit, cancelEdit);
form_.append(actions);
wrapper.append(form_);
return wrapper;
}
async function submitEditSchedule(originalSchedule, form_) {
const s = originalSchedule;
const fd = new FormData(form_);
const newBody = String(fd.get('body') || '').trim();
const newDescription = String(fd.get('description') || '').trim();
const newNextFireStr = String(fd.get('next_fire') || '');
if (!newBody) { alert('body must be non-empty'); return; }
if (!newNextFireStr) { alert('next-fire timestamp is required'); return; }
const newNextFireDate = new Date(newNextFireStr);
if (Number.isNaN(newNextFireDate.getTime())) {
alert('next-fire is not a valid datetime'); return;
}
const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000);
// Compute interval total from the d/h/m/s fields (composer uses
// `edit_interval_*` names in this form).
const partSecs = (name, mult) => {
const raw = String(fd.get(name) || '').trim();
if (!raw) return 0;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) return NaN;
return n * mult;
};
const partD = partSecs('edit_interval_d', 86400);
const partH = partSecs('edit_interval_h', 3600);
const partM = partSecs('edit_interval_m', 60);
const partS = partSecs('edit_interval_s', 1);
if ([partD, partH, partM, partS].some((v) => Number.isNaN(v))) {
alert('interval fields must be non-negative integers');
return;
}
const intervalTotal = partD + partH + partM + partS;
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
// Build the PATCH body. Only include keys for fields that
// actually changed; explicit `null` clears description / flips
// recurring→one-shot.
const patch = {};
if (newBody !== s.body) patch.body = newBody;
if (newDescription !== (s.description || '')) {
patch.description = newDescription || null;
}
if (newNextFireUnix !== s.next_fire_at_unix) {
patch.next_fire_at_unix = newNextFireUnix;
}
if (newIntervalSeconds !== (s.interval_seconds || null)) {
patch.interval_seconds = newIntervalSeconds;
}
if (!Object.keys(patch).length) {
// No-op submit. Treat as "close edit form".
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
renderSchedulesList();
return;
}
const submitBtn = form_.querySelector('button[type="submit"]');
const originalLabel = submitBtn ? submitBtn.textContent : '';
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'saving…'; }
try {
const resp = await fetch('/api/schedules/' + encodeURIComponent(s.id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('edit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
return;
}
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
await refreshSchedules();
} catch (err) {
alert('edit failed: ' + err);
} finally {
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; }
}
}
async function cancelScheduleAll(id) {
if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return;
await postScheduleCancel(id, null);

View file

@ -850,6 +850,10 @@ ul form.inline { display: inline-block; }
.btn-start { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
.btn-spawn { color: var(--amber); border-color: var(--amber); }
/* #474: inline edit button on each schedule row. Yellow reads as a
parallel destructive-adjacent action (edit changes state, but
isn't deletion). */
.btn-edit-schedule { color: var(--yellow, #f9e2af); border-color: var(--yellow, #f9e2af); }
.spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; }
.spawnform input {
font-family: inherit;
@ -1604,7 +1608,8 @@ 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-new-form,
.schedule-edit-form {
display: flex;
flex-direction: column;
gap: 0.6em;
@ -1614,6 +1619,20 @@ body.flow-shell .tabbar .tab.active.tab-link {
padding: 0.8em 1em;
margin-bottom: 0.5em;
}
/* #474 inline edit form opens directly under the schedule row's
actions strip, indented slightly so it visually nests under the
row it edits. */
.schedule-edit-form-wrapper {
margin-top: 0.5em;
padding-left: 0.5em;
border-left: 2px solid var(--yellow, #f9e2af);
}
.schedule-edit-targets-note {
color: var(--muted);
font-style: italic;
font-size: 0.85em;
margin: 0.2em 0 0 0;
}
.schedule-field {
display: flex;
flex-direction: column;