Automation editor with graphical schedule and assistant; movable chat list groups

Automations
- Job files hold only the instructions; name, schedule, mode, model, approval
  mode and time limit are settings in the database (jobs.settings). Existing
  files are adopted on start-up (frontmatter into the database and removed from
  the file); plain Markdown files registered later get default settings and stay
  switched off until scheduled.
- New automation: name, project and a starting point (blank example structure or
  a template) create .codecraft/jobs/<name>.md and open the editor: settings with
  a graphical cron builder (next runs in server time via /jobs/schedule), the
  instructions in the Markdown editor and an assistant chat
  (POST /jobs/{id}/assistant) whose model edits the file; the editor reloads it
  after each turn and warns about conflicts with unsaved edits.
- The schedule tool writes prompt-only files with settings.

Chat list
- Folders and projects keep a user-defined order and can be pinned (drag and
  drop, or the group's menu), stored per user via /account/sidebar. A new
  message moves the chat to the top of its group, no longer the project itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-10 10:05:15 +01:00
1 parent 872ebb94dd
commit 05c215f685
19 files changed
+1661 -356

No files matched your search

+298
View File
@@ -0,0 +1,298 @@
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<JobInfo>();
const [settings, setSettings] = useState<Settings>();
const [saved, setSaved] = useState<Settings>();
const [text, setText] = useState<string>();
const [savedText, setSavedText] = useState<string>();
const [mtime, setMtime] = useState<number>();
const [changedOnDisk, setChangedOnDisk] = useState(false);
const [saving, setSaving] = useState(false);
const [assistant, setAssistant] = useState<string>();
const [tab, setTab] = useState<Tab>("prompt");
const loadJob = useCallback(async () => {
const j = await get<JobInfo>(`/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<JobInfo>(`/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<JobInfo>(`/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 <div className="empty"><Spinner /></div>;
const up = (p: Partial<Settings>) => setSettings({ ...settings, ...p });
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<button className="small ghost" onClick={back} title={t("jobEditor.back")} aria-label={t("jobEditor.back")}>
<Icon name="arrow-left" />
</button>
<div className="grow">
<h1>{settings.name || t("nav.jobs")}</h1>
<div className="path mono">{job.path}</div>
</div>
<label className="switch" title={t(job.enabled ? "jobs.disable" : "jobs.enable")}>
<input type="checkbox" checked={job.enabled} onChange={(e) => void setEnabled(e.target.checked)} />
<span>{t(job.enabled ? "jobs.on" : "jobs.off")}</span>
</label>
<button className="small ghost" onClick={runNow} title={t("jobs.runNow")}>
<Icon name="play" /> <span className="hide-mobile">{t("jobs.runNow")}</span>
</button>
<button className="small primary" disabled={!dirty || saving} onClick={() => void save()}>
{saving ? <Spinner small /> : <Icon name="check" />} {t("common.save")}
</button>
</div>
<div className="job-tabs" role="tablist">
{(["prompt", "settings", "assistant"] as Tab[]).map((x) => (
<button key={x} role="tab" aria-selected={tab === x} className={tab === x ? "active" : ""} onClick={() => setTab(x)}>
{t(`jobEditor.tab.${x}` as const)}
{x === "prompt" && fileDirty && " •"}
{x === "settings" && settingsDirty && " •"}
</button>
))}
</div>
<div className={`job-editor tab-${tab}`}>
<div className="job-editor-main">
<section className="job-editor-settings panel">
<div className="job-editor-row">
<label className="field" style={{ flex: 2 }}>
<span>{t("jobs.tplName")}</span>
<input value={settings.name} onChange={(e) => up({ name: e.target.value })} />
</label>
<div className="job-editor-status">
<span>
{t("jobs.next")}: {job.enabled && job.next_run ? new Date(job.next_run).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–"}
</span>
<span>
{t("jobs.last")}: {job.last_run ? fmtRelative(job.last_run) : "–"}
{job.last_session_id && (
<button className="link-btn" style={{ marginLeft: 6 }} onClick={() => navigate(`/s/${job.last_session_id}`)}>
{t("jobs.openRun")}
</button>
)}
</span>
</div>
</div>
<div className="field">
<span>{t("jobs.schedule")}</span>
<CronBuilder value={settings.schedule} onChange={(schedule) => up({ schedule })} />
</div>
<div className="job-editor-row">
<label className="field">
<span>{t("jobs.tplMode")}</span>
<select value={settings.agent} onChange={(e) => up({ agent: e.target.value })}>
<option value="build">build</option>
<option value="plan">plan</option>
</select>
</label>
<label className="field">
<span>{t("jobs.permissionMode")}</span>
<select value={settings.permission_mode} title={t("jobs.permissionModeHint")} onChange={(e) => up({ permission_mode: e.target.value })}>
<option value="default">{t("permissionMode.default")}</option>
<option value="auto">{t("permissionMode.auto")}</option>
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
<option value="bypass">{t("permissionMode.bypass")}</option>
</select>
</label>
<label className="field">
<span>{t("composer.model")}</span>
<ModelSelect value={settings.model} onChange={(model) => up({ model })} emptyLabel={t("jobEditor.defaultModel")} />
</label>
<label className="field" style={{ maxWidth: 140 }}>
<span>{t("jobEditor.timeout")}</span>
<input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} />
</label>
</div>
{settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>}
</section>
<section className="job-editor-prompt">
<div className="job-editor-prompt-head">
<b>{t("jobEditor.instructions")}</b>
<span className="hint">{t("jobEditor.instructionsHint")}</span>
</div>
{changedOnDisk && (
<div className="notice">
{t("jobEditor.changedOnDisk")}{" "}
<button className="small" onClick={() => void loadFile(job.path)}>
{t("jobEditor.reload")}
</button>
</div>
)}
{job.error && !job.error.includes("empty") && <div className="notice err">{job.error}</div>}
{text === undefined ? (
<div className="empty"><Spinner /></div>
) : (
<Suspense fallback={<div className="empty"><Spinner /></div>}>
<CodeEditor value={text} filename={job.path} onChange={setText} onSave={() => void save()} />
</Suspense>
)}
</section>
</div>
<aside className="job-editor-chat">
{assistant ? (
<ChatView key={assistant} sessionId={assistant} onMenu={onMenu} />
) : (
<div className="job-assistant-start">
<Icon name="sparkle" />
<p>{t("jobEditor.assistantIntro")}</p>
<button className="primary" onClick={startAssistant}>
{t("jobEditor.assistantStart")}
</button>
</div>
)}
</aside>
</div>
</>
);
}