diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 0c7fa53c..aa217993 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -14,6 +14,7 @@ domain, covers host-level detail for one hive. | `/agents//terminal` | one agent's live terminal | | `/agents//subagents//terminal` | one of that agent's subagents' live terminal | | `/jobs` | the controller's job graph — where agent creation and credential mints show progress | +| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/issues` | a cross-repo issue report | Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). @@ -32,6 +33,26 @@ agent spawns shows up once it writes output. A preview shows rows published while it's open. A subagent terminal has no turn-state badges. +### Scheduled prompts + +`/schedules` has one card per scheduled prompt. The **name** is the card's +title, with the **agents** it fires for, the message, how often it repeats and +when it fires next. **edit** turns the card into its own form; **pause** and +**resume** stop and restart it; **remove** deletes it after a confirmation. +**+ schedule** opens an empty form above the list, and the filters narrow the +list by name, agent or state (`active`, `paused`, or `done` for a one-shot that +has fired). + +A schedule repeats **every** some number of seconds, minutes, hours or days, or +fires **once**. Its start time is the moment a one-shot fires, and the origin of +the fire times of a repeating one. The form previews the next three fire times. +Changing the start or the repeat moves them; editing only the name, message or +agents does not, and the form warns when an edit would. + +Only an operator can change a schedule here or with `swarmctl schedule`; an +agent has no way to add one. The page needs the controller's `/api/schedules` +routes. + ### Sending a message to an agent Under an agent's terminal, in the preview and in its own tab, is a **message** diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 9df8630d..bd20222d 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -8,6 +8,7 @@ import { ComponentsPage } from "./pages/ComponentsPage.js"; import { JobsPage } from "./pages/JobsPage.js"; import { HivesPage } from "./pages/HivesPage.js"; import { IssueReportPage } from "./pages/IssueReportPage.js"; +import { SchedulesPage } from "./pages/schedules/SchedulesPage.js"; import { Panel } from "./ui/panel/Panel.js"; function NotFound() { @@ -30,6 +31,7 @@ export function App() { component={AgentTerminalPage} /> + diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index a83a5dd1..4f5e520b 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -22,6 +22,7 @@ import { import { SplitView } from "../ui/split-view/SplitView.js"; import { Table, type TableColumn } from "../ui/table/Table.js"; import { TextField } from "../ui/text-field/TextField.js"; +import { TextAreaField } from "../ui/text-area-field/TextAreaField.js"; import { SelectField, type SelectOption, @@ -114,6 +115,18 @@ const MODEL_OPTIONS: DropdownOption[] = [ { value: "opus", label: "opus", description: "powerful" }, ]; +function TextAreaFieldSample() { + const [value, setValue] = useState(""); + return ( + + ); +} + // Controlled samples need their own state to actually type/select into — // module-level consts can't do that, hence these two small wrappers // rather than inline JSX in the page body below. @@ -465,6 +478,12 @@ export function ComponentsPage() { +
+ + + +
+
diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.css b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.css new file mode 100644 index 00000000..9d819fdb --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.css @@ -0,0 +1,36 @@ +/* content layout — the bordered container is `Card`'s. */ +.ui-schedule-card { + gap: 0.5em; +} +.ui-schedule-card-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5em; +} +.ui-schedule-card-name { + margin: 0; + font-size: 1.1em; + /* Takes the leftover width so the badges stay on the title's line when it + is short and wrap under it when it is long. */ + margin-right: auto; + overflow-wrap: anywhere; +} +.ui-schedule-card-agents, +.ui-schedule-card-next { + color: var(--muted); + overflow-wrap: anywhere; +} +/* The prompt text keeps its own line breaks; long unbroken tokens wrap + rather than widening the card. */ +.ui-schedule-card-body { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.ui-schedule-card-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + justify-content: flex-end; +} diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx new file mode 100644 index 00000000..00748032 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx @@ -0,0 +1,90 @@ +// — one scheduled prompt as a card: its name as the title, +// "agents: foo, bar" beneath, then the message, the cadence and when it +// fires next. Edit, pause/resume and remove sit at the foot; edit swaps the +// card for `ScheduleForm` (the page owns that swap). +import { Badge, type BadgeTone } from "@hive/shared/badge.js"; +import { Button } from "../../ui/button/Button.js"; +import { Card } from "../../ui/card/Card.js"; +import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; +import { + fmtInterval, + fmtLocal, + scheduleState, + type Schedule, + type ScheduleState, +} from "./ScheduleTypes.js"; +import "./ScheduleCard.css"; + +const STATE_TONE: Record = { + active: "positive", + paused: "warning", + done: "neutral", +}; + +export function ScheduleCard({ + schedule, + pending, + onEdit, + onTogglePaused, + onRemove, +}: { + schedule: Schedule; + /** A pause/resume or remove for this schedule is in flight. */ + pending: boolean; + onEdit: () => void; + onTogglePaused: () => void; + onRemove: () => void; +}) { + const state = scheduleState(schedule); + return ( + +
+

{schedule.name}

+ + +
+
+ agents: {schedule.agents.join(", ")} +
+

{schedule.body}

+
+ {schedule.next_fire_unix !== null ? ( + <> + next fire:{" "} + + + + + ) : state === "paused" ? ( + "paused, will not fire" + ) : ( + <> + fired{" "} + + + + + )} +
+
+ + {state !== "done" ? ( + + ) : null} + +
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.css b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.css new file mode 100644 index 00000000..849559dc --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.css @@ -0,0 +1,46 @@ +/* The form fills its card; fields stack with the page's usual gap. The + cadence controls sit in one wrapping row so "repeats / every / unit / + starts at" read as one sentence on a wide card and stack on a narrow one. */ +.ui-schedule-form { + display: flex; + flex-direction: column; + gap: 0.9em; +} +.ui-schedule-form-agents { + display: flex; + flex-direction: column; + gap: 0.3em; +} +/* The picker's own list is a scrolling column sized for a filter popover; in + a form the agents read better as a wrapping row, all visible at once. */ +.ui-schedule-form-agents .ui-multiselect-filter-options { + flex-direction: row; + flex-wrap: wrap; + gap: 0.3em 1.2em; + max-height: none; + overflow-y: visible; +} +.ui-schedule-form-cadence { + display: flex; + flex-wrap: wrap; + gap: 0.75em; +} +.ui-schedule-form-cadence > .ui-form-field { + width: auto; + flex: 1 1 9em; +} +.ui-schedule-form-hint { + margin: 0; + font-size: 0.85em; + color: var(--muted); +} +.ui-schedule-form-warn { + margin: 0; + font-size: 0.85em; + color: var(--yellow); +} +.ui-schedule-form-actions { + display: flex; + gap: 0.5em; + justify-content: flex-end; +} diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx new file mode 100644 index 00000000..e96c5279 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx @@ -0,0 +1,294 @@ +// — create or edit one scheduled prompt, rendered as a card +// so an edited schedule's card turns into its own form in place (mara: "the +// edit button changes the card to be the edit form"). With `schedule` it +// edits and sends a PATCH carrying only what changed; without, it POSTs a +// new one. +// +// Cadence is either "once" or "every N unit", plus a start time: the fire +// time of a one-shot, the origin of the fire grid of a repeating schedule. +// Changing the start or the interval moves that grid; the name, message and +// agents never do, so the form says which of the two an edit is. +import { useState } from "preact/hooks"; +import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; +import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; +import { Button } from "../../ui/button/Button.js"; +import { Card } from "../../ui/card/Card.js"; +import { + MultiselectFilterOptions, + type MultiselectFilterState, +} from "../../ui/multiselect-filter/MultiselectFilter.js"; +import { SelectField } from "../../ui/select-field/SelectField.js"; +import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js"; +import { TextField } from "../../ui/text-field/TextField.js"; +import { + UNIT_SECONDS, + fmtLocal, + fromLocalInput, + splitInterval, + toLocalInput, + upcomingFires, + type IntervalUnit, + type Schedule, +} from "./ScheduleTypes.js"; +import "./ScheduleForm.css"; + +const UNIT_OPTIONS = [ + { value: "s", label: "seconds" }, + { value: "m", label: "minutes" }, + { value: "h", label: "hours" }, + { value: "d", label: "days" }, +]; + +const PREVIEW_COUNT = 3; + +// The current time rounded up to the next whole minute: what a new schedule +// starts at unless the operator picks another time. Rounded because the +// time input has minute resolution. +function nextMinuteUnix(): number { + return Math.ceil(Date.now() / 60_000) * 60; +} + +export function ScheduleForm({ + schedule, + agentOptions, + onSaved, + onCancel, +}: { + /** The schedule being edited; absent for a new one. */ + schedule?: Schedule; + /** Every agent a schedule can target. */ + agentOptions: string[]; + onSaved: () => void; + onCancel: () => void; +}) { + const initialInterval = + schedule?.interval_seconds != null + ? splitInterval(schedule.interval_seconds) + : { amount: 1, unit: "h" as IntervalUnit }; + const initialStart = toLocalInput(schedule?.anchor_unix ?? nextMinuteUnix()); + + const [name, setName] = useState(schedule?.name ?? ""); + const [body, setBody] = useState(schedule?.body ?? ""); + const [agents, setAgents] = useState(schedule?.agents ?? []); + const [repeat, setRepeat] = useState( + schedule ? schedule.interval_seconds !== null : true, + ); + const [amount, setAmount] = useState(String(initialInterval.amount)); + const [unit, setUnit] = useState(initialInterval.unit); + const [start, setStart] = useState(initialStart); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const idPrefix = `schedule-${schedule?.id ?? "new"}`; + const amountNum = Number(amount); + const intervalSeconds = + repeat && Number.isInteger(amountNum) && amountNum > 0 + ? amountNum * UNIT_SECONDS[unit] + : null; + const startUnix = fromLocalInput(start); + const cadenceValid = startUnix !== null && (!repeat || intervalSeconds !== null); + + const picked: MultiselectFilterState = { values: agents, negate: false }; + const toggleAgent = (a: string) => + setAgents((cur) => + cur.includes(a) ? cur.filter((x) => x !== a) : [...cur, a], + ); + + // The grid moves when the start or the repeat interval differs from what + // the schedule has now; only meaningful when editing. The start compares + // as the text the input shows, not as seconds: the input has minute + // resolution, so a schedule created off a whole minute would otherwise + // always look edited. + const startChanged = start !== initialStart; + const cadenceChanged = + schedule !== undefined && + (startChanged || + (repeat ? intervalSeconds : null) !== schedule.interval_seconds); + + const fires = + cadenceValid && startUnix !== null + ? upcomingFires( + startUnix, + repeat ? intervalSeconds : null, + Math.floor(Date.now() / 1000), + PREVIEW_COUNT, + ) + : []; + + async function submit(e: Event) { + e.preventDefault(); + if (!cadenceValid || startUnix === null) return; + setSaving(true); + setError(null); + try { + const cadence = repeat ? intervalSeconds : null; + const patch = schedule + ? patchBody(schedule, { + name, + body, + agents, + startUnix: startChanged ? startUnix : null, + cadence, + }) + : null; + // Nothing differs: there is nothing to send. + if (patch && Object.keys(patch).length === 0) { + onSaved(); + return; + } + const r = schedule + ? await fetch(`/api/schedules/${encodeURIComponent(schedule.id)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch), + }) + : await fetch("/api/schedules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name, + agents, + body, + anchor_unix: startUnix, + interval_seconds: cadence, + }), + }); + if (!r.ok) { + setError(await readApiError(r)); + return; + } + onSaved(); + } catch (err) { + setError({ detail: String(err) }); + } finally { + setSaving(false); + } + } + + return ( + +
+ +
+ agents + {agentOptions.length === 0 ? ( + no agents to pick from + ) : ( + + )} +
+ +
+ setRepeat(v === "every")} + options={[ + { value: "every", label: "every…" }, + { value: "once", label: "once" }, + ]} + /> + {repeat ? ( + <> + + setUnit(v as IntervalUnit)} + options={UNIT_OPTIONS} + /> + + ) : null} + +
+ {fires.length > 0 ? ( +

+ {repeat ? "next fires: " : "fires: "} + {fires.map(fmtLocal).join(" · ")} +

+ ) : cadenceValid ? ( +

that time has already passed

+ ) : null} + {cadenceChanged ? ( +

+ Changing the start or the repeat moves when this schedule fires. + Editing only the name, message or agents does not. +

+ ) : null} + {error ? ( + + ) : null} +
+ + +
+ +
+ ); +} + +// A PATCH body holding only the fields that differ from `schedule`, so an +// edit never rewrites what the operator did not touch (and a body-only edit +// cannot move the fire grid by resending the anchor). +function patchBody( + schedule: Schedule, + next: { + name: string; + body: string; + agents: string[]; + /** The new start, or null when the operator left it alone. */ + startUnix: number | null; + cadence: number | null; + }, +): Record { + const patch: Record = {}; + if (next.name !== schedule.name) patch.name = next.name; + if (next.body !== schedule.body) patch.body = next.body; + if (next.startUnix !== null) patch.anchor_unix = next.startUnix; + if (next.cadence !== schedule.interval_seconds) { + patch.interval_seconds = next.cadence; + } + const add = next.agents.filter((a) => !schedule.agents.includes(a)); + const remove = schedule.agents.filter((a) => !next.agents.includes(a)); + if (add.length > 0) patch.add_agents = add; + if (remove.length > 0) patch.remove_agents = remove; + return patch; +} diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleTypes.ts b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleTypes.ts new file mode 100644 index 00000000..d04eed50 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleTypes.ts @@ -0,0 +1,99 @@ +// Wire type and the small pure helpers the schedules page shares between +// its card, its form and the page itself. + +// One scheduled prompt as swarm-controller's `GET /api/schedules` sends it. +// The controller computes `next_fire_unix`: null while paused, and for a +// one-shot whose time has passed. `interval_seconds` is always present, +// null for a one-shot. +export interface Schedule { + id: string; + name: string; + agents: string[]; + body: string; + anchor_unix: number; + interval_seconds: number | null; + paused: boolean; + next_fire_unix: number | null; +} + +export type ScheduleState = "active" | "paused" | "done"; + +// A one-shot that has fired reads "done" rather than "active": nothing is +// left to wait for, but the card stays until the operator removes it. +export function scheduleState(s: Schedule): ScheduleState { + if (s.paused) return "paused"; + if (s.interval_seconds === null && s.next_fire_unix === null) return "done"; + return "active"; +} + +export type IntervalUnit = "s" | "m" | "h" | "d"; + +export const UNIT_SECONDS: Record = { + s: 1, + m: 60, + h: 3600, + d: 86400, +}; + +// "1d 2h", "15m", "90s": the non-zero parts, largest first. +export function fmtInterval(seconds: number): string { + const parts: string[] = []; + let rest = seconds; + for (const unit of ["d", "h", "m", "s"] as const) { + const n = Math.floor(rest / UNIT_SECONDS[unit]); + if (n > 0) parts.push(`${n}${unit}`); + rest -= n * UNIT_SECONDS[unit]; + } + return parts.join(" "); +} + +// Splits an interval into the form's amount + unit: the largest unit that +// divides it evenly, so "86400" reopens as "1 d" and "5400" as "90 m". +export function splitInterval(seconds: number): { + amount: number; + unit: IntervalUnit; +} { + for (const unit of ["d", "h", "m"] as const) { + if (seconds % UNIT_SECONDS[unit] === 0) { + return { amount: seconds / UNIT_SECONDS[unit], unit }; + } + } + return { amount: seconds, unit: "s" }; +} + +// The next `count` fire times after `nowUnix` on the grid `anchor + k * +// interval`; a one-shot has at most one. Only a preview for the form: the +// controller's own `next_fire_unix` is what a saved schedule shows. +export function upcomingFires( + anchorUnix: number, + intervalSeconds: number | null, + nowUnix: number, + count: number, +): number[] { + if (intervalSeconds === null) return anchorUnix > nowUnix ? [anchorUnix] : []; + const first = Math.max(0, Math.ceil((nowUnix - anchorUnix) / intervalSeconds)); + return Array.from( + { length: count }, + (_, i) => anchorUnix + (first + i) * intervalSeconds, + ); +} + +// `` reads and writes local wall-clock time +// with no zone, so converting either way goes through `Date`'s local fields. +export function toLocalInput(unix: number): string { + const d = new Date(unix * 1000); + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; +} + +export function fromLocalInput(value: string): number | null { + const ms = new Date(value).getTime(); + return Number.isNaN(ms) ? null : Math.floor(ms / 1000); +} + +export function fmtLocal(unix: number): string { + return new Date(unix * 1000).toLocaleString(undefined, { + dateStyle: "short", + timeStyle: "short", + }); +} diff --git a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx b/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx new file mode 100644 index 00000000..8637ba3a --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx @@ -0,0 +1,252 @@ +// — the swarm's scheduled prompts, one card per schedule. +// Backed by swarm-controller's `GET/POST /api/schedules` and +// `PATCH/DELETE /api/schedules/{id}`; the controller is the only writer of +// the schedule store and computes each schedule's `next_fire_unix`, so +// nothing here derives a fire time except the form's preview. Operator-only: +// the click is the approval, there is no approval queue behind it. +// +// Filtering reuses `FilterableView`'s card bar (name text, agent and state +// multiselects). A card being edited keeps its form state across the +// periodic refetch because `FilterableView` keys cards by schedule id and +// the open edit is held here by id, not by position. +import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; +import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; +import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; +import { Button } from "../../ui/button/Button.js"; +import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js"; +import { FilterableView } from "../../ui/filterable-view/FilterableView.js"; +import { Panel } from "../../ui/panel/Panel.js"; +import { + RefreshIntervalPicker, + useRefreshInterval, + type RefreshIntervalMs, +} from "../../ui/refresh-interval/RefreshInterval.js"; +import type { TableColumn } from "../../ui/table/Table.js"; +import type { AgentRow } from "../agents/AgentTypes.js"; +import { ScheduleCard } from "./ScheduleCard.js"; +import { ScheduleForm } from "./ScheduleForm.js"; +import { scheduleState, type Schedule } from "./ScheduleTypes.js"; + +// A refetch never touches an open form's own state (it lives in the form), +// so the usual 30s default is safe here too. +const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; + +const COLUMNS: TableColumn[] = [ + { + key: "name", + header: "name", + render: (s) => s.name, + sortBy: (s) => s.name, + filterValue: (s) => s.name, + }, + { + key: "agents", + header: "agents", + render: (s) => s.agents.join(", "), + filterValues: (s) => s.agents, + filterMode: "multiselect", + }, + { + key: "state", + header: "state", + render: (s) => scheduleState(s), + sortBy: (s) => scheduleState(s), + filterValues: (s) => [scheduleState(s)], + filterMode: "multiselect", + }, +]; + +// The agent names a schedule can target, from the same roster the agents +// page lists. A failed load leaves the picker empty rather than blocking +// the page: the cards themselves do not need it. +async function loadAgentNames(): Promise { + const r = await fetch("/api/agents/status"); + if (!r.ok) return []; + const rows = (await r.json()) as AgentRow[]; + return rows.map((a) => a.name).sort(); +} + +export function SchedulesPage() { + const [intervalMs, setIntervalMs] = + useState(DEFAULT_INTERVAL_MS); + const [schedules, setSchedules] = useState(null); + const [error, setError] = useState(null); + const [agentNames, setAgentNames] = useState([]); + // The schedule whose card is currently its edit form, and whether the + // "new schedule" form is open above the list. + const [editingId, setEditingId] = useState(null); + const [creating, setCreating] = useState(false); + const [pendingIds, setPendingIds] = useState>(new Set()); + const [removeTarget, setRemoveTarget] = useState(null); + const [actionError, setActionError] = useState(null); + + const load = useCallback(async () => { + try { + const r = await fetch("/api/schedules"); + if (!r.ok) { + setError(await readApiError(r)); + return; + } + setSchedules((await r.json()) as Schedule[]); + setError(null); + } catch (err) { + setError({ detail: String(err) }); + } + }, []); + + useRefreshInterval(intervalMs, load); + + useEffect(() => { + loadAgentNames().then(setAgentNames, () => setAgentNames([])); + }, []); + + function markPending(id: string, on: boolean) { + setPendingIds((cur) => { + const next = new Set(cur); + if (on) next.add(id); + else next.delete(id); + return next; + }); + } + + // One mutation on one schedule: pending while in flight, the error kept + // for the banner, a refetch on success so the card shows the controller's + // own answer rather than a guessed one. + async function act(id: string, request: () => Promise) { + markPending(id, true); + setActionError(null); + try { + const r = await request(); + if (!r.ok) { + setActionError(await readApiError(r)); + return; + } + await load(); + } catch (err) { + setActionError({ detail: String(err) }); + } finally { + markPending(id, false); + } + } + + function togglePaused(s: Schedule) { + return act(s.id, () => + fetch(`/api/schedules/${encodeURIComponent(s.id)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ paused: !s.paused }), + }), + ); + } + + async function confirmRemove() { + const target = removeTarget; + if (!target) return; + setRemoveTarget(null); + await act(target.id, () => + fetch(`/api/schedules/${encodeURIComponent(target.id)}`, { + method: "DELETE", + }), + ); + } + + // A schedule may still target an agent that has since left the roster; + // its own agents stay pickable so an edit can keep or drop them. + const optionsFor = useMemo( + () => (s?: Schedule) => + [...new Set([...agentNames, ...(s?.agents ?? [])])].sort(), + [agentNames], + ); + + async function saved() { + setCreating(false); + setEditingId(null); + await load(); + } + + return ( + + + + + } + > + {error ? ( + + ) : null} + {actionError ? ( + + ) : null} + {creating ? ( + setCreating(false)} + /> + ) : null} + {!error && schedules === null ?

loading…

: null} + {schedules ? ( + s.id} + storageKey="swarm-ui:schedules:table-filters" + view="cards" + emptyMessage="no schedules yet — add one with + schedule" + renderCard={(s) => + editingId === s.id ? ( + setEditingId(null)} + /> + ) : ( + setEditingId(s.id)} + onTogglePaused={() => togglePaused(s)} + onRemove={() => setRemoveTarget(s)} + /> + ) + } + /> + ) : null} + setRemoveTarget(null)} + onConfirm={confirmRemove} + > + {removeTarget ? ( +

+ Remove {removeTarget.name}? It stops firing for{" "} + {removeTarget.agents.join(", ")} and cannot be brought back. +

+ ) : null} +
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index e8305219..71f57543 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -49,6 +49,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ { href: "/", label: "hives", accent: "var(--purple)" }, { href: "/agents", label: "agents", accent: "var(--green)" }, { href: "/jobs", label: "jobs", accent: "var(--pink)" }, + { href: "/schedules", label: "schedules", accent: "var(--cyan)" }, { href: "/issues", label: "issues", accent: "var(--yellow)" }, { href: "/components", label: "components", accent: "var(--blue)" }, ]; diff --git a/frontend/packages/swarm-ui/src/ui/relative-time/RelativeTime.tsx b/frontend/packages/swarm-ui/src/ui/relative-time/RelativeTime.tsx index b5864fb5..dbf3950b 100644 --- a/frontend/packages/swarm-ui/src/ui/relative-time/RelativeTime.tsx +++ b/frontend/packages/swarm-ui/src/ui/relative-time/RelativeTime.tsx @@ -9,14 +9,24 @@ // package's matrix-rain effect — and resyncs immediately on becoming // visible again instead of waiting out the rest of a stale interval. import { useEffect, useState } from "preact/hooks"; -import { fmtAgo } from "../../util.js"; +import { fmtAgo, fmtUntil } from "../../util.js"; // Cheap at the handful of rows/chips this renders for today, and coarse // enough to be correct without visibly ticking every frame — the same // choice a wall clock makes. const TICK_MS = 1000; -export function RelativeTime({ epochMs }: { epochMs: number }) { +// `upcoming`: the instant is expected to be ahead (a schedule's next fire), +// so one still ahead reads "in 5m" instead of clamping to "0s ago". Off by +// default, so a caller showing an age never sees a skewed clock turn it +// into a countdown. +export function RelativeTime({ + epochMs, + upcoming = false, +}: { + epochMs: number; + upcoming?: boolean; +}) { const [now, setNow] = useState(() => Date.now()); useEffect(() => { @@ -43,5 +53,8 @@ export function RelativeTime({ epochMs }: { epochMs: number }) { }; }, []); - return <>{fmtAgo((now - epochMs) / 1000)}; + const ageSeconds = (now - epochMs) / 1000; + return ( + <>{upcoming && ageSeconds < 0 ? fmtUntil(-ageSeconds) : fmtAgo(ageSeconds)} + ); } diff --git a/frontend/packages/swarm-ui/src/ui/text-area-field/TextAreaField.css b/frontend/packages/swarm-ui/src/ui/text-area-field/TextAreaField.css new file mode 100644 index 00000000..252d6255 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/text-area-field/TextAreaField.css @@ -0,0 +1,5 @@ +/* `min-height` from `.ui-form-control` is a single-line floor; a textarea's + own `rows` sets its height, so only the resize axis needs saying here. */ +.ui-text-area { + resize: vertical; +} diff --git a/frontend/packages/swarm-ui/src/ui/text-area-field/TextAreaField.tsx b/frontend/packages/swarm-ui/src/ui/text-area-field/TextAreaField.tsx new file mode 100644 index 00000000..966b5f1f --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/text-area-field/TextAreaField.tsx @@ -0,0 +1,39 @@ +// — `TextField`'s multi-line sibling, for free text that +// runs past one line (a scheduled prompt's body is the motivating caller). +// Same label chrome and `.ui-form-control` look as the rest of the kit; +// the box only grows vertically, since a horizontal drag would break out +// of whatever column the page gave the field. +import { FormField } from "../form-field/FormField.js"; +import "./TextAreaField.css"; + +export function TextAreaField({ + id, + label, + value, + onInput, + rows = 4, + required, + placeholder, +}: { + id: string; + label: string; + value: string; + onInput: (value: string) => void; + rows?: number; + required?: boolean; + placeholder?: string; +}) { + return ( + +