import * as React from 'react'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; import { useUIStore } from '@/stores/useUIStore'; import { formatTimeForPreference } from '@/lib/timeFormat'; import type { TimeFormatPreference } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { refreshGlobalSessions } from '@/stores/useGlobalSessionsStore'; import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { cn, formatDirectoryName } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import type { ProjectEntry } from '@/lib/api/types'; import { deleteScheduledTask, deleteScheduledTaskLoopFile, fetchScheduledTasks, runScheduledTaskNow, setLoopScheduledTaskEnabled, upsertScheduledTask, type ScheduledTask, type ScheduledTaskStatus, } from '@/lib/scheduledTasksApi'; import { ScheduledTaskEditorDialog } from './ScheduledTaskEditorDialog'; import { canonicalizeTimezone } from '@/lib/timezones'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; const scheduleTimes = (task: ScheduledTask): string[] => { const raw = Array.isArray(task.schedule.times) ? task.schedule.times : (task.schedule.time ? [task.schedule.time] : []); const valid = raw.filter((value) => typeof value === 'string' && /^([01]\d|2[0-3]):([0-5]\d)$/.test(value)); return Array.from(new Set(valid)).sort((a, b) => a.localeCompare(b)); }; const formatSchedule = (task: ScheduledTask, t: ReturnType['t']): string => { const timesLabel = scheduleTimes(task).join(', ') || '--:--'; const formatWeekday = (value: number) => { if (value === 0) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.sun'); if (value === 1) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.mon'); if (value === 2) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.tue'); if (value === 3) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.wed'); if (value === 4) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.thu'); if (value === 5) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.fri'); if (value === 6) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.sat'); return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.unknown'); }; if (task.schedule.kind === 'daily') { if (task.schedule.timezone) { return t('sessions.scheduledTasks.dialog.schedule.dailyWithTimezone', { time: timesLabel, timezone: canonicalizeTimezone(task.schedule.timezone), }); } return t('sessions.scheduledTasks.dialog.schedule.daily', { time: timesLabel }); } if (task.schedule.kind === 'weekly') { const days = Array.isArray(task.schedule.weekdays) ? task.schedule.weekdays.map((value) => formatWeekday(value)).join(', ') : ''; if (task.schedule.timezone) { return t('sessions.scheduledTasks.dialog.schedule.weeklyWithTimezone', { days, time: timesLabel, timezone: canonicalizeTimezone(task.schedule.timezone), }); } return t('sessions.scheduledTasks.dialog.schedule.weekly', { days, time: timesLabel }); } if (task.schedule.kind === 'once') { const date = typeof task.schedule.date === 'string' && task.schedule.date.trim().length > 0 ? task.schedule.date : t('sessions.scheduledTasks.dialog.schedule.unknownDate'); const time = typeof task.schedule.time === 'string' && task.schedule.time.trim().length > 0 ? task.schedule.time : '--:--'; if (task.schedule.timezone) { return t('sessions.scheduledTasks.dialog.schedule.onceWithTimezone', { date, time, timezone: canonicalizeTimezone(task.schedule.timezone), }); } return t('sessions.scheduledTasks.dialog.schedule.once', { date, time }); } if (task.schedule.timezone) { return t('sessions.scheduledTasks.dialog.schedule.cronWithTimezone', { cron: task.schedule.cron || '', timezone: canonicalizeTimezone(task.schedule.timezone), }); } return t('sessions.scheduledTasks.dialog.schedule.cron', { cron: task.schedule.cron || '' }); }; const formatClockTime = (value: number | undefined, timeFormatPreference: TimeFormatPreference): string => { if (!value || !Number.isFinite(value)) { return ''; } return formatTimeForPreference(value, timeFormatPreference); }; const formatRelativeTime = (value: number | undefined, t: ReturnType['t']): string => { if (!value || !Number.isFinite(value)) { return ''; } const diff = value - Date.now(); const abs = Math.abs(diff); const minute = 60_000; const hour = 60 * minute; const day = 24 * hour; const future = diff >= 0; if (abs < minute) { return future ? t('sessions.scheduledTasks.dialog.relativeTime.inLessThanOneMinute') : t('sessions.scheduledTasks.dialog.relativeTime.justNow'); } if (abs < hour) { const m = Math.round(abs / minute); return future ? t('sessions.scheduledTasks.dialog.relativeTime.inMinutes', { count: m }) : t('sessions.scheduledTasks.dialog.relativeTime.minutesAgo', { count: m }); } if (abs < day) { const h = Math.floor(abs / hour); const m = Math.round((abs % hour) / minute); const body = m > 0 ? `${h}h ${m}m` : `${h}h`; return future ? t('sessions.scheduledTasks.dialog.relativeTime.inDuration', { duration: body }) : t('sessions.scheduledTasks.dialog.relativeTime.durationAgo', { duration: body }); } const d = Math.floor(abs / day); const h = Math.round((abs % day) / hour); const body = h > 0 ? `${d}d ${h}h` : `${d}d`; return future ? t('sessions.scheduledTasks.dialog.relativeTime.inDuration', { duration: body }) : t('sessions.scheduledTasks.dialog.relativeTime.durationAgo', { duration: body }); }; type StatusTone = 'success' | 'error' | 'warning' | 'muted'; const STATUS_META: Record< ScheduledTaskStatus, { tone: StatusTone; Icon: IconName; spin?: boolean; } > = { success: { tone: 'success', Icon: 'checkbox-circle' }, error: { tone: 'error', Icon: 'error-warning' }, running: { tone: 'warning', Icon: 'loader-4', spin: true }, idle: { tone: 'muted', Icon: 'pulse' }, }; const toneStyle = (tone: StatusTone): React.CSSProperties => { if (tone === 'muted') { return {}; } return { color: `var(--status-${tone})`, backgroundColor: `var(--status-${tone}-background)`, borderColor: `var(--status-${tone}-border)`, }; }; export function ScheduledTasksDialog() { const { t } = useI18n(); const open = useUIStore((state) => state.isScheduledTasksDialogOpen); const setOpen = useUIStore((state) => state.setScheduledTasksDialogOpen); const isMobile = useUIStore((state) => state.isMobile); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const projects = useProjectsStore((state) => state.projects); const activeProject = useProjectsStore((state) => state.getActiveProject()); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const { currentTheme } = useThemeSystem(); const [selectedProjectID, setSelectedProjectID] = React.useState(''); const [tasks, setTasks] = React.useState([]); // Start in loading state so the first frame after open shows the spinner, // not an empty/select-project flash before the fetch effect runs. const [loading, setLoading] = React.useState(true); const [editorOpen, setEditorOpen] = React.useState(false); const [editorTask, setEditorTask] = React.useState(null); const [mutatingTaskID, setMutatingTaskID] = React.useState(null); const selectedProject = React.useMemo( () => projects.find((project) => project.id === selectedProjectID) || null, [projects, selectedProjectID], ); const renderProjectLabel = React.useCallback((project: ProjectEntry) => { const displayLabel = project.label?.trim() || formatDirectoryName(project.path, homeDirectory || undefined); const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const iconColor = project.color ? PROJECT_COLOR_MAP[project.color] : undefined; const fallbackIcon = projectIconName ? ( ) : ( ); return ( {project.iconImage ? ( ) : fallbackIcon} {displayLabel} ); }, [homeDirectory, currentTheme.metadata.variant, currentTheme.colors.surface.foreground]); const reloadTasks = React.useCallback(async (projectID: string, options?: { silent?: boolean }) => { if (!projectID) { setTasks([]); return; } if (!options?.silent) { setLoading(true); } try { const nextTasks = await fetchScheduledTasks(projectID); nextTasks.sort((a, b) => { if (a.enabled !== b.enabled) { return a.enabled ? -1 : 1; } const byName = a.name.localeCompare(b.name); if (byName !== 0) { return byName; } return (a.state?.nextRunAt || Number.MAX_SAFE_INTEGER) - (b.state?.nextRunAt || Number.MAX_SAFE_INTEGER); }); setTasks(nextTasks); } catch (error) { toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.loadFailed')); if (!options?.silent) { setTasks([]); } } finally { if (!options?.silent) { setLoading(false); } } }, [t]); React.useEffect(() => { if (!open) { return; } const preferredProjectID = activeProject?.id || projects[0]?.id || ''; setSelectedProjectID(preferredProjectID); if (preferredProjectID) { void reloadTasks(preferredProjectID); } else { setTasks([]); setLoading(false); } }, [open, activeProject, projects, reloadTasks]); React.useEffect(() => { if (!open) { return; } let timeoutID: ReturnType | null = null; const unsubscribe = subscribeOpenchamberEvents((event) => { if (event.type !== 'scheduled-task-ran') { return; } if (event.projectId !== selectedProjectID) { return; } if (timeoutID) { clearTimeout(timeoutID); } timeoutID = setTimeout(() => { void reloadTasks(selectedProjectID, { silent: true }); }, 400); }); return () => { if (timeoutID) { clearTimeout(timeoutID); } unsubscribe(); }; }, [open, selectedProjectID, reloadTasks]); const handleSaveTask = React.useCallback(async (taskDraft: Partial) => { if (!selectedProjectID) { throw new Error(t('sessions.scheduledTasks.dialog.error.chooseProjectFirst')); } await upsertScheduledTask(selectedProjectID, taskDraft); await reloadTasks(selectedProjectID); toast.success(t('sessions.scheduledTasks.dialog.toast.saved')); }, [selectedProjectID, reloadTasks, t]); const handleToggleEnabled = React.useCallback(async (task: ScheduledTask, enabled: boolean) => { if (!selectedProjectID) { return; } setMutatingTaskID(task.id); setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, enabled } : item))); try { if (task.loopFile) { await setLoopScheduledTaskEnabled(selectedProjectID, task.id, enabled); } else { await upsertScheduledTask(selectedProjectID, { ...task, enabled }); } await reloadTasks(selectedProjectID, { silent: true }); } catch (error) { toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.updateFailed')); await reloadTasks(selectedProjectID, { silent: true }); } finally { setMutatingTaskID(null); } }, [selectedProjectID, reloadTasks, t]); const handleDeleteTask = React.useCallback(async (task: ScheduledTask) => { if (!selectedProjectID) { return; } const confirmed = window.confirm(task.loopFile ? t('sessions.scheduledTasks.dialog.confirm.deleteLoopFile', { taskName: task.name }) : t('sessions.scheduledTasks.dialog.confirm.deleteTask', { taskName: task.name })); if (!confirmed) { return; } setMutatingTaskID(task.id); try { if (task.loopFile) { await deleteScheduledTaskLoopFile(selectedProjectID, task.id); } else { await deleteScheduledTask(selectedProjectID, task.id); } await reloadTasks(selectedProjectID, { silent: true }); toast.success(t('sessions.scheduledTasks.dialog.toast.deleted')); } catch (error) { toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.deleteFailed')); } finally { setMutatingTaskID(null); } }, [selectedProjectID, reloadTasks, t]); const handleEditTask = React.useCallback((task: ScheduledTask) => { if (!task.loopFile) { setEditorTask(task); setEditorOpen(true); return; } if (!selectedProject?.path) { return; } setOpen(false); if (isMobile) { useFilesViewTabsStore.getState().setSelectedPath(selectedProject.path, task.loopFile, { allowOutsideRoot: true }); useUIStore.getState().setActiveSurface('files'); return; } useUIStore.getState().openContextFile(selectedProject.path, task.loopFile); }, [isMobile, selectedProject?.path, setOpen]); const handleRunNow = React.useCallback(async (task: ScheduledTask) => { if (!selectedProjectID) { return; } setMutatingTaskID(task.id); try { const { sessionId, persistError } = await runScheduledTaskNow(selectedProjectID, task.id); await Promise.all([ reloadTasks(selectedProjectID, { silent: true }), refreshGlobalSessions(), ]); if (persistError) { toast.warning(t('sessions.scheduledTasks.dialog.toast.startedPersistWarning')); } else { toast.success(t('sessions.scheduledTasks.dialog.toast.started')); } if (sessionId) { // Jump straight into the started session; selecting it also closes // this surface (MainLayout closes surfaces on session selection). const project = projects.find((entry) => entry.id === selectedProjectID); useSessionUIStore.getState().setCurrentSession(sessionId, project?.path ?? null); useUIStore.getState().setActiveSurface('chat'); } } catch (error) { toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed')); } finally { setMutatingTaskID(null); } }, [selectedProjectID, projects, reloadTasks, t]); const projectSelector = (
{t('sessions.scheduledTasks.dialog.project.label')}
); const openNewTaskEditor = () => { setEditorTask(null); setEditorOpen(true); }; const selectProject = (nextProjectID: string) => { setSelectedProjectID(nextProjectID); if (nextProjectID) { void reloadTasks(nextProjectID); } else { setTasks([]); } }; const tasksList = (
{loading ? (
{t('sessions.scheduledTasks.dialog.loading')}
) : tasks.length === 0 ? (
{selectedProjectID ? t('sessions.scheduledTasks.dialog.empty.noTasks') : t('sessions.scheduledTasks.dialog.empty.selectProject')}
) : (
{tasks.map((task) => { const isBusy = mutatingTaskID === task.id; const status = (task.state?.lastStatus || 'idle') as ScheduledTaskStatus; const meta = STATUS_META[status]; const statusLabel = status === 'success' ? t('sessions.scheduledTasks.dialog.status.success') : status === 'error' ? t('sessions.scheduledTasks.dialog.status.error') : status === 'running' ? t('sessions.scheduledTasks.dialog.status.running') : t('sessions.scheduledTasks.dialog.status.idle'); const nextAt = task.state?.nextRunAt; const lastAt = task.state?.lastRunAt; return (
{task.name}
{formatSchedule(task, t)}
{task.loopFile ? (
{t('sessions.scheduledTasks.dialog.loopFile.note', { file: task.loopFile })}
) : null}
{t('sessions.scheduledTasks.dialog.nextRun.label')} {nextAt ? ( <> {formatRelativeTime(nextAt, t)} · {formatClockTime(nextAt, timeFormatPreference)} ) : ( )} {t('sessions.scheduledTasks.dialog.lastRun.label')} {status === 'running' ? ( {t('sessions.scheduledTasks.dialog.lastRun.runningNow')} ) : lastAt ? ( <> {meta.tone !== 'muted' ? ( {statusLabel} ) : null} · {formatRelativeTime(lastAt, t)} ) : ( {t('sessions.scheduledTasks.dialog.lastRun.never')} )}
{task.state?.lastError ? (
{task.state.lastError}
) : null}
); })}
)}
); const tasksContent = (
{projectSelector} {tasksList}
); return ( <> {isMobile ? ( setOpen(false)} contentMaxHeightClassName="max-h-[min(80vh,640px)]" renderHeader={(closeButton) => (

{t('sessions.scheduledTasks.dialog.title')}

{closeButton}

{t('sessions.scheduledTasks.dialog.description')}

)} footer={( )} > {tasksContent}
) : open ? ( // Full-page surface replacing the chat area (mounted inside
). // Master-detail: a scrollable project filter panel at the left, the // selected project's tasks at the right. The app Header shows the // surface title, so the page itself only carries the close affordance.
{projects.length === 0 ? (
{t('sessions.scheduledTasks.dialog.project.empty')}
) : projects.map((project) => ( ))}
{/* Pages have no close button: you leave by picking a session, a draft, or another surface in the sidebar. */}
{tasksList}
) : null} ); }