Files
codecraft/web/src/components/Jobs.tsx
T
Richard ZinkandClaude Opus 5.5 05c215f685 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>
2026-11-10 10:05:15 +01:00

204 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { del, get, patch, post } from "../api";
import { fmtRelative, useT } from "../i18n";
import { navigate } from "../router";
import { confirmDialog, toast } from "../dialogs";
import { Icon } from "./Icon";
import { Spinner } from "./Activity";
import { NewJobDialog } from "./NewJobDialog";
export interface JobInfo {
id: string;
path: string;
enabled: boolean;
last_run?: number | null;
last_session_id?: string | null;
last_status?: string | null;
next_run?: number | null;
error?: string;
webhook?: boolean;
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string; assistant_session?: string | null };
}
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
/** Automations: agents started on a schedule with the instructions from a Markdown file. */
export function Jobs({ onMenu }: { onMenu: () => void }) {
const t = useT();
const [jobs, setJobs] = useState<JobInfo[]>();
const [path, setPath] = useState("");
const [busy, setBusy] = useState<string>();
const [hook, setHook] = useState<{ id: string; url: string; secret: string }>();
const [templates, setTemplates] = useState(false);
const reload = () =>
get<JobInfo[]>("/jobs")
.then(setJobs)
.catch((e) => toast(e.message));
useEffect(() => {
void reload();
const h = setInterval(reload, 15000);
return () => clearInterval(h);
}, []);
const act = async (id: string, f: () => Promise<unknown>) => {
setBusy(id);
try {
await f();
await reload();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(undefined);
}
};
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("nav.jobs")}</h1>
</div>
</div>
<div className="page">
<div className="page-inner" style={{ maxWidth: 980 }}>
<div className="notice">{t("jobs.intro")}</div>
<div className="row" style={{ margin: "12px 0" }}>
<button className="primary" onClick={() => setTemplates(true)}>
<Icon name="plus" /> {t("jobs.new")}
</button>
</div>
{templates && <NewJobDialog onClose={() => setTemplates(false)} />}
{!jobs && <div className="empty"><Spinner /></div>}
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
{jobs?.map((j) => (
<div key={j.id} className={`panel job ${j.enabled ? "" : "off"}`}>
<div className="job-head">
<Icon name="clock" className="job-icon" />
<div style={{ flex: 1, minWidth: 0 }}>
<h3>
<button className="link-btn job-title" onClick={() => navigate(`/jobs/${j.id}`)}>
{j.spec?.name ?? j.path.split("/").pop()}
</button>
</h3>
<div className="hint mono">{j.path}</div>
</div>
<label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}>
<input type="checkbox" checked={j.enabled} onChange={(e) => act(j.id, () => patch(`/jobs/${j.id}`, { enabled: e.target.checked }))} />
<span>{t(j.enabled ? "jobs.on" : "jobs.off")}</span>
</label>
</div>
{j.error ? (
<div className="notice err">{j.error}</div>
) : (
<div className="job-grid">
<span>{t("jobs.schedule")}</span>
<code>{j.spec?.schedule}</code>
<span>{t("jobs.next")}</span>
<span>{j.enabled ? fmtDate(j.next_run) : "–"}</span>
<span>{t("jobs.last")}</span>
<span>
{j.last_run ? `${fmtRelative(j.last_run)} · ` : "–"}
{j.last_status && <span className={`chip ${j.last_status === "done" ? "ok" : j.last_status === "running" ? "" : "warn"}`}>{t(`jobs.status.${j.last_status.startsWith("error") ? "error" : j.last_status}` as any)}</span>}
{j.last_session_id && (
<button className="link-btn" style={{ marginLeft: 8 }} onClick={() => navigate(`/s/${j.last_session_id}`)}>
{t("jobs.openRun")}
</button>
)}
</span>
<span>{t("jobs.mode")}</span>
<span>
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {t(`permissionMode.${j.spec?.permission_mode ?? "auto"}` as any)} · max {j.spec?.timeout_minutes} min
</span>
</div>
)}
{j.last_status?.startsWith("error") && <div className="notice err">{j.last_status}</div>}
{j.spec && (
<details className="job-prompt">
<summary>{t("jobs.prompt")}</summary>
<pre>{j.spec.prompt}</pre>
</details>
)}
<div className="job-hook">
<span>
<Icon name="external" /> {t("jobs.webhook")}: {j.webhook ? t("jobs.on") : t("jobs.off")}
</span>
<button
className="small ghost"
onClick={() =>
act(j.id, async () => {
const r = await post<{ url?: string; secret?: string }>(`/jobs/${j.id}/webhook`, { enable: !j.webhook });
if (r.url && r.secret) setHook({ id: j.id, url: r.url, secret: r.secret });
})
}
>
{j.webhook ? t("jobs.webhookOff") : t("jobs.webhookOn")}
</button>
{j.webhook && (
<button
className="small ghost"
onClick={() =>
act(j.id, async () => {
const r = await post<{ url: string; secret: string }>(`/jobs/${j.id}/webhook`, { enable: true });
setHook({ id: j.id, url: r.url, secret: r.secret });
})
}
>
{t("jobs.webhookRenew")}
</button>
)}
</div>
{hook?.id === j.id && (
<div className="notice ok">
<div>{t("jobs.webhookShown")}</div>
<div className="mono" style={{ marginTop: 6, wordBreak: "break-all" }}>URL: {hook.url}</div>
<div className="mono" style={{ wordBreak: "break-all" }}>Secret: {hook.secret}</div>
<div className="hint" style={{ marginTop: 6 }}>{t("jobs.webhookHelp")}</div>
<pre className="job-example">{`curl -X POST ${hook.url} \\\n -H "X-CodeCraft-Secret: ${hook.secret}" \\\n -H "Content-Type: application/json" -d '{"event":"test"}'`}</pre>
</div>
)}
<div className="row" style={{ gap: 8, marginTop: 10 }}>
<button className="small" disabled={busy === j.id || !!j.error} onClick={() => act(j.id, async () => {
const r = await post<{ session_id: string }>(`/jobs/${j.id}/run`, {});
navigate(`/s/${r.session_id}`);
})}>
{busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")}
</button>
<button className="small" onClick={() => navigate(`/jobs/${j.id}`)}>
<Icon name="edit" /> {t("jobs.edit")}
</button>
<span className="grow" />
<button
className="small ghost danger"
onClick={async () => {
if (await confirmDialog({ message: t("jobs.deleteConfirm", { name: j.spec?.name ?? j.path }), danger: true })) void act(j.id, () => del(`/jobs/${j.id}`));
}}
>
<Icon name="trash" /> {t("jobs.remove")}
</button>
</div>
</div>
))}
<div className="panel">
<h3>{t("jobs.register")}</h3>
<div className="hint" style={{ marginBottom: 8 }}>{t("jobs.registerHint")}</div>
<div className="row" style={{ gap: 8 }}>
<input className="mono" style={{ flex: 1, minWidth: 0 }} value={path} placeholder="/workspace/project/.codecraft/jobs/nightly.md" onChange={(e) => setPath(e.target.value)} />
<button disabled={!path.trim()} onClick={() => act("new", async () => {
const j = await post<JobInfo>("/jobs", { path: path.trim() });
setPath("");
navigate(`/jobs/${j.id}`);
})}>
{t("jobs.registerBtn")}
</button>
</div>
</div>
</div>
</div>
</>
);
}