diff --git a/client/src/App.tsx b/client/src/App.tsx index 79e23e59..6aea35c7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -358,6 +358,27 @@ function AppBody() { handleSelectSession(target.projectId, target.sessionId, target.worktreeId); }, []); + /** + * Open a session referenced by a schedule run. The Schedules section + * passes the run's `projectId` (the cross-project view can show schedules + * from any project, not just the active one — issue #303 P2 review), and + * we prefer that over `activeProjectId` so deep-links land in the right + * project. `activeProjectId` is the fallback for callers that don't pass + * it. Mirrors `handleOpenConversation` but tolerates a missing `worktreeId`. + */ + const handleOpenSessionFromSchedule = useCallback( + (params: { + sessionId: string; + worktreeId?: string; + projectId?: string; + }) => { + const projectId = params.projectId ?? activeProjectId; + if (!projectId) return; + handleSelectSession(projectId, params.sessionId, params.worktreeId); + }, + [activeProjectId] + ); + const handleNewThread = (projectId: string, worktreeId?: string) => { setActiveProjectId(projectId); setView({ page: "session", projectId, worktreeId }); @@ -799,6 +820,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..de0bc1f2 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -1688,3 +1688,191 @@ 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; +} + +/** + * A schedule paired with its owning project's id and a label, so the + * cross-project Schedules section can render a single list without the + * caller having to track which project each row came from. The + * `projectName` is denormalised at fetch time and is purely a display + * hint; the `projectId` is the source of truth. + */ +export interface ScheduleWithProject extends Schedule { + projectName: string; +} + +/** + * Result of a cross-project schedule fetch. `schedules` is what the + * UI renders; `failedProjectIds` lists projects whose storage we + * couldn't read this round, so the section can surface a non-fatal + * "some projects failed to load" banner instead of silently + * dropping them. Issue #303 P2 review: swallowing per-project + * failures made the UI show "No schedules yet" while active + * schedules were still around. + */ +export interface AllSchedulesResult { + schedules: ScheduleWithProject[]; + failedProjectIds: string[]; +} + +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[]) : []; +} + +/** + * Fetch every project's schedules in parallel and merge them into a + * single list annotated with the owning project's display name. + * + * The server doesn't expose a `/api/schedules` cross-project endpoint + * by design (issue #303 — the CLI is the source of truth for the data + * model), so this is a client-side fan-out. It's the same shape every + * per-project page already uses, just collected in one place. + * + * Per-project failures are reported back via `failedProjectIds` + * rather than swallowed: in a corrupt-store or transient 500 case, + * the user should see "X projects failed to load" rather than an + * incomplete list that looks healthy. Issue #303 P2 review. + */ +export async function fetchAllSchedules( + includeDisabled = true +): Promise { + const projects = await fetchProjects(); + const settled = await Promise.all( + projects.map(async (project) => { + try { + const schedules = await fetchSchedules(project.id, includeDisabled); + return { + ok: true as const, + project, + schedules: schedules.map((schedule) => ({ + ...schedule, + projectName: project.name, + })), + }; + } catch (error) { + return { + ok: false as const, + project, + error: error instanceof Error ? error.message : String(error), + }; + } + }), + ); + const schedules: ScheduleWithProject[] = []; + const failedProjectIds: string[] = []; + for (const entry of settled) { + if (entry.ok) { + schedules.push(...entry.schedules); + } else { + failedProjectIds.push(entry.project.id); + } + } + return { schedules, failedProjectIds }; +} + +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..61e10f99 --- /dev/null +++ b/client/src/components/__tests__/schedules-section.test.tsx @@ -0,0 +1,472 @@ +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 { + Project, + Schedule, + ScheduleWithProject, + Worktree, +} 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 project name, prompt + * preview, trigger type, enabled state, last run, and last error. + * The section is cross-project (per review feedback on #303), so + * each row also carries a project badge. + * 2. Create happy path — `CreateScheduleForm` (extracted from the + * dialog wrapper) exposes the project + worktree selectors, the + * prompt, the trigger toggle, and the 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 NOOP = () => {}; +const NOOP_STR = (_v: string) => {}; +const NOOP_TRIGGER = (_v: "runAt" | "cron") => {}; +const NOOP_ASYNC = async () => []; + +const PROJECT: Project = { + id: "project-1", + name: "Anita", + path: "/tmp/project-1", + createdAt: "2025-01-01T00:00:00.000Z", +}; + +const PROJECT_2: Project = { + id: "project-2", + name: "Germini", + path: "/tmp/project-2", + createdAt: "2025-01-01T00:00:00.000Z", +}; + +const WORKTREES: Worktree[] = [ + { + 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", + }, +]; + +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 withProject( + base: Schedule, + project: Project +): ScheduleWithProject { + return { ...base, projectName: project.name }; +} + +function renderRow( + entry: ScheduleWithProject, + options: { toggling?: boolean } = {} +): string { + return renderToStaticMarkup( + , + ); +} + +// --------------------------------------------------------------------------- +// 1. List rendering — `ScheduleRow` +// --------------------------------------------------------------------------- + +test("ScheduleRow surfaces the project name, trigger, and prompt preview for a cron schedule", () => { + const html = renderRow(withProject(schedule(), PROJECT)); + assert.match(html, /data-testid="schedule-row-sched-1"/); + assert.match(html, /Run the daily digest/); + // Project badge is the first thing in the row. + assert.match(html, />Anitauserdisabled { + const html = renderRow( + withProject( + schedule({ id: "sched-2", cron: null, runAt: "2025-06-01T00:00:00.000Z" }), + PROJECT + ) + ); + // The trigger badge switches to "runAt" for one-shots. + assert.match(html, />runAt { + const html = renderRow(withProject(schedule({ enabled: false }), PROJECT)); + assert.match(html, />disabled { + const html = renderRow( + withProject( + schedule({ lastRunAt: "2025-04-30T09:00:00.000Z" }), + PROJECT + ) + ); + assert.match(html, /last: 2025-04-30 09:00 UTC/); +}); + +test("ScheduleRow surfaces lastError inline when the last fire failed", () => { + const html = renderRow( + withProject( + schedule({ lastError: "Invalid cron: foo bar baz" }), + PROJECT + ) + ); + assert.match(html, /Invalid cron: foo bar baz/); +}); + +test("ScheduleRow exposes the enable toggle, runs link, and delete control", () => { + const html = renderRow(withProject(schedule(), PROJECT)); + 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(withProject(schedule(), PROJECT), { 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/); +}); + +test("ScheduleRow shows the correct project badge per row in a cross-project list", () => { + const a = renderRow(withProject(schedule({ id: "sched-a" }), PROJECT)); + const b = renderRow(withProject(schedule({ id: "sched-b" }), PROJECT_2)); + assert.match(a, />AnitaGerminiGerminiAnita, + ); +} + +test("CreateScheduleForm renders the project and worktree selectors populated from props", () => { + const html = renderForm(); + assert.match(html, /data-testid="schedule-form-project"/); + assert.match(html, /data-testid="schedule-form-worktree"/); + // The project select lists every project the caller passed in. + assert.match(html, /