Files
openchamber/packages/ui/src/components/session/ScheduledTasksDialog.tsx
T
Bohdan Triapitsyn 4da7604d7d refactor(surface): drop the deprecated main-tab aliases and dead diagram surface
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.
2026-08-24 16:21:05 +03:00

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}
/>
</>
);
}