diff --git a/apps/desktop/electron/ipc/cron.ts b/apps/desktop/electron/ipc/cron.ts new file mode 100644 index 00000000..19cf591f --- /dev/null +++ b/apps/desktop/electron/ipc/cron.ts @@ -0,0 +1,68 @@ +/** + * Cron IPC handlers for Electron main process. + * + * These handlers expose CronService operations to the renderer process + * for the Cron Jobs management page. + */ +import { ipcMain } from 'electron' +import { getCronService, formatSchedule } from '../../../../src/cron/index.js' + +/** + * Register all Cron-related IPC handlers. + */ +export function registerCronIpcHandlers(): void { + /** + * List all cron jobs with formatted display fields. + */ + ipcMain.handle('cron:list', async () => { + const service = getCronService() + const jobs = service.list() + + return jobs.map((job) => ({ + id: job.id, + name: job.name, + description: job.description, + enabled: job.enabled, + schedule: formatSchedule(job.schedule), + sessionTarget: job.sessionTarget, + nextRunAt: job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : null, + lastStatus: job.state.lastStatus ?? null, + lastRunAt: job.state.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null, + lastDurationMs: job.state.lastDurationMs ?? null, + lastError: job.state.lastError ?? null, + })) + }) + + /** + * Toggle a cron job's enabled status. + */ + ipcMain.handle('cron:toggle', async (_event, jobId: string) => { + const service = getCronService() + const job = service.get(jobId) + if (!job) { + return { error: `Job not found: ${jobId}` } + } + + const updated = service.update(jobId, { enabled: !job.enabled }) + if (!updated) { + return { error: `Failed to update job: ${jobId}` } + } + + return { + id: updated.id, + enabled: updated.enabled, + } + }) + + /** + * Remove a cron job. + */ + ipcMain.handle('cron:remove', async (_event, jobId: string) => { + const service = getCronService() + const removed = service.remove(jobId) + if (!removed) { + return { error: `Job not found: ${jobId}` } + } + return { ok: true } + }) +} diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index fc11179c..fc9335ee 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -6,12 +6,14 @@ export { registerSkillsIpcHandlers } from './skills.js' export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js' export { registerProfileIpcHandlers } from './profile.js' export { registerProviderIpcHandlers } from './provider.js' +export { registerCronIpcHandlers } from './cron.js' import { registerAgentIpcHandlers, cleanupAgent } from './agent.js' import { registerSkillsIpcHandlers } from './skills.js' import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js' import { registerProfileIpcHandlers } from './profile.js' import { registerProviderIpcHandlers } from './provider.js' +import { registerCronIpcHandlers } from './cron.js' /** * Register all IPC handlers. @@ -23,6 +25,7 @@ export function registerAllIpcHandlers(): void { registerSkillsIpcHandlers() registerProfileIpcHandlers() registerProviderIpcHandlers() + registerCronIpcHandlers() } /** diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index a0d3d00e..1ea1a64c 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -201,6 +201,13 @@ const electronAPI = { ipcRenderer.invoke('provider:importOAuth', providerId), }, + // Cron jobs management + cron: { + list: () => ipcRenderer.invoke('cron:list'), + toggle: (jobId: string) => ipcRenderer.invoke('cron:toggle', jobId), + remove: (jobId: string) => ipcRenderer.invoke('cron:remove', jobId), + }, + // Local chat (direct IPC, no Gateway required) localChat: { /** Subscribe to agent events for local direct chat */ diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index b67f362f..48cc4883 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -3,6 +3,7 @@ import Layout from './pages/layout' import HomePage from './pages/home' import ToolsPage from './pages/tools' import SkillsPage from './pages/skills' +import CronsPage from './pages/crons' const router = createHashRouter([ { @@ -13,6 +14,7 @@ const router = createHashRouter([ { path: 'chat' }, { path: 'tools', element: }, { path: 'skills', element: }, + { path: 'crons', element: }, ], }, ]) diff --git a/apps/desktop/src/components/cron-job-list.tsx b/apps/desktop/src/components/cron-job-list.tsx new file mode 100644 index 00000000..d01a2613 --- /dev/null +++ b/apps/desktop/src/components/cron-job-list.tsx @@ -0,0 +1,215 @@ +import { useState } from 'react' +import { Switch } from '@multica/ui/components/ui/switch' +import { Button } from '@multica/ui/components/ui/button' +import { HugeiconsIcon } from '@hugeicons/react' +import { + RotateClockwiseIcon, + Delete02Icon, + Loading03Icon, + Time04Icon, + CheckmarkCircle02Icon, + CancelCircleIcon, + AlertCircleIcon, +} from '@hugeicons/core-free-icons' +import type { CronJobInfo } from '../hooks/use-cron-jobs' + +interface CronJobListProps { + jobs: CronJobInfo[] + loading: boolean + error: string | null + onToggleJob: (jobId: string) => Promise + onRemoveJob: (jobId: string) => Promise + onRefresh: () => Promise +} + +function StatusBadge({ status }: { status: CronJobInfo['lastStatus'] }) { + if (!status) { + return ( + + no runs + + ) + } + + const config = { + ok: { icon: CheckmarkCircle02Icon, className: 'text-emerald-600', label: 'ok' }, + error: { icon: CancelCircleIcon, className: 'text-destructive', label: 'error' }, + skipped: { icon: AlertCircleIcon, className: 'text-yellow-600', label: 'skipped' }, + }[status] + + return ( + + + {config.label} + + ) +} + +function formatRelativeTime(isoString: string): string { + const date = new Date(isoString) + const now = Date.now() + const diffMs = date.getTime() - now + + if (Math.abs(diffMs) < 60_000) return 'just now' + + const absMs = Math.abs(diffMs) + const minutes = Math.floor(absMs / 60_000) + const hours = Math.floor(absMs / 3_600_000) + const days = Math.floor(absMs / 86_400_000) + + const unit = days > 0 ? `${days}d` : hours > 0 ? `${hours}h` : `${minutes}m` + return diffMs > 0 ? `in ${unit}` : `${unit} ago` +} + +export function CronJobList({ + jobs, + loading, + error, + onToggleJob, + onRemoveJob, + onRefresh, +}: CronJobListProps) { + const [togglingJobs, setTogglingJobs] = useState>(new Set()) + const [removingJobs, setRemovingJobs] = useState>(new Set()) + + const handleToggle = async (jobId: string) => { + setTogglingJobs((prev) => new Set(prev).add(jobId)) + try { + await onToggleJob(jobId) + } finally { + setTogglingJobs((prev) => { + const next = new Set(prev) + next.delete(jobId) + return next + }) + } + } + + const handleRemove = async (jobId: string) => { + setRemovingJobs((prev) => new Set(prev).add(jobId)) + try { + await onRemoveJob(jobId) + } finally { + setRemovingJobs((prev) => { + const next = new Set(prev) + next.delete(jobId) + return next + }) + } + } + + if (loading && jobs.length === 0) { + return ( +
+ + Loading cron jobs... +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ {jobs.filter((j) => j.enabled).length} of {jobs.length} jobs enabled +
+ +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Empty state */} + {jobs.length === 0 && !loading && ( +
+ +

No scheduled tasks

+

+ Use the cron tool in Chat to create one. +

+
+ )} + + {/* Job list */} + {jobs.length > 0 && ( +
+ {jobs.map((job) => { + const isToggling = togglingJobs.has(job.id) + const isRemoving = removingJobs.has(job.id) + + return ( +
+ {/* Info */} +
+
+ {job.name} + +
+
+ {job.schedule} + {job.nextRunAt && job.enabled && ( + next: {formatRelativeTime(job.nextRunAt)} + )} + {job.lastRunAt && ( + last: {formatRelativeTime(job.lastRunAt)} + )} +
+ {job.lastError && ( +

{job.lastError}

+ )} +
+ + {/* Actions */} +
+ + {isToggling && ( + + )} + handleToggle(job.id)} + disabled={isToggling} + /> +
+
+ ) + })} +
+ )} +
+ ) +} + +export default CronJobList diff --git a/apps/desktop/src/hooks/use-cron-jobs.ts b/apps/desktop/src/hooks/use-cron-jobs.ts new file mode 100644 index 00000000..e01c81e3 --- /dev/null +++ b/apps/desktop/src/hooks/use-cron-jobs.ts @@ -0,0 +1,107 @@ +import { useState, useEffect, useCallback } from 'react' + +export interface CronJobInfo { + id: string + name: string + description?: string + enabled: boolean + schedule: string + sessionTarget: string + nextRunAt: string | null + lastStatus: 'ok' | 'error' | 'skipped' | null + lastRunAt: string | null + lastDurationMs: number | null + lastError: string | null +} + +export interface UseCronJobsReturn { + jobs: CronJobInfo[] + loading: boolean + error: string | null + toggleJob: (jobId: string) => Promise + removeJob: (jobId: string) => Promise + refresh: () => Promise +} + +export function useCronJobs(): UseCronJobsReturn { + const [jobs, setJobs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchJobs = useCallback(async () => { + try { + setLoading(true) + setError(null) + + const result = window.electronAPI + ? await window.electronAPI.cron.list() + : await window.ipcRenderer.invoke('cron:list') + + if (Array.isArray(result)) { + setJobs(result) + } else { + setError('Invalid response from cron:list') + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch cron jobs') + setJobs([]) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchJobs() + }, [fetchJobs]) + + const toggleJob = useCallback(async (jobId: string) => { + try { + const result = window.electronAPI + ? await window.electronAPI.cron.toggle(jobId) + : await window.ipcRenderer.invoke('cron:toggle', jobId) + + const typed = result as { error?: string; id?: string; enabled?: boolean } + if (typed.error) { + setError(typed.error) + return + } + + setJobs((prev) => + prev.map((job) => + job.id === jobId ? { ...job, enabled: typed.enabled ?? !job.enabled } : job + ) + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to toggle job') + } + }, []) + + const removeJob = useCallback(async (jobId: string) => { + try { + const result = window.electronAPI + ? await window.electronAPI.cron.remove(jobId) + : await window.ipcRenderer.invoke('cron:remove', jobId) + + const typed = result as { error?: string; ok?: boolean } + if (typed.error) { + setError(typed.error) + return + } + + setJobs((prev) => prev.filter((job) => job.id !== jobId)) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove job') + } + }, []) + + return { + jobs, + loading, + error, + toggleJob, + removeJob, + refresh: fetchJobs, + } +} + +export default useCronJobs diff --git a/apps/desktop/src/pages/crons.tsx b/apps/desktop/src/pages/crons.tsx new file mode 100644 index 00000000..0947d5c3 --- /dev/null +++ b/apps/desktop/src/pages/crons.tsx @@ -0,0 +1,43 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@multica/ui/components/ui/card' +import { useCronJobs } from '../hooks/use-cron-jobs' +import { CronJobList } from '../components/cron-job-list' + +export default function CronsPage() { + const { + jobs, + loading, + error, + toggleJob, + removeJob, + refresh, + } = useCronJobs() + + return ( +
+ + + Cron Jobs + + View and manage scheduled tasks. Create new jobs by asking the Agent in Chat. + + + + + + +
+ ) +} diff --git a/apps/desktop/src/pages/layout.tsx b/apps/desktop/src/pages/layout.tsx index 85e97e15..7cb021a0 100644 --- a/apps/desktop/src/pages/layout.tsx +++ b/apps/desktop/src/pages/layout.tsx @@ -8,6 +8,7 @@ import { CodeIcon, PlugIcon, Comment01Icon, + Time04Icon, } from '@hugeicons/core-free-icons' import { cn } from '@multica/ui/lib/utils' import { DeviceConfirmDialog } from '../components/device-confirm-dialog' @@ -18,6 +19,7 @@ const tabs = [ { path: '/chat', label: 'Chat', icon: Comment01Icon }, { path: '/tools', label: 'Tools', icon: CodeIcon }, { path: '/skills', label: 'Skills', icon: PlugIcon }, + { path: '/crons', label: 'Cron', icon: Time04Icon }, ] export default function Layout() {