Add web terminal, git credentials, OpenSCAD, review loops, automations
- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay) - Per-user git credentials and commit identity (credential store in the user's home) - OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card - Craft phase inferred from tool activity when the agent does not report it - Configurable review loop per mode (rounds, reviewer model, criteria) - Delete config and Markdown agents from the agent editor - Automations: cron-scheduled agents from Markdown job files, schedule tool, page - change_directory tool (only on the user's request) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a0d0e707f2
commit
df1924b0a1
41 files changed
+2809
-34
No files matched your search
@@ -0,0 +1,164 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string };
|
||||
}
|
||||
|
||||
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
|
||||
|
||||
/** Automations: agents started on a schedule from a Markdown job 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 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>
|
||||
{!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>{j.spec?.name ?? j.path.split("/").pop()}</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")} · {j.spec?.permission_mode} · 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="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 ghost"
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("codecraft.openFile", j.path);
|
||||
navigate("/files");
|
||||
}}
|
||||
>
|
||||
<Icon name="edit" /> {t("jobs.editFile")}
|
||||
</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 () => {
|
||||
await post("/jobs", { path: path.trim() });
|
||||
setPath("");
|
||||
})}>
|
||||
{t("jobs.registerBtn")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="job-example">{`---
|
||||
name: "Nightly dependency check"
|
||||
schedule: "0 3 * * *"
|
||||
agent: build
|
||||
permission_mode: auto
|
||||
timeout_minutes: 60
|
||||
---
|
||||
Check the project for outdated dependencies …`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user