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.
This commit is contained in:
@@ -5,6 +5,29 @@ import type { WebSocketRoute } from "@playwright/test";
|
|||||||
import { gotoAppShell, openSettings } from "./app";
|
import { gotoAppShell, openSettings } from "./app";
|
||||||
import { daemonWsRoutePattern } from "./daemon-port";
|
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<string, unknown> | 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
|
|
||||||
export async function openProjects(page: Page): Promise<void> {
|
export async function openProjects(page: Page): Promise<void> {
|
||||||
@@ -171,33 +194,39 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
|
|||||||
|
|
||||||
// --- WebSocket helpers ---
|
// --- WebSocket helpers ---
|
||||||
|
|
||||||
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
// Proxies all daemon WS traffic transparently, but rejects paseo.json reads
|
||||||
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
// until the test explicitly allows recovery. Closing the transport leaves the
|
||||||
// connections pass through so the Reload action can succeed.
|
// client-side RPC pending across reconnects, so this injects the same correlated
|
||||||
export async function installReadTransportFailure(page: Page): Promise<void> {
|
// rpc_error shape the daemon emits for failed async session requests.
|
||||||
let armed = true;
|
export async function installReadTransportFailure(
|
||||||
|
page: Page,
|
||||||
|
): Promise<{ allowRecovery: () => void }> {
|
||||||
|
let shouldFailReads = true;
|
||||||
|
|
||||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
const server = ws.connectToServer();
|
const server = ws.connectToServer();
|
||||||
|
|
||||||
ws.onMessage((message) => {
|
ws.onMessage((message) => {
|
||||||
if (armed && typeof message === "string") {
|
const sessionMessage = getSessionMessage(message);
|
||||||
try {
|
if (shouldFailReads && sessionMessage?.type === "read_project_config_request") {
|
||||||
const envelope = JSON.parse(message) as {
|
const requestId = sessionMessage.requestId;
|
||||||
type?: string;
|
if (typeof requestId === "string") {
|
||||||
message?: { type?: string };
|
ws.send(
|
||||||
};
|
JSON.stringify({
|
||||||
if (
|
type: "session",
|
||||||
envelope.type === "session" &&
|
message: {
|
||||||
envelope.message?.type === "read_project_config_request"
|
type: "rpc_error",
|
||||||
) {
|
payload: {
|
||||||
armed = false;
|
requestId,
|
||||||
void ws.close({ code: 1001 });
|
requestType: "read_project_config_request",
|
||||||
return;
|
error: "Test read transport failure.",
|
||||||
}
|
code: "transport",
|
||||||
} catch {
|
},
|
||||||
// binary or malformed frame — pass through
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
server.send(message);
|
server.send(message);
|
||||||
@@ -214,6 +243,12 @@ export async function installReadTransportFailure(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowRecovery() {
|
||||||
|
shouldFailReads = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Installs a transparent WS proxy that can later drop all active daemon connections
|
// Installs a transparent WS proxy that can later drop all active daemon connections
|
||||||
|
|||||||
@@ -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<string, unknown> & { type?: string; requestId?: string };
|
||||||
|
|
||||||
|
export interface FakeScheduleHostWorkspace {
|
||||||
|
serverId: string;
|
||||||
|
projectId: string;
|
||||||
|
projectDisplayName: string;
|
||||||
|
workspace: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<FakeScheduleHostWorkspace> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -282,9 +282,9 @@ test.describe("Projects settings — error UX", () => {
|
|||||||
page,
|
page,
|
||||||
editableProject,
|
editableProject,
|
||||||
}) => {
|
}) => {
|
||||||
// Drop the WS connection the moment a read_project_config_request is sent.
|
// Reject read_project_config_request calls until the user clicks Reload.
|
||||||
// Subsequent connections are proxied transparently so Reload can succeed.
|
// This keeps automatic reconnect refetches from racing past the callout.
|
||||||
await installReadTransportFailure(page);
|
const transportFailure = await installReadTransportFailure(page);
|
||||||
|
|
||||||
await openProjects(page);
|
await openProjects(page);
|
||||||
await navigateToProjectSettings(page, editableProject.name);
|
await navigateToProjectSettings(page, editableProject.name);
|
||||||
@@ -292,7 +292,8 @@ test.describe("Projects settings — error UX", () => {
|
|||||||
await expectProjectSettingsError(page, "transport");
|
await expectProjectSettingsError(page, "transport");
|
||||||
await expectProjectSettingsFormHidden(page);
|
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 expect(async () => {
|
||||||
await clickReloadProjectSettings(page);
|
await clickReloadProjectSettings(page);
|
||||||
await expectNoProjectSettingsError(page, "transport", 3_000);
|
await expectNoProjectSettingsError(page, "transport", 3_000);
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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<void> {
|
||||||
|
await (workspace.client as unknown as ScheduleSeedClient)
|
||||||
|
.scheduleDelete({ id })
|
||||||
|
.catch(ignoreScheduleDeleteError);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Schedules", () => {
|
||||||
|
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>> = [];
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -893,6 +893,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
(pathname === "/open-project" ||
|
(pathname === "/open-project" ||
|
||||||
pathname === "/new" ||
|
pathname === "/new" ||
|
||||||
pathname === "/sessions" ||
|
pathname === "/sessions" ||
|
||||||
|
pathname === "/schedules" ||
|
||||||
routeHasKnownHost);
|
routeHasKnownHost);
|
||||||
|
|
||||||
// Parse selectedAgentKey directly from pathname
|
// Parse selectedAgentKey directly from pathname
|
||||||
@@ -951,6 +952,7 @@ function RootStack() {
|
|||||||
<Stack.Screen name="new" />
|
<Stack.Screen name="new" />
|
||||||
<Stack.Screen name="open-project" />
|
<Stack.Screen name="open-project" />
|
||||||
<Stack.Screen name="sessions" />
|
<Stack.Screen name="sessions" />
|
||||||
|
<Stack.Screen name="schedules" />
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen name="h/[serverId]" />
|
<Stack.Screen name="h/[serverId]" />
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
|
import { SchedulesScreen } from "@/screens/schedules-screen";
|
||||||
|
|
||||||
|
export default function SchedulesRoute() {
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<SchedulesScreen />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 type { ReactNode, Ref } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
// Horizontal indent token shared by the sheet header (title, back arrow,
|
// Horizontal indent token shared by the sheet header (title, back arrow,
|
||||||
@@ -178,6 +179,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
|
desktopScrollContainer: {
|
||||||
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
desktopScroll: {
|
desktopScroll: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
@@ -451,6 +457,11 @@ export interface AdaptiveModalSheetProps {
|
|||||||
desktopMaxWidth?: number;
|
desktopMaxWidth?: number;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
presentation?: "push" | "replace";
|
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({
|
export function AdaptiveModalSheet({
|
||||||
@@ -464,11 +475,16 @@ export function AdaptiveModalSheet({
|
|||||||
desktopMaxWidth,
|
desktopMaxWidth,
|
||||||
scrollable = true,
|
scrollable = true,
|
||||||
presentation,
|
presentation,
|
||||||
|
webScrollbar = false,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const desktopScrollRef = useRef<ScrollView>(null);
|
||||||
|
const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, {
|
||||||
|
enabled: webScrollbar && !isMobile,
|
||||||
|
});
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
const compactSafeAreaPadding = useMemo(
|
const compactSafeAreaPadding = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -574,13 +590,22 @@ export function AdaptiveModalSheet({
|
|||||||
<>
|
<>
|
||||||
<SheetHeaderView header={header} onClose={onClose} />
|
<SheetHeaderView header={header} onClose={onClose} />
|
||||||
{scrollable ? (
|
{scrollable ? (
|
||||||
<ScrollView
|
<View style={styles.desktopScrollContainer}>
|
||||||
style={styles.desktopScroll}
|
<ScrollView
|
||||||
contentContainerStyle={styles.desktopContent}
|
ref={desktopScrollRef}
|
||||||
keyboardShouldPersistTaps="handled"
|
style={styles.desktopScroll}
|
||||||
>
|
contentContainerStyle={styles.desktopContent}
|
||||||
{children}
|
keyboardShouldPersistTaps="handled"
|
||||||
</ScrollView>
|
onLayout={desktopScrollbar.onLayout}
|
||||||
|
onScroll={desktopScrollbar.onScroll}
|
||||||
|
onContentSizeChange={desktopScrollbar.onContentSizeChange}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
showsVerticalScrollIndicator={!webScrollbar}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
{desktopScrollbar.overlay}
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.desktopStaticContent}>{children}</View>
|
<View style={styles.desktopStaticContent}>{children}</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ interface CombinedModelSelectorProps {
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
hovered: boolean;
|
||||||
|
pressed: boolean;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
@@ -87,6 +89,15 @@ interface CombinedModelSelectorProps {
|
|||||||
isRetryingProvider?: boolean;
|
isRetryingProvider?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
serverId?: string | null;
|
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 {
|
interface SelectorContentProps {
|
||||||
@@ -574,6 +585,7 @@ export function CombinedModelSelector({
|
|||||||
isRetryingProvider = false,
|
isRetryingProvider = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
serverId = null,
|
serverId = null,
|
||||||
|
triggerFill = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -693,14 +705,26 @@ export function CombinedModelSelector({
|
|||||||
}, [handleOpenChange, isOpen]);
|
}, [handleOpenChange, isOpen]);
|
||||||
|
|
||||||
const triggerStyle = useCallback(
|
const triggerStyle = useCallback(
|
||||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||||
styles.trigger,
|
// Fill mode: transparent full-width passthrough. The trigger paints its own
|
||||||
Boolean(hovered) && styles.triggerHovered,
|
// hover/pressed state from the args, so the wrapper must not double-paint.
|
||||||
(pressed || isOpen) && styles.triggerPressed,
|
if (triggerFill) {
|
||||||
disabled && styles.triggerDisabled,
|
return [
|
||||||
renderTrigger ? styles.customTriggerWrapper : null,
|
styles.trigger,
|
||||||
],
|
styles.customTriggerWrapper,
|
||||||
[disabled, isOpen, renderTrigger],
|
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(() => {
|
const handleBackToAll = useCallback(() => {
|
||||||
@@ -789,12 +813,16 @@ export function CombinedModelSelector({
|
|||||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||||
testID="combined-model-selector"
|
testID="combined-model-selector"
|
||||||
>
|
>
|
||||||
{renderTrigger({
|
{({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||||
selectedModelLabel: triggerLabel,
|
renderTrigger({
|
||||||
onPress: handleTriggerPress,
|
selectedModelLabel: triggerLabel,
|
||||||
disabled,
|
onPress: handleTriggerPress,
|
||||||
isOpen,
|
disabled,
|
||||||
})}
|
isOpen,
|
||||||
|
hovered: Boolean(hovered),
|
||||||
|
pressed,
|
||||||
|
})
|
||||||
|
}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<ComboboxTrigger
|
<ComboboxTrigger
|
||||||
@@ -886,6 +914,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
height: "auto",
|
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: {
|
favoritesContainer: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { router, usePathname } from "expo-router";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +66,7 @@ import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
|||||||
import {
|
import {
|
||||||
buildOpenProjectRoute,
|
buildOpenProjectRoute,
|
||||||
buildNewWorkspaceRoute,
|
buildNewWorkspaceRoute,
|
||||||
|
buildSchedulesRoute,
|
||||||
buildSessionsRoute,
|
buildSessionsRoute,
|
||||||
buildSettingsAddHostRoute,
|
buildSettingsAddHostRoute,
|
||||||
buildSettingsHostSectionRoute,
|
buildSettingsHostSectionRoute,
|
||||||
@@ -105,6 +116,7 @@ interface SidebarLabels {
|
|||||||
switchHost: string;
|
switchHost: string;
|
||||||
searchHosts: string;
|
searchHosts: string;
|
||||||
sessions: string;
|
sessions: string;
|
||||||
|
schedules: string;
|
||||||
closeSidebar: string;
|
closeSidebar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,12 +126,14 @@ interface MobileSidebarProps extends SidebarSharedProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
closeSidebar: () => void;
|
closeSidebar: () => void;
|
||||||
handleViewMoreNavigate: () => void;
|
handleViewMoreNavigate: () => void;
|
||||||
|
handleViewSchedulesNavigate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||||
insetsTop: number;
|
insetsTop: number;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
handleViewMore: () => void;
|
handleViewMore: () => void;
|
||||||
|
handleViewSchedules: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LeftSidebar = memo(function LeftSidebar({
|
export const LeftSidebar = memo(function LeftSidebar({
|
||||||
@@ -221,6 +235,10 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
router.push(buildSessionsRoute());
|
router.push(buildSessionsRoute());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleViewSchedulesNavigate = useCallback(() => {
|
||||||
|
router.push(buildSchedulesRoute());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
||||||
const labels = useMemo(
|
const labels = useMemo(
|
||||||
(): SidebarLabels => ({
|
(): SidebarLabels => ({
|
||||||
@@ -231,6 +249,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
switchHost: t("sidebar.host.switchTitle"),
|
switchHost: t("sidebar.host.switchTitle"),
|
||||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||||
sessions: t("sidebar.sections.sessions"),
|
sessions: t("sidebar.sections.sessions"),
|
||||||
|
schedules: t("sidebar.sections.schedules"),
|
||||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||||
}),
|
}),
|
||||||
[t],
|
[t],
|
||||||
@@ -267,6 +286,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
handleAddHost={handleAddHostMobile}
|
handleAddHost={handleAddHostMobile}
|
||||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||||
|
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -282,6 +302,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
handleAddHost={handleAddHostDesktop}
|
handleAddHost={handleAddHostDesktop}
|
||||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||||
handleViewMore={handleViewMoreNavigate}
|
handleViewMore={handleViewMoreNavigate}
|
||||||
|
handleViewSchedules={handleViewSchedulesNavigate}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -557,9 +578,11 @@ function MobileSidebar({
|
|||||||
isOpen,
|
isOpen,
|
||||||
closeSidebar,
|
closeSidebar,
|
||||||
handleViewMoreNavigate,
|
handleViewMoreNavigate,
|
||||||
|
handleViewSchedulesNavigate,
|
||||||
}: MobileSidebarProps) {
|
}: MobileSidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isSessionsActive = pathname.includes("/sessions");
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
|
const isSchedulesActive = pathname.includes("/schedules");
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -587,6 +610,13 @@ function MobileSidebar({
|
|||||||
handleViewMoreNavigate();
|
handleViewMoreNavigate();
|
||||||
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
||||||
|
|
||||||
|
const handleViewSchedules = useCallback(() => {
|
||||||
|
translateX.value = -windowWidth;
|
||||||
|
backdropOpacity.value = 0;
|
||||||
|
closeSidebar();
|
||||||
|
handleViewSchedulesNavigate();
|
||||||
|
}, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]);
|
||||||
|
|
||||||
const handleWorkspacePress = useCallback(() => {
|
const handleWorkspacePress = useCallback(() => {
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
}, [closeSidebar]);
|
}, [closeSidebar]);
|
||||||
@@ -747,6 +777,14 @@ function MobileSidebar({
|
|||||||
testID="sidebar-sessions"
|
testID="sidebar-sessions"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
|
<SidebarHeaderRow
|
||||||
|
icon={CalendarClock}
|
||||||
|
label={labels.schedules}
|
||||||
|
onPress={handleViewSchedules}
|
||||||
|
isActive={isSchedulesActive}
|
||||||
|
testID="sidebar-schedules"
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<WorkspacesSectionHeader />
|
<WorkspacesSectionHeader />
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -827,9 +865,11 @@ function DesktopSidebar({
|
|||||||
insetsTop,
|
insetsTop,
|
||||||
isOpen,
|
isOpen,
|
||||||
handleViewMore,
|
handleViewMore,
|
||||||
|
handleViewSchedules,
|
||||||
}: DesktopSidebarProps) {
|
}: DesktopSidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isSessionsActive = pathname.includes("/sessions");
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
|
const isSchedulesActive = pathname.includes("/schedules");
|
||||||
const padding = useWindowControlsPadding("sidebar");
|
const padding = useWindowControlsPadding("sidebar");
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||||
@@ -909,6 +949,14 @@ function DesktopSidebar({
|
|||||||
testID="sidebar-sessions"
|
testID="sidebar-sessions"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
|
<SidebarHeaderRow
|
||||||
|
icon={CalendarClock}
|
||||||
|
label={labels.schedules}
|
||||||
|
onPress={handleViewSchedules}
|
||||||
|
isActive={isSchedulesActive}
|
||||||
|
testID="sidebar-schedules"
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<WorkspacesSectionHeader />
|
<WorkspacesSectionHeader />
|
||||||
|
|||||||
@@ -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<IntervalUnit, string> = {
|
||||||
|
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<IntervalUnit>(
|
||||||
|
() => 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 = <Text style={styles.error}>{cronError}</Text>;
|
||||||
|
} else if (cronPreview) {
|
||||||
|
cronFeedback = <Text style={styles.preview}>{cronPreview}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
value={mode}
|
||||||
|
onValueChange={handleModeChange}
|
||||||
|
options={MODE_OPTIONS}
|
||||||
|
style={styles.modeControl}
|
||||||
|
testID="cadence-mode"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === "every" ? (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.intervalRow}>
|
||||||
|
<AdaptiveTextInput
|
||||||
|
testID="cadence-interval-value"
|
||||||
|
accessibilityLabel="Interval value"
|
||||||
|
initialValue={intervalValueText}
|
||||||
|
resetKey={`cadence-interval-${fieldResetKey}`}
|
||||||
|
value={intervalValueText}
|
||||||
|
onChangeText={handleIntervalValueChange}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
style={styles.intervalInput}
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
value={intervalUnit}
|
||||||
|
onValueChange={handleUnitChange}
|
||||||
|
options={UNIT_OPTIONS}
|
||||||
|
style={styles.unitControl}
|
||||||
|
testID="cadence-interval-unit"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.preview}>{intervalPreview}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.presetRow}>
|
||||||
|
{CRON_PRESETS.map((preset) => (
|
||||||
|
<CronPresetChip
|
||||||
|
key={preset.expression}
|
||||||
|
label={preset.label}
|
||||||
|
expression={preset.expression}
|
||||||
|
isSelected={trimmedCron === preset.expression}
|
||||||
|
onSelect={handlePresetPress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<AdaptiveTextInput
|
||||||
|
testID="cadence-cron-expression"
|
||||||
|
accessibilityLabel="Cron expression"
|
||||||
|
initialValue={cronText}
|
||||||
|
resetKey={`cadence-cron-${fieldResetKey}`}
|
||||||
|
value={cronText}
|
||||||
|
onChangeText={handleCronChange}
|
||||||
|
placeholder="0 9 * * *"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
spellCheck={false}
|
||||||
|
style={styles.cronInput}
|
||||||
|
/>
|
||||||
|
{cronFeedback}
|
||||||
|
<Text style={styles.hint}>Times are in UTC</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={accessibilityState}
|
||||||
|
onPress={handlePress}
|
||||||
|
style={chipStyle}
|
||||||
|
>
|
||||||
|
<Text style={labelStyle} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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<string, ScheduleProjectTarget>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<string, ScheduleProjectTarget>();
|
||||||
|
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 => (
|
||||||
|
<ModelTrigger
|
||||||
|
label={selectedModelLabel}
|
||||||
|
provider={selectedProvider}
|
||||||
|
disabled={disabled}
|
||||||
|
active={hovered || pressed || isOpen}
|
||||||
|
isPlaceholder={!selectedModel}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[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<ScheduleCadence>(
|
||||||
|
() => schedule?.cadence ?? DEFAULT_CADENCE,
|
||||||
|
);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(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<SheetHeader>(
|
||||||
|
() => ({ title: isEdit ? "Edit schedule" : "New schedule" }),
|
||||||
|
[isEdit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const footer = useMemo(
|
||||||
|
() => (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Button
|
||||||
|
style={styles.footerButton}
|
||||||
|
variant="secondary"
|
||||||
|
onPress={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
style={styles.footerButton}
|
||||||
|
variant="default"
|
||||||
|
onPress={handleSubmitPress}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
loading={isSubmitting}
|
||||||
|
testID="schedule-form-submit"
|
||||||
|
>
|
||||||
|
{isEdit ? "Save changes" : "Create schedule"}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[canSubmit, handleSubmitPress, isEdit, isSubmitting, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdaptiveModalSheet
|
||||||
|
header={header}
|
||||||
|
visible={visible}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={footer}
|
||||||
|
webScrollbar
|
||||||
|
testID="schedule-form-sheet"
|
||||||
|
>
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Name</Text>
|
||||||
|
<AdaptiveTextInput
|
||||||
|
testID="schedule-name-input"
|
||||||
|
accessibilityLabel="Schedule name"
|
||||||
|
initialValue={name}
|
||||||
|
resetKey={`schedule-name-${fieldResetKey}`}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder="Optional"
|
||||||
|
style={styles.input}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Prompt</Text>
|
||||||
|
<AdaptiveTextInput
|
||||||
|
testID="schedule-prompt-input"
|
||||||
|
accessibilityLabel="Prompt"
|
||||||
|
initialValue={prompt}
|
||||||
|
resetKey={`schedule-prompt-${fieldResetKey}`}
|
||||||
|
value={prompt}
|
||||||
|
onChangeText={setPrompt}
|
||||||
|
placeholder="What should the agent do each run?"
|
||||||
|
style={styles.multilineInput}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Project</Text>
|
||||||
|
<ProjectField
|
||||||
|
options={projectOptions.options}
|
||||||
|
targetByOptionId={projectOptions.targetByOptionId}
|
||||||
|
value={selectedProjectOptionId}
|
||||||
|
selectedTarget={selectedProjectTarget}
|
||||||
|
onSelect={handleSelectProject}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Model</Text>
|
||||||
|
<CombinedModelSelector
|
||||||
|
providers={modelSelectorProviders}
|
||||||
|
selectedProvider={selectedProvider ?? ""}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onSelect={setProviderAndModelFromUser}
|
||||||
|
isLoading={isAllModelsLoading}
|
||||||
|
renderTrigger={renderModelTrigger}
|
||||||
|
triggerFill
|
||||||
|
serverId={mutationServerId}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{modeOptions.length > 0 ? (
|
||||||
|
<ModeField options={modeOptions} selectedMode={selectedMode} onSelect={setModeFromUser} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Cadence</Text>
|
||||||
|
<CadenceEditor value={cadence} onChange={setCadence} error={cadenceError ?? undefined} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Max runs</Text>
|
||||||
|
<AdaptiveTextInput
|
||||||
|
testID="schedule-max-runs-input"
|
||||||
|
accessibilityLabel="Max runs"
|
||||||
|
initialValue={maxRuns}
|
||||||
|
resetKey={`schedule-max-runs-${fieldResetKey}`}
|
||||||
|
value={maxRuns}
|
||||||
|
onChangeText={setMaxRuns}
|
||||||
|
placeholder="Unlimited"
|
||||||
|
style={styles.input}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
/>
|
||||||
|
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{editConfig === null && isEdit ? (
|
||||||
|
<Text style={styles.hint}>This schedule does not target a new agent.</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{submitError ? <Text style={styles.error}>{submitError}</Text> : null}
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<View>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const comboboxOptions = useMemo<ComboboxOption[]>(
|
||||||
|
() => 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 (
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Mode</Text>
|
||||||
|
<View ref={anchorRef} collapsable={false}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
style={triggerStyle}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Select mode (${selectedLabel})`}
|
||||||
|
testID="schedule-mode-trigger"
|
||||||
|
>
|
||||||
|
<Text style={styles.selectTriggerText} numberOfLines={1}>
|
||||||
|
{selectedLabel}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={16} color={styles.chevron.color} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<Combobox
|
||||||
|
options={comboboxOptions}
|
||||||
|
value={selectedMode}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
searchable={comboboxOptions.length > 6}
|
||||||
|
title="Select mode"
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
desktopPlacement="bottom-start"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectField({
|
||||||
|
options,
|
||||||
|
targetByOptionId,
|
||||||
|
value,
|
||||||
|
selectedTarget,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
options: ComboboxOption[];
|
||||||
|
targetByOptionId: Map<string, ScheduleProjectTarget>;
|
||||||
|
value: string;
|
||||||
|
selectedTarget: ScheduleProjectTarget | null;
|
||||||
|
onSelect: (target: ScheduleProjectTarget) => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const anchorRef = useRef<View>(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;
|
||||||
|
}) => (
|
||||||
|
<ProjectOptionItem option={option} selected={selected} active={active} onPress={onPress} />
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View ref={anchorRef} collapsable={false}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
style={triggerStyle}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Select project (${displayValue})`}
|
||||||
|
testID="schedule-project-trigger"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={16} color={styles.chevron.color} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
{description ? <Text style={styles.hint}>{description}</Text> : null}
|
||||||
|
<Combobox
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
searchable
|
||||||
|
searchPlaceholder="Search projects..."
|
||||||
|
emptyText="No projects found"
|
||||||
|
title="Select project"
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
desktopPlacement="bottom-start"
|
||||||
|
renderOption={renderOption}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectOptionItem({
|
||||||
|
option,
|
||||||
|
selected,
|
||||||
|
active,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
option: ComboboxOption;
|
||||||
|
selected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const leadingSlot = useMemo(
|
||||||
|
() => (
|
||||||
|
<View style={styles.optionIconBox}>
|
||||||
|
<Folder size={16} color={styles.chevron.color} />
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComboboxItem
|
||||||
|
testID={buildProjectOptionTestId(option.id)}
|
||||||
|
label={option.label}
|
||||||
|
description={option.description}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onPress={onPress}
|
||||||
|
leadingSlot={leadingSlot}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 <Icon size={16} color={styles.providerIcon.color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<View pointerEvents="none" style={containerStyle} testID="schedule-model-trigger">
|
||||||
|
<ProviderGlyph provider={provider} />
|
||||||
|
<Text
|
||||||
|
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={16} color={styles.chevron.color} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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 <Icon size={PROVIDER_ICON_SIZE} color={styles.providerIcon.color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<View
|
||||||
|
style={styles.rowContainer}
|
||||||
|
onPointerEnter={handlePointerEnter}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={rowStyle}
|
||||||
|
onPress={onEdit}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Edit schedule ${title}`}
|
||||||
|
testID={`schedule-row-${schedule.id}`}
|
||||||
|
>
|
||||||
|
<View style={styles.main}>
|
||||||
|
<View style={styles.leading}>
|
||||||
|
<ProviderGlyph provider={provider} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.textGroup}>
|
||||||
|
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||||
|
{meta}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.trailing}>
|
||||||
|
<StatusBadge
|
||||||
|
label={statusLabel(schedule.status)}
|
||||||
|
variant={statusVariant(schedule.status)}
|
||||||
|
/>
|
||||||
|
<ScheduleKebabMenu
|
||||||
|
schedule={schedule}
|
||||||
|
pending={pending}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onPause={onPause}
|
||||||
|
onResume={onResume}
|
||||||
|
onRunNow={onRunNow}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const editLeading = <ThemedPencil size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||||
|
const pauseLeading = <ThemedPause size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||||
|
const resumeLeading = <ThemedPlay size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||||
|
const runLeading = <ThemedRotateCw size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||||
|
const deleteLeading = <ThemedTrash2 size={MENU_ICON_SIZE} uniProps={destructiveColorMapping} />;
|
||||||
|
|
||||||
|
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement {
|
||||||
|
return (
|
||||||
|
<ThemedKebab
|
||||||
|
size={MENU_ICON_SIZE}
|
||||||
|
uniProps={hovered ? foregroundColorMapping : mutedColorMapping}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduleKebabMenu({
|
||||||
|
schedule,
|
||||||
|
pending,
|
||||||
|
onEdit,
|
||||||
|
onPause,
|
||||||
|
onResume,
|
||||||
|
onRunNow,
|
||||||
|
onDelete,
|
||||||
|
}: Omit<ScheduleRowProps, "isFirst">): ReactElement {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
hitSlop={8}
|
||||||
|
style={kebabTriggerStyle}
|
||||||
|
accessibilityRole={isNative ? "button" : undefined}
|
||||||
|
accessibilityLabel="Schedule actions"
|
||||||
|
testID={`schedule-kebab-${schedule.id}`}
|
||||||
|
>
|
||||||
|
{renderKebabTriggerIcon}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={220}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
leading={editLeading}
|
||||||
|
onSelect={onEdit}
|
||||||
|
testID={`schedule-menu-edit-${schedule.id}`}
|
||||||
|
>
|
||||||
|
Edit schedule
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{schedule.status === "paused" ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
leading={resumeLeading}
|
||||||
|
status={pending?.resume ? "pending" : "idle"}
|
||||||
|
pendingLabel="Resuming..."
|
||||||
|
onSelect={onResume}
|
||||||
|
testID={`schedule-menu-resume-${schedule.id}`}
|
||||||
|
>
|
||||||
|
Resume schedule
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
leading={pauseLeading}
|
||||||
|
disabled={schedule.status === "completed"}
|
||||||
|
status={pending?.pause ? "pending" : "idle"}
|
||||||
|
pendingLabel="Pausing..."
|
||||||
|
onSelect={onPause}
|
||||||
|
testID={`schedule-menu-pause-${schedule.id}`}
|
||||||
|
>
|
||||||
|
Pause schedule
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
leading={runLeading}
|
||||||
|
status={pending?.runNow ? "pending" : "idle"}
|
||||||
|
pendingLabel="Starting..."
|
||||||
|
onSelect={onRunNow}
|
||||||
|
testID={`schedule-menu-run-${schedule.id}`}
|
||||||
|
>
|
||||||
|
Run now
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
leading={deleteLeading}
|
||||||
|
destructive
|
||||||
|
status={pending?.delete ? "pending" : "idle"}
|
||||||
|
pendingLabel="Deleting..."
|
||||||
|
onSelect={onDelete}
|
||||||
|
testID={`schedule-menu-delete-${schedule.id}`}
|
||||||
|
>
|
||||||
|
Delete schedule
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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 (
|
||||||
|
<View style={styles.listContent} testID="schedules-table">
|
||||||
|
<View style={settingsStyles.card}>
|
||||||
|
{schedules.map((schedule, index) => (
|
||||||
|
<SchedulesTableRow
|
||||||
|
key={schedule.id}
|
||||||
|
schedule={schedule}
|
||||||
|
isFirst={index === 0}
|
||||||
|
mutations={mutations}
|
||||||
|
onEditSchedule={onEditSchedule}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<typeof useScheduleMutations>;
|
||||||
|
|
||||||
|
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<ScheduleRowPending>(NO_PENDING);
|
||||||
|
|
||||||
|
const runAction = useCallback(
|
||||||
|
async (key: keyof ScheduleRowPending, action: () => Promise<void>): Promise<void> => {
|
||||||
|
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 (
|
||||||
|
<ScheduleRow
|
||||||
|
schedule={schedule}
|
||||||
|
isFirst={isFirst}
|
||||||
|
pending={pending}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onPause={handlePause}
|
||||||
|
onResume={handleResume}
|
||||||
|
onRunNow={handleRunNow}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -76,6 +76,7 @@ export interface UseAgentFormStateResult {
|
|||||||
refreshProviderModels: (provider?: AgentProvider) => void;
|
refreshProviderModels: (provider?: AgentProvider) => void;
|
||||||
refetchProviderModelsIfStale: () => void;
|
refetchProviderModelsIfStale: () => void;
|
||||||
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
||||||
|
clearProviderSelectionFromUser: () => void;
|
||||||
workingDirIsEmpty: boolean;
|
workingDirIsEmpty: boolean;
|
||||||
persistFormPreferences: () => Promise<void>;
|
persistFormPreferences: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -404,6 +405,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearProviderSelectionFromUser = useCallback(() => {
|
||||||
|
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setModeFromUser = useCallback(
|
const setModeFromUser = useCallback(
|
||||||
(modeId: string) => {
|
(modeId: string) => {
|
||||||
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||||
@@ -547,6 +552,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
refreshProviderModels,
|
refreshProviderModels,
|
||||||
refetchProviderModelsIfStale,
|
refetchProviderModelsIfStale,
|
||||||
setProviderAndModelFromUser,
|
setProviderAndModelFromUser,
|
||||||
|
clearProviderSelectionFromUser,
|
||||||
workingDirIsEmpty,
|
workingDirIsEmpty,
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
}),
|
}),
|
||||||
@@ -581,6 +587,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
refreshProviderModels,
|
refreshProviderModels,
|
||||||
refetchProviderModelsIfStale,
|
refetchProviderModelsIfStale,
|
||||||
setProviderAndModelFromUser,
|
setProviderAndModelFromUser,
|
||||||
|
clearProviderSelectionFromUser,
|
||||||
workingDirIsEmpty,
|
workingDirIsEmpty,
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||||
import type { ProjectSummary } from "@/utils/projects";
|
import type { ProjectSummary } from "@/utils/projects";
|
||||||
@@ -16,6 +16,10 @@ export type {
|
|||||||
|
|
||||||
export const projectsQueryKey = ["projects"] as const;
|
export const projectsQueryKey = ["projects"] as const;
|
||||||
|
|
||||||
|
function projectsQueryRuntimeKey(hosts: readonly ProjectsHostInput[]) {
|
||||||
|
return hosts.map((host) => host.serverId).join("|");
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseProjectsResult {
|
export interface UseProjectsResult {
|
||||||
projects: ProjectSummary[];
|
projects: ProjectSummary[];
|
||||||
hostErrors: ProjectHostError[];
|
hostErrors: ProjectHostError[];
|
||||||
@@ -27,6 +31,11 @@ export interface UseProjectsResult {
|
|||||||
export function useProjects(): UseProjectsResult {
|
export function useProjects(): UseProjectsResult {
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
|
const runtimeVersion = useSyncExternalStore(
|
||||||
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
|
() => runtime.getVersion(),
|
||||||
|
() => runtime.getVersion(),
|
||||||
|
);
|
||||||
const hostInputs = useMemo<ProjectsHostInput[]>(
|
const hostInputs = useMemo<ProjectsHostInput[]>(
|
||||||
() =>
|
() =>
|
||||||
hosts.map((host) => ({
|
hosts.map((host) => ({
|
||||||
@@ -37,8 +46,9 @@ export function useProjects(): UseProjectsResult {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const projectsQuery = useQuery({
|
const projectsQuery = useQuery({
|
||||||
queryKey: projectsQueryKey,
|
queryKey: [...projectsQueryKey, projectsQueryRuntimeKey(hostInputs), runtimeVersion] as const,
|
||||||
queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }),
|
queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }),
|
||||||
|
staleTime: 5_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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<CreateScheduleOptions, "requestId">;
|
||||||
|
export type UpdateScheduleInput = Omit<UpdateScheduleOptions, "requestId">;
|
||||||
|
|
||||||
|
export interface UseScheduleMutationsResult {
|
||||||
|
createSchedule: (input: CreateScheduleInput) => Promise<void>;
|
||||||
|
updateSchedule: (input: UpdateScheduleInput) => Promise<void>;
|
||||||
|
pauseSchedule: (id: string) => Promise<void>;
|
||||||
|
resumeSchedule: (id: string) => Promise<void>;
|
||||||
|
deleteSchedule: (id: string) => Promise<void>;
|
||||||
|
runScheduleNow: (id: string) => Promise<void>;
|
||||||
|
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<FetchAggregatedSchedulesResult>({
|
||||||
|
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<FetchAggregatedSchedulesResult>(
|
||||||
|
{ 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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<ScheduleListSnapshot> => {
|
||||||
|
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<void> => {
|
||||||
|
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<ScheduleListSnapshot> => {
|
||||||
|
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<void> => {
|
||||||
|
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<ScheduleListSnapshot> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
await createMutation.mutateAsync(input);
|
||||||
|
},
|
||||||
|
[createMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSchedule = useCallback(
|
||||||
|
async (input: UpdateScheduleInput): Promise<void> => {
|
||||||
|
await updateMutation.mutateAsync(input);
|
||||||
|
},
|
||||||
|
[updateMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pauseSchedule = useCallback(
|
||||||
|
async (id: string): Promise<void> => {
|
||||||
|
await pauseMutation.mutateAsync(id);
|
||||||
|
},
|
||||||
|
[pauseMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resumeSchedule = useCallback(
|
||||||
|
async (id: string): Promise<void> => {
|
||||||
|
await resumeMutation.mutateAsync(id);
|
||||||
|
},
|
||||||
|
[resumeMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteSchedule = useCallback(
|
||||||
|
async (id: string): Promise<void> => {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
},
|
||||||
|
[deleteMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runScheduleNow = useCallback(
|
||||||
|
async (id: string): Promise<void> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<ScheduleHostInput[]>(
|
||||||
|
() =>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -789,6 +789,7 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "السجل",
|
sessions: "السجل",
|
||||||
|
schedules: "الجداول",
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "إعداد البرامج النصية لشجرة العمل",
|
title: "إعداد البرامج النصية لشجرة العمل",
|
||||||
|
|||||||
@@ -796,6 +796,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "History",
|
sessions: "History",
|
||||||
|
schedules: "Schedules",
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "Set up worktree scripts",
|
title: "Set up worktree scripts",
|
||||||
|
|||||||
@@ -816,6 +816,7 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "Historial",
|
sessions: "Historial",
|
||||||
|
schedules: "Horarios",
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "Configurar secuencias de comandos del árbol de trabajo",
|
title: "Configurar secuencias de comandos del árbol de trabajo",
|
||||||
|
|||||||
@@ -815,6 +815,7 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "Historique",
|
sessions: "Historique",
|
||||||
|
schedules: "Planifications",
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "Configurer les scripts d'arbre de travail",
|
title: "Configurer les scripts d'arbre de travail",
|
||||||
|
|||||||
@@ -801,6 +801,7 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "履歴",
|
sessions: "履歴",
|
||||||
|
schedules: "スケジュール",
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "ワークツリースクリプトを設定",
|
title: "ワークツリースクリプトを設定",
|
||||||
|
|||||||
@@ -807,6 +807,7 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "Histórico",
|
sessions: "Histórico",
|
||||||
|
schedules: "Agendamentos",
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "Configurar scripts de worktree",
|
title: "Configurar scripts de worktree",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "История",
|
sessions: "История",
|
||||||
|
schedules: "Расписания",
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "Настройка сценариев рабочего дерева",
|
title: "Настройка сценариев рабочего дерева",
|
||||||
|
|||||||
@@ -783,6 +783,7 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "历史",
|
sessions: "历史",
|
||||||
|
schedules: "计划",
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "设置 worktree scripts",
|
title: "设置 worktree scripts",
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export type AgentFormAction =
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
availableModels: AgentModelDefinition[] | null;
|
availableModels: AgentModelDefinition[] | null;
|
||||||
}
|
}
|
||||||
|
| { type: "CLEAR_PROVIDER_SELECTION_FROM_USER" }
|
||||||
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
|
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
|
||||||
| { type: "SET_WORKING_DIR"; value: string }
|
| { type: "SET_WORKING_DIR"; value: string }
|
||||||
| { type: "SET_WORKING_DIR_FROM_USER"; 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":
|
case "SET_THINKING_OPTION_FROM_USER":
|
||||||
return {
|
return {
|
||||||
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
|
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
|
||||||
|
|||||||
@@ -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<DaemonClient, "scheduleList"> | 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<FetchAggregatedSchedulesResult> {
|
||||||
|
const sections = await Promise.all(
|
||||||
|
input.hosts.map(async (host): Promise<ScheduleHostSection> => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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 <View style={styles.container} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SchedulesScreenContent />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SchedulesScreenContent(): ReactElement {
|
||||||
|
const { sections, isLoading, isError, error, refetch } = useSchedules();
|
||||||
|
const [form, setForm] = useState<FormState>({ 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(
|
||||||
|
() => (
|
||||||
|
<Button leftIcon={Plus} onPress={openCreate} size="sm" testID="schedules-new">
|
||||||
|
New schedule
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[openCreate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<MenuHeader title="Schedules" rightContent={headerAction} />
|
||||||
|
<SchedulesBody
|
||||||
|
sections={sections}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
onRetry={refetch}
|
||||||
|
onCreate={openCreate}
|
||||||
|
onEdit={openEdit}
|
||||||
|
/>
|
||||||
|
<ScheduleFormSheet
|
||||||
|
serverId={form.mode === "edit" ? form.serverId : undefined}
|
||||||
|
visible={form.mode === "create" || form.mode === "edit"}
|
||||||
|
onClose={closeForm}
|
||||||
|
mode={form.mode === "edit" ? "edit" : "create"}
|
||||||
|
schedule={form.mode === "edit" ? form.schedule : undefined}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<LoadingSpinner size="large" color={styles.spinner.color} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<Text style={styles.message}>{error?.message ?? "Could not load schedules"}</Text>
|
||||||
|
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<Text style={styles.message}>No schedules yet</Text>
|
||||||
|
<Button leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
|
||||||
|
New schedule
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
testID="schedules-sections"
|
||||||
|
>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<ScheduleHostSectionView key={section.serverId} section={section} onEdit={onEdit} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.section} testID={`schedules-section-${section.serverId}`}>
|
||||||
|
<Text style={styles.sectionTitle} testID={`schedules-section-title-${section.serverId}`}>
|
||||||
|
{section.serverName}
|
||||||
|
</Text>
|
||||||
|
{section.error ? <Text style={styles.sectionError}>{section.error}</Text> : null}
|
||||||
|
{section.schedules.length > 0 ? (
|
||||||
|
<SchedulesTable
|
||||||
|
serverId={section.serverId}
|
||||||
|
schedules={section.schedules}
|
||||||
|
onEditSchedule={handleEdit}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{section.schedules.length === 0 && !section.error ? (
|
||||||
|
<View style={styles.sectionEmpty}>
|
||||||
|
<Text style={styles.sectionEmptyText}>{emptyMessage}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -415,6 +415,10 @@ export function buildSessionsRoute() {
|
|||||||
return "/sessions" as const;
|
return "/sessions" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildSchedulesRoute() {
|
||||||
|
return "/schedules" as const;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildOpenProjectRoute() {
|
export function buildOpenProjectRoute() {
|
||||||
return "/open-project" as const;
|
return "/open-project" as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<IntervalUnit, number> = {
|
||||||
|
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<IntervalUnit, string> = {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<number>();
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", () => {
|
test("rejects invalid cron time zones", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
validateScheduleCadence({
|
validateScheduleCadence({
|
||||||
|
|||||||
@@ -1,86 +1,6 @@
|
|||||||
|
import { parseCronExpression } from "@getpaseo/protocol/schedule/cron-expression";
|
||||||
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
||||||
|
|
||||||
interface CronFieldMatcher {
|
|
||||||
matches(value: number): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildValueSet(values: Iterable<number>): Set<number> {
|
|
||||||
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<number>();
|
|
||||||
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 {
|
interface CronDateParts {
|
||||||
minute: number;
|
minute: number;
|
||||||
hour: number;
|
hour: number;
|
||||||
@@ -89,21 +9,6 @@ interface CronDateParts {
|
|||||||
dayOfWeek: number;
|
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 {
|
function startOfNextMinute(date: Date): Date {
|
||||||
return new Date(
|
return new Date(
|
||||||
Date.UTC(
|
Date.UTC(
|
||||||
|
|||||||
Reference in New Issue
Block a user