MainTab/activeMainTab/setActiveMainTab/setMainTabGuard were deprecated mirrors of the surface names — every call site now uses activeSurface/setActiveSurface/setSurfaceGuard directly and the aliases are gone, including the persisted mirror field. The 'diagram' surface had no way to open it (navigateToDiagram had no callers except a .drawio attachment click that navigated to a surface nothing rendered); the surface, DiagramView, and its store plumbing are removed, and a .drawio attachment now opens in the file panel. ?tab= deep links map to the matching context-panel surface instead of setting a main-area surface nothing renders, and a persisted non-chat surface can no longer rehydrate into a blank main area.
714 lines
29 KiB
TypeScript
714 lines
29 KiB
TypeScript
import * as React from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|
import { toast } from '@/components/ui';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import type { IconName } from "@/components/icon/icons";
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { formatTimeForPreference } from '@/lib/timeFormat';
|
|
import type { TimeFormatPreference } from '@/stores/useUIStore';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
import { refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
|
|
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
|
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|
import { cn, formatDirectoryName } from '@/lib/utils';
|
|
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)
|
|
? task.schedule.times
|
|
: (task.schedule.time ? [task.schedule.time] : []);
|
|
const valid = raw.filter((value) => typeof value === 'string' && /^([01]\d|2[0-3]):([0-5]\d)$/.test(value));
|
|
return Array.from(new Set(valid)).sort((a, b) => a.localeCompare(b));
|
|
};
|
|
|
|
const formatSchedule = (task: ScheduledTask, t: ReturnType<typeof useI18n>['t']): string => {
|
|
const timesLabel = scheduleTimes(task).join(', ') || '--:--';
|
|
const formatWeekday = (value: number) => {
|
|
if (value === 0) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.sun');
|
|
if (value === 1) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.mon');
|
|
if (value === 2) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.tue');
|
|
if (value === 3) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.wed');
|
|
if (value === 4) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.thu');
|
|
if (value === 5) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.fri');
|
|
if (value === 6) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.sat');
|
|
return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.unknown');
|
|
};
|
|
if (task.schedule.kind === 'daily') {
|
|
if (task.schedule.timezone) {
|
|
return t('sessions.scheduledTasks.dialog.schedule.dailyWithTimezone', {
|
|
time: timesLabel,
|
|
timezone: canonicalizeTimezone(task.schedule.timezone),
|
|
});
|
|
}
|
|
return t('sessions.scheduledTasks.dialog.schedule.daily', { time: timesLabel });
|
|
}
|
|
if (task.schedule.kind === 'weekly') {
|
|
const days = Array.isArray(task.schedule.weekdays)
|
|
? task.schedule.weekdays.map((value) => formatWeekday(value)).join(', ')
|
|
: '';
|
|
if (task.schedule.timezone) {
|
|
return t('sessions.scheduledTasks.dialog.schedule.weeklyWithTimezone', {
|
|
days,
|
|
time: timesLabel,
|
|
timezone: canonicalizeTimezone(task.schedule.timezone),
|
|
});
|
|
}
|
|
return t('sessions.scheduledTasks.dialog.schedule.weekly', { days, time: timesLabel });
|
|
}
|
|
if (task.schedule.kind === 'once') {
|
|
const date = typeof task.schedule.date === 'string' && task.schedule.date.trim().length > 0
|
|
? task.schedule.date
|
|
: t('sessions.scheduledTasks.dialog.schedule.unknownDate');
|
|
const time = typeof task.schedule.time === 'string' && task.schedule.time.trim().length > 0
|
|
? task.schedule.time
|
|
: '--:--';
|
|
if (task.schedule.timezone) {
|
|
return t('sessions.scheduledTasks.dialog.schedule.onceWithTimezone', {
|
|
date,
|
|
time,
|
|
timezone: canonicalizeTimezone(task.schedule.timezone),
|
|
});
|
|
}
|
|
return t('sessions.scheduledTasks.dialog.schedule.once', { date, time });
|
|
}
|
|
if (task.schedule.timezone) {
|
|
return t('sessions.scheduledTasks.dialog.schedule.cronWithTimezone', {
|
|
cron: task.schedule.cron || '',
|
|
timezone: canonicalizeTimezone(task.schedule.timezone),
|
|
});
|
|
}
|
|
return t('sessions.scheduledTasks.dialog.schedule.cron', { cron: task.schedule.cron || '' });
|
|
};
|
|
|
|
const formatClockTime = (value: number | undefined, timeFormatPreference: TimeFormatPreference): string => {
|
|
if (!value || !Number.isFinite(value)) {
|
|
return '';
|
|
}
|
|
return formatTimeForPreference(value, timeFormatPreference);
|
|
};
|
|
|
|
const formatRelativeTime = (value: number | undefined, t: ReturnType<typeof useI18n>['t']): string => {
|
|
if (!value || !Number.isFinite(value)) {
|
|
return '';
|
|
}
|
|
const diff = value - Date.now();
|
|
const abs = Math.abs(diff);
|
|
const minute = 60_000;
|
|
const hour = 60 * minute;
|
|
const day = 24 * hour;
|
|
const future = diff >= 0;
|
|
if (abs < minute) {
|
|
return future ? t('sessions.scheduledTasks.dialog.relativeTime.inLessThanOneMinute') : t('sessions.scheduledTasks.dialog.relativeTime.justNow');
|
|
}
|
|
if (abs < hour) {
|
|
const m = Math.round(abs / minute);
|
|
return future
|
|
? t('sessions.scheduledTasks.dialog.relativeTime.inMinutes', { count: m })
|
|
: t('sessions.scheduledTasks.dialog.relativeTime.minutesAgo', { count: m });
|
|
}
|
|
if (abs < day) {
|
|
const h = Math.floor(abs / hour);
|
|
const m = Math.round((abs % hour) / minute);
|
|
const body = m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
return future
|
|
? t('sessions.scheduledTasks.dialog.relativeTime.inDuration', { duration: body })
|
|
: t('sessions.scheduledTasks.dialog.relativeTime.durationAgo', { duration: body });
|
|
}
|
|
const d = Math.floor(abs / day);
|
|
const h = Math.round((abs % day) / hour);
|
|
const body = h > 0 ? `${d}d ${h}h` : `${d}d`;
|
|
return future
|
|
? t('sessions.scheduledTasks.dialog.relativeTime.inDuration', { duration: body })
|
|
: t('sessions.scheduledTasks.dialog.relativeTime.durationAgo', { duration: body });
|
|
};
|
|
|
|
type StatusTone = 'success' | 'error' | 'warning' | 'muted';
|
|
|
|
const STATUS_META: Record<
|
|
ScheduledTaskStatus,
|
|
{
|
|
tone: StatusTone;
|
|
Icon: IconName;
|
|
spin?: boolean;
|
|
}
|
|
> = {
|
|
success: { tone: 'success', Icon: 'checkbox-circle' },
|
|
error: { tone: 'error', Icon: 'error-warning' },
|
|
running: { tone: 'warning', Icon: 'loader-4', spin: true },
|
|
idle: { tone: 'muted', Icon: 'pulse' },
|
|
};
|
|
|
|
const toneStyle = (tone: StatusTone): React.CSSProperties => {
|
|
if (tone === 'muted') {
|
|
return {};
|
|
}
|
|
return {
|
|
color: `var(--status-${tone})`,
|
|
backgroundColor: `var(--status-${tone}-background)`,
|
|
borderColor: `var(--status-${tone}-border)`,
|
|
};
|
|
};
|
|
|
|
export function ScheduledTasksDialog() {
|
|
const { t } = useI18n();
|
|
const open = useUIStore((state) => state.isScheduledTasksDialogOpen);
|
|
const setOpen = useUIStore((state) => state.setScheduledTasksDialogOpen);
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
|
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
|
const projects = useProjectsStore((state) => state.projects);
|
|
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
|
const { currentTheme } = useThemeSystem();
|
|
|
|
const [selectedProjectID, setSelectedProjectID] = React.useState<string>('');
|
|
const [tasks, setTasks] = React.useState<ScheduledTask[]>([]);
|
|
// Start in loading state so the first frame after open shows the spinner,
|
|
// not an empty/select-project flash before the fetch effect runs.
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [editorOpen, setEditorOpen] = React.useState(false);
|
|
const [editorTask, setEditorTask] = React.useState<ScheduledTask | null>(null);
|
|
const [mutatingTaskID, setMutatingTaskID] = React.useState<string | null>(null);
|
|
|
|
const selectedProject = React.useMemo(
|
|
() => projects.find((project) => project.id === selectedProjectID) || null,
|
|
[projects, selectedProjectID],
|
|
);
|
|
|
|
const renderProjectLabel = React.useCallback((project: ProjectEntry) => {
|
|
const displayLabel = project.label?.trim() || formatDirectoryName(project.path, homeDirectory || undefined);
|
|
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
|
const iconColor = project.color ? PROJECT_COLOR_MAP[project.color] : undefined;
|
|
const fallbackIcon = projectIconName ? (
|
|
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
|
|
) : (
|
|
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
|
|
);
|
|
|
|
return (
|
|
<span className="inline-flex min-w-0 items-center gap-1.5">
|
|
{project.iconImage ? (
|
|
<span
|
|
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
|
|
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
|
>
|
|
<ProjectIconImage
|
|
project={{ id: project.id, iconImage: project.iconImage ?? null }}
|
|
options={{
|
|
themeVariant: currentTheme.metadata.variant,
|
|
iconColor: currentTheme.colors.surface.foreground,
|
|
}}
|
|
className="h-full w-full object-contain"
|
|
fallback={fallbackIcon}
|
|
/>
|
|
</span>
|
|
) : fallbackIcon}
|
|
<span className="truncate">{displayLabel}</span>
|
|
</span>
|
|
);
|
|
}, [homeDirectory, currentTheme.metadata.variant, currentTheme.colors.surface.foreground]);
|
|
|
|
const reloadTasks = React.useCallback(async (projectID: string, options?: { silent?: boolean }) => {
|
|
if (!projectID) {
|
|
setTasks([]);
|
|
return;
|
|
}
|
|
if (!options?.silent) {
|
|
setLoading(true);
|
|
}
|
|
try {
|
|
const nextTasks = await fetchScheduledTasks(projectID);
|
|
nextTasks.sort((a, b) => {
|
|
if (a.enabled !== b.enabled) {
|
|
return a.enabled ? -1 : 1;
|
|
}
|
|
const byName = a.name.localeCompare(b.name);
|
|
if (byName !== 0) {
|
|
return byName;
|
|
}
|
|
return (a.state?.nextRunAt || Number.MAX_SAFE_INTEGER) - (b.state?.nextRunAt || Number.MAX_SAFE_INTEGER);
|
|
});
|
|
setTasks(nextTasks);
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.loadFailed'));
|
|
if (!options?.silent) {
|
|
setTasks([]);
|
|
}
|
|
} finally {
|
|
if (!options?.silent) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}, [t]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
const preferredProjectID = activeProject?.id || projects[0]?.id || '';
|
|
setSelectedProjectID(preferredProjectID);
|
|
if (preferredProjectID) {
|
|
void reloadTasks(preferredProjectID);
|
|
} else {
|
|
setTasks([]);
|
|
setLoading(false);
|
|
}
|
|
}, [open, activeProject, projects, reloadTasks]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
let timeoutID: ReturnType<typeof setTimeout> | null = null;
|
|
const unsubscribe = subscribeOpenchamberEvents((event) => {
|
|
if (event.type !== 'scheduled-task-ran') {
|
|
return;
|
|
}
|
|
if (event.projectId !== selectedProjectID) {
|
|
return;
|
|
}
|
|
if (timeoutID) {
|
|
clearTimeout(timeoutID);
|
|
}
|
|
timeoutID = setTimeout(() => {
|
|
void reloadTasks(selectedProjectID, { silent: true });
|
|
}, 400);
|
|
});
|
|
return () => {
|
|
if (timeoutID) {
|
|
clearTimeout(timeoutID);
|
|
}
|
|
unsubscribe();
|
|
};
|
|
}, [open, selectedProjectID, reloadTasks]);
|
|
|
|
const handleSaveTask = React.useCallback(async (taskDraft: Partial<ScheduledTask>) => {
|
|
if (!selectedProjectID) {
|
|
throw new Error(t('sessions.scheduledTasks.dialog.error.chooseProjectFirst'));
|
|
}
|
|
await upsertScheduledTask(selectedProjectID, taskDraft);
|
|
await reloadTasks(selectedProjectID);
|
|
toast.success(t('sessions.scheduledTasks.dialog.toast.saved'));
|
|
}, [selectedProjectID, reloadTasks, t]);
|
|
|
|
const handleToggleEnabled = React.useCallback(async (task: ScheduledTask, enabled: boolean) => {
|
|
if (!selectedProjectID) {
|
|
return;
|
|
}
|
|
setMutatingTaskID(task.id);
|
|
setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, enabled } : item)));
|
|
try {
|
|
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'));
|
|
await reloadTasks(selectedProjectID, { silent: true });
|
|
} finally {
|
|
setMutatingTaskID(null);
|
|
}
|
|
}, [selectedProjectID, reloadTasks, t]);
|
|
|
|
const handleDeleteTask = React.useCallback(async (task: ScheduledTask) => {
|
|
if (!selectedProjectID) {
|
|
return;
|
|
}
|
|
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 {
|
|
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) {
|
|
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.deleteFailed'));
|
|
} finally {
|
|
setMutatingTaskID(null);
|
|
}
|
|
}, [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().setActiveSurface('files');
|
|
return;
|
|
}
|
|
useUIStore.getState().openContextFile(selectedProject.path, task.loopFile);
|
|
}, [isMobile, selectedProject?.path, setOpen]);
|
|
|
|
const handleRunNow = React.useCallback(async (task: ScheduledTask) => {
|
|
if (!selectedProjectID) {
|
|
return;
|
|
}
|
|
setMutatingTaskID(task.id);
|
|
try {
|
|
const { sessionId, persistError } = await runScheduledTaskNow(selectedProjectID, task.id);
|
|
await Promise.all([
|
|
reloadTasks(selectedProjectID, { silent: true }),
|
|
refreshGlobalSessions(),
|
|
]);
|
|
if (persistError) {
|
|
toast.warning(t('sessions.scheduledTasks.dialog.toast.startedPersistWarning'));
|
|
} else {
|
|
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
|
|
}
|
|
if (sessionId) {
|
|
// Jump straight into the started session; selecting it also closes
|
|
// this surface (MainLayout closes surfaces on session selection).
|
|
const project = projects.find((entry) => entry.id === selectedProjectID);
|
|
useSessionUIStore.getState().setCurrentSession(sessionId, project?.path ?? null);
|
|
useUIStore.getState().setActiveSurface('chat');
|
|
}
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed'));
|
|
} finally {
|
|
setMutatingTaskID(null);
|
|
}
|
|
}, [selectedProjectID, projects, reloadTasks, t]);
|
|
|
|
const projectSelector = (
|
|
<div className="flex flex-col items-start gap-1">
|
|
<span className="typography-meta text-muted-foreground">{t('sessions.scheduledTasks.dialog.project.label')}</span>
|
|
<Select
|
|
value={selectedProjectID || '__none'}
|
|
onValueChange={(value) => {
|
|
const nextProjectID = value === '__none' ? '' : value;
|
|
setSelectedProjectID(nextProjectID);
|
|
if (nextProjectID) {
|
|
void reloadTasks(nextProjectID);
|
|
} else {
|
|
setTasks([]);
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger size="lg" className={isMobile ? 'w-full' : undefined}>
|
|
{selectedProject ? (
|
|
<SelectValue>{renderProjectLabel(selectedProject)}</SelectValue>
|
|
) : (
|
|
<SelectValue placeholder={t('sessions.scheduledTasks.dialog.project.placeholder')} />
|
|
)}
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{projects.length === 0 ? <SelectItem value="__none">{t('sessions.scheduledTasks.dialog.project.empty')}</SelectItem> : null}
|
|
{projects.map((project) => (
|
|
<SelectItem key={project.id} value={project.id}>
|
|
{renderProjectLabel(project)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
|
|
const openNewTaskEditor = () => {
|
|
setEditorTask(null);
|
|
setEditorOpen(true);
|
|
};
|
|
|
|
const selectProject = (nextProjectID: string) => {
|
|
setSelectedProjectID(nextProjectID);
|
|
if (nextProjectID) {
|
|
void reloadTasks(nextProjectID);
|
|
} else {
|
|
setTasks([]);
|
|
}
|
|
};
|
|
|
|
const tasksList = (
|
|
<div className="min-h-[280px]">
|
|
{loading ? (
|
|
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
|
|
<Icon name="loader-4" className="h-4 w-4 animate-spin" /> {t('sessions.scheduledTasks.dialog.loading')}
|
|
</div>
|
|
) : tasks.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed border-border p-4 typography-meta text-muted-foreground">
|
|
{selectedProjectID ? t('sessions.scheduledTasks.dialog.empty.noTasks') : t('sessions.scheduledTasks.dialog.empty.selectProject')}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2.5">
|
|
{tasks.map((task) => {
|
|
const isBusy = mutatingTaskID === task.id;
|
|
const status = (task.state?.lastStatus || 'idle') as ScheduledTaskStatus;
|
|
const meta = STATUS_META[status];
|
|
const statusLabel = status === 'success'
|
|
? t('sessions.scheduledTasks.dialog.status.success')
|
|
: status === 'error'
|
|
? t('sessions.scheduledTasks.dialog.status.error')
|
|
: status === 'running'
|
|
? t('sessions.scheduledTasks.dialog.status.running')
|
|
: t('sessions.scheduledTasks.dialog.status.idle');
|
|
const nextAt = task.state?.nextRunAt;
|
|
const lastAt = task.state?.lastRunAt;
|
|
|
|
return (
|
|
<div
|
|
key={task.id}
|
|
className={cn(
|
|
'rounded-lg border border-border p-4 transition-opacity',
|
|
!task.enabled && 'opacity-60',
|
|
)}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="typography-ui-header truncate font-semibold text-foreground">
|
|
{task.name}
|
|
</div>
|
|
<div className="typography-micro truncate text-muted-foreground">
|
|
{formatSchedule(task, t)}
|
|
</div>
|
|
{task.loopFile ? (
|
|
<div
|
|
className="typography-micro truncate text-muted-foreground/70"
|
|
title={task.loopFile}
|
|
>
|
|
{t('sessions.scheduledTasks.dialog.loopFile.note', { file: task.loopFile })}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 typography-micro text-muted-foreground">
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Icon name="timer" className="h-3.5 w-3.5" />
|
|
<span className="font-medium text-foreground">{t('sessions.scheduledTasks.dialog.nextRun.label')}</span>
|
|
{nextAt ? (
|
|
<>
|
|
<span className="text-foreground">{formatRelativeTime(nextAt, t)}</span>
|
|
<span className="text-muted-foreground/50">·</span>
|
|
<span>{formatClockTime(nextAt, timeFormatPreference)}</span>
|
|
</>
|
|
) : (
|
|
<span>—</span>
|
|
)}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Icon name="history" className="h-3.5 w-3.5" />
|
|
<span className="font-medium text-foreground">{t('sessions.scheduledTasks.dialog.lastRun.label')}</span>
|
|
{status === 'running' ? (
|
|
<span
|
|
className="inline-flex items-center gap-1"
|
|
style={{ color: 'var(--status-warning)' }}
|
|
>
|
|
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />
|
|
{t('sessions.scheduledTasks.dialog.lastRun.runningNow')}
|
|
</span>
|
|
) : lastAt ? (
|
|
<>
|
|
{meta.tone !== 'muted' ? (
|
|
<span
|
|
className="inline-flex items-center gap-1"
|
|
style={{ color: `var(--status-${meta.tone})` }}
|
|
>
|
|
<Icon name={meta.Icon} className="h-3.5 w-3.5" />
|
|
{statusLabel}
|
|
</span>
|
|
) : null}
|
|
<span className="text-muted-foreground/50">·</span>
|
|
<span>{formatRelativeTime(lastAt, t)}</span>
|
|
</>
|
|
) : (
|
|
<span>{t('sessions.scheduledTasks.dialog.lastRun.never')}</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
{task.state?.lastError ? (
|
|
<div
|
|
className="mt-3 flex items-start gap-2 rounded-md border p-2 typography-micro"
|
|
style={toneStyle('error')}
|
|
>
|
|
<Icon name="error-warning" className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
<span className="min-w-0 break-words">{task.state.lastError}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-2">
|
|
<label
|
|
className={cn(
|
|
'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium',
|
|
task.enabled ? 'text-foreground' : 'text-muted-foreground',
|
|
isBusy && 'cursor-not-allowed opacity-50',
|
|
)}
|
|
>
|
|
<Checkbox
|
|
checked={task.enabled}
|
|
onChange={(enabled) => void handleToggleEnabled(task, enabled)}
|
|
ariaLabel={task.enabled
|
|
? t('sessions.scheduledTasks.dialog.taskToggle.pauseAria', { taskName: task.name })
|
|
: t('sessions.scheduledTasks.dialog.taskToggle.enableAria', { taskName: task.name })}
|
|
disabled={isBusy}
|
|
/>
|
|
{task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')}
|
|
</label>
|
|
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => void handleRunNow(task)}
|
|
disabled={isBusy}
|
|
>
|
|
<Icon name="play" className="h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.runNow')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
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')}
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => void handleDeleteTask(task)}
|
|
disabled={isBusy}
|
|
aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })}
|
|
>
|
|
<Icon name="delete-bin" className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const tasksContent = (
|
|
<div className="space-y-4">
|
|
{projectSelector}
|
|
{tasksList}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{isMobile ? (
|
|
<MobileOverlayPanel
|
|
open={open}
|
|
title={t('sessions.scheduledTasks.dialog.title')}
|
|
onClose={() => setOpen(false)}
|
|
contentMaxHeightClassName="max-h-[min(80vh,640px)]"
|
|
renderHeader={(closeButton) => (
|
|
<div className="flex flex-col gap-1 border-b border-border/40 px-3 py-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h2 className="typography-ui-label font-semibold text-foreground">{t('sessions.scheduledTasks.dialog.title')}</h2>
|
|
{closeButton}
|
|
</div>
|
|
<p className="typography-micro text-muted-foreground">
|
|
{t('sessions.scheduledTasks.dialog.description')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
footer={(
|
|
<Button
|
|
className="w-full"
|
|
onClick={openNewTaskEditor}
|
|
disabled={!selectedProjectID}
|
|
>
|
|
<Icon name="add" className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')}
|
|
</Button>
|
|
)}
|
|
>
|
|
{tasksContent}
|
|
</MobileOverlayPanel>
|
|
) : open ? (
|
|
// Full-page surface replacing the chat area (mounted inside <main>).
|
|
// Master-detail: a scrollable project filter panel at the left, the
|
|
// selected project's tasks at the right. The app Header shows the
|
|
// surface title, so the page itself only carries the close affordance.
|
|
<div className="absolute inset-0 z-10 flex flex-col bg-background">
|
|
<div className="flex min-h-0 flex-1">
|
|
<div className="flex w-60 flex-shrink-0 flex-col border-r border-border/50">
|
|
<div className="flex-1 space-y-0.5 overflow-y-auto p-2">
|
|
{projects.length === 0 ? (
|
|
<div className="px-2 py-2 typography-meta text-muted-foreground">
|
|
{t('sessions.scheduledTasks.dialog.project.empty')}
|
|
</div>
|
|
) : projects.map((project) => (
|
|
<button
|
|
key={project.id}
|
|
type="button"
|
|
onClick={() => selectProject(project.id)}
|
|
className={cn(
|
|
'flex w-full min-w-0 items-center rounded-md px-2 py-1.5 text-left typography-ui-label focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
|
selectedProjectID === project.id
|
|
? 'bg-interactive-selection text-foreground'
|
|
: 'text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground',
|
|
)}
|
|
>
|
|
{renderProjectLabel(project)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
{/* Pages have no close button: you leave by picking a session,
|
|
a draft, or another surface in the sidebar. */}
|
|
<div className="flex items-center px-6 pt-3">
|
|
<Button size="sm" onClick={openNewTaskEditor} disabled={!selectedProjectID}>
|
|
<Icon name="add" className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')}
|
|
</Button>
|
|
</div>
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
|
<div className="mx-auto w-full max-w-3xl">
|
|
{tasksList}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<ScheduledTaskEditorDialog
|
|
open={editorOpen}
|
|
task={editorTask}
|
|
onOpenChange={setEditorOpen}
|
|
onSave={handleSaveTask}
|
|
/>
|
|
</>
|
|
);
|
|
}
|