hermes-agent/web/src/pages/CronPage.tsx
Teknium cb29e8a82e refactor(cron): rebrand Cron Recipes -> Automation Blueprints
Product rename across every surface: module/file names (blueprint_catalog,
tools/blueprints, blueprint_cmd), slash command /cron-recipe -> /blueprint
(alias /bp), dashboard API /api/cron/blueprints, desktop deep-link
hermes://blueprint/<key>, docs catalog page + extract script, and the
skill frontmatter block metadata.hermes.blueprint. No behavior change.
2026-06-11 10:49:47 -07:00

917 lines
31 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Clock, Pause, Pencil, Play, Trash2, X, Zap } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { CronJob, CronDeliveryTarget, ProfileInfo, SkillInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import {
DEFAULT_SCHEDULE_STATE,
ScheduleBuilder,
} from "@/components/ScheduleBuilder";
import {
buildScheduleString,
describeSchedule,
englishOrdinal,
type ScheduleBuilderState,
type ScheduleDescribeStrings,
} from "@/lib/schedule";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
import { Segmented } from "@nous-research/ui/ui/components/segmented";
import { AutomationBlueprints } from "@/components/AutomationBlueprints";
import { cn, themedBody } from "@/lib/utils";
function formatTime(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleString();
}
function asText(value: unknown): string {
return typeof value === "string" ? value : "";
}
function truncateText(value: string, maxLength: number): string {
return value.length > maxLength
? value.slice(0, maxLength) + "..."
: value;
}
function getJobPrompt(job: CronJob): string {
return asText(job.prompt);
}
/** Compact multi-select for attaching skills to a cron job.
*
* A checkbox list (native inputs — the `onValueChange` rule is Select-only)
* capped to a scrollable box. Skills already on the job but missing from the
* available list (e.g. removed from disk, or the job was created via CLI in
* another profile) are still rendered so saving doesn't silently drop them.
*/
function SkillsPicker({
id,
available,
selected,
onChange,
emptyLabel,
}: {
id: string;
available: SkillInfo[];
selected: string[];
onChange: (skills: string[]) => void;
emptyLabel: string;
}) {
const names = available.map((s) => s.name);
const orphaned = selected.filter((s) => !names.includes(s));
const all = [...orphaned.map((name) => ({ name, description: "" })), ...available];
if (all.length === 0) {
return <p className="text-xs text-muted-foreground">{emptyLabel}</p>;
}
const toggle = (name: string, checked: boolean) => {
if (checked) onChange([...selected, name]);
else onChange(selected.filter((s) => s !== name));
};
return (
<div
id={id}
className="max-h-36 overflow-y-auto border border-border bg-background/40 p-1"
>
{all.map((skill) => (
<label
key={skill.name}
className="flex cursor-pointer items-center gap-2 px-2 py-1 text-xs hover:bg-muted/40"
title={skill.description || undefined}
>
<input
type="checkbox"
className="accent-foreground"
checked={selected.includes(skill.name)}
onChange={(e) => toggle(skill.name, e.target.checked)}
/>
<span className="font-mono-ui truncate">{skill.name}</span>
</label>
))}
</div>
);
}
function getJobName(job: CronJob): string {
return asText(job.name).trim();
}
function getJobTitle(job: CronJob): string {
const name = getJobName(job);
if (name) return name;
const prompt = getJobPrompt(job);
if (prompt) return truncateText(prompt, 60);
const script = asText(job.script);
if (script) return truncateText(script, 60);
return job.id || "Cron job";
}
function getJobScheduleDisplay(
job: CronJob,
strings: ScheduleDescribeStrings,
): string {
// Prefer a structured render so cron expressions like
// ``30 14 * * 1,3,5`` surface as "Weekly on Mon, Wed, Fri at 14:30"
// in the list instead of the raw five-field gibberish. Falls back
// through the existing chain (``schedule_display`` from the backend,
// then the structured ``display`` field, then the raw ``expr``) so
// legacy job rows still render *something* meaningful.
return describeSchedule(
job.schedule,
asText(job.schedule_display) || asText(job.schedule?.display),
strings,
);
}
function getJobState(job: CronJob): string {
return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled");
}
function getJobProfile(job: CronJob): string {
return asText(job.profile) || asText(job.profile_name) || "default";
}
function getJobKey(job: CronJob): string {
return `${getJobProfile(job)}:${job.id}`;
}
function splitJobKey(key: string): { profile: string; id: string } {
const idx = key.indexOf(":");
if (idx === -1) return { profile: "default", id: key };
return { profile: key.slice(0, idx) || "default", id: key.slice(idx + 1) };
}
function profileLabel(profile: string): string {
return profile === "default" ? "default" : profile;
}
const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
enabled: "success",
scheduled: "success",
paused: "warning",
error: "destructive",
completed: "destructive",
};
export default function CronPage() {
const [jobs, setJobs] = useState<CronJob[]>([]);
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [selectedProfile, setSelectedProfile] = useState("all");
const [view, setView] = useState<"jobs" | "blueprints">("jobs");
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t, locale } = useI18n();
const { setEnd } = usePageHeader();
// Translation surface for the human-readable schedule describer.
// English ordinals are a special case ("1st", "2nd", "23rd"); every
// other locale falls back to the plain numeric form, which avoids
// shipping incorrect grammar (e.g. naive "1th"/"2th" suffixes that
// don't exist in most languages).
//
// Built inline (not memoized) — the cron page renders a small job
// list, this is single-digit microseconds, and a useMemo here would
// just add boilerplate.
const scheduleDescribeStrings: ScheduleDescribeStrings = {
...t.cron.scheduleDescribe,
weekdaysShort: t.cron.scheduleModes.weekdaysShort,
ordinal: locale === "en" ? englishOrdinal : (n: number) => String(n),
};
// New job modal state
const [createModalOpen, setCreateModalOpen] = useState(false);
const [prompt, setPrompt] = useState("");
// The schedule is now constructed via the ScheduleBuilder; we keep
// the full builder state so flipping between modes during edit
// doesn't erase the user's intermediate inputs. The actual string
// sent to the backend is derived via ``buildScheduleString`` at
// submit time.
const [scheduleState, setScheduleState] = useState<ScheduleBuilderState>(
DEFAULT_SCHEDULE_STATE,
);
const [name, setName] = useState("");
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
const createModalRef = useModalBehavior({
open: createModalOpen,
onClose: closeCreateModal,
});
const [deliver, setDeliver] = useState("local");
const [jobSkills, setJobSkills] = useState<string[]>([]);
const [deliveryTargets, setDeliveryTargets] = useState<CronDeliveryTarget[]>([
{ id: "local", name: "Local", home_target_set: true, home_env_var: null },
]);
const [creating, setCreating] = useState(false);
const createProfile = selectedProfile === "all" ? "default" : selectedProfile;
// Edit job modal state
const [editJob, setEditJob] = useState<CronJob | null>(null);
const [editPrompt, setEditPrompt] = useState("");
const [editSchedule, setEditSchedule] = useState("");
const [editName, setEditName] = useState("");
const [editDeliver, setEditDeliver] = useState("local");
const [editSkills, setEditSkills] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const closeEditModal = useCallback(() => setEditJob(null), []);
const editModalRef = useModalBehavior({
open: editJob !== null,
onClose: closeEditModal,
});
// Skills installed in the profile a job will run under, for the
// attach-skill selector (parity with `hermes cron edit --add-skill`).
// Keyed on the create-modal profile; the edit modal reuses the list —
// a job's current skills are always shown even if not in it.
const [availableSkills, setAvailableSkills] = useState<SkillInfo[]>([]);
const openEditModal = useCallback((job: CronJob) => {
setEditJob(job);
setEditPrompt(getJobPrompt(job));
setEditSchedule(
asText(job.schedule?.expr) || asText(job.schedule_display) || "",
);
setEditName(getJobName(job));
setEditDeliver(asText(job.deliver) || "local");
setEditSkills(Array.isArray(job.skills) ? job.skills.filter(Boolean) : []);
}, []);
const loadJobs = useCallback(() => {
api
.getCronJobs(selectedProfile)
.then(setJobs)
.catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false));
}, [selectedProfile, showToast, t.common.loading]);
useEffect(() => {
api
.getProfiles()
.then((res) => setProfiles(res.profiles))
.catch(() => setProfiles([]));
}, []);
useEffect(() => {
api
.getCronDeliveryTargets()
.then((res) => setDeliveryTargets(res.targets))
.catch(() =>
// Fall back to local-only so the modal still works if the endpoint fails.
setDeliveryTargets([
{ id: "local", name: "Local", home_target_set: true, home_env_var: null },
]),
);
}, []);
useEffect(() => {
loadJobs();
}, [loadJobs]);
// Load installed skills for the profile new jobs will be created under.
// "" / "default" maps to the dashboard's own profile via the optional
// ?profile= scoping on /api/skills.
useEffect(() => {
let cancelled = false;
api
.getSkills(createProfile === "default" ? undefined : createProfile)
.then((s) => {
if (!cancelled)
setAvailableSkills(
[...s].sort((a, b) => a.name.localeCompare(b.name)),
);
})
.catch(() => !cancelled && setAvailableSkills([]));
return () => {
cancelled = true;
};
}, [createProfile]);
const scheduleString = buildScheduleString(scheduleState);
// Label for a delivery option. Configured platforms missing their cron home
// channel are still offered (option B), annotated so the user knows what to
// fix rather than wondering why delivery silently no-ops.
const deliverLabel = useCallback(
(target: CronDeliveryTarget): string => {
const base = target.id === "local" ? t.cron.delivery.local : target.name;
if (target.id !== "local" && !target.home_target_set) {
const hint = t.cron.delivery.needsHomeChannel ?? "set a home channel first";
return `${base}${hint}`;
}
return base;
},
[t.cron.delivery],
);
const renderDeliverOptions = useCallback(
() =>
deliveryTargets.map((target) => (
<SelectOption key={target.id} value={target.id}>
{deliverLabel(target)}
</SelectOption>
)),
[deliveryTargets, deliverLabel],
);
// The edit modal must always show the job's current target, even if that
// platform is no longer configured (e.g. job created via CLI, or the
// gateway was later removed) — otherwise the value would silently vanish
// from the dropdown and saving would drop it.
const renderEditDeliverOptions = useCallback(
(current: string) => {
const known = new Set(deliveryTargets.map((target) => target.id));
const options = deliveryTargets.map((target) => (
<SelectOption key={target.id} value={target.id}>
{deliverLabel(target)}
</SelectOption>
));
if (current && !known.has(current)) {
options.push(
<SelectOption key={current} value={current}>
{current}
</SelectOption>,
);
}
return options;
},
[deliveryTargets, deliverLabel],
);
const onlyLocalAvailable =
deliveryTargets.filter((target) => target.id !== "local").length === 0;
const handleCreate = async () => {
if (!prompt.trim() || !scheduleString) {
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
return;
}
setCreating(true);
try {
await api.createCronJob(
{
prompt: prompt.trim(),
schedule: scheduleString,
name: name.trim() || undefined,
deliver,
skills: jobSkills.length > 0 ? jobSkills : undefined,
},
createProfile,
);
showToast(t.common.create + " ✓", "success");
setPrompt("");
setScheduleState(DEFAULT_SCHEDULE_STATE);
setName("");
setDeliver("local");
setJobSkills([]);
setCreateModalOpen(false);
loadJobs();
} catch (e) {
showToast(`${t.config.failedToSave}: ${e}`, "error");
} finally {
setCreating(false);
}
};
const handleEdit = async () => {
if (!editJob) return;
if (!editPrompt.trim() || !editSchedule.trim()) {
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
return;
}
setSaving(true);
try {
await api.updateCronJob(
editJob.id,
{
prompt: editPrompt.trim(),
schedule: editSchedule.trim(),
name: editName.trim(),
deliver: editDeliver,
skills: editSkills,
},
getJobProfile(editJob),
);
showToast("Saved changes ✓", "success");
setEditJob(null);
loadJobs();
} catch (e) {
showToast(`${t.config.failedToSave}: ${e}`, "error");
} finally {
setSaving(false);
}
};
const handlePauseResume = async (job: CronJob) => {
try {
const isPaused = getJobState(job) === "paused";
const profile = getJobProfile(job);
if (isPaused) {
await api.resumeCronJob(job.id, profile);
showToast(
`${t.cron.resume}: "${truncateText(getJobTitle(job), 30)}"`,
"success",
);
} else {
await api.pauseCronJob(job.id, profile);
showToast(
`${t.cron.pause}: "${truncateText(getJobTitle(job), 30)}"`,
"success",
);
}
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
}
};
const handleTrigger = async (job: CronJob) => {
try {
await api.triggerCronJob(job.id, getJobProfile(job));
showToast(
`${t.cron.triggerNow}: "${truncateText(getJobTitle(job), 30)}"`,
"success",
);
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
}
};
const jobDelete = useConfirmDelete({
onDelete: useCallback(
async (key: string) => {
const { profile, id } = splitJobKey(key);
const job = jobs.find((j) => getJobKey(j) === key);
try {
await api.deleteCronJob(id, profile);
showToast(
`${t.common.delete}: "${job ? truncateText(getJobTitle(job), 30) : id}"`,
"success",
);
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
throw e;
}
},
[jobs, loadJobs, showToast, t.common.delete, t.status.error],
),
});
// Put "Create" button in page header
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
{t.common.create}
</Button>,
);
return () => {
setEnd(null);
};
}, [setEnd, t.common.create, loading]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const pendingJob = jobDelete.pendingId
? jobs.find((j) => getJobKey(j) === jobDelete.pendingId)
: null;
return (
<div className="flex flex-col gap-6">
<PluginSlot name="cron:top" />
<Toast toast={toast} />
<Segmented
value={view}
onChange={(v) => setView(v as "jobs" | "blueprints")}
options={[
{ value: "jobs", label: "Jobs" },
{ value: "blueprints", label: "Blueprints" },
]}
/>
{view === "blueprints" && (
<AutomationBlueprints
profile={selectedProfile === "all" ? "default" : selectedProfile}
onCreated={loadJobs}
/>
)}
<DeleteConfirmDialog
open={jobDelete.isOpen}
onCancel={jobDelete.cancel}
onConfirm={jobDelete.confirm}
title={t.cron.confirmDeleteTitle}
description={
pendingJob
? `"${truncateText(getJobTitle(pendingJob), 40)}" — ${
t.cron.confirmDeleteMessage
}`
: t.cron.confirmDeleteMessage
}
loading={jobDelete.isDeleting}
/>
{/* Create job modal */}
{createModalOpen && (
<div
ref={createModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && setCreateModalOpen(false)}
role="dialog"
aria-modal="true"
aria-labelledby="create-cron-title"
>
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
onClick={() => setCreateModalOpen(false)}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="create-cron-title"
className="font-mondwest text-display text-base tracking-wider"
>
{t.cron.newJob}
</h2>
</header>
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-profile">Profile</Label>
<Select
id="cron-profile"
value={createProfile}
onValueChange={(v) => setSelectedProfile(v)}
>
{profiles.map((profile) => (
<SelectOption key={profile.name} value={profile.name}>
{profileLabel(profile.name)}
</SelectOption>
))}
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
<Input
id="cron-name"
autoFocus
placeholder={t.cron.namePlaceholder}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
<textarea
id="cron-prompt"
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
placeholder={t.cron.promptPlaceholder}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
<ScheduleBuilder
value={scheduleState}
onChange={setScheduleState}
/>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="cron-deliver"
value={deliver}
onValueChange={(v) => setDeliver(v)}
>
{renderDeliverOptions()}
</Select>
{onlyLocalAvailable && (
<p className="text-xs text-muted-foreground">
{t.cron.delivery.noneConfigured ??
"No messaging platforms configured. Set one up under Channels to deliver reports."}
</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="cron-skills">Skills (optional)</Label>
<SkillsPicker
id="cron-skills"
available={availableSkills}
selected={jobSkills}
onChange={setJobSkills}
emptyLabel="No skills installed for this profile."
/>
<p className="text-xs text-muted-foreground">
Selected skills are loaded before the prompt runs the cron
sets when, the skill sets how.
</p>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleCreate}
disabled={creating}
prefix={creating ? <Spinner /> : undefined}
>
{creating ? t.common.creating : t.common.create}
</Button>
</div>
</div>
</div>
</div>
)}
{/* Edit job modal */}
{editJob && (
<div
ref={editModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && setEditJob(null)}
role="dialog"
aria-modal="true"
aria-labelledby="edit-cron-title"
>
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
onClick={() => setEditJob(null)}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="edit-cron-title"
className="font-mondwest text-display text-base tracking-wider"
>
Edit job
</h2>
</header>
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="edit-cron-name">{t.cron.nameOptional}</Label>
<Input
id="edit-cron-name"
autoFocus
placeholder={t.cron.namePlaceholder}
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-cron-prompt">{t.cron.prompt}</Label>
<textarea
id="edit-cron-prompt"
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
placeholder={t.cron.promptPlaceholder}
value={editPrompt}
onChange={(e) => setEditPrompt(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="edit-cron-schedule">{t.cron.schedule}</Label>
<Input
id="edit-cron-schedule"
placeholder={t.cron.schedulePlaceholder}
value={editSchedule}
onChange={(e) => setEditSchedule(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="edit-cron-deliver"
value={editDeliver}
onValueChange={(v) => setEditDeliver(v)}
>
{renderEditDeliverOptions(editDeliver)}
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-cron-skills">Skills</Label>
<SkillsPicker
id="edit-cron-skills"
available={availableSkills}
selected={editSkills}
onChange={setEditSkills}
emptyLabel="No skills installed for this profile."
/>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleEdit}
disabled={saving}
prefix={saving ? <Spinner /> : undefined}
>
{saving ? t.common.loading : "Save changes"}
</Button>
</div>
</div>
</div>
</div>
)}
{view === "jobs" && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Clock className="h-4 w-4" />
{t.cron.scheduledJobs} ({jobs.length})
</H2>
<div className="grid gap-1 min-w-[220px]">
<Label htmlFor="cron-profile-filter">Profile</Label>
<Select
id="cron-profile-filter"
value={selectedProfile}
onValueChange={(v) => setSelectedProfile(v)}
>
<SelectOption value="all">All profiles</SelectOption>
{profiles.map((profile) => (
<SelectOption key={profile.name} value={profile.name}>
{profileLabel(profile.name)}
</SelectOption>
))}
</Select>
</div>
</div>
{jobs.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.cron.noJobs}
</CardContent>
</Card>
)}
{jobs.map((job) => {
const state = getJobState(job);
const promptText = getJobPrompt(job);
const title = getJobTitle(job);
const hasName = Boolean(getJobName(job));
const deliver = asText(job.deliver);
const profile = getJobProfile(job);
const jobKey = getJobKey(job);
return (
<Card key={jobKey}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{title}
</span>
<Badge tone={STATUS_TONE[state] ?? "secondary"}>
{state}
</Badge>
<Badge tone="outline">{profileLabel(profile)}</Badge>
{deliver && deliver !== "local" && (
<Badge tone="outline">{deliver}</Badge>
)}
{Array.isArray(job.skills) && job.skills.length > 0 && (
<Badge tone="outline" title={job.skills.join(", ")}>
{job.skills.length === 1
? job.skills[0]
: `${job.skills.length} skills`}
</Badge>
)}
</div>
{hasName && promptText && (
<p className="text-xs text-muted-foreground truncate mb-1">
{truncateText(promptText, 100)}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono-ui">
{getJobScheduleDisplay(job, scheduleDescribeStrings)}
</span>
<span>
{t.cron.last}: {formatTime(job.last_run_at)}
</span>
<span>
{t.cron.next}: {formatTime(job.next_run_at)}
</span>
</div>
{job.last_error && (
<p className="text-xs text-destructive mt-1">
{job.last_error}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="icon"
title={state === "paused" ? t.cron.resume : t.cron.pause}
aria-label={
state === "paused" ? t.cron.resume : t.cron.pause
}
onClick={() => handlePauseResume(job)}
className={
state === "paused" ? "text-success" : "text-warning"
}
>
{state === "paused" ? <Play /> : <Pause />}
</Button>
<Button
ghost
size="icon"
title={t.cron.triggerNow}
aria-label={t.cron.triggerNow}
onClick={() => handleTrigger(job)}
>
<Zap />
</Button>
<Button
ghost
size="icon"
title="Edit job"
aria-label="Edit job"
onClick={() => openEditModal(job)}
>
<Pencil />
</Button>
<Button
ghost
destructive
size="icon"
title={t.common.delete}
aria-label={t.common.delete}
onClick={() => jobDelete.requestDelete(jobKey)}
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
<PluginSlot name="cron:bottom" />
</div>
);
}