From 0c751f46a3399092a018613100587bcf9cb8d431 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 30 May 2026 21:44:43 -0700
Subject: [PATCH] fix: address review findings (#3983)
---
.../automations/AutomationCustomCronPanel.tsx | 148 ++++++++++++++++++
.../AutomationSchedulePicker.test.ts | 102 ++++++++++++
.../automations/AutomationSchedulePicker.tsx | 100 ++++++------
.../automations/AutomationsPage.tsx | 17 +-
src/shared/automation-schedules.test.ts | 14 ++
src/shared/automation-schedules.ts | 21 +++
6 files changed, 342 insertions(+), 60 deletions(-)
create mode 100644 src/renderer/src/components/automations/AutomationCustomCronPanel.tsx
create mode 100644 src/renderer/src/components/automations/AutomationSchedulePicker.test.ts
diff --git a/src/renderer/src/components/automations/AutomationCustomCronPanel.tsx b/src/renderer/src/components/automations/AutomationCustomCronPanel.tsx
new file mode 100644
index 000000000..64c7554ff
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationCustomCronPanel.tsx
@@ -0,0 +1,148 @@
+import React from 'react'
+import { CheckCircle2, CircleAlert, RotateCcw } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { cn } from '@/lib/utils'
+import { formatAutomationSchedule } from '../../../../shared/automation-schedules'
+import type { AutomationDraft } from './AutomationEditorDialog'
+import { Field } from './automation-page-parts'
+
+const FIELD_CONTROL_CLASS = 'border-input bg-input/30 shadow-xs dark:bg-input/30'
+
+export const AUTOMATION_CRON_QUICK_STARTS = [
+ { label: 'Every 15 min', expression: '*/15 * * * *' },
+ { label: 'Hourly workday', expression: '0 9-17 * * 1-5' },
+ { label: 'Weekdays 9 AM', expression: '0 9 * * 1-5' },
+ { label: 'Monthly audit', expression: '0 9 1 * *' }
+] as const
+
+export const AUTOMATION_CRON_FIELD_LABELS = ['Minute', 'Hour', 'Day', 'Month', 'Weekday'] as const
+
+export function getCronScheduleStatusLabel(
+ schedule: string,
+ validateSchedule: (schedule: string) => boolean
+): { kind: 'empty' | 'invalid' | 'valid'; label: string } {
+ const trimmed = schedule.trim()
+ if (!trimmed) {
+ return { kind: 'empty', label: 'Choose a quick start or enter a five-field cron.' }
+ }
+ if (!validateSchedule(trimmed)) {
+ return { kind: 'invalid', label: 'Enter a valid five-field cron before saving.' }
+ }
+ const formatted = formatAutomationSchedule(trimmed)
+ return { kind: 'valid', label: formatted === 'Custom schedule' ? 'Valid custom cron' : formatted }
+}
+
+export function getCronFieldValues(schedule: string): readonly string[] {
+ const parts = schedule.trim().split(/\s+/).filter(Boolean)
+ return AUTOMATION_CRON_FIELD_LABELS.map((_, index) => parts[index] ?? '...')
+}
+
+export function AutomationCustomCronPanel({
+ draft,
+ customScheduleInvalid,
+ validateAdvancedSchedule,
+ onUseSimpleSchedule,
+ onDraftChange
+}: {
+ draft: AutomationDraft
+ customScheduleInvalid: boolean
+ validateAdvancedSchedule: (schedule: string) => boolean
+ onUseSimpleSchedule: () => void
+ onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
+}): React.JSX.Element {
+ const customScheduleStatus = getCronScheduleStatusLabel(
+ draft.customSchedule,
+ validateAdvancedSchedule
+ )
+ const cronFieldValues = getCronFieldValues(draft.customSchedule)
+
+ return (
+
+
+
+
Quick starts
+
+
+
+ {AUTOMATION_CRON_QUICK_STARTS.map((preset) => (
+
+ ))}
+
+
+
+
+ onDraftChange((current) => ({
+ ...current,
+ customSchedule: event.target.value,
+ scheduleWarning: null
+ }))
+ }
+ />
+
+ {AUTOMATION_CRON_FIELD_LABELS.map((label, index) => (
+
+
{label}
+
+ {cronFieldValues[index]}
+
+
+ ))}
+
+
+ {customScheduleStatus.kind === 'invalid' ? (
+
+ ) : (
+
+ )}
+ {customScheduleStatus.label}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts b/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts
new file mode 100644
index 000000000..ffcd5a3cb
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts
@@ -0,0 +1,102 @@
+import React from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+import type { AutomationDraft } from './AutomationEditorDialog'
+import {
+ AUTOMATION_CRON_QUICK_STARTS,
+ AutomationCustomCronPanel,
+ getCronFieldValues,
+ getCronScheduleStatusLabel
+} from './AutomationCustomCronPanel'
+import {
+ AUTOMATION_SCHEDULE_PRESET_OPTIONS,
+ getSchedulePresetDraft
+} from './AutomationSchedulePicker'
+import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedules'
+
+const BASE_DRAFT: AutomationDraft = {
+ name: '',
+ prompt: '',
+ agentId: 'codex',
+ projectId: '',
+ workspaceMode: 'existing',
+ workspaceId: '',
+ baseBranch: '',
+ reuseSession: false,
+ preset: 'weekdays',
+ time: '09:15',
+ dayOfWeek: '1',
+ customSchedule: '',
+ missedRunGraceMinutes: '720',
+ scheduleWarning: null
+}
+
+describe('AutomationSchedulePicker', () => {
+ it('offers custom cron as a selectable cadence', () => {
+ expect(AUTOMATION_SCHEDULE_PRESET_OPTIONS).toContainEqual(['custom', 'Custom cron'])
+ })
+
+ it('includes quick starts that are valid cron schedules', () => {
+ expect(AUTOMATION_CRON_QUICK_STARTS.length).toBeGreaterThan(0)
+ expect(
+ AUTOMATION_CRON_QUICK_STARTS.every((preset) =>
+ isValidAutomationCronSchedule(preset.expression)
+ )
+ ).toBe(true)
+ })
+
+ it('seeds custom cron from the current simple schedule', () => {
+ expect(getSchedulePresetDraft(BASE_DRAFT, 'custom')).toMatchObject({
+ preset: 'custom',
+ customSchedule: '15 9 * * 1-5',
+ scheduleWarning: null
+ })
+ })
+
+ it('preserves an existing custom cron when toggling back to custom', () => {
+ expect(
+ getSchedulePresetDraft({ ...BASE_DRAFT, customSchedule: '*/30 9-17 * * 1-5' }, 'custom')
+ ).toMatchObject({
+ preset: 'custom',
+ customSchedule: '*/30 9-17 * * 1-5'
+ })
+ })
+
+ it('summarizes custom cron validity for the inline status row', () => {
+ expect(getCronScheduleStatusLabel('', isValidAutomationCronSchedule)).toEqual({
+ kind: 'empty',
+ label: 'Choose a quick start or enter a five-field cron.'
+ })
+ expect(getCronScheduleStatusLabel('not cron', isValidAutomationCronSchedule)).toEqual({
+ kind: 'invalid',
+ label: 'Enter a valid five-field cron before saving.'
+ })
+ expect(getCronScheduleStatusLabel('0 9 * * 1-5', isValidAutomationCronSchedule)).toMatchObject({
+ kind: 'valid'
+ })
+ })
+
+ it('splits cron expressions into labeled field values', () => {
+ expect(getCronFieldValues('0 9 * * 1-5')).toEqual(['0', '9', '*', '*', '1-5'])
+ expect(getCronFieldValues('0 9')).toEqual(['0', '9', '...', '...', '...'])
+ })
+
+ it('renders quick starts beside the cron expression field', () => {
+ const markup = renderToStaticMarkup(
+ React.createElement(AutomationCustomCronPanel, {
+ draft: { ...BASE_DRAFT, preset: 'custom', customSchedule: '0 9 * * 1-5' },
+ customScheduleInvalid: false,
+ validateAdvancedSchedule: isValidAutomationCronSchedule,
+ onUseSimpleSchedule: () => undefined,
+ onDraftChange: () => undefined
+ })
+ )
+
+ expect(markup).toContain('Quick starts')
+ expect(markup).toContain('Every 15 min')
+ expect(markup).toContain('Cron expression')
+ expect(markup).toContain('Minute')
+ expect(markup).toContain('Weekday')
+ expect(markup).toContain('automation-cron-status')
+ })
+})
diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.tsx b/src/renderer/src/components/automations/AutomationSchedulePicker.tsx
index 7e5c72f19..b45bce35c 100644
--- a/src/renderer/src/components/automations/AutomationSchedulePicker.tsx
+++ b/src/renderer/src/components/automations/AutomationSchedulePicker.tsx
@@ -1,7 +1,6 @@
import React from 'react'
import { CalendarClock, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import {
@@ -13,23 +12,25 @@ import {
} from '@/components/ui/select'
import type { AutomationSchedulePreset } from '../../../../shared/automations-types'
import {
+ buildAutomationCronSchedule,
buildAutomationRrule,
classifyAutomationCronSchedule,
formatAutomationSchedule,
isValidAutomationSchedule
} from '../../../../shared/automation-schedules'
import type { AutomationDraft } from './AutomationEditorDialog'
+import { AutomationCustomCronPanel } from './AutomationCustomCronPanel'
import { Field } from './automation-page-parts'
const FIELD_CONTROL_CLASS = 'border-input bg-input/30 shadow-xs dark:bg-input/30'
-type SimpleSchedulePreset = Exclude
-const SIMPLE_PRESETS = [
+export const AUTOMATION_SCHEDULE_PRESET_OPTIONS = [
['hourly', 'Hourly'],
['daily', 'Daily'],
['weekdays', 'Weekdays'],
- ['weekly', 'Weekly']
-] as const
+ ['weekly', 'Weekly'],
+ ['custom', 'Custom cron']
+] as const satisfies readonly [AutomationSchedulePreset, string][]
const DAY_OPTIONS = [
['0', 'Sunday'],
@@ -130,6 +131,34 @@ function getSimpleScheduleDraft(
return { preset: 'weekdays', time: current.time, dayOfWeek: current.dayOfWeek || '1' }
}
+function buildCustomScheduleSeed(draft: AutomationDraft): string {
+ const existing = draft.customSchedule.trim()
+ if (existing) {
+ return draft.customSchedule
+ }
+ if (draft.preset === 'custom') {
+ return ''
+ }
+ const { hour, minute } = parseTime(draft.time)
+ return buildAutomationCronSchedule({
+ preset: draft.preset,
+ hour,
+ minute,
+ dayOfWeek: Number(draft.dayOfWeek)
+ })
+}
+
+export function getSchedulePresetDraft(
+ current: AutomationDraft,
+ preset: AutomationSchedulePreset
+): Pick {
+ return {
+ preset,
+ customSchedule: preset === 'custom' ? buildCustomScheduleSeed(current) : current.customSchedule,
+ scheduleWarning: null
+ }
+}
+
export function AutomationSchedulePicker({
draft,
triggerClassName,
@@ -169,51 +198,23 @@ export function AutomationSchedulePicker({
{draft.preset === 'custom' ? (
-
-
-
- onDraftChange((current) => ({
- ...current,
- customSchedule: event.target.value,
- scheduleWarning: null
- }))
- }
- />
-
- Existing advanced schedules are preserved until you choose a simple schedule.
-
- {customScheduleInvalid ? (
-
- Enter a valid advanced schedule before saving.
-
- ) : null}
-
-
-
+
+ onDraftChange((current) => ({
+ ...current,
+ ...getSimpleScheduleDraft(current),
+ scheduleWarning: null
+ }))
+ }
+ />
) : (
<>
@@ -222,8 +223,7 @@ export function AutomationSchedulePicker({
onValueChange={(preset) =>
onDraftChange((current) => ({
...current,
- preset: preset as SimpleSchedulePreset,
- scheduleWarning: null
+ ...getSchedulePresetDraft(current, preset as AutomationSchedulePreset)
}))
}
>
@@ -231,7 +231,7 @@ export function AutomationSchedulePicker({
- {SIMPLE_PRESETS.map(([value, presetLabel]) => (
+ {AUTOMATION_SCHEDULE_PRESET_OPTIONS.map(([value, presetLabel]) => (
{presetLabel}
diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx
index 8aee616f3..bc3a58b20 100644
--- a/src/renderer/src/components/automations/AutomationsPage.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.tsx
@@ -54,6 +54,7 @@ import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import type { Worktree } from '../../../../shared/types'
import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id'
import {
+ buildAutomationCronSchedule,
buildAutomationRrule,
formatAutomationSchedule,
isValidAutomationCronSchedule,
@@ -136,16 +137,12 @@ function buildHermesCronSchedule(draft: AutomationDraft): string {
return draft.customSchedule.trim()
}
const { hour, minute } = parseDraftTime(draft.time)
- if (draft.preset === 'hourly') {
- return `${minute} * * * *`
- }
- if (draft.preset === 'daily') {
- return `${minute} ${hour} * * *`
- }
- if (draft.preset === 'weekdays') {
- return `${minute} ${hour} * * 1-5`
- }
- return `${minute} ${hour} * * ${Number(draft.dayOfWeek)}`
+ return buildAutomationCronSchedule({
+ preset: draft.preset,
+ hour,
+ minute,
+ dayOfWeek: Number(draft.dayOfWeek)
+ })
}
function getAgentLabel(agentId: string): string {
diff --git a/src/shared/automation-schedules.test.ts b/src/shared/automation-schedules.test.ts
index 027c59fe5..f6f269a00 100644
--- a/src/shared/automation-schedules.test.ts
+++ b/src/shared/automation-schedules.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
+ buildAutomationCronSchedule,
buildAutomationRrule,
classifyAutomationCronSchedule,
formatAutomationSchedule,
@@ -94,6 +95,19 @@ describe('automation schedules', () => {
expect(latest).toBe(new Date('2026-05-15T10:15:00').getTime())
})
+ it('builds cron schedules from simple automation presets', () => {
+ expect(buildAutomationCronSchedule({ preset: 'hourly', hour: 9, minute: 15 })).toBe(
+ '15 * * * *'
+ )
+ expect(buildAutomationCronSchedule({ preset: 'daily', hour: 9, minute: 15 })).toBe('15 9 * * *')
+ expect(buildAutomationCronSchedule({ preset: 'weekdays', hour: 9, minute: 15 })).toBe(
+ '15 9 * * 1-5'
+ )
+ expect(
+ buildAutomationCronSchedule({ preset: 'weekly', hour: 9, minute: 15, dayOfWeek: 0 })
+ ).toBe('15 9 * * 0')
+ })
+
it('formats simple cron schedules with friendly labels', () => {
expect(formatAutomationSchedule('5 * * * *')).toBe('Hourly at :05')
expect(formatAutomationSchedule('15 10 * * *')).toBe(`Daily at ${formatTimeForTest(10, 15)}`)
diff --git a/src/shared/automation-schedules.ts b/src/shared/automation-schedules.ts
index 7eb90c352..5a477163d 100644
--- a/src/shared/automation-schedules.ts
+++ b/src/shared/automation-schedules.ts
@@ -490,6 +490,27 @@ export function buildAutomationRrule(args: {
return `FREQ=DAILY;BYHOUR=${hour};BYMINUTE=${minute}`
}
+export function buildAutomationCronSchedule(args: {
+ preset: Exclude
+ hour: number
+ minute: number
+ dayOfWeek?: number
+}): string {
+ const hour = Math.max(0, Math.min(23, Math.floor(args.hour)))
+ const minute = Math.max(0, Math.min(59, Math.floor(args.minute)))
+ if (args.preset === 'hourly') {
+ return `${minute} * * * *`
+ }
+ if (args.preset === 'weekdays') {
+ return `${minute} ${hour} * * 1-5`
+ }
+ if (args.preset === 'weekly') {
+ const day = Math.max(0, Math.min(6, Math.floor(args.dayOfWeek ?? 1)))
+ return `${minute} ${hour} * * ${day}`
+ }
+ return `${minute} ${hour} * * *`
+}
+
export function nextAutomationOccurrenceAfter(
rrule: string,
dtstart: number,