import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { ApiError, get, patch, post, put } from "../api"; import { confirmDialog, toast } from "../dialogs"; import { fmtRelative, useT } from "../i18n"; import { navigate } from "../router"; import { useStore } from "../store"; import { Spinner } from "./Activity"; import { ChatView } from "./ChatView"; import { CronBuilder } from "./CronBuilder"; import { Icon } from "./Icon"; import type { JobInfo } from "./Jobs"; import { ModelSelect } from "./ModelSelect"; const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor }))); interface Settings { name: string; schedule: string; agent: string; model: string; permission_mode: string; timeout_minutes: number; } const settingsOf = (j: JobInfo): Settings => ({ name: j.spec?.name ?? "", schedule: j.spec?.schedule ?? "0 3 * * *", agent: j.spec?.agent ?? "build", model: j.spec?.model ?? "", permission_mode: j.spec?.permission_mode ?? "auto", timeout_minutes: j.spec?.timeout_minutes ?? 60, }); type Tab = "prompt" | "settings" | "assistant"; /** * One automation: its instructions (Markdown file) in an editor, schedule and settings, * and a chat with a model that helps to write the instructions (it edits the file). */ export function JobEditor({ id, onMenu }: { id: string; onMenu: () => void }) { const t = useT(); const [job, setJob] = useState(); const [settings, setSettings] = useState(); const [saved, setSaved] = useState(); const [text, setText] = useState(); const [savedText, setSavedText] = useState(); const [mtime, setMtime] = useState(); const [changedOnDisk, setChangedOnDisk] = useState(false); const [saving, setSaving] = useState(false); const [assistant, setAssistant] = useState(); const [tab, setTab] = useState("prompt"); const loadJob = useCallback(async () => { const j = await get(`/jobs/${id}`); setJob(j); const s = settingsOf(j); setSettings((cur) => (cur && saved && JSON.stringify(cur) !== JSON.stringify(saved) ? cur : s)); setSaved(s); setAssistant(j.spec?.assistant_session ?? undefined); return j; // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); const loadFile = useCallback(async (path: string) => { const f = await get<{ content?: string; mtime?: number }>(`/files/read?path=${encodeURIComponent(path)}`); setText(f.content ?? ""); setSavedText(f.content ?? ""); setMtime(f.mtime); setChangedOnDisk(false); }, []); useEffect(() => { loadJob() .then((j) => loadFile(j.path)) .catch((e) => toast(e.message)); }, [loadJob, loadFile]); const fileDirty = text !== undefined && text !== savedText; const settingsDirty = !!settings && !!saved && JSON.stringify(settings) !== JSON.stringify(saved); const dirty = fileDirty || settingsDirty; // The assistant edits the file: reload it when a turn ends (unless there are unsaved edits). const busy = useStore((s) => (assistant ? !!s.busy[assistant] : false)); const wasBusy = useRef(false); const dirtyRef = useRef(fileDirty); dirtyRef.current = fileDirty; useEffect(() => { if (wasBusy.current && !busy && job) { get<{ mtime?: number }>(`/files/stat?path=${encodeURIComponent(job.path)}`) .then((st) => { if (st.mtime && mtime && st.mtime <= mtime) return; if (dirtyRef.current) setChangedOnDisk(true); else void loadFile(job.path); }) .catch(() => undefined); } wasBusy.current = busy; }, [busy, job, mtime, loadFile]); // Unsaved changes: ask before leaving the page. useEffect(() => { if (!dirty) return; const warn = (e: BeforeUnloadEvent) => e.preventDefault(); window.addEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn); }, [dirty]); const save = async (force = false) => { if (!job || !settings) return; setSaving(true); try { if (settingsDirty) { const j = await patch(`/jobs/${id}`, settings); setJob(j); const s = settingsOf(j); setSettings(s); setSaved(s); } if (fileDirty) { const r = await put<{ mtime?: number }>("/files/write", { path: job.path, content: text, expected_mtime: mtime, force }); setSavedText(text); setMtime(r.mtime); setChangedOnDisk(false); void loadJob(); } toast(t("jobEditor.saved"), "success"); } catch (e: any) { if (e instanceof ApiError && e.status === 409) { if (await confirmDialog({ message: t("jobEditor.conflict"), confirmLabel: t("jobEditor.overwrite"), danger: true })) return save(true); } else toast(e.message); } finally { setSaving(false); } }; const setEnabled = async (enabled: boolean) => { try { setJob(await patch(`/jobs/${id}`, { enabled })); } catch (e: any) { toast(e.message); } }; const runNow = async () => { if (dirty && !(await confirmDialog({ message: t("jobEditor.runUnsaved") }))) return; try { const r = await post<{ session_id: string }>(`/jobs/${id}/run`, {}); navigate(`/s/${r.session_id}`); } catch (e: any) { toast(e.message); } }; const startAssistant = async () => { try { const r = await post<{ session_id: string }>(`/jobs/${id}/assistant`, {}); setAssistant(r.session_id); setTab("assistant"); } catch (e: any) { toast(e.message); } }; const back = async () => { if (dirty && !(await confirmDialog({ message: t("jobEditor.discard"), danger: true }))) return; navigate("/jobs"); }; if (!job || !settings) return
; const up = (p: Partial) => setSettings({ ...settings, ...p }); return ( <>

{settings.name || t("nav.jobs")}

{job.path}
{(["prompt", "settings", "assistant"] as Tab[]).map((x) => ( ))}
{t("jobs.next")}: {job.enabled && job.next_run ? new Date(job.next_run).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–"} {t("jobs.last")}: {job.last_run ? fmtRelative(job.last_run) : "–"} {job.last_session_id && ( )}
{t("jobs.schedule")} up({ schedule })} />
{settings.permission_mode !== "bypass" &&
{t("jobEditor.bypassHint")}
}
{t("jobEditor.instructions")} {t("jobEditor.instructionsHint")}
{changedOnDisk && (
{t("jobEditor.changedOnDisk")}{" "}
)} {job.error && !job.error.includes("empty") &&
{job.error}
} {text === undefined ? (
) : (
}> void save()} /> )}
); }