330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import { useId, useMemo, useRef, useState } from "react";
|
|||
|
|
import type { Project, ScheduledTask, ScheduledTaskInput, ScheduleKind } from "../../../lib/types";
|
||
|
|
import { addScheduledTask, updateScheduledTask } from "../../../lib/tauri-commands";
|
||
|
|
import { effectivePermissionMode, PERMISSION_MODES } from "../PermissionModeControl";
|
||
|
|
import Button from "../../ui/Button";
|
||
|
|
import Modal from "../../ui/Modal";
|
||
|
|
import SegmentedControl from "../../ui/SegmentedControl";
|
||
|
|
import { inputClass, monoInputClass } from "../../ui/Field";
|
||
|
|
import {
|
||
|
|
atTimestampIsPast,
|
||
|
|
CRON_PRESETS,
|
||
|
|
DEFAULT_WORKING_DIR,
|
||
|
|
describeCron,
|
||
|
|
MAX_TASK_PROMPT_LEN,
|
||
|
|
validateAtTimestamp,
|
||
|
|
validateCronExpression,
|
||
|
|
validateTaskName,
|
||
|
|
validateTaskPrompt,
|
||
|
|
validateWorkingDir,
|
||
|
|
} from "./taskValidation";
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
project: Project;
|
||
|
|
/** `null` creates a new task; a task edits it in place. */
|
||
|
|
task: ScheduledTask | null;
|
||
|
|
onClose: () => void;
|
||
|
|
/** Called after the scheduler accepted the change, to refresh the list. */
|
||
|
|
onSaved: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
const DEFAULT_CRON = "0 9 * * *";
|
||
|
|
|
||
|
|
/** `YYYY-MM-DD HH:MM`, one hour from now, as the one-shot default. */
|
||
|
|
function defaultAtTimestamp(now = new Date()): string {
|
||
|
|
const at = new Date(now.getTime() + 60 * 60 * 1000);
|
||
|
|
at.setSeconds(0, 0);
|
||
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||
|
|
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(
|
||
|
|
at.getHours(),
|
||
|
|
)}:${pad(at.getMinutes())}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create or edit a `triple-c-scheduler` task.
|
||
|
|
*
|
||
|
|
* Validation here mirrors the backend so mistakes surface before a round trip;
|
||
|
|
* the backend re-checks everything regardless.
|
||
|
|
*/
|
||
|
|
export default function TaskEditorModal({ project, task, onClose, onSaved }: Props) {
|
||
|
|
const formId = useId();
|
||
|
|
const nameRef = useRef<HTMLInputElement>(null);
|
||
|
|
|
||
|
|
const [name, setName] = useState(task?.name ?? "");
|
||
|
|
const [prompt, setPrompt] = useState(task?.prompt ?? "");
|
||
|
|
const [workingDir, setWorkingDir] = useState(task?.working_dir ?? DEFAULT_WORKING_DIR);
|
||
|
|
const [kind, setKind] = useState<ScheduleKind>(
|
||
|
|
task?.task_type === "once" ? "once" : "recurring",
|
||
|
|
);
|
||
|
|
const [cron, setCron] = useState(
|
||
|
|
task && task.task_type !== "once" ? task.schedule : DEFAULT_CRON,
|
||
|
|
);
|
||
|
|
const [at, setAt] = useState(task?.at ?? defaultAtTimestamp());
|
||
|
|
|
||
|
|
const [showAllErrors, setShowAllErrors] = useState(false);
|
||
|
|
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
||
|
|
const [saving, setSaving] = useState(false);
|
||
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||
|
|
|
||
|
|
const errors = {
|
||
|
|
name: validateTaskName(name),
|
||
|
|
prompt: validateTaskPrompt(prompt),
|
||
|
|
workingDir: validateWorkingDir(workingDir),
|
||
|
|
schedule: kind === "recurring" ? validateCronExpression(cron) : validateAtTimestamp(at),
|
||
|
|
};
|
||
|
|
const hasErrors = Object.values(errors).some(Boolean);
|
||
|
|
|
||
|
|
const show = (field: keyof typeof errors) =>
|
||
|
|
(showAllErrors || touched[field]) && errors[field] ? errors[field] : null;
|
||
|
|
|
||
|
|
const cronReading = useMemo(() => describeCron(cron), [cron]);
|
||
|
|
const atIsPast = kind === "once" && atTimestampIsPast(at);
|
||
|
|
|
||
|
|
const mode = effectivePermissionMode(project);
|
||
|
|
const modeLabel = PERMISSION_MODES.find((m) => m.value === mode)?.label ?? mode;
|
||
|
|
|
||
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
setShowAllErrors(true);
|
||
|
|
setSubmitError(null);
|
||
|
|
if (hasErrors) return;
|
||
|
|
|
||
|
|
const input: ScheduledTaskInput = {
|
||
|
|
name: name.trim(),
|
||
|
|
prompt: prompt.trim(),
|
||
|
|
scheduleKind: kind,
|
||
|
|
schedule: kind === "recurring" ? cron.trim() : at.trim(),
|
||
|
|
workingDir: workingDir.trim() || DEFAULT_WORKING_DIR,
|
||
|
|
};
|
||
|
|
|
||
|
|
setSaving(true);
|
||
|
|
try {
|
||
|
|
if (task) {
|
||
|
|
await updateScheduledTask(project.id, task.id, input, task.enabled);
|
||
|
|
} else {
|
||
|
|
await addScheduledTask(project.id, input);
|
||
|
|
}
|
||
|
|
onSaved();
|
||
|
|
onClose();
|
||
|
|
} catch (err) {
|
||
|
|
setSubmitError(String(err));
|
||
|
|
} finally {
|
||
|
|
setSaving(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const errorText = (message: string | null) =>
|
||
|
|
message ? (
|
||
|
|
<p role="alert" className="mt-1 text-xs text-[var(--error)]">
|
||
|
|
{message}
|
||
|
|
</p>
|
||
|
|
) : null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Modal
|
||
|
|
title={task ? `Edit task — ${task.name}` : "New scheduled task"}
|
||
|
|
onClose={onClose}
|
||
|
|
widthClassName="w-[40rem]"
|
||
|
|
initialFocusRef={nameRef}
|
||
|
|
footer={
|
||
|
|
<>
|
||
|
|
<Button size="md" variant="ghost" onClick={onClose} disabled={saving}>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button size="md" variant="primary" type="submit" form={formId} disabled={saving}>
|
||
|
|
{saving ? "Saving…" : task ? "Save changes" : "Create task"}
|
||
|
|
</Button>
|
||
|
|
</>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||
|
|
{/* Name */}
|
||
|
|
<div>
|
||
|
|
<label
|
||
|
|
htmlFor={`${formId}-name`}
|
||
|
|
className="block text-[13px] font-medium text-[var(--text-primary)] mb-1"
|
||
|
|
>
|
||
|
|
Name
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
id={`${formId}-name`}
|
||
|
|
ref={nameRef}
|
||
|
|
value={name}
|
||
|
|
onChange={(e) => setName(e.target.value)}
|
||
|
|
onBlur={() => setTouched((t) => ({ ...t, name: true }))}
|
||
|
|
placeholder="nightly-tests"
|
||
|
|
aria-invalid={show("name") ? true : undefined}
|
||
|
|
className={inputClass}
|
||
|
|
/>
|
||
|
|
{errorText(show("name"))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Prompt */}
|
||
|
|
<div>
|
||
|
|
<label
|
||
|
|
htmlFor={`${formId}-prompt`}
|
||
|
|
className="block text-[13px] font-medium text-[var(--text-primary)]"
|
||
|
|
>
|
||
|
|
Prompt
|
||
|
|
</label>
|
||
|
|
<p className="mt-0.5 mb-1 text-xs text-[var(--text-secondary)] leading-snug">
|
||
|
|
What Claude Code is asked to do on each run.
|
||
|
|
</p>
|
||
|
|
<textarea
|
||
|
|
id={`${formId}-prompt`}
|
||
|
|
value={prompt}
|
||
|
|
onChange={(e) => setPrompt(e.target.value)}
|
||
|
|
onBlur={() => setTouched((t) => ({ ...t, prompt: true }))}
|
||
|
|
rows={4}
|
||
|
|
maxLength={MAX_TASK_PROMPT_LEN}
|
||
|
|
placeholder="Run the test suite and summarise any failures."
|
||
|
|
aria-invalid={show("prompt") ? true : undefined}
|
||
|
|
className={`${inputClass} resize-y`}
|
||
|
|
/>
|
||
|
|
{errorText(show("prompt"))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Schedule */}
|
||
|
|
<div>
|
||
|
|
<span className="block text-[13px] font-medium text-[var(--text-primary)] mb-1">
|
||
|
|
Schedule
|
||
|
|
</span>
|
||
|
|
<SegmentedControl
|
||
|
|
label="Schedule kind"
|
||
|
|
segments={[
|
||
|
|
{ value: "recurring", label: "Recurring" },
|
||
|
|
{ value: "once", label: "Once" },
|
||
|
|
]}
|
||
|
|
value={kind}
|
||
|
|
onChange={(v) => {
|
||
|
|
setKind(v);
|
||
|
|
setSubmitError(null);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{kind === "recurring" ? (
|
||
|
|
<div className="mt-2 space-y-2">
|
||
|
|
<div className="flex flex-wrap gap-1">
|
||
|
|
{CRON_PRESETS.map((preset) => (
|
||
|
|
<Button
|
||
|
|
key={preset.expression}
|
||
|
|
onClick={() => {
|
||
|
|
setCron(preset.expression);
|
||
|
|
setTouched((t) => ({ ...t, schedule: true }));
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{preset.label}
|
||
|
|
</Button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
<input
|
||
|
|
id={`${formId}-cron`}
|
||
|
|
value={cron}
|
||
|
|
onChange={(e) => setCron(e.target.value)}
|
||
|
|
onBlur={() => setTouched((t) => ({ ...t, schedule: true }))}
|
||
|
|
placeholder="0 9 * * 1-5"
|
||
|
|
aria-label="Cron expression"
|
||
|
|
aria-describedby={`${formId}-cron-reading`}
|
||
|
|
aria-invalid={show("schedule") ? true : undefined}
|
||
|
|
className={monoInputClass}
|
||
|
|
/>
|
||
|
|
<p
|
||
|
|
id={`${formId}-cron-reading`}
|
||
|
|
aria-live="polite"
|
||
|
|
className="text-xs text-[var(--text-secondary)]"
|
||
|
|
>
|
||
|
|
<span className="font-mono">minute hour day-of-month month day-of-week</span> ·{" "}
|
||
|
|
{cronReading ? (
|
||
|
|
<span className="text-[var(--text-primary)]">{cronReading}</span>
|
||
|
|
) : (
|
||
|
|
<span>not a valid schedule yet</span>
|
||
|
|
)}
|
||
|
|
</p>
|
||
|
|
{errorText(show("schedule"))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="mt-2 space-y-1">
|
||
|
|
<input
|
||
|
|
id={`${formId}-at`}
|
||
|
|
value={at}
|
||
|
|
onChange={(e) => setAt(e.target.value)}
|
||
|
|
onBlur={() => setTouched((t) => ({ ...t, schedule: true }))}
|
||
|
|
placeholder="2026-12-25 09:05"
|
||
|
|
aria-label="Run at (YYYY-MM-DD HH:MM)"
|
||
|
|
aria-invalid={show("schedule") ? true : undefined}
|
||
|
|
className={monoInputClass}
|
||
|
|
/>
|
||
|
|
<p className="text-xs text-[var(--text-secondary)]">
|
||
|
|
Container local time, as <code className="font-mono">YYYY-MM-DD HH:MM</code>. The
|
||
|
|
task removes itself after it runs.
|
||
|
|
</p>
|
||
|
|
{atIsPast && (
|
||
|
|
<p className="text-xs text-[var(--warning)]">
|
||
|
|
That time has already passed. A one-shot task is stored as a cron entry without a
|
||
|
|
year, so it would next fire on that date next year.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{errorText(show("schedule"))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Working directory */}
|
||
|
|
<div>
|
||
|
|
<label
|
||
|
|
htmlFor={`${formId}-wd`}
|
||
|
|
className="block text-[13px] font-medium text-[var(--text-primary)]"
|
||
|
|
>
|
||
|
|
Working directory
|
||
|
|
</label>
|
||
|
|
<p className="mt-0.5 mb-1 text-xs text-[var(--text-secondary)] leading-snug">
|
||
|
|
Absolute path inside the container. Project folders are mounted under{" "}
|
||
|
|
<code className="font-mono">/workspace</code>.
|
||
|
|
</p>
|
||
|
|
<input
|
||
|
|
id={`${formId}-wd`}
|
||
|
|
value={workingDir}
|
||
|
|
onChange={(e) => setWorkingDir(e.target.value)}
|
||
|
|
onBlur={() => setTouched((t) => ({ ...t, workingDir: true }))}
|
||
|
|
placeholder={DEFAULT_WORKING_DIR}
|
||
|
|
aria-invalid={show("workingDir") ? true : undefined}
|
||
|
|
className={monoInputClass}
|
||
|
|
/>
|
||
|
|
{errorText(show("workingDir"))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* How a scheduled run actually behaves. */}
|
||
|
|
<div className="rounded-[var(--radius-control)] border border-[var(--border-color)] bg-[var(--bg-secondary)] px-3 py-2 space-y-1">
|
||
|
|
<p className="text-xs text-[var(--text-secondary)]">
|
||
|
|
Scheduled runs are <strong className="text-[var(--text-primary)]">headless</strong> —
|
||
|
|
the container executes <code className="font-mono">claude -p "…"</code> with no
|
||
|
|
terminal attached, using this project’s permission mode (
|
||
|
|
<strong className="text-[var(--text-primary)]">{modeLabel}</strong>).
|
||
|
|
</p>
|
||
|
|
{mode !== "bypass" && (
|
||
|
|
<p className="text-xs text-[var(--warning)]">
|
||
|
|
A headless run cannot answer a permission prompt. In {modeLabel} mode the task may
|
||
|
|
stall and produce an empty log; set the mode to Bypass in the Config tab for
|
||
|
|
unattended runs.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{task && (
|
||
|
|
<p className="text-xs text-[var(--text-secondary)]">
|
||
|
|
The scheduler has no edit command, so saving re-creates this task under a new id and
|
||
|
|
removes <code className="font-mono">{task.id}</code>. Its previous run logs stay under
|
||
|
|
the old id.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{submitError && (
|
||
|
|
<p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap break-words">
|
||
|
|
{submitError}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</form>
|
||
|
|
</Modal>
|
||
|
|
);
|
||
|
|
}
|