From 66d4a9f4dfa65c91321a276c2ac28e3e8a0dbb51 Mon Sep 17 00:00:00 2001 From: Sean Callan Date: Wed, 20 May 2026 11:51:57 -0400 Subject: [PATCH 1/4] feat(scheduler): Loops and Schedules view + /loop and /schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a user-facing UI on top of the existing agent-scheduling backend (`agent_scheduled_tasks`). Previously routines were only creatable via agent tool calls or the `claudette routine` CLI; this exposes them in the GUI without inventing a parallel schema. - Clock icon next to the dashboard icon in the sidebar opens the "Loops and Schedules" view (renders inside the content area, keeps the sidebar + PanelHeader + PanelToggles in place). Two sections: Loops (kind=cron) and Schedules (kind=wakeup), with Run-now and Delete actions sharing main's existing API. - "New scheduled task" button opens a modal with One-shot (wakeup) or Recurring (cron) mode, a session picker that defaults to the active chat, datetime-local or cron-expression inputs, optional name, and a prompt textarea. - `/loop ` translates whole-minute intervals that evenly tile their unit (1m/2m/5m/10m/15m/20m/30m, 1h/2h/3h/4h/6h/8h/12h, 1d) to a 5-field cron expression and creates a recurring routine in the current session. Sub-minute or odd intervals open the modal so the user can write the cron expression directly. - `/schedule [] []` fires a wakeup inline when both a leading `YYYY-MM-DD[THH:MM]` timestamp and a prompt are present; otherwise opens the modal with whatever was provided. The same task list also drives Settings → Automation, so creating from the page or a slash command shows up in both places. --- .../docs/features/agent-scheduling.mdx | 35 +++ .../content/docs/features/slash-commands.mdx | 2 + src/slash_commands.rs | 16 ++ src/ui/src/App.tsx | 5 + src/ui/src/components/chat/ChatPanel.tsx | 3 + .../chat/nativeSlashCommands.test.ts | 7 + .../components/chat/nativeSlashCommands.ts | 150 +++++++++- src/ui/src/components/layout/AppLayout.tsx | 17 +- .../CreateScheduledTaskModal.module.css | 13 + .../modals/CreateScheduledTaskModal.tsx | 252 +++++++++++++++++ src/ui/src/components/modals/ModalRouter.tsx | 3 + .../scheduler/SchedulerPage.module.css | 177 ++++++++++++ .../components/scheduler/SchedulerPage.tsx | 258 ++++++++++++++++++ .../src/components/sidebar/Sidebar.module.css | 6 + src/ui/src/components/sidebar/Sidebar.tsx | 14 +- src/ui/src/i18n.ts | 14 +- src/ui/src/locales/de/scheduler.json | 39 +++ src/ui/src/locales/de/sidebar.json | 1 + src/ui/src/locales/en/scheduler.json | 39 +++ src/ui/src/locales/en/sidebar.json | 1 + src/ui/src/locales/es/scheduler.json | 39 +++ src/ui/src/locales/es/sidebar.json | 1 + src/ui/src/locales/ja/scheduler.json | 39 +++ src/ui/src/locales/ja/sidebar.json | 1 + src/ui/src/locales/pt-BR/scheduler.json | 39 +++ src/ui/src/locales/pt-BR/sidebar.json | 1 + src/ui/src/locales/zh-CN/scheduler.json | 39 +++ src/ui/src/locales/zh-CN/sidebar.json | 1 + src/ui/src/services/tauri/scheduling.ts | 36 +++ src/ui/src/stores/slices/schedulingSlice.ts | 38 +++ src/ui/src/stores/slices/uiSlice.ts | 28 ++ src/ui/src/stores/slices/workspacesSlice.ts | 24 +- src/ui/src/stores/useAppStore.ts | 6 + src/ui/src/types/i18next.d.ts | 2 + 34 files changed, 1334 insertions(+), 12 deletions(-) create mode 100644 src/ui/src/components/modals/CreateScheduledTaskModal.module.css create mode 100644 src/ui/src/components/modals/CreateScheduledTaskModal.tsx create mode 100644 src/ui/src/components/scheduler/SchedulerPage.module.css create mode 100644 src/ui/src/components/scheduler/SchedulerPage.tsx create mode 100644 src/ui/src/locales/de/scheduler.json create mode 100644 src/ui/src/locales/en/scheduler.json create mode 100644 src/ui/src/locales/es/scheduler.json create mode 100644 src/ui/src/locales/ja/scheduler.json create mode 100644 src/ui/src/locales/pt-BR/scheduler.json create mode 100644 src/ui/src/locales/zh-CN/scheduler.json create mode 100644 src/ui/src/stores/slices/schedulingSlice.ts diff --git a/site/src/content/docs/features/agent-scheduling.mdx b/site/src/content/docs/features/agent-scheduling.mdx index d9cbee234..9a64fbcfe 100644 --- a/site/src/content/docs/features/agent-scheduling.mdx +++ b/site/src/content/docs/features/agent-scheduling.mdx @@ -34,3 +34,38 @@ claudette routine delete weekday-prs ``` `routine create` accepts literal prompts, `@file` prompts, and `-` for stdin, matching `chat send`. + +## Loops and Schedules view + +You can drive the same scheduling backend from the GUI without the agent involved. + +Click the **clock icon** next to the dashboard icon in the sidebar header to open the **Loops and Schedules** view. Like the Dashboard, it keeps the sidebar and header (panel toggles) in place — click the clock again, the dashboard icon, or a workspace to leave it. + +The page lists every persisted task, split into two sections: + +- **Loops** — cron routines (`kind: cron`). Shows the human-readable schedule, recurring/one-shot mode, target session, and next-fire countdown. +- **Schedules** — one-shot wakeups (`kind: wakeup`). Shows the fire time, countdown, and target session. + +Each row has **Run now** (fires immediately, same as the CLI's `routine run`) and **Delete** buttons. The same list also appears under **Settings → Automation**. + +### Creating a task from the GUI + +The **New scheduled task** button at the top of the page opens a dialog with two modes: + +- **One-shot (wakeup)** — pick a target chat session, a date/time, and a prompt. +- **Recurring (cron)** — pick a target chat session, write a standard 5-field cron expression (e.g. `0 9 * * 1-5`), optionally name the routine, choose recurring or one-shot, and write the prompt. + +### Slash commands + +In a chat, you can create tasks for the current session without opening the dialog: + +``` +/loop 5m run the test suite +/schedule 2026-06-01T09:00 cut the release branch +/schedule # opens the dialog (no args) +/schedule write the weekly status update # opens the dialog, prompt prefilled +``` + +`/loop ` translates whole-minute intervals that evenly divide their unit (1m/2m/5m/10m/15m/20m/30m, 1h/2h/3h/4h/6h/8h/12h, 1d) to a cron expression and creates a recurring routine in the current session. Sub-minute or odd intervals open the dialog instead so you can write the cron expression directly. + +`/schedule ` accepts a leading `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM` timestamp. If both the time and prompt are present, it creates the wakeup inline; otherwise it opens the dialog prefilled with whatever you provided. diff --git a/site/src/content/docs/features/slash-commands.mdx b/site/src/content/docs/features/slash-commands.mdx index 0a4b5b6e2..d238ce7a5 100644 --- a/site/src/content/docs/features/slash-commands.mdx +++ b/site/src/content/docs/features/slash-commands.mdx @@ -21,6 +21,8 @@ Native commands ship with the app and always work. They split into two flavors: | `/status` | Show session/permission status. | | `/help` | Show available commands. | | `/init` | Generate a `CLAUDE.md` for the current repo. | +| `/loop` ` ` | Schedule a recurring cron routine on this chat session (e.g. `/loop 5m run the tests`). See [Agent Scheduling → Loops and Schedules view](/claudette/features/agent-scheduling/#loops-and-schedules-view). | +| `/schedule` `[] []` | Schedule a one-shot wakeup on this chat session, or open the New scheduled task dialog if args are missing. | | `/login` | Start sign-in for the active model provider. Claude models open the Claude Code browser/manual-code flow; Codex models run `codex login`; Pi models show `pi auth` guidance (run `pi auth` in a terminal, then refresh Pi models — Claudette does not query Pi auth status). | | `/config` `[section]` | Open Claudette settings (optional section: `general`, `models`, `usage`, `appearance`, `notifications`, `git`, `plugins`, `experimental`). | | `/usage` | Open the Claude Code usage panel. | diff --git a/src/slash_commands.rs b/src/slash_commands.rs index 2d814698f..981c0fb47 100644 --- a/src/slash_commands.rs +++ b/src/slash_commands.rs @@ -218,6 +218,22 @@ pub fn native_command_registry() -> Vec { argument_hint: Some("[extra guidance]".to_string()), kind: Some(NativeKind::PromptExpansion), }); + commands.push(SlashCommand { + name: "loop".to_string(), + description: "Schedule a recurring cron routine on this chat session".to_string(), + source: "builtin".to_string(), + aliases: Vec::new(), + argument_hint: Some(" ".to_string()), + kind: Some(NativeKind::LocalAction), + }); + commands.push(SlashCommand { + name: "schedule".to_string(), + description: "Schedule a one-shot wakeup on this chat session".to_string(), + source: "builtin".to_string(), + aliases: Vec::new(), + argument_hint: Some("[] []".to_string()), + kind: Some(NativeKind::LocalAction), + }); commands } diff --git a/src/ui/src/App.tsx b/src/ui/src/App.tsx index 480652eb0..51bcbb384 100644 --- a/src/ui/src/App.tsx +++ b/src/ui/src/App.tsx @@ -937,6 +937,11 @@ function App() { store.addToast(msg); }); + // Scheduled tasks (loops + wakeups): load the current list. They share + // the source-of-truth list shown by Settings → Automation and the + // sidebar's Loops-and-Schedules view. + void useAppStore.getState().loadScheduledTasks(); + return () => { isActive = false; window.clearInterval(discoveredServersPollId); diff --git a/src/ui/src/components/chat/ChatPanel.tsx b/src/ui/src/components/chat/ChatPanel.tsx index dbc484980..051a4ad00 100644 --- a/src/ui/src/components/chat/ChatPanel.tsx +++ b/src/ui/src/components/chat/ChatPanel.tsx @@ -651,6 +651,9 @@ export function ChatPanel() { ); }, workspaceId, + sessionId, + openScheduler: (prefill) => + useAppStore.getState().openScheduler(prefill), agentStatus: agentStatusLabel, selectedModel: currentModel, selectedModelProvider: currentModelProvider, diff --git a/src/ui/src/components/chat/nativeSlashCommands.test.ts b/src/ui/src/components/chat/nativeSlashCommands.test.ts index 47b1e8492..c3ec249e7 100644 --- a/src/ui/src/components/chat/nativeSlashCommands.test.ts +++ b/src/ui/src/components/chat/nativeSlashCommands.test.ts @@ -33,6 +33,13 @@ function makeCtx(overrides: Partial = {}): NativeCommandCo openUsageSettingsExternal: vi.fn<() => void>(), openReleaseNotes: vi.fn<() => void>(), workspaceId: "ws-1", + sessionId: "sess-1", + openScheduler: vi.fn<(prefill?: { + sessionId?: string; + prompt?: string; + fireAt?: string; + cronExpr?: string; + }) => void>(), agentStatus: "Idle", selectedModel: "opus", selectedModelProvider: "anthropic", diff --git a/src/ui/src/components/chat/nativeSlashCommands.ts b/src/ui/src/components/chat/nativeSlashCommands.ts index 3eb2f8e8a..aba97650a 100644 --- a/src/ui/src/components/chat/nativeSlashCommands.ts +++ b/src/ui/src/components/chat/nativeSlashCommands.ts @@ -1,5 +1,10 @@ import type { PluginSettingsIntent } from "../../types/plugins"; -import type { NativeSlashKind, SlashCommand } from "../../services/tauri"; +import { + type NativeSlashKind, + type SlashCommand, + scheduleWakeup, + createCronRoutine, +} from "../../services/tauri"; import type { PermissionLevel } from "../../stores/useAppStore"; import { parsePluginSlashCommand } from "./pluginSlashCommand"; import { buildModelRegistry, resolveModelSelection } from "./modelRegistry"; @@ -54,6 +59,18 @@ export interface NativeCommandContext { // -- Per-workspace state read by workspace-control commands // (/clear, /plan, /model, /permissions, /status). -- workspaceId: string | null; + /** Active chat session id (read by `/loop` and `/schedule` so a new + * scheduled task is bound to the same session the user typed in). */ + sessionId: string | null; + /** Open the Loops and Schedules view, optionally prefilling the + * create-task modal (used by `/schedule` and `/loop` when their args + * can't be turned into a concrete task on the spot). */ + openScheduler: (prefill?: { + sessionId?: string; + prompt?: string; + fireAt?: string; + cronExpr?: string; + }) => void; agentStatus: string | null; selectedModel: string; selectedModelProvider: string; @@ -723,6 +740,135 @@ const initHandler: NativeHandler = { }, }; +/** Map a friendly interval token (e.g. `5m`, `1h`, `1d`) to a standard + * 5-field cron expression. Returns `null` for sub-minute or odd intervals + * that cron can't represent cleanly. */ +function intervalToCron(token: string): string | null { + const m = /^(\d+)(s|m|h|d)$/i.exec(token.trim()); + if (!m) return null; + const n = Number(m[1]); + if (!Number.isFinite(n) || n <= 0) return null; + const unit = m[2].toLowerCase(); + if (unit === "s") return null; // sub-minute — cron can't express + if (unit === "m") { + if (n >= 60) return null; + if (60 % n !== 0) return null; // odd intervals don't tile the hour + return `*/${n} * * * *`; + } + if (unit === "h") { + if (n >= 24) return null; + if (24 % n !== 0) return null; + return n === 1 ? `0 * * * *` : `0 */${n} * * *`; + } + // unit === "d" + if (n !== 1) return null; + return `0 0 * * *`; +} + +const LOOP_USAGE = + "/loop: usage — `/loop ` (e.g. `/loop 5m run the tests`). Whole-minute intervals up to 1d that evenly divide their unit (1m/2m/5m/10m/15m/20m/30m, 1h/2h/3h/4h/6h/8h/12h, 1d). Sub-minute or odd intervals: write a cron expression in the New scheduled task dialog."; + +const loopHandler: NativeHandler = { + name: "loop", + aliases: [], + kind: "local_action", + execute: async (ctx, args) => { + const handled = { kind: "handled" as const, canonicalName: "loop" }; + if (!ctx.workspaceId || !ctx.sessionId) { + ctx.addLocalMessage("/loop: no active workspace"); + return handled; + } + const tokens = args.trim().split(/\s+/); + if (tokens.length === 0 || tokens[0] === "") { + ctx.addLocalMessage(LOOP_USAGE); + return handled; + } + const cronExpr = intervalToCron(tokens[0]); + const prompt = tokens.slice(1).join(" ").trim(); + if (!cronExpr) { + ctx.openScheduler({ + sessionId: ctx.sessionId, + prompt: prompt || undefined, + }); + ctx.addLocalMessage(LOOP_USAGE); + return handled; + } + if (!prompt) { + ctx.addLocalMessage(LOOP_USAGE); + return handled; + } + try { + await createCronRoutine({ + sessionId: ctx.sessionId, + cronExpr, + prompt, + recurring: true, + }); + ctx.addLocalMessage( + `Looping (\`${cronExpr}\`). Manage it from the scheduler (clock icon in the sidebar).`, + ); + } catch (e) { + ctx.addLocalMessage(`/loop failed: ${String(e)}`); + } + return handled; + }, +}; + +/** Strict-ish leading-timestamp matcher so we don't mistake a prompt that + * starts with a date for an explicit schedule time. */ +const SCHEDULE_TIME_RE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?$/; + +const scheduleHandler: NativeHandler = { + name: "schedule", + aliases: [], + kind: "local_action", + execute: async (ctx, args) => { + const handled = { kind: "handled" as const, canonicalName: "schedule" }; + const trimmed = args.trim(); + if (!trimmed) { + // No args — open the modal in its default state (one-shot). + ctx.openScheduler({ sessionId: ctx.sessionId ?? undefined }); + return handled; + } + const firstTok = trimmed.split(/\s+/, 1)[0] ?? ""; + if (SCHEDULE_TIME_RE.test(firstTok)) { + const parsed = new Date(firstTok); + const rest = trimmed.slice(firstTok.length).trim(); + if (!Number.isNaN(parsed.getTime()) && ctx.sessionId && rest) { + // Inline schedule: known time, known prompt, known session — fire + // the wakeup directly instead of opening the modal. + try { + await scheduleWakeup({ + sessionId: ctx.sessionId, + fireAt: parsed.toISOString(), + prompt: rest, + }); + ctx.addLocalMessage( + `Scheduled for ${parsed.toLocaleString()}. Manage it from the scheduler (clock icon in the sidebar).`, + ); + } catch (e) { + ctx.addLocalMessage(`/schedule failed: ${String(e)}`); + } + return handled; + } + // Time parsed but missing prompt or session — open the modal + // prefilled with what we have. + ctx.openScheduler({ + sessionId: ctx.sessionId ?? undefined, + prompt: rest || undefined, + fireAt: Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString(), + }); + return handled; + } + // No leading timestamp — treat all of args as the prompt. + ctx.openScheduler({ + sessionId: ctx.sessionId ?? undefined, + prompt: trimmed, + }); + return handled; + }, +}; + function formatCommandLine( cmd: SlashCommand, shadowedAliases: ReadonlySet, @@ -915,6 +1061,8 @@ export const NATIVE_HANDLERS: NativeHandler[] = [ statusHandler, helpHandler, initHandler, + loopHandler, + scheduleHandler, ]; /** Resolve a slash command token (no leading `/`) against the native handler table. */ diff --git a/src/ui/src/components/layout/AppLayout.tsx b/src/ui/src/components/layout/AppLayout.tsx index 426bbc0f0..468ecb335 100644 --- a/src/ui/src/components/layout/AppLayout.tsx +++ b/src/ui/src/components/layout/AppLayout.tsx @@ -14,6 +14,7 @@ import { CommandPalette } from "../command-palette/CommandPalette"; import { Dashboard } from "./Dashboard"; import { ModalRouter } from "../modals/ModalRouter"; import { SettingsPage } from "../settings/SettingsPage"; +import { SchedulerPage } from "../scheduler/SchedulerPage"; import { ResizeHandle } from "./ResizeHandle"; import { ToastContainer } from "./Toast"; import { AppTooltip } from "../shared/AppTooltip"; @@ -40,6 +41,7 @@ export function AppLayout() { const terminalHeight = useAppStore((s) => s.terminalHeight); const setTerminalHeight = useAppStore((s) => s.setTerminalHeight); const settingsOpen = useAppStore((s) => s.settingsOpen); + const schedulerOpen = useAppStore((s) => s.schedulerOpen); const fuzzyFinderOpen = useAppStore((s) => s.fuzzyFinderOpen); const commandPaletteOpen = useAppStore((s) => s.commandPaletteOpen); @@ -132,7 +134,12 @@ export function AppLayout() { )}
- {selectedWorkspaceId ? ( + {/* The Loops and Schedules view replaces the workspace/dashboard + content but, like the Dashboard, keeps the sidebar + + PanelHeader + PanelToggles chrome in place. */} + {schedulerOpen ? ( + + ) : selectedWorkspaceId ? ( <>
- {selectedWorkspaceId && ( + {!schedulerOpen && selectedWorkspaceId && ( <> {rightSidebarVisible && ( value for `` (local time, no tz). */ +function toDatetimeLocalValue(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** Very-loose 5-field cron validator. The backend has the authoritative + * parser; this just catches obvious typos before the round trip. */ +function looksLikeCron(expr: string): boolean { + const trimmed = expr.trim(); + if (!trimmed) return false; + const fields = trimmed.split(/\s+/); + return fields.length === 5; +} + +export function CreateScheduledTaskModal() { + const { t } = useTranslation("scheduler"); + const { t: tCommon } = useTranslation("common"); + const closeModal = useAppStore((s) => s.closeModal); + const modalData = useAppStore((s) => s.modalData); + const sessionsByWorkspace = useAppStore((s) => s.sessionsByWorkspace); + const workspaces = useAppStore((s) => s.workspaces); + const loadScheduledTasks = useAppStore((s) => s.loadScheduledTasks); + const activeSessionId = useAppStore(selectActiveSessionId); + + /** Flat list of selectable sessions, with a label that disambiguates + * across workspaces. */ + const sessions = useMemo(() => { + const rows: { id: string; label: string }[] = []; + for (const [wsId, list] of Object.entries(sessionsByWorkspace)) { + const ws = workspaces.find((w) => w.id === wsId); + const wsLabel = ws?.branch_name ?? wsId.slice(0, 8); + for (const s of list) { + if (s.status === "Archived") continue; + rows.push({ id: s.id, label: `${wsLabel} · ${s.name}` }); + } + } + return rows; + }, [sessionsByWorkspace, workspaces]); + + const prefillSessionId = + typeof modalData.sessionId === "string" ? (modalData.sessionId as string) : null; + const prefillPrompt = + typeof modalData.prompt === "string" ? (modalData.prompt as string) : ""; + const prefillFireAt = + typeof modalData.fireAt === "string" ? (modalData.fireAt as string) : null; + const prefillCron = + typeof modalData.cronExpr === "string" ? (modalData.cronExpr as string) : ""; + + const [type, setType] = useState(() => + prefillCron ? "cron" : "wakeup", + ); + const [sessionId, setSessionId] = useState( + () => prefillSessionId ?? activeSessionId ?? sessions[0]?.id ?? "", + ); + const [fireAt, setFireAt] = useState(() => { + if (prefillFireAt) { + const d = new Date(prefillFireAt); + if (!Number.isNaN(d.getTime())) return toDatetimeLocalValue(d); + } + return toDatetimeLocalValue(new Date(Date.now() + 60 * 60 * 1000)); + }); + const [cronExpr, setCronExpr] = useState(prefillCron); + const [name, setName] = useState(""); + const [recurring, setRecurring] = useState(true); + const [prompt, setPrompt] = useState(prefillPrompt); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // If the picker has no value yet but sessions arrive, default to the active + // one (or the first available). + useEffect(() => { + if (!sessionId && sessions.length > 0) { + setSessionId(activeSessionId ?? sessions[0].id); + } + }, [sessionId, sessions, activeSessionId]); + + const handleSubmit = async () => { + setError(null); + if (!sessionId) { + setError(t("error_pick_session")); + return; + } + if (!prompt.trim()) { + setError(t("error_empty_prompt")); + return; + } + try { + setSubmitting(true); + if (type === "wakeup") { + const when = new Date(fireAt); + if (Number.isNaN(when.getTime())) { + setError(t("error_bad_time")); + setSubmitting(false); + return; + } + await scheduleWakeup({ + sessionId, + fireAt: when.toISOString(), + prompt: prompt.trim(), + }); + } else { + if (!looksLikeCron(cronExpr)) { + setError(t("error_bad_cron")); + setSubmitting(false); + return; + } + await createCronRoutine({ + sessionId, + name: name.trim() || undefined, + cronExpr: cronExpr.trim(), + prompt: prompt.trim(), + recurring, + }); + } + void loadScheduledTasks(); + closeModal(); + } catch (e) { + setError(String(e)); + } finally { + setSubmitting(false); + } + }; + + return ( + +
+ +
+ + +
+
+ +
+ + +
+ + {type === "wakeup" ? ( +
+ + setFireAt(e.target.value)} + /> +
+ ) : ( + <> +
+ + setCronExpr(e.target.value)} + placeholder="0 9 * * 1-5" + /> +
{t("cron_expr_hint")}
+
+
+ + setName(e.target.value)} + /> +
+ + + )} + +
+ +