feat(scheduler): Loops and Schedules view + /loop and /schedule - #975
feat(scheduler): Loops and Schedules view + /loop and /schedule#975doomspork wants to merge 4 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #975 +/- ##
==========================================
+ Coverage 80.74% 81.06% +0.31%
==========================================
Files 123 123
Lines 46156 47324 +1168
==========================================
+ Hits 37270 38361 +1091
- Misses 8886 8963 +77 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adds a first-class “Loops and Schedules” UI surface (plus /loop and /schedule native slash commands) on top of the existing scheduled-task backend (agent_scheduled_tasks), wiring it into the app layout/sidebar, creating a task-creation modal, and documenting the feature.
Changes:
- Introduces a dedicated scheduler page (sidebar clock icon) and a “New scheduled task” modal for wakeups + cron routines.
- Adds
/loop(interval→cron) and/schedule(timestamp parsing / modal fallback) native slash commands and registers them in the Rust slash command registry. - Adds a new
scheduleri18n namespace + locale resources and a new ZustandschedulingSliceto load/store scheduled tasks.
Reviewed changes
Copilot reviewed 31 out of 32 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/ui/src/types/i18next.d.ts | Adds scheduler namespace typing for i18next. |
| src/ui/src/stores/useAppStore.ts | Registers the new schedulingSlice in the app store. |
| src/ui/src/stores/slices/workspacesSlice.ts | Clears schedulerOpen during navigation and updates no-op guards. |
| src/ui/src/stores/slices/uiSlice.ts | Adds scheduler page state + openScheduler/closeScheduler. |
| src/ui/src/stores/slices/schedulingSlice.ts | New slice to load and store scheduled tasks list. |
| src/ui/src/services/tauri/scheduling.ts | Adds Tauri service wrappers for schedule_wakeup and create_cron_routine. |
| src/ui/src/locales/en/sidebar.json | Adds sidebar label for scheduler entry. |
| src/ui/src/locales/es/sidebar.json | Adds sidebar label for scheduler entry. |
| src/ui/src/locales/pt-BR/sidebar.json | Adds sidebar label for scheduler entry. |
| src/ui/src/locales/ja/sidebar.json | Adds sidebar label for scheduler entry. |
| src/ui/src/locales/zh-CN/sidebar.json | Adds sidebar label for scheduler entry. |
| src/ui/src/locales/en/scheduler.json | New scheduler namespace strings (English). |
| src/ui/src/locales/es/scheduler.json | New scheduler namespace strings (currently English content). |
| src/ui/src/locales/pt-BR/scheduler.json | New scheduler namespace strings (currently English content). |
| src/ui/src/locales/ja/scheduler.json | New scheduler namespace strings (currently English content). |
| src/ui/src/locales/zh-CN/scheduler.json | New scheduler namespace strings (currently English content). |
| src/ui/src/i18n.ts | Registers scheduler namespace and resources for all supported languages. |
| src/ui/src/components/sidebar/Sidebar.tsx | Adds clock icon toggle to open/close scheduler view. |
| src/ui/src/components/sidebar/Sidebar.module.css | Adds an active style variant for the dashboard/scheduler header buttons. |
| src/ui/src/components/scheduler/SchedulerPage.tsx | New Loops/Schedules page: list, refresh, run-now, delete, countdown ticker. |
| src/ui/src/components/scheduler/SchedulerPage.module.css | Styling for the new scheduler page. |
| src/ui/src/components/modals/ModalRouter.tsx | Routes the new createScheduledTask modal. |
| src/ui/src/components/modals/CreateScheduledTaskModal.tsx | New modal to create wakeups or cron routines. |
| src/ui/src/components/modals/CreateScheduledTaskModal.module.css | Styling for the new modal. |
| src/ui/src/components/layout/AppLayout.tsx | Renders scheduler page when schedulerOpen and hides workspace-only panes. |
| src/ui/src/components/chat/nativeSlashCommands.ts | Adds /loop and /schedule handlers + interval→cron helper. |
| src/ui/src/components/chat/nativeSlashCommands.test.ts | Extends native command context fixture with scheduler fields. |
| src/ui/src/components/chat/ChatPanel.tsx | Passes sessionId and openScheduler into native slash command context. |
| src/ui/src/App.tsx | Loads scheduled tasks list during app initialization. |
| src/slash_commands.rs | Registers /loop and /schedule in the native command registry. |
| site/src/content/docs/features/slash-commands.mdx | Documents /loop and /schedule. |
| site/src/content/docs/features/agent-scheduling.mdx | Adds “Loops and Schedules view” docs and creation/slash-command guidance. |
Comments suppressed due to low confidence (1)
src/ui/src/stores/slices/workspacesSlice.ts:619
- When
id === s.selectedWorkspaceIdbutschedulerOpenis true, this path re-callsnotifyBackendSelection(id)andprewarmWorkspaceSelection(...)even though the workspace selection didn’t actually change (the user is just exiting the scheduler view). Consider handling this case by returning a minimal state update (e.g.{ schedulerOpen: false }) to avoid redundant backend notifications and prewarm work.
selectWorkspace: (id) =>
set((s) => {
if (id === s.selectedWorkspaceId && !s.schedulerOpen) return s;
notifyBackendSelection(id);
prewarmWorkspaceSelection(
id,
s.fileTreeRefreshNonceByWorkspace,
s.pendingCreates,
s.pendingForks,
);
| const wantsModal = | ||
| !!prefill && (!!prefill.prompt || !!prefill.fireAt || !!prefill.cronExpr); |
There was a problem hiding this comment.
Done in d008224 — openScheduler now opens the create dialog for any prefill object (including no-args /schedule, which previously opened only the page). /loop and /schedule pass a mode hint so the dialog opens in the correct type (cron vs wakeup).
| 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(); |
There was a problem hiding this comment.
Done in d008224 — the leading timestamp is now parsed in local time by building the Date from components (a bare YYYY-MM-DD is no longer treated as UTC midnight), the space-separated YYYY-MM-DD HH:MM form is accepted as two tokens, and the time token no longer leaks into the prompt. Covered by new unit tests.
| function formatCountdown(iso: string | null, now: number): string { | ||
| if (!iso) return "—"; | ||
| const target = new Date(iso).getTime(); | ||
| if (Number.isNaN(target)) return iso; | ||
| let delta = Math.round((target - now) / 1000); | ||
| if (delta <= 0) return "due now"; |
There was a problem hiding this comment.
Done in d008224 — formatCountdown now returns 0s for due/overdue times, so the label reads "next in 0s" rather than the ungrammatical "next in due now".
| setError(null); | ||
| try { | ||
| await deleteScheduledRoutine(id); | ||
| setScheduledTasks(scheduledTasks.filter((task) => task.id !== id)); |
There was a problem hiding this comment.
Done in d008224 — Delete and Run-now now reload the list from the backend instead of filtering a render-time snapshot, so a concurrent refresh cannot drop or reintroduce rows.
| ? `· ${t("last_fired", { time: formatLocalDateTime(task.last_fired_at) ?? "" })}` | ||
| : ""; | ||
| const mode = !isWakeup | ||
| ? task.recurring | ||
| ? t("mode_recurring") | ||
| : t("mode_one_shot") | ||
| : null; | ||
| const isBusy = busyId === task.id; | ||
|
|
||
| return ( | ||
| <li className={`${styles.row} ${task.enabled ? "" : styles.rowMuted}`}> | ||
| <div className={styles.rowMain}> | ||
| <div className={styles.rowTitle}>{task.name || task.prompt}</div> | ||
| <div className={styles.rowMeta}> | ||
| {schedule} | ||
| {mode ? ` · ${mode}` : ""} · {next} {last} · {workspaceLabel(task.workspace_id)} | ||
| </div> |
There was a problem hiding this comment.
Done in d008224 — each row now shows the target session name for reuse-mode tasks (sessions are loaded per task workspace, falling back to a short id), or "new session each run" for new-session tasks, alongside the workspace.
| export interface SchedulingSlice { | ||
| /** All scheduled tasks (wakeups + cron routines), source-of-truth list | ||
| * shared with the Settings → Automation panel. */ | ||
| scheduledTasks: ScheduledTask[]; | ||
| setScheduledTasks: (tasks: ScheduledTask[]) => void; |
There was a problem hiding this comment.
Done in d008224 — Settings -> Automation now reads the shared schedulingSlice (scheduledTasks / loadScheduledTasks), so it and the Loops and Schedules view stay in sync. The slice comment is now accurate.
| import schedulerEs from "./locales/es/scheduler.json"; | ||
| import schedulerPtBr from "./locales/pt-BR/scheduler.json"; | ||
| import schedulerJa from "./locales/ja/scheduler.json"; | ||
| import schedulerZhCn from "./locales/zh-CN/scheduler.json"; |
There was a problem hiding this comment.
Done in d008224 — added real translations for the scheduler namespace and the chat Scheduled-badge keys across es, pt-BR, ja, zh-CN (and de).
| helpHandler, | ||
| initHandler, | ||
| loopHandler, | ||
| scheduleHandler, |
There was a problem hiding this comment.
Done in d008224 — added Vitest coverage for /loop (interval->cron mapping plus non-cron fallback) and /schedule (inline local-time wakeup, two-token time, bare-date midnight, and the no-args / no-prompt dialog paths).
| openScheduler: (prefill) => { | ||
| const wantsModal = | ||
| !!prefill && (!!prefill.prompt || !!prefill.fireAt || !!prefill.cronExpr); | ||
| set({ | ||
| schedulerOpen: true, | ||
| settingsOpen: false, | ||
| ...(wantsModal | ||
| ? { activeModal: "createScheduledTask", modalData: { ...prefill } } | ||
| : {}), | ||
| }); |
There was a problem hiding this comment.
Done in d008224 — openScheduler now opens the create dialog for any prefill object (including no-args /schedule, which previously opened only the page). /loop and /schedule pass a mode hint so the dialog opens in the correct type (cron vs wakeup).
| if (unit === "m") { | ||
| if (n >= 60) return null; | ||
| if (60 % n !== 0) return null; // odd intervals don't tile the hour | ||
| return `*/${n} * * * *`; | ||
| } |
There was a problem hiding this comment.
Done in d008224 — reworded the usage string and docs to state the actual rule (intervals that evenly divide their unit: minutes that divide an hour, hours that divide a day, or 1d) instead of an inaccurate fixed list.
| 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; |
There was a problem hiding this comment.
Done in d008224 — the /loop non-cron-interval fallback now opens the dialog in cron mode via the new mode hint (and openScheduler opens the modal for any prefill).
| * 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})?)?$/; |
There was a problem hiding this comment.
Done in d008224 — the leading timestamp is now parsed in local time by building the Date from components (a bare YYYY-MM-DD is no longer treated as UTC midnight), the space-separated YYYY-MM-DD HH:MM form is accepted as two tokens, and the time token no longer leaks into the prompt. Covered by new unit tests.
| 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(); |
There was a problem hiding this comment.
Done in d008224 — the leading timestamp is now parsed in local time by building the Date from components (a bare YYYY-MM-DD is no longer treated as UTC midnight), the space-separated YYYY-MM-DD HH:MM form is accepted as two tokens, and the time token no longer leaks into the prompt. Covered by new unit tests.
| setError(null); | ||
| try { | ||
| await deleteScheduledRoutine(id); | ||
| setScheduledTasks(scheduledTasks.filter((task) => task.id !== id)); |
There was a problem hiding this comment.
Done in d008224 — Delete and Run-now now reload the list from the backend instead of filtering a render-time snapshot, so a concurrent refresh cannot drop or reintroduce rows.
| "title": "Loops and Schedules", | ||
| "new_btn": "New scheduled task", | ||
| "refresh": "Refresh", | ||
| "loops_heading": "Loops", | ||
| "loops_empty": "No cron routines scheduled. Use /loop <interval> <prompt> in a chat, or click \"New scheduled task\".", | ||
| "schedules_heading": "Schedules", | ||
| "schedules_empty": "No one-shot wakeups scheduled.", | ||
| "run_now": "Run now", | ||
| "delete": "Delete", | ||
| "next_in": "next in {{time}}", | ||
| "last_fired": "last fired {{time}}", | ||
| "not_scheduled": "not scheduled", | ||
| "fire_at_unknown": "no fire time", | ||
| "schedule_unknown": "no schedule", | ||
| "mode_recurring": "recurring", | ||
| "mode_one_shot": "one-shot", | ||
| "modal_title": "New scheduled task", | ||
| "type_label": "Type", | ||
| "type_wakeup": "One-shot (wakeup)", | ||
| "type_cron": "Recurring (cron)", | ||
| "session_label": "Chat session", | ||
| "session_current": "Current session", | ||
| "session_pick": "Pick a session…", | ||
| "session_none_available": "No chat sessions available", | ||
| "fire_at_label": "Fire at", | ||
| "cron_expr_label": "Cron expression", | ||
| "cron_expr_hint": "Standard 5-field cron in local time (e.g. `0 9 * * 1-5`, `*/15 * * * *`).", | ||
| "name_label": "Name (optional)", | ||
| "recurring_label": "Recurring (runs every time the cron matches)", | ||
| "prompt_label": "Prompt", | ||
| "prompt_placeholder": "What should the agent do when this fires?", | ||
| "creating": "Scheduling…", | ||
| "create_btn": "Schedule", | ||
| "error_pick_session": "Pick a chat session.", | ||
| "error_empty_prompt": "Enter a prompt.", | ||
| "error_bad_time": "Enter a valid date and time.", | ||
| "error_bad_cron": "Enter a valid cron expression (5 fields)." |
There was a problem hiding this comment.
Done in d008224 — added real translations for the scheduler namespace and the chat Scheduled-badge keys across es, pt-BR, ja, zh-CN (and de).
| "title": "Loops and Schedules", | ||
| "new_btn": "New scheduled task", | ||
| "refresh": "Refresh", | ||
| "loops_heading": "Loops", | ||
| "loops_empty": "No cron routines scheduled. Use /loop <interval> <prompt> in a chat, or click \"New scheduled task\".", | ||
| "schedules_heading": "Schedules", | ||
| "schedules_empty": "No one-shot wakeups scheduled.", | ||
| "run_now": "Run now", | ||
| "delete": "Delete", | ||
| "next_in": "next in {{time}}", | ||
| "last_fired": "last fired {{time}}", | ||
| "not_scheduled": "not scheduled", | ||
| "fire_at_unknown": "no fire time", | ||
| "schedule_unknown": "no schedule", | ||
| "mode_recurring": "recurring", | ||
| "mode_one_shot": "one-shot", | ||
| "modal_title": "New scheduled task", | ||
| "type_label": "Type", | ||
| "type_wakeup": "One-shot (wakeup)", | ||
| "type_cron": "Recurring (cron)", | ||
| "session_label": "Chat session", | ||
| "session_current": "Current session", | ||
| "session_pick": "Pick a session…", | ||
| "session_none_available": "No chat sessions available", | ||
| "fire_at_label": "Fire at", | ||
| "cron_expr_label": "Cron expression", | ||
| "cron_expr_hint": "Standard 5-field cron in local time (e.g. `0 9 * * 1-5`, `*/15 * * * *`).", | ||
| "name_label": "Name (optional)", | ||
| "recurring_label": "Recurring (runs every time the cron matches)", | ||
| "prompt_label": "Prompt", | ||
| "prompt_placeholder": "What should the agent do when this fires?", | ||
| "creating": "Scheduling…", | ||
| "create_btn": "Schedule", | ||
| "error_pick_session": "Pick a chat session.", | ||
| "error_empty_prompt": "Enter a prompt.", | ||
| "error_bad_time": "Enter a valid date and time.", | ||
| "error_bad_cron": "Enter a valid cron expression (5 fields)." |
There was a problem hiding this comment.
Done in d008224 — added real translations for the scheduler namespace and the chat Scheduled-badge keys across es, pt-BR, ja, zh-CN (and de).
| "title": "Loops and Schedules", | ||
| "new_btn": "New scheduled task", | ||
| "refresh": "Refresh", | ||
| "loops_heading": "Loops", | ||
| "loops_empty": "No cron routines scheduled. Use /loop <interval> <prompt> in a chat, or click \"New scheduled task\".", | ||
| "schedules_heading": "Schedules", | ||
| "schedules_empty": "No one-shot wakeups scheduled.", | ||
| "run_now": "Run now", | ||
| "delete": "Delete", | ||
| "next_in": "next in {{time}}", | ||
| "last_fired": "last fired {{time}}", | ||
| "not_scheduled": "not scheduled", | ||
| "fire_at_unknown": "no fire time", | ||
| "schedule_unknown": "no schedule", | ||
| "mode_recurring": "recurring", | ||
| "mode_one_shot": "one-shot", | ||
| "modal_title": "New scheduled task", | ||
| "type_label": "Type", | ||
| "type_wakeup": "One-shot (wakeup)", | ||
| "type_cron": "Recurring (cron)", | ||
| "session_label": "Chat session", | ||
| "session_current": "Current session", | ||
| "session_pick": "Pick a session…", | ||
| "session_none_available": "No chat sessions available", | ||
| "fire_at_label": "Fire at", | ||
| "cron_expr_label": "Cron expression", | ||
| "cron_expr_hint": "Standard 5-field cron in local time (e.g. `0 9 * * 1-5`, `*/15 * * * *`).", | ||
| "name_label": "Name (optional)", | ||
| "recurring_label": "Recurring (runs every time the cron matches)", | ||
| "prompt_label": "Prompt", | ||
| "prompt_placeholder": "What should the agent do when this fires?", | ||
| "creating": "Scheduling…", | ||
| "create_btn": "Schedule", | ||
| "error_pick_session": "Pick a chat session.", | ||
| "error_empty_prompt": "Enter a prompt.", | ||
| "error_bad_time": "Enter a valid date and time.", | ||
| "error_bad_cron": "Enter a valid cron expression (5 fields)." |
There was a problem hiding this comment.
Done in d008224 — added real translations for the scheduler namespace and the chat Scheduled-badge keys across es, pt-BR, ja, zh-CN (and de).
| "title": "Loops and Schedules", | ||
| "new_btn": "New scheduled task", | ||
| "refresh": "Refresh", | ||
| "loops_heading": "Loops", | ||
| "loops_empty": "No cron routines scheduled. Use /loop <interval> <prompt> in a chat, or click \"New scheduled task\".", | ||
| "schedules_heading": "Schedules", | ||
| "schedules_empty": "No one-shot wakeups scheduled.", | ||
| "run_now": "Run now", | ||
| "delete": "Delete", | ||
| "next_in": "next in {{time}}", | ||
| "last_fired": "last fired {{time}}", | ||
| "not_scheduled": "not scheduled", | ||
| "fire_at_unknown": "no fire time", | ||
| "schedule_unknown": "no schedule", | ||
| "mode_recurring": "recurring", | ||
| "mode_one_shot": "one-shot", | ||
| "modal_title": "New scheduled task", | ||
| "type_label": "Type", | ||
| "type_wakeup": "One-shot (wakeup)", | ||
| "type_cron": "Recurring (cron)", | ||
| "session_label": "Chat session", | ||
| "session_current": "Current session", | ||
| "session_pick": "Pick a session…", | ||
| "session_none_available": "No chat sessions available", | ||
| "fire_at_label": "Fire at", | ||
| "cron_expr_label": "Cron expression", | ||
| "cron_expr_hint": "Standard 5-field cron in local time (e.g. `0 9 * * 1-5`, `*/15 * * * *`).", | ||
| "name_label": "Name (optional)", | ||
| "recurring_label": "Recurring (runs every time the cron matches)", | ||
| "prompt_label": "Prompt", | ||
| "prompt_placeholder": "What should the agent do when this fires?", | ||
| "creating": "Scheduling…", | ||
| "create_btn": "Schedule", | ||
| "error_pick_session": "Pick a chat session.", | ||
| "error_empty_prompt": "Enter a prompt.", | ||
| "error_bad_time": "Enter a valid date and time.", | ||
| "error_bad_cron": "Enter a valid cron expression (5 fields)." |
There was a problem hiding this comment.
Done in d008224 — added real translations for the scheduler namespace and the chat Scheduled-badge keys across es, pt-BR, ja, zh-CN (and de).
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 <interval> <prompt>` 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 [<when>] [<prompt>]` 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.
* feat(scheduler): expose scheduling tools to Codex and Pi agents The ScheduleWakeup / CronCreate / CronList / CronDelete tools previously reached only Claude agents, and only while the send_to_user "Agent Attachments" plugin was enabled — disabling that plugin silently killed agent scheduling. - Pi sidecar: register the four scheduling tools as native customTools. Pi has no MCP bridge, so each tool round-trips a generic host_tool message through the new PiSdkSession sink (a ChatBridgeSink), reusing the existing BridgePayload + agent_mcp_sink DB path — no new DB code. The host_tool channel is deliberately generic so a future user Pi extension needing host services can reuse it. - Codex + Claude: inject the Claudette MCP server unconditionally. It carries always-on scheduling tools, so the send_to_user toggle now gates only that tool's prompt nudge, not the whole server. - Pi system prompt: add PI_SCHEDULING_NUDGE (MCP-free wording) so Pi models know the native scheduling tools exist. * fix(scheduler): preserve Agent Attachments toggle and extend decoupling to Remote Control Codex peer-review findings on 6b13daf: 1. Unconditional MCP injection (the fix that gives Claude/Codex always-on scheduling) re-exposed the `send_to_user` tool when the Agent Attachments plugin is disabled, breaking the Settings contract that turning it off removes the tool. Gate the call inside the sink — at the point that actually does the delivery — instead of trying to manipulate `tools/list` in the out-of-process MCP grandchild. Returns a clear error so the agent can adjust. 2. `chat/remote_control.rs` had the same `if send_to_user_enabled { … } else { start_chat_bridge }` ternary as the spawn / respawn paths and was missed in 6b13daf. Without this, Remote Control sessions still lose scheduling when Agent Attachments is off — contradicting the doc claim that scheduling stays available regardless. Same unconditional injection now applies here too. * fix(scheduler): pin backend per task and persist schedule confirmation Two bugs surfaced after the agent-callable rollout: 1. `/loop` (and `/schedule`) created from a Codex or Pi chat fired on Claude when its turn came due. `dispatch_prompt_to_session` was calling `send_chat_message` with `backend_id: None`, and the resolver falls back to the `default_agent_backend` app setting (defaults to "anthropic") — not the user's toolbar choice. So the first cron fire in a fresh Codex chat looked like nothing fired at all. 2. The "Looping (`*/1 * * * *`)" / "Scheduled for …" confirmation was posted via `addLocalMessage({persisted:false})`, so it vanished on reload and the user had no chat-side evidence the schedule existed. Fix: - New migration adds nullable `backend_id` + `model` columns to `agent_scheduled_tasks`. Pre-migration rows and agent-callable scheduling stay `NULL` and keep the prior global-default behavior. - `/loop` and `/schedule` capture the toolbar's `selectedModelProvider` + `selectedModel` and persist them on the row. `dispatch_task_prompt` forwards both into `send_chat_message`. - The `schedule_wakeup` / `create_cron_routine` Tauri commands now write a System `ChatMessage` on success and emit `chat-system-message`, which the existing `useAgentStream` listener appends to chat state. One persisted note for all 3 backends; frontend `addLocalMessage` is reserved for failure messages only. * fix(scheduler): address Copilot review on #980 Three findings, all valid: - `post_creation_note` was writing `Utc::now().to_rfc3339()` while every other chat-message site uses `now_iso()` (Unix-seconds string). Mixing formats breaks the frontend's string-sort over `created_at`. - Stale comment above the unconditional MCP-bridge injection in `send_chat_message`'s spawn block claimed injection was gated on the `send_to_user` toggle. - `SYSTEM_PROMPT_NUDGE` bundled the scheduling/Monitor guidance with the `send_to_user` guidance, so disabling Agent Attachments stripped both — partially re-coupling scheduling discoverability to that toggle. Split into `MCP_SCHEDULING_NUDGE` (always on, mirroring the unconditional bridge) and `SEND_TO_USER_NUDGE` (toggle-gated), with a new `mcp_system_prompt_nudge()` helper composing them. Both call sites (`send.rs` and `remote_control.rs`) now use the helper; two new tests pin the toggle behavior.
…endering Three improvements to scheduled tasks, building on the agent-scheduling backend (agent_scheduled_tasks): 1. Fire-time fresh sessions. A scheduled task can now create a brand-new chat session in its target workspace each time it fires, instead of only reusing one existing session — so a recurring cron gets a clean session per run. chat_session_id becomes nullable and a create_new_session column is added (SQLite table-rebuild migration, reproducing all columns + indexes; existing rows stay reuse-mode). A typed ScheduleTarget enum (Session | NewSessionInWorkspace) is the single create chokepoint, so an inconsistent row can't be persisted; the new-session arm validates the workspace exists. Agent-facing MCP tools stay session-bound. 2. Cascading target picker. The New scheduled task dialog now selects a repository -> workspace -> session, lazily loading sessions for any workspace (not just the open one), with a "New session each run" option. The scheduler page shows each task's target workspace. 3. Fired prompt is visible. The triggering prompt now renders as a user message tagged with a "Scheduled" badge — chat_messages gains a scheduled_task_id column threaded through send_chat_message, and the dispatch emits a chat-message (after the send succeeds) plus a chat-session-created event for new-session fires so the tab appears. This also fixes the same latent invisible-prompt bug on the agent Monitor dispatch path. A failed new-session fire archives the empty session it created so failing crons don't accrete blank tabs.
8d8d042 to
0a0d93b
Compare
| function formatCountdown(iso: string | null, now: number): string { | ||
| if (!iso) return "—"; | ||
| const target = new Date(iso).getTime(); | ||
| if (Number.isNaN(target)) return iso; | ||
| let delta = Math.round((target - now) / 1000); | ||
| if (delta <= 0) return "due now"; | ||
| const d = Math.floor(delta / 86400); |
There was a problem hiding this comment.
Done in d008224 — formatCountdown now returns 0s for due/overdue times, so the label reads "next in 0s" rather than the ungrammatical "next in due now".
| const onDelete = async (id: string) => { | ||
| setBusyId(id); | ||
| setError(null); | ||
| try { | ||
| await deleteScheduledRoutine(id); | ||
| setScheduledTasks(scheduledTasks.filter((task) => task.id !== id)); | ||
| } catch (e) { | ||
| setError(String(e)); | ||
| } finally { | ||
| setBusyId(null); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Done in d008224 — Delete and Run-now now reload the list from the backend instead of filtering a render-time snapshot, so a concurrent refresh cannot drop or reintroduce rows.
| 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 |
There was a problem hiding this comment.
Done in d008224 — the leading timestamp is now parsed in local time by building the Date from components (a bare YYYY-MM-DD is no longer treated as UTC midnight), the space-separated YYYY-MM-DD HH:MM form is accepted as two tokens, and the time token no longer leaks into the prompt. Covered by new unit tests.
Addresses the Copilot review on #975: - openScheduler now opens the create dialog for ANY prefill (incl. no-args /schedule, which previously opened only the page). /loop's non-cron-interval fallback and /schedule pass a mode hint so the dialog opens in the right type (cron vs wakeup). - /schedule timestamp parsing is local-time-safe: build the Date from components (a bare YYYY-MM-DD is no longer parsed as UTC midnight), accept "YYYY-MM-DD HH:MM" as two tokens, and stop leaking the time token into the prompt. - /loop interval rule + docs now agree (intervals that evenly divide their unit); dropped the bogus 20m wording mismatch... (rule, not a fixed list). - SchedulerPage: countdown returns "0s" instead of "due now" (no more "next in due now"); Run-now and Delete reload from the backend instead of mutating a render-time snapshot; each row now shows its target session name (reuse mode) or "new session each run". - Settings -> Automation reads the shared schedulingSlice, so it and the Loops and Schedules view stay in sync. - Real es/pt-BR/ja/zh-CN/de translations for the scheduler namespace and the chat "Scheduled" badge keys (were English placeholders). - Add Vitest coverage for /loop (interval→cron + fallback) and /schedule (inline local-time wakeup, two-token time, bare date, no-args/no-prompt dialog).
|
Incorporated the Copilot review in d008224 (every comment addressed — all were valid):
Verification: |

Summary
Adds a user-facing UI on top of the existing agent-scheduling backend (
agent_scheduled_tasks). Until now, scheduled wakeups and cron routines could only be created by the agent (viaScheduleWakeup/CronCreatetool calls) or theclaudette routineCLI, and the only GUI surface was a read-only list under Settings → Automation. This branch makes them first-class in the app: a dedicated sidebar-accessible view, a creation dialog, and two slash commands — all reusing the same Tauri commands, DB table, and Rust scheduler that already shipped, so there is no parallel schema.PanelHeader+PanelToggleschrome in place. Click the clock again, the dashboard icon, or a workspace to leave it.kind: cron) and Schedules (kind: wakeup) — each row showing the human-readable schedule / fire time, target session, next-fire countdown, with Run now and Delete actions./loop <interval> <prompt>translates whole-minute intervals that evenly tile their unit (1m/2m/5m/10m/15m/20m/30m, 1h/2h/3h/4h/6h/8h/12h, 1d) into 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 [<when>] [<prompt>]fires a wakeup inline when a leadingYYYY-MM-DD[THH:MM]timestamp and a prompt are both present; otherwise opens the modal prefilled with whatever was provided.The list is shared (via a new
schedulingSlice) with Settings → Automation, so tasks created from the page or a slash command appear in both places.flowchart LR subgraph Create A["/loop, /schedule\n(nativeSlashCommands)"] B["New scheduled task modal"] end A -->|"scheduleWakeup /\ncreateCronRoutine"| C["Tauri commands\n(main's commands::scheduling)"] B -->|"scheduleWakeup /\ncreateCronRoutine"| C C --> D[("agent_scheduled_tasks")] D --> E["list_scheduled_routines"] E --> F["schedulingSlice.scheduledTasks"] F --> G["Loops and Schedules page"] F --> H["Settings -> Automation"] D --> I["Rust scheduler (start_scheduler)\nfires via send_chat_message"]Complexity Notes
scheduled_jobstable. As a result, a few semantics from the first design are intentionally not present becauseagent_scheduled_taskscan't express them:/loophas no iteration cap and no sub-minute intervals; there are no tri-state composer-toggle overrides; and/scheduletargets an existing chat session rather than spinning up a fresh workspace per fire. The original implementation is preserved on thescheduler-original-impltag for reference./loopinterval → cron translation (intervalToCroninnativeSlashCommands.ts) only accepts intervals that evenly divide their unit; everything else falls through to the modal. Worth a sanity check on the whitelist.selectWorkspace/selectRepository/goToDashboardinworkspacesSlicenow also clearschedulerOpen(with their no-op guards updated), and the clock button is a toggle. This is how you exit the view without a close button.slash_commands.rsregistry entries — the scheduler service, DB layer, and Tauri commands are all main's.Test Steps
cd src/ui && bun install && bunx tsc -b && bun run test && bun run lint:css && bun run build— all green.cargo fmt --all --check && cargo clippy -p claudette -p claudette-server -p claudette-cli --all-targets --all-features && cargo test -p claudette --all-features— all green../scripts/dev.sh). Click the clock icon next to the dashboard icon — the Loops and Schedules view opens with the sidebar + header still present. Click it again to toggle back./loop 5m say hi→ a confirmation message appears; the loop shows under Loops with a human schedule ("every 5 minutes") and a next-fire countdown. Run/loop 37s x→ the New scheduled task modal opens instead (odd interval)./schedule 2026-06-01T09:00 cut the release→ a wakeup appears under Schedules with the fire time + countdown. Run/schedulewith no args → modal opens.Checklist
site/.../features/agent-scheduling.mdx(new "Loops and Schedules view" + slash-command sections) andslash-commands.mdxrows for/loopand/schedule