diff --git a/app/src/components/settings/panels/CronJobsPanel.test.tsx b/app/src/components/settings/panels/CronJobsPanel.test.tsx new file mode 100644 index 000000000..d4ccc17e4 --- /dev/null +++ b/app/src/components/settings/panels/CronJobsPanel.test.tsx @@ -0,0 +1,359 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mock i18n ─────────────────────────────────────────────────────────── +// Stable identity so useT-derived useCallback deps don't churn (would +// otherwise re-fire loadCoreCronJobsOnly's useEffect every render and +// stomp coreError back to null mid-test). +const stableI18n = { t: (k: string) => k }; +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => stableI18n })); + +// ── Mock navigation ───────────────────────────────────────────────────── +vi.mock('../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }), +})); + +// ── Mock SettingsHeader ───────────────────────────────────────────────── +vi.mock('../components/SettingsHeader', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})); + +// ── Mock CronJobFormModal ─────────────────────────────────────────────── +// The modal is independently tested; here we just verify it opens/closes +// and the callbacks fire. +const mockModalOnCreate = vi.fn(); +const mockModalOnUpdate = vi.fn(); + +vi.mock('./cron/CronJobFormModal', () => ({ + default: ({ + open, + mode, + job, + onClose, + onCreate, + onUpdate, + }: { + open: boolean; + mode: string; + job?: { id: string }; + onClose: () => void; + onCreate: (p: unknown) => Promise; + onUpdate: (id: string, p: unknown) => Promise; + }) => { + if (!open) return null; + // Capture the callbacks on each render so tests can invoke them + mockModalOnCreate.mockImplementation(onCreate); + mockModalOnUpdate.mockImplementation(onUpdate); + return ( +
+ {job?.id ?? ''} + +
+ ); + }, +})); + +// ── Mock tauriCommands ────────────────────────────────────────────────── +const cronAddMock = vi.fn(); +const cronListMock = vi.fn(); +const cronUpdateMock = vi.fn(); +const cronRemoveMock = vi.fn(); +const cronRunMock = vi.fn(); +const cronRunsMock = vi.fn(); + +vi.mock('../../../utils/tauriCommands', () => ({ + openhumanCronAdd: (...args: unknown[]) => cronAddMock(...args), + openhumanCronList: () => cronListMock(), + openhumanCronUpdate: (...args: unknown[]) => cronUpdateMock(...args), + openhumanCronRemove: (...args: unknown[]) => cronRemoveMock(...args), + openhumanCronRun: (...args: unknown[]) => cronRunMock(...args), + openhumanCronRuns: (...args: unknown[]) => cronRunsMock(...args), +})); + +// ── Helpers ───────────────────────────────────────────────────────────── +const sampleJob = { + id: 'job-1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + name: 'Daily Briefing', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-05-01T00:00:00.000Z', + next_run: '2026-06-01T09:00:00.000Z', + prompt: 'Summarise the news', +}; + +async function importPanel() { + vi.resetModules(); + const mod = await import('./CronJobsPanel'); + return mod.default; +} + +describe('', () => { + beforeEach(() => { + [ + cronListMock, + cronAddMock, + cronUpdateMock, + cronRemoveMock, + cronRunMock, + cronRunsMock, + mockModalOnCreate, + mockModalOnUpdate, + ].forEach(fn => fn.mockReset()); + cronListMock.mockResolvedValue({ result: [sampleJob] }); + cronAddMock.mockResolvedValue({ result: { ...sampleJob, id: 'job-new' } }); + cronUpdateMock.mockResolvedValue({ result: sampleJob }); + cronRemoveMock.mockResolvedValue({ result: { job_id: 'job-1', removed: true } }); + cronRunMock.mockResolvedValue({ result: {} }); + cronRunsMock.mockResolvedValue({ result: [] }); + }); + + it('renders the "+ New Scheduled Job" button', async () => { + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + expect(screen.getByTestId('cron-new-job')).toBeInTheDocument(); + }); + + it('clicking "+ New Scheduled Job" opens create modal', async () => { + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + fireEvent.click(screen.getByTestId('cron-new-job')); + expect(screen.getByTestId('cron-form-modal-create')).toBeInTheDocument(); + }); + + it('onCreate triggers openhumanCronAdd and refresh', async () => { + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + // Open create modal + fireEvent.click(screen.getByTestId('cron-new-job')); + await waitFor(() => expect(screen.getByTestId('cron-form-modal-create')).toBeInTheDocument()); + + // Invoke create via captured mock callback + const params = { + schedule: { kind: 'cron', expr: '0 9 * * *' }, + job_type: 'agent', + prompt: 'hi', + }; + await mockModalOnCreate(params); + + await waitFor(() => expect(cronAddMock).toHaveBeenCalledWith(params)); + // List should be refreshed (at least 2 calls total: initial + refresh) + expect(cronListMock.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('edit button click opens edit modal with the correct job', async () => { + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + // The mock CoreJobList renders are replaced here — we need to simulate the + // onEditCoreJob callback by clicking the edit button rendered by the real CoreJobList. + // Since CoreJobList is NOT mocked, it renders actual buttons. + const editBtn = await screen.findByTestId('cron-job-edit-job-1'); + fireEvent.click(editBtn); + + await waitFor(() => expect(screen.getByTestId('cron-form-modal-edit')).toBeInTheDocument()); + expect(screen.getByTestId('modal-job-id')).toHaveTextContent('job-1'); + }); + + it('onUpdate triggers openhumanCronUpdate and refresh', async () => { + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + // Open edit modal via edit button + const editBtn = await screen.findByTestId('cron-job-edit-job-1'); + fireEvent.click(editBtn); + + await waitFor(() => expect(screen.getByTestId('cron-form-modal-edit')).toBeInTheDocument()); + + const patch = { name: 'Updated', schedule: { kind: 'cron', expr: '0 9 * * *' } }; + await mockModalOnUpdate('job-1', patch); + + await waitFor(() => expect(cronUpdateMock).toHaveBeenCalledWith('job-1', patch)); + expect(cronListMock.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('surfaces errorLoadList when openhumanCronList rejects', async () => { + cronListMock.mockRejectedValueOnce(new Error('boom')); + const Panel = await importPanel(); + render(); + await waitFor(() => { + expect(screen.getByText('settings.cron.jobs.errorLoadList')).toBeInTheDocument(); + }); + }); + + it('surfaces errorToggle when openhumanCronUpdate rejects on toggle', async () => { + cronUpdateMock.mockRejectedValueOnce(new Error('nope')); + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + const toggle = await screen.findByTestId('cron-job-toggle-job-1'); + fireEvent.click(toggle); + + await waitFor(() => { + expect(screen.getByText('settings.cron.jobs.errorToggle')).toBeInTheDocument(); + }); + }); + + it('successful toggle replaces job in state', async () => { + cronUpdateMock.mockResolvedValueOnce({ result: { ...sampleJob, enabled: false } }); + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + const toggle = await screen.findByTestId('cron-job-toggle-job-1'); + fireEvent.click(toggle); + + await waitFor(() => expect(cronUpdateMock).toHaveBeenCalledWith('job-1', { enabled: false })); + }); + + it('runCoreJob invokes cronRun + cronRuns + cronList; surfaces errorRun on failure', async () => { + cronRunsMock.mockResolvedValueOnce({ result: [{ id: 1, status: 'ok' }] }); + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + fireEvent.click(await screen.findByTestId('cron-job-run-job-1')); + await waitFor(() => expect(cronRunMock).toHaveBeenCalledWith('job-1')); + await waitFor(() => expect(cronRunsMock).toHaveBeenCalledWith('job-1', 10)); + + // Then trigger a failure + cronRunMock.mockRejectedValueOnce(new Error('explode')); + fireEvent.click(screen.getByTestId('cron-job-run-job-1')); + await waitFor(() => { + expect(screen.getByText('settings.cron.jobs.errorRun')).toBeInTheDocument(); + }); + }); + + it('loadCoreRuns invokes cronRuns; surfaces errorLoadRuns on failure', async () => { + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + cronRunsMock.mockResolvedValueOnce({ result: [{ id: 9, status: 'ok' }] }); + fireEvent.click(await screen.findByTestId('cron-job-view-runs-job-1')); + await waitFor(() => expect(cronRunsMock).toHaveBeenCalledWith('job-1', 10)); + + cronRunsMock.mockRejectedValueOnce(new Error('runs-failed')); + fireEvent.click(screen.getByTestId('cron-job-view-runs-job-1')); + await waitFor(() => { + expect(screen.getByText('settings.cron.jobs.errorLoadRuns')).toBeInTheDocument(); + }); + }); + + it('removeCoreJob removes from list; surfaces errorRemove on failure', async () => { + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + fireEvent.click(await screen.findByTestId('cron-job-remove-job-1')); + await waitFor(() => expect(cronRemoveMock).toHaveBeenCalledWith('job-1')); + + // Re-load and trigger failure path + cronListMock.mockResolvedValueOnce({ result: [sampleJob] }); + cronRemoveMock.mockRejectedValueOnce(new Error('remove-failed')); + // Force re-render by refreshing + fireEvent.click(screen.getByTestId('cron-refresh')); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + const removeBtn = await screen.findByTestId('cron-job-remove-job-1'); + fireEvent.click(removeBtn); + await waitFor(() => { + expect(screen.getByText('settings.cron.jobs.errorRemove')).toBeInTheDocument(); + }); + }); + + it('handleCreate surfaces errorCreate and re-throws when cronAdd rejects', async () => { + cronAddMock.mockRejectedValueOnce(new Error('add-failed')); + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + fireEvent.click(screen.getByTestId('cron-new-job')); + await waitFor(() => expect(screen.getByTestId('cron-form-modal-create')).toBeInTheDocument()); + + const params = { + schedule: { kind: 'cron', expr: '0 9 * * *' }, + job_type: 'agent', + prompt: 'hi', + }; + let caught: unknown = null; + try { + await mockModalOnCreate(params); + } catch (e) { + caught = e; + } + expect((caught as Error).message).toBe('add-failed'); + await waitFor(() => { + expect(screen.getByText('settings.cron.jobs.errorCreate')).toBeInTheDocument(); + }); + }); + + it('handleUpdate surfaces errorUpdate and re-throws when cronUpdate rejects', async () => { + cronUpdateMock.mockRejectedValueOnce(new Error('update-failed')); + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + fireEvent.click(await screen.findByTestId('cron-job-edit-job-1')); + await waitFor(() => expect(screen.getByTestId('cron-form-modal-edit')).toBeInTheDocument()); + + const patch = { + name: 'Updated', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + session_target: 'isolated', + delete_after_run: false, + }; + let caught: unknown = null; + try { + await mockModalOnUpdate('job-1', patch); + } catch (e) { + caught = e; + } + expect((caught as Error).message).toBe('update-failed'); + await waitFor(() => { + expect(screen.getByText('settings.cron.jobs.errorUpdate')).toBeInTheDocument(); + }); + }); + + it('closing the create modal hides it', async () => { + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + fireEvent.click(screen.getByTestId('cron-new-job')); + await waitFor(() => expect(screen.getByTestId('cron-form-modal-create')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('modal-close')); + await waitFor(() => + expect(screen.queryByTestId('cron-form-modal-create')).not.toBeInTheDocument() + ); + }); + + it('closing the edit modal clears editingJob', async () => { + const Panel = await importPanel(); + render(); + await waitFor(() => expect(cronListMock).toHaveBeenCalled()); + + fireEvent.click(await screen.findByTestId('cron-job-edit-job-1')); + await waitFor(() => expect(screen.getByTestId('cron-form-modal-edit')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('modal-close')); + await waitFor(() => + expect(screen.queryByTestId('cron-form-modal-edit')).not.toBeInTheDocument() + ); + }); +}); diff --git a/app/src/components/settings/panels/CronJobsPanel.tsx b/app/src/components/settings/panels/CronJobsPanel.tsx index 8e6f10e0f..13f35ee5a 100644 --- a/app/src/components/settings/panels/CronJobsPanel.tsx +++ b/app/src/components/settings/panels/CronJobsPanel.tsx @@ -5,6 +5,8 @@ import { useT } from '../../../lib/i18n/I18nContext'; import { type CoreCronJob, type CoreCronRun, + type CronAddParams, + openhumanCronAdd, openhumanCronList, openhumanCronRemove, openhumanCronRun, @@ -14,12 +16,17 @@ import { import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import CoreJobList from './cron/CoreJobList'; +import CronJobFormModal from './cron/CronJobFormModal'; const loadCronJobsLog = createDebug('app:settings:CronJobsPanel:loadCronSkills'); const CronJobsPanel = () => { const { t } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const formatCronError = useCallback( + (key: string, message: string) => t(key).replace('{message}', message), + [t] + ); const [loading, setLoading] = useState(true); const [coreError, setCoreError] = useState(null); @@ -28,6 +35,10 @@ const CronJobsPanel = () => { const [coreRunsByJob, setCoreRunsByJob] = useState>({}); const [coreBusyKey, setCoreBusyKey] = useState(null); + // Create / edit modal state + const [formOpen, setFormOpen] = useState(false); + const [editingJob, setEditingJob] = useState(null); + const loadCoreCronJobs = useCallback(async () => { const response = await openhumanCronList(); const sorted = [...response.result].sort((a, b) => { @@ -49,11 +60,11 @@ const CronJobsPanel = () => { } catch (err) { loadCronJobsLog('failure', err); const message = err instanceof Error ? err.message : String(err); - setCoreError(`Failed to load core cron jobs: ${message}`); + setCoreError(formatCronError('settings.cron.jobs.errorLoadList', message)); } finally { setLoading(false); } - }, [loadCoreCronJobs]); + }, [formatCronError, loadCoreCronJobs]); useEffect(() => { void loadCoreCronJobsOnly(); @@ -69,7 +80,7 @@ const CronJobsPanel = () => { setCoreJobs(prev => prev.map(item => (item.id === updated.id ? updated : item))); } catch (err) { const message = err instanceof Error ? err.message : String(err); - setCoreError(`Failed to update core cron job: ${message}`); + setCoreError(formatCronError('settings.cron.jobs.errorToggle', message)); } finally { setCoreBusyKey(null); } @@ -87,7 +98,7 @@ const CronJobsPanel = () => { await loadCoreCronJobs(); } catch (err) { const message = err instanceof Error ? err.message : String(err); - setCoreError(`Failed to run core cron job: ${message}`); + setCoreError(formatCronError('settings.cron.jobs.errorRun', message)); } finally { setCoreBusyKey(null); } @@ -103,12 +114,55 @@ const CronJobsPanel = () => { setCoreRunsByJob(prev => ({ ...prev, [jobId]: runs.result })); } catch (err) { const message = err instanceof Error ? err.message : String(err); - setCoreError(`Failed to load run history: ${message}`); + setCoreError(formatCronError('settings.cron.jobs.errorLoadRuns', message)); } finally { setCoreBusyKey(null); } }; + const handleCreate = async (params: CronAddParams) => { + loadCronJobsLog('handleCreate metadata=%o', { + jobType: params.job_type, + scheduleKind: params.schedule.kind, + hasName: Boolean(params.name), + hasSessionTarget: Boolean(params.session_target), + deleteAfterRun: params.delete_after_run, + }); + try { + await openhumanCronAdd(params); + await loadCoreCronJobs(); + setFormOpen(false); + loadCronJobsLog('handleCreate success'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + loadCronJobsLog('handleCreate error: %s', message); + setCoreError(formatCronError('settings.cron.jobs.errorCreate', message)); + throw err; // Re-throw so modal can surface inline error + } + }; + + const handleUpdate = async (jobId: string, patch: Record) => { + const patchSchedule = patch.schedule as { kind?: string } | undefined; + loadCronJobsLog('handleUpdate metadata=%o', { + jobId, + scheduleKind: patchSchedule?.kind ?? 'unknown', + hasName: patch.name !== null, + hasSessionTarget: 'session_target' in patch, + deleteAfterRun: patch.delete_after_run, + }); + try { + await openhumanCronUpdate(jobId, patch); + await loadCoreCronJobs(); + setEditingJob(null); + loadCronJobsLog('handleUpdate success'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + loadCronJobsLog('handleUpdate error: %s', message); + setCoreError(formatCronError('settings.cron.jobs.errorUpdate', message)); + throw err; // Re-throw so modal can surface inline error + } + }; + const removeCoreJob = async (jobId: string) => { const key = `core-remove:${jobId}`; setCoreBusyKey(key); @@ -124,7 +178,7 @@ const CronJobsPanel = () => { }); } catch (err) { const message = err instanceof Error ? err.message : String(err); - setCoreError(`Failed to remove core cron job: ${message}`); + setCoreError(formatCronError('settings.cron.jobs.errorRemove', message)); } finally { setCoreBusyKey(null); } @@ -147,6 +201,20 @@ const CronJobsPanel = () => {

{t('cron.manageCronJobs')}

+ {/* "+ New Scheduled Job" button */} +
+ +
+ {coreError && (
{coreError} @@ -162,17 +230,43 @@ const CronJobsPanel = () => { onRunCoreJob={jobId => void runCoreJob(jobId)} onLoadCoreRuns={jobId => void loadCoreRuns(jobId)} onRemoveCoreJob={jobId => void removeCoreJob(jobId)} + onEditCoreJob={job => setEditingJob(job)} />
+ + {/* Create modal */} + {formOpen && editingJob === null && ( + setFormOpen(false)} + onCreate={params => handleCreate(params)} + onUpdate={handleUpdate} + /> + )} + + {/* Edit modal */} + {editingJob !== null && ( + setEditingJob(null)} + onCreate={handleCreate} + onUpdate={handleUpdate} + /> + )} ); }; diff --git a/app/src/components/settings/panels/DevWorkflowPanel.tsx b/app/src/components/settings/panels/DevWorkflowPanel.tsx index 43bf74c9e..3481e87c0 100644 --- a/app/src/components/settings/panels/DevWorkflowPanel.tsx +++ b/app/src/components/settings/panels/DevWorkflowPanel.tsx @@ -2,6 +2,7 @@ import createDebug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import { execute as composioExecute, listConnections } from '../../../lib/composio/composioApi'; +import { SCHEDULE_PRESETS } from '../../../lib/cron/schedulePresets'; import { useT } from '../../../lib/i18n/I18nContext'; import { CoreCronJob, @@ -42,14 +43,6 @@ interface GhBranch { name: string; } -const SCHEDULE_PRESETS = [ - { labelKey: 'settings.devWorkflow.schedule.every30min' as const, value: '*/30 * * * *' }, - { labelKey: 'settings.devWorkflow.schedule.everyHour' as const, value: '0 * * * *' }, - { labelKey: 'settings.devWorkflow.schedule.every2hours' as const, value: '0 */2 * * *' }, - { labelKey: 'settings.devWorkflow.schedule.every6hours' as const, value: '0 */6 * * *' }, - { labelKey: 'settings.devWorkflow.schedule.onceDaily' as const, value: '0 9 * * *' }, -]; - // ── Component ────────────────────────────────────────────────────────── const DevWorkflowPanel = () => { diff --git a/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx b/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx index 0c8049a86..bffac7699 100644 --- a/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx @@ -836,8 +836,8 @@ describe('DevWorkflowPanel', () => { renderWithProviders(); await waitFor(() => { - // Schedule preset matches — should show the label key - expect(screen.getByText('settings.devWorkflow.schedule.every30min')).toBeInTheDocument(); + // Schedule preset matches — should show the shared label key (migrated to cron namespace) + expect(screen.getByText('settings.cron.schedule.every30min')).toBeInTheDocument(); }); }); diff --git a/app/src/components/settings/panels/cron/CoreJobList.test.tsx b/app/src/components/settings/panels/cron/CoreJobList.test.tsx index b5ec087e5..8cdcecf34 100644 --- a/app/src/components/settings/panels/cron/CoreJobList.test.tsx +++ b/app/src/components/settings/panels/cron/CoreJobList.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import type { CoreCronJob, CoreCronRun } from '../../../../utils/tauriCommands'; @@ -11,10 +11,12 @@ vi.mock('../../../../lib/i18n/I18nContext', () => ({ 'common.enabled': 'Enabled', 'common.remove': 'Remove', 'settings.cron.jobs.desc': 'Manage cron jobs', + 'settings.cron.jobs.edit': 'Edit', 'settings.cron.jobs.lastStatus': 'Last status', 'settings.cron.jobs.nextRun': 'Next run', 'settings.cron.jobs.pause': 'Pause', 'settings.cron.jobs.recentRuns': 'Recent runs', + 'settings.cron.jobs.saving': 'Saving…', 'settings.cron.jobs.schedule': 'Schedule', 'settings.cron.jobs.title': 'Scheduled Jobs', 'settings.cron.jobs.viewRuns': 'View Runs', @@ -75,4 +77,50 @@ describe('CoreJobList stable test hooks', () => { expect(screen.getByTestId('cron-job-remove-morning_briefing')).toHaveTextContent('Remove'); expect(screen.getByTestId('cron-job-runs-morning_briefing')).toHaveTextContent('success'); }); + + test('edit button absent when onEditCoreJob prop is not provided', () => { + renderList(); + expect(screen.queryByTestId('cron-job-edit-morning_briefing')).not.toBeInTheDocument(); + }); + + test('edit button present and invokes callback with job when onEditCoreJob is provided', () => { + const onEditCoreJob = vi.fn(); + render( + + ); + + const editBtn = screen.getByTestId('cron-job-edit-morning_briefing'); + expect(editBtn).toBeInTheDocument(); + expect(editBtn).toHaveTextContent('Edit'); + + fireEvent.click(editBtn); + expect(onEditCoreJob).toHaveBeenCalledOnce(); + expect(onEditCoreJob).toHaveBeenCalledWith(job); + }); + + test('toggle button shows saving label when coreBusyKey targets the toggle', () => { + render( + + ); + expect(screen.getByTestId(`cron-job-toggle-${job.id}`)).toHaveTextContent('Saving…'); + }); }); diff --git a/app/src/components/settings/panels/cron/CoreJobList.tsx b/app/src/components/settings/panels/cron/CoreJobList.tsx index af6e916dd..49214255c 100644 --- a/app/src/components/settings/panels/cron/CoreJobList.tsx +++ b/app/src/components/settings/panels/cron/CoreJobList.tsx @@ -10,6 +10,8 @@ interface CoreJobListProps { onRunCoreJob: (jobId: string) => void; onLoadCoreRuns: (jobId: string) => void; onRemoveCoreJob: (jobId: string) => void; + /** Optional: when provided, an Edit button is rendered per row. */ + onEditCoreJob?: (job: CoreCronJob) => void; } const CoreJobList = ({ @@ -21,8 +23,34 @@ const CoreJobList = ({ onRunCoreJob, onLoadCoreRuns, onRemoveCoreJob, + onEditCoreJob, }: CoreJobListProps) => { const { t } = useT(); + const neutralButtonClassName = + 'inline-flex min-h-9 items-center justify-center whitespace-nowrap rounded-lg border border-stone-300 dark:border-stone-700 bg-white dark:bg-stone-900 px-3 py-1.5 text-[13px] font-medium leading-5 text-stone-700 dark:text-stone-200 shadow-sm transition-colors hover:bg-stone-100 hover:text-stone-900 dark:hover:bg-stone-800 dark:hover:text-stone-100 focus:outline-none focus:ring-2 focus:ring-primary-500/30 disabled:cursor-not-allowed disabled:opacity-70 disabled:text-stone-500 dark:disabled:text-stone-400'; + const removeButtonClassName = + 'inline-flex min-h-9 items-center justify-center whitespace-nowrap rounded-lg border border-red-700/40 bg-red-600 px-3 py-1.5 text-[13px] font-medium leading-5 text-white shadow-sm transition-colors hover:bg-red-700 hover:text-white active:bg-red-800 focus:outline-none focus:ring-2 focus:ring-red-500/30 disabled:cursor-not-allowed disabled:opacity-70'; + + const toggleButtonLabel = (job: CoreCronJob) => { + if (coreBusyKey === `core-toggle:${job.id}`) { + return t('settings.cron.jobs.saving'); + } + return job.enabled ? t('settings.cron.jobs.pause') : t('settings.cron.jobs.resume'); + }; + + const runButtonLabel = (jobId: string) => + coreBusyKey === `core-run:${jobId}` + ? t('settings.cron.jobs.runningNow') + : t('subconscious.runNow'); + + const viewRunsButtonLabel = (jobId: string) => + coreBusyKey === `core-runs:${jobId}` + ? t('settings.cron.jobs.loadingRuns') + : t('settings.cron.jobs.viewRuns'); + + const removeButtonLabel = (jobId: string) => + coreBusyKey === `core-remove:${jobId}` ? t('settings.cron.jobs.removing') : t('common.remove'); + return (
@@ -102,44 +130,43 @@ const CoreJobList = ({ + {onEditCoreJob && ( + + )}
diff --git a/app/src/components/settings/panels/cron/CronJobFormModal.test.tsx b/app/src/components/settings/panels/cron/CronJobFormModal.test.tsx new file mode 100644 index 000000000..3879486a9 --- /dev/null +++ b/app/src/components/settings/panels/cron/CronJobFormModal.test.tsx @@ -0,0 +1,438 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CoreCronJob } from '../../../../utils/tauriCommands'; +import CronJobFormModal, { type CronJobFormModalProps } from './CronJobFormModal'; + +// ── Mock i18n ────────────────────────────────────────────────────────── +vi.mock('../../../../lib/i18n/I18nContext', () => ({ + useT: () => ({ + t: (key: string, fallback?: string) => { + const map: Record = { + 'settings.cron.jobs.createJob': 'New Scheduled Job', + 'settings.cron.jobs.editJob': 'Edit Scheduled Job', + 'settings.cron.jobs.formName': 'Job name', + 'settings.cron.jobs.formNamePlaceholder': 'e.g. daily-report, cleanup-task', + 'settings.cron.jobs.formJobType': 'Job type', + 'settings.cron.jobs.formJobTypeAgent': 'Agent (AI prompt)', + 'settings.cron.jobs.formJobTypeShell': 'Shell command', + 'settings.cron.jobs.formScheduleType': 'Schedule type', + 'settings.cron.jobs.formScheduleCron': 'Recurring (cron)', + 'settings.cron.jobs.formScheduleAt': 'One-time (run at)', + 'settings.cron.jobs.formScheduleEvery': 'Interval (every N ms)', + 'settings.cron.jobs.formCronPreset': 'Preset', + 'settings.cron.jobs.formCronCustom': 'Custom expression', + 'settings.cron.jobs.formCronCustomPlaceholder': 'e.g. */30 * * * *', + 'settings.cron.jobs.formCronPreview': 'Runs: {preview}', + 'settings.cron.jobs.formAtLabel': 'Run at', + 'settings.cron.jobs.formEveryLabel': 'Interval (milliseconds)', + 'settings.cron.jobs.formEveryPlaceholder': 'e.g. 3600000', + 'settings.cron.jobs.formPrompt': 'Agent prompt', + 'settings.cron.jobs.formPromptPlaceholder': 'What should the agent do each run?', + 'settings.cron.jobs.formCommand': 'Shell command', + 'settings.cron.jobs.formCommandPlaceholder': 'e.g. curl https://example.com/health', + 'settings.cron.jobs.formSessionTarget': 'Session target', + 'settings.cron.jobs.formSessionIsolated': 'Isolated (recommended)', + 'settings.cron.jobs.formSessionMain': 'Main session', + 'settings.cron.jobs.formDelivery': 'Delivery mode', + 'settings.cron.jobs.formDeliveryNone': 'None (output only)', + 'settings.cron.jobs.formDeliveryProactive': 'Proactive (push notification)', + 'settings.cron.jobs.formDeleteAfterRun': 'Delete after first run', + 'settings.cron.jobs.formCancel': 'Cancel', + 'settings.cron.jobs.formSave': 'Save', + 'settings.cron.jobs.formCreate': 'Create', + 'settings.cron.jobs.formSaving': 'Saving…', + 'settings.cron.jobs.formError': 'Failed to save job', + 'settings.cron.jobs.custom': 'Custom', + 'settings.cron.schedule.every30min': 'Every 30 minutes', + 'settings.cron.schedule.everyHour': 'Every hour', + 'settings.cron.schedule.every2hours': 'Every 2 hours', + 'settings.cron.schedule.every6hours': 'Every 6 hours', + 'settings.cron.schedule.onceDaily': 'Once daily (9 AM)', + }; + return map[key] ?? fallback ?? key; + }, + }), +})); + +// ── Mock cronToHuman ──────────────────────────────────────────────────── +vi.mock('../../../../lib/cron/cronToHuman', () => ({ + cronToHuman: (expr: string) => `Parsed: ${expr}`, +})); + +// ── Sample data ───────────────────────────────────────────────────────── +const sampleJob: CoreCronJob = { + id: 'job-abc', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + name: 'Test Job', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-05-01T00:00:00.000Z', + next_run: '2026-05-01T01:00:00.000Z', + prompt: 'Do something daily', +}; + +// ── Helpers ───────────────────────────────────────────────────────────── +function makeProps(overrides: Partial = {}): CronJobFormModalProps { + return { + mode: 'create', + open: true, + onClose: vi.fn(), + onCreate: vi.fn().mockResolvedValue(undefined), + onUpdate: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Closed state ──────────────────────────────────────────────────── + + it('renders nothing when open is false', () => { + const props = makeProps({ open: false }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + // ── Create mode defaults ───────────────────────────────────────────── + + it('opens in create mode with agent type and cron schedule by default', () => { + render(); + + expect(screen.getByTestId('cron-form-modal')).toBeInTheDocument(); + expect(screen.getByText('New Scheduled Job')).toBeInTheDocument(); + expect(screen.getByTestId('cron-form-job-type-agent')).toBeChecked(); + expect(screen.getByTestId('cron-form-schedule-cron')).toBeChecked(); + }); + + it('submit button is disabled when prompt is empty in create mode', () => { + render(); + // Prompt textarea should be visible for agent type + expect(screen.getByTestId('cron-form-prompt')).toBeInTheDocument(); + // Submit disabled without prompt + expect(screen.getByTestId('cron-form-submit')).toBeDisabled(); + }); + + it('enables submit after filling prompt + selecting preset, then calls onCreate with correct shape', async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + render(); + + // Fill in the prompt + fireEvent.change(screen.getByTestId('cron-form-prompt'), { + target: { value: 'Send daily report' }, + }); + + // The first preset is already selected so submit should be enabled now + const submit = screen.getByTestId('cron-form-submit'); + expect(submit).not.toBeDisabled(); + + fireEvent.click(submit); + + await waitFor(() => { + expect(onCreate).toHaveBeenCalledOnce(); + }); + + const [params] = onCreate.mock.calls[0]; + expect(params.job_type).toBe('agent'); + expect(params.prompt).toBe('Send daily report'); + expect(params.schedule).toMatchObject({ kind: 'cron' }); + expect(params.session_target).toBe('isolated'); + expect(params.delivery).toMatchObject({ mode: 'proactive', best_effort: true }); + }); + + // ── Switching to shell job type ─────────────────────────────────────── + + it('switching to shell hides prompt, shows command, and requires command for submit', async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByTestId('cron-form-job-type-shell')); + + // Prompt should not be rendered + expect(screen.queryByTestId('cron-form-prompt')).not.toBeInTheDocument(); + // Command should appear + expect(screen.getByTestId('cron-form-command')).toBeInTheDocument(); + // Submit disabled (command empty) + expect(screen.getByTestId('cron-form-submit')).toBeDisabled(); + + // Fill command + fireEvent.change(screen.getByTestId('cron-form-command'), { + target: { value: 'curl https://example.com/health' }, + }); + + expect(screen.getByTestId('cron-form-submit')).not.toBeDisabled(); + fireEvent.click(screen.getByTestId('cron-form-submit')); + + await waitFor(() => expect(onCreate).toHaveBeenCalledOnce()); + const [params] = onCreate.mock.calls[0]; + expect(params.job_type).toBe('shell'); + expect(params.command).toBe('curl https://example.com/health'); + expect(params).not.toHaveProperty('prompt'); + }); + + // ── Schedule type: at ───────────────────────────────────────────────── + + it('switching to "at" schedule shows datetime input and sets deleteAfterRun to true', () => { + render(); + + fireEvent.click(screen.getByTestId('cron-form-schedule-at')); + + expect(screen.getByTestId('cron-form-at')).toBeInTheDocument(); + expect(screen.queryByTestId('cron-form-cron-preset')).not.toBeInTheDocument(); + // deleteAfterRun checkbox should be checked + expect(screen.getByTestId('cron-form-delete-after-run')).toBeChecked(); + }); + + // ── Schedule type: every ────────────────────────────────────────────── + + it('switching to "every" schedule shows ms input', () => { + render(); + + fireEvent.click(screen.getByTestId('cron-form-schedule-every')); + + expect(screen.getByTestId('cron-form-every')).toBeInTheDocument(); + expect(screen.queryByTestId('cron-form-cron-preset')).not.toBeInTheDocument(); + }); + + // ── Edit mode prefill ────────────────────────────────────────────────── + + it('prefills fields from job prop in edit mode', () => { + render(); + + expect(screen.getByText('Edit Scheduled Job')).toBeInTheDocument(); + expect(screen.getByTestId('cron-form-name')).toHaveValue('Test Job'); + expect(screen.getByTestId('cron-form-prompt')).toHaveValue('Do something daily'); + }); + + it('disables job type radio in edit mode', () => { + render(); + + expect(screen.getByTestId('cron-form-job-type-agent')).toBeDisabled(); + expect(screen.getByTestId('cron-form-job-type-shell')).toBeDisabled(); + }); + + // ── Edit submit ──────────────────────────────────────────────────────── + + it('calls onUpdate with job.id and patch on edit submit', async () => { + const onUpdate = vi.fn().mockResolvedValue(undefined); + render(); + + // Change the name + fireEvent.change(screen.getByTestId('cron-form-name'), { target: { value: 'Updated Name' } }); + + fireEvent.click(screen.getByTestId('cron-form-submit')); + + await waitFor(() => expect(onUpdate).toHaveBeenCalledOnce()); + const [jobId, patch] = onUpdate.mock.calls[0]; + expect(jobId).toBe('job-abc'); + expect(patch).toMatchObject({ schedule: { kind: 'cron' } }); + }); + + // ── Cancel ──────────────────────────────────────────────────────────── + + it('calls onClose when Cancel button is clicked', () => { + const onClose = vi.fn(); + render(); + + // There are two cancel buttons (header x and footer Cancel) + const cancelButtons = screen.getAllByTestId('cron-form-cancel'); + fireEvent.click(cancelButtons[cancelButtons.length - 1]); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + // ── Error surfacing ──────────────────────────────────────────────────── + + it('surfaces error in cron-form-error when onCreate rejects', async () => { + const onCreate = vi.fn().mockRejectedValue(new Error('network error')); + render(); + + // Fill prompt so submit is enabled + fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'Some prompt' } }); + + fireEvent.click(screen.getByTestId('cron-form-submit')); + + await waitFor(() => { + expect(screen.getByTestId('cron-form-error')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('cron-form-error')).toHaveTextContent('Failed to save job'); + }); + + // ── Create: "at" schedule ─────────────────────────────────────────── + it('submits with at-schedule, isoifies datetime input', async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByTestId('cron-form-schedule-at')); + fireEvent.change(screen.getByTestId('cron-form-at'), { target: { value: '2030-01-01T09:00' } }); + fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'go' } }); + + expect(screen.getByTestId('cron-form-submit')).not.toBeDisabled(); + fireEvent.click(screen.getByTestId('cron-form-submit')); + + await waitFor(() => expect(onCreate).toHaveBeenCalledOnce()); + const [params] = onCreate.mock.calls[0]; + expect(params.schedule.kind).toBe('at'); + expect(params.schedule.at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(params.delete_after_run).toBe(true); + }); + + it('submits with every-schedule using parsed ms', async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByTestId('cron-form-schedule-every')); + fireEvent.change(screen.getByTestId('cron-form-every'), { target: { value: '60000' } }); + fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'tick' } }); + + expect(screen.getByTestId('cron-form-submit')).not.toBeDisabled(); + fireEvent.click(screen.getByTestId('cron-form-submit')); + + await waitFor(() => expect(onCreate).toHaveBeenCalledOnce()); + const [params] = onCreate.mock.calls[0]; + expect(params.schedule).toEqual({ kind: 'every', every_ms: 60000 }); + }); + + it('submit disabled when every ms is empty or non-positive', () => { + render(); + fireEvent.click(screen.getByTestId('cron-form-schedule-every')); + fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'x' } }); + expect(screen.getByTestId('cron-form-submit')).toBeDisabled(); + fireEvent.change(screen.getByTestId('cron-form-every'), { target: { value: '0' } }); + expect(screen.getByTestId('cron-form-submit')).toBeDisabled(); + }); + + // ── Cron custom expression ────────────────────────────────────────── + it('typing a custom cron expression clears preset and renders preview', () => { + render(); + const preset = screen.getByTestId('cron-form-cron-preset') as HTMLSelectElement; + // Select empty/custom option + fireEvent.change(preset, { target: { value: '' } }); + const custom = screen.getByTestId('cron-form-cron-custom'); + fireEvent.change(custom, { target: { value: '*/15 * * * *' } }); + expect(screen.getByTestId('cron-form-cron-preview')).toHaveTextContent('*/15 * * * *'); + }); + + it('typing a value that matches a preset sets cronPreset', () => { + render(); + fireEvent.change(screen.getByTestId('cron-form-cron-preset'), { target: { value: '' } }); + const custom = screen.getByTestId('cron-form-cron-custom'); + // value matches a preset + fireEvent.change(custom, { target: { value: '0 9 * * *' } }); + // Preview rendered for that expression + expect(screen.getByTestId('cron-form-cron-preview')).toHaveTextContent('0 9 * * *'); + }); + + // ── Session target / delivery / deleteAfterRun ────────────────────── + it('changes session_target and delivery mode in the submitted params', async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'p' } }); + fireEvent.change(screen.getByTestId('cron-form-session-target'), { target: { value: 'main' } }); + fireEvent.change(screen.getByTestId('cron-form-delivery'), { target: { value: 'none' } }); + fireEvent.click(screen.getByTestId('cron-form-delete-after-run')); + + fireEvent.click(screen.getByTestId('cron-form-submit')); + await waitFor(() => expect(onCreate).toHaveBeenCalledOnce()); + const [params] = onCreate.mock.calls[0]; + expect(params.session_target).toBe('main'); + expect(params.delivery).toMatchObject({ mode: 'none' }); + expect(params.delete_after_run).toBe(true); + }); + + // ── Edit mode: at and every prefill ───────────────────────────────── + it('edit mode prefills "at" schedule and converts ISO to datetime-local', () => { + const atJob: CoreCronJob = { + ...sampleJob, + schedule: { kind: 'at', at: '2030-01-01T09:00:00.000Z' }, + }; + render(); + const atInput = screen.getByTestId('cron-form-at') as HTMLInputElement; + expect(atInput.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/); + }); + + it('edit mode prefills "every" schedule with every_ms as string', () => { + const everyJob: CoreCronJob = { ...sampleJob, schedule: { kind: 'every', every_ms: 120000 } }; + render(); + expect(screen.getByTestId('cron-form-every')).toHaveValue(120000); + }); + + it('edit mode with a non-preset custom cron expression shows the custom input', () => { + const job: CoreCronJob = { ...sampleJob, schedule: { kind: 'cron', expr: '*/7 * * * *' } }; + render(); + expect(screen.getByTestId('cron-form-cron-custom')).toHaveValue('*/7 * * * *'); + }); + + it('edit mode handleSubmit builds patch with all fields and clears name to null when blank', async () => { + const onUpdate = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.change(screen.getByTestId('cron-form-name'), { target: { value: '' } }); + fireEvent.click(screen.getByTestId('cron-form-submit')); + await waitFor(() => expect(onUpdate).toHaveBeenCalledOnce()); + const [, patch] = onUpdate.mock.calls[0]; + expect(patch.name).toBeNull(); + expect(patch.session_target).toBe('isolated'); + expect(patch.delivery).toMatchObject({ mode: 'proactive' }); + }); + + it('edit mode with shell job patches command not prompt', async () => { + const shellJob: CoreCronJob = { + ...sampleJob, + job_type: 'shell', + command: 'echo hi', + prompt: '', + }; + const onUpdate = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByTestId('cron-form-submit')); + await waitFor(() => expect(onUpdate).toHaveBeenCalledOnce()); + const [, patch] = onUpdate.mock.calls[0]; + expect(patch.command).toBe('echo hi'); + expect(patch).not.toHaveProperty('prompt'); + expect(patch).not.toHaveProperty('session_target'); + }); + + // ── Schedule kind switching back to cron resets delete flag ───────── + it('switching from "at" back to "cron" in create mode clears deleteAfterRun', () => { + render(); + fireEvent.click(screen.getByTestId('cron-form-schedule-at')); + expect(screen.getByTestId('cron-form-delete-after-run')).toBeChecked(); + fireEvent.click(screen.getByTestId('cron-form-schedule-cron')); + expect(screen.getByTestId('cron-form-delete-after-run')).not.toBeChecked(); + }); + + it('edit mode with an unparseable "at" ISO falls back to empty input', () => { + const badAtJob: CoreCronJob = { ...sampleJob, schedule: { kind: 'at', at: 'not-a-real-date' } }; + render(); + // The input stays empty when ISO can't be parsed. + expect(screen.getByTestId('cron-form-at')).toHaveValue(''); + }); + + it('changing preset dropdown to a different preset updates the expression and clears custom', () => { + render(); + // Initially first preset is selected. Pick a different preset value. + fireEvent.change(screen.getByTestId('cron-form-cron-preset'), { + target: { value: '0 9 * * *' }, + }); + // Preview should reflect the newly-picked preset + expect(screen.getByTestId('cron-form-cron-preview')).toHaveTextContent('0 9 * * *'); + }); + + // ── Toggle job type back to agent ─────────────────────────────────── + it('toggling back to agent job type restores the prompt field', () => { + render(); + fireEvent.click(screen.getByTestId('cron-form-job-type-shell')); + expect(screen.queryByTestId('cron-form-prompt')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('cron-form-job-type-agent')); + expect(screen.getByTestId('cron-form-prompt')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/panels/cron/CronJobFormModal.tsx b/app/src/components/settings/panels/cron/CronJobFormModal.tsx new file mode 100644 index 000000000..dc3d52c71 --- /dev/null +++ b/app/src/components/settings/panels/cron/CronJobFormModal.tsx @@ -0,0 +1,636 @@ +/** + * CronJobFormModal — Create / Edit cron job form modal. + * + * Reachable from CronJobsPanel via the "+ New Scheduled Job" button (create) + * or the "Edit" button per job row (edit). + */ +import createDebug from 'debug'; +import { useState } from 'react'; + +import { cronToHuman } from '../../../../lib/cron/cronToHuman'; +import { SCHEDULE_PRESET_VALUES, SCHEDULE_PRESETS } from '../../../../lib/cron/schedulePresets'; +import { useT } from '../../../../lib/i18n/I18nContext'; +import type { + CoreCronJob, + CoreCronSchedule, + CronAddParams, +} from '../../../../utils/tauriCommands/cron'; + +const log = createDebug('app:settings:CronJobFormModal'); + +// ── Types ────────────────────────────────────────────────────────────── + +type JobType = 'agent' | 'shell'; +type ScheduleKind = 'cron' | 'at' | 'every'; +type DeliveryMode = 'none' | 'proactive'; +type SessionTarget = 'isolated' | 'main'; + +export interface CronJobFormModalProps { + mode: 'create' | 'edit'; + job?: CoreCronJob; + open: boolean; + onClose: () => void; + onCreate: (params: CronAddParams) => Promise; + onUpdate: (jobId: string, patch: Record) => Promise; +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +function buildSchedule( + kind: ScheduleKind, + cronExpr: string, + atValue: string, + everyMs: string +): CoreCronSchedule | null { + if (kind === 'cron') { + const expr = cronExpr.trim(); + if (!expr) return null; + return { kind: 'cron', expr, tz: null }; + } + if (kind === 'at') { + if (!atValue) return null; + return { kind: 'at', at: new Date(atValue).toISOString() }; + } + if (kind === 'every') { + const ms = parseInt(everyMs, 10); + if (!ms || ms <= 0) return null; + return { kind: 'every', every_ms: ms }; + } + return null; +} + +function getInitialScheduleKind(job: CoreCronJob): ScheduleKind { + return job.schedule.kind; +} + +function getInitialCronExpr(job: CoreCronJob): string { + return job.schedule.kind === 'cron' ? job.schedule.expr : ''; +} + +function getInitialAtValue(job: CoreCronJob): string { + if (job.schedule.kind === 'at') { + // Convert ISO to datetime-local format (YYYY-MM-DDTHH:MM) + try { + const d = new Date(job.schedule.at); + const offset = d.getTimezoneOffset(); + const local = new Date(d.getTime() - offset * 60000); + return local.toISOString().slice(0, 16); + } catch { + return ''; + } + } + return ''; +} + +function getInitialEveryMs(job: CoreCronJob): string { + return job.schedule.kind === 'every' ? String(job.schedule.every_ms) : ''; +} + +function getInitialDelivery(job: CoreCronJob): DeliveryMode { + return job.delivery.mode === 'proactive' ? 'proactive' : 'none'; +} + +interface CronJobFormInitialState { + name: string; + jobType: JobType; + scheduleKind: ScheduleKind; + cronPreset: string; + cronCustom: string; + atValue: string; + everyMs: string; + prompt: string; + command: string; + sessionTarget: SessionTarget; + delivery: DeliveryMode; + deleteAfterRun: boolean; +} + +function getInitialFormState(mode: 'create' | 'edit', job?: CoreCronJob): CronJobFormInitialState { + if (mode === 'edit' && job) { + const scheduleKind = getInitialScheduleKind(job); + const cronExpr = getInitialCronExpr(job); + const hasPresetCron = scheduleKind === 'cron' && SCHEDULE_PRESET_VALUES.has(cronExpr); + + return { + name: job.name ?? '', + jobType: job.job_type === 'shell' ? 'shell' : 'agent', + scheduleKind, + cronPreset: + scheduleKind === 'cron' ? (hasPresetCron ? cronExpr : '') : SCHEDULE_PRESETS[0].value, + cronCustom: scheduleKind === 'cron' && !hasPresetCron ? cronExpr : '', + atValue: scheduleKind === 'at' ? getInitialAtValue(job) : '', + everyMs: scheduleKind === 'every' ? getInitialEveryMs(job) : '', + prompt: job.prompt ?? '', + command: job.command ?? '', + sessionTarget: job.session_target === 'main' ? 'main' : 'isolated', + delivery: getInitialDelivery(job), + deleteAfterRun: job.delete_after_run, + }; + } + + return { + name: '', + jobType: 'agent', + scheduleKind: 'cron', + cronPreset: SCHEDULE_PRESETS[0].value, + cronCustom: '', + atValue: '', + everyMs: '', + prompt: '', + command: '', + sessionTarget: 'isolated', + delivery: 'proactive', + deleteAfterRun: false, + }; +} + +// ── Component ────────────────────────────────────────────────────────── + +const CronJobFormModal = ({ + mode, + job, + open, + onClose, + onCreate, + onUpdate, +}: CronJobFormModalProps) => { + const { t } = useT(); + const initialState = getInitialFormState(mode, job); + + // ── Form state ───────────────────────────────────────────────────── + + const [name, setName] = useState(initialState.name); + const [jobType, setJobType] = useState(initialState.jobType); + const [scheduleKind, setScheduleKind] = useState(initialState.scheduleKind); + const [cronPreset, setCronPreset] = useState(initialState.cronPreset); + const [cronCustom, setCronCustom] = useState(initialState.cronCustom); + const [atValue, setAtValue] = useState(initialState.atValue); + const [everyMs, setEveryMs] = useState(initialState.everyMs); + const [prompt, setPrompt] = useState(initialState.prompt); + const [command, setCommand] = useState(initialState.command); + const [sessionTarget, setSessionTarget] = useState(initialState.sessionTarget); + const [delivery, setDelivery] = useState(initialState.delivery); + const [deleteAfterRun, setDeleteAfterRun] = useState(initialState.deleteAfterRun); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Effective cron expression: if preset is selected use its value, else custom + const cronExpr = SCHEDULE_PRESET_VALUES.has(cronPreset) ? cronPreset : cronCustom.trim(); + + const handleScheduleKindChange = (nextKind: ScheduleKind) => { + setScheduleKind(nextKind); + if (nextKind === 'at') { + setDeleteAfterRun(true); + } else if (mode === 'create') { + setDeleteAfterRun(false); + } + }; + + // ── Validation ────────────────────────────────────────────────────── + const schedule = buildSchedule(scheduleKind, cronExpr, atValue, everyMs); + const isScheduleValid = schedule !== null; + const isPromptValid = jobType !== 'agent' || prompt.trim().length > 0; + const isCommandValid = jobType !== 'shell' || command.trim().length > 0; + const canSubmit = isScheduleValid && isPromptValid && isCommandValid && !saving; + + // ── Submit ────────────────────────────────────────────────────────── + const handleSubmit = async () => { + if (!canSubmit || !schedule) return; + setError(null); + setSaving(true); + + log( + '[CronJobFormModal] submit mode=%s, jobType=%s, scheduleKind=%s', + mode, + jobType, + scheduleKind + ); + + try { + if (mode === 'create') { + const params: CronAddParams = { + name: name.trim() || undefined, + schedule, + job_type: jobType, + ...(jobType === 'agent' ? { prompt: prompt.trim() } : {}), + ...(jobType === 'shell' ? { command: command.trim() } : {}), + ...(jobType === 'agent' ? { session_target: sessionTarget } : {}), + ...(jobType === 'agent' + ? { delivery: { mode: delivery, best_effort: true } } + : { delivery: { mode: 'none', best_effort: false } }), + delete_after_run: deleteAfterRun, + }; + log('[CronJobFormModal] calling onCreate metadata=%o', { + mode: 'create', + jobType: params.job_type, + scheduleKind: params.schedule.kind, + hasName: Boolean(params.name), + hasSessionTarget: Boolean(params.session_target), + deleteAfterRun: params.delete_after_run, + }); + await onCreate(params); + } else { + if (!job) return; + const patch: Record = { + name: name.trim() || null, + schedule, + ...(jobType === 'agent' ? { prompt: prompt.trim() } : {}), + ...(jobType === 'shell' ? { command: command.trim() } : {}), + ...(jobType === 'agent' ? { session_target: sessionTarget } : {}), + ...(jobType === 'agent' + ? { delivery: { mode: delivery, best_effort: true } } + : { delivery: { mode: 'none', best_effort: false } }), + delete_after_run: deleteAfterRun, + }; + const patchSchedule = patch.schedule as { kind?: string } | undefined; + log('[CronJobFormModal] calling onUpdate metadata=%o', { + mode: 'edit', + jobId: job.id, + scheduleKind: patchSchedule?.kind ?? 'unknown', + hasName: patch.name !== null, + hasSessionTarget: 'session_target' in patch, + deleteAfterRun: patch.delete_after_run, + }); + await onUpdate(job.id, patch); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log('[CronJobFormModal] save error: %s', msg); + setError(t('settings.cron.jobs.formError')); + } finally { + setSaving(false); + } + }; + + if (!open) return null; + + // ── Render ────────────────────────────────────────────────────────── + const title = + mode === 'create' ? t('settings.cron.jobs.createJob') : t('settings.cron.jobs.editJob'); + + const submitLabel = saving + ? t('settings.cron.jobs.formSaving') + : mode === 'create' + ? t('settings.cron.jobs.formCreate') + : t('settings.cron.jobs.formSave'); + + return ( +
+ {/* Backdrop */} +