diff --git a/agentex-ui/components/agentex-ui-root.tsx b/agentex-ui/components/agentex-ui-root.tsx index e027b974..1ac111bb 100644 --- a/agentex-ui/components/agentex-ui-root.tsx +++ b/agentex-ui/components/agentex-ui-root.tsx @@ -67,6 +67,7 @@ export function AgentexUIRoot() { (taskId: string | null) => { updateParams({ [SearchParamKey.TASK_ID]: taskId, + [SearchParamKey.VIEW]: null, }); }, [updateParams] diff --git a/agentex-ui/components/primary-content/primary-content.tsx b/agentex-ui/components/primary-content/primary-content.tsx index c7bdbe78..4afcfcff 100644 --- a/agentex-ui/components/primary-content/primary-content.tsx +++ b/agentex-ui/components/primary-content/primary-content.tsx @@ -6,8 +6,9 @@ import { ArrowDown } from 'lucide-react'; import { ChatView } from '@/components/primary-content/chat-view'; import { HomeView } from '@/components/primary-content/home-view'; import { PromptInput } from '@/components/primary-content/prompt-input'; +import { ScheduledTasksPage } from '@/components/scheduled-tasks/scheduled-tasks-page'; import { IconButton } from '@/components/ui/icon-button'; -import { useSafeSearchParams } from '@/hooks/use-safe-search-params'; +import { AppView, useSafeSearchParams } from '@/hooks/use-safe-search-params'; type ContentAreaProps = { isTracesSidebarOpen: boolean; @@ -18,7 +19,8 @@ export function PrimaryContent({ isTracesSidebarOpen, toggleTracesSidebar, }: ContentAreaProps) { - const { taskID } = useSafeSearchParams(); + const { taskID, view } = useSafeSearchParams(); + const isScheduledTasksView = view === AppView.SCHEDULED_TASKS; const [prompt, setPrompt] = useState(''); const [showScrollButton, setShowScrollButton] = useState(false); @@ -56,20 +58,24 @@ export function PrimaryContent({ }, [scrollContainerRef]); useEffect(() => { - if (scrollContainerRef.current && taskID) { + if (scrollContainerRef.current && taskID && !isScheduledTasksView) { setTimeout(() => { scrollToBottom(); }, 150); } - }, [scrollToBottom, taskID]); + }, [scrollToBottom, taskID, isScheduledTasksView]); return ( - {taskID ? ( + {isScheduledTasksView ? ( + + ) : taskID ? ( )} - - - {taskID && showScrollButton && ( - - )} - - - - + {!isScheduledTasksView && ( + + + {taskID && showScrollButton && ( + + )} + + + + + )} ); } diff --git a/agentex-ui/components/providers/agentex-provider.tsx b/agentex-ui/components/providers/agentex-provider.tsx index 4d3481ed..af927f83 100644 --- a/agentex-ui/components/providers/agentex-provider.tsx +++ b/agentex-ui/components/providers/agentex-provider.tsx @@ -6,6 +6,7 @@ import AgentexSDK from 'agentex'; interface AgentexContextValue { agentexClient: AgentexSDK; + agentexAPIBaseURL: string; sgpAppURL: string; } @@ -34,7 +35,9 @@ export function AgentexProvider({ ); return ( - + {children} ); diff --git a/agentex-ui/components/scheduled-tasks/scheduled-tasks-page.tsx b/agentex-ui/components/scheduled-tasks/scheduled-tasks-page.tsx new file mode 100644 index 00000000..63bdb6e2 --- /dev/null +++ b/agentex-ui/components/scheduled-tasks/scheduled-tasks-page.tsx @@ -0,0 +1,1398 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { + Bot, + CalendarClock, + Clock3, + Loader2, + MoreHorizontal, + PauseCircle, + Pencil, + Play, + Trash2, + X, +} from 'lucide-react'; + +import { useAgentexClient } from '@/components/providers'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { toast } from '@/components/ui/toast'; +import { useAgentByName } from '@/hooks/use-agent-by-name'; +import { + useAgentRunScheduleDetailsForItems, + useAgentRunSchedules, + useAgentRunSchedulesForAgents, + useCreateAgentRunSchedule, + useScheduleAction, + useUpdateAgentRunSchedule, +} from '@/hooks/use-agent-run-schedules'; +import { useAgents } from '@/hooks/use-agents'; +import { + SearchParamKey, + useSafeSearchParams, +} from '@/hooks/use-safe-search-params'; +import type { AgentRunSchedule } from '@/lib/agent-run-schedules'; +import { + cadenceToPayload, + DEFAULT_CADENCE, + describeCadence, + generateScheduleName, + normalizeScheduleName, + scheduleToCadence, + type CadenceConfig, + type CadenceType, +} from '@/lib/schedule-utils'; +import { cn } from '@/lib/utils'; + +import type { Agent } from 'agentex/resources'; + +type ScheduleScope = 'current' | 'all'; +type ScheduleView = 'upcoming' | 'all'; + +type ScheduleListItem = { + agentId: string; + agentName: string; + schedule: AgentRunSchedule; +}; + +type UpcomingRunItem = ScheduleListItem & { + runTime: Date; + isSkipped: boolean; +}; + +const DAYS = [ + ['MON', 'Monday'], + ['TUE', 'Tuesday'], + ['WED', 'Wednesday'], + ['THU', 'Thursday'], + ['FRI', 'Friday'], + ['SAT', 'Saturday'], + ['SUN', 'Sunday'], +] as const; + +const COMMON_TIMEZONES = [ + 'UTC', + 'America/Los_Angeles', + 'America/Denver', + 'America/Chicago', + 'America/New_York', + 'America/Toronto', + 'America/Sao_Paulo', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Asia/Dubai', + 'Asia/Kolkata', + 'Asia/Singapore', + 'Asia/Tokyo', + 'Australia/Sydney', +] as const; + +function getBrowserTimezone() { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; +} + +function getNextRun(schedule: AgentRunSchedule) { + const nextRun = schedule.next_action_times[0]; + return nextRun ? new Date(nextRun) : null; +} + +function getNextRunTime(schedule: AgentRunSchedule) { + return getNextRun(schedule)?.getTime() ?? null; +} + +function getVisibleUpcomingRuns(schedule: AgentRunSchedule) { + const now = Date.now(); + const runItems = [ + ...schedule.next_action_times.map(nextRun => ({ + runTime: new Date(nextRun), + isSkipped: false, + })), + ...schedule.skipped_action_times.map(skippedRun => ({ + runTime: new Date(skippedRun), + isSkipped: true, + })), + ]; + const runsByTime = new Map(); + + for (const runItem of runItems) { + if (runItem.runTime.getTime() >= now) { + const key = runItem.runTime.toISOString(); + const existing = runsByTime.get(key); + runsByTime.set(key, { + runTime: runItem.runTime, + isSkipped: runItem.isSkipped || existing?.isSkipped === true, + }); + } + } + + return Array.from(runsByTime.values()) + .sort((a, b) => a.runTime.getTime() - b.runTime.getTime()) + .slice(0, 5); +} + +function isSchedulePaused(schedule: AgentRunSchedule) { + return schedule.state === 'PAUSED' || schedule.paused; +} + +function sortScheduleItems(items: ScheduleListItem[], view: ScheduleView) { + return [...items].sort((a, b) => { + const aNextRun = getNextRunTime(a.schedule); + const bNextRun = getNextRunTime(b.schedule); + + if (view === 'upcoming') { + return (aNextRun ?? 0) - (bNextRun ?? 0); + } + + if (aNextRun != null && bNextRun != null) { + return aNextRun - bNextRun; + } + if (aNextRun != null) return -1; + if (bNextRun != null) return 1; + return a.schedule.name.localeCompare(b.schedule.name); + }); +} + +function formatRunTime(date: Date) { + return date.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }); +} + +function formatRunDateTime(date: Date) { + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function getUpcomingGroup(date: Date) { + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + const startOfTomorrow = new Date(startOfToday); + startOfTomorrow.setDate(startOfToday.getDate() + 1); + const startOfDayAfterTomorrow = new Date(startOfTomorrow); + startOfDayAfterTomorrow.setDate(startOfTomorrow.getDate() + 1); + + if (date >= startOfToday && date < startOfTomorrow) { + return 'Today'; + } + if (date >= startOfTomorrow && date < startOfDayAfterTomorrow) { + return 'Tomorrow'; + } + return 'Later'; +} + +function formatUpcomingSubtitle(date: Date) { + const group = getUpcomingGroup(date); + if (group === 'Today') { + return `Today, ${formatRunTime(date)}`; + } + if (group === 'Tomorrow') { + return `Tomorrow, ${formatRunTime(date)}`; + } + return formatRunDateTime(date); +} + +function groupUpcomingRuns(items: ScheduleListItem[]) { + const groups = new Map(); + for (const item of items) { + for (const run of getVisibleUpcomingRuns(item.schedule)) { + const group = getUpcomingGroup(run.runTime); + groups.set(group, [...(groups.get(group) ?? []), { ...item, ...run }]); + } + } + return ['Today', 'Tomorrow', 'Later'] + .map(label => ({ + label, + items: [...(groups.get(label) ?? [])].sort( + (a, b) => a.runTime.getTime() - b.runTime.getTime() + ), + })) + .filter(group => group.items.length > 0); +} + +function useCloseOnOutsideClick( + isOpen: boolean, + onClose: () => void +) { + const ref = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + if (!ref.current?.contains(event.target as Node)) { + onClose(); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, onClose]); + + return ref; +} + +export function ScheduledTasksPage() { + const { agentName, updateParams } = useSafeSearchParams(); + const { agentexClient, agentexAPIBaseURL } = useAgentexClient(); + const { data: agents = [], isLoading: agentsLoading } = + useAgents(agentexClient); + const { data: agentByName } = useAgentByName(agentexClient, agentName); + const [scope, setScope] = useState('current'); + const [scheduleView, setScheduleView] = useState('upcoming'); + const selectedAgent = + agents.find(agent => agent.name === agentName) ?? agentByName ?? null; + const agentId = selectedAgent?.id ?? null; + + const schedulesQuery = useAgentRunSchedules(agentexAPIBaseURL, agentId); + const schedules = useMemo( + () => schedulesQuery.data ?? [], + [schedulesQuery.data] + ); + const allScheduleQueries = useAgentRunSchedulesForAgents( + agentexAPIBaseURL, + agents, + scope === 'all' + ); + + const currentItems = useMemo( + () => + selectedAgent + ? schedules.map(schedule => ({ + agentId: selectedAgent.id, + agentName: selectedAgent.name, + schedule, + })) + : [], + [schedules, selectedAgent] + ); + + const allItems = useMemo( + () => + agents.flatMap((agent, index) => + (allScheduleQueries[index]?.data ?? []).map(schedule => ({ + agentId: agent.id, + agentName: agent.name, + schedule, + })) + ), + [agents, allScheduleQueries] + ); + + const baseItems = scope === 'all' ? allItems : currentItems; + const detailQueries = useAgentRunScheduleDetailsForItems( + agentexAPIBaseURL, + baseItems + ); + const schedulesWithLiveFields = baseItems.map((item, index) => ({ + ...item, + schedule: detailQueries[index]?.data ?? item.schedule, + })); + + const visibleItems = useMemo(() => { + const scopedItems = + scheduleView === 'upcoming' + ? schedulesWithLiveFields.filter( + item => + !isSchedulePaused(item.schedule) && + getNextRunTime(item.schedule) != null + ) + : schedulesWithLiveFields; + return sortScheduleItems(scopedItems, scheduleView); + }, [scheduleView, schedulesWithLiveFields]); + + const isLoading = + scope === 'all' + ? agentsLoading || allScheduleQueries.some(query => query.isLoading) + : schedulesQuery.isLoading; + const error = + scope === 'all' + ? (allScheduleQueries.find(query => query.error)?.error ?? null) + : schedulesQuery.error; + const emptyMessage = + scheduleView === 'upcoming' + ? 'No upcoming scheduled runs' + : scope === 'all' + ? 'No schedules across agents yet' + : 'No scheduled tasks yet'; + + return ( +
+
+
+

+ Scheduled Tasks +

+

+ {scope === 'all' + ? 'Browse schedules across all agents.' + : agentName + ? `Run ${agentName} automatically on a cadence.` + : 'Select an agent to schedule recurring tasks.'} +

+
+ { + setScope('current'); + updateParams({ [SearchParamKey.AGENT_NAME]: nextAgentName }); + }} + /> +
+ +
+ {scope === 'current' && !selectedAgent ? ( + + ) : ( + <> + {scope === 'current' && selectedAgent && ( + + )} + + + + )} +
+
+ ); +} + +function ScheduleScopeSelector({ + scope, + selectedAgent, + agents, + onChange, + onSelectAgent, +}: { + scope: ScheduleScope; + selectedAgent: Agent | null; + agents: Agent[]; + onChange: (scope: ScheduleScope) => void; + onSelectAgent: (agentName: string) => void; +}) { + const value = scope === 'all' ? 'all' : selectedAgent?.name; + + return ( + + ); +} + +function ScheduleViewTabs({ + view, + onChange, +}: { + view: ScheduleView; + onChange: (view: ScheduleView) => void; +}) { + return ( +
+
+ {(['upcoming', 'all'] as const).map(option => ( + + ))} +
+
+ ); +} + +function ScheduleComposer({ + agentId, + baseURL, + schedules, +}: { + agentId: string; + baseURL: string; + schedules: AgentRunSchedule[]; +}) { + const [prompt, setPrompt] = useState(''); + const [cadence, setCadence] = useState(DEFAULT_CADENCE); + const [timezone, setTimezone] = useState(getBrowserTimezone); + const [pendingName, setPendingName] = useState(null); + const createSchedule = useCreateAgentRunSchedule({ baseURL, agentId }); + + const handleReview = () => { + if (!prompt.trim()) { + toast.error('Enter a prompt to schedule'); + return; + } + setPendingName(generateScheduleName(prompt, schedules)); + }; + + const handleCreate = async () => { + if (!pendingName) return; + await createSchedule.mutateAsync({ + name: pendingName, + timezone, + ...cadenceToPayload(cadence), + initial_input: { + type: 'text', + author: 'user', + content: prompt.trim(), + }, + }); + setPrompt(''); + setPendingName(null); + }; + + return ( +
+
+