+ {wasFallback && (
+
+ This expression can't be edited visually. Switch to Custom to
+ keep it, or change anything below to overwrite it.
+
+ )}
+
+
+ Run
+ update({ frequency: e.target.value })}
+ className={inputClass}
+ >
+ every minute
+ hourly
+ daily
+ weekly
+ monthly
+
+
+
+ {state.frequency === "minute" && (
+
+ Every
+
+ update({ minuteInterval: parseInt(e.target.value, 10) })
+ }
+ className={inputClass}
+ >
+ {MINUTE_INTERVALS.map((n) => (
+
+ {n === 1 ? "1 minute" : `${n} minutes`}
+
+ ))}
+
+
+ )}
+
+ {state.frequency === "hour" && (
+
+ At minute
+
+ update({ hourMinuteOffset: parseInt(e.target.value, 10) })
+ }
+ className={inputClass}
+ >
+ {MINUTES.map((n) => (
+
+ {pad2(n)}
+
+ ))}
+
+ past every hour
+
+ )}
+
+ {(state.frequency === "day" ||
+ state.frequency === "week" ||
+ state.frequency === "month") && (
+
+ At
+ {
+ const [h, m] = e.target.value.split(":");
+ update({
+ hour: parseInt(h, 10) || 0,
+ minute: parseInt(m, 10) || 0,
+ });
+ }}
+ className={inputClass}
+ />
+
+ )}
+
+ {state.frequency === "week" && (
+
+
On
+
+ {WEEKDAYS.map((day) => {
+ const selected = state.weekdays.includes(day.value);
+ return (
+ {
+ const next = selected
+ ? state.weekdays.filter((d) => d !== day.value)
+ : [...state.weekdays, day.value];
+ update({ weekdays: next.length ? next : [day.value] });
+ }}
+ className={`px-3 py-1 text-xs rounded-full border transition-colors ${
+ selected
+ ? "bg-primary-button text-white border-primary-button"
+ : "bg-transparent text-theme-text-secondary border-white/10 hover:border-white/30"
+ }`}
+ >
+ {day.label}
+
+ );
+ })}
+
+
+ )}
+
+ {state.frequency === "month" && (
+
+ On day
+
+ update({ dayOfMonth: parseInt(e.target.value, 10) })
+ }
+ className={inputClass}
+ >
+ {DAYS_OF_MONTH.map((n) => (
+
+ {n}
+
+ ))}
+
+ of every month
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/ScheduledJobs/NewJobModal.jsx b/frontend/src/pages/GeneralSettings/ScheduledJobs/NewJobModal.jsx
new file mode 100644
index 00000000000..042a345ea35
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ScheduledJobs/NewJobModal.jsx
@@ -0,0 +1,242 @@
+import { useState, useEffect } from "react";
+import { X } from "@phosphor-icons/react";
+import ScheduledJobs from "@/models/scheduledJobs";
+import showToast from "@/utils/toast";
+import { safeJsonParse } from "@/utils/request";
+import { humanizeCron } from "./utils/cron";
+import CronBuilder from "./CronBuilder";
+
+export default function NewJobModal({ job = null, onClose, onSaved }) {
+ const isEditing = !!job;
+ const [form, setForm] = useState({
+ name: job?.name || "",
+ prompt: job?.prompt || "",
+ schedule: job?.schedule || "0 9 * * *",
+ scheduleMode: "builder",
+ selectedTools: job?.tools ? safeJsonParse(job.tools, []) : [],
+ });
+ const [availableTools, setAvailableTools] = useState([]);
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ ScheduledJobs.availableTools().then(({ tools }) => {
+ setAvailableTools(tools || []);
+ });
+ }, []);
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setForm((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleModeChange = (mode) => {
+ setForm((prev) => ({ ...prev, scheduleMode: mode }));
+ };
+
+ const toggleTool = (toolName) => {
+ setForm((prev) => ({
+ ...prev,
+ selectedTools: prev.selectedTools.includes(toolName)
+ ? prev.selectedTools.filter((t) => t !== toolName)
+ : [...prev.selectedTools, toolName],
+ }));
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!form.name.trim() || !form.prompt.trim() || !form.schedule.trim()) {
+ showToast("Please fill in all required fields", "error");
+ return;
+ }
+
+ setSaving(true);
+ const data = {
+ name: form.name.trim(),
+ prompt: form.prompt.trim(),
+ schedule: form.schedule.trim(),
+ tools: form.selectedTools,
+ };
+
+ const result = isEditing
+ ? await ScheduledJobs.update(job.id, data)
+ : await ScheduledJobs.create(data);
+
+ setSaving(false);
+
+ if (result.error) {
+ showToast(result.error, "error");
+ return;
+ }
+
+ showToast(isEditing ? "Job updated" : "Job created", "success");
+ onSaved();
+ };
+
+ return (
+
+
+
+
+
+ {job.name}
+
+
+
+
+ {humanizeCron(job.schedule)}
+
+
+ {job.latestRun ? (
+
+ ) : (
+ Never run
+ )}
+
+
+ {job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : "—"}
+
+
+ {job.enabled && job.nextRunAt
+ ? new Date(job.nextRunAt).toLocaleString()
+ : "—"}
+
+
+
+
navigate(`/settings/scheduled-jobs/${job.id}/runs`)}
+ className="p-1.5 rounded-lg hover:bg-theme-bg-primary text-theme-text-secondary hover:text-theme-text-primary transition-colors"
+ title="View runs"
+ >
+
+
+
onTrigger(job.id)}
+ className="p-1.5 rounded-lg hover:bg-theme-bg-primary text-theme-text-secondary hover:text-theme-text-primary transition-colors"
+ title="Run now"
+ >
+
+
+
onToggle(job.id)}
+ className={`p-1.5 rounded-lg hover:bg-theme-bg-primary transition-colors ${
+ job.enabled
+ ? "text-green-400 hover:text-yellow-400"
+ : "text-gray-500 hover:text-green-400"
+ }`}
+ title={job.enabled ? "Disable" : "Enable"}
+ >
+
+
+
onEdit(job)}
+ className="p-1.5 rounded-lg hover:bg-theme-bg-primary text-theme-text-secondary hover:text-theme-text-primary transition-colors"
+ title="Edit"
+ >
+
+
+
onDelete(job.id)}
+ className="p-1.5 rounded-lg hover:bg-theme-bg-primary text-theme-text-secondary hover:text-red-400 transition-colors"
+ title="Delete"
+ >
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/ScheduledJobs/components/RunRow.jsx b/frontend/src/pages/GeneralSettings/ScheduledJobs/components/RunRow.jsx
new file mode 100644
index 00000000000..8a100860973
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ScheduledJobs/components/RunRow.jsx
@@ -0,0 +1,58 @@
+import { useNavigate } from "react-router-dom";
+import { Circle, Eye } from "@phosphor-icons/react";
+import paths from "@/utils/paths";
+import StatusBadge from "./StatusBadge";
+
+// Format a run's elapsed time as ms / s / m. Only used here so it lives
+// alongside the row component.
+function formatDuration(run) {
+ if (!run.completedAt || !run.startedAt) return "—";
+ const ms =
+ new Date(run.completedAt).getTime() - new Date(run.startedAt).getTime();
+ if (ms < 1000) return `${ms}ms`;
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
+ return `${(ms / 60000).toFixed(1)}m`;
+}
+
+// One row of the run history table for a job. Shows status, timestamps,
+// duration, error preview, and a link to the run detail page.
+export default function RunRow({ run, jobId }) {
+ const navigate = useNavigate();
+ return (
+
+
+
+ {!run.readAt && run.status !== "running" && (
+
+ )}
+
+
+
+
+ {new Date(run.startedAt).toLocaleString()}
+
+
+ {formatDuration(run)}
+
+
+ {run.error || "—"}
+
+
+
+
+ navigate(paths.settings.scheduledJobRunDetail(jobId, run.id))
+ }
+ className="p-1.5 rounded-lg hover:bg-theme-bg-primary text-theme-text-secondary hover:text-theme-text-primary transition-colors"
+ title="View details"
+ >
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/ScheduledJobs/components/StatusBadge.jsx b/frontend/src/pages/GeneralSettings/ScheduledJobs/components/StatusBadge.jsx
new file mode 100644
index 00000000000..fb48b9d1a81
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ScheduledJobs/components/StatusBadge.jsx
@@ -0,0 +1,20 @@
+const STATUS_COLORS = {
+ running: "bg-yellow-500/20 text-yellow-400",
+ completed: "bg-green-500/20 text-green-400",
+ failed: "bg-red-500/20 text-red-400",
+ timed_out: "bg-orange-500/20 text-orange-400",
+};
+
+// Pill badge for a scheduled-job-run status. Used in the jobs list and the
+// run history table to keep the visual treatment consistent.
+export default function StatusBadge({ status }) {
+ return (
+
+
+
+
+
+ {toolCall.toolName}
+
+
+ {toolCall.timestamp && (
+
+ {new Date(toolCall.timestamp).toLocaleTimeString()}
+
+ )}
+
+
+ {toolCall.arguments && (
+
+
Arguments:
+ {highlightedArgs ? (
+
+ ) : (
+
+ {typeof toolCall.arguments === "string"
+ ? toolCall.arguments
+ : JSON.stringify(toolCall.arguments, null, 2)}
+
+ )}
+
+ )}
+
+ {resultText && (
+
+
setShowResult(!showResult)}
+ className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
+ >
+ {showResult ? "Hide result" : "Show result"}
+
+ {showResult &&
+ (highlightedResult ? (
+
+ ) : (
+
+ {truncatedResult}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/ScheduledJobs/index.jsx b/frontend/src/pages/GeneralSettings/ScheduledJobs/index.jsx
new file mode 100644
index 00000000000..d25ccadf6f8
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ScheduledJobs/index.jsx
@@ -0,0 +1,146 @@
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import { PlusCircle } from "@phosphor-icons/react";
+import ScheduledJobs from "@/models/scheduledJobs";
+import useWebPushNotifications from "@/hooks/useWebPushNotifications";
+import usePolling from "@/hooks/usePolling";
+import NewJobModal from "./NewJobModal";
+import ModalWrapper from "@/components/ModalWrapper";
+import { useModal } from "@/hooks/useModal";
+import CTAButton from "@/components/lib/CTAButton";
+import showToast from "@/utils/toast";
+import JobRow from "./components/JobRow";
+
+export default function ScheduledJobsPage() {
+ useWebPushNotifications();
+ const { isOpen, openModal, closeModal } = useModal();
+ const [loading, setLoading] = useState(true);
+ const [jobs, setJobs] = useState([]);
+ const [editingJob, setEditingJob] = useState(null);
+
+ const fetchJobs = async () => {
+ const { jobs: foundJobs } = await ScheduledJobs.list();
+ setJobs(foundJobs || []);
+ setLoading(false);
+ };
+
+ useEffect(() => {
+ fetchJobs();
+ }, []);
+
+ // Poll every 5s while tab is visible so status badges and run timestamps stay in sync.
+ usePolling(fetchJobs, 5000);
+
+ const handleDelete = async (id) => {
+ if (!window.confirm("Are you sure you want to delete this scheduled job?"))
+ return;
+ await ScheduledJobs.delete(id);
+ showToast("Job deleted", "success");
+ fetchJobs();
+ };
+
+ const handleToggle = async (id) => {
+ const result = await ScheduledJobs.toggle(id);
+ if (result?.error) {
+ showToast(result.error, "error");
+ }
+ fetchJobs();
+ };
+
+ const handleTrigger = async (id) => {
+ const { success, error } = await ScheduledJobs.trigger(id);
+ if (success) {
+ showToast("Job triggered successfully", "success");
+ } else {
+ showToast(error || "Failed to trigger job", "error");
+ }
+ fetchJobs();
+ };
+
+ const handleEdit = (job) => {
+ setEditingJob(job);
+ openModal();
+ };
+
+ const handleCreate = () => {
+ setEditingJob(null);
+ openModal();
+ };
+
+ return (
+