feat: enhance scheduled tasks with flexible scheduling, run now, execution logs, and edit support

- Add 7 frequency modes (every N min/hours, daily, weekdays, specific days, monthly, custom cron) with progressive disclosure UI
- Add "Run Now" button with confirmation popover and fire-and-forget API
- Add execution logs panel (TaskRunsPanel) with auto-polling and accordion behavior
- Add task edit mode with cron reverse-parsing (parseCron) to populate form
- Add server-side extractAssistantText to store meaningful AI responses instead of raw NDJSON
- Fix session linking: pass --session-id to CLI subprocess so "View conversation" navigates to actual content
- Fix MACRO undefined error by adding --preload to Bun.spawn
- Add confirmation popovers for all destructive actions (run/disable/delete)
- Add DayOfWeekPicker component for specific-days scheduling
- Add cronDescribe utility with i18n support and unit tests
- Display task creation time and last run time
- Add ~50 i18n keys (en/zh) for all new UI elements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-08 17:40:13 +08:00
parent 41fb22790a
commit 983d83f86b
15 changed files with 1352 additions and 94 deletions

View File

@ -22,6 +22,10 @@ export const tasksApi = {
return api.delete<{ ok: true }>(`/api/scheduled-tasks/${id}`)
},
runTask(id: string) {
return api.post<{ ok: true }>(`/api/scheduled-tasks/${id}/run`, {})
},
getRecentRuns(limit = 50) {
return api.get<RunsResponse>(`/api/scheduled-tasks/runs?limit=${limit}`)
},

View File

@ -0,0 +1,57 @@
import { useTranslation } from '../../i18n'
type Props = {
selected: number[]
onChange: (days: number[]) => void
}
// Display order: Mon(1) → Sun(0), matching Chinese convention
const DAY_ORDER = [1, 2, 3, 4, 5, 6, 0]
const DAY_KEYS = [
'newTask.daySun',
'newTask.dayMon',
'newTask.dayTue',
'newTask.dayWed',
'newTask.dayThu',
'newTask.dayFri',
'newTask.daySat',
] as const
export function DayOfWeekPicker({ selected, onChange }: Props) {
const t = useTranslation()
const toggle = (day: number) => {
if (selected.includes(day)) {
// Don't allow deselecting the last day
if (selected.length <= 1) return
onChange(selected.filter((d) => d !== day))
} else {
onChange([...selected, day])
}
}
return (
<div className="flex gap-1.5">
{DAY_ORDER.map((day) => {
const isActive = selected.includes(day)
return (
<button
key={day}
type="button"
onClick={() => toggle(day)}
className={`
w-8 h-8 rounded-full text-xs font-medium transition-colors
${isActive
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] border border-[var(--color-border-focus)]'
: 'bg-[var(--color-surface)] text-[var(--color-text-tertiary)] border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
{t(DAY_KEYS[day] as any)}
</button>
)
})}
</div>
)
}

View File

@ -5,100 +5,146 @@ import { Modal } from '../shared/Modal'
import { Input } from '../shared/Input'
import { Button } from '../shared/Button'
import { PromptEditor } from './PromptEditor'
import { DayOfWeekPicker } from './DayOfWeekPicker'
import { useTranslation } from '../../i18n'
import { describeCron, isValidCron, parseCron, type FrequencyKey } from '../../lib/cronDescribe'
import type { PermissionMode } from '../../types/settings'
import type { CronTask } from '../../types/task'
type Props = {
open: boolean
onClose: () => void
editTask?: CronTask
}
type FrequencyKey = 'hourly' | 'daily' | 'weekdays' | 'weekly' | 'monthly'
const MINUTE_INTERVALS = [5, 10, 15, 20, 30]
const HOUR_INTERVALS = [1, 2, 3, 4, 6, 8, 12]
const MINUTE_OFFSETS = [0, 15, 30, 45]
function buildCron(freq: FrequencyKey, time: string): string {
function buildCron(
freq: FrequencyKey,
time: string,
opts: {
minuteInterval: number
hourInterval: number
minuteOffset: number
selectedDays: number[]
monthDay: number
customCron: string
},
): string {
const [hours, minutes] = time.split(':').map(Number)
switch (freq) {
case 'hourly': return '0 * * * *'
case 'daily': return `${minutes} ${hours} * * *`
case 'weekdays': return `${minutes} ${hours} * * 1-5`
case 'weekly': return `${minutes} ${hours} * * 1`
case 'monthly': return `${minutes} ${hours} 1 * *`
case 'everyNMinutes':
return `*/${opts.minuteInterval} * * * *`
case 'everyNHours':
return `${opts.minuteOffset} */${opts.hourInterval} * * *`
case 'daily':
return `${minutes} ${hours} * * *`
case 'weekdays':
return `${minutes} ${hours} * * 1-5`
case 'specificDays':
return `${minutes} ${hours} * * ${[...opts.selectedDays].sort((a, b) => a - b).join(',')}`
case 'monthly':
return `${minutes} ${hours} ${opts.monthDay} * *`
case 'customCron':
return opts.customCron.trim()
}
}
export function NewTaskModal({ open, onClose }: Props) {
export function NewTaskModal({ open, onClose, editTask }: Props) {
const t = useTranslation()
const { createTask } = useTaskStore()
const { createTask, updateTask } = useTaskStore()
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const activeSession = sessions.find((s) => s.id === activeSessionId)
const defaultWorkDir = activeSession?.workDir || ''
const FREQUENCY_OPTIONS: Array<{ value: FrequencyKey; label: string; showTime: boolean }> = [
{ value: 'hourly', label: t('newTask.hourly'), showTime: false },
{ value: 'daily', label: t('newTask.daily'), showTime: true },
{ value: 'weekdays', label: t('newTask.weekdays'), showTime: true },
{ value: 'weekly', label: t('newTask.weekly'), showTime: true },
{ value: 'monthly', label: t('newTask.monthly'), showTime: true },
const isEdit = !!editTask
const parsed = editTask ? parseCron(editTask.cron) : null
const FREQUENCY_OPTIONS: Array<{ value: FrequencyKey; label: string }> = [
{ value: 'everyNMinutes', label: t('newTask.everyNMinutes') },
{ value: 'everyNHours', label: t('newTask.everyNHours') },
{ value: 'daily', label: t('newTask.daily') },
{ value: 'weekdays', label: t('newTask.weekdays') },
{ value: 'specificDays', label: t('newTask.specificDays') },
{ value: 'monthly', label: t('newTask.monthly') },
{ value: 'customCron', label: t('newTask.customCron') },
]
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [prompt, setPrompt] = useState('')
const [frequency, setFrequency] = useState<FrequencyKey>('daily')
const [time, setTime] = useState('09:00')
const [model, setModel] = useState('')
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default')
const [folderPath, setFolderPath] = useState(defaultWorkDir)
const [useWorktree, setUseWorktree] = useState(false)
const [name, setName] = useState(editTask?.name || '')
const [description, setDescription] = useState(editTask?.description || '')
const [prompt, setPrompt] = useState(editTask?.prompt || '')
const [frequency, setFrequency] = useState<FrequencyKey>(parsed?.frequency || 'daily')
const [time, setTime] = useState(parsed?.time || '09:00')
const [model, setModel] = useState(editTask?.model || '')
const [permissionMode, setPermissionMode] = useState<PermissionMode>((editTask?.permissionMode as PermissionMode) || 'default')
const [folderPath, setFolderPath] = useState(editTask?.folderPath || defaultWorkDir)
const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false)
const [isSubmitting, setIsSubmitting] = useState(false)
const canSubmit = name.trim() && description.trim() && prompt.trim()
const showTime = FREQUENCY_OPTIONS.find((o) => o.value === frequency)?.showTime ?? false
// Enhanced scheduling state
const [minuteInterval, setMinuteInterval] = useState(parsed?.minuteInterval || 15)
const [hourInterval, setHourInterval] = useState(parsed?.hourInterval || 1)
const [minuteOffset, setMinuteOffset] = useState(parsed?.minuteOffset || 0)
const [selectedDays, setSelectedDays] = useState<number[]>(parsed?.selectedDays || [1])
const [monthDay, setMonthDay] = useState(parsed?.monthDay || 1)
const [customCron, setCustomCron] = useState(parsed?.customCron || '0 9 * * *')
const showTime = ['daily', 'weekdays', 'specificDays', 'monthly'].includes(frequency)
const cronValue = buildCron(frequency, time, {
minuteInterval, hourInterval, minuteOffset, selectedDays, monthDay, customCron,
})
const canSubmit =
name.trim() &&
description.trim() &&
prompt.trim() &&
(frequency !== 'customCron' || isValidCron(customCron)) &&
(frequency !== 'specificDays' || selectedDays.length > 0)
const handleSubmit = async () => {
if (!canSubmit) return
setIsSubmitting(true)
try {
await createTask({
const payload = {
name: name.trim(),
description: description.trim(),
cron: buildCron(frequency, time),
cron: cronValue,
prompt: prompt.trim(),
enabled: true,
recurring: true,
model: model || undefined,
permissionMode: permissionMode !== 'default' ? permissionMode : undefined,
folderPath: folderPath.trim() || undefined,
useWorktree: useWorktree || undefined,
})
// Reset form
setName('')
setDescription('')
setPrompt('')
setFrequency('daily')
setTime('09:00')
setModel('')
setPermissionMode('default')
setFolderPath('')
setUseWorktree(false)
}
if (isEdit) {
await updateTask(editTask!.id, payload)
} else {
await createTask({ ...payload, enabled: true, recurring: true })
}
onClose()
} catch (err) {
console.error('Failed to create task:', err)
console.error(`Failed to ${isEdit ? 'update' : 'create'} task:`, err)
} finally {
setIsSubmitting(false)
}
}
const selectClass = 'w-full h-10 px-3 pr-8 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)] appearance-none cursor-pointer'
return (
<Modal
open={open}
onClose={onClose}
title={t('newTask.title')}
title={isEdit ? t('tasks.editTitle') : t('newTask.title')}
footer={
<>
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>{t('newTask.create')}</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
{isEdit ? t('tasks.saveChanges') : t('newTask.create')}
</Button>
</>
}
>
@ -149,7 +195,7 @@ export function NewTaskModal({ open, onClose }: Props) {
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as FrequencyKey)}
className="w-full h-10 px-3 pr-8 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)] appearance-none cursor-pointer"
className={selectClass}
>
{FREQUENCY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
@ -161,7 +207,95 @@ export function NewTaskModal({ open, onClose }: Props) {
</div>
</div>
{/* Time picker — shown when frequency supports specific time */}
{/* Sub-controls based on frequency */}
{frequency === 'everyNMinutes' && (
<div className="relative">
<select
value={minuteInterval}
onChange={(e) => setMinuteInterval(Number(e.target.value))}
className={selectClass}
>
{MINUTE_INTERVALS.map((n) => (
<option key={n} value={n}>{t('newTask.intervalMinutes', { n })}</option>
))}
</select>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
expand_more
</span>
</div>
)}
{frequency === 'everyNHours' && (
<div className="flex gap-2">
<div className="relative flex-1">
<select
value={hourInterval}
onChange={(e) => setHourInterval(Number(e.target.value))}
className={selectClass}
>
{HOUR_INTERVALS.map((n) => (
<option key={n} value={n}>{t('newTask.intervalHours', { n })}</option>
))}
</select>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
expand_more
</span>
</div>
<div className="relative flex-1">
<select
value={minuteOffset}
onChange={(e) => setMinuteOffset(Number(e.target.value))}
className={selectClass}
>
{MINUTE_OFFSETS.map((m) => (
<option key={m} value={m}>{t('newTask.atMinute', { m: m.toString().padStart(2, '0') })}</option>
))}
</select>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
expand_more
</span>
</div>
</div>
)}
{frequency === 'specificDays' && (
<DayOfWeekPicker selected={selectedDays} onChange={setSelectedDays} />
)}
{frequency === 'monthly' && (
<div className="relative">
<select
value={monthDay}
onChange={(e) => setMonthDay(Number(e.target.value))}
className={selectClass}
>
{Array.from({ length: 28 }, (_, i) => i + 1).map((d) => (
<option key={d} value={d}>{t('newTask.onMonthDay', { d })}</option>
))}
</select>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
expand_more
</span>
</div>
)}
{frequency === 'customCron' && (
<div className="flex flex-col gap-1">
<input
type="text"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
placeholder={t('newTask.cronFormatHint')}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] font-[var(--font-mono)] outline-none focus:border-[var(--color-border-focus)]"
/>
<span className="text-xs text-[var(--color-text-tertiary)]">{t('newTask.cronFormatHint')}</span>
{customCron.trim() && !isValidCron(customCron) && (
<span className="text-xs text-[var(--color-error)]">{t('newTask.invalidCron')}</span>
)}
</div>
)}
{/* Time picker — shown for daily, weekdays, specificDays, monthly */}
{showTime && (
<div className="flex flex-col gap-1">
<input
@ -174,6 +308,17 @@ export function NewTaskModal({ open, onClose }: Props) {
</div>
)}
{/* Cron preview */}
<div className="flex items-center gap-2 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] text-xs text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[16px]">schedule</span>
<span>
{frequency === 'customCron' && customCron.trim() && !isValidCron(customCron)
? t('newTask.invalidCron')
: describeCron(cronValue, t)
}
</span>
</div>
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('newTask.delayNote')}
</p>

View File

@ -1,3 +1,4 @@
import { useState } from 'react'
import type { CronTask } from '../../types/task'
import { TaskRow } from './TaskRow'
import { useTranslation } from '../../i18n'
@ -9,6 +10,7 @@ type Props = {
export function TaskList({ tasks }: Props) {
const t = useTranslation()
const enabledCount = tasks.filter((task) => task.enabled).length
const [expandedLogsId, setExpandedLogsId] = useState<string | null>(null)
return (
<div>
@ -19,10 +21,15 @@ export function TaskList({ tasks }: Props) {
<StatCard label={t('tasks.disabled')} value={String(tasks.length - enabledCount)} />
</div>
{/* Task rows */}
{/* Task rows — accordion: only one logs panel open at a time */}
<div className="flex flex-col">
{tasks.map((task) => (
<TaskRow key={task.id} task={task} />
<TaskRow
key={task.id}
task={task}
showLogs={expandedLogsId === task.id}
onToggleLogs={() => setExpandedLogsId(expandedLogsId === task.id ? null : task.id)}
/>
))}
</div>
</div>

View File

@ -1,52 +1,250 @@
import { useState, useRef, useEffect } from 'react'
import type { CronTask } from '../../types/task'
import { useTaskStore } from '../../stores/taskStore'
import { useTranslation } from '../../i18n'
import { describeCron } from '../../lib/cronDescribe'
import { TaskRunsPanel } from './TaskRunsPanel'
import { NewTaskModal } from './NewTaskModal'
type Props = {
task: CronTask
showLogs: boolean
onToggleLogs: () => void
}
export function TaskRow({ task }: Props) {
const { deleteTask, updateTask } = useTaskStore()
const t = useTranslation()
type ConfirmAction = 'run' | 'toggle' | 'delete' | null
const toggleEnabled = () => {
export function TaskRow({ task, showLogs, onToggleLogs }: Props) {
const { deleteTask, updateTask, runTask } = useTaskStore()
const t = useTranslation()
const [showEdit, setShowEdit] = useState(false)
const [showMenu, setShowMenu] = useState(false)
const [isRunning, setIsRunning] = useState(false)
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null)
const [logsRefreshKey, setLogsRefreshKey] = useState(0)
const menuRef = useRef<HTMLDivElement>(null)
const confirmRef = useRef<HTMLDivElement>(null)
// Close menu / confirm on outside click
useEffect(() => {
if (!showMenu && !confirmAction) return
const handler = (e: MouseEvent) => {
const target = e.target as Node
if (showMenu && menuRef.current && !menuRef.current.contains(target)) {
setShowMenu(false)
}
if (confirmAction && confirmRef.current && !confirmRef.current.contains(target)) {
setConfirmAction(null)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [showMenu, confirmAction])
const handleRunNow = async () => {
setConfirmAction(null)
setIsRunning(true)
if (!showLogs) onToggleLogs() // open logs panel (accordion will close others)
try {
await runTask(task.id)
setLogsRefreshKey((k) => k + 1)
} catch (err) {
console.error('Failed to run task:', err)
} finally {
setIsRunning(false)
}
}
const handleToggle = () => {
setConfirmAction(null)
setShowMenu(false)
updateTask(task.id, { enabled: !task.enabled })
}
return (
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--color-border-separator)] hover:bg-[var(--color-surface-hover)] transition-colors group">
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Status indicator */}
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${task.enabled ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
const handleDelete = () => {
setConfirmAction(null)
setShowMenu(false)
deleteTask(task.id)
}
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">{task.name}</div>
{task.description && (
<div className="text-xs text-[var(--color-text-secondary)] truncate">{task.description}</div>
)}
const iconBtn = 'p-1.5 rounded-[var(--radius-sm)] transition-colors'
const menuItem = 'flex items-center gap-2.5 w-full px-3 py-2 text-xs text-left rounded-[var(--radius-sm)] transition-colors'
return (
<div className="border-b border-[var(--color-border-separator)]">
<div className="flex items-center justify-between px-4 py-3 hover:bg-[var(--color-surface-hover)] transition-colors group">
{/* Left: status + info */}
<div className="flex items-center gap-3 min-w-0 flex-1">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${task.enabled ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">{task.name}</div>
{task.description && (
<div className="text-xs text-[var(--color-text-secondary)] truncate">{task.description}</div>
)}
<div className="flex items-center gap-3 text-[11px] text-[var(--color-text-tertiary)] mt-0.5">
<span>{t('tasks.createdAt')}{new Date(task.createdAt).toLocaleDateString()}</span>
{task.lastFiredAt && (
<span>{t('tasks.lastRunAt')}{new Date(task.lastFiredAt).toLocaleDateString()}</span>
)}
</div>
</div>
</div>
{/* Right: cron + actions */}
<div className="flex items-center gap-3 flex-shrink-0">
<span className="text-xs text-[var(--color-text-tertiary)]" title={task.cron}>
{describeCron(task.cron, t)}
</span>
<div className="flex items-center gap-0.5">
{/* Run Now */}
<div className="relative" ref={confirmAction === 'run' ? confirmRef : undefined}>
<button
onClick={() => isRunning ? undefined : setConfirmAction(confirmAction === 'run' ? null : 'run')}
disabled={isRunning}
className={`${iconBtn} text-[var(--color-brand)] hover:bg-[var(--color-surface-selected)] disabled:opacity-50`}
title={t('tasks.runNow')}
>
<span className={`material-symbols-outlined text-[18px] ${isRunning ? 'animate-spin' : ''}`}>
{isRunning ? 'sync' : 'play_arrow'}
</span>
</button>
{confirmAction === 'run' && (
<ConfirmPopover
message={t('tasks.confirmRun')}
confirmLabel={t('tasks.runNow')}
onConfirm={handleRunNow}
onCancel={() => setConfirmAction(null)}
cancelLabel={t('common.cancel')}
/>
)}
</div>
{/* View Logs */}
<button
onClick={onToggleLogs}
className={`${iconBtn} ${showLogs ? 'text-[var(--color-brand)] bg-[var(--color-surface-selected)]' : 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-selected)]'}`}
title={t('tasks.viewLogs')}
>
<span className="material-symbols-outlined text-[18px]">receipt_long</span>
</button>
{/* More menu */}
<div className="relative" ref={menuRef}>
<button
onClick={() => { setShowMenu(!showMenu); setConfirmAction(null) }}
className={`${iconBtn} text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-selected)]`}
>
<span className="material-symbols-outlined text-[18px]">more_vert</span>
</button>
{showMenu && !confirmAction && (
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-lg py-1">
{/* Edit */}
<button
onClick={() => { setShowMenu(false); setShowEdit(true) }}
className={`${menuItem} text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]`}
>
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-secondary)]">edit</span>
{t('tasks.edit')}
</button>
{/* Toggle */}
<button
onClick={() => setConfirmAction('toggle')}
className={`${menuItem} text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]`}
>
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-secondary)]">
{task.enabled ? 'pause_circle' : 'play_circle'}
</span>
{task.enabled ? t('common.disable') : t('common.enable')}
</button>
<div className="my-1 h-px bg-[var(--color-border-separator)]" />
{/* Delete */}
<button
onClick={() => setConfirmAction('delete')}
className={`${menuItem} text-[var(--color-error)] hover:bg-red-50`}
>
<span className="material-symbols-outlined text-[16px]">delete</span>
{t('common.delete')}
</button>
</div>
)}
{/* Confirm popovers for menu actions */}
{confirmAction === 'toggle' && (
<div ref={confirmRef}>
<ConfirmPopover
message={task.enabled ? t('tasks.confirmDisable') : t('tasks.confirmEnable')}
confirmLabel={task.enabled ? t('common.disable') : t('common.enable')}
onConfirm={handleToggle}
onCancel={() => { setConfirmAction(null); setShowMenu(false) }}
cancelLabel={t('common.cancel')}
/>
</div>
)}
{confirmAction === 'delete' && (
<div ref={confirmRef}>
<ConfirmPopover
message={t('tasks.confirmDelete')}
confirmLabel={t('common.delete')}
onConfirm={handleDelete}
onCancel={() => { setConfirmAction(null); setShowMenu(false) }}
cancelLabel={t('common.cancel')}
variant="error"
/>
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-4 flex-shrink-0">
{/* Cron expression */}
<span className="text-xs text-[var(--color-text-tertiary)] font-[var(--font-mono)]">{task.cron}</span>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={toggleEnabled}
className="px-2 py-1 text-xs rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-selected)] transition-colors"
>
{task.enabled ? t('common.disable') : t('common.enable')}
</button>
<button
onClick={() => deleteTask(task.id)}
className="px-2 py-1 text-xs rounded-[var(--radius-sm)] text-[var(--color-error)] hover:bg-red-50 transition-colors"
>
{t('common.delete')}
</button>
{/* Runs panel */}
{showLogs && (
<div className="px-4 pb-3">
<TaskRunsPanel taskId={task.id} onClose={onToggleLogs} refreshKey={logsRefreshKey} />
</div>
)}
{/* Edit modal */}
{showEdit && (
<NewTaskModal open editTask={task} onClose={() => setShowEdit(false)} />
)}
</div>
)
}
// ─── Confirm Popover ─────────────────────────────────────────────────────────
function ConfirmPopover({ message, confirmLabel, onConfirm, onCancel, cancelLabel, variant = 'brand' }: {
message: string
confirmLabel: string
onConfirm: () => void
onCancel: () => void
cancelLabel: string
variant?: 'brand' | 'error'
}) {
return (
<div className="absolute right-0 top-full mt-1.5 z-50 w-52 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-lg p-3">
<p className="text-xs text-[var(--color-text-secondary)] mb-2.5">{message}</p>
<div className="flex justify-end gap-1.5">
<button
onClick={onCancel}
className="px-2.5 py-1 text-xs rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`px-2.5 py-1 text-xs rounded-[var(--radius-sm)] text-white hover:opacity-90 transition-opacity ${
variant === 'error' ? 'bg-[var(--color-error)]' : 'bg-[var(--color-brand)]'
}`}
>
{confirmLabel}
</button>
</div>
</div>
)

View File

@ -0,0 +1,198 @@
import { useEffect, useState } from 'react'
import { useTaskStore } from '../../stores/taskStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useChatStore } from '../../stores/chatStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import { parseRunOutput } from '../../lib/parseRunOutput'
import type { TaskRun } from '../../types/task'
function RunOutput({ run }: { run: TaskRun }) {
const t = useTranslation()
// Show error prominently if present
if (run.error) {
return (
<div className="mt-2 p-2.5 rounded-[var(--radius-sm)] bg-red-50 border border-red-200 text-xs text-[var(--color-error)] whitespace-pre-wrap break-words max-h-40 overflow-y-auto">
{run.error}
</div>
)
}
const text = parseRunOutput(run.output || '')
if (!text) {
return (
<div className="mt-2 p-2.5 rounded-[var(--radius-sm)] bg-[var(--color-surface-container)] text-xs text-[var(--color-text-tertiary)] italic">
{run.sessionId ? t('tasks.outputHintSession') : t('tasks.noOutputText')}
</div>
)
}
// Render AI text response with proper formatting (not monospace <pre>)
return (
<div className="mt-2 p-2.5 rounded-[var(--radius-sm)] bg-[var(--color-surface-container)] text-xs text-[var(--color-text-secondary)] whitespace-pre-wrap break-words max-h-48 overflow-y-auto leading-relaxed">
{text}
</div>
)
}
type Props = {
taskId: string
onClose: () => void
refreshKey?: number
}
const STATUS_CONFIG: Record<string, { icon: string; color: string }> = {
running: { icon: 'sync', color: 'var(--color-warning)' },
completed: { icon: 'check_circle', color: 'var(--color-success)' },
failed: { icon: 'error', color: 'var(--color-error)' },
timeout: { icon: 'timer_off', color: 'var(--color-error)' },
}
export function TaskRunsPanel({ taskId, onClose, refreshKey }: Props) {
const t = useTranslation()
const { fetchTaskRuns } = useTaskStore()
const setActiveSession = useSessionStore((s) => s.setActiveSession)
const connectToSession = useChatStore((s) => s.connectToSession)
const setActiveView = useUIStore((s) => s.setActiveView)
const [runs, setRuns] = useState<TaskRun[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
const openSession = (sessionId: string) => {
setActiveView('code')
setActiveSession(sessionId)
connectToSession(sessionId)
}
const refresh = () => {
fetchTaskRuns(taskId).then((r) => {
setRuns(r)
setLoading(false)
}).catch(() => setLoading(false))
}
// Initial fetch + re-fetch when refreshKey changes
useEffect(() => {
setLoading(true)
refresh()
}, [taskId, fetchTaskRuns, refreshKey])
// Auto-poll while any run is "running" or shortly after a manual trigger.
// Uses faster 1s polling for the first 10s after refreshKey changes, then 3s.
const hasRunning = runs.some((r) => r.status === 'running')
useEffect(() => {
if (!hasRunning && refreshKey === 0) return // no reason to poll initially
// Start with fast polling (1s) to give snappy feedback after "Run Now"
let interval = 1000
let timer = setInterval(refresh, interval)
// After 10s, switch to slower 3s polling if still running
const slowDown = setTimeout(() => {
clearInterval(timer)
if (hasRunning) {
timer = setInterval(refresh, 3000)
}
}, 10000)
// If nothing is running and initial window passes, stop entirely
const stopTimer = hasRunning ? undefined : setTimeout(() => clearInterval(timer), 12000)
return () => {
clearInterval(timer)
clearTimeout(slowDown)
if (stopTimer) clearTimeout(stopTimer)
}
}, [hasRunning, taskId, refreshKey])
return (
<div className="mt-2 mb-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--color-surface-container)]">
<span className="text-xs font-medium text-[var(--color-text-primary)]">{t('tasks.logsTitle')}</span>
<button
onClick={onClose}
className="p-0.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
{/* Content */}
<div className="max-h-64 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-6">
<div className="animate-spin w-4 h-4 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
) : runs.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">
{t('tasks.noLogs')}
</div>
) : (
<div className="divide-y divide-[var(--color-border-separator)]">
{runs.map((run) => {
const cfg = STATUS_CONFIG[run.status] || STATUS_CONFIG.failed!
const isExpanded = expandedId === run.id
return (
<div key={run.id} className="px-4 py-2.5">
<div className="flex items-center gap-3">
{/* Status icon */}
<span
className={`material-symbols-outlined text-[16px] ${run.status === 'running' ? 'animate-spin' : ''}`}
style={{ color: cfg.color, fontVariationSettings: "'FILL' 1" }}
>
{cfg.icon}
</span>
{/* Status text */}
<span className="text-xs font-medium" style={{ color: cfg.color }}>
{t(`tasks.runStatus.${run.status}` as any)}
</span>
{/* Time */}
<span className="text-xs text-[var(--color-text-tertiary)]">
{new Date(run.startedAt).toLocaleString()}
</span>
{/* Duration */}
{run.durationMs != null && (
<span className="text-xs text-[var(--color-text-tertiary)]">
{t('tasks.duration', { s: Math.round(run.durationMs / 1000) })}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{/* Open session — only after run completes (session is empty while running) */}
{run.sessionId && run.status !== 'running' && (
<button
onClick={() => openSession(run.sessionId!)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-[var(--color-brand)] bg-[var(--color-brand)]/8 hover:bg-[var(--color-brand)]/15 rounded-[var(--radius-sm)] transition-colors"
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
{t('tasks.openSession')}
</button>
)}
{/* Summary toggle */}
{(run.output || run.error) && (
<button
onClick={() => setExpandedId(isExpanded ? null : run.id)}
className="text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
{isExpanded ? t('tasks.hideOutput') : t('tasks.viewOutput')}
</button>
)}
</div>
</div>
{/* Expanded output */}
{isExpanded && (
<RunOutput run={run} />
)}
</div>
)
})}
</div>
)}
</div>
</div>
)
}

View File

@ -95,6 +95,35 @@ export const en = {
'settings.permissions.bypass': 'Bypass all',
'settings.permissions.bypassDesc': 'Skip all permission checks (dangerous)',
// Settings > Adapters
'settings.tab.adapters': 'IM Adapters',
'settings.adapters.description': 'Configure IM adapters to chat with Claude Code via Telegram or Feishu.',
'settings.adapters.telegram': 'Telegram',
'settings.adapters.feishu': 'Feishu',
'settings.adapters.botToken': 'Bot Token',
'settings.adapters.botTokenPlaceholder': 'Paste token from @BotFather',
'settings.adapters.appId': 'App ID',
'settings.adapters.appIdPlaceholder': 'e.g. cli_xxx',
'settings.adapters.appSecret': 'App Secret',
'settings.adapters.appSecretPlaceholder': 'From Feishu Open Platform',
'settings.adapters.encryptKey': 'Encrypt Key',
'settings.adapters.encryptKeyPlaceholder': 'Optional',
'settings.adapters.verificationToken': 'Verification Token',
'settings.adapters.verificationTokenPlaceholder': 'Optional',
'settings.adapters.allowedUsers': 'Allowed Users',
'settings.adapters.allowedUsersHint': 'Comma-separated. Leave empty to allow everyone.',
'settings.adapters.tgAllowedUsersPlaceholder': 'e.g. 123456789, 987654321',
'settings.adapters.fsAllowedUsersPlaceholder': 'e.g. ou_xxx, ou_yyy',
'settings.adapters.workDir': 'Working Directory',
'settings.adapters.streamingCard': 'Streaming Card Mode',
'settings.adapters.streamingCardDesc': 'Real-time card updates for better experience',
'settings.adapters.serverUrl': 'Server URL',
'settings.adapters.serverUrlPlaceholder': 'ws://127.0.0.1:3456',
'settings.adapters.save': 'Save',
'settings.adapters.saved': 'Saved',
'settings.adapters.saving': 'Saving...',
'settings.adapters.startHint': 'After saving, run adapter: cd adapters && bun run {platform}',
// Settings > General
'settings.general.languageTitle': 'Language',
'settings.general.languageDescription': 'Choose the display language for the application.',
@ -207,15 +236,76 @@ export const en = {
'newTask.description': 'Description',
'newTask.descPlaceholder': "Review yesterday's commits and flag anything concerning",
'newTask.frequency': 'Frequency',
'newTask.hourly': 'Hourly',
'newTask.everyNMinutes': 'Every N minutes',
'newTask.everyNHours': 'Every N hours',
'newTask.daily': 'Daily',
'newTask.weekdays': 'Weekdays',
'newTask.weekly': 'Weekly',
'newTask.weekdays': 'Weekdays (MonFri)',
'newTask.specificDays': 'Specific days of week',
'newTask.monthly': 'Monthly',
'newTask.customCron': 'Custom cron expression',
'newTask.intervalMinutes': 'Every {n} minutes',
'newTask.intervalHours': 'Every {n} hours',
'newTask.atMinute': 'at minute :{m}',
'newTask.onMonthDay': 'On day {d} of each month',
'newTask.cronFormatHint': 'Format: minute hour day-of-month month day-of-week',
'newTask.invalidCron': 'Invalid cron expression',
'newTask.daySun': 'Sun',
'newTask.dayMon': 'Mon',
'newTask.dayTue': 'Tue',
'newTask.dayWed': 'Wed',
'newTask.dayThu': 'Thu',
'newTask.dayFri': 'Fri',
'newTask.daySat': 'Sat',
'newTask.delayNote': 'Scheduled tasks use a randomized delay of several minutes for server performance.',
'newTask.create': 'Create task',
'newTask.promptPlaceholder': 'Look at the commits from the last 24 hours. Summarize what changed, call out any risky patterns or missing tests, and note anything worth following up on.',
// ─── Cron Descriptions ──────────────────────────────────────
'cron.everyMinute': 'Runs every minute',
'cron.everyNMinutes': 'Runs every {n} minutes',
'cron.everyHour': 'Runs every hour',
'cron.everyNHours': 'Runs every {n} hours',
'cron.everyNHoursAtMinute': 'Runs every {n} hours at :{m}',
'cron.dailyAt': 'Runs daily at {time}',
'cron.weekdaysAt': 'Runs on weekdays at {time}',
'cron.specificDaysAt': 'Runs every {days} at {time}',
'cron.monthlyAt': 'Runs on the {day}th of every month at {time}',
'cron.customSchedule': 'Custom: {cron}',
'cron.dow.0': 'Sun',
'cron.dow.1': 'Mon',
'cron.dow.2': 'Tue',
'cron.dow.3': 'Wed',
'cron.dow.4': 'Thu',
'cron.dow.5': 'Fri',
'cron.dow.6': 'Sat',
// ─── Task Actions & Logs ──────────────────────────────────────
'tasks.runNow': 'Run now',
'tasks.confirmRun': 'Execute this task immediately?',
'tasks.confirmDisable': 'Disable this scheduled task?',
'tasks.confirmEnable': 'Enable this scheduled task?',
'tasks.confirmDelete': 'Permanently delete this task and all its logs?',
'tasks.running': 'Running...',
'tasks.viewLogs': 'Logs',
'tasks.logsTitle': 'Execution logs',
'tasks.noLogs': 'No execution logs yet',
'tasks.runStatus.running': 'Running',
'tasks.runStatus.completed': 'Completed',
'tasks.runStatus.failed': 'Failed',
'tasks.runStatus.timeout': 'Timeout',
'tasks.duration': '{s}s',
'tasks.viewOutput': 'Summary',
'tasks.hideOutput': 'Hide',
'tasks.close': 'Close',
'tasks.edit': 'Edit',
'tasks.editTitle': 'Edit scheduled task',
'tasks.saveChanges': 'Save changes',
'tasks.openSession': 'View conversation',
'tasks.createdAt': 'Created: ',
'tasks.lastRunAt': 'Last run: ',
'tasks.outputHintSession': 'Click "View conversation" to see the full output in session view.',
'tasks.noOutputText': 'No output available.',
// ─── Prompt Editor ──────────────────────────────────────
'promptEditor.worktree': 'worktree',
'promptEditor.bypassWarning': 'Bypass mode grants full system access',

View File

@ -97,6 +97,35 @@ export const zh: Record<TranslationKey, string> = {
'settings.permissions.bypass': '跳过全部',
'settings.permissions.bypassDesc': '跳过所有权限检查(危险)',
// Settings > Adapters
'settings.tab.adapters': 'IM 接入',
'settings.adapters.description': '配置即时通讯适配器,通过 Telegram 或飞书与 Claude Code 对话。',
'settings.adapters.telegram': 'Telegram',
'settings.adapters.feishu': '飞书 (Feishu)',
'settings.adapters.botToken': 'Bot Token',
'settings.adapters.botTokenPlaceholder': '粘贴从 @BotFather 获取的 Token',
'settings.adapters.appId': 'App ID',
'settings.adapters.appIdPlaceholder': '如 cli_xxx',
'settings.adapters.appSecret': 'App Secret',
'settings.adapters.appSecretPlaceholder': '从飞书开放平台获取',
'settings.adapters.encryptKey': 'Encrypt Key',
'settings.adapters.encryptKeyPlaceholder': '可选',
'settings.adapters.verificationToken': 'Verification Token',
'settings.adapters.verificationTokenPlaceholder': '可选',
'settings.adapters.allowedUsers': '允许的用户',
'settings.adapters.allowedUsersHint': '逗号分隔。留空允许所有人。',
'settings.adapters.tgAllowedUsersPlaceholder': '如 123456789, 987654321',
'settings.adapters.fsAllowedUsersPlaceholder': '如 ou_xxx, ou_yyy',
'settings.adapters.workDir': '工作目录',
'settings.adapters.streamingCard': '流式卡片模式',
'settings.adapters.streamingCardDesc': '实时更新消息内容,体验更好',
'settings.adapters.serverUrl': '服务器地址',
'settings.adapters.serverUrlPlaceholder': 'ws://127.0.0.1:3456',
'settings.adapters.save': '保存',
'settings.adapters.saved': '已保存',
'settings.adapters.saving': '保存中...',
'settings.adapters.startHint': '保存后运行适配器cd adapters && bun run {platform}',
// Settings > General
'settings.general.languageTitle': '语言',
'settings.general.languageDescription': '选择应用程序的显示语言。',
@ -209,15 +238,76 @@ export const zh: Record<TranslationKey, string> = {
'newTask.description': '描述',
'newTask.descPlaceholder': '审查昨天的提交并标记可疑之处',
'newTask.frequency': '频率',
'newTask.hourly': '每小时',
'newTask.everyNMinutes': '每 N 分钟',
'newTask.everyNHours': '每 N 小时',
'newTask.daily': '每天',
'newTask.weekdays': '工作日',
'newTask.weekly': '每周',
'newTask.weekdays': '工作日(周一至周五)',
'newTask.specificDays': '指定星期几',
'newTask.monthly': '每月',
'newTask.customCron': '自定义 Cron 表达式',
'newTask.intervalMinutes': '每 {n} 分钟',
'newTask.intervalHours': '每 {n} 小时',
'newTask.atMinute': '在第 :{m} 分钟',
'newTask.onMonthDay': '每月第 {d} 天',
'newTask.cronFormatHint': '格式:分 时 日 月 星期',
'newTask.invalidCron': '无效的 Cron 表达式',
'newTask.daySun': '日',
'newTask.dayMon': '一',
'newTask.dayTue': '二',
'newTask.dayWed': '三',
'newTask.dayThu': '四',
'newTask.dayFri': '五',
'newTask.daySat': '六',
'newTask.delayNote': '定时任务会使用随机延迟以优化服务器性能。',
'newTask.create': '创建任务',
'newTask.promptPlaceholder': '查看过去 24 小时的提交。总结变更内容,标记有风险的模式或缺失的测试,并记录值得跟进的事项。',
// ─── Cron 描述 ──────────────────────────────────────
'cron.everyMinute': '每分钟执行',
'cron.everyNMinutes': '每 {n} 分钟执行',
'cron.everyHour': '每小时执行',
'cron.everyNHours': '每 {n} 小时执行',
'cron.everyNHoursAtMinute': '每 {n} 小时在第 :{m} 分钟执行',
'cron.dailyAt': '每天 {time} 执行',
'cron.weekdaysAt': '工作日 {time} 执行',
'cron.specificDaysAt': '每{days} {time} 执行',
'cron.monthlyAt': '每月 {day} 日 {time} 执行',
'cron.customSchedule': '自定义:{cron}',
'cron.dow.0': '周日',
'cron.dow.1': '周一',
'cron.dow.2': '周二',
'cron.dow.3': '周三',
'cron.dow.4': '周四',
'cron.dow.5': '周五',
'cron.dow.6': '周六',
// ─── 任务操作与日志 ──────────────────────────────────────
'tasks.runNow': '立即执行',
'tasks.confirmRun': '确定要立即执行此任务吗?',
'tasks.confirmDisable': '确定要禁用此定时任务吗?',
'tasks.confirmEnable': '确定要启用此定时任务吗?',
'tasks.confirmDelete': '确定要永久删除此任务及其所有日志吗?',
'tasks.running': '执行中...',
'tasks.viewLogs': '日志',
'tasks.logsTitle': '执行记录',
'tasks.noLogs': '暂无执行记录',
'tasks.runStatus.running': '运行中',
'tasks.runStatus.completed': '已完成',
'tasks.runStatus.failed': '失败',
'tasks.runStatus.timeout': '超时',
'tasks.duration': '{s}秒',
'tasks.viewOutput': '摘要',
'tasks.hideOutput': '收起',
'tasks.close': '关闭',
'tasks.edit': '编辑',
'tasks.editTitle': '编辑定时任务',
'tasks.saveChanges': '保存修改',
'tasks.openSession': '查看完整对话',
'tasks.createdAt': '创建于 ',
'tasks.lastRunAt': '上次执行 ',
'tasks.outputHintSession': '点击「查看完整对话」可在会话中查看完整输出。',
'tasks.noOutputText': '暂无输出内容。',
// ─── Prompt Editor ──────────────────────────────────────
'promptEditor.worktree': '工作树',
'promptEditor.bypassWarning': '跳过模式将授予完整系统访问权限',

View File

@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest'
import { describeCron, isValidCron } from '../cronDescribe'
// Simple mock t() that returns the key with params interpolated
const t = (key: string, params?: Record<string, string | number>) => {
let text = key
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
}
}
return text
}
describe('describeCron', () => {
it('every minute', () => {
expect(describeCron('* * * * *', t)).toBe('cron.everyMinute')
})
it('every N minutes', () => {
expect(describeCron('*/15 * * * *', t)).toBe('cron.everyNMinutes')
expect(describeCron('*/5 * * * *', t)).toBe('cron.everyNMinutes')
})
it('*/1 is treated as every minute', () => {
expect(describeCron('*/1 * * * *', t)).toBe('cron.everyMinute')
})
it('every hour', () => {
expect(describeCron('0 */1 * * *', t)).toBe('cron.everyHour')
})
it('every N hours', () => {
expect(describeCron('0 */4 * * *', t)).toBe('cron.everyNHours')
})
it('every N hours at minute offset', () => {
expect(describeCron('30 */4 * * *', t)).toBe('cron.everyNHoursAtMinute')
})
it('daily at time', () => {
expect(describeCron('30 9 * * *', t)).toBe('cron.dailyAt')
})
it('weekdays at time', () => {
expect(describeCron('30 9 * * 1-5', t)).toBe('cron.weekdaysAt')
})
it('specific days of week', () => {
const result = describeCron('0 9 * * 1,3,5', t)
expect(result).toBe('cron.specificDaysAt')
})
it('monthly on specific day', () => {
expect(describeCron('0 9 15 * *', t)).toBe('cron.monthlyAt')
})
it('unrecognized pattern falls back to custom', () => {
expect(describeCron('0 9 1 6 *', t)).toBe('cron.customSchedule')
})
it('invalid field count falls back to custom', () => {
expect(describeCron('0 9 *', t)).toBe('cron.customSchedule')
})
})
describe('isValidCron', () => {
it('accepts valid expressions', () => {
expect(isValidCron('0 9 * * *')).toBe(true)
expect(isValidCron('*/15 * * * *')).toBe(true)
expect(isValidCron('30 14 * * 1-5')).toBe(true)
expect(isValidCron('0 9 * * 1,3,5')).toBe(true)
expect(isValidCron('0 9 15 * *')).toBe(true)
expect(isValidCron('0 */2 * * *')).toBe(true)
})
it('rejects invalid expressions', () => {
expect(isValidCron('')).toBe(false)
expect(isValidCron('hello')).toBe(false)
expect(isValidCron('0 9 *')).toBe(false) // too few fields
expect(isValidCron('0 9 * * * *')).toBe(false) // too many fields
expect(isValidCron('60 9 * * *')).toBe(false) // minute > 59
expect(isValidCron('0 25 * * *')).toBe(false) // hour > 23
expect(isValidCron('0 9 32 * *')).toBe(false) // day > 31
expect(isValidCron('0 9 * 13 *')).toBe(false) // month > 12
expect(isValidCron('0 9 * * 8')).toBe(false) // dow > 7
})
it('accepts edge case values', () => {
expect(isValidCron('0 0 1 1 0')).toBe(true)
expect(isValidCron('59 23 31 12 7')).toBe(true)
})
})

View File

@ -0,0 +1,187 @@
/**
* Cron expression utilities: human-readable description & validation.
* Works with standard 5-field cron: minute hour day-of-month month day-of-week
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TFunc = (key: any, params?: Record<string, string | number>) => string
function pad(n: number): string {
return n.toString().padStart(2, '0')
}
function formatTime(hour: number, minute: number): string {
return `${pad(hour)}:${pad(minute)}`
}
function describeDow(field: string, t: TFunc): string {
const parts = field.split(',')
const days: number[] = []
for (const part of parts) {
const range = part.match(/^(\d+)-(\d+)$/)
if (range) {
const start = parseInt(range[1]!)
const end = parseInt(range[2]!)
for (let i = start; i <= end; i++) days.push(i)
} else {
days.push(parseInt(part))
}
}
return days.map((d) => t(`cron.dow.${d % 7}`)).join(', ')
}
export function describeCron(cron: string, t: TFunc): string {
const fields = cron.trim().split(/\s+/)
if (fields.length !== 5) return t('cron.customSchedule', { cron })
const min = fields[0]!
const hour = fields[1]!
const dom = fields[2]!
const month = fields[3]!
const dow = fields[4]!
// */N * * * * → every N minutes
if (hour === '*' && dom === '*' && month === '*' && dow === '*') {
const stepMatch = min.match(/^\*\/(\d+)$/)
if (stepMatch) {
const n = parseInt(stepMatch[1]!)
if (n === 1) return t('cron.everyMinute')
return t('cron.everyNMinutes', { n })
}
if (min === '*') return t('cron.everyMinute')
}
// M */N * * * → every N hours (optionally at :M)
if (/^\d+$/.test(min) && dom === '*' && month === '*' && dow === '*') {
const hourStep = hour.match(/^\*\/(\d+)$/)
if (hourStep) {
const n = parseInt(hourStep[1]!)
const m = parseInt(min)
if (m === 0) {
if (n === 1) return t('cron.everyHour')
return t('cron.everyNHours', { n })
}
return t('cron.everyNHoursAtMinute', { n, m: pad(m) })
}
}
// M H * * <dow> → daily / weekdays / specific days
if (/^\d+$/.test(min) && /^\d+$/.test(hour) && dom === '*' && month === '*') {
const time = formatTime(parseInt(hour), parseInt(min))
if (dow === '*') {
return t('cron.dailyAt', { time })
}
if (dow === '1-5') {
return t('cron.weekdaysAt', { time })
}
if (/^[\d,\-]+$/.test(dow)) {
const days = describeDow(dow, t)
return t('cron.specificDaysAt', { days, time })
}
}
// M H D * * → monthly on day D
if (/^\d+$/.test(min) && /^\d+$/.test(hour) && /^\d+$/.test(dom) && month === '*' && dow === '*') {
const time = formatTime(parseInt(hour), parseInt(min))
return t('cron.monthlyAt', { day: parseInt(dom), time })
}
return t('cron.customSchedule', { cron })
}
/**
* Reverse-parse a cron expression back into UI-friendly state
* for the task edit modal.
*/
export type FrequencyKey = 'everyNMinutes' | 'everyNHours' | 'daily' | 'weekdays' | 'specificDays' | 'monthly' | 'customCron'
export type ParsedCron = {
frequency: FrequencyKey
time: string
minuteInterval: number
hourInterval: number
minuteOffset: number
selectedDays: number[]
monthDay: number
customCron: string
}
const DEFAULTS: ParsedCron = {
frequency: 'customCron',
time: '09:00',
minuteInterval: 15,
hourInterval: 1,
minuteOffset: 0,
selectedDays: [1],
monthDay: 1,
customCron: '',
}
export function parseCron(cron: string): ParsedCron {
const fields = cron.trim().split(/\s+/)
if (fields.length !== 5) return { ...DEFAULTS, customCron: cron }
const min = fields[0]!
const hour = fields[1]!
const dom = fields[2]!
const month = fields[3]!
const dow = fields[4]!
// */N * * * * → everyNMinutes
if (/^\*\/\d+$/.test(min) && hour === '*' && dom === '*' && month === '*' && dow === '*') {
return { ...DEFAULTS, frequency: 'everyNMinutes', minuteInterval: parseInt(min.split('/')[1]!) }
}
// M */N * * * → everyNHours
if (/^\d+$/.test(min) && /^\*\/\d+$/.test(hour) && dom === '*' && month === '*' && dow === '*') {
return { ...DEFAULTS, frequency: 'everyNHours', minuteOffset: parseInt(min), hourInterval: parseInt(hour.split('/')[1]!) }
}
// M H ... patterns need time
if (/^\d+$/.test(min) && /^\d+$/.test(hour)) {
const time = formatTime(parseInt(hour), parseInt(min))
// M H * * * → daily
if (dom === '*' && month === '*' && dow === '*') {
return { ...DEFAULTS, frequency: 'daily', time }
}
// M H * * 1-5 → weekdays
if (dom === '*' && month === '*' && dow === '1-5') {
return { ...DEFAULTS, frequency: 'weekdays', time }
}
// M H * * <list> → specificDays
if (dom === '*' && month === '*' && /^[\d,]+$/.test(dow)) {
return { ...DEFAULTS, frequency: 'specificDays', time, selectedDays: dow.split(',').map(Number) }
}
// M H D * * → monthly
if (/^\d+$/.test(dom) && month === '*' && dow === '*') {
return { ...DEFAULTS, frequency: 'monthly', time, monthDay: parseInt(dom) }
}
}
return { ...DEFAULTS, customCron: cron }
}
export function isValidCron(cron: string): boolean {
const fields = cron.trim().split(/\s+/)
if (fields.length !== 5) return false
const fieldPattern = /^(\*|(\d+(-\d+)?(\/\d+)?)(,(\d+(-\d+)?(\/\d+)?))*)$/
const maxValues = [59, 23, 31, 12, 7]
const minValues = [0, 0, 1, 1, 0]
for (let i = 0; i < 5; i++) {
const field = fields[i]!
if (/^\*\/\d+$/.test(field)) continue
if (field === '*') continue
if (!fieldPattern.test(field)) return false
const nums = field.replace(/\/\d+/g, '').split(/[,\-]/).filter((s) => /^\d+$/.test(s))
for (const num of nums) {
const n = parseInt(num)
if (n < minValues[i]! || n > maxValues[i]!) return false
}
}
return true
}

View File

@ -0,0 +1,79 @@
/**
* Parse task run output into displayable text.
*
* The output may be in one of two formats:
*
* 1. **Extracted text** (new runs) The server's `extractAssistantText` has
* already parsed the raw NDJSON and stored only the AI's text response.
* This is plain text / markdown that should be returned as-is.
*
* 2. **Raw NDJSON** (old runs before the server-side extraction was added)
* Each line is a JSON object from the CLI's stream-json output. We parse
* these and extract assistant text blocks + result messages.
*
* Detection: if at least one line parses as JSON with a recognized `type`
* field, treat as NDJSON. Otherwise return as-is.
*/
export function parseRunOutput(raw: string): string {
if (!raw || !raw.trim()) return ''
const lines = raw.trim().split('\n')
// Quick check: does this look like NDJSON? (first non-empty line starts with '{')
const firstLine = lines.find((l) => l.trim())
if (!firstLine || !firstLine.trim().startsWith('{')) {
// Already extracted plain text — return as-is
return raw.trim()
}
// Try to parse as NDJSON (legacy format)
const textParts: string[] = []
let anyRecognized = false
for (const line of lines) {
if (!line.trim()) continue
let parsed: any
try {
parsed = JSON.parse(line)
} catch {
continue
}
const type = parsed?.type
if (type === 'assistant') {
anyRecognized = true
const content = parsed?.message?.content
if (!Array.isArray(content)) continue
for (const block of content) {
if (block.type === 'text' && block.text?.trim()) {
textParts.push(block.text.trim())
}
}
}
if (type === 'result') {
anyRecognized = true
const result = parsed?.result
if (typeof result === 'string' && result.trim()) {
textParts.push(result.trim())
} else if (result?.message?.trim()) {
textParts.push(result.message.trim())
}
}
if (type === 'system' || type === 'user') {
anyRecognized = true
// Skip these — not useful to display
}
}
// If we recognized NDJSON structure, return extracted text
if (anyRecognized) {
return textParts.join('\n\n')
}
// Fallback: the JSON lines didn't have recognized types — return raw
return raw.trim()
}

View File

@ -12,6 +12,7 @@ type TaskStore = {
createTask: (input: CreateTaskInput) => Promise<void>
updateTask: (id: string, updates: Partial<CronTask>) => Promise<void>
deleteTask: (id: string) => Promise<void>
runTask: (taskId: string) => Promise<void>
fetchRecentRuns: () => Promise<void>
fetchTaskRuns: (taskId: string) => Promise<TaskRun[]>
}
@ -47,6 +48,10 @@ export const useTaskStore = create<TaskStore>((set) => ({
set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) }))
},
runTask: async (taskId) => {
await tasksApi.runTask(taskId)
},
fetchRecentRuns: async () => {
try {
const { runs } = await tasksApi.getRecentRuns()

View File

@ -45,4 +45,5 @@ export type TaskRun = {
error?: string
exitCode?: number
durationMs?: number
sessionId?: string
}

View File

@ -5,6 +5,7 @@
* POST /api/scheduled-tasks
* GET /api/scheduled-tasks/runs
* GET /api/scheduled-tasks/:id/runs
* POST /api/scheduled-tasks/:id/run
* PUT /api/scheduled-tasks/:id
* DELETE /api/scheduled-tasks/:id
*/
@ -64,6 +65,21 @@ export async function handleScheduledTasksApi(
return Response.json({ task }, { status: 201 })
}
// ── POST /api/scheduled-tasks/:id/run ──────────────────────────────────
// Fire-and-forget: start execution in background, return immediately.
// The frontend polls GET /:id/runs to track progress.
if (method === 'POST' && taskId && subResource === 'run') {
const tasks = await cronService.listTasks()
const task = tasks.find((t) => t.id === taskId)
if (!task) throw ApiError.notFound(`Task ${taskId} not found`)
cronScheduler.executeTask(task, { createSession: true }).catch((err) => {
console.error(`[ScheduledTasks] Manual run failed for task ${taskId}:`, err)
})
// Small delay to let appendRun() write the "running" entry to disk
await new Promise((r) => setTimeout(r, 200))
return Response.json({ ok: true })
}
// ── PUT /api/scheduled-tasks/:id ──────────────────────────────────────
if (method === 'PUT' && taskId && !subResource) {
const body = await parseJsonBody(req)

View File

@ -12,6 +12,7 @@ import * as path from 'path'
import * as os from 'os'
import * as crypto from 'crypto'
import { CronService, type CronTask } from './cronService.js'
import { SessionService } from './sessionService.js'
// ─── Types ─────────────────────────────────────────────────────────────────────
@ -27,6 +28,59 @@ export type TaskRun = {
error?: string
exitCode?: number
durationMs?: number
sessionId?: string // links to a session for rich output rendering
}
// ─── Output extraction ────────────────────────────────────────────────────────
/**
* Extract meaningful assistant text from raw CLI stream-json (NDJSON) output.
*
* The raw stdout contains system/init messages, tool_use blocks, tool_result
* echoes, and thinking blocks all of which are noise to the end user. The
* actual AI answer (assistant text blocks + final result) is what matters.
*
* By extracting server-side we avoid the 10K naive truncation problem where
* the useful content sits well past the first 10K characters.
*/
function extractAssistantText(raw: string): string {
if (!raw) return ''
const lines = raw.split('\n')
const parts: string[] = []
for (const line of lines) {
if (!line.trim()) continue
let parsed: any
try {
parsed = JSON.parse(line)
} catch {
continue // skip non-JSON lines and truncated lines
}
const type = parsed?.type
if (type === 'assistant') {
const content = parsed?.message?.content
if (!Array.isArray(content)) continue
for (const block of content) {
if (block.type === 'text' && block.text?.trim()) {
parts.push(block.text.trim())
}
// Skip tool_use, thinking blocks
}
}
if (type === 'result') {
const result = parsed?.result
if (typeof result === 'string' && result.trim()) {
parts.push(result.trim())
} else if (result?.message?.trim()) {
parts.push(result.message.trim())
}
}
}
return parts.join('\n\n')
}
// ─── Cron expression matching ──────────────────────────────────────────────────
@ -193,9 +247,11 @@ export class CronScheduler {
{ proc: ReturnType<typeof Bun.spawn>; startedAt: number; runId: string }
>()
private cronService: CronService
private sessionService: SessionService
constructor(cronService?: CronService) {
this.cronService = cronService || new CronService()
this.sessionService = new SessionService()
}
/** Start the scheduler (called on server boot). */
@ -252,10 +308,30 @@ export class CronScheduler {
}
}
/** Execute a single task by spawning a CLI subprocess. */
async executeTask(task: CronTask): Promise<TaskRun> {
/**
* Execute a single task by spawning a CLI subprocess.
* @param task The task to execute
* @param options.createSession When true, creates a Session for rich output viewing (used for manual "Run Now")
*/
async executeTask(task: CronTask, options?: { createSession?: boolean }): Promise<TaskRun> {
const runId = crypto.randomBytes(6).toString('hex')
const startedAt = new Date().toISOString()
const workDir = task.folderPath || os.homedir()
// Only create a session when explicitly requested (manual "Run Now"),
// not for automatic cron runs — avoids flooding the sidebar.
let sessionId: string | undefined
if (options?.createSession) {
try {
const result = await this.sessionService.createSession(workDir)
sessionId = result.sessionId
// Delete the placeholder JSONL file so the CLI can create it fresh
// with actual content. Same pattern as conversationService.ts.
await this.sessionService.deleteSessionFile(sessionId)
} catch {
// Fall back to no session if creation fails
}
}
const run: TaskRun = {
id: runId,
@ -264,13 +340,16 @@ export class CronScheduler {
startedAt,
status: 'running',
prompt: task.prompt,
sessionId,
}
// Persist the "running" state
await appendRun(run)
// Resolve CLI entry point relative to this file
const cliPath = path.resolve(import.meta.dir, '../../entrypoints/cli.tsx')
// Resolve paths relative to project root
const projectRoot = path.resolve(import.meta.dir, '../../..')
const cliPath = path.join(projectRoot, 'src/entrypoints/cli.tsx')
const preloadPath = path.join(projectRoot, 'preload.ts')
const inputPayload = JSON.stringify({
type: 'user',
@ -279,12 +358,14 @@ export class CronScheduler {
content: [{ type: 'text', text: task.prompt }],
},
parent_tool_use_id: null,
session_id: '',
session_id: sessionId || '',
}) + '\n'
const proc = Bun.spawn(
[
'bun',
'--preload',
preloadPath,
cliPath,
'--print',
'--verbose',
@ -292,12 +373,13 @@ export class CronScheduler {
'stream-json',
'--output-format',
'stream-json',
...(sessionId ? ['--session-id', sessionId] : []),
],
{
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
cwd: task.folderPath || os.homedir(),
cwd: workDir,
},
)
@ -346,18 +428,24 @@ export class CronScheduler {
this.runningTasks.delete(task.id)
const completedAt = new Date().toISOString()
const output = stdoutChunks.join('')
const rawOutput = stdoutChunks.join('')
const durationMs =
new Date(completedAt).getTime() - new Date(startedAt).getTime()
// Determine if this was a timeout
const wasTimeout = durationMs >= TASK_TIMEOUT_MS
// Extract only meaningful AI text responses from raw NDJSON output.
// The raw stream contains system/init messages, tool_use blocks, and
// tool_result echoes that consume thousands of chars before any actual
// AI answer appears. A naive .slice(0, 10_000) would lose the answer.
const output = extractAssistantText(rawOutput)
const completedRun: TaskRun = {
...run,
completedAt,
status: wasTimeout ? 'timeout' : exitCode === 0 ? 'completed' : 'failed',
output: output.slice(0, 10_000), // cap stored output
output: output.slice(0, 50_000), // cap after extraction
exitCode,
durationMs,
}