Add shared time formatting helpers that apply the Appearance time format preference consistently. Update visible time labels across chat, quota usage, scheduled tasks, tunnels, context details, git views, PR metadata, and passkey settings to use the selected 12-hour, 24-hour, or automatic format. Leave date-only and non-UI formatting untouched so unrelated behavior does not change.
637 lines
25 KiB
TypeScript
637 lines
25 KiB
TypeScript
import * as React from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
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 { 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,
|
|
fetchScheduledTasks,
|
|
runScheduledTaskNow,
|
|
upsertScheduledTask,
|
|
type ScheduledTask,
|
|
type ScheduledTaskStatus,
|
|
} from '@/lib/scheduledTasksApi';
|
|
import { ScheduledTaskEditorDialog } from './ScheduledTaskEditorDialog';
|
|
|
|
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: 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: 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: 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: 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 {
|
|
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(t('sessions.scheduledTasks.dialog.confirm.deleteTask', { taskName: task.name }));
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
setMutatingTaskID(task.id);
|
|
try {
|
|
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 handleRunNow = React.useCallback(async (task: ScheduledTask) => {
|
|
if (!selectedProjectID) {
|
|
return;
|
|
}
|
|
setMutatingTaskID(task.id);
|
|
try {
|
|
await runScheduledTaskNow(selectedProjectID, task.id);
|
|
await Promise.all([
|
|
reloadTasks(selectedProjectID, { silent: true }),
|
|
refreshGlobalSessions(),
|
|
]);
|
|
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed'));
|
|
} finally {
|
|
setMutatingTaskID(null);
|
|
}
|
|
}, [selectedProjectID, 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 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 tasksContent = (
|
|
<div className="space-y-4">
|
|
{!isMobile ? (
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
{projectSelector}
|
|
<Button onClick={openNewTaskEditor} disabled={!selectedProjectID}>
|
|
<Icon name="add" className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
projectSelector
|
|
)}
|
|
|
|
<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>
|
|
</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={() => {
|
|
setEditorTask(task);
|
|
setEditorOpen(true);
|
|
}}
|
|
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>
|
|
</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>
|
|
) : (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('sessions.scheduledTasks.dialog.title')}</DialogTitle>
|
|
<DialogDescription>{t('sessions.scheduledTasks.dialog.description')}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{tasksContent}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
|
|
<ScheduledTaskEditorDialog
|
|
open={editorOpen}
|
|
task={editorTask}
|
|
onOpenChange={setEditorOpen}
|
|
onSave={handleSaveTask}
|
|
/>
|
|
</>
|
|
);
|
|
}
|