From bf5e3b47e7199341f6d644817a04a7537785ed9a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 30 Jun 2026 22:58:49 +0200 Subject: [PATCH] Add a Schedules screen to manage recurring agents (#1246) * feat: add Schedules screen to manage scheduled agents Adds a Schedules section in the sidebar header (below Sessions) for viewing and managing new-agent schedules: recurring jobs that spawn an agent on a cron or interval cadence. - Borderless table showing provider icon, model, cadence, next run, and status, with row actions to edit, pause/resume, run now, and delete. - Create/edit sheet that reuses the agent provider/model/mode/working- directory pickers, plus an interval/cron cadence editor with presets, validation, and a UTC preview. - Responsive: hover-revealed row actions on desktop, an always-visible action menu on mobile. - Data layer over the existing schedule RPCs (list/create/update/pause/ resume/delete/run-once) with optimistic pause, resume, and delete. Scope is limited to new-agent schedules for now. * Make the schedule form controls visually consistent Merge the provider and model pickers into the single nested selector the draft screen uses, render it as a full-width field, mute the placeholder, proportion the cadence segmented controls, unify the cron preset chips, and route the sheet scroll through the themed scrollbar. * Address schedules review feedback * Translate schedule hook client fallback errors * Make schedules global and project scoped * Stabilize project settings transport E2E * Reset schedule model on cross-host project change * Reject malformed cron step expressions * Tighten schedules project query freshness * refactor(schedules): clean up review feedback * test(schedules): extract fake host setup * refactor(schedules): share cron expression parsing Use one structural cron parser for app previews and daemon cadence validation so the accepted grammar cannot drift between surfaces. Remove the unused host-scoped schedules redirect while keeping the global schedules route. --- packages/app/e2e/helpers/project-settings.ts | 77 +- .../app/e2e/helpers/schedule-fake-host.ts | 249 +++++ packages/app/e2e/projects-settings.spec.ts | 9 +- .../schedules-edit-model-hydration.spec.ts | 92 ++ .../app/e2e/schedules-project-target.spec.ts | 156 ++++ packages/app/src/app/_layout.tsx | 2 + packages/app/src/app/schedules.tsx | 10 + .../src/components/adaptive-modal-sheet.tsx | 41 +- .../components/combined-model-selector.tsx | 66 +- packages/app/src/components/left-sidebar.tsx | 50 +- .../components/schedules/cadence-editor.tsx | 375 ++++++++ .../schedules/schedule-form-sheet.tsx | 867 ++++++++++++++++++ .../src/components/schedules/schedule-row.tsx | 339 +++++++ .../components/schedules/schedules-table.tsx | 151 +++ .../app/src/hooks/use-agent-form-state.ts | 7 + packages/app/src/hooks/use-projects.ts | 14 +- .../app/src/hooks/use-schedule-mutations.ts | 277 ++++++ packages/app/src/hooks/use-schedules.ts | 60 ++ packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../provider-selection/resolve-agent-form.ts | 19 + .../app/src/schedules/aggregated-schedules.ts | 81 ++ packages/app/src/screens/schedules-screen.tsx | 238 +++++ packages/app/src/utils/host-routes.ts | 4 + .../app/src/utils/schedule-format.test.ts | 135 +++ packages/app/src/utils/schedule-format.ts | 184 ++++ .../src/schedule/cron-expression.test.ts | 25 + .../protocol/src/schedule/cron-expression.ts | 129 +++ .../server/src/server/schedule/cron.test.ts | 6 + packages/server/src/server/schedule/cron.ts | 97 +- 36 files changed, 3622 insertions(+), 146 deletions(-) create mode 100644 packages/app/e2e/helpers/schedule-fake-host.ts create mode 100644 packages/app/e2e/schedules-edit-model-hydration.spec.ts create mode 100644 packages/app/e2e/schedules-project-target.spec.ts create mode 100644 packages/app/src/app/schedules.tsx create mode 100644 packages/app/src/components/schedules/cadence-editor.tsx create mode 100644 packages/app/src/components/schedules/schedule-form-sheet.tsx create mode 100644 packages/app/src/components/schedules/schedule-row.tsx create mode 100644 packages/app/src/components/schedules/schedules-table.tsx create mode 100644 packages/app/src/hooks/use-schedule-mutations.ts create mode 100644 packages/app/src/hooks/use-schedules.ts create mode 100644 packages/app/src/schedules/aggregated-schedules.ts create mode 100644 packages/app/src/screens/schedules-screen.tsx create mode 100644 packages/app/src/utils/schedule-format.test.ts create mode 100644 packages/app/src/utils/schedule-format.ts create mode 100644 packages/protocol/src/schedule/cron-expression.test.ts create mode 100644 packages/protocol/src/schedule/cron-expression.ts diff --git a/packages/app/e2e/helpers/project-settings.ts b/packages/app/e2e/helpers/project-settings.ts index f6811f890..eed5bf9b5 100644 --- a/packages/app/e2e/helpers/project-settings.ts +++ b/packages/app/e2e/helpers/project-settings.ts @@ -5,6 +5,29 @@ import type { WebSocketRoute } from "@playwright/test"; import { gotoAppShell, openSettings } from "./app"; import { daemonWsRoutePattern } from "./daemon-port"; +type WebSocketMessage = string | Buffer; + +function parseWebSocketJson(message: WebSocketMessage): unknown { + const raw = typeof message === "string" ? message : message.toString("utf8"); + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function getSessionMessage(message: WebSocketMessage): Record | null { + const envelope = parseWebSocketJson(message); + if (!envelope || typeof envelope !== "object") { + return null; + } + const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; + if (maybeEnvelope.type !== "session" || typeof maybeEnvelope.message !== "object") { + return null; + } + return maybeEnvelope.message as Record; +} + // --- Navigation --- export async function openProjects(page: Page): Promise { @@ -171,33 +194,39 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise // --- WebSocket helpers --- -// Proxies all daemon WS traffic transparently until a read_project_config_request -// is seen, then closes that connection (triggering readQuery.isError). Subsequent -// connections pass through so the Reload action can succeed. -export async function installReadTransportFailure(page: Page): Promise { - let armed = true; +// Proxies all daemon WS traffic transparently, but rejects paseo.json reads +// until the test explicitly allows recovery. Closing the transport leaves the +// client-side RPC pending across reconnects, so this injects the same correlated +// rpc_error shape the daemon emits for failed async session requests. +export async function installReadTransportFailure( + page: Page, +): Promise<{ allowRecovery: () => void }> { + let shouldFailReads = true; await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { const server = ws.connectToServer(); ws.onMessage((message) => { - if (armed && typeof message === "string") { - try { - const envelope = JSON.parse(message) as { - type?: string; - message?: { type?: string }; - }; - if ( - envelope.type === "session" && - envelope.message?.type === "read_project_config_request" - ) { - armed = false; - void ws.close({ code: 1001 }); - return; - } - } catch { - // binary or malformed frame — pass through + const sessionMessage = getSessionMessage(message); + if (shouldFailReads && sessionMessage?.type === "read_project_config_request") { + const requestId = sessionMessage.requestId; + if (typeof requestId === "string") { + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "rpc_error", + payload: { + requestId, + requestType: "read_project_config_request", + error: "Test read transport failure.", + code: "transport", + }, + }, + }), + ); } + return; } try { server.send(message); @@ -214,6 +243,12 @@ export async function installReadTransportFailure(page: Page): Promise { } }); }); + + return { + allowRecovery() { + shouldFailReads = false; + }, + }; } // Installs a transparent WS proxy that can later drop all active daemon connections diff --git a/packages/app/e2e/helpers/schedule-fake-host.ts b/packages/app/e2e/helpers/schedule-fake-host.ts new file mode 100644 index 000000000..f4dad75ad --- /dev/null +++ b/packages/app/e2e/helpers/schedule-fake-host.ts @@ -0,0 +1,249 @@ +import { type Page } from "@playwright/test"; +import { buildSeededHost } from "./daemon-registry"; +import { wsRoutePatternForPort } from "./daemon-port"; +import { type SeededWorkspace } from "./seed-client"; + +const REGISTRY_KEY = "@paseo:daemon-registry"; +const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce"; +const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once"; +const FAKE_HOST_MODEL_ID = "fake-host-model"; +const FAKE_HOST_MODEL_LABEL = "Fake host model"; +const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project"; + +type WebSocketMessage = string | Buffer; +type SessionRequest = Record & { type?: string; requestId?: string }; + +export interface FakeScheduleHostWorkspace { + serverId: string; + projectId: string; + projectDisplayName: string; + workspace: Record; +} + +function parseJson(message: WebSocketMessage): unknown { + const raw = typeof message === "string" ? message : message.toString("utf8"); + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function buildSessionMessage(type: string, payload: Record) { + return JSON.stringify({ + type: "session", + message: { + type, + payload, + }, + }); +} + +function buildFakeProviderEntries(nowIso: string) { + return [ + { + provider: "mock", + label: "Mock", + status: "ready", + enabled: true, + fetchedAt: nowIso, + models: [ + { + provider: "mock", + id: FAKE_HOST_MODEL_ID, + label: FAKE_HOST_MODEL_LABEL, + isDefault: true, + }, + ], + modes: [{ id: "load-test", label: "Load test" }], + defaultModeId: "load-test", + }, + ]; +} + +function readSessionRequest(message: WebSocketMessage): SessionRequest | null { + const parsed = parseJson(message); + if (!parsed || typeof parsed !== "object") { + return null; + } + + const envelope = parsed as { type?: string; message?: SessionRequest }; + if (envelope.type !== "session" || !envelope.message) { + return null; + } + + return envelope.message; +} + +function getRequestId(request: SessionRequest): string { + return typeof request.requestId === "string" ? request.requestId : "fake-request"; +} + +export async function buildFakeScheduleHostWorkspace( + workspace: SeededWorkspace, +): Promise { + const workspaceList = await workspace.client.fetchWorkspaces({ + filter: { projectId: workspace.projectId }, + }); + const baseWorkspace = workspaceList.entries.find((entry) => entry.id === workspace.workspaceId); + if (!baseWorkspace) { + throw new Error(`Failed to load seeded workspace descriptor ${workspace.workspaceId}`); + } + + const projectId = `${workspace.projectId}-fake-host`; + const cwd = `${workspace.repoPath}-fake-host`; + return { + serverId: "schedule-fake-host", + projectId, + projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME, + workspace: { + ...baseWorkspace, + id: `${baseWorkspace.id}-fake-host`, + projectId, + projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME, + projectRootPath: cwd, + workspaceDirectory: cwd, + name: FAKE_HOST_PROJECT_DISPLAY_NAME, + project: undefined, + }, + }; +} + +export async function installFakeScheduleHost(input: { + page: Page; + port: string; + serverId: string; + workspace: Record; +}): Promise { + await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => { + ws.onMessage((message) => { + const parsed = parseJson(message); + if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "hello") { + ws.send( + buildSessionMessage("status", { + status: "server_info", + serverId: input.serverId, + hostname: "fake-schedule-host", + version: "0.0.0-e2e", + features: { + providersSnapshot: true, + workspaceMultiplicity: true, + projectAdd: true, + projectRemove: true, + worktreeRestore: true, + }, + }), + ); + return; + } + + if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "ping") { + ws.send(JSON.stringify({ type: "pong" })); + return; + } + + const request = readSessionRequest(message); + if (!request) { + return; + } + + const requestId = getRequestId(request); + const now = Date.now(); + const nowIso = new Date(now).toISOString(); + switch (request.type) { + case "ping": + ws.send( + buildSessionMessage("pong", { + requestId, + clientSentAt: typeof request.clientSentAt === "number" ? request.clientSentAt : now, + serverReceivedAt: now, + serverSentAt: now, + }), + ); + return; + case "fetch_workspaces_request": + ws.send( + buildSessionMessage("fetch_workspaces_response", { + requestId, + entries: [input.workspace], + emptyProjects: [], + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + }), + ); + return; + case "fetch_agents_request": + ws.send( + buildSessionMessage("fetch_agents_response", { + requestId, + entries: [], + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + }), + ); + return; + case "get_providers_snapshot_request": + ws.send( + buildSessionMessage("get_providers_snapshot_response", { + requestId, + entries: buildFakeProviderEntries(nowIso), + generatedAt: nowIso, + }), + ); + return; + case "refresh_providers_snapshot_request": + ws.send( + buildSessionMessage("refresh_providers_snapshot_response", { + requestId, + acknowledged: true, + }), + ); + return; + case "schedule/list": + ws.send( + buildSessionMessage("schedule/list/response", { + requestId, + schedules: [], + error: null, + }), + ); + return; + } + }); + }); +} + +export async function addFakeScheduleHostAndReload(input: { + page: Page; + serverId: string; + label: string; + port: string; +}): Promise { + const host = buildSeededHost({ + serverId: input.serverId, + label: input.label, + endpoint: `127.0.0.1:${input.port}`, + nowIso: new Date().toISOString(), + }); + + await input.page.evaluate( + ({ seededHost, keys }) => { + const nonce = localStorage.getItem(keys.nonce); + if (!nonce) { + throw new Error("Expected the e2e seed nonce before overriding the host registry."); + } + const raw = localStorage.getItem(keys.registry); + const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : []; + localStorage.setItem(keys.registry, JSON.stringify([...registry, seededHost])); + localStorage.setItem(keys.disableSeedOnce, nonce); + }, + { + seededHost: host, + keys: { + registry: REGISTRY_KEY, + nonce: SEED_NONCE_KEY, + disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY, + }, + }, + ); + + await input.page.reload(); +} diff --git a/packages/app/e2e/projects-settings.spec.ts b/packages/app/e2e/projects-settings.spec.ts index 541949298..c8549a596 100644 --- a/packages/app/e2e/projects-settings.spec.ts +++ b/packages/app/e2e/projects-settings.spec.ts @@ -282,9 +282,9 @@ test.describe("Projects settings — error UX", () => { page, editableProject, }) => { - // Drop the WS connection the moment a read_project_config_request is sent. - // Subsequent connections are proxied transparently so Reload can succeed. - await installReadTransportFailure(page); + // Reject read_project_config_request calls until the user clicks Reload. + // This keeps automatic reconnect refetches from racing past the callout. + const transportFailure = await installReadTransportFailure(page); await openProjects(page); await navigateToProjectSettings(page, editableProject.name); @@ -292,7 +292,8 @@ test.describe("Projects settings — error UX", () => { await expectProjectSettingsError(page, "transport"); await expectProjectSettingsFormHidden(page); - // The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds. + // Retry Reload until the refetch wins any in-flight error-state rendering. + transportFailure.allowRecovery(); await expect(async () => { await clickReloadProjectSettings(page); await expectNoProjectSettingsError(page, "transport", 3_000); diff --git a/packages/app/e2e/schedules-edit-model-hydration.spec.ts b/packages/app/e2e/schedules-edit-model-hydration.spec.ts new file mode 100644 index 000000000..56d075be5 --- /dev/null +++ b/packages/app/e2e/schedules-edit-model-hydration.spec.ts @@ -0,0 +1,92 @@ +import { expect, test } from "./fixtures"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { buildSchedulesRoute } from "../src/utils/host-routes"; + +interface ScheduleSeedClient { + scheduleCreate(input: { + prompt: string; + name?: string; + cadence: { type: "cron"; expression: string }; + target: { + type: "new-agent"; + config: { + provider: "mock"; + cwd: string; + model: string; + modeId: string; + title: string; + }; + }; + runOnCreate: boolean; + }): Promise<{ schedule: { id: string } | null; error: string | null }>; + scheduleDelete(input: { id: string }): Promise<{ error: string | null }>; +} + +async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promise { + const client = workspace.client as unknown as ScheduleSeedClient; + const result = await client.scheduleCreate({ + prompt: "Say hello from the scheduled agent.", + name, + cadence: { type: "cron", expression: "0 9 * * *" }, + target: { + type: "new-agent", + config: { + provider: "mock", + cwd: workspace.repoPath, + model: "ten-second-stream", + modeId: "load-test", + title: name, + }, + }, + runOnCreate: false, + }); + + if (!result.schedule) { + throw new Error(result.error ?? "Failed to seed schedule"); + } + + return result.schedule.id; +} + +function ignoreScheduleDeleteError(): void {} + +async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise { + await (workspace.client as unknown as ScheduleSeedClient) + .scheduleDelete({ id }) + .catch(ignoreScheduleDeleteError); +} + +test.describe("Schedules", () => { + const cleanupTasks: Array<() => Promise> = []; + + test.afterEach(async () => { + for (const cleanup of cleanupTasks.toReversed()) { + await cleanup(); + } + cleanupTasks.length = 0; + }); + + test("edit form hydrates the scheduled model selection", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "schedule-model-hydration-" }); + cleanupTasks.push(() => workspace.cleanup()); + const scheduleName = `Hydrate model ${Date.now()}`; + const scheduleId = await seedMockSchedule(workspace, scheduleName); + cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId)); + + await page.goto(buildSchedulesRoute()); + const row = page.getByTestId(`schedule-row-${scheduleId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row).toContainText("ten-second-stream"); + + await row.click(); + await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0); + await expect(page.getByTestId("schedule-project-trigger")).toContainText( + workspace.projectDisplayName, + { timeout: 30_000 }, + ); + await expect(page.getByTestId("schedule-model-trigger")).toContainText("Ten second stream", { + timeout: 30_000, + }); + }); +}); diff --git a/packages/app/e2e/schedules-project-target.spec.ts b/packages/app/e2e/schedules-project-target.spec.ts new file mode 100644 index 000000000..a10f4c587 --- /dev/null +++ b/packages/app/e2e/schedules-project-target.spec.ts @@ -0,0 +1,156 @@ +import { expect, test, type Page } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { + addFakeScheduleHostAndReload, + buildFakeScheduleHostWorkspace, + installFakeScheduleHost, +} from "./helpers/schedule-fake-host"; +import { getServerId } from "./helpers/server-id"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { waitForSidebarHydration } from "./helpers/workspace-ui"; +import { buildSchedulesRoute } from "../src/utils/host-routes"; + +interface ScheduleListItem { + id: string; + name: string | null; + target: { type: string; config?: { cwd?: string } }; +} + +interface ScheduleSeedClient { + scheduleList(): Promise<{ schedules: ScheduleListItem[]; error: string | null }>; + scheduleDelete(input: { id: string }): Promise<{ error: string | null }>; +} + +async function selectModelByLabel(page: Page, label: string): Promise { + await page.getByRole("button", { name: /select model/i }).click(); + const popup = page.getByTestId("combobox-desktop-container"); + await expect(popup).toBeVisible({ timeout: 30_000 }); + await popup.getByText(label, { exact: true }).click(); + await expect(popup).toHaveCount(0, { timeout: 30_000 }); +} + +async function deleteScheduleByName(workspace: SeededWorkspace, name: string): Promise { + const client = workspace.client as unknown as ScheduleSeedClient; + const list = await client.scheduleList(); + const schedule = list.schedules.find((candidate) => candidate.name === name); + if (schedule) { + await client.scheduleDelete({ id: schedule.id }).catch(() => undefined); + } +} + +async function expectScheduleCreatedForProject(input: { + workspace: SeededWorkspace; + name: string; +}): Promise { + const client = input.workspace.client as unknown as ScheduleSeedClient; + const list = await client.scheduleList(); + const schedule = list.schedules.find((candidate) => candidate.name === input.name); + expect(schedule).toEqual( + expect.objectContaining({ + name: input.name, + target: expect.objectContaining({ + type: "new-agent", + config: expect.objectContaining({ + cwd: input.workspace.repoPath, + }), + }), + }), + ); +} + +test.describe("Schedules project target", () => { + const cleanupTasks: Array<() => Promise> = []; + + test.afterEach(async () => { + for (const cleanup of cleanupTasks.toReversed()) { + await cleanup(); + } + cleanupTasks.length = 0; + }); + + test("creates a schedule from a project picker instead of a raw CWD selector", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" }); + cleanupTasks.push(() => workspace.cleanup()); + const scheduleName = `Project schedule ${Date.now()}`; + cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName)); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + + await page.getByRole("button", { name: "Schedules" }).click(); + await expect(page).toHaveURL(/\/schedules$/); + await expect(page).not.toHaveURL(/\/h\//); + await expect(page.getByTestId(`schedules-section-${getServerId()}`)).toBeVisible(); + + await page.getByRole("button", { name: "New schedule" }).click(); + await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0); + + await page.getByRole("button", { name: /select project/i }).click(); + await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click(); + await expect(page.getByRole("button", { name: /select project/i })).toContainText( + workspace.projectDisplayName, + ); + + await page.getByLabel("Schedule name").fill(scheduleName); + await page.getByLabel("Prompt").fill("Summarize the project status."); + await page.getByRole("button", { name: "Cron" }).click(); + await page.getByRole("button", { name: "Create schedule" }).click(); + + await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 }); + await expectScheduleCreatedForProject({ workspace, name: scheduleName }); + }); + + test("clears the selected model when the chosen project moves to another host", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" }); + cleanupTasks.push(() => workspace.cleanup()); + const fakeHost = await buildFakeScheduleHostWorkspace(workspace); + const fakePort = String(59_000 + Math.floor(Math.random() * 900)); + + await installFakeScheduleHost({ + page, + port: fakePort, + serverId: fakeHost.serverId, + workspace: fakeHost.workspace, + }); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await page.goto(buildSchedulesRoute()); + await addFakeScheduleHostAndReload({ + page, + serverId: fakeHost.serverId, + label: "Fake host", + port: fakePort, + }); + await expect(page.getByTestId(`schedules-section-${fakeHost.serverId}`)).toBeVisible({ + timeout: 30_000, + }); + await page.getByRole("button", { name: "New schedule" }).click(); + await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 }); + + await page.getByRole("button", { name: /select project/i }).click(); + await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click(); + await expect(page.getByRole("button", { name: /select project/i })).toContainText( + workspace.projectDisplayName, + ); + + await selectModelByLabel(page, "Ten second stream"); + await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible(); + + await page.getByRole("button", { name: /select project/i }).click(); + await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click(); + await expect(page.getByRole("button", { name: /select project/i })).toContainText( + fakeHost.projectDisplayName, + ); + await expect(page.getByRole("button", { name: /select model/i })).toBeVisible(); + + await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`); + await page.getByLabel("Prompt").fill("Run on the fake host project."); + await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled(); + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index b0557f9eb..0d32b7184 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -893,6 +893,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) { (pathname === "/open-project" || pathname === "/new" || pathname === "/sessions" || + pathname === "/schedules" || routeHasKnownHost); // Parse selectedAgentKey directly from pathname @@ -951,6 +952,7 @@ function RootStack() { + diff --git a/packages/app/src/app/schedules.tsx b/packages/app/src/app/schedules.tsx new file mode 100644 index 000000000..5f13b7a14 --- /dev/null +++ b/packages/app/src/app/schedules.tsx @@ -0,0 +1,10 @@ +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { SchedulesScreen } from "@/screens/schedules-screen"; + +export default function SchedulesRoute() { + return ( + + + + ); +} diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index e564f429d..15b33314d 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useCallback, useEffect, useMemo } from "react"; +import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import type { ReactNode, Ref } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; @@ -21,6 +21,7 @@ import { } from "@/components/ui/isolated-bottom-sheet-modal"; import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout"; import { isNative, isWeb } from "@/constants/platform"; +import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useSafeAreaInsets } from "react-native-safe-area-context"; // Horizontal indent token shared by the sheet header (title, back arrow, @@ -178,6 +179,11 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, + desktopScrollContainer: { + flexShrink: 1, + minHeight: 0, + position: "relative", + }, desktopScroll: { flexShrink: 1, minHeight: 0, @@ -451,6 +457,11 @@ export interface AdaptiveModalSheetProps { desktopMaxWidth?: number; scrollable?: boolean; presentation?: "push" | "replace"; + /** + * Render the themed desktop-web scrollbar over the scroll area instead of the + * native browser scrollbar. No-op on native and on the mobile bottom sheet. + */ + webScrollbar?: boolean; } export function AdaptiveModalSheet({ @@ -464,11 +475,16 @@ export function AdaptiveModalSheet({ desktopMaxWidth, scrollable = true, presentation, + webScrollbar = false, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); + const desktopScrollRef = useRef(null); + const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, { + enabled: webScrollbar && !isMobile, + }); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const compactSafeAreaPadding = useMemo( () => @@ -574,13 +590,22 @@ export function AdaptiveModalSheet({ <> {scrollable ? ( - - {children} - + + + {children} + + {desktopScrollbar.overlay} + ) : ( {children} )} diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 7af64abab..9c6ca1996 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -80,6 +80,8 @@ interface CombinedModelSelectorProps { onPress: () => void; disabled: boolean; isOpen: boolean; + hovered: boolean; + pressed: boolean; }) => React.ReactNode; onOpen?: () => void; onClose?: () => void; @@ -87,6 +89,15 @@ interface CombinedModelSelectorProps { isRetryingProvider?: boolean; disabled?: boolean; serverId?: string | null; + /** + * Render the custom trigger as a full-width form field: the outer Pressable + * becomes a transparent passthrough that stretches its child edge-to-edge and + * stops painting its own hover/pressed background and rounded corners. The + * trigger itself owns the field visuals and reads hovered/pressed to show its + * active state. Without this the trigger stays a content-width toolbar chip + * (the composer's layout). + */ + triggerFill?: boolean; } interface SelectorContentProps { @@ -574,6 +585,7 @@ export function CombinedModelSelector({ isRetryingProvider = false, disabled = false, serverId = null, + triggerFill = false, }: CombinedModelSelectorProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -693,14 +705,26 @@ export function CombinedModelSelector({ }, [handleOpenChange, isOpen]); const triggerStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.trigger, - Boolean(hovered) && styles.triggerHovered, - (pressed || isOpen) && styles.triggerPressed, - disabled && styles.triggerDisabled, - renderTrigger ? styles.customTriggerWrapper : null, - ], - [disabled, isOpen, renderTrigger], + ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => { + // Fill mode: transparent full-width passthrough. The trigger paints its own + // hover/pressed state from the args, so the wrapper must not double-paint. + if (triggerFill) { + return [ + styles.trigger, + styles.customTriggerWrapper, + styles.triggerFill, + disabled && styles.triggerDisabled, + ]; + } + return [ + styles.trigger, + Boolean(hovered) && styles.triggerHovered, + (pressed || isOpen) && styles.triggerPressed, + disabled && styles.triggerDisabled, + renderTrigger ? styles.customTriggerWrapper : null, + ]; + }, + [disabled, isOpen, renderTrigger, triggerFill], ); const handleBackToAll = useCallback(() => { @@ -789,12 +813,16 @@ export function CombinedModelSelector({ accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })} testID="combined-model-selector" > - {renderTrigger({ - selectedModelLabel: triggerLabel, - onPress: handleTriggerPress, - disabled, - isOpen, - })} + {({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => + renderTrigger({ + selectedModelLabel: triggerLabel, + onPress: handleTriggerPress, + disabled, + isOpen, + hovered: Boolean(hovered), + pressed, + }) + } ) : ( ({ paddingVertical: 0, height: "auto", }, + // Stretch the wrapper (and, via column + stretch, its single child) to the + // full width of the field, with no background or rounding of its own. + triggerFill: { + alignSelf: "stretch", + flexShrink: 0, + flexDirection: "column", + alignItems: "stretch", + backgroundColor: "transparent", + borderRadius: 0, + }, favoritesContainer: { backgroundColor: theme.colors.surface1, borderBottomWidth: 1, diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 6c6fd9c09..018c541f1 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,15 @@ import { router, usePathname } from "expo-router"; -import { FolderPlus, History, Home, Plus, Search, Server, Settings, X } from "lucide-react-native"; +import { + CalendarClock, + FolderPlus, + History, + Home, + Plus, + Search, + Server, + Settings, + X, +} from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -56,6 +66,7 @@ import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state"; import { buildOpenProjectRoute, buildNewWorkspaceRoute, + buildSchedulesRoute, buildSessionsRoute, buildSettingsAddHostRoute, buildSettingsHostSectionRoute, @@ -105,6 +116,7 @@ interface SidebarLabels { switchHost: string; searchHosts: string; sessions: string; + schedules: string; closeSidebar: string; } @@ -114,12 +126,14 @@ interface MobileSidebarProps extends SidebarSharedProps { isOpen: boolean; closeSidebar: () => void; handleViewMoreNavigate: () => void; + handleViewSchedulesNavigate: () => void; } interface DesktopSidebarProps extends SidebarSharedProps { insetsTop: number; isOpen: boolean; handleViewMore: () => void; + handleViewSchedules: () => void; } export const LeftSidebar = memo(function LeftSidebar({ @@ -221,6 +235,10 @@ export const LeftSidebar = memo(function LeftSidebar({ router.push(buildSessionsRoute()); }, []); + const handleViewSchedulesNavigate = useCallback(() => { + router.push(buildSchedulesRoute()); + }, []); + const newWorkspaceKeys = useShortcutKeys("new-workspace"); const labels = useMemo( (): SidebarLabels => ({ @@ -231,6 +249,7 @@ export const LeftSidebar = memo(function LeftSidebar({ switchHost: t("sidebar.host.switchTitle"), searchHosts: t("sidebar.host.searchPlaceholder"), sessions: t("sidebar.sections.sessions"), + schedules: t("sidebar.sections.schedules"), closeSidebar: t("sidebar.actions.closeSidebar"), }), [t], @@ -267,6 +286,7 @@ export const LeftSidebar = memo(function LeftSidebar({ handleAddHost={handleAddHostMobile} handleOpenHostSettings={handleOpenHostSettingsMobile} handleViewMoreNavigate={handleViewMoreNavigate} + handleViewSchedulesNavigate={handleViewSchedulesNavigate} /> ); } @@ -282,6 +302,7 @@ export const LeftSidebar = memo(function LeftSidebar({ handleAddHost={handleAddHostDesktop} handleOpenHostSettings={handleOpenHostSettingsDesktop} handleViewMore={handleViewMoreNavigate} + handleViewSchedules={handleViewSchedulesNavigate} /> ); }); @@ -557,9 +578,11 @@ function MobileSidebar({ isOpen, closeSidebar, handleViewMoreNavigate, + handleViewSchedulesNavigate, }: MobileSidebarProps) { const pathname = usePathname(); const isSessionsActive = pathname.includes("/sessions"); + const isSchedulesActive = pathname.includes("/schedules"); const { translateX, backdropOpacity, @@ -587,6 +610,13 @@ function MobileSidebar({ handleViewMoreNavigate(); }, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]); + const handleViewSchedules = useCallback(() => { + translateX.value = -windowWidth; + backdropOpacity.value = 0; + closeSidebar(); + handleViewSchedulesNavigate(); + }, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]); + const handleWorkspacePress = useCallback(() => { closeSidebar(); }, [closeSidebar]); @@ -747,6 +777,14 @@ function MobileSidebar({ testID="sidebar-sessions" variant="compact" /> + state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); @@ -909,6 +949,14 @@ function DesktopSidebar({ testID="sidebar-sessions" variant="compact" /> + diff --git a/packages/app/src/components/schedules/cadence-editor.tsx b/packages/app/src/components/schedules/cadence-editor.tsx new file mode 100644 index 000000000..96fe0b8df --- /dev/null +++ b/packages/app/src/components/schedules/cadence-editor.tsx @@ -0,0 +1,375 @@ +import { type ReactNode, useCallback, useMemo, useReducer, useRef, useState } from "react"; +import { Pressable, Text, View } from "react-native"; +import type { PressableStateCallbackType } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { isWeb } from "@/constants/platform"; +import { + describeCron, + everyMsToParts, + type IntervalUnit, + partsToEveryMs, + validateCron, +} from "@/utils/schedule-format"; +import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types"; + +type CadenceMode = ScheduleCadence["type"]; + +interface CronPreset { + label: string; + expression: string; +} + +// 5-field expressions evaluated in UTC by the daemon. Each one round-trips +// through describeCron() so the chip and the live preview agree. +const CRON_PRESETS: CronPreset[] = [ + { label: "Every hour", expression: "0 * * * *" }, + { label: "Daily 9:00", expression: "0 9 * * *" }, + { label: "Weekdays 9:00", expression: "0 9 * * 1-5" }, + { label: "Mondays 9:00", expression: "0 9 * * 1" }, +]; + +const MODE_OPTIONS = [ + { value: "every" as const, label: "Interval" }, + { value: "cron" as const, label: "Cron" }, +]; + +const UNIT_OPTIONS = [ + { value: "minutes" as const, label: "Minutes" }, + { value: "hours" as const, label: "Hours" }, + { value: "days" as const, label: "Days" }, +]; + +const DEFAULT_INTERVAL_MS = partsToEveryMs(1, "hours"); +const DEFAULT_CRON_EXPRESSION = "0 9 * * *"; + +const UNIT_NOUN: Record = { + minutes: "minute", + hours: "hour", + days: "day", +}; + +function describeInterval(value: number, unit: IntervalUnit): string { + const noun = UNIT_NOUN[unit]; + if (value === 1) { + return `Runs every ${noun}`; + } + return `Runs every ${value} ${noun}s`; +} + +export interface CadenceEditorProps { + value: ScheduleCadence; + onChange: (next: ScheduleCadence) => void; + error?: string; +} + +export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) { + const mode = value.type; + + // The numeric/text fields are native-owned (AdaptiveTextInput). We seed them + // once from the incoming cadence via lazy state initializers and bump + // resetKey only when we change the content ourselves (mode switch, preset + // chip) — never on every keystroke. + const [intervalValueText, setIntervalValueText] = useState(() => + String(everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).value), + ); + const [intervalUnit, setIntervalUnit] = useState( + () => everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).unit, + ); + const [cronText, setCronText] = useState(() => + value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION, + ); + const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0); + + // Remember the cron expression the user had so toggling Interval -> Cron and + // back does not discard a blanked-out field. Interval mode rebuilds straight + // from the live numeric value + unit, so it needs no equivalent ref. + const lastCronExpression = useRef( + value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION, + ); + + const parsedIntervalValue = useMemo(() => { + const parsed = Number.parseInt(intervalValueText, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; + }, [intervalValueText]); + + const emitInterval = useCallback( + (rawValue: number, unit: IntervalUnit) => { + onChange({ type: "every", everyMs: partsToEveryMs(rawValue, unit) }); + }, + [onChange], + ); + + const emitCron = useCallback( + (expression: string) => { + lastCronExpression.current = expression; + onChange({ type: "cron", expression }); + }, + [onChange], + ); + + const handleModeChange = useCallback( + (nextMode: CadenceMode) => { + if (nextMode === mode) { + return; + } + if (nextMode === "every") { + emitInterval(parsedIntervalValue, intervalUnit); + } else { + emitCron(cronText.trim() || lastCronExpression.current); + } + }, + [mode, parsedIntervalValue, intervalUnit, cronText, emitInterval, emitCron], + ); + + const handleIntervalValueChange = useCallback( + (text: string) => { + // Keep only digits so the cadence stays a positive integer count. + const digits = text.replace(/[^0-9]/g, ""); + setIntervalValueText(digits); + const parsed = Number.parseInt(digits, 10); + emitInterval(Number.isFinite(parsed) && parsed > 0 ? parsed : 1, intervalUnit); + }, + [emitInterval, intervalUnit], + ); + + const handleUnitChange = useCallback( + (unit: IntervalUnit) => { + setIntervalUnit(unit); + emitInterval(parsedIntervalValue, unit); + }, + [emitInterval, parsedIntervalValue], + ); + + const handleCronChange = useCallback( + (text: string) => { + setCronText(text); + emitCron(text.trim()); + }, + [emitCron], + ); + + const handlePresetPress = useCallback( + (expression: string) => { + setCronText(expression); + bumpFieldResetKey(); + emitCron(expression); + }, + [emitCron], + ); + + const intervalPreview = describeInterval(parsedIntervalValue, intervalUnit); + const trimmedCron = cronText.trim(); + const cronError = trimmedCron ? validateCron(trimmedCron) : null; + const cronPreview = cronError ? null : (describeCron(trimmedCron) ?? trimmedCron); + + let cronFeedback: ReactNode = null; + if (cronError) { + cronFeedback = {cronError}; + } else if (cronPreview) { + cronFeedback = {cronPreview}; + } + + return ( + + + + {mode === "every" ? ( + + + + + + {intervalPreview} + + ) : ( + + + {CRON_PRESETS.map((preset) => ( + + ))} + + + {cronFeedback} + Times are in UTC + + )} + + {error ? {error} : null} + + ); +} + +function CronPresetChip({ + label, + expression, + isSelected, + onSelect, +}: { + label: string; + expression: string; + isSelected: boolean; + onSelect: (expression: string) => void; +}) { + const handlePress = useCallback(() => { + onSelect(expression); + }, [onSelect, expression]); + const chipStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.chip, + isSelected && styles.chipSelected, + !isSelected && (Boolean(hovered) || pressed) && styles.chipHover, + ], + [isSelected], + ); + const labelStyle = useMemo( + () => [styles.chipLabel, isSelected && styles.chipLabelSelected], + [isSelected], + ); + const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); + return ( + + + {label} + + + ); +} + +const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo"; + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[3], + }, + section: { + gap: theme.spacing[3], + }, + intervalRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + intervalInput: { + width: 88, + minHeight: 44, + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, + }, + // Both cadence segmented controls hug their options and stand at the form's + // field height, so the interval row reads as input + toggle rather than a + // full-width track with the controls floating inside it. + modeControl: { + alignSelf: "flex-start", + height: 44, + }, + unitControl: { + height: 44, + }, + presetRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing[2], + }, + chip: { + minHeight: 32, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, + }, + chipHover: { + backgroundColor: theme.colors.surface3, + }, + chipSelected: { + backgroundColor: theme.colors.accent, + borderColor: theme.colors.accent, + }, + chipLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + }, + chipLabelSelected: { + color: theme.colors.accentForeground, + }, + cronInput: { + minHeight: 44, + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.sm, + fontFamily: MONOSPACE_FONT, + }, + preview: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + hint: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + error: { + fontSize: theme.fontSize.xs, + color: theme.colors.destructive, + }, +})); diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx new file mode 100644 index 000000000..08607b1f2 --- /dev/null +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -0,0 +1,867 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type ReactNode, +} from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { ChevronDown, Folder } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import { + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; +import { Button } from "@/components/ui/button"; +import { CombinedModelSelector } from "@/components/combined-model-selector"; +import { getProviderIcon } from "@/components/provider-icons"; +import { CadenceEditor } from "@/components/schedules/cadence-editor"; +import { useScheduleMutations } from "@/hooks/use-schedule-mutations"; +import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state"; +import { useProjects } from "@/hooks/use-projects"; +import { validateCron } from "@/utils/schedule-format"; +import { toErrorMessage } from "@/utils/error-messages"; +import { shortenPath } from "@/utils/shorten-path"; +import type { ProjectSummary } from "@/utils/projects"; +import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; + +const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 }; +const PROJECT_OPTION_PREFIX = "project:"; + +export interface ScheduleFormSheetProps { + serverId?: string; + visible: boolean; + onClose: () => void; + mode: "create" | "edit"; + schedule?: ScheduleSummary; +} + +interface ScheduleProjectTarget { + optionId: string; + serverId: string; + serverName: string; + projectKey: string; + projectName: string; + cwd: string; +} + +interface ScheduleProjectOptions { + targets: ScheduleProjectTarget[]; + options: ComboboxOption[]; + targetByOptionId: Map; +} + +// The model/cwd config only exists on new-agent schedules; this screen filters +// to that target, but guard anyway so prefill stays type-safe. +function newAgentConfig(schedule: ScheduleSummary | undefined) { + if (schedule && schedule.target.type === "new-agent") { + return schedule.target.config; + } + return null; +} + +function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialValues | undefined { + const config = newAgentConfig(schedule); + if (!config) { + return undefined; + } + return { + provider: config.provider as AgentProvider, + model: config.model ?? null, + modeId: config.modeId ?? null, + workingDir: config.cwd, + }; +} + +function buildProjectOptionId(serverId: string, projectKey: string): string { + return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`; +} + +function buildProjectOptionTestId(optionId: string): string { + const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, ""); + return `schedule-project-option-${targetKey}`; +} + +function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions { + const targets: ScheduleProjectTarget[] = []; + const targetByOptionId = new Map(); + const options: ComboboxOption[] = []; + + for (const project of projects) { + for (const host of project.hosts) { + const cwd = host.repoRoot.trim(); + if (!host.isOnline || !cwd) { + continue; + } + const target: ScheduleProjectTarget = { + optionId: buildProjectOptionId(host.serverId, project.projectKey), + serverId: host.serverId, + serverName: host.serverName, + projectKey: project.projectKey, + projectName: project.projectName, + cwd, + }; + targets.push(target); + targetByOptionId.set(target.optionId, target); + options.push({ + id: target.optionId, + label: target.projectName, + description: `${target.serverName} - ${shortenPath(cwd)}`, + }); + } + } + + return { targets, options, targetByOptionId }; +} + +function resolveSelectedScheduleProjectTarget(input: { + targets: readonly ScheduleProjectTarget[]; + serverId: string | null; + cwd: string; +}): ScheduleProjectTarget | null { + const cwd = input.cwd.trim(); + if (!input.serverId || !cwd) { + return null; + } + return ( + input.targets.find((target) => target.serverId === input.serverId && target.cwd === cwd) ?? null + ); +} + +function isSelectedModelValidForProviders(input: { + providers: ProviderSelectorProvider[]; + selectedProvider: AgentProvider | null; + selectedModel: string; +}): boolean { + if (!input.selectedProvider) { + return false; + } + const provider = input.providers.find((entry) => entry.id === input.selectedProvider); + if (!provider || provider.modelSelection.kind !== "models") { + return false; + } + const selectedModel = input.selectedModel.trim(); + if (!selectedModel) { + return true; + } + return provider.modelSelection.rows.some((row) => row.modelId === selectedModel); +} + +export function ScheduleFormSheet({ + serverId, + visible, + onClose, + mode, + schedule, +}: ScheduleFormSheetProps): ReactElement { + const isEdit = mode === "edit"; + const editConfig = newAgentConfig(schedule); + const { projects } = useProjects(); + const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]); + + const onlineServerIds = useMemo( + () => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))), + [projectOptions.targets], + ); + const initialValues = useMemo( + () => (isEdit ? buildInitialValues(schedule) : undefined), + [isEdit, schedule], + ); + + // isCreateFlow drives useAgentFormState's RESOLVE pass that applies + // initialValues. We want that for edit too (to prefill the picker fields from + // the schedule's config), so this stays true in both modes: the form is + // always a "fill these fields" flow, seeded either from preferences (create) + // or from the schedule (edit). + const form = useAgentFormState({ + initialServerId: serverId ?? null, + initialValues, + isVisible: visible, + isCreateFlow: true, + onlineServerIds, + }); + + const { + selectedServerId, + selectedProvider, + selectedModel, + selectedMode, + selectedThinkingOptionId, + workingDir, + setProviderAndModelFromUser, + clearProviderSelectionFromUser, + setModeFromUser, + setSelectedServerIdFromUser, + setWorkingDirFromUser, + modeOptions, + modelSelectorProviders, + isAllModelsLoading, + persistFormPreferences, + } = form; + + const selectedProjectTarget = useMemo( + () => + resolveSelectedScheduleProjectTarget({ + targets: projectOptions.targets, + serverId: selectedServerId, + cwd: workingDir, + }), + [projectOptions.targets, selectedServerId, workingDir], + ); + const selectedProjectOptionId = selectedProjectTarget?.optionId ?? ""; + const mutationServerId = selectedProjectTarget?.serverId ?? selectedServerId ?? serverId ?? ""; + + const handleSelectProject = useCallback( + (target: ScheduleProjectTarget) => { + if (selectedProjectTarget && selectedProjectTarget.serverId !== target.serverId) { + clearProviderSelectionFromUser(); + } + setSelectedServerIdFromUser(target.serverId); + setWorkingDirFromUser(target.cwd); + }, + [ + clearProviderSelectionFromUser, + selectedProjectTarget, + setSelectedServerIdFromUser, + setWorkingDirFromUser, + ], + ); + + // One nested control selects provider -> model (the draft screen's selector). + // Render it as a full-width field that leads with the provider glyph and mutes + // its placeholder, matching the working-directory field. + const renderModelTrigger = useCallback( + ({ + selectedModelLabel, + disabled, + isOpen, + hovered, + pressed, + }: { + selectedModelLabel: string; + onPress: () => void; + disabled: boolean; + isOpen: boolean; + hovered: boolean; + pressed: boolean; + }): ReactNode => ( + + ), + [selectedModel, selectedProvider], + ); + + const { createSchedule, updateSchedule, isCreating, isUpdating } = useScheduleMutations({ + serverId: mutationServerId, + }); + const isSubmitting = isCreating || isUpdating; + + // Name / prompt / cadence / maxRuns are local to this form, not part of + // useAgentFormState. Seed once per open from the schedule being edited. + const [name, setName] = useState(() => schedule?.name ?? ""); + const [prompt, setPrompt] = useState(() => schedule?.prompt ?? ""); + const [maxRuns, setMaxRuns] = useState(() => + schedule?.maxRuns != null ? String(schedule.maxRuns) : "", + ); + const [cadence, setCadence] = useState( + () => schedule?.cadence ?? DEFAULT_CADENCE, + ); + const [submitError, setSubmitError] = useState(null); + const [fieldResetKey, setFieldResetKey] = useState(0); + + // The sheet stays mounted across opens, so the lazy initializers above only + // run once. Re-seed the locally-owned fields (name/prompt/cadence/maxRuns) + // each time the sheet transitions closed -> open; the picker fields are + // re-seeded by useAgentFormState from initialValues on the same flip. + const wasVisibleRef = useRef(false); + useEffect(() => { + if (visible && !wasVisibleRef.current) { + setName(schedule?.name ?? ""); + setPrompt(schedule?.prompt ?? ""); + setMaxRuns(schedule?.maxRuns != null ? String(schedule.maxRuns) : ""); + setCadence(schedule?.cadence ?? DEFAULT_CADENCE); + setSubmitError(null); + setFieldResetKey((key) => key + 1); + } + wasVisibleRef.current = visible; + }, [visible, schedule]); + + const promptTrimmed = prompt.trim(); + const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null; + const selectedModelIsValid = isSelectedModelValidForProviders({ + providers: modelSelectorProviders, + selectedProvider, + selectedModel, + }); + const canSubmit = + promptTrimmed.length > 0 && + selectedModelIsValid && + Boolean(selectedProjectTarget) && + cadenceError === null && + !isSubmitting; + + const handleSubmit = useCallback(async () => { + if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) { + return; + } + setSubmitError(null); + try { + await persistFormPreferences(); + const parsedMaxRuns = Number.parseInt(maxRuns, 10); + const maxRunsValue = + Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null; + + if (isEdit && schedule) { + await updateSchedule({ + id: schedule.id, + name: name.trim() || null, + prompt: promptTrimmed, + cadence, + newAgentConfig: { + provider: selectedProvider, + model: selectedModel || null, + modeId: selectedMode || null, + cwd: selectedProjectTarget.cwd, + }, + maxRuns: maxRunsValue, + }); + } else { + await createSchedule({ + prompt: promptTrimmed, + name: name.trim() || undefined, + cadence, + target: { + type: "new-agent", + config: { + provider: selectedProvider, + cwd: selectedProjectTarget.cwd, + model: selectedModel || undefined, + modeId: selectedMode || undefined, + thinkingOptionId: selectedThinkingOptionId || undefined, + title: name.trim() || undefined, + }, + }, + ...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}), + }); + } + onClose(); + } catch (error) { + setSubmitError(toErrorMessage(error)); + } + }, [ + cadence, + createSchedule, + isEdit, + maxRuns, + name, + onClose, + persistFormPreferences, + promptTrimmed, + schedule, + selectedMode, + selectedModel, + selectedProjectTarget, + selectedProvider, + selectedThinkingOptionId, + updateSchedule, + ]); + + const handleSubmitPress = useCallback(() => { + void handleSubmit(); + }, [handleSubmit]); + + const header = useMemo( + () => ({ title: isEdit ? "Edit schedule" : "New schedule" }), + [isEdit], + ); + + const footer = useMemo( + () => ( + + + + + ), + [canSubmit, handleSubmitPress, isEdit, isSubmitting, onClose], + ); + + return ( + + + Name + + + + + Prompt + + + + + Project + + + + + Model + + + + {modeOptions.length > 0 ? ( + + ) : null} + + + Cadence + + + + + Max runs + + Leave blank to run indefinitely + + + {editConfig === null && isEdit ? ( + This schedule does not target a new agent. + ) : null} + + {submitError ? {submitError} : null} + + ); +} + +// --------------------------------------------------------------------------- +// Mode field - Combobox over the selected provider's modes. +// --------------------------------------------------------------------------- + +function ModeField({ + options, + selectedMode, + onSelect, +}: { + options: { id: string; label: string }[]; + selectedMode: string; + onSelect: (modeId: string) => void; +}): ReactElement { + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + + const comboboxOptions = useMemo( + () => options.map((option) => ({ id: option.id, label: option.label })), + [options], + ); + + const selectedLabel = + options.find((option) => option.id === selectedMode)?.label ?? "Default mode"; + + const handleSelect = useCallback( + (id: string) => { + onSelect(id); + setOpen(false); + }, + [onSelect], + ); + + const handlePress = useCallback(() => { + setOpen((current) => !current); + }, []); + + const triggerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.selectTrigger, + (Boolean(hovered) || pressed || open) && styles.selectTriggerActive, + ], + [open], + ); + + return ( + + Mode + + + + {selectedLabel} + + + + + 6} + title="Select mode" + open={open} + onOpenChange={setOpen} + anchorRef={anchorRef} + desktopPlacement="bottom-start" + /> + + ); +} + +function ProjectField({ + options, + targetByOptionId, + value, + selectedTarget, + onSelect, +}: { + options: ComboboxOption[]; + targetByOptionId: Map; + value: string; + selectedTarget: ScheduleProjectTarget | null; + onSelect: (target: ScheduleProjectTarget) => void; +}): ReactElement { + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + + const handleSelect = useCallback( + (id: string) => { + const target = targetByOptionId.get(id); + if (!target) { + return; + } + onSelect(target); + setOpen(false); + }, + [onSelect, targetByOptionId], + ); + + const handlePress = useCallback(() => { + setOpen((current) => !current); + }, []); + + const triggerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.selectTrigger, + (Boolean(hovered) || pressed || open) && styles.selectTriggerActive, + ], + [open], + ); + + const displayValue = selectedTarget?.projectName ?? "Select project"; + const description = selectedTarget + ? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}` + : null; + + const renderOption = useCallback( + ({ + option, + selected, + active, + onPress, + }: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => ( + + ), + [], + ); + + return ( + <> + + + + {displayValue} + + + + + {description ? {description} : null} + + + ); +} + +function ProjectOptionItem({ + option, + selected, + active, + onPress, +}: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; +}): ReactElement { + const leadingSlot = useMemo( + () => ( + + + + ), + [], + ); + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Shared bits +// --------------------------------------------------------------------------- + +/** Dynamic provider glyph - reads its color off a StyleSheet object so the + * runtime-resolved component stays compliant without useUnistyles. */ +function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { + if (!provider) { + return null; + } + const Icon = getProviderIcon(provider); + return ; +} + +// Non-interactive field rendered inside CombinedModelSelector's trigger (with +// triggerFill). The selector's outer Pressable owns press/hover; this leaf just +// paints the field and reads `active` for the focus border. +function ModelTrigger({ + label, + provider, + disabled, + active, + isPlaceholder, +}: { + label: string; + provider: string | null; + disabled: boolean; + active: boolean; + isPlaceholder: boolean; +}): ReactElement { + const containerStyle = useMemo( + () => [ + styles.selectTrigger, + active && styles.selectTriggerActive, + disabled && styles.selectTriggerDisabled, + ], + [active, disabled], + ); + return ( + + + + {label} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + field: { + gap: theme.spacing[2], + }, + label: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + input: { + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + color: theme.colors.foreground, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, + }, + multilineInput: { + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + color: theme.colors.foreground, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, + minHeight: 96, + }, + hint: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + error: { + color: theme.colors.destructive, + fontSize: theme.fontSize.xs, + }, + selectTrigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + minHeight: 44, + }, + selectTriggerActive: { + borderColor: theme.colors.borderAccent, + }, + selectTriggerDisabled: { + opacity: theme.opacity[50], + }, + selectTriggerText: { + flex: 1, + minWidth: 0, + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + selectTriggerPlaceholder: { + flex: 1, + minWidth: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, + optionIconBox: { + width: 18, + height: 18, + alignItems: "center", + justifyContent: "center", + }, + footer: { + flex: 1, + flexDirection: "row", + gap: theme.spacing[3], + }, + footerButton: { + flex: 1, + }, + // Static color holders read by the dynamic provider icon + chevron (compliant + // idiom - no useUnistyles in render). + providerIcon: { + color: theme.colors.foregroundMuted, + }, + chevron: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx new file mode 100644 index 000000000..a24eb8e25 --- /dev/null +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -0,0 +1,339 @@ +import { MoreVertical, Pause, Pencil, Play, RotateCw, Trash2 } from "lucide-react-native"; +import { useCallback, useState, type ReactElement } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { getProviderIcon } from "@/components/provider-icons"; +import { isNative } from "@/constants/platform"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { settingsStyles } from "@/styles/settings"; +import type { Theme } from "@/styles/theme"; +import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format"; +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; + +// Themed lucide wrappers — module-scope so only the icon re-renders on theme +// change (never call useUnistyles in render). See docs/unistyles.md. +const ThemedPencil = withUnistyles(Pencil); +const ThemedPause = withUnistyles(Pause); +const ThemedPlay = withUnistyles(Play); +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedTrash2 = withUnistyles(Trash2); +const ThemedKebab = withUnistyles(MoreVertical); + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); + +const MENU_ICON_SIZE = 14; +const PROVIDER_ICON_SIZE = 16; + +// Pending flags for each action so the parent table can wire a mutation hook +// and the row reflects in-flight state without owning the mutation itself. +export interface ScheduleRowPending { + pause?: boolean; + resume?: boolean; + runNow?: boolean; + delete?: boolean; +} + +export interface ScheduleRowActions { + onEdit: () => void; + onPause: () => void; + onResume: () => void; + onRunNow: () => void; + onDelete: () => void; +} + +interface ScheduleRowProps extends ScheduleRowActions { + schedule: ScheduleSummary; + pending?: ScheduleRowPending; + isFirst: boolean; +} + +function resolveProvider(schedule: ScheduleSummary): string | null { + return schedule.target.type === "new-agent" ? schedule.target.config.provider : null; +} + +function resolveModelLabel(schedule: ScheduleSummary): string { + if (schedule.target.type === "new-agent" && schedule.target.config.model) { + return schedule.target.config.model; + } + return "Default model"; +} + +function statusVariant(status: ScheduleSummary["status"]): "success" | "muted" { + return status === "active" ? "success" : "muted"; +} + +function statusLabel(status: ScheduleSummary["status"]): string { + if (status === "active") { + return "Active"; + } + if (status === "paused") { + return "Paused"; + } + return "Completed"; +} + +function nextRunLabel(schedule: ScheduleSummary): string { + if (schedule.status === "paused") { + return "Paused"; + } + if (schedule.status === "completed") { + return "Completed"; + } + return formatNextRun(schedule.nextRunAt) || "—"; +} + +/** Small provider glyph. Reads the icon color off a StyleSheet object so the + * dynamic component (getProviderIcon) stays compliant without useUnistyles. */ +function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { + if (!provider) { + return null; + } + const Icon = getProviderIcon(provider); + return ; +} + +/** + * One schedule, rendered as a settings-style card row: provider glyph + title, + * a muted secondary line (model · cadence · next run), a StatusBadge, and the + * kebab menu that owns every row action. Tapping the row opens the editor. + * + * Hover lives on the outer plain View (docs/hover.md): the inner Pressable owns + * press, the nested kebab Pressable never fights it, and the row background + * highlights without reflow. + */ +export function ScheduleRow({ + schedule, + pending, + isFirst, + onEdit, + onPause, + onResume, + onRunNow, + onDelete, +}: ScheduleRowProps): ReactElement { + const isCompact = useIsCompactFormFactor(); + const [isHovered, setIsHovered] = useState(false); + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + + const provider = resolveProvider(schedule); + const title = resolveScheduleTitle(schedule); + const meta = [ + resolveModelLabel(schedule), + formatCadence(schedule.cadence), + nextRunLabel(schedule), + ] + .filter(Boolean) + .join(" · "); + + const rowStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + settingsStyles.row, + styles.row, + !isFirst && settingsStyles.rowBorder, + isHovered && !isCompact && styles.rowHovered, + pressed && styles.rowPressed, + ], + [isFirst, isHovered, isCompact], + ); + + return ( + + + + + + + + + {title} + + + {meta} + + + + + + + + + + + ); +} + +const editLeading = ; +const pauseLeading = ; +const resumeLeading = ; +const runLeading = ; +const deleteLeading = ; + +function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement { + return ( + + ); +} + +function ScheduleKebabMenu({ + schedule, + pending, + onEdit, + onPause, + onResume, + onRunNow, + onDelete, +}: Omit): ReactElement { + return ( + + + {renderKebabTriggerIcon} + + + + Edit schedule + + {schedule.status === "paused" ? ( + + Resume schedule + + ) : ( + + Pause schedule + + )} + + Run now + + + + Delete schedule + + + + ); +} + +function kebabTriggerStyle({ + hovered = false, +}: PressableStateCallbackType & { hovered?: boolean }) { + return [styles.kebabTrigger, hovered && styles.kebabTriggerHovered]; +} + +const styles = StyleSheet.create((theme) => ({ + // Static color holder for the dynamic provider icon (compliant idiom). + providerIcon: { + color: theme.colors.foregroundMuted, + }, + rowContainer: { + position: "relative", + }, + row: { + gap: theme.spacing[3], + }, + rowHovered: { + backgroundColor: theme.colors.surface2, + }, + rowPressed: { + backgroundColor: theme.colors.surface3, + }, + main: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + leading: { + width: PROVIDER_ICON_SIZE, + height: PROVIDER_ICON_SIZE, + alignItems: "center", + justifyContent: "center", + }, + textGroup: { + flex: 1, + minWidth: 0, + }, + trailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + kebabTrigger: { + padding: theme.spacing[1], + borderRadius: theme.borderRadius.base, + }, + kebabTriggerHovered: { + backgroundColor: theme.colors.surface2, + }, +})); diff --git a/packages/app/src/components/schedules/schedules-table.tsx b/packages/app/src/components/schedules/schedules-table.tsx new file mode 100644 index 000000000..3ab1ce96b --- /dev/null +++ b/packages/app/src/components/schedules/schedules-table.tsx @@ -0,0 +1,151 @@ +import { useCallback, useState, type ReactElement } from "react"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row"; +import { useScheduleMutations } from "@/hooks/use-schedule-mutations"; +import { settingsStyles } from "@/styles/settings"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { resolveScheduleTitle } from "@/utils/schedule-format"; +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; + +interface SchedulesTableProps { + serverId: string; + schedules: ScheduleSummary[]; + /** + * The form sheet is owned by the screen (it serves both create and edit and + * shares the header's "New schedule" button), so the table delegates edit + * upward rather than mounting a second sheet here. + */ + onEditSchedule: (schedule: ScheduleSummary) => void; +} + +/** + * The schedules list: a single settings-style card of rows in a centered, + * width-constrained reading column, matching the projects list. Owns row-level + * actions (pause/resume/run/delete via the mutations hook + a destructive + * confirm for delete) and delegates editing to the parent. + */ +export function SchedulesTable({ + serverId, + schedules, + onEditSchedule, +}: SchedulesTableProps): ReactElement { + const mutations = useScheduleMutations({ serverId }); + + return ( + + + {schedules.map((schedule, index) => ( + + ))} + + + ); +} + +// --------------------------------------------------------------------------- +// Per-row wrapper owns local in-flight state and binds the table's mutation +// callbacks to this schedule. Local state keeps pending precise to the acting +// row even when several rows are acted on at once (the mutations hook exposes +// only a single global pending flag per action). +// --------------------------------------------------------------------------- + +type ScheduleMutations = ReturnType; + +const NO_PENDING: ScheduleRowPending = {}; + +function SchedulesTableRow({ + schedule, + isFirst, + mutations, + onEditSchedule, +}: { + schedule: ScheduleSummary; + isFirst: boolean; + mutations: ScheduleMutations; + onEditSchedule: (schedule: ScheduleSummary) => void; +}): ReactElement { + const { id } = schedule; + const [pending, setPending] = useState(NO_PENDING); + + const runAction = useCallback( + async (key: keyof ScheduleRowPending, action: () => Promise): Promise => { + setPending((current) => ({ ...current, [key]: true })); + try { + await action(); + } catch { + // Mutations roll back their own optimistic cache writes on error and + // re-fetch on settle; surfacing per-row toasts here is out of scope. + } finally { + setPending((current) => { + const next = { ...current }; + delete next[key]; + return next; + }); + } + }, + [], + ); + + const handleEdit = useCallback(() => { + onEditSchedule(schedule); + }, [onEditSchedule, schedule]); + + const handlePause = useCallback(() => { + void runAction("pause", () => mutations.pauseSchedule(id)); + }, [runAction, mutations, id]); + + const handleResume = useCallback(() => { + void runAction("resume", () => mutations.resumeSchedule(id)); + }, [runAction, mutations, id]); + + const handleRunNow = useCallback(() => { + void runAction("runNow", () => mutations.runScheduleNow(id)); + }, [runAction, mutations, id]); + + const handleDelete = useCallback(() => { + void (async () => { + const confirmed = await confirmDialog({ + title: "Delete schedule", + message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`, + confirmLabel: "Delete", + destructive: true, + }); + if (!confirmed) { + return; + } + await runAction("delete", () => mutations.deleteSchedule(id)); + })(); + }, [runAction, mutations, id, schedule]); + + return ( + + ); +} + +const CONTENT_MAX_WIDTH = 720; + +const styles = StyleSheet.create((theme) => ({ + // Center the card in a readable column, matching settings and projects. + listContent: { + width: "100%", + maxWidth: CONTENT_MAX_WIDTH, + alignSelf: "center", + paddingHorizontal: theme.spacing[4], + }, +})); diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 95812bec9..bb780ad04 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -76,6 +76,7 @@ export interface UseAgentFormStateResult { refreshProviderModels: (provider?: AgentProvider) => void; refetchProviderModelsIfStale: () => void; setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void; + clearProviderSelectionFromUser: () => void; workingDirIsEmpty: boolean; persistFormPreferences: () => Promise; } @@ -404,6 +405,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ], ); + const clearProviderSelectionFromUser = useCallback(() => { + dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" }); + }, []); + const setModeFromUser = useCallback( (modeId: string) => { dispatch({ type: "SET_MODE_FROM_USER", modeId }); @@ -547,6 +552,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg refreshProviderModels, refetchProviderModelsIfStale, setProviderAndModelFromUser, + clearProviderSelectionFromUser, workingDirIsEmpty, persistFormPreferences, }), @@ -581,6 +587,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg refreshProviderModels, refetchProviderModelsIfStale, setProviderAndModelFromUser, + clearProviderSelectionFromUser, workingDirIsEmpty, persistFormPreferences, ], diff --git a/packages/app/src/hooks/use-projects.ts b/packages/app/src/hooks/use-projects.ts index 7c25d7bfe..b5b038b4b 100644 --- a/packages/app/src/hooks/use-projects.ts +++ b/packages/app/src/hooks/use-projects.ts @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useSyncExternalStore } from "react"; import { useQuery } from "@tanstack/react-query"; import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import type { ProjectSummary } from "@/utils/projects"; @@ -16,6 +16,10 @@ export type { export const projectsQueryKey = ["projects"] as const; +function projectsQueryRuntimeKey(hosts: readonly ProjectsHostInput[]) { + return hosts.map((host) => host.serverId).join("|"); +} + export interface UseProjectsResult { projects: ProjectSummary[]; hostErrors: ProjectHostError[]; @@ -27,6 +31,11 @@ export interface UseProjectsResult { export function useProjects(): UseProjectsResult { const hosts = useHosts(); const runtime = getHostRuntimeStore(); + const runtimeVersion = useSyncExternalStore( + (onStoreChange) => runtime.subscribeAll(onStoreChange), + () => runtime.getVersion(), + () => runtime.getVersion(), + ); const hostInputs = useMemo( () => hosts.map((host) => ({ @@ -37,8 +46,9 @@ export function useProjects(): UseProjectsResult { ); const projectsQuery = useQuery({ - queryKey: projectsQueryKey, + queryKey: [...projectsQueryKey, projectsQueryRuntimeKey(hostInputs), runtimeVersion] as const, queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }), + staleTime: 5_000, }); return { diff --git a/packages/app/src/hooks/use-schedule-mutations.ts b/packages/app/src/hooks/use-schedule-mutations.ts new file mode 100644 index 000000000..8deeea1e7 --- /dev/null +++ b/packages/app/src/hooks/use-schedule-mutations.ts @@ -0,0 +1,277 @@ +import { useCallback } from "react"; +import { + useMutation, + useQueryClient, + type QueryClient, + type QueryKey, +} from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import type { + CreateScheduleOptions, + DaemonClient, + UpdateScheduleOptions, +} from "@getpaseo/client/internal/daemon-client"; +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import { schedulesQueryBaseKey } from "@/hooks/use-schedules"; +import type { FetchAggregatedSchedulesResult } from "@/schedules/aggregated-schedules"; +import { useSessionStore } from "@/stores/session-store"; + +export type CreateScheduleInput = Omit; +export type UpdateScheduleInput = Omit; + +export interface UseScheduleMutationsResult { + createSchedule: (input: CreateScheduleInput) => Promise; + updateSchedule: (input: UpdateScheduleInput) => Promise; + pauseSchedule: (id: string) => Promise; + resumeSchedule: (id: string) => Promise; + deleteSchedule: (id: string) => Promise; + runScheduleNow: (id: string) => Promise; + isCreating: boolean; + isUpdating: boolean; + isPausing: boolean; + isResuming: boolean; + isDeleting: boolean; + isRunningNow: boolean; +} + +interface ScheduleListSnapshot { + previous: Array<[QueryKey, FetchAggregatedSchedulesResult | undefined]>; +} + +function requireClient(serverId: string, unavailableMessage: string): DaemonClient { + const client = useSessionStore.getState().sessions[serverId]?.client ?? null; + if (!client) { + throw new Error(unavailableMessage); + } + return client; +} + +function snapshotSchedules(queryClient: QueryClient): ScheduleListSnapshot { + return { + previous: queryClient.getQueriesData({ + queryKey: schedulesQueryBaseKey, + }), + }; +} + +function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapshot): void { + for (const [queryKey, previous] of snapshot.previous) { + queryClient.setQueryData(queryKey, previous); + } +} + +function updateScheduleSections( + queryClient: QueryClient, + updateSection: ( + section: FetchAggregatedSchedulesResult["sections"][number], + ) => FetchAggregatedSchedulesResult["sections"][number], +): void { + queryClient.setQueriesData( + { queryKey: schedulesQueryBaseKey }, + (current) => { + if (!current) { + return current; + } + return { sections: current.sections.map(updateSection) }; + }, + ); +} + +function optimisticallySetStatus( + queryClient: QueryClient, + serverId: string, + id: string, + status: ScheduleSummary["status"], +): void { + const pausedAt = status === "paused" ? new Date().toISOString() : null; + updateScheduleSections(queryClient, (section) => + section.serverId === serverId + ? { + ...section, + schedules: section.schedules.map((schedule) => + schedule.id === id ? { ...schedule, status, pausedAt } : schedule, + ), + } + : section, + ); +} + +function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void { + updateScheduleSections(queryClient, (section) => + section.serverId === serverId + ? { + ...section, + schedules: section.schedules.filter((schedule) => schedule.id !== id), + } + : section, + ); +} + +export function useScheduleMutations({ + serverId, +}: { + serverId: string; +}): UseScheduleMutationsResult { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + const invalidate = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey }); + }, [queryClient]); + + const createMutation = useMutation({ + mutationFn: async (input: CreateScheduleInput): Promise => { + const client = requireClient(serverId, t("common.errors.daemonClientUnavailable")); + const payload = await client.scheduleCreate(input); + if (payload.error) { + throw new Error(payload.error); + } + }, + onSettled: invalidate, + }); + + const updateMutation = useMutation({ + mutationFn: async (input: UpdateScheduleInput): Promise => { + const client = requireClient(serverId, t("common.errors.daemonClientUnavailable")); + const payload = await client.scheduleUpdate(input); + if (payload.error) { + throw new Error(payload.error); + } + }, + onSettled: invalidate, + }); + + const pauseMutation = useMutation({ + mutationFn: async (id: string): Promise => { + const client = requireClient(serverId, t("common.errors.daemonClientUnavailable")); + const payload = await client.schedulePause({ id }); + if (payload.error) { + throw new Error(payload.error); + } + }, + onMutate: async (id): Promise => { + await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey }); + const snapshot = snapshotSchedules(queryClient); + optimisticallySetStatus(queryClient, serverId, id, "paused"); + return snapshot; + }, + onError: (_error, _id, context) => { + if (context) { + restoreSchedules(queryClient, context); + } + }, + onSettled: invalidate, + }); + + const resumeMutation = useMutation({ + mutationFn: async (id: string): Promise => { + const client = requireClient(serverId, t("common.errors.daemonClientUnavailable")); + const payload = await client.scheduleResume({ id }); + if (payload.error) { + throw new Error(payload.error); + } + }, + onMutate: async (id): Promise => { + await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey }); + const snapshot = snapshotSchedules(queryClient); + optimisticallySetStatus(queryClient, serverId, id, "active"); + return snapshot; + }, + onError: (_error, _id, context) => { + if (context) { + restoreSchedules(queryClient, context); + } + }, + onSettled: invalidate, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string): Promise => { + const client = requireClient(serverId, t("common.errors.daemonClientUnavailable")); + const payload = await client.scheduleDelete({ id }); + if (payload.error) { + throw new Error(payload.error); + } + }, + onMutate: async (id): Promise => { + await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey }); + const snapshot = snapshotSchedules(queryClient); + optimisticallyRemove(queryClient, serverId, id); + return snapshot; + }, + onError: (_error, _id, context) => { + if (context) { + restoreSchedules(queryClient, context); + } + }, + onSettled: invalidate, + }); + + const runNowMutation = useMutation({ + mutationFn: async (id: string): Promise => { + const client = requireClient(serverId, t("common.errors.daemonClientUnavailable")); + const payload = await client.scheduleRunOnce({ id }); + if (payload.error) { + throw new Error(payload.error); + } + }, + onSettled: invalidate, + }); + + const createSchedule = useCallback( + async (input: CreateScheduleInput): Promise => { + await createMutation.mutateAsync(input); + }, + [createMutation], + ); + + const updateSchedule = useCallback( + async (input: UpdateScheduleInput): Promise => { + await updateMutation.mutateAsync(input); + }, + [updateMutation], + ); + + const pauseSchedule = useCallback( + async (id: string): Promise => { + await pauseMutation.mutateAsync(id); + }, + [pauseMutation], + ); + + const resumeSchedule = useCallback( + async (id: string): Promise => { + await resumeMutation.mutateAsync(id); + }, + [resumeMutation], + ); + + const deleteSchedule = useCallback( + async (id: string): Promise => { + await deleteMutation.mutateAsync(id); + }, + [deleteMutation], + ); + + const runScheduleNow = useCallback( + async (id: string): Promise => { + await runNowMutation.mutateAsync(id); + }, + [runNowMutation], + ); + + return { + createSchedule, + updateSchedule, + pauseSchedule, + resumeSchedule, + deleteSchedule, + runScheduleNow, + isCreating: createMutation.isPending, + isUpdating: updateMutation.isPending, + isPausing: pauseMutation.isPending, + isResuming: resumeMutation.isPending, + isDeleting: deleteMutation.isPending, + isRunningNow: runNowMutation.isPending, + }; +} diff --git a/packages/app/src/hooks/use-schedules.ts b/packages/app/src/hooks/use-schedules.ts new file mode 100644 index 000000000..66cbbcfef --- /dev/null +++ b/packages/app/src/hooks/use-schedules.ts @@ -0,0 +1,60 @@ +import { useMemo, useSyncExternalStore } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; +import { + fetchAggregatedSchedules, + type ScheduleHostInput, + type ScheduleHostSection, +} from "@/schedules/aggregated-schedules"; + +export type { ScheduleHostSection } from "@/schedules/aggregated-schedules"; + +export const schedulesQueryBaseKey = ["schedules"] as const; + +export function schedulesQueryKey(hosts: readonly ScheduleHostInput[]) { + return [...schedulesQueryBaseKey, hosts.map((host) => host.serverId).join("|")] as const; +} + +export interface UseSchedulesResult { + sections: ScheduleHostSection[]; + isLoading: boolean; + isError: boolean; + error: Error | null; + refetch: () => void; + isRefetching: boolean; +} + +export function useSchedules(): UseSchedulesResult { + const hosts = useHosts(); + const runtime = getHostRuntimeStore(); + const runtimeVersion = useSyncExternalStore( + (onStoreChange) => runtime.subscribeAll(onStoreChange), + () => runtime.getVersion(), + () => runtime.getVersion(), + ); + const hostInputs = useMemo( + () => + hosts.map((host) => ({ + serverId: host.serverId, + serverName: host.label, + })), + [hosts], + ); + + const query = useQuery({ + queryKey: [...schedulesQueryKey(hostInputs), runtimeVersion] as const, + queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }), + staleTime: 5_000, + }); + + return { + sections: query.data?.sections ?? [], + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + refetch: () => { + void query.refetch(); + }, + isRefetching: query.isRefetching, + }; +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index cb9fd0844..7aaecc7aa 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -789,6 +789,7 @@ export const ar: TranslationResources = { }, sections: { sessions: "السجل", + schedules: "الجداول", }, worktreeSetup: { title: "إعداد البرامج النصية لشجرة العمل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index ff2702b90..3bfc0079a 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -796,6 +796,7 @@ export const en = { }, sections: { sessions: "History", + schedules: "Schedules", }, worktreeSetup: { title: "Set up worktree scripts", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1fbb47b2b..115baa26b 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -816,6 +816,7 @@ export const es: TranslationResources = { }, sections: { sessions: "Historial", + schedules: "Horarios", }, worktreeSetup: { title: "Configurar secuencias de comandos del árbol de trabajo", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 54939303b..7dce73b4a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -815,6 +815,7 @@ export const fr: TranslationResources = { }, sections: { sessions: "Historique", + schedules: "Planifications", }, worktreeSetup: { title: "Configurer les scripts d'arbre de travail", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index f854c8b6f..9075c9014 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -801,6 +801,7 @@ export const ja: TranslationResources = { }, sections: { sessions: "履歴", + schedules: "スケジュール", }, worktreeSetup: { title: "ワークツリースクリプトを設定", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 8b8ba6929..4af64d0b2 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -807,6 +807,7 @@ export const ptBR: TranslationResources = { }, sections: { sessions: "Histórico", + schedules: "Agendamentos", }, worktreeSetup: { title: "Configurar scripts de worktree", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9418288a6..3c55ccb05 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -808,6 +808,7 @@ export const ru: TranslationResources = { }, sections: { sessions: "История", + schedules: "Расписания", }, worktreeSetup: { title: "Настройка сценариев рабочего дерева", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 2da0f70e1..fcadb9ff0 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -783,6 +783,7 @@ export const zhCN: TranslationResources = { }, sections: { sessions: "历史", + schedules: "计划", }, worktreeSetup: { title: "设置 worktree scripts", diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index c0e9ba8ec..e6c7303ba 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -90,6 +90,7 @@ export type AgentFormAction = modelId: string; availableModels: AgentModelDefinition[] | null; } + | { type: "CLEAR_PROVIDER_SELECTION_FROM_USER" } | { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string } | { type: "SET_WORKING_DIR"; value: string } | { type: "SET_WORKING_DIR_FROM_USER"; value: string } @@ -565,6 +566,24 @@ export function resolveAgentForm( }; } + case "CLEAR_PROVIDER_SELECTION_FROM_USER": + return { + form: { + ...state.form, + provider: null, + model: "", + modeId: "", + thinkingOptionId: "", + }, + userModified: { + ...state.userModified, + provider: true, + model: true, + modeId: true, + thinkingOptionId: true, + }, + }; + case "SET_THINKING_OPTION_FROM_USER": return { form: { ...state.form, thinkingOptionId: action.thinkingOptionId }, diff --git a/packages/app/src/schedules/aggregated-schedules.ts b/packages/app/src/schedules/aggregated-schedules.ts new file mode 100644 index 000000000..fb4bbe0fb --- /dev/null +++ b/packages/app/src/schedules/aggregated-schedules.ts @@ -0,0 +1,81 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import { isNewAgentSchedule } from "@/utils/schedule-format"; +import { toErrorMessage } from "@/utils/error-messages"; + +export interface ScheduleHostInput { + serverId: string; + serverName: string; +} + +export interface ScheduleRuntimeSnapshot { + connectionStatus: string; +} + +export interface ScheduleRuntime { + getClient(serverId: string): Pick | null; + getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined; +} + +export interface ScheduleHostSection { + serverId: string; + serverName: string; + isOnline: boolean; + schedules: ScheduleSummary[]; + error: string | null; +} + +export interface FetchAggregatedSchedulesInput { + hosts: ScheduleHostInput[]; + runtime: ScheduleRuntime; +} + +export interface FetchAggregatedSchedulesResult { + sections: ScheduleHostSection[]; +} + +export async function fetchAggregatedSchedules( + input: FetchAggregatedSchedulesInput, +): Promise { + const sections = await Promise.all( + input.hosts.map(async (host): Promise => { + const snapshot = input.runtime.getSnapshot(host.serverId); + const isOnline = snapshot?.connectionStatus === "online"; + const client = input.runtime.getClient(host.serverId); + + if (!client || !isOnline) { + return { + serverId: host.serverId, + serverName: host.serverName, + isOnline, + schedules: [], + error: null, + }; + } + + try { + const payload = await client.scheduleList(); + if (payload.error) { + throw new Error(payload.error); + } + return { + serverId: host.serverId, + serverName: host.serverName, + isOnline, + schedules: payload.schedules.filter(isNewAgentSchedule), + error: null, + }; + } catch (error) { + return { + serverId: host.serverId, + serverName: host.serverName, + isOnline, + schedules: [], + error: toErrorMessage(error), + }; + } + }), + ); + + return { sections }; +} diff --git a/packages/app/src/screens/schedules-screen.tsx b/packages/app/src/screens/schedules-screen.tsx new file mode 100644 index 000000000..61de8e98d --- /dev/null +++ b/packages/app/src/screens/schedules-screen.tsx @@ -0,0 +1,238 @@ +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { ScrollView, Text, View } from "react-native"; +import { useIsFocused } from "@react-navigation/native"; +import { Plus } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet"; +import { SchedulesTable } from "@/components/schedules/schedules-table"; +import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useSchedules, type ScheduleHostSection } from "@/hooks/use-schedules"; +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; + +type FormState = + | { mode: "closed" } + | { mode: "create" } + | { mode: "edit"; serverId: string; schedule: ScheduleSummary }; + +export function SchedulesScreen(): ReactElement { + const isFocused = useIsFocused(); + + if (!isFocused) { + return ; + } + + return ; +} + +function SchedulesScreenContent(): ReactElement { + const { sections, isLoading, isError, error, refetch } = useSchedules(); + const [form, setForm] = useState({ mode: "closed" }); + + const openCreate = useCallback(() => { + setForm({ mode: "create" }); + }, []); + + const openEdit = useCallback((serverId: string, schedule: ScheduleSummary) => { + setForm({ mode: "edit", serverId, schedule }); + }, []); + + const closeForm = useCallback(() => { + setForm({ mode: "closed" }); + }, []); + + const headerAction = useMemo( + () => ( + + ), + [openCreate], + ); + + return ( + + + + + + ); +} + +function SchedulesBody({ + sections, + isLoading, + isError, + error, + onRetry, + onCreate, + onEdit, +}: { + sections: ScheduleHostSection[]; + isLoading: boolean; + isError: boolean; + error: Error | null; + onRetry: () => void; + onCreate: () => void; + onEdit: (serverId: string, schedule: ScheduleSummary) => void; +}): ReactElement { + if (isLoading) { + return ( + + + + ); + } + + if (isError) { + return ( + + {error?.message ?? "Could not load schedules"} + + + ); + } + + if (sections.length === 0) { + return ( + + No schedules yet + + + ); + } + + return ( + + {sections.map((section) => ( + + ))} + + ); +} + +function ScheduleHostSectionView({ + section, + onEdit, +}: { + section: ScheduleHostSection; + onEdit: (serverId: string, schedule: ScheduleSummary) => void; +}): ReactElement { + const handleEdit = useCallback( + (schedule: ScheduleSummary) => { + onEdit(section.serverId, schedule); + }, + [onEdit, section.serverId], + ); + const emptyMessage = section.isOnline ? "No schedules" : "Host offline"; + + return ( + + + {section.serverName} + + {section.error ? {section.error} : null} + {section.schedules.length > 0 ? ( + + ) : null} + {section.schedules.length === 0 && !section.error ? ( + + {emptyMessage} + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + gap: theme.spacing[6], + padding: theme.spacing[6], + }, + scroll: { + flex: 1, + minHeight: 0, + }, + scrollContent: { + gap: theme.spacing[4], + paddingTop: theme.spacing[4], + paddingBottom: theme.spacing[6], + }, + section: { + gap: theme.spacing[2], + }, + sectionTitle: { + width: "100%", + maxWidth: 720, + alignSelf: "center", + paddingHorizontal: theme.spacing[4], + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + sectionError: { + width: "100%", + maxWidth: 720, + alignSelf: "center", + paddingHorizontal: theme.spacing[4], + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + }, + sectionEmpty: { + width: "100%", + maxWidth: 720, + alignSelf: "center", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + }, + sectionEmptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + message: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.lg, + textAlign: "center", + }, + // Static color holder read by the spinner; keeps the muted token without + // useUnistyles (banned in new code). + spinner: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index d32257243..944cbe437 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -415,6 +415,10 @@ export function buildSessionsRoute() { return "/sessions" as const; } +export function buildSchedulesRoute() { + return "/schedules" as const; +} + export function buildOpenProjectRoute() { return "/open-project" as const; } diff --git a/packages/app/src/utils/schedule-format.test.ts b/packages/app/src/utils/schedule-format.test.ts new file mode 100644 index 000000000..b9d15581f --- /dev/null +++ b/packages/app/src/utils/schedule-format.test.ts @@ -0,0 +1,135 @@ +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + describeCron, + everyMsToParts, + formatCadence, + formatNextRun, + isNewAgentSchedule, + partsToEveryMs, + resolveScheduleTitle, + validateCron, +} from "./schedule-format"; + +function createSchedule(input: { + name?: string | null; + prompt?: string; + title?: string | null; + targetType?: "agent" | "new-agent"; +}): ScheduleSummary { + return { + id: "schedule-1", + name: input.name ?? null, + prompt: input.prompt ?? "Run the task", + cadence: { type: "every", everyMs: 60_000 }, + target: + input.targetType === "agent" + ? { type: "agent", agentId: "00000000-0000-4000-8000-000000000000" } + : { + type: "new-agent", + config: { + provider: "codex", + cwd: "/tmp/project", + title: input.title, + }, + }, + status: "active", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("schedule title helpers", () => { + it("identifies new-agent schedules", () => { + expect(isNewAgentSchedule(createSchedule({ targetType: "new-agent" }))).toBe(true); + expect(isNewAgentSchedule(createSchedule({ targetType: "agent" }))).toBe(false); + }); + + it("resolves display titles by name, config title, prompt, then fallback", () => { + expect( + resolveScheduleTitle(createSchedule({ name: "Morning run", title: "Config title" })), + ).toBe("Morning run"); + expect(resolveScheduleTitle(createSchedule({ name: " ", title: "Config title" }))).toBe( + "Config title", + ); + expect( + resolveScheduleTitle(createSchedule({ name: " ", title: " ", prompt: "\nPrompt line" })), + ).toBe("Prompt line"); + expect(resolveScheduleTitle(createSchedule({ name: " ", title: " ", prompt: "\n " }))).toBe( + "Untitled schedule", + ); + }); +}); + +describe("interval formatting", () => { + it("round-trips interval parts and formats cadence labels", () => { + expect(everyMsToParts(2 * 24 * 60 * 60_000)).toEqual({ value: 2, unit: "days" }); + expect(everyMsToParts(3 * 60 * 60_000)).toEqual({ value: 3, unit: "hours" }); + expect(everyMsToParts(90_000)).toEqual({ value: 2, unit: "minutes" }); + expect(everyMsToParts(0)).toEqual({ value: 1, unit: "hours" }); + + expect(partsToEveryMs(2, "hours")).toBe(2 * 60 * 60_000); + expect(partsToEveryMs(0, "minutes")).toBe(60_000); + expect(formatCadence({ type: "every", everyMs: 2 * 60 * 60_000 })).toBe("Every 2 hours"); + }); +}); + +describe("describeCron", () => { + it("humanizes common fixed-time cron shapes", () => { + expect(describeCron("0 * * * *")).toBe("Every hour"); + expect(describeCron("15 * * * *")).toBe("Every hour at :15"); + expect(describeCron("0 9 * * *")).toBe("Daily at 09:00 UTC"); + expect(describeCron("0 9 * * 1-5")).toBe("Weekdays at 09:00 UTC"); + expect(describeCron("0 9 * * 0,6")).toBe("Weekends at 09:00 UTC"); + expect(describeCron("0 9 * * 1")).toBe("Mondays at 09:00 UTC"); + }); + + it("returns null for invalid or unrecognized valid cron expressions", () => { + expect(describeCron("not a cron")).toBeNull(); + expect(describeCron("*/5 * * * *")).toBeNull(); + expect(formatCadence({ type: "cron", expression: "0 9 * * *" })).toBe("Daily at 09:00 UTC"); + }); +}); + +describe("validateCron", () => { + it("accepts structurally valid cron expressions", () => { + expect(validateCron("*/5 9-17 * 1,6 1-5")).toBeNull(); + }); + + it("rejects step fields with extra slash tokens", () => { + expect(validateCron("*/5/2 * * * *")).toBe("Invalid minute step"); + }); + + it("rejects malformed fields with targeted messages", () => { + expect(validateCron("")).toBe("Enter a cron expression"); + expect(validateCron("* * *")).toBe("Cron expressions must have 5 fields"); + expect(validateCron("60 * * * *")).toBe("Invalid minute value"); + expect(validateCron("* 24 * * *")).toBe("Invalid hour value"); + expect(validateCron("* * 31-1 * *")).toBe("Invalid day-of-month range"); + expect(validateCron("* * * */0 *")).toBe("Invalid month step"); + expect(validateCron("* * * * mon")).toBe("Invalid day-of-week value"); + }); +}); + +describe("formatNextRun", () => { + it("formats next-run distance from the current clock", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + expect(formatNextRun(null)).toBe(""); + expect(formatNextRun("not-a-date")).toBe(""); + expect(formatNextRun("2026-01-01T00:00:15.000Z")).toBe("soon"); + expect(formatNextRun("2026-01-01T00:30:00.000Z")).toBe("in 30m"); + expect(formatNextRun("2026-01-01T03:00:00.000Z")).toBe("in 3h"); + expect(formatNextRun("2026-01-03T00:00:00.000Z")).toBe("in 2d"); + }); +}); diff --git a/packages/app/src/utils/schedule-format.ts b/packages/app/src/utils/schedule-format.ts new file mode 100644 index 000000000..6fd816c35 --- /dev/null +++ b/packages/app/src/utils/schedule-format.ts @@ -0,0 +1,184 @@ +import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import { validateCronExpression } from "@getpaseo/protocol/schedule/cron-expression"; + +export type IntervalUnit = "minutes" | "hours" | "days"; + +const MS_PER_MINUTE = 60_000; +const MS_PER_HOUR = MS_PER_MINUTE * 60; +const MS_PER_DAY = MS_PER_HOUR * 24; + +const UNIT_MS: Record = { + minutes: MS_PER_MINUTE, + hours: MS_PER_HOUR, + days: MS_PER_DAY, +}; + +const DAY_NAMES = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] as const; + +export function isNewAgentSchedule(schedule: ScheduleSummary): boolean { + return schedule.target.type === "new-agent"; +} + +export function resolveScheduleTitle(schedule: ScheduleSummary): string { + const name = schedule.name?.trim(); + if (name) { + return name; + } + if (schedule.target.type === "new-agent") { + const configTitle = schedule.target.config.title?.trim(); + if (configTitle) { + return configTitle; + } + } + const firstPromptLine = schedule.prompt + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0); + return firstPromptLine || "Untitled schedule"; +} + +function pluralize(value: number, noun: string): string { + return value === 1 ? `1 ${noun}` : `${value} ${noun}s`; +} + +export function everyMsToParts(ms: number): { value: number; unit: IntervalUnit } { + if (!Number.isFinite(ms) || ms <= 0) { + return { value: 1, unit: "hours" }; + } + if (ms % MS_PER_DAY === 0) { + return { value: ms / MS_PER_DAY, unit: "days" }; + } + if (ms % MS_PER_HOUR === 0) { + return { value: ms / MS_PER_HOUR, unit: "hours" }; + } + return { value: Math.max(1, Math.round(ms / MS_PER_MINUTE)), unit: "minutes" }; +} + +export function partsToEveryMs(value: number, unit: IntervalUnit): number { + const normalized = Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1; + return normalized * UNIT_MS[unit]; +} + +const UNIT_NOUN: Record = { + minutes: "minute", + hours: "hour", + days: "day", +}; + +function formatEvery(everyMs: number): string { + const { value, unit } = everyMsToParts(everyMs); + return `Every ${pluralize(value, UNIT_NOUN[unit])}`; +} + +export function formatCadence(cadence: ScheduleCadence): string { + if (cadence.type === "every") { + return formatEvery(cadence.everyMs); + } + return describeCron(cadence.expression) ?? cadence.expression; +} + +/** + * Humanize a handful of common 5-field cron shapes. Returns null when the + * expression is valid but not one of the recognized patterns (callers fall + * back to showing the raw expression). + */ +export function describeCron(expr: string): string | null { + const trimmed = expr.trim(); + if (validateCron(trimmed) !== null) { + return null; + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = trimmed.split(/\s+/); + + // Only humanize the simple "fixed time" family: literal minute/hour with the + // date fields either wildcarded or a recognized day-of-week constraint. + const minuteNum = Number.parseInt(minute, 10); + const isLiteralMinute = /^\d+$/.test(minute); + const isWildcardMonth = month === "*"; + const isWildcardDom = dayOfMonth === "*"; + + if (!isLiteralMinute || !isWildcardMonth || !isWildcardDom) { + return null; + } + + // "Every hour" / "Every hour at :MM" + if (hour === "*") { + if (dayOfWeek !== "*") { + return null; + } + return minuteNum === 0 ? "Every hour" : `Every hour at :${pad2(minuteNum)}`; + } + + if (!/^\d+$/.test(hour)) { + return null; + } + const time = `${pad2(Number.parseInt(hour, 10))}:${pad2(minuteNum)}`; + + if (dayOfWeek === "*") { + return `Daily at ${time} UTC`; + } + if (dayOfWeek === "1-5") { + return `Weekdays at ${time} UTC`; + } + if (dayOfWeek === "0,6" || dayOfWeek === "6,0") { + return `Weekends at ${time} UTC`; + } + if (/^\d$/.test(dayOfWeek)) { + const day = DAY_NAMES[Number.parseInt(dayOfWeek, 10)]; + if (day) { + return `${day}s at ${time} UTC`; + } + } + return null; +} + +export function validateCron(expr: string): string | null { + const trimmed = expr.trim(); + if (!trimmed) { + return "Enter a cron expression"; + } + + const error = validateCronExpression(trimmed); + return error?.replace(/^Invalid cron /, "Invalid ") ?? null; +} + +function pad2(value: number): string { + return value < 10 ? `0${value}` : String(value); +} + +/** + * Forward-relative description of the next run, e.g. "in 3h", "in 2d", "soon". + * Returns "" when there is no scheduled next run. + */ +export function formatNextRun(iso: string | null): string { + if (!iso) { + return ""; + } + const target = new Date(iso).getTime(); + if (Number.isNaN(target)) { + return ""; + } + + const diffMs = target - Date.now(); + if (diffMs <= 0) { + return "soon"; + } + if (diffMs < MS_PER_MINUTE) { + return "soon"; + } + if (diffMs < MS_PER_HOUR) { + return `in ${Math.round(diffMs / MS_PER_MINUTE)}m`; + } + if (diffMs < MS_PER_DAY) { + return `in ${Math.round(diffMs / MS_PER_HOUR)}h`; + } + return `in ${Math.round(diffMs / MS_PER_DAY)}d`; +} diff --git a/packages/protocol/src/schedule/cron-expression.test.ts b/packages/protocol/src/schedule/cron-expression.test.ts new file mode 100644 index 000000000..0db1d68ea --- /dev/null +++ b/packages/protocol/src/schedule/cron-expression.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { parseCronExpression, validateCronExpression } from "./cron-expression.js"; + +describe("cron expression parser", () => { + it("matches structurally valid expressions", () => { + const cron = parseCronExpression("*/5 9-17 * 1,6 1-5"); + + expect(cron.minute.matches(10)).toBe(true); + expect(cron.minute.matches(11)).toBe(false); + expect(cron.hour.matches(12)).toBe(true); + expect(cron.hour.matches(18)).toBe(false); + expect(cron.month.matches(6)).toBe(true); + expect(cron.dayOfWeek.matches(0)).toBe(false); + }); + + it("reports invalid expressions with server-facing copy", () => { + expect(validateCronExpression("* * *")).toBe("Cron expressions must have 5 fields"); + expect(validateCronExpression("*/5/2 * * * *")).toBe("Invalid cron minute step"); + expect(validateCronExpression("60 * * * *")).toBe("Invalid cron minute value"); + expect(validateCronExpression("* 24 * * *")).toBe("Invalid cron hour value"); + expect(validateCronExpression("* * 31-1 * *")).toBe("Invalid cron day-of-month range"); + expect(validateCronExpression("* * * */0 *")).toBe("Invalid cron month step"); + expect(validateCronExpression("* * * * mon")).toBe("Invalid cron day-of-week value"); + }); +}); diff --git a/packages/protocol/src/schedule/cron-expression.ts b/packages/protocol/src/schedule/cron-expression.ts new file mode 100644 index 000000000..691631d68 --- /dev/null +++ b/packages/protocol/src/schedule/cron-expression.ts @@ -0,0 +1,129 @@ +interface CronFieldBounds { + min: number; + max: number; + name: string; +} + +export interface CronFieldMatcher { + matches(value: number): boolean; +} + +export interface ParsedCronExpression { + minute: CronFieldMatcher; + hour: CronFieldMatcher; + dayOfMonth: CronFieldMatcher; + month: CronFieldMatcher; + dayOfWeek: CronFieldMatcher; +} + +const CRON_FIELD_BOUNDS: CronFieldBounds[] = [ + { min: 0, max: 59, name: "minute" }, + { min: 0, max: 23, name: "hour" }, + { min: 1, max: 31, name: "day-of-month" }, + { min: 1, max: 12, name: "month" }, + { min: 0, max: 6, name: "day-of-week" }, +]; + +function createRange(start: number, end: number, step: number): number[] { + const values: number[] = []; + for (let value = start; value <= end; value += step) { + values.push(value); + } + return values; +} + +function parseStepPart(part: string, bounds: CronFieldBounds): { base: string; step: number } { + const stepParts = part.split("/"); + if (stepParts.length > 2) { + throw new Error(`Invalid cron ${bounds.name} step`); + } + + const [base, stepSource] = stepParts; + const normalizedStep = stepSource?.trim(); + const step = normalizedStep === undefined ? 1 : Number.parseInt(normalizedStep, 10); + if ( + !Number.isInteger(step) || + step <= 0 || + (normalizedStep !== undefined && String(step) !== normalizedStep) + ) { + throw new Error(`Invalid cron ${bounds.name} step`); + } + + return { base, step }; +} + +function parseField(source: string, bounds: CronFieldBounds): CronFieldMatcher { + const trimmed = source.trim(); + if (!trimmed) { + throw new Error(`Invalid cron ${bounds.name} field`); + } + + const allowed = new Set(); + for (const rawPart of trimmed.split(",")) { + const part = rawPart.trim(); + if (!part) { + throw new Error(`Invalid cron ${bounds.name} field`); + } + + const { base, step } = parseStepPart(part, bounds); + + if (base === "*") { + for (const value of createRange(bounds.min, bounds.max, step)) { + allowed.add(value); + } + continue; + } + + const rangeMatch = base.match(/^(\d+)-(\d+)$/); + if (rangeMatch) { + const start = Number.parseInt(rangeMatch[1], 10); + const end = Number.parseInt(rangeMatch[2], 10); + if (start > end || start < bounds.min || end > bounds.max) { + throw new Error(`Invalid cron ${bounds.name} range`); + } + for (const value of createRange(start, end, step)) { + allowed.add(value); + } + continue; + } + + if (!/^\d+$/.test(base)) { + throw new Error(`Invalid cron ${bounds.name} value`); + } + const value = Number.parseInt(base, 10); + if (!Number.isInteger(value) || value < bounds.min || value > bounds.max) { + throw new Error(`Invalid cron ${bounds.name} value`); + } + allowed.add(value); + } + + return { + matches(value: number): boolean { + return allowed.has(value); + }, + }; +} + +export function parseCronExpression(expression: string): ParsedCronExpression { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) { + throw new Error("Cron expressions must have 5 fields"); + } + + return { + minute: parseField(parts[0], CRON_FIELD_BOUNDS[0]), + hour: parseField(parts[1], CRON_FIELD_BOUNDS[1]), + dayOfMonth: parseField(parts[2], CRON_FIELD_BOUNDS[2]), + month: parseField(parts[3], CRON_FIELD_BOUNDS[3]), + dayOfWeek: parseField(parts[4], CRON_FIELD_BOUNDS[4]), + }; +} + +export function validateCronExpression(expression: string): string | null { + try { + parseCronExpression(expression); + return null; + } catch (error) { + return error instanceof Error ? error.message : "Invalid cron expression"; + } +} diff --git a/packages/server/src/server/schedule/cron.test.ts b/packages/server/src/server/schedule/cron.test.ts index 3d0b27cd5..9d4172ff2 100644 --- a/packages/server/src/server/schedule/cron.test.ts +++ b/packages/server/src/server/schedule/cron.test.ts @@ -54,6 +54,12 @@ describe("schedule cron cadence", () => { ); }); + test("rejects step fields with extra slash tokens", () => { + expect(() => validateScheduleCadence({ type: "cron", expression: "*/5/2 * * * *" })).toThrow( + "Invalid cron minute step", + ); + }); + test("rejects invalid cron time zones", () => { expect(() => validateScheduleCadence({ diff --git a/packages/server/src/server/schedule/cron.ts b/packages/server/src/server/schedule/cron.ts index cba2f97fc..ab24746a5 100644 --- a/packages/server/src/server/schedule/cron.ts +++ b/packages/server/src/server/schedule/cron.ts @@ -1,86 +1,6 @@ +import { parseCronExpression } from "@getpaseo/protocol/schedule/cron-expression"; import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types"; -interface CronFieldMatcher { - matches(value: number): boolean; -} - -function buildValueSet(values: Iterable): Set { - return new Set(values); -} - -function createRange(start: number, end: number, step: number): number[] { - const values: number[] = []; - for (let value = start; value <= end; value += step) { - values.push(value); - } - return values; -} - -function parseField( - source: string, - bounds: { min: number; max: number; name: string }, -): CronFieldMatcher { - const trimmed = source.trim(); - if (!trimmed) { - throw new Error(`Invalid cron ${bounds.name} field`); - } - - const values = new Set(); - for (const rawPart of trimmed.split(",")) { - const part = rawPart.trim(); - if (!part) { - throw new Error(`Invalid cron ${bounds.name} field`); - } - - const [base, stepSource] = part.split("/"); - const step = stepSource === undefined ? 1 : Number.parseInt(stepSource, 10); - if (!Number.isInteger(step) || step <= 0) { - throw new Error(`Invalid cron ${bounds.name} step`); - } - - if (base === "*") { - for (const value of createRange(bounds.min, bounds.max, step)) { - values.add(value); - } - continue; - } - - const rangeMatch = base.match(/^(\d+)-(\d+)$/); - if (rangeMatch) { - const start = Number.parseInt(rangeMatch[1], 10); - const end = Number.parseInt(rangeMatch[2], 10); - if (start > end || start < bounds.min || end > bounds.max) { - throw new Error(`Invalid cron ${bounds.name} range`); - } - for (const value of createRange(start, end, step)) { - values.add(value); - } - continue; - } - - const value = Number.parseInt(base, 10); - if (!Number.isInteger(value) || value < bounds.min || value > bounds.max) { - throw new Error(`Invalid cron ${bounds.name} value`); - } - values.add(value); - } - - const allowed = buildValueSet(values); - return { - matches(value: number): boolean { - return allowed.has(value); - }, - }; -} - -interface ParsedCronExpression { - minute: CronFieldMatcher; - hour: CronFieldMatcher; - dayOfMonth: CronFieldMatcher; - month: CronFieldMatcher; - dayOfWeek: CronFieldMatcher; -} - interface CronDateParts { minute: number; hour: number; @@ -89,21 +9,6 @@ interface CronDateParts { dayOfWeek: number; } -function parseCronExpression(expression: string): ParsedCronExpression { - const parts = expression.trim().split(/\s+/); - if (parts.length !== 5) { - throw new Error("Cron expressions must have 5 fields"); - } - - return { - minute: parseField(parts[0], { min: 0, max: 59, name: "minute" }), - hour: parseField(parts[1], { min: 0, max: 23, name: "hour" }), - dayOfMonth: parseField(parts[2], { min: 1, max: 31, name: "day-of-month" }), - month: parseField(parts[3], { min: 1, max: 12, name: "month" }), - dayOfWeek: parseField(parts[4], { min: 0, max: 6, name: "day-of-week" }), - }; -} - function startOfNextMinute(date: Date): Date { return new Date( Date.UTC(