From 14ababb0d633c063a5f3562814ba21cbd7bea00e Mon Sep 17 00:00:00 2001 From: germanescobar Date: Fri, 10 Jul 2026 17:04:07 -0500 Subject: [PATCH 1/4] Add Schedules section to the Settings UI (#303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires a new 'Schedules' entry into the Settings page so the user can manage the active project's scheduled sessions without leaving the app. The component is a thin client over the existing REST surface in server/routes/schedules.ts (the CLI is still the source of truth; no server changes): * List every schedule (including disabled) with worktree, prompt preview, trigger type, next/last run, enabled state, source, and last error inline. * Create via a form that mirrors the CLI: worktree (from the project's worktrees), prompt, and either a one-shot ISO timestamp or a cron expression + timezone. Server-side validation errors (bad cron / timezone) are surfaced inline. 'createdBy' defaults to 'ui'. * Toggle enabled/disabled with a Switch. * Delete with an AlertDialog confirmation. * View runs for a schedule in a drawer; each run with a 'sessionId' is a button that switches the main view to that session (mirrors the 'controller://' deep-link path the transcript already uses). Editing an existing schedule is intentionally not exposed — the server has no PUT route for it (non-goal). The form resets on close; the section refreshes after every mutation. Plumbing: 'SettingsPage' now accepts an optional 'projectId' and 'onOpenSession' prop so the section can be project-scoped and so runs can deep-link to a session. Both are optional so the page still renders without an active project. A regression test in 'client/src/components/__tests__/schedules-section.test.tsx' covers list rendering (cron and one-shot rows, disabled badge, last run, last error, action controls), the create form (worktree select, prompt, trigger toggle, both one-shot and cron fields, empty-worktree state, validation errors), the delete confirmation flow (data-testid contract + wiring to 'deleteSchedule'), and the enable/disable switch. --- client/src/App.tsx | 17 + client/src/api.ts | 112 +++ .../__tests__/schedules-section.test.tsx | 303 +++++++ client/src/components/schedules-section.tsx | 801 ++++++++++++++++++ client/src/pages/Settings.tsx | 48 +- 5 files changed, 1279 insertions(+), 2 deletions(-) create mode 100644 client/src/components/__tests__/schedules-section.test.tsx create mode 100644 client/src/components/schedules-section.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 79e23e59..3e30d7e0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -358,6 +358,21 @@ function AppBody() { handleSelectSession(target.projectId, target.sessionId, target.worktreeId); }, []); + /** + * Open a session referenced by a schedule run. The Schedules section only + * carries the bare `sessionId` (no `controller://` envelope), so we + * resolve the project from `activeProjectId` and pass through the + * `worktreeId` from the schedule record. Mirrors `handleOpenConversation` + * but tolerates a missing `worktreeId`. + */ + const handleOpenSessionFromSchedule = useCallback( + (params: { sessionId: string; worktreeId?: string }) => { + if (!activeProjectId) return; + handleSelectSession(activeProjectId, params.sessionId, params.worktreeId); + }, + [activeProjectId] + ); + const handleNewThread = (projectId: string, worktreeId?: string) => { setActiveProjectId(projectId); setView({ page: "session", projectId, worktreeId }); @@ -799,6 +814,8 @@ function AppBody() { section={activeView.section} onSectionChange={(section) => setView({ page: "settings", section })} onClose={() => setView(preSettingsViewRef.current)} + projectId={activeProjectId ?? undefined} + onOpenSession={handleOpenSessionFromSchedule} /> )} diff --git a/client/src/api.ts b/client/src/api.ts index 6ce425cd..72b4d7bd 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -1688,3 +1688,115 @@ export async function resetShortcutBindings(): Promise { const res = await fetch(`${BASE}/shortcuts`, { method: "DELETE" }); return res.json(); } + +// --- Schedules (issue #303) --- +// +// Mirrors the server's `Schedule` / `ScheduleInput` shapes (server/lib/schedules.ts). +// The UI is a thin client over the existing REST surface: list, create, toggle +// enabled, delete, and inspect runs. Editing a trigger is intentionally out of +// scope — the server has no PUT route for it (issue #303 non-goals). + +export type ScheduleSource = "user" | "agent"; + +export interface Schedule { + id: string; + projectId: string; + worktreeId: string; + prompt: string; + provider?: string; + model?: string; + mode?: "default" | "plan"; + cron: string | null; + timezone: string; + runAt: string | null; + nextRunAt: string; + lastRunAt: string | null; + lastRunSessionId: string | null; + lastError: string | null; + source: ScheduleSource; + enabled: boolean; + createdAt: string; + createdBy: string | null; +} + +export interface ScheduleInput { + worktreeId: string; + prompt: string; + provider?: string; + model?: string; + mode?: "default" | "plan"; + cron?: string | null; + timezone?: string; + runAt?: string | null; + enabled?: boolean; + source?: ScheduleSource; + createdBy?: string | null; +} + +export interface ScheduleRun { + firedAt: string; + sessionId: string | null; + error: string | null; +} + +function schedulePath(projectId: string, scheduleId?: string): string { + const base = `${BASE}/projects/${projectId}/schedules`; + return scheduleId ? `${base}/${scheduleId}` : base; +} + +export async function fetchSchedules( + projectId: string, + includeDisabled = true +): Promise { + const params = new URLSearchParams({ includeDisabled: String(includeDisabled) }); + const res = await fetch(`${schedulePath(projectId)}?${params}`); + await throwIfNotOk(res, "Failed to fetch schedules"); + const body = (await res.json()) as unknown; + return Array.isArray(body) ? (body as Schedule[]) : []; +} + +export async function createSchedule( + projectId: string, + input: ScheduleInput +): Promise { + const res = await fetch(schedulePath(projectId), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + await throwIfNotOk(res, "Failed to create schedule"); + return res.json(); +} + +export async function setScheduleEnabled( + projectId: string, + scheduleId: string, + enabled: boolean +): Promise { + const action = enabled ? "enable" : "disable"; + const res = await fetch(`${schedulePath(projectId, scheduleId)}/${action}`, { + method: "POST", + }); + await throwIfNotOk(res, `Failed to ${action} schedule`); + return res.json(); +} + +export async function deleteSchedule( + projectId: string, + scheduleId: string +): Promise { + const res = await fetch(schedulePath(projectId, scheduleId), { + method: "DELETE", + }); + await throwIfNotOk(res, "Failed to delete schedule"); +} + +export async function fetchScheduleRuns( + projectId: string, + scheduleId: string +): Promise { + const res = await fetch(`${schedulePath(projectId, scheduleId)}/runs`); + await throwIfNotOk(res, "Failed to fetch schedule runs"); + const body = (await res.json()) as unknown; + return Array.isArray(body) ? (body as ScheduleRun[]) : []; +} diff --git a/client/src/components/__tests__/schedules-section.test.tsx b/client/src/components/__tests__/schedules-section.test.tsx new file mode 100644 index 00000000..64db93a8 --- /dev/null +++ b/client/src/components/__tests__/schedules-section.test.tsx @@ -0,0 +1,303 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { renderToStaticMarkup } from "react-dom/server"; +import { + SchedulesSection, + ScheduleRow, + CreateScheduleForm, +} from "../schedules-section.tsx"; +import type { Schedule } from "../../api.ts"; + +/* + * Regression test for the Schedules settings section (issue #303). + * + * The server side is fully covered by `server/lib/__tests__/schedules.test.ts` + * and the REST surface in `server/routes/schedules.ts`. This file covers what + * the UI is responsible for: + * + * 1. List rendering — `ScheduleRow` surfaces worktree name, prompt + * preview, trigger type, enabled state, last run, and last error. + * 2. Create happy path — `CreateScheduleForm` (extracted from the + * dialog wrapper) exposes the worktree selector, prompt, trigger + * toggle, and save button. + * 3. Delete confirmation — the section renders an `AlertDialog` and + * only fires DELETE after the destructive action is confirmed + * (verified via the data-testid hooks). + * 4. Enable / disable toggle — `ScheduleRow` renders a `Switch` that + * reflects `schedule.enabled` and is wired to `onToggle`. + * + * We use `renderToStaticMarkup` (matching the other client regression + * tests in this folder) and export the inner `ScheduleRow` / + * `CreateScheduleForm` so we can render them with deterministic inputs + * without mounting the full stateful section. + */ + +const PROJECT_ID = "project-1"; + +const WORKTREES = [ + { + id: "wt-1", + projectId: PROJECT_ID, + name: "main", + path: "/tmp/wt-1", + isMain: true, + createdAt: "2025-01-01T00:00:00.000Z", + }, + { + id: "wt-2", + projectId: PROJECT_ID, + name: "feature", + path: "/tmp/wt-2", + isMain: false, + createdAt: "2025-01-01T00:00:00.000Z", + }, +] as const; + +const NOOP = () => {}; +const NOOP_STR = (_v: string) => {}; +const NOOP_TRIGGER = (_v: "runAt" | "cron") => {}; + +function schedule(overrides: Partial = {}): Schedule { + return { + id: "sched-1", + projectId: PROJECT_ID, + worktreeId: "wt-1", + prompt: "Run the daily digest", + cron: "0 9 * * *", + timezone: "UTC", + runAt: null, + nextRunAt: "2025-05-01T09:00:00.000Z", + lastRunAt: null, + lastRunSessionId: null, + lastError: null, + source: "user", + enabled: true, + createdAt: "2025-04-30T00:00:00.000Z", + createdBy: "ui", + ...overrides, + }; +} + +function renderRow( + entry: Schedule, + options: { worktreeName?: string; toggling?: boolean } = {} +): string { + return renderToStaticMarkup( + , + ); +} + +// --------------------------------------------------------------------------- +// 1. List rendering — `ScheduleRow` +// --------------------------------------------------------------------------- + +test("ScheduleRow surfaces the worktree name, trigger, and prompt preview for a cron schedule", () => { + const html = renderRow(schedule()); + assert.match(html, /data-testid="schedule-row-sched-1"/); + assert.match(html, /Run the daily digest/); + // Trigger label is human-readable: cron expression. + assert.match(html, /cron: 0 9 \* \* \*/); + // Source badge. + assert.match(html, />user { + const html = renderRow( + schedule({ id: "sched-2", cron: null, runAt: "2025-06-01T00:00:00.000Z" }) + ); + // The trigger badge switches to "runAt" for one-shots. + assert.match(html, />runAt { + const html = renderRow(schedule({ enabled: false })); + assert.match(html, />disabled { + const html = renderRow( + schedule({ lastRunAt: "2025-04-30T09:00:00.000Z" }) + ); + assert.match(html, /last: 2025-04-30 09:00 UTC/); +}); + +test("ScheduleRow surfaces lastError inline when the last fire failed", () => { + const html = renderRow( + schedule({ lastError: "Invalid cron: foo bar baz" }) + ); + assert.match(html, /Invalid cron: foo bar baz/); +}); + +test("ScheduleRow exposes the enable toggle, runs link, and delete control", () => { + const html = renderRow(schedule()); + assert.match(html, /data-testid="schedule-toggle-sched-1"/); + assert.match(html, /data-testid="schedule-runs-sched-1"/); + assert.match(html, /data-testid="schedule-delete-sched-1"/); +}); + +test("ScheduleRow renders a spinner in place of the switch while toggling", () => { + const html = renderRow(schedule(), { toggling: true }); + // The switch testid is gone while a toggle is in flight; the spinner + // takes its place so users see that the action is pending. + assert.doesNotMatch(html, /data-testid="schedule-toggle-sched-1"/); + assert.match(html, /animate-spin/); +}); + +// --------------------------------------------------------------------------- +// 2. Create happy path — `CreateScheduleForm` +// --------------------------------------------------------------------------- + +function renderForm( + options: { + worktrees?: typeof WORKTREES | unknown[]; + worktreeId?: string; + prompt?: string; + triggerType?: "runAt" | "cron"; + runAt?: string; + cron?: string; + timezone?: string; + error?: string | null; + } = {} +): string { + return renderToStaticMarkup( + , + ); +} + +test("CreateScheduleForm renders the worktree select populated from the project", () => { + const html = renderForm(); + assert.match(html, /data-testid="schedule-form-worktree"/); + assert.match(html, /