feat(desktop): add Cron Jobs management page

- Add "Cron" tab to navigation bar with Time04Icon
- Add /crons route with CronsPage component
- Add cron IPC handlers (list, toggle, remove) in electron/ipc/cron.ts
- Expose cron API in preload.ts for renderer process
- Add useCronJobs hook for fetching and managing jobs
- Add CronJobList component with status badges, toggle switches,
  delete buttons, relative time display, and empty state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-02-06 15:16:33 +08:00
parent 9bd18472b8
commit a8dd9f2bbb
8 changed files with 447 additions and 0 deletions

View file

@ -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 }
})
}

View file

@ -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()
}
/**

View file

@ -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 */

View file

@ -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: <ToolsPage /> },
{ path: 'skills', element: <SkillsPage /> },
{ path: 'crons', element: <CronsPage /> },
],
},
])

View file

@ -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<void>
onRemoveJob: (jobId: string) => Promise<void>
onRefresh: () => Promise<void>
}
function StatusBadge({ status }: { status: CronJobInfo['lastStatus'] }) {
if (!status) {
return (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
no runs
</span>
)
}
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 (
<span className={`flex items-center gap-1 text-xs ${config.className}`}>
<HugeiconsIcon icon={config.icon} className="size-3.5" />
{config.label}
</span>
)
}
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<Set<string>>(new Set())
const [removingJobs, setRemovingJobs] = useState<Set<string>>(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 (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading cron jobs...</span>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
{jobs.filter((j) => j.enabled).length} of {jobs.length} jobs enabled
</div>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="gap-1.5"
disabled={loading}
>
<HugeiconsIcon
icon={loading ? Loading03Icon : RotateClockwiseIcon}
className={`size-4 ${loading ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Empty state */}
{jobs.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<HugeiconsIcon icon={Time04Icon} className="size-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No scheduled tasks</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Use the cron tool in Chat to create one.
</p>
</div>
)}
{/* Job list */}
{jobs.length > 0 && (
<div className="border rounded-lg divide-y">
{jobs.map((job) => {
const isToggling = togglingJobs.has(job.id)
const isRemoving = removingJobs.has(job.id)
return (
<div
key={job.id}
className="flex items-center gap-4 px-4 py-3 hover:bg-muted/20 transition-colors"
>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{job.name}</span>
<StatusBadge status={job.lastStatus} />
</div>
<div className="flex items-center gap-3 mt-0.5 text-xs text-muted-foreground">
<span className="font-mono">{job.schedule}</span>
{job.nextRunAt && job.enabled && (
<span>next: {formatRelativeTime(job.nextRunAt)}</span>
)}
{job.lastRunAt && (
<span>last: {formatRelativeTime(job.lastRunAt)}</span>
)}
</div>
{job.lastError && (
<p className="text-xs text-destructive mt-0.5 truncate">{job.lastError}</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => handleRemove(job.id)}
disabled={isRemoving}
>
{isRemoving ? (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
) : (
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
)}
</Button>
{isToggling && (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin text-muted-foreground" />
)}
<Switch
checked={job.enabled}
onCheckedChange={() => handleToggle(job.id)}
disabled={isToggling}
/>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
export default CronJobList

View file

@ -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<void>
removeJob: (jobId: string) => Promise<void>
refresh: () => Promise<void>
}
export function useCronJobs(): UseCronJobsReturn {
const [jobs, setJobs] = useState<CronJobInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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

View file

@ -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 (
<div className="max-w-4xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Cron Jobs</CardTitle>
<CardDescription>
View and manage scheduled tasks. Create new jobs by asking the Agent in Chat.
</CardDescription>
</CardHeader>
<CardContent>
<CronJobList
jobs={jobs}
loading={loading}
error={error}
onToggleJob={toggleJob}
onRemoveJob={removeJob}
onRefresh={refresh}
/>
</CardContent>
</Card>
</div>
)
}

View file

@ -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() {