From dd92ef07fabccfbb68c868ce53e03e3071992305 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 15:09:46 +0200 Subject: [PATCH] dashboard: inline edit form on schedule rows (closes #474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- docs/web-ui.md | 20 +- frontend/packages/dashboard/src/app.js | 410 ++++++++++++++---- frontend/packages/dashboard/src/dashboard.css | 21 +- 3 files changed, 356 insertions(+), 95 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index 957de0ff..76853b0c 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -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. diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index cd1ed70a..48c78aa5 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -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); diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 7a0fadfc..117ed785 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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;