Scheduled runs each get their own workspace in the sidebar (#1934)

* feat(schedules): per-run workspaces, isolation and archive controls

Scheduled agents never appeared in the sidebar: each schedule reused one
stamped workspace and archived the run's agent on completion. Now every run
mints its own workspace — the entity the sidebar shows — with per-schedule
controls for isolation (local or worktree) and whether to archive that
workspace when the run finishes.

The schedule form is rebuilt on a non-React form model (open/commands/close)
to end the flicker, edit-into-create contamination, and multi-host hydration
bugs that came from effect-choreographed shared state. That lands the reusable
foundations it needed: a form kit over a single control-geometry owner, a
data-access taxonomy (replica/snapshot/fetch) with real load-state semantics,
and lint/boundary guardrails so new screens inherit the paved road.

Also fixes desktop combobox popovers truncating options when the trigger is
narrower than the content. New schedule protocol fields are optional on both
create and update, so old and new clients/daemons stay compatible.

* test(app): complete mock themes for control-geometry tokens

switch and terminal-profile-edit-modal now style through
createControlGeometry, which reads theme.borderWidth[1], theme.opacity[50],
and theme.colors.borderAccent. The hand-rolled mock themes in these two
suites lacked those tokens, so the eager StyleSheet.create mock threw at
import (collection error), not on any assertion. Add the missing tokens.

* fix(schedules): address review findings on run cleanup, edit isolation, load error, sheet dismiss, and preference hydration

- Server: archive the run's workspace even when agent creation fails, so a
  misconfigured provider no longer orphans a sidebar workspace/worktree.
- Edit form: keep a stored worktree isolation while worktree eligibility is
  still resolving, instead of silently downgrading a saved schedule to local.
- Schedules screen: show the load error/retry UI when every host fails, rather
  than spinning forever behind the loading branch.
- Form sheet: fire the parent onClose on native gesture/backdrop dismiss so the
  sheet closes instead of re-opening.
- Form model: apply late-loading saved preferences to untouched create-mode
  fields (never to edited schedules or user-modified fields).

* fix(schedules): review batch — reconnect resubscribe, worktree prune, Android dismiss, cadence and provider edit safety

- Push-router: re-send terminal and checkout-diff subscriptions after a
  reconnect; the old per-hook effect resubscribed on isConnected and the
  extracted router did not, so daemon restarts silently stopped terminals_changed
  pushes (root cause of the terminal-activity e2e failures).
- Server: pass the run's source repo root when archiving worktree runs so the
  worktree is unregistered (git worktree remove/prune), not just deleted; also
  archive the workspace when agent creation fails even with archive-off (an
  agentless workspace has nothing to inspect).
- Sheet: RN Modal onDismiss is iOS-only — notify dismiss once-per-close on the
  Android native path too; replace the JSDOM component test with a real
  Playwright dismiss spec and inline the trivial dismiss decision.
- Form model: editing an interval schedule no longer rewrites its cadence to
  cron unless the cadence UI is touched (90-minute intervals have no cron
  equivalent); switching projects clears stale provider/model state and gates
  submit until the new host's snapshot resolves.

* chore(lint): drop custom lint wrapper and boundary script, plain oxlint

The import bans stay in .oxlintrc.json (oxlint-native no-restricted-imports).
The receiver-sensitive checks the custom script enforced are not worth a
parallel lint pipeline; if they come back it will be as oxlint rules.

* fix(schedules): crash-safe run cleanup, capability-gated run options, resilient mutations

- Persist the run's workspace/agent ids on the running-run record so a daemon
  crash mid-run can be recovered: restart archives the interrupted run's
  workspace under the same policy as normal cleanup (agentless always archives,
  otherwise archiveOnFinish is respected).
- Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on
  hosts without workspaceMultiplicity — an old daemon ignores the fields, so
  offering the switch there would lie.
- Optimistic pause/resume/delete no longer throw when the schedules cache holds
  a still-connecting entry alongside loaded data.
- Mode field is gated on provider mode options, not a selected model, so
  model-less providers can pick their advertised modes.
This commit is contained in:
Mohamed Boudra
2026-07-07 22:13:42 +02:00
committed by GitHub
parent d3e1fe48f6
commit d9b65e269f
89 changed files with 9316 additions and 3499 deletions
@@ -0,0 +1,130 @@
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { describe, expect, it } from "vitest";
import {
fetchAggregatedSchedules,
type ScheduleRuntime,
type ScheduleRuntimeSnapshot,
} from "./aggregated-schedules";
function makeSchedule(overrides: Partial<ScheduleSummary> = {}): ScheduleSummary {
return {
id: "schedule-1",
name: "Nightly",
prompt: "Run the task",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
status: "active",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
nextRunAt: "2026-07-02T01:00:00.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
...overrides,
};
}
function makeRuntime(input: {
snapshots: Record<string, ScheduleRuntimeSnapshot | null>;
schedules?: Record<string, ScheduleSummary[]>;
}): ScheduleRuntime {
return {
getSnapshot: (serverId) => input.snapshots[serverId] ?? null,
getClient: (serverId) => {
const schedules = input.schedules?.[serverId];
if (!schedules) {
return null;
}
return {
scheduleList: async () => ({ requestId: "test-request", schedules, error: null }),
};
},
};
}
describe("fetchAggregatedSchedules load state", () => {
it("does not report loaded empty while known hosts are still connecting", async () => {
const result = await fetchAggregatedSchedules({
hosts: [
{ serverId: "host-a", serverName: "Host A" },
{ serverId: "host-b", serverName: "Host B" },
],
runtime: makeRuntime({
snapshots: {
"host-a": { connectionStatus: "connecting" },
"host-b": { connectionStatus: "connecting" },
},
}),
});
expect(result.status).not.toBe("loaded");
expect(result).toEqual({ status: "connecting" });
});
it("reports loaded empty after all reachable hosts answer with no schedules", async () => {
const result = await fetchAggregatedSchedules({
hosts: [
{ serverId: "host-a", serverName: "Host A" },
{ serverId: "host-b", serverName: "Host B" },
],
runtime: makeRuntime({
snapshots: {
"host-a": { connectionStatus: "online" },
"host-b": { connectionStatus: "online" },
},
schedules: {
"host-a": [],
"host-b": [],
},
}),
});
expect(result).toEqual({ status: "loaded", data: [], hostErrors: [] });
});
it("does not report loaded empty while another known host is still connecting", async () => {
const result = await fetchAggregatedSchedules({
hosts: [
{ serverId: "host-a", serverName: "Host A" },
{ serverId: "host-b", serverName: "Host B" },
],
runtime: makeRuntime({
snapshots: {
"host-a": { connectionStatus: "online" },
"host-b": { connectionStatus: "connecting" },
},
schedules: {
"host-a": [],
},
}),
});
expect(result).toEqual({ status: "connecting" });
});
it("loads reachable host data when another known host is still connecting", async () => {
const schedule = makeSchedule();
const result = await fetchAggregatedSchedules({
hosts: [
{ serverId: "host-a", serverName: "Host A" },
{ serverId: "host-b", serverName: "Host B" },
],
runtime: makeRuntime({
snapshots: {
"host-a": { connectionStatus: "online" },
"host-b": { connectionStatus: "connecting" },
},
schedules: {
"host-a": [schedule],
},
}),
});
expect(result).toEqual({
status: "loaded",
data: [{ ...schedule, serverId: "host-a", serverName: "Host A" }],
hostErrors: [],
});
});
});
@@ -2,6 +2,8 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { toErrorMessage } from "@/utils/error-messages";
export const schedulesQueryBaseKey = ["schedules"] as const;
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
export interface ScheduleHostInput {
@@ -31,11 +33,25 @@ export interface ScheduleHostError {
message: string;
}
export interface FetchAggregatedSchedulesConnectingResult {
status: "connecting";
}
export interface FetchAggregatedSchedulesResult {
schedules: AggregatedSchedule[];
status: "loaded";
data: AggregatedSchedule[];
hostErrors: ScheduleHostError[];
}
export type FetchAggregatedSchedulesState =
| FetchAggregatedSchedulesConnectingResult
| FetchAggregatedSchedulesResult;
export type AggregateLoadState<T> =
| { status: "connecting" }
| { status: "loading" }
| { status: "loaded"; data: T[] };
export interface FetchAggregatedSchedulesInput {
hosts: readonly ScheduleHostInput[];
runtime: ScheduleRuntime;
@@ -43,9 +59,8 @@ export interface FetchAggregatedSchedulesInput {
/**
* Fetch schedules across connected hosts and merge them into one flat list.
* Connectivity is checked here at execution time (not pre-filtered by the hook)
* so the query — retried as the runtime version changes — reliably picks a host
* up the moment it comes online, including on a cold deep-link.
* Connectivity is checked here at execution time, so explicit query refreshes
* pick up the currently connected host set.
*
* Offline hosts are skipped. A connected host that fails contributes to
* `hostErrors` (surfaced as a banner) while the rest still render; only when
@@ -53,7 +68,19 @@ export interface FetchAggregatedSchedulesInput {
*/
export async function fetchAggregatedSchedules(
input: FetchAggregatedSchedulesInput,
): Promise<FetchAggregatedSchedulesResult> {
): Promise<FetchAggregatedSchedulesState> {
const hasSettlingHost = input.hosts.some((host) =>
isScheduleHostConnectionSettling(input.runtime.getSnapshot(host.serverId)),
);
const hasAskableHost = input.hosts.some((host) => {
const snapshot = input.runtime.getSnapshot(host.serverId);
return snapshot?.connectionStatus === "online" && input.runtime.getClient(host.serverId);
});
if (!hasAskableHost && hasSettlingHost) {
return { status: "connecting" };
}
const schedules: AggregatedSchedule[] = [];
const hostErrors: ScheduleHostError[] = [];
let connectedAttempts = 0;
@@ -89,5 +116,18 @@ export async function fetchAggregatedSchedules(
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
}
return { schedules, hostErrors };
if (schedules.length === 0 && hasSettlingHost) {
return { status: "connecting" };
}
return { status: "loaded", data: schedules, hostErrors };
}
function isScheduleHostConnectionSettling(
snapshot: ScheduleRuntimeSnapshot | null | undefined,
): boolean {
if (!snapshot) {
return true;
}
return snapshot.connectionStatus === "connecting" || snapshot.connectionStatus === "idle";
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
CADENCE_PRESET_OPTIONS,
normalizeScheduleFormCadence,
resolveCronPresetId,
} from "./schedule-cadence-options";
describe("schedule cadence form options", () => {
it("offers the approved cron preset vocabulary", () => {
expect(CADENCE_PRESET_OPTIONS.map((option) => option.label)).toEqual([
"Every minute",
"Every hour",
"Daily 9:00",
"Weekdays 9:00",
"Mondays 9:00",
]);
});
it("maps interval cadences to cron cadences for the form", () => {
expect(
normalizeScheduleFormCadence({ type: "every", everyMs: 60_000 }, "Europe/Madrid"),
).toEqual({
type: "cron",
expression: "* * * * *",
timezone: "Europe/Madrid",
});
expect(
normalizeScheduleFormCadence({ type: "every", everyMs: 60 * 60_000 }, "Europe/Madrid"),
).toEqual({
type: "cron",
expression: "0 * * * *",
timezone: "Europe/Madrid",
});
expect(
normalizeScheduleFormCadence({ type: "every", everyMs: 24 * 60 * 60_000 }, "Europe/Madrid"),
).toEqual({
type: "cron",
expression: "0 9 * * *",
timezone: "Europe/Madrid",
});
});
it("maps unsupported intervals to the closest custom cron expression", () => {
const cadence = normalizeScheduleFormCadence(
{ type: "every", everyMs: 5 * 60_000 },
"Europe/Madrid",
);
expect(cadence).toEqual({
type: "cron",
expression: "*/5 * * * *",
timezone: "Europe/Madrid",
});
expect(resolveCronPresetId(cadence)).toBe("custom");
});
});
@@ -0,0 +1,62 @@
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
import { everyMsToParts } from "@/utils/schedule-format";
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
export interface CadencePresetOption {
id: string;
label: string;
expression: string;
}
export const CUSTOM_CRON_PRESET_ID = "custom";
export const CADENCE_PRESET_OPTIONS: CadencePresetOption[] = [
{ id: "every-minute", label: "Every minute", expression: "* * * * *" },
{ id: "every-hour", label: "Every hour", expression: "0 * * * *" },
{ id: "daily-9", label: "Daily 9:00", expression: "0 9 * * *" },
{ id: "weekdays-9", label: "Weekdays 9:00", expression: "0 9 * * 1-5" },
{ id: "mondays-9", label: "Mondays 9:00", expression: "0 9 * * 1" },
];
export function resolveCronPresetId(cadence: CronCadence): string {
const expression = cadence.expression.trim();
return (
CADENCE_PRESET_OPTIONS.find((option) => option.expression === expression)?.id ??
CUSTOM_CRON_PRESET_ID
);
}
export function resolveCronPresetDisplay(cadence: CronCadence): { label: string } {
return {
label:
CADENCE_PRESET_OPTIONS.find((option) => option.id === resolveCronPresetId(cadence))?.label ??
"Custom cron",
};
}
export function normalizeScheduleFormCadence(
cadence: ScheduleCadence,
timezone: string,
): CronCadence {
if (cadence.type === "cron") {
return { ...cadence, timezone: cadence.timezone ?? timezone };
}
return {
type: "cron",
expression: everyMsToCronExpression(cadence.everyMs),
timezone,
};
}
function everyMsToCronExpression(everyMs: number): string {
const { value, unit } = everyMsToParts(everyMs);
if (unit === "minutes") {
return value === 1 ? "* * * * *" : `*/${Math.min(value, 59)} * * * *`;
}
if (unit === "hours") {
return value === 1 ? "0 * * * *" : `0 */${Math.min(value, 23)} * * *`;
}
return value === 1 ? "0 9 * * *" : `0 9 */${Math.min(value, 31)} * *`;
}
@@ -0,0 +1,612 @@
import type {
AgentMode,
AgentModelDefinition,
ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { describe, expect, it } from "vitest";
import { buildProjectOptionId, type ScheduleProjectTarget } from "./schedule-project-targets";
import { openScheduleForm, type ScheduleFormSnapshot } from "./schedule-form-model";
type TestSchedule = ScheduleSummary & { serverId: string; serverName: string };
const HOSTS = [
{ serverId: "host-a", label: "Host A", supportsWorkspaceMultiplicity: true },
{ serverId: "host-b", label: "Host B", supportsWorkspaceMultiplicity: true },
] as const;
const MOCK_MODES: AgentMode[] = [{ id: "load-test", label: "Load test" }];
const HOST_A_MODELS: AgentModelDefinition[] = [
{
provider: "mock",
id: "model-a",
label: "Model A",
isDefault: true,
},
];
const HOST_B_MODELS: AgentModelDefinition[] = [
{
provider: "mock",
id: "model-b",
label: "Model B",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "high", label: "High", isDefault: true },
],
},
];
const ALL_MODELS = [...HOST_A_MODELS, ...HOST_B_MODELS];
function target(input: {
serverId: string;
projectKey: string;
projectName: string;
cwd: string;
isGit?: boolean;
}): ScheduleProjectTarget {
return {
optionId: buildProjectOptionId(input.serverId, input.projectKey),
serverId: input.serverId,
serverName: input.serverId === "host-a" ? "Host A" : "Host B",
projectKey: input.projectKey,
projectName: input.projectName,
cwd: input.cwd,
isGit: input.isGit ?? true,
};
}
const PROJECT_TARGETS = [
target({
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
cwd: "/repo/a",
}),
target({
serverId: "host-b",
projectKey: "project-b",
projectName: "Project B",
cwd: "/repo/b",
}),
];
function scheduleOnHost(input: {
serverId: string;
serverName: string;
cwd: string;
model: string;
cadence?: ScheduleSummary["cadence"];
}): TestSchedule {
return {
id: `schedule-${input.serverId}`,
serverId: input.serverId,
serverName: input.serverName,
name: "Nightly",
prompt: "Run the schedule",
cadence: input.cadence ?? { type: "cron", expression: "0 9 * * *" },
target: {
type: "new-agent",
config: {
provider: "mock",
cwd: input.cwd,
model: input.model,
modeId: "load-test",
thinkingOptionId: "high",
archiveOnFinish: false,
isolation: "worktree",
},
},
status: "active",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
nextRunAt: "2026-07-02T00:00:00.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: 3,
};
}
function providerSnapshot(models: AgentModelDefinition[]): { entries: ProviderSnapshotEntry[] } {
return {
entries: [
{
provider: "mock",
label: "Mock",
status: "ready",
enabled: true,
fetchedAt: "2026-07-01T00:00:00.000Z",
models,
modes: MOCK_MODES,
defaultModeId: "load-test",
},
],
};
}
function open(snapshot: Omit<ScheduleFormSnapshot, "hosts">) {
return openScheduleForm({
...snapshot,
hosts: HOSTS,
});
}
function openWithHosts(snapshot: ScheduleFormSnapshot) {
return openScheduleForm(snapshot);
}
function applyPreferences(form: ReturnType<typeof open>, preferences: FormPreferences) {
form.applyPreferences(preferences);
}
describe("schedule form model", () => {
it("opens edit from the schedule host snapshot and completes that host resolution", () => {
const previous = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-a",
serverName: "Host A",
cwd: "/repo/a",
model: "model-a",
}),
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
});
previous.applyProviderSnapshot("host-a", providerSnapshot(HOST_A_MODELS));
previous.close();
const form = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-b",
serverName: "Host B",
cwd: "/repo/b",
model: "model-b",
}),
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
});
expect(form.getState()).toMatchObject({
mode: "edit",
selectedServerId: "host-b",
workingDir: "/repo/b",
selectedProvider: "mock",
selectedModel: "model-b",
selectedMode: "load-test",
selectedThinkingOptionId: "high",
projectDisplay: { label: "Project B" },
selectedProjectOptionId: buildProjectOptionId("host-b", "project-b"),
archiveOnFinish: false,
isolation: "worktree",
effectiveIsolation: "worktree",
providerResolutionByServerId: { "host-b": "pending" },
providerSnapshotRequest: {
serverId: "host-b",
cwd: "/repo/b",
},
});
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
expect(form.getState()).toMatchObject({
selectedServerId: "host-b",
selectedModelDisplay: { label: "Model B" },
selectedModeDisplay: { label: "Load test" },
selectedThinkingDisplay: { label: "High" },
providerResolutionByServerId: { "host-b": "complete" },
providerSnapshotRequest: null,
});
});
it("opens create pristine after an edit instance closes", () => {
const edit = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-b",
serverName: "Host B",
cwd: "/repo/b",
model: "model-b",
}),
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
});
edit.close();
const create = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
});
expect(create.getState()).toMatchObject({
mode: "create",
selectedServerId: "host-a",
workingDir: "",
selectedProvider: null,
selectedModel: "",
selectedMode: "",
selectedThinkingOptionId: "",
projectDisplay: null,
selectedProjectOptionId: "",
selectedModelDisplay: null,
selectedThinkingDisplay: null,
archiveOnFinish: true,
isolation: "local",
effectiveIsolation: "local",
providerResolutionByServerId: {},
});
});
it("derives the host to project to model disclosure chain from model state", () => {
const form = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
});
expect(form.getState().disclosure).toEqual({
showProjectField: true,
showModelField: false,
showThinkingField: false,
showModeField: false,
showIsolationField: false,
showArchiveOnFinishField: false,
});
form.setProject(buildProjectOptionId("host-a", "project-a"), { label: "Project A" });
expect(form.getState().disclosure).toEqual({
showProjectField: true,
showModelField: true,
showThinkingField: false,
showModeField: false,
showIsolationField: true,
showArchiveOnFinishField: true,
});
form.applyProviderSnapshot("host-a", providerSnapshot(HOST_B_MODELS));
form.setModel("mock", "model-b");
expect(form.getState().disclosure).toEqual({
showProjectField: true,
showModelField: true,
showThinkingField: true,
showModeField: true,
showIsolationField: true,
showArchiveOnFinishField: true,
});
});
it("hides isolation unless the selected project can create a worktree", () => {
const nonGitTarget = target({
serverId: "host-a",
projectKey: "plain-project",
projectName: "Plain Project",
cwd: "/tmp/plain",
isGit: false,
});
const nonGit = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: [nonGitTarget], preferences: {} },
});
nonGit.setProject(nonGitTarget.optionId, { label: "Plain Project" });
expect(nonGit.getState().disclosure).toMatchObject({
showIsolationField: false,
showArchiveOnFinishField: true,
});
const gitTarget = target({
serverId: "host-a",
projectKey: "git-project",
projectName: "Git Project",
cwd: "/tmp/git",
isGit: true,
});
const unsupportedHost = openWithHosts({
mode: "create",
hosts: [{ serverId: "host-a", label: "Host A" }],
defaults: { serverId: "host-a", projectTargets: [gitTarget], preferences: {} },
});
unsupportedHost.setProject(gitTarget.optionId, { label: "Git Project" });
expect(unsupportedHost.getState().disclosure).toMatchObject({
showIsolationField: false,
showArchiveOnFinishField: false,
});
expect(
Object.prototype.hasOwnProperty.call(unsupportedHost.getState(), "submitIsolation"),
).toBe(true);
expect(
Object.prototype.hasOwnProperty.call(unsupportedHost.getState(), "submitArchiveOnFinish"),
).toBe(true);
expect(
(unsupportedHost.getState() as unknown as { submitIsolation?: string }).submitIsolation,
).toBeUndefined();
expect(
(unsupportedHost.getState() as unknown as { submitArchiveOnFinish?: boolean })
.submitArchiveOnFinish,
).toBeUndefined();
});
it("shows modes for providers without selectable models", () => {
const form = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
});
form.setProject(buildProjectOptionId("host-a", "project-a"), { label: "Project A" });
form.applyProviderSnapshot("host-a", providerSnapshot([]));
form.setModel("mock", "");
expect(form.getState()).toMatchObject({
selectedProvider: "mock",
selectedModel: "",
disclosure: {
showModeField: true,
showThinkingField: false,
},
});
});
it("preserves stored worktree isolation until host resolution proves it unavailable", () => {
const nonGitTarget = target({
serverId: "host-b",
projectKey: "plain-project",
projectName: "Plain Project",
cwd: "/repo/b",
isGit: false,
});
const form = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-b",
serverName: "Host B",
cwd: "/repo/b",
model: "model-b",
}),
defaults: { serverId: null, projectTargets: [nonGitTarget], preferences: {} },
});
expect(form.getState()).toMatchObject({
isolation: "worktree",
effectiveIsolation: "worktree",
canUseWorktreeIsolation: false,
providerResolutionByServerId: { "host-b": "pending" },
});
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
expect(form.getState()).toMatchObject({
isolation: "worktree",
effectiveIsolation: "local",
canUseWorktreeIsolation: false,
providerResolutionByServerId: { "host-b": "complete" },
});
});
it("normalizes interval cadences to cron cadences when opening the form", () => {
const form = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-a",
serverName: "Host A",
cwd: "/repo/a",
model: "model-a",
cadence: { type: "every", everyMs: 60_000 },
}),
defaults: {
serverId: null,
projectTargets: PROJECT_TARGETS,
preferences: {},
timezone: "Europe/Madrid",
},
});
expect(form.getState().cadence).toEqual({
type: "cron",
expression: "* * * * *",
timezone: "Europe/Madrid",
});
});
it("preserves an edited schedule's interval cadence until the cadence changes", () => {
const originalCadence = { type: "every" as const, everyMs: 90 * 60_000 };
const form = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-a",
serverName: "Host A",
cwd: "/repo/a",
model: "model-a",
cadence: originalCadence,
}),
defaults: {
serverId: null,
projectTargets: PROJECT_TARGETS,
preferences: {},
timezone: "Europe/Madrid",
},
});
expect(form.getState().cadence).toEqual({
type: "cron",
expression: "*/59 * * * *",
timezone: "Europe/Madrid",
});
form.setName("Renamed without touching cadence");
expect(form.getState().submitCadence).toEqual(originalCadence);
form.setCadence({
type: "cron",
expression: "0 9 * * *",
timezone: "Europe/Madrid",
});
expect(form.getState().submitCadence).toEqual({
type: "cron",
expression: "0 9 * * *",
timezone: "Europe/Madrid",
});
});
it("clears provider selection while resolving a different project", () => {
const form = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
});
form.setPrompt("Run on a selected project.");
form.setProject(buildProjectOptionId("host-a", "project-a"), { label: "Project A" });
form.applyProviderSnapshot("host-a", providerSnapshot(HOST_A_MODELS));
form.setModel("mock", "model-a");
expect(form.getState()).toMatchObject({
selectedServerId: "host-a",
workingDir: "/repo/a",
selectedProvider: "mock",
selectedModel: "model-a",
canSubmit: true,
});
form.setProject(buildProjectOptionId("host-b", "project-b"), { label: "Project B" });
expect(form.getState()).toMatchObject({
selectedServerId: "host-b",
workingDir: "/repo/b",
selectedProvider: null,
selectedModel: "",
modelSelectorProviders: [],
canSubmit: false,
providerResolutionByServerId: { "host-a": "complete", "host-b": "pending" },
providerSnapshotRequest: { serverId: "host-b", cwd: "/repo/b" },
});
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
expect(form.getState()).toMatchObject({
selectedServerId: "host-b",
selectedProvider: null,
selectedModel: "",
providerResolutionByServerId: { "host-a": "complete", "host-b": "complete" },
providerSnapshotRequest: null,
modelSelectorProviders: [expect.objectContaining({ id: "mock", label: "Mock" })],
});
});
it("applies new project targets without resetting selected values", () => {
const projectA = PROJECT_TARGETS[0];
const form = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: [projectA], preferences: {} },
});
form.setName("Draft name");
form.setPrompt("Draft prompt");
form.setProject(projectA.optionId, { label: "Project A" });
form.applyProjectTargets([
projectA,
target({
serverId: "host-a",
projectKey: "project-c",
projectName: "Project C",
cwd: "/repo/c",
}),
]);
expect(form.getState()).toMatchObject({
name: "Draft name",
prompt: "Draft prompt",
selectedServerId: "host-a",
workingDir: "/repo/a",
projectDisplay: { label: "Project A" },
selectedProjectOptionId: projectA.optionId,
projectOptions: [
{
id: projectA.optionId,
value: projectA.optionId,
label: "Project A",
testID: "schedule-project-option-project-a",
},
{
id: buildProjectOptionId("host-a", "project-c"),
value: buildProjectOptionId("host-a", "project-c"),
label: "Project C",
testID: "schedule-project-option-project-c",
},
],
});
});
it("hydrates late create preferences without overwriting user changes or edited schedules", () => {
const savedPreferences: FormPreferences = {
provider: "mock",
providerPreferences: {
mock: {
model: "model-b",
mode: "load-test",
thinkingByModel: { "model-b": "high" },
},
},
isolation: "worktree",
};
const create = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
});
create.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS));
applyPreferences(create, savedPreferences);
expect(create.getState()).toMatchObject({
selectedProvider: "mock",
selectedModel: "model-b",
selectedMode: "load-test",
selectedThinkingOptionId: "high",
isolation: "worktree",
});
const userEdited = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
});
userEdited.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS));
userEdited.setModel("mock", "model-a");
userEdited.setIsolation("local");
applyPreferences(userEdited, savedPreferences);
expect(userEdited.getState()).toMatchObject({
selectedProvider: "mock",
selectedModel: "model-a",
isolation: "local",
});
const edit = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-a",
serverName: "Host A",
cwd: "/repo/a",
model: "model-a",
}),
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
});
edit.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS));
applyPreferences(edit, { ...savedPreferences, isolation: "local" });
expect(edit.getState()).toMatchObject({
selectedProvider: "mock",
selectedModel: "model-a",
selectedMode: "load-test",
isolation: "worktree",
});
});
});
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ export interface ScheduleProjectTarget {
projectKey: string;
projectName: string;
cwd: string;
isGit: boolean;
}
export function buildProjectOptionId(serverId: string, projectKey: string): string {
@@ -38,6 +39,7 @@ export function buildScheduleProjectTargets(
projectKey: project.projectKey,
projectName: project.projectName,
cwd,
isGit: Boolean(host.gitRuntime),
});
}
}
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { buildProjectOptionId, type ScheduleProjectTarget } from "./schedule-project-targets";
import { useScheduleFormModel } from "./use-schedule-form-model";
import type { ScheduleFormSnapshot } from "./schedule-form-model";
const HOSTS = [{ serverId: "host-a", label: "Host A" }] as const;
const PROJECT_A_ID = buildProjectOptionId("host-a", "project-a");
function projectTarget(input: {
projectKey: string;
projectName: string;
cwd: string;
}): ScheduleProjectTarget {
return {
optionId: buildProjectOptionId("host-a", input.projectKey),
serverId: "host-a",
serverName: "Host A",
projectKey: input.projectKey,
projectName: input.projectName,
cwd: input.cwd,
isGit: true,
};
}
function createSnapshot(projectTargets: readonly ScheduleProjectTarget[]): ScheduleFormSnapshot {
return {
mode: "create",
hosts: HOSTS,
defaults: {
serverId: "host-a",
projectTargets,
preferences: {},
},
};
}
describe("useScheduleFormModel", () => {
afterEach(() => {
cleanup();
});
it("keeps one model instance and draft state while open snapshot inputs churn", () => {
const firstTargets = [
projectTarget({ projectKey: "project-a", projectName: "Project A", cwd: "/repo/a" }),
];
const { result, rerender } = renderHook(({ snapshot }) => useScheduleFormModel(snapshot), {
initialProps: { snapshot: createSnapshot(firstTargets) },
});
const openedModel = result.current;
act(() => {
openedModel.setName("Draft name");
openedModel.setPrompt("Run the draft");
openedModel.setProject(PROJECT_A_ID, { label: "Project A" });
});
rerender({
snapshot: createSnapshot([
projectTarget({ projectKey: "project-a", projectName: "Project A", cwd: "/repo/a" }),
]),
});
expect(result.current).toBe(openedModel);
expect(result.current.getState()).toMatchObject({
name: "Draft name",
prompt: "Run the draft",
selectedServerId: "host-a",
workingDir: "/repo/a",
projectDisplay: { label: "Project A" },
selectedProjectOptionId: PROJECT_A_ID,
});
});
});
@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
import { openScheduleForm, type ScheduleFormSnapshot } from "./schedule-form-model";
export function useScheduleFormModel(snapshot: ScheduleFormSnapshot) {
const [model] = useState(() => openScheduleForm(snapshot));
useEffect(() => {
return () => {
model.close();
};
}, [model]);
useEffect(() => {
model.applyHosts(snapshot.hosts);
model.applyProjectTargets(snapshot.defaults.projectTargets);
model.applyPreferences(snapshot.defaults.preferences);
}, [model, snapshot.hosts, snapshot.defaults.preferences, snapshot.defaults.projectTargets]);
return model;
}
@@ -0,0 +1,25 @@
import { useEffect } from "react";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import type { ScheduleFormModel, ScheduleFormState } from "./schedule-form-model";
export function useScheduleFormProviderSnapshot(
model: ScheduleFormModel,
state: ScheduleFormState,
) {
const serverId = state.providerSnapshotRequest?.serverId ?? state.selectedServerId;
const cwd = state.providerSnapshotRequest?.cwd ?? state.workingDir;
const enabled = state.targetKind === "new-agent" && Boolean(serverId && cwd.trim());
const snapshot = useProvidersSnapshot(serverId ?? null, {
cwd,
enabled,
});
useEffect(() => {
if (!enabled || !serverId || !snapshot.entries) {
return;
}
model.applyProviderSnapshot(serverId, { entries: snapshot.entries });
}, [enabled, model, serverId, snapshot.entries]);
return snapshot;
}