feat(tasks): manage markdown loops from scheduled tasks

This commit is contained in:
Bohdan Triapitsyn
2026-08-09 21:43:16 +03:00
parent ce6912fe40
commit e13b4526c6
20 changed files with 322 additions and 34 deletions
@@ -21,14 +21,17 @@ import { useI18n } from '@/lib/i18n';
import type { ProjectEntry } from '@/lib/api/types';
import {
deleteScheduledTask,
deleteScheduledTaskLoopFile,
fetchScheduledTasks,
runScheduledTaskNow,
setLoopScheduledTaskEnabled,
upsertScheduledTask,
type ScheduledTask,
type ScheduledTaskStatus,
} from '@/lib/scheduledTasksApi';
import { ScheduledTaskEditorDialog } from './ScheduledTaskEditorDialog';
import { canonicalizeTimezone } from '@/lib/timezones';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
const scheduleTimes = (task: ScheduledTask): string[] => {
const raw = Array.isArray(task.schedule.times)
@@ -314,10 +317,11 @@ export function ScheduledTasksDialog() {
setMutatingTaskID(task.id);
setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, enabled } : item)));
try {
await upsertScheduledTask(selectedProjectID, {
...task,
enabled,
});
if (task.loopFile) {
await setLoopScheduledTaskEnabled(selectedProjectID, task.id, enabled);
} else {
await upsertScheduledTask(selectedProjectID, { ...task, enabled });
}
await reloadTasks(selectedProjectID, { silent: true });
} catch (error) {
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.updateFailed'));
@@ -331,14 +335,20 @@ export function ScheduledTasksDialog() {
if (!selectedProjectID) {
return;
}
const confirmed = window.confirm(t('sessions.scheduledTasks.dialog.confirm.deleteTask', { taskName: task.name }));
const confirmed = window.confirm(task.loopFile
? t('sessions.scheduledTasks.dialog.confirm.deleteLoopFile', { taskName: task.name })
: t('sessions.scheduledTasks.dialog.confirm.deleteTask', { taskName: task.name }));
if (!confirmed) {
return;
}
setMutatingTaskID(task.id);
try {
await deleteScheduledTask(selectedProjectID, task.id);
if (task.loopFile) {
await deleteScheduledTaskLoopFile(selectedProjectID, task.id);
} else {
await deleteScheduledTask(selectedProjectID, task.id);
}
await reloadTasks(selectedProjectID, { silent: true });
toast.success(t('sessions.scheduledTasks.dialog.toast.deleted'));
} catch (error) {
@@ -348,6 +358,24 @@ export function ScheduledTasksDialog() {
}
}, [selectedProjectID, reloadTasks, t]);
const handleEditTask = React.useCallback((task: ScheduledTask) => {
if (!task.loopFile) {
setEditorTask(task);
setEditorOpen(true);
return;
}
if (!selectedProject?.path) {
return;
}
setOpen(false);
if (isMobile) {
useFilesViewTabsStore.getState().setSelectedPath(selectedProject.path, task.loopFile, { allowOutsideRoot: true });
useUIStore.getState().setActiveMainTab('files');
return;
}
useUIStore.getState().openContextFile(selectedProject.path, task.loopFile);
}, [isMobile, selectedProject?.path, setOpen]);
const handleRunNow = React.useCallback(async (task: ScheduledTask) => {
if (!selectedProjectID) {
return;
@@ -533,11 +561,8 @@ export function ScheduledTasksDialog() {
className={cn(
'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium',
task.enabled ? 'text-foreground' : 'text-muted-foreground',
(isBusy || task.loopFile) && 'cursor-not-allowed opacity-50',
isBusy && 'cursor-not-allowed opacity-50',
)}
title={task.loopFile
? t('sessions.scheduledTasks.dialog.loopFile.toggleDisabled')
: undefined}
>
<Checkbox
checked={task.enabled}
@@ -545,7 +570,7 @@ export function ScheduledTasksDialog() {
ariaLabel={task.enabled
? t('sessions.scheduledTasks.dialog.taskToggle.pauseAria', { taskName: task.name })
: t('sessions.scheduledTasks.dialog.taskToggle.enableAria', { taskName: task.name })}
disabled={isBusy || Boolean(task.loopFile)}
disabled={isBusy}
/>
{task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')}
</label>
@@ -562,14 +587,8 @@ export function ScheduledTasksDialog() {
<Button
variant="outline"
size="sm"
onClick={() => {
setEditorTask(task);
setEditorOpen(true);
}}
disabled={isBusy || Boolean(task.loopFile)}
title={task.loopFile
? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled')
: undefined}
onClick={() => handleEditTask(task)}
disabled={isBusy}
aria-label={t('sessions.scheduledTasks.dialog.actions.editAria', { taskName: task.name })}
>
<Icon name="edit-2" className="h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.edit')}
@@ -578,10 +597,7 @@ export function ScheduledTasksDialog() {
variant="destructive"
size="sm"
onClick={() => void handleDeleteTask(task)}
disabled={isBusy || Boolean(task.loopFile)}
title={task.loopFile
? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled')
: undefined}
disabled={isBusy}
aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })}
>
<Icon name="delete-bin" className="h-4 w-4" />
@@ -75,6 +75,8 @@ import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
import { syncScheduledTaskLoops } from '@/lib/scheduledTasksApi';
import { useProjectsStore } from '@/stores/useProjectsStore';
type FileNode = {
name: string;
@@ -1668,6 +1670,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
sessionEvents.requestGitRefresh({ directory: root, paths: [relativePath] });
}
}
if (root && /(?:^|\/)\.agents\/loops\/[^/]+\.md$/i.test(normalizePath(selectedFile.path))) {
const project = useProjectsStore.getState().projects.find((entry) => normalizePath(entry.path) === normalizePath(root));
if (project) {
try {
await syncScheduledTaskLoops(project.id);
} catch {
toast.error(t('sessions.scheduledTasks.dialog.toast.updateFailed'));
}
}
}
if (selectedFile?.path && isDrawioFile(selectedFile.path)) {
diagramXmlRef.current = draftContent;
diagramSavedXmlRef.current = draftContent;
+1
View File
@@ -213,6 +213,7 @@ export const dict = {
'sessions.scheduledTasks.dialog.toast.started': 'Aufgabe gestartet',
'sessions.scheduledTasks.dialog.toast.runFailed': 'Fehler beim Ausführen der Aufgabe',
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Geplante Aufgabe "{taskName}" löschen?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Loop-Aufgabe „{taskName}“ und ihre Markdown-Datei löschen?',
'sessions.scheduledTasks.dialog.schedule.daily': 'Täglich {time}',
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Täglich {time} ({timezone})',
'sessions.scheduledTasks.dialog.schedule.weekly': 'Wöchentlich {days} {time}',
+1
View File
@@ -233,6 +233,7 @@ export const dict = {
'sessions.scheduledTasks.dialog.toast.started': 'Task started',
'sessions.scheduledTasks.dialog.toast.runFailed': 'Failed to run task',
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Delete scheduled task "{taskName}"?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Delete loop task "{taskName}" and its markdown file?',
'sessions.scheduledTasks.dialog.schedule.daily': 'Daily {time}',
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Daily {time} ({timezone})',
'sessions.scheduledTasks.dialog.schedule.weekly': 'Weekly {days} {time}',
+1
View File
@@ -234,6 +234,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.scheduledTasks.dialog.toast.started": "Tarea iniciada",
"sessions.scheduledTasks.dialog.toast.runFailed": "No se pudo ejecutar la tarea",
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Eliminar tarea programada \"{taskName}\"?",
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '¿Eliminar la tarea de bucle «{taskName}» y su archivo Markdown?',
"sessions.scheduledTasks.dialog.schedule.daily": "Cada día {time}",
"sessions.scheduledTasks.dialog.schedule.dailyWithTimezone": "Cada día {time} ({timezone})",
"sessions.scheduledTasks.dialog.schedule.weekly": "Cada semana {days} {time}",
+1
View File
@@ -70,6 +70,7 @@ export const dict = {
'sessions.scheduledTasks.dialog.toast.started': 'Tâche démarrée',
'sessions.scheduledTasks.dialog.toast.runFailed': 'Échec de l\'exécution de la tâche',
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Supprimer la tâche planifiée « {taskName} » ?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Supprimer la tâche en boucle « {taskName} » et son fichier Markdown ?',
'sessions.scheduledTasks.dialog.schedule.daily': 'Quotidien {time}',
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Quotidien {time} ({timezone})',
'sessions.scheduledTasks.dialog.schedule.weekly': 'Hebdomadaire {days} {time}',
+1
View File
@@ -234,6 +234,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.scheduledTasks.dialog.toast.started': 'タスクを開始しました',
'sessions.scheduledTasks.dialog.toast.runFailed': 'タスクの実行に失敗しました',
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'スケジュールタスク「{taskName}」を削除しますか?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'ループタスク「{taskName}」とその Markdown ファイルを削除しますか?',
'sessions.scheduledTasks.dialog.schedule.daily': '毎日 {time}',
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '毎日 {time}{timezone}',
'sessions.scheduledTasks.dialog.schedule.weekly': '毎週 {days} {time}',
+1
View File
@@ -234,6 +234,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.scheduledTasks.dialog.toast.started': '작업 시작됨',
'sessions.scheduledTasks.dialog.toast.runFailed': '작업 실행 실패',
'sessions.scheduledTasks.dialog.confirm.deleteTask': '예약 작업 "{taskName}" 삭제?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '루프 작업 "{taskName}" 및 해당 Markdown 파일을 삭제할까요?',
'sessions.scheduledTasks.dialog.schedule.daily': '매일 {time}',
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '매일 {time} ({timezone})',
'sessions.scheduledTasks.dialog.schedule.weekly': '매주 {days} {time}',
+1
View File
@@ -361,6 +361,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.scheduledTasks.dialog.toast.started': 'Zadanie uruchomione',
'sessions.scheduledTasks.dialog.toast.runFailed': 'Nie udało się uruchomić zadania',
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Usunąć zaplanowane zadanie "{taskName}"?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Usunąć zadanie pętli „{taskName}” i jego plik Markdown?',
'sessions.scheduledTasks.dialog.schedule.daily': 'Codziennie {time}',
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Codziennie {time} ({timezone})',
'sessions.scheduledTasks.dialog.schedule.weekly': 'Co tydzień {days} {time}',
@@ -234,6 +234,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.scheduledTasks.dialog.toast.started": "Tarefa iniciada",
"sessions.scheduledTasks.dialog.toast.runFailed": "Não foi possível executar a tarefa",
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Excluir tarefa agendada \"{taskName}\"?",
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Excluir a tarefa de loop “{taskName}” e seu arquivo Markdown?',
"sessions.scheduledTasks.dialog.schedule.daily": "Todos os dias {time}",
"sessions.scheduledTasks.dialog.schedule.dailyWithTimezone": "Todos os dias {time} ({timezone})",
"sessions.scheduledTasks.dialog.schedule.weekly": "Toda semana {days} {time}",
+1
View File
@@ -234,6 +234,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.scheduledTasks.dialog.toast.started": "Завдання розпочато",
"sessions.scheduledTasks.dialog.toast.runFailed": "Не вдалося виконати завдання",
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Видалити заплановане завдання \"{taskName}\"?",
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Видалити циклічне завдання «{taskName}» і його Markdown-файл?',
"sessions.scheduledTasks.dialog.schedule.daily": "Щодня {time}",
"sessions.scheduledTasks.dialog.schedule.dailyWithTimezone": "Щодня {time} ({timezone})",
"sessions.scheduledTasks.dialog.schedule.weekly": "Щотижня {days} {time}",
@@ -234,6 +234,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.scheduledTasks.dialog.toast.started': '任务已启动',
'sessions.scheduledTasks.dialog.toast.runFailed': '运行任务失败',
'sessions.scheduledTasks.dialog.confirm.deleteTask': '删除计划任务“{taskName}”?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '删除循环任务“{taskName}”及其 Markdown 文件?',
'sessions.scheduledTasks.dialog.schedule.daily': '每天 {time}',
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '每天 {time}{timezone}',
'sessions.scheduledTasks.dialog.schedule.weekly': '每周 {days} {time}',
@@ -247,6 +247,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.scheduledTasks.dialog.toast.started': '任務已啟動',
'sessions.scheduledTasks.dialog.toast.runFailed': '執行任務失敗',
'sessions.scheduledTasks.dialog.confirm.deleteTask': '刪除排程任務「{taskName}」?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '刪除迴圈任務「{taskName}」及其 Markdown 檔案?',
'sessions.scheduledTasks.dialog.schedule.daily': '每天 {time}',
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '每天 {time}{timezone}',
'sessions.scheduledTasks.dialog.schedule.weekly': '每週 {days} {time}',
+31
View File
@@ -112,6 +112,37 @@ export const deleteScheduledTask = async (projectID: string, taskID: string): Pr
return parsed.tasks as ScheduledTask[];
};
const getLoopFileEndpoint = (projectID: string, taskID: string): string => {
const safeProjectID = ensureProjectID(projectID);
const safeTaskID = ensureProjectID(taskID);
return `/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks/${encodeURIComponent(safeTaskID)}/loop-file`;
};
export const setLoopScheduledTaskEnabled = async (projectID: string, taskID: string, enabled: boolean): Promise<void> => {
const response = await runtimeFetch(getLoopFileEndpoint(projectID, taskID), {
method: 'PATCH',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
throw new Error(await parseErrorMessage(response, 'Failed to update loop task'));
}
};
export const deleteScheduledTaskLoopFile = async (projectID: string, taskID: string): Promise<void> => {
const response = await runtimeFetch(getLoopFileEndpoint(projectID, taskID), {
method: 'DELETE',
headers: { accept: 'application/json' },
});
if (!response.ok) {
throw new Error(await parseErrorMessage(response, 'Failed to delete loop file'));
}
};
export const syncScheduledTaskLoops = async (projectID: string): Promise<void> => {
await fetchScheduledTasks(projectID);
};
export const runScheduledTaskNow = async (projectID: string, taskID: string): Promise<{ sessionId?: string }> => {
const safeProjectID = ensureProjectID(projectID);
const safeTaskID = ensureProjectID(taskID);