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

+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useState } from "react";
import { get } from "../api";
import { getLang, useT } from "../i18n";
/**
* Graphical editor for a 5-field cron expression (minute hour day month weekday,
* server time zone): every N minutes, hourly, daily, on weekdays, monthly – or a
* custom expression. Shows the next runs as computed by the server.
*/
type Mode = "minutes" | "hourly" | "daily" | "weekly" | "monthly" | "custom";
interface State {
mode: Mode;
every: number;
minute: number;
time: string; // HH:MM
days: number[]; // cron weekdays, 0 = Sunday
dom: number;
custom: string;
}
const MINUTE_STEPS = [5, 10, 15, 20, 30];
/** Monday first; cron numbers (0 = Sunday). */
const WEEK = [1, 2, 3, 4, 5, 6, 0];
const pad = (n: number) => String(n).padStart(2, "0");
const hhmm = (h: string, m: string) => `${pad(Number(h))}:${pad(Number(m))}`;
function expandDays(field: string): number[] | null {
const out = new Set<number>();
for (const part of field.split(",")) {
const m = part.match(/^(\d)(?:-(\d))?$/);
if (!m) return null;
const a = Number(m[1]);
const b = m[2] ? Number(m[2]) : a;
if (a > 7 || b > 7 || b < a) return null;
for (let d = a; d <= b; d++) out.add(d % 7);
}
return [...out];
}
export function parseCron(expr: string): State {
const base: State = { mode: "custom", every: 15, minute: 0, time: "03:00", days: [1, 2, 3, 4, 5], dom: 1, custom: expr.trim() };
const f = expr.trim().split(/\s+/);
if (f.length !== 5) return base;
const [mi, h, dom, mon, dow] = f;
const num = (s: string, max: number) => /^\d+$/.test(s) && Number(s) <= max;
if (mon !== "*") return base;
let m;
if ((m = mi.match(/^\*\/(\d+)$/)) && h === "*" && dom === "*" && dow === "*" && MINUTE_STEPS.includes(Number(m[1]))) return { ...base, mode: "minutes", every: Number(m[1]) };
if (num(mi, 59) && h === "*" && dom === "*" && dow === "*") return { ...base, mode: "hourly", minute: Number(mi) };
if (!num(mi, 59) || !num(h, 23)) return base;
const time = hhmm(h, mi);
if (dom === "*" && dow === "*") return { ...base, mode: "daily", time };
if (dom === "*") {
const days = expandDays(dow);
return days?.length ? { ...base, mode: "weekly", time, days } : base;
}
if (num(dom, 31) && Number(dom) >= 1 && dow === "*") return { ...base, mode: "monthly", time, dom: Number(dom) };
return base;
}
export function buildCron(s: State): string {
const [h, m] = s.time.split(":").map(Number);
switch (s.mode) {
case "minutes":
return `*/${s.every} * * * *`;
case "hourly":
return `${s.minute} * * * *`;
case "daily":
return `${m} ${h} * * *`;
case "weekly": {
const days = [...s.days].sort((a, b) => a - b);
return `${m} ${h} * * ${days.length ? days.join(",") : "*"}`;
}
case "monthly":
return `${m} ${h} ${s.dom} * *`;
default:
return s.custom.trim();
}
}
const dayName = (d: number, style: "short" | "long") => {
// 2023-01-01 was a Sunday.
return new Date(2023, 0, 1 + d).toLocaleDateString(getLang(), { weekday: style });
};
/** "2026-11-11T06:30" (server wall time) for display, without time zone conversion. */
const fmtWall = (s: string) => {
const [d, hm] = s.split("T");
const [y, mo, da] = d.split("-").map(Number);
const date = new Date(y, mo - 1, da).toLocaleDateString(getLang(), { weekday: "short", day: "numeric", month: "short" });
return `${date}, ${hm}`;
};
export function CronBuilder({ value, onChange }: { value: string; onChange: (cron: string) => void }) {
const t = useT();
const [s, setS] = useState<State>(() => parseCron(value));
const [preview, setPreview] = useState<{ ok: boolean; next_local?: string[]; error?: string; tz?: string }>();
// A value from outside (another automation, reset) that the builder did not produce.
useEffect(() => {
if (value.trim() !== buildCron(s)) setS(parseCron(value));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useEffect(() => {
const expr = value.trim();
if (!expr) return setPreview(undefined);
let alive = true;
const h = setTimeout(() => {
get<typeof preview>(`/jobs/schedule?expr=${encodeURIComponent(expr)}`)
.then((p) => alive && setPreview(p))
.catch(() => alive && setPreview(undefined));
}, 250);
return () => {
alive = false;
clearTimeout(h);
};
}, [value]);
const set = (patch: Partial<State>) => {
const next = { ...s, ...patch };
// Switching to "custom" starts from the current expression.
if (patch.mode === "custom" && s.mode !== "custom") next.custom = buildCron(s);
setS(next);
onChange(buildCron(next));
};
const modes: Mode[] = ["minutes", "hourly", "daily", "weekly", "monthly", "custom"];
return (
<div className="cron-builder">
<div className="cron-modes" role="radiogroup" aria-label={t("cron.label")}>
{modes.map((m) => (
<button key={m} type="button" role="radio" aria-checked={s.mode === m} className={`chip-btn ${s.mode === m ? "active" : ""}`} onClick={() => set({ mode: m })}>
{t(`cron.mode.${m}` as const)}
</button>
))}
</div>
<div className="cron-fields">
{s.mode === "minutes" && (
<label>
{t("cron.every")}
<select value={s.every} onChange={(e) => set({ every: Number(e.target.value) })}>
{MINUTE_STEPS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
{t("cron.minutes")}
</label>
)}
{s.mode === "hourly" && (
<label>
{t("cron.atMinute")}
<input type="number" min={0} max={59} value={s.minute} onChange={(e) => set({ minute: Math.min(59, Math.max(0, Number(e.target.value) || 0)) })} />
</label>
)}
{s.mode === "weekly" && (
<div className="cron-days" role="group" aria-label={t("cron.days")}>
{WEEK.map((d) => (
<button
key={d}
type="button"
aria-pressed={s.days.includes(d)}
className={`chip-btn ${s.days.includes(d) ? "active" : ""}`}
title={dayName(d, "long")}
onClick={() => set({ days: s.days.includes(d) ? s.days.filter((x) => x !== d) : [...s.days, d] })}
>
{dayName(d, "short")}
</button>
))}
<button type="button" className="link-btn" onClick={() => set({ days: [1, 2, 3, 4, 5] })}>
{t("cron.workdays")}
</button>
</div>
)}
{s.mode === "monthly" && (
<label>
{t("cron.onDay")}
<select value={s.dom} onChange={(e) => set({ dom: Number(e.target.value) })}>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={d}>
{d}.
</option>
))}
</select>
</label>
)}
{(s.mode === "daily" || s.mode === "weekly" || s.mode === "monthly") && (
<label>
{t("cron.at")}
<input type="time" value={s.time} onChange={(e) => e.target.value && set({ time: e.target.value })} />
</label>
)}
{s.mode === "custom" && (
<label className="cron-custom">
<input className="mono" value={s.custom} placeholder="0 3 * * *" spellCheck={false} onChange={(e) => set({ custom: e.target.value })} />
<small className="hint">{t("cron.customHint")}</small>
</label>
)}
</div>
<div className="cron-preview">
<code>{value || "–"}</code>
{preview?.ok === false && <span className="err">{preview.error}</span>}
{preview?.ok && preview.next_local && preview.next_local.length > 0 && (
<span>
{t("cron.nextRuns")} {preview.next_local.slice(0, 3).map(fmtWall).join(" · ")}
</span>
)}
{preview?.tz && s.mode !== "minutes" && <span className="hint">{t("cron.serverTz", { tz: preview.tz })}</span>}
{s.mode === "monthly" && s.dom > 28 && <span className="hint">{t("cron.shortMonths")}</span>}
</div>
</div>
);
}