Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 98 additions & 55 deletions apps/web/src/components/settings/ScheduledTasksSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
import type {
EnvironmentId,
ModelSelection,
OrchestrationV2ThreadLaunchWorkspaceStrategy,
ProjectId,
ScheduledTask,
ScheduledTaskId,
Expand All @@ -24,6 +23,7 @@ import type {
ThreadId,
} from "@t3tools/contracts";
import { DEFAULT_WEBHOOK_PROMPT } from "@t3tools/client-runtime/scheduled-task-webhook";
import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
import {
MAX_WEBHOOK_DELIVERY_AGE_MINUTES,
MIN_SCHEDULED_TASK_INTERVAL_MS,
Expand Down Expand Up @@ -54,6 +54,7 @@ import {
type EnvironmentPresentation,
} from "../../state/environments";
import { useProjects } from "../../state/entities";
import { projectEnvironment } from "../../state/projects";
import { useEnvironmentQuery } from "../../state/query";
import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
Expand All @@ -67,7 +68,10 @@ import {
matchesScheduledTaskScope,
scheduleFromDraft,
scheduledTaskDefaultModel,
scheduledTaskDefaultProjectId,
scheduledTaskProjectChoices,
taskToDraft,
workspaceStrategyFromDraft,
type DraftState,
type ScheduleMode,
type WorkspaceMode,
Expand Down Expand Up @@ -106,6 +110,7 @@ import {
const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] as const;
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
const WEEKDAY_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const;
const NO_PROJECT_VALUE = "no-project";

const WORKSPACE_MODE_LABELS: Record<WorkspaceMode, string> = {
worktree: "Create a new worktree",
Expand Down Expand Up @@ -786,14 +791,13 @@ function ScheduledTaskEditorDialog({
connected ? serverEnvironment.scheduledTasksLive({ environmentId, input: {} }) : null,
);
const allProjects = useProjects();
const projects = useMemo(
() =>
allProjects.filter(
(project) =>
project.environmentId === environmentId &&
matchesScheduledTaskScope(scope, environmentId, project.id),
),
[allProjects, environmentId, scope],
const scratchWorkspaceRoot = availableScratchWorkspaceRoot(
environment?.connection.phase,
environment?.serverConfig,
);
const { projects, scratchProject, canSelectNoProject } = useMemo(
() => scheduledTaskProjectChoices(scope, environmentId, allProjects, scratchWorkspaceRoot),
[allProjects, environmentId, scope, scratchWorkspaceRoot],
);
const settings = useEnvironmentSettings(environmentId);
const providers =
Expand All @@ -804,8 +808,14 @@ function ScheduledTaskEditorDialog({
const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, {
label: "scheduled task upsert",
});
const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false });
const [draft, setDraft] = useState<DraftState>(() =>
task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" },
task
? taskToDraft(task)
: {
...EMPTY_DRAFT,
projectId: scheduledTaskDefaultProjectId({ projects, canSelectNoProject }),
},
);
const [saving, setSaving] = useState(false);
const submissionPending = useRef(false);
Expand All @@ -818,8 +828,11 @@ function ScheduledTaskEditorDialog({
const liveTask = tasksQuery.data
? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null)
: task;
const selectedProjectId = draft.projectId || projects[0]?.id || "";
const selectedProject = projects.find((project) => project.id === selectedProjectId);
const selectedProjectId = draft.projectId;
const noProject = selectedProjectId === null || selectedProjectId === scratchProject?.id;
const selectedProject = noProject
? scratchProject
: projects.find((project) => project.id === selectedProjectId);
// The task runs in selectedProject, so its model picker should reflect that
// project's effective enablement, not just the machine's.
const instanceEntries = useMemo(
Expand Down Expand Up @@ -875,10 +888,13 @@ function ScheduledTaskEditorDialog({
if (
!draft.title.trim() ||
!draft.prompt.trim() ||
!projects.some((project) => project.id === selectedProjectId) ||
(noProject ? !canSelectNoProject : !selectedProject) ||
selection === null
) {
reportFailure("Scheduled task is incomplete", "Add a title, prompt, project, and model.");
reportFailure(
"Scheduled task is incomplete",
"Add a title, prompt, and model, and select a project or No project.",
);
return;
}
const schedule = scheduleFromDraft(draft);
Expand Down Expand Up @@ -906,7 +922,11 @@ function ScheduledTaskEditorDialog({
reportFailure("Invalid interval", "Enter an interval of at least one minute.");
return;
}
if (draft.workspaceMode === "existing_worktree" && !draft.existingWorktreePath.trim()) {
if (
!noProject &&
draft.workspaceMode === "existing_worktree" &&
!draft.existingWorktreePath.trim()
) {
reportFailure("Checkout path is required", "Enter the path of the checkout to run in.");
return;
}
Expand All @@ -918,33 +938,37 @@ function ScheduledTaskEditorDialog({
draft.baseModelSelection.model === selection.model
? draft.baseModelSelection
: selection;
const workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy =
draft.workspaceMode === "root"
? { type: "root" }
: draft.workspaceMode === "existing_worktree"
? { type: "existing_worktree", worktreePath: draft.existingWorktreePath.trim() }
: {
type: "worktree",
baseRef: draft.baseRef.trim() || "main",
startFromOrigin: draft.startFromOrigin,
};
// Lock before resolving Scratch, which may create the folder on first use.
submissionPending.current = true;
setSaving(true);
let projectId = selectedProject?.id;
if (noProject) {
const result = await openScratch({ environmentId, input: {} });
if (result._tag === "Failure") {
submissionPending.current = false;
setSaving(false);
if (!isAtomCommandInterrupted(result)) {
reportFailure("Could not save scheduled task", squashAtomCommandFailure(result));
}
return;
}
projectId = result.value.id;
}
const input: ScheduledTaskUpsertInput = {
...(draft.editingId ? { id: draft.editingId as ScheduledTaskId, requireExisting: true } : {}),
title: draft.title.trim(),
prompt: draft.prompt.trim(),
enabled: draft.enabled,
schedule,
projectId: selectedProjectId as ProjectId,
projectId: projectId as ProjectId,
threadId: draft.threadId ? (draft.threadId as ThreadId) : null,
workspaceStrategy,
workspaceStrategy: workspaceStrategyFromDraft(draft, noProject),
modelSelection,
runtimeMode: draft.runtimeMode,
interactionMode: draft.interactionMode,
creationSource: "web",
};
// Lock before React renders, and keep successful creates locked until the form closes.
submissionPending.current = true;
setSaving(true);
// Keep successful creates locked until the form closes.
const result = await upsertTask({ environmentId, input });
setSaving(false);
if (result._tag === "Failure") {
Expand Down Expand Up @@ -985,10 +1009,17 @@ function ScheduledTaskEditorDialog({
onValueChange={(id) => {
const next = connectedEnvironments.find((entry) => entry.environmentId === id);
if (!next) return;
const nextChoices = scheduledTaskProjectChoices(
scope,
next.environmentId,
allProjects,
availableScratchWorkspaceRoot(next.connection.phase, next.serverConfig),
);
setEnvironmentId(next.environmentId);
setDraft((current) => ({
...current,
projectId: "",
projectId: scheduledTaskDefaultProjectId(nextChoices, noProject),
threadId: "",
modelKey: "",
baseModelSelection: null,
baseRef: "main",
Expand Down Expand Up @@ -1045,17 +1076,27 @@ function ScheduledTaskEditorDialog({
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Project" htmlFor="scheduled-task-project">
<Select
value={selectedProjectId}
onValueChange={(projectId) =>
setDraft((current) => ({ ...current, projectId: projectId ?? "" }))
}
value={noProject ? NO_PROJECT_VALUE : selectedProjectId}
onValueChange={(projectId) => {
setDraft((current) => ({
...current,
projectId: projectId === NO_PROJECT_VALUE ? null : (projectId ?? ""),
threadId:
projectId === (noProject ? NO_PROJECT_VALUE : selectedProjectId)
? current.threadId
: "",
}));
}}
>
<SelectTrigger size="sm" id="scheduled-task-project">
<SelectValue placeholder="Select a project">
{selectedProject?.title}
{noProject ? "No project" : selectedProject?.title}
</SelectValue>
</SelectTrigger>
<SelectPopup>
{canSelectNoProject ? (
<SelectItem value={NO_PROJECT_VALUE}>No project</SelectItem>
) : null}
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.title}
Expand All @@ -1065,26 +1106,28 @@ function ScheduledTaskEditorDialog({
</Select>
</Field>

<Field label="Workspace" htmlFor="scheduled-task-workspace">
<Select
value={draft.workspaceMode}
onValueChange={(value) =>
setDraft((current) => ({ ...current, workspaceMode: value as WorkspaceMode }))
}
>
<SelectTrigger size="sm" id="scheduled-task-workspace">
<SelectValue>{WORKSPACE_MODE_LABELS[draft.workspaceMode]}</SelectValue>
</SelectTrigger>
<SelectPopup>
<SelectItem value="worktree">Create a new worktree</SelectItem>
<SelectItem value="root">Use the project checkout</SelectItem>
<SelectItem value="existing_worktree">Use a specific checkout</SelectItem>
</SelectPopup>
</Select>
</Field>
{!noProject ? (
<Field label="Workspace" htmlFor="scheduled-task-workspace">
<Select
value={draft.workspaceMode}
onValueChange={(value) =>
setDraft((current) => ({ ...current, workspaceMode: value as WorkspaceMode }))
}
>
<SelectTrigger size="sm" id="scheduled-task-workspace">
<SelectValue>{WORKSPACE_MODE_LABELS[draft.workspaceMode]}</SelectValue>
</SelectTrigger>
<SelectPopup>
<SelectItem value="worktree">Create a new worktree</SelectItem>
<SelectItem value="root">Use the project checkout</SelectItem>
<SelectItem value="existing_worktree">Use a specific checkout</SelectItem>
</SelectPopup>
</Select>
</Field>
) : null}
</div>

{draft.workspaceMode === "worktree" ? (
{!noProject && draft.workspaceMode === "worktree" ? (
<Field label="Base branch" htmlFor="scheduled-task-base-ref">
<WorktreeBaseBranchPicker
key={`${environmentId}:${selectedProjectId}`}
Expand All @@ -1101,7 +1144,7 @@ function ScheduledTaskEditorDialog({
/>
</Field>
) : null}
{draft.workspaceMode === "existing_worktree" ? (
{!noProject && draft.workspaceMode === "existing_worktree" ? (
<Field label="Checkout path" htmlFor="scheduled-task-checkout">
<Input
id="scheduled-task-checkout"
Expand Down
Loading
Loading