import * as React from 'react'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; import { NumberInput } from '@/components/ui/number-input'; import { Button } from '@/components/ui/button'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { toast } from '@/components/ui'; import { ModelSelector } from '@/components/sections/agents/ModelSelector'; import { AgentSelector } from '@/components/sections/commands/AgentSelector'; import { isPrimaryMode } from '@/components/chat/mobileControlsUtils'; import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from '@/components/chat/CommandAutocomplete'; import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete'; import { SnippetAutocomplete, type SnippetAutocompleteHandle } from '@/components/chat/SnippetAutocomplete'; import { Icon } from "@/components/icon/Icon"; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import type { ScheduledTask } from '@/lib/scheduledTasksApi'; import { useI18n } from '@/lib/i18n'; import { isValidCronExpression, getNextRuns, CRON_EXAMPLES } from '@/lib/cron'; import { canonicalizeTimezone } from '@/lib/timezones'; const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const; // Mirrors the composer's footer icon toggles (PermissionAutoAcceptButton / // SessionGoalButton) so the state reads the same in both places. const EDITOR_TOGGLE_BUTTON_CLASS = 'flex h-6 w-6 cursor-pointer items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0'; const TIMEZONE_OPTIONS = (() => { if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') { return [...new Set(Intl.supportedValuesOf('timeZone').map(canonicalizeTimezone))]; } return [ 'UTC', 'Europe/Kyiv', 'Europe/London', 'Europe/Berlin', 'America/New_York', 'America/Los_Angeles', 'Asia/Tokyo', ]; })(); const getLocalDateISO = () => { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; const parseISODateToLocal = (value: string): Date | null => { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) { return null; } const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); const date = new Date(year, month - 1, day); if ( Number.isNaN(date.getTime()) || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day ) { return null; } return date; }; const formatLocalDateISO = (date: Date): string => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; const formatDateLabel = (isoDate: string, fallbackLabel: string, locale: string): string => { const date = parseISODateToLocal(isoDate); if (!date) { return fallbackLabel; } return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', year: 'numeric', }).format(date); }; const shiftMonth = (date: Date, delta: number): Date => { return new Date(date.getFullYear(), date.getMonth() + delta, 1); }; const getCalendarCells = (monthDate: Date, weekStartsOn: number): Array<{ date: Date; inCurrentMonth: boolean }> => { const year = monthDate.getFullYear(); const month = monthDate.getMonth(); const firstDay = new Date(year, month, 1); const firstWeekday = firstDay.getDay(); const leadDays = (firstWeekday - weekStartsOn + 7) % 7; const daysInMonth = new Date(year, month + 1, 0).getDate(); const daysInPrevMonth = new Date(year, month, 0).getDate(); const cells: Array<{ date: Date; inCurrentMonth: boolean }> = []; for (let index = 0; index < 42; index += 1) { const dayOffset = index - leadDays + 1; if (dayOffset <= 0) { const day = daysInPrevMonth + dayOffset; cells.push({ date: new Date(year, month - 1, day), inCurrentMonth: false }); continue; } if (dayOffset > daysInMonth) { cells.push({ date: new Date(year, month + 1, dayOffset - daysInMonth), inCurrentMonth: false }); continue; } cells.push({ date: new Date(year, month, dayOffset), inCurrentMonth: true }); } return cells; }; const parse24hTime = (value: string): { hour24: string; hour12: string; minute: string; meridiem: 'AM' | 'PM' } => { const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value); if (!match) { return { hour24: '00', hour12: '12', minute: '00', meridiem: 'AM' }; } const hour24 = Number(match[1]); const minute = match[2]; const meridiem = hour24 >= 12 ? 'PM' : 'AM'; const rawHour12 = hour24 % 12 === 0 ? 12 : hour24 % 12; return { hour24: String(hour24).padStart(2, '0'), hour12: String(rawHour12).padStart(2, '0'), minute, meridiem, }; }; const to24hTime = (hour12: string, minute: string, meridiem: 'AM' | 'PM'): string => { const hourNumRaw = Number(hour12); const minuteNumRaw = Number(minute); const hourNum = Number.isFinite(hourNumRaw) ? Math.min(12, Math.max(1, hourNumRaw)) : 12; const minuteNum = Number.isFinite(minuteNumRaw) ? Math.min(59, Math.max(0, minuteNumRaw)) : 0; let hour24 = hourNum % 12; if (meridiem === 'PM') { hour24 += 12; } return `${String(hour24).padStart(2, '0')}:${String(minuteNum).padStart(2, '0')}`; }; const getValidNumber = (value: string, config: { max: number; min?: number; loop?: boolean }) => { const { max, min = 0, loop = false } = config; let numericValue = Number.parseInt(value, 10); if (Number.isFinite(numericValue)) { if (!loop) { if (numericValue > max) { numericValue = max; } if (numericValue < min) { numericValue = min; } } else { if (numericValue > max) { numericValue = min; } if (numericValue < min) { numericValue = max; } } return String(numericValue).padStart(2, '0'); } return '00'; }; const getValid12Hour = (value: string) => { if (/^(0[1-9]|1[0-2])$/.test(value)) { return value; } return getValidNumber(value, { min: 1, max: 12 }); }; const getValidMinute = (value: string) => { if (/^[0-5][0-9]$/.test(value)) { return value; } return getValidNumber(value, { max: 59 }); }; const getArrowHour = (value: string, step: number) => { return getValidNumber(String(Number.parseInt(value, 10) + step), { min: 1, max: 12, loop: true }); }; const getArrowMinute = (value: string, step: number) => { return getValidNumber(String(Number.parseInt(value, 10) + step), { min: 0, max: 59, loop: true }); }; const getWeekStartsOn = (locale: string): number => { try { const localeApi = (Intl as unknown as { Locale?: new (tag: string) => { weekInfo?: { firstDay?: number } }; }).Locale; if (typeof localeApi !== 'function') { return 1; } const weekInfo = new localeApi(locale).weekInfo; const firstDayRaw = weekInfo?.firstDay; if (typeof firstDayRaw !== 'number') { return 1; } return firstDayRaw % 7; } catch { return 1; } }; const getUses24Hour = (locale: string): boolean => { try { const options = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions(); if (typeof options.hour12 === 'boolean') { return !options.hour12; } return options.hourCycle === 'h23' || options.hourCycle === 'h24'; } catch { return true; } }; const getLocalizedWeekdayLabels = (locale: string): string[] => { const formatter = new Intl.DateTimeFormat(locale, { weekday: 'short' }); const sundayBase = new Date(2023, 0, 1); return WEEKDAY_INDEXES.map((offset) => formatter.format(new Date(sundayBase.getFullYear(), sundayBase.getMonth(), sundayBase.getDate() + offset))); }; const rotateWeekdays = (items: T[], weekStartsOn: number): T[] => { return [...items.slice(weekStartsOn), ...items.slice(0, weekStartsOn)]; }; interface TimePillProps { value: string; onChange: (next: string) => void; use24Hour: boolean; hourAriaLabel: string; minuteAriaLabel: string; periodAriaLabel: string; amLabel: string; pmLabel: string; } const FieldLabel: React.FC<{ htmlFor?: string; required?: boolean; children: React.ReactNode; }> = ({ htmlFor, required, children }) => (
); const TimePill: React.FC = ({ value, onChange, use24Hour, hourAriaLabel, minuteAriaLabel, periodAriaLabel, amLabel, pmLabel, }) => { const parts = React.useMemo(() => parse24hTime(value), [value]); const hourRef = React.useRef(null); const minuteRef = React.useRef(null); const [hourDraft, setHourDraftState] = React.useState(null); const [minuteDraft, setMinuteDraftState] = React.useState(null); const hourDraftRef = React.useRef(null); const minuteDraftRef = React.useRef(null); const setHourDraft = React.useCallback((next: string | null) => { hourDraftRef.current = next; setHourDraftState(next); }, []); const setMinuteDraft = React.useCallback((next: string | null) => { minuteDraftRef.current = next; setMinuteDraftState(next); }, []); const getValid24Hour = (hour: string) => getValidNumber(hour, { min: 0, max: 23 }); const getArrow24Hour = (hour: string, step: number) => getValidNumber(String(Number.parseInt(hour, 10) + step), { min: 0, max: 23, loop: true, }); const to24hFrom24Hour = (hour24: string, minute: string) => { const hourNumRaw = Number(hour24); const minuteNumRaw = Number(minute); const hourNum = Number.isFinite(hourNumRaw) ? Math.min(23, Math.max(0, hourNumRaw)) : 0; const minuteNum = Number.isFinite(minuteNumRaw) ? Math.min(59, Math.max(0, minuteNumRaw)) : 0; return `${String(hourNum).padStart(2, '0')}:${String(minuteNum).padStart(2, '0')}`; }; const onHourChange = (raw: string) => { const digits = raw.replace(/\D/g, '').slice(0, 2); setHourDraft(digits); if (digits.length === 2) { if (use24Hour) { onChange(to24hFrom24Hour(getValid24Hour(digits), parts.minute)); } else { onChange(to24hTime(getValid12Hour(digits), parts.minute, parts.meridiem)); } setHourDraft(null); minuteRef.current?.focus(); } }; const commitHour = () => { const digits = hourDraftRef.current; if (digits === null) return; setHourDraft(null); if (digits.length === 0) return; if (use24Hour) { onChange(to24hFrom24Hour(getValid24Hour(digits.padStart(2, '0')), parts.minute)); return; } onChange(to24hTime(getValid12Hour(digits.padStart(2, '0')), parts.minute, parts.meridiem)); }; const onMinuteChange = (raw: string) => { const digits = raw.replace(/\D/g, '').slice(0, 2); setMinuteDraft(digits); if (digits.length === 2) { onChange(use24Hour ? to24hFrom24Hour(parts.hour24, getValidMinute(digits)) : to24hTime(parts.hour12, getValidMinute(digits), parts.meridiem)); setMinuteDraft(null); } }; const commitMinute = () => { const digits = minuteDraftRef.current; if (digits === null) return; setMinuteDraft(null); if (digits.length === 0) return; onChange(use24Hour ? to24hFrom24Hour(parts.hour24, getValidMinute(digits.padStart(2, '0'))) : to24hTime(parts.hour12, getValidMinute(digits.padStart(2, '0')), parts.meridiem)); }; const stepHour = (step: number) => onChange(use24Hour ? to24hFrom24Hour(getArrow24Hour(parts.hour24, step), parts.minute) : to24hTime(getArrowHour(parts.hour12, step), parts.minute, parts.meridiem)); const stepMinute = (step: number) => onChange(use24Hour ? to24hFrom24Hour(parts.hour24, getArrowMinute(parts.minute, step)) : to24hTime(parts.hour12, getArrowMinute(parts.minute, step), parts.meridiem)); const setPeriod = (next: 'AM' | 'PM') => { if (next !== parts.meridiem) { onChange(to24hTime(parts.hour12, parts.minute, next)); } }; const onHourKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'ArrowRight') { event.preventDefault(); commitHour(); minuteRef.current?.focus(); return; } if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); setHourDraft(null); stepHour(event.key === 'ArrowUp' ? 1 : -1); } }; const onMinuteKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'ArrowLeft' && (event.currentTarget.selectionStart ?? 0) === 0) { event.preventDefault(); commitMinute(); hourRef.current?.focus(); return; } if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); setMinuteDraft(null); stepMinute(event.key === 'ArrowUp' ? 1 : -1); } }; return (
onHourChange(event.target.value)} onKeyDown={onHourKeyDown} onFocus={() => setHourDraft('')} onBlur={commitHour} maxLength={2} aria-label={hourAriaLabel} className="h-7 w-7 shrink-0 rounded-sm bg-transparent text-center font-mono text-sm tabular-nums text-foreground outline-none caret-transparent focus:bg-interactive-hover" /> : onMinuteChange(event.target.value)} onKeyDown={onMinuteKeyDown} onFocus={() => setMinuteDraft('')} onBlur={commitMinute} maxLength={2} aria-label={minuteAriaLabel} className="h-7 w-7 shrink-0 rounded-sm bg-transparent text-center font-mono text-sm tabular-nums text-foreground outline-none caret-transparent focus:bg-interactive-hover" /> {!use24Hour ? ( ) : null}
); }; const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1); const startOfToday = () => { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate()); }; const isSameCalendarDay = (a: Date, b: Date) => ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); type ScheduledTaskDraft = { id?: string; name: string; enabled: boolean; schedule: { kind: 'daily' | 'weekly' | 'once' | 'cron'; times: string[]; onceDate: string; onceTime: string; weekdays: number[]; timezone: string; cronExpression: string; }; execution: { prompt: string; providerID: string; modelID: string; variant: string; agent: string; goalEnabled: boolean; goalTokenBudget: number | null; permissionAutoAccept: boolean; }; state?: ScheduledTask['state']; }; const normalizeDraftTimes = (task: ScheduledTask | null): string[] => { if (!task) { return ['09:00']; } const candidates = Array.isArray(task.schedule.times) ? task.schedule.times : (task.schedule.time ? [task.schedule.time] : []); const valid = candidates .filter((value) => typeof value === 'string' && /^([01]\d|2[0-3]):([0-5]\d)$/.test(value)) .map((value) => value.trim()); const unique = Array.from(new Set(valid)).sort((a, b) => a.localeCompare(b)); return unique.length > 0 ? unique : ['09:00']; }; const toDraft = ( task: ScheduledTask | null, defaults: { providerID: string; modelID: string; variant: string; agent: string; }, ): ScheduledTaskDraft => { const timezoneFallback = canonicalizeTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'); if (!task) { return { name: '', enabled: true, schedule: { kind: 'daily', times: ['09:00'], onceDate: getLocalDateISO(), onceTime: '09:00', weekdays: [1], timezone: timezoneFallback, cronExpression: '', }, execution: { prompt: '', providerID: defaults.providerID, modelID: defaults.modelID, variant: defaults.variant, agent: defaults.agent, goalEnabled: false, goalTokenBudget: null, permissionAutoAccept: false, }, }; } return { id: task.id, name: task.name, enabled: task.enabled, schedule: { kind: task.schedule.kind === 'cron' ? 'cron' : (task.schedule.kind === 'once' ? 'once' : (task.schedule.kind === 'weekly' ? 'weekly' : 'daily')), times: normalizeDraftTimes(task), onceDate: typeof task.schedule.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(task.schedule.date) ? task.schedule.date : getLocalDateISO(), onceTime: typeof task.schedule.time === 'string' && /^([01]\d|2[0-3]):([0-5]\d)$/.test(task.schedule.time) ? task.schedule.time : '09:00', weekdays: Array.isArray(task.schedule.weekdays) ? task.schedule.weekdays : [1], timezone: canonicalizeTimezone(task.schedule.timezone || timezoneFallback), cronExpression: task.schedule.kind === 'cron' && typeof task.schedule.cron === 'string' ? task.schedule.cron : '', }, execution: { prompt: task.execution.prompt, providerID: task.execution.providerID, modelID: task.execution.modelID, variant: task.execution.variant || '', agent: task.execution.agent || '', goalEnabled: task.execution.goalEnabled === true, goalTokenBudget: typeof task.execution.goalTokenBudget === 'number' && task.execution.goalTokenBudget > 0 ? task.execution.goalTokenBudget : null, permissionAutoAccept: task.execution.permissionAutoAccept === true, }, state: task.state, }; }; const validateDraft = (draft: ScheduledTaskDraft, t: ReturnType['t']): string | null => { if (!draft.name.trim()) { return t('sessions.scheduledTasks.editor.validation.taskNameRequired'); } if (!draft.execution.prompt.trim()) { return t('sessions.scheduledTasks.editor.validation.promptRequired'); } if (!draft.execution.providerID.trim() || !draft.execution.modelID.trim()) { return t('sessions.scheduledTasks.editor.validation.modelRequired'); } if (draft.schedule.kind === 'once') { if (!/^\d{4}-\d{2}-\d{2}$/.test(draft.schedule.onceDate)) { return t('sessions.scheduledTasks.editor.validation.dateFormat'); } if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(draft.schedule.onceTime)) { return t('sessions.scheduledTasks.editor.validation.timeFormat'); } } else if (draft.schedule.kind === 'cron') { if (!draft.schedule.cronExpression.trim()) { return t('sessions.scheduledTasks.editor.validation.cronRequired'); } const cronResult = isValidCronExpression(draft.schedule.cronExpression); if (!cronResult.valid) { return t('sessions.scheduledTasks.editor.validation.cronInvalid'); } } else { const validTimes = draft.schedule.times.filter((value) => /^([01]\d|2[0-3]):([0-5]\d)$/.test(value)); if (validTimes.length === 0) { return t('sessions.scheduledTasks.editor.validation.atLeastOneTime'); } } if (draft.schedule.kind === 'weekly' && draft.schedule.weekdays.length === 0) { return t('sessions.scheduledTasks.editor.validation.atLeastOneWeekday'); } if (!draft.schedule.timezone.trim()) { return t('sessions.scheduledTasks.editor.validation.timezoneRequired'); } return null; }; const dedupeSortTimes = (times: string[]) => { const filtered = times.filter((value) => /^([01]\d|2[0-3]):([0-5]\d)$/.test(value)); return Array.from(new Set(filtered)).sort((a, b) => a.localeCompare(b)); }; const CronScheduleSection: React.FC<{ draft: ScheduledTaskDraft; setDraft: React.Dispatch>; locale: string; t: ReturnType['t']; }> = ({ draft, setDraft, locale, t }) => { const cronExpression = draft.schedule.cronExpression; const cronValidation = React.useMemo( () => (cronExpression.trim() ? isValidCronExpression(cronExpression) : null), [cronExpression], ); const nextRuns = React.useMemo(() => { if (!cronValidation?.valid || !cronExpression.trim()) { return []; } return getNextRuns(cronExpression, draft.schedule.timezone); }, [cronExpression, cronValidation, draft.schedule.timezone]); const formatNextRun = React.useCallback( (date: Date) => new Intl.DateTimeFormat(locale, { timeZone: draft.schedule.timezone, month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }).format(date), [locale, draft.schedule.timezone], ); return (
{t('sessions.scheduledTasks.editor.cronExpression.label')} setDraft((prev) => ({ ...prev, schedule: { ...prev.schedule, cronExpression: event.target.value }, }))} placeholder={t('sessions.scheduledTasks.editor.cronExpression.placeholder')} className="w-full max-w-xs font-mono" /> {cronValidation && !cronValidation.valid && cronExpression.trim() ? ( {t('sessions.scheduledTasks.editor.validation.cronInvalid')} ) : null}
{nextRuns.length > 0 ? (
{t('sessions.scheduledTasks.editor.cronExpression.nextRuns')} {nextRuns.map(formatNextRun).join(', ')}
) : null}
{t('sessions.scheduledTasks.editor.cronExpression.examples')}
{CRON_EXAMPLES.map((example) => ( ))}
{t('sessions.scheduledTasks.editor.timezone.label')}
); }; export function ScheduledTaskEditorDialog(props: { open: boolean; task: ScheduledTask | null; onOpenChange: (open: boolean) => void; onSave: (draft: Partial) => Promise; }) { const { open, task, onOpenChange, onSave } = props; const { t, locale } = useI18n(); const loadProviders = useConfigStore((state) => state.loadProviders); const loadAgents = useConfigStore((state) => state.loadAgents); const providers = useConfigStore((state) => state.providers); const currentProviderID = useConfigStore((state) => state.currentProviderId); const currentModelID = useConfigStore((state) => state.currentModelId); const currentVariant = useConfigStore((state) => state.currentVariant || ''); const currentAgentName = useConfigStore((state) => state.currentAgentName || ''); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const weekStartPreference = useUIStore((state) => state.weekStartPreference); const isMobile = useUIStore((state) => state.isMobile); const [draft, setDraft] = React.useState(() => toDraft(task, { providerID: currentProviderID, modelID: currentModelID, variant: currentVariant, agent: currentAgentName, }) ); const [saving, setSaving] = React.useState(false); const [isDatePickerOpen, setIsDatePickerOpen] = React.useState(false); const [showFileMention, setShowFileMention] = React.useState(false); const [mentionQuery, setMentionQuery] = React.useState(''); const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false); const [commandQuery, setCommandQuery] = React.useState(''); const [showSnippetAutocomplete, setShowSnippetAutocomplete] = React.useState(false); const [snippetQuery, setSnippetQuery] = React.useState(''); const [calendarMonth, setCalendarMonth] = React.useState(() => { const initialDate = parseISODateToLocal(task?.schedule?.date || '') || new Date(); return new Date(initialDate.getFullYear(), initialDate.getMonth(), 1); }); const datePickerRef = React.useRef(null); const promptTextareaRef = React.useRef(null); const mentionRef = React.useRef(null); const commandRef = React.useRef(null); const snippetRef = React.useRef(null); const localeUse24Hour = React.useMemo(() => getUses24Hour(locale), [locale]); const localeWeekStartsOn = React.useMemo(() => getWeekStartsOn(locale), [locale]); const use24Hour = React.useMemo(() => { if (timeFormatPreference === '24h') { return true; } if (timeFormatPreference === '12h') { return false; } return localeUse24Hour; }, [timeFormatPreference, localeUse24Hour]); const weekStartsOn = React.useMemo(() => { if (weekStartPreference === 'sunday') { return 0; } if (weekStartPreference === 'monday') { return 1; } return localeWeekStartsOn; }, [weekStartPreference, localeWeekStartsOn]); const orderedWeekdays = React.useMemo(() => { const labels = getLocalizedWeekdayLabels(locale); return rotateWeekdays( WEEKDAY_INDEXES.map((value) => ({ value, label: labels[value] || '' })), weekStartsOn, ); }, [locale, weekStartsOn]); React.useEffect(() => { if (!open) { return; } void loadProviders({ source: 'scheduledTaskEditor' }); void loadAgents({ source: 'scheduledTaskEditor' }); }, [open, loadProviders, loadAgents]); React.useEffect(() => { if (!open) { return; } setDraft( toDraft(task, { providerID: currentProviderID, modelID: currentModelID, variant: currentVariant, agent: currentAgentName, }) ); const sourceDate = parseISODateToLocal(task?.schedule?.date || '') || new Date(); setCalendarMonth(new Date(sourceDate.getFullYear(), sourceDate.getMonth(), 1)); setIsDatePickerOpen(false); setShowCommandAutocomplete(false); setShowFileMention(false); setCommandQuery(''); setMentionQuery(''); }, [open, task, currentProviderID, currentModelID, currentVariant, currentAgentName]); React.useEffect(() => { if (!isDatePickerOpen) { return; } const onPointerDown = (event: MouseEvent) => { if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) { setIsDatePickerOpen(false); } }; document.addEventListener('mousedown', onPointerDown); return () => { document.removeEventListener('mousedown', onPointerDown); }; }, [isDatePickerOpen]); const variantOptions = React.useMemo(() => { const provider = providers.find((item) => item.id === draft.execution.providerID); const model = provider?.models?.find((item) => item.id === draft.execution.modelID) as { variants?: Record } | undefined; return model?.variants ? Object.keys(model.variants) : []; }, [providers, draft.execution.providerID, draft.execution.modelID]); const hasVariantOptions = variantOptions.length > 0; const selectedVariantValue = React.useMemo(() => { if (!hasVariantOptions) { return '__default'; } if (!draft.execution.variant) { return '__default'; } return variantOptions.includes(draft.execution.variant) ? draft.execution.variant : '__default'; }, [draft.execution.variant, hasVariantOptions, variantOptions]); React.useEffect(() => { if (hasVariantOptions || !draft.execution.variant) { return; } setDraft((prev) => ({ ...prev, execution: { ...prev.execution, variant: '', }, })); }, [hasVariantOptions, draft.execution.variant]); const toggleWeekday = React.useCallback((weekday: number, nextChecked: boolean) => { setDraft((prev) => { const current = new Set(prev.schedule.weekdays); if (nextChecked) { current.add(weekday); } else { current.delete(weekday); } return { ...prev, schedule: { ...prev.schedule, weekdays: Array.from(current).sort((a, b) => a - b), }, }; }); }, []); const updateTimeAt = React.useCallback((index: number, value: string) => { setDraft((prev) => { const next = prev.schedule.times.slice(); next[index] = value; return { ...prev, schedule: { ...prev.schedule, times: next, }, }; }); }, []); const removeTimeAt = React.useCallback((index: number) => { setDraft((prev) => { const next = prev.schedule.times.filter((_, idx) => idx !== index); return { ...prev, schedule: { ...prev.schedule, times: next.length > 0 ? next : ['09:00'], }, }; }); }, []); const addTime = React.useCallback(() => { setDraft((prev) => ({ ...prev, schedule: { ...prev.schedule, times: [...prev.schedule.times, '12:00'], }, })); }, []); const todayDate = React.useMemo(() => startOfToday(), []); const currentMonthStart = React.useMemo(() => startOfMonth(todayDate), [todayDate]); const selectedDateLabel = React.useMemo(() => { const selectedDate = parseISODateToLocal(draft.schedule.onceDate); if (!selectedDate) { return null; } if (isSameCalendarDay(selectedDate, todayDate)) { return t('sessions.scheduledTasks.editor.date.today'); } return new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(selectedDate); }, [draft.schedule.onceDate, locale, t, todayDate]); const isAtCurrentMonth = React.useMemo( () => startOfMonth(calendarMonth).getTime() <= currentMonthStart.getTime(), [calendarMonth, currentMonthStart], ); const calendarWeekdayLabels = React.useMemo( () => orderedWeekdays.map((weekday) => weekday.label), [orderedWeekdays], ); const setOneTimeDate = React.useCallback((isoDate: string) => { setDraft((prev) => ({ ...prev, schedule: { ...prev.schedule, onceDate: isoDate, }, })); }, []); const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => { if (value.startsWith('/')) { const firstSpace = value.indexOf(' '); const firstNewline = value.indexOf('\n'); const commandEnd = Math.min( firstSpace === -1 ? value.length : firstSpace, firstNewline === -1 ? value.length : firstNewline, ); if (cursorPosition <= commandEnd && firstSpace === -1) { setCommandQuery(value.substring(1, commandEnd)); setShowCommandAutocomplete(true); setShowFileMention(false); setShowSnippetAutocomplete(false); return; } } setShowCommandAutocomplete(false); const textBeforeCursor = value.substring(0, cursorPosition); const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); if (lastHashSymbol !== -1) { const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null; const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1); const isWordBoundary = !charBefore || /\s/.test(charBefore); if (isWordBoundary && !textAfterHash.includes(' ') && !textAfterHash.includes('\n')) { setSnippetQuery(textAfterHash); setShowSnippetAutocomplete(true); setShowFileMention(false); return; } } setShowSnippetAutocomplete(false); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); if (lastAtSymbol !== -1) { const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null; const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1); const isWordBoundary = !charBefore || /\s/.test(charBefore); if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { setMentionQuery(textAfterAt); setShowFileMention(true); } else { setShowFileMention(false); } return; } setShowFileMention(false); }, []); const setPromptValue = React.useCallback((value: string) => { setDraft((prev) => ({ ...prev, execution: { ...prev.execution, prompt: value, }, })); }, []); const handleFileSelect = React.useCallback((file: { name: string; path: string; relativePath?: string }) => { const promptValue = draft.execution.prompt; const textarea = promptTextareaRef.current; const cursorPosition = textarea?.selectionStart ?? promptValue.length; const textBeforeCursor = promptValue.substring(0, cursorPosition); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); const mentionPath = (file.relativePath && file.relativePath.trim().length > 0) ? file.relativePath.trim() : (file.path || file.name); const startIndex = lastAtSymbol !== -1 ? lastAtSymbol : cursorPosition; const nextPrompt = `${promptValue.substring(0, startIndex)}@${mentionPath} ${promptValue.substring(cursorPosition)}`; const nextCursor = startIndex + mentionPath.length + 2; setPromptValue(nextPrompt); setShowFileMention(false); setMentionQuery(''); requestAnimationFrame(() => { const currentTextarea = promptTextareaRef.current; if (currentTextarea) { currentTextarea.selectionStart = nextCursor; currentTextarea.selectionEnd = nextCursor; currentTextarea.focus(); } updateAutocompleteState(nextPrompt, nextCursor); }); }, [draft.execution.prompt, setPromptValue, updateAutocompleteState]); const handleAgentSelect = React.useCallback((agentName: string) => { const promptValue = draft.execution.prompt; const textarea = promptTextareaRef.current; const cursorPosition = textarea?.selectionStart ?? promptValue.length; const textBeforeCursor = promptValue.substring(0, cursorPosition); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); const startIndex = lastAtSymbol !== -1 ? lastAtSymbol : cursorPosition; const nextPrompt = `${promptValue.substring(0, startIndex)}@${agentName} ${promptValue.substring(cursorPosition)}`; const nextCursor = startIndex + agentName.length + 2; setPromptValue(nextPrompt); setShowFileMention(false); setMentionQuery(''); requestAnimationFrame(() => { const currentTextarea = promptTextareaRef.current; if (currentTextarea) { currentTextarea.selectionStart = nextCursor; currentTextarea.selectionEnd = nextCursor; currentTextarea.focus(); } updateAutocompleteState(nextPrompt, nextCursor); }); }, [draft.execution.prompt, setPromptValue, updateAutocompleteState]); const handleCommandSelect = React.useCallback((command: CommandInfo) => { const nextPrompt = `/${command.name} `; setPromptValue(nextPrompt); setShowCommandAutocomplete(false); setCommandQuery(''); setShowSnippetAutocomplete(false); requestAnimationFrame(() => { const currentTextarea = promptTextareaRef.current; if (currentTextarea) { currentTextarea.focus(); currentTextarea.selectionStart = currentTextarea.value.length; currentTextarea.selectionEnd = currentTextarea.value.length; } updateAutocompleteState(nextPrompt, nextPrompt.length); }); }, [setPromptValue, updateAutocompleteState]); const handleSnippetSelect = React.useCallback((_snippet: unknown, trigger: string) => { const promptValue = draft.execution.prompt; const textarea = promptTextareaRef.current; const cursorPosition = textarea?.selectionStart ?? promptValue.length; const textBeforeCursor = promptValue.substring(0, cursorPosition); const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); const startIndex = lastHashSymbol !== -1 ? lastHashSymbol : cursorPosition; const nextPrompt = `${promptValue.substring(0, startIndex)}#${trigger} ${promptValue.substring(cursorPosition)}`; const nextCursor = startIndex + trigger.length + 2; setPromptValue(nextPrompt); setShowSnippetAutocomplete(false); setSnippetQuery(''); requestAnimationFrame(() => { const currentTextarea = promptTextareaRef.current; if (currentTextarea) { currentTextarea.selectionStart = nextCursor; currentTextarea.selectionEnd = nextCursor; currentTextarea.focus(); } updateAutocompleteState(nextPrompt, nextCursor); }); }, [draft.execution.prompt, setPromptValue, updateAutocompleteState]); const handlePromptKeyDown = React.useCallback((event: React.KeyboardEvent) => { if (showCommandAutocomplete && commandRef.current) { if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') { event.preventDefault(); commandRef.current.handleKeyDown(event.key); return; } } if (showFileMention && mentionRef.current) { if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') { event.preventDefault(); mentionRef.current.handleKeyDown(event.key); } } if (showSnippetAutocomplete && snippetRef.current) { if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') { event.preventDefault(); snippetRef.current.handleKeyDown(event.key); } } }, [showCommandAutocomplete, showFileMention, showSnippetAutocomplete]); const handleSubmit = React.useCallback(async () => { const validationError = validateDraft(draft, t); if (validationError) { toast.error(validationError); return; } const payload: Partial = { ...(draft.id ? { id: draft.id } : {}), name: draft.name.trim(), enabled: draft.enabled, schedule: { kind: draft.schedule.kind, timezone: draft.schedule.timezone.trim(), ...(draft.schedule.kind === 'cron' ? { cron: draft.schedule.cronExpression.trim() } : draft.schedule.kind === 'once' ? { date: draft.schedule.onceDate, time: draft.schedule.onceTime, } : { times: dedupeSortTimes(draft.schedule.times), ...(draft.schedule.kind === 'weekly' ? { weekdays: draft.schedule.weekdays } : {}), }), }, execution: { prompt: draft.execution.prompt, providerID: draft.execution.providerID, modelID: draft.execution.modelID, ...(draft.execution.variant.trim() ? { variant: draft.execution.variant.trim() } : {}), ...(draft.execution.agent.trim() ? { agent: draft.execution.agent.trim() } : {}), ...(draft.execution.permissionAutoAccept ? { permissionAutoAccept: true } : {}), ...(draft.execution.goalEnabled ? { goalEnabled: true } : {}), ...(draft.execution.goalEnabled && draft.execution.goalTokenBudget ? { goalTokenBudget: draft.execution.goalTokenBudget } : {}), }, ...(draft.state ? { state: draft.state } : {}), }; setSaving(true); try { await onSave(payload); onOpenChange(false); } catch (error) { toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.editor.toast.saveFailed')); } finally { setSaving(false); } }, [draft, onOpenChange, onSave, t]); const descriptionId = React.useId(); const hasOpenFloatingMenu = React.useCallback(() => { if (typeof document === 'undefined') return false; return Boolean( document.querySelector( '[data-slot="dropdown-menu-content"][data-open], [data-slot="select-content"][data-open]' ) ); }, []); const title = task ? t('sessions.scheduledTasks.editor.title.edit') : t('sessions.scheduledTasks.editor.title.new'); const description = t('sessions.scheduledTasks.editor.description'); const formBody = (
{t('sessions.scheduledTasks.editor.taskName.label')} setDraft((prev) => ({ ...prev, name: event.target.value }))} placeholder={t('sessions.scheduledTasks.editor.taskName.placeholder')} maxLength={80} className="w-full sm:max-w-[220px]" />
{t('sessions.scheduledTasks.editor.scheduleType.label')}
{draft.schedule.kind === 'cron' ? ( ) : draft.schedule.kind === 'once' ? (
{t('sessions.scheduledTasks.editor.date.label')}
{isDatePickerOpen ? (
{new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(calendarMonth)}
{calendarWeekdayLabels.map((weekday, index) => (
{weekday}
))}
{getCalendarCells(calendarMonth, weekStartsOn).map(({ date, inCurrentMonth }) => { const isoDate = formatLocalDateISO(date); const isSelected = isoDate === draft.schedule.onceDate; const isToday = isSameCalendarDay(date, todayDate); const isPast = date.getTime() < todayDate.getTime(); const dayClass = isSelected ? 'bg-interactive-selection text-interactive-selection-foreground' : (isPast ? 'text-muted-foreground/40' : (inCurrentMonth ? 'text-foreground hover:bg-interactive-hover' : 'text-muted-foreground/60 hover:bg-interactive-hover')); return ( ); })}
{selectedDateLabel || ''}
) : null}
{t('sessions.scheduledTasks.editor.time.label')} setDraft((prev) => ({ ...prev, schedule: { ...prev.schedule, onceTime: next }, }))} />
{t('sessions.scheduledTasks.editor.timezone.label')}
) : (
{draft.schedule.kind === 'weekly' ? (
{t('sessions.scheduledTasks.editor.weekdays.label')}
{orderedWeekdays.map((weekday) => { const checked = draft.schedule.weekdays.includes(weekday.value); return (
toggleWeekday(weekday.value, next)} ariaLabel={weekday.label} />
); })}
) : null}
{t('sessions.scheduledTasks.editor.times.label')}
{draft.schedule.times.map((time, index) => (
updateTimeAt(index, next)} /> {draft.schedule.times.length > 1 ? ( ) : null}
))}
{t('sessions.scheduledTasks.editor.timezone.label')}
)}
{t('sessions.scheduledTasks.editor.model.label')} { setDraft((prev) => ({ ...prev, execution: { ...prev.execution, providerID, modelID, variant: '', }, })); }} />
{t('sessions.scheduledTasks.editor.thinkingLevel.label')}
{t('sessions.scheduledTasks.editor.agent.label')} isPrimaryMode(agent.mode)} onChange={(agent) => setDraft((prev) => ({ ...prev, execution: { ...prev.execution, agent, }, }))} />
{t('sessions.scheduledTasks.editor.prompt.label')}