diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 0bc94eab..08150b4a 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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 diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index a14e24e4..fdd9a62b 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -133,26 +133,21 @@ - +
-

◆ N3W SCH3DUL3 ◆

+

◆ SCH3DUL3S ◆

══════════════════════════════════════════════════════════════
-

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.

+

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

loading…

diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 1903ab77..a31d28af 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -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) { // `` 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 = ' 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
. 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 = ''; + 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', ]; //
sections that should survive a refresh need a stable