feat: add scheduled tasks with locale-aware scheduling and safer desktop quit flow (#920)
* feat: keep desktop app running in background when closing last window Closing last window hides it instead of quitting — sidecar keeps running Cmd+Q now shows confirmation dialog warning about stopping background processes Clicking dock icon reopens hidden window or creates a new one * docs: add scheduled tasks impl plan * feat: add scheduled tasks runtime, api, and ui * feat: conditionally confirm desktop quit on risks * chore: remove scheduled tasks plan doc * feat: add scheduled tasks runtime and management UI Add server-side scheduled task runtime with project-backed config persistence Add task scheduling UI and API integration for creating and editing schedules Add tests for runtime scheduling behavior and project config validation * feat: add locale display preferences for scheduled tasks Add Appearance settings for time format and week start with settings.json persistence Apply preferences in scheduled task editor for time display and weekday ordering Rename Thinking level control and disable it when model variants are unavailable * feat: improve scheduled tasks editor and sidebar action order Reorder session sidebar header actions to separate creation and management tools Polish scheduled tasks dialog layout and controls for clearer editing flow * feat: polish scheduled task editor usability Improve scheduled task dialog layout for clearer scheduling controls Refine time and weekday inputs for more intuitive task configuration Update editor labels and control states for better model variant guidance * feat: add prompt autocomplete and command-aware scheduled runs Add @ and / autocomplete support to task, multi-run, and agent manager prompt fields Fix agent mention selection so subagents can be inserted from @ suggestions Run scheduled prompts as commands when they match slash commands, with message fallback
This commit is contained in:
committed by
GitHub
parent
ea5c19e934
commit
4f228f768d
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,587 @@
|
||||
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 {
|
||||
RiLoader4Line,
|
||||
RiPlayLine,
|
||||
RiEdit2Line,
|
||||
RiDeleteBinLine,
|
||||
RiAddLine,
|
||||
RiFolderLine,
|
||||
RiTimerLine,
|
||||
RiHistoryLine,
|
||||
RiCheckboxCircleLine,
|
||||
RiErrorWarningLine,
|
||||
RiPulseLine,
|
||||
} from '@remixicon/react';
|
||||
import { useUIStore } 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, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import {
|
||||
deleteScheduledTask,
|
||||
fetchScheduledTasks,
|
||||
runScheduledTaskNow,
|
||||
upsertScheduledTask,
|
||||
type ScheduledTask,
|
||||
type ScheduledTaskStatus,
|
||||
} from '@/lib/scheduledTasksApi';
|
||||
import { ScheduledTaskEditorDialog } from './ScheduledTaskEditorDialog';
|
||||
|
||||
const WEEKDAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
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): string => {
|
||||
const timesLabel = scheduleTimes(task).join(', ') || '--:--';
|
||||
if (task.schedule.kind === 'daily') {
|
||||
return `Daily ${timesLabel}${task.schedule.timezone ? ` (${task.schedule.timezone})` : ''}`;
|
||||
}
|
||||
if (task.schedule.kind === 'weekly') {
|
||||
const days = Array.isArray(task.schedule.weekdays)
|
||||
? task.schedule.weekdays.map((value) => WEEKDAY_NAMES[value] || '?').join(', ')
|
||||
: '';
|
||||
return `Weekly ${days} ${timesLabel}${task.schedule.timezone ? ` (${task.schedule.timezone})` : ''}`;
|
||||
}
|
||||
if (task.schedule.kind === 'once') {
|
||||
const date = typeof task.schedule.date === 'string' && task.schedule.date.trim().length > 0
|
||||
? task.schedule.date
|
||||
: 'Unknown date';
|
||||
const time = typeof task.schedule.time === 'string' && task.schedule.time.trim().length > 0
|
||||
? task.schedule.time
|
||||
: '--:--';
|
||||
return `One-time ${date} ${time}${task.schedule.timezone ? ` (${task.schedule.timezone})` : ''}`;
|
||||
}
|
||||
return `Cron: ${task.schedule.cron || ''}${task.schedule.timezone ? ` (${task.schedule.timezone})` : ''}`;
|
||||
};
|
||||
|
||||
const formatClockTime = (value?: number): string => {
|
||||
if (!value || !Number.isFinite(value)) {
|
||||
return '';
|
||||
}
|
||||
return new Date(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const formatRelativeTime = (value?: number): 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 ? 'in <1m' : 'just now';
|
||||
}
|
||||
if (abs < hour) {
|
||||
const m = Math.round(abs / minute);
|
||||
return future ? `in ${m}m` : `${m}m ago`;
|
||||
}
|
||||
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 ? `in ${body}` : `${body} ago`;
|
||||
}
|
||||
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 ? `in ${body}` : `${body} ago`;
|
||||
};
|
||||
|
||||
type StatusTone = 'success' | 'error' | 'warning' | 'muted';
|
||||
|
||||
const STATUS_META: Record<
|
||||
ScheduledTaskStatus,
|
||||
{
|
||||
tone: StatusTone;
|
||||
label: string;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
spin?: boolean;
|
||||
}
|
||||
> = {
|
||||
success: { tone: 'success', label: 'Success', Icon: RiCheckboxCircleLine },
|
||||
error: { tone: 'error', label: 'Error', Icon: RiErrorWarningLine },
|
||||
running: { tone: 'warning', label: 'Running', Icon: RiLoader4Line, spin: true },
|
||||
idle: { tone: 'muted', label: 'Idle', Icon: RiPulseLine },
|
||||
};
|
||||
|
||||
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 open = useUIStore((state) => state.isScheduledTasksDialogOpen);
|
||||
const setOpen = useUIStore((state) => state.setScheduledTasksDialogOpen);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
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[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
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 imageUrl = getProjectIconImageUrl(
|
||||
{ id: project.id, iconImage: project.iconImage ?? null },
|
||||
{
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
},
|
||||
);
|
||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const iconColor = project.color ? PROJECT_COLOR_MAP[project.color] : undefined;
|
||||
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
{imageUrl ? (
|
||||
<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}
|
||||
>
|
||||
<img src={imageUrl} alt="" className="h-full w-full object-contain" draggable={false} />
|
||||
</span>
|
||||
) : ProjectIcon ? (
|
||||
<ProjectIcon className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<RiFolderLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
|
||||
)}
|
||||
<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 : 'Failed to load scheduled tasks');
|
||||
if (!options?.silent) {
|
||||
setTasks([]);
|
||||
}
|
||||
} finally {
|
||||
if (!options?.silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const preferredProjectID = activeProject?.id || projects[0]?.id || '';
|
||||
setSelectedProjectID(preferredProjectID);
|
||||
if (preferredProjectID) {
|
||||
void reloadTasks(preferredProjectID);
|
||||
} else {
|
||||
setTasks([]);
|
||||
}
|
||||
}, [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('Choose a project first');
|
||||
}
|
||||
await upsertScheduledTask(selectedProjectID, taskDraft);
|
||||
await reloadTasks(selectedProjectID);
|
||||
toast.success('Scheduled task saved');
|
||||
}, [selectedProjectID, reloadTasks]);
|
||||
|
||||
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 : 'Failed to update task');
|
||||
await reloadTasks(selectedProjectID, { silent: true });
|
||||
} finally {
|
||||
setMutatingTaskID(null);
|
||||
}
|
||||
}, [selectedProjectID, reloadTasks]);
|
||||
|
||||
const handleDeleteTask = React.useCallback(async (task: ScheduledTask) => {
|
||||
if (!selectedProjectID) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(`Delete scheduled task "${task.name}"?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMutatingTaskID(task.id);
|
||||
try {
|
||||
await deleteScheduledTask(selectedProjectID, task.id);
|
||||
await reloadTasks(selectedProjectID, { silent: true });
|
||||
toast.success('Scheduled task deleted');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete task');
|
||||
} finally {
|
||||
setMutatingTaskID(null);
|
||||
}
|
||||
}, [selectedProjectID, reloadTasks]);
|
||||
|
||||
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('Task started');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to run task');
|
||||
} finally {
|
||||
setMutatingTaskID(null);
|
||||
}
|
||||
}, [selectedProjectID, reloadTasks]);
|
||||
|
||||
const projectSelector = (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="typography-meta text-muted-foreground">Project</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="Select project" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.length === 0 ? <SelectItem value="__none">No projects</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}>
|
||||
<RiAddLine className="mr-1 h-4 w-4" /> New task
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
projectSelector
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" /> Loading tasks...
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-4 typography-meta text-muted-foreground">
|
||||
{selectedProjectID ? 'No scheduled tasks yet.' : 'Select a project to manage scheduled tasks.'}
|
||||
</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 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)}
|
||||
</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">
|
||||
<RiTimerLine className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-foreground">Next</span>
|
||||
{nextAt ? (
|
||||
<>
|
||||
<span className="text-foreground">{formatRelativeTime(nextAt)}</span>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span>{formatClockTime(nextAt)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>—</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<RiHistoryLine className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-foreground">Last run</span>
|
||||
{status === 'running' ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1"
|
||||
style={{ color: 'var(--status-warning)' }}
|
||||
>
|
||||
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
|
||||
running now
|
||||
</span>
|
||||
) : lastAt ? (
|
||||
<>
|
||||
{meta.tone !== 'muted' ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1"
|
||||
style={{ color: `var(--status-${meta.tone})` }}
|
||||
>
|
||||
<meta.Icon className="h-3.5 w-3.5" />
|
||||
{meta.label}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span>{formatRelativeTime(lastAt)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>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')}
|
||||
>
|
||||
<RiErrorWarningLine 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 ? `Pause ${task.name}` : `Enable ${task.name}`}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
{task.enabled ? 'Enabled' : 'Paused'}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleRunNow(task)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<RiPlayLine className="h-4 w-4" /> run now
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditorTask(task);
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
disabled={isBusy}
|
||||
aria-label={`Edit ${task.name}`}
|
||||
>
|
||||
<RiEdit2Line className="h-4 w-4" /> edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => void handleDeleteTask(task)}
|
||||
disabled={isBusy}
|
||||
aria-label={`Delete ${task.name}`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<MobileOverlayPanel
|
||||
open={open}
|
||||
title="Scheduled tasks"
|
||||
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">Scheduled tasks</h2>
|
||||
{closeButton}
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Server-side tasks that create a new session and send a configured prompt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
footer={(
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={openNewTaskEditor}
|
||||
disabled={!selectedProjectID}
|
||||
>
|
||||
<RiAddLine className="mr-1 h-4 w-4" /> New task
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{tasksContent}
|
||||
</MobileOverlayPanel>
|
||||
) : (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Scheduled tasks</DialogTitle>
|
||||
<DialogDescription>Server-side tasks that create a new session and send a configured prompt.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{tasksContent}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<ScheduledTaskEditorDialog
|
||||
open={editorOpen}
|
||||
task={editorTask}
|
||||
onOpenChange={setEditorOpen}
|
||||
onSave={handleSaveTask}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/us
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { NewWorktreeDialog } from './NewWorktreeDialog';
|
||||
import { ScheduledTasksDialog } from './ScheduledTasksDialog';
|
||||
import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
@@ -71,6 +72,7 @@ import {
|
||||
import { refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
|
||||
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||
@@ -267,6 +269,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const setAboutDialogOpen = useUIStore((state) => state.setAboutDialogOpen);
|
||||
const deviceInfo = useDeviceInfo();
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const setScheduledTasksDialogOpen = useUIStore((state) => state.setScheduledTasksDialogOpen);
|
||||
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
|
||||
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
|
||||
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
|
||||
@@ -407,6 +410,27 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
};
|
||||
}, [currentDirectory, syncSessionStructureSignature]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const unsubscribe = subscribeOpenchamberEvents((event) => {
|
||||
if (event.type !== 'scheduled-task-ran') {
|
||||
return;
|
||||
}
|
||||
if (refreshTimeout) {
|
||||
clearTimeout(refreshTimeout);
|
||||
}
|
||||
refreshTimeout = setTimeout(() => {
|
||||
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
|
||||
}, 500);
|
||||
});
|
||||
return () => {
|
||||
if (refreshTimeout) {
|
||||
clearTimeout(refreshTimeout);
|
||||
}
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
||||
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||
const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false);
|
||||
@@ -1511,6 +1535,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
searchMatchCount={searchMatchCount}
|
||||
collapseAllProjects={collapseAllProjects}
|
||||
expandAllProjects={expandAllProjects}
|
||||
openScheduledTasksDialog={() => setScheduledTasksDialogOpen(true)}
|
||||
/>
|
||||
|
||||
<SidebarProjectsList
|
||||
@@ -1603,6 +1628,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScheduledTasksDialog />
|
||||
|
||||
{useMobileNotesPanel ? (
|
||||
<MobileOverlayPanel
|
||||
open={projectNotesPanelOpen}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
RiContractUpDownLine,
|
||||
RiExpandUpDownLine,
|
||||
RiStickyNoteLine,
|
||||
RiCalendarScheduleLine,
|
||||
} from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
@@ -47,6 +48,7 @@ type Props = {
|
||||
searchMatchCount: number;
|
||||
collapseAllProjects: () => void;
|
||||
expandAllProjects: () => void;
|
||||
openScheduledTasksDialog: () => void;
|
||||
};
|
||||
|
||||
export function SidebarHeader(props: Props): React.ReactNode {
|
||||
@@ -74,6 +76,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
searchMatchCount,
|
||||
collapseAllProjects,
|
||||
expandAllProjects,
|
||||
openScheduledTasksDialog,
|
||||
} = props;
|
||||
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
@@ -115,9 +118,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New session</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -132,6 +133,22 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New multi-run</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openScheduledTasksDialog}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Scheduled tasks"
|
||||
>
|
||||
<RiCalendarScheduleLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Scheduled tasks</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{useMobileNotesPanel ? (
|
||||
<Tooltip>
|
||||
|
||||
Reference in New Issue
Block a user