diff --git a/apps/web/app/components/cron/cron-dashboard.tsx b/apps/web/app/components/cron/cron-dashboard.tsx index e3405a3b679..525e601b178 100644 --- a/apps/web/app/components/cron/cron-dashboard.tsx +++ b/apps/web/app/components/cron/cron-dashboard.tsx @@ -1,12 +1,16 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; import type { CronJob, + CronRunLogEntry, HeartbeatInfo, CronStatusInfo, CronJobsResponse, + CronRunsResponse, } from "../../types/cron"; +import type { CronDashboardView } from "@/lib/workspace-links"; +import type { CalendarMode } from "@/lib/object-filters"; /* ─── Helpers ─── */ @@ -16,9 +20,9 @@ function formatSchedule(schedule: CronJob["schedule"]): string { return `cron: ${schedule.expr}${schedule.tz ? ` (${schedule.tz})` : ""}`; case "every": { const ms = schedule.everyMs; - if (ms >= 86_400_000) {return `every ${Math.round(ms / 86_400_000)}d`;} - if (ms >= 3_600_000) {return `every ${Math.round(ms / 3_600_000)}h`;} - if (ms >= 60_000) {return `every ${Math.round(ms / 60_000)}m`;} + if (ms >= 86_400_000) return `every ${Math.round(ms / 86_400_000)}d`; + if (ms >= 3_600_000) return `every ${Math.round(ms / 3_600_000)}h`; + if (ms >= 60_000) return `every ${Math.round(ms / 60_000)}m`; return `every ${Math.round(ms / 1000)}s`; } case "at": @@ -29,12 +33,12 @@ function formatSchedule(schedule: CronJob["schedule"]): string { } function formatCountdown(ms: number): string { - if (ms <= 0) {return "now";} + if (ms <= 0) return "now"; const totalSec = Math.ceil(ms / 1000); - if (totalSec < 60) {return `${totalSec}s`;} + if (totalSec < 60) return `${totalSec}s`; const min = Math.floor(totalSec / 60); const sec = totalSec % 60; - if (min < 60) {return sec > 0 ? `${min}m ${sec}s` : `${min}m`;} + if (min < 60) return sec > 0 ? `${min}m ${sec}s` : `${min}m`; const hr = Math.floor(min / 60); const remMin = min % 60; return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`; @@ -42,21 +46,21 @@ function formatCountdown(ms: number): string { function formatTimeAgo(ms: number): string { const ago = Date.now() - ms; - if (ago < 60_000) {return "just now";} - if (ago < 3_600_000) {return `${Math.floor(ago / 60_000)}m ago`;} - if (ago < 86_400_000) {return `${Math.floor(ago / 3_600_000)}h ago`;} + if (ago < 60_000) return "just now"; + if (ago < 3_600_000) return `${Math.floor(ago / 60_000)}m ago`; + if (ago < 86_400_000) return `${Math.floor(ago / 3_600_000)}h ago`; return `${Math.floor(ago / 86_400_000)}d ago`; } function formatDuration(ms: number): string { - if (ms < 1000) {return `${ms}ms`;} - if (ms < 60_000) {return `${(ms / 1000).toFixed(1)}s`;} + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`; } function jobStatusLabel(job: CronJob): string { - if (!job.enabled) {return "disabled";} - if (job.state.runningAtMs) {return "running";} + if (!job.enabled) return "disabled"; + if (job.state.runningAtMs) return "running"; return job.state.lastStatus ?? "idle"; } @@ -71,30 +75,49 @@ function jobStatusColor(status: string): string { } } -/* ─── Countdown hook ─── */ - function useCountdown(targetMs: number | null | undefined): string | null { const [now, setNow] = useState(Date.now()); useEffect(() => { - if (!targetMs) {return;} + if (!targetMs) return; const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); }, [targetMs]); - if (!targetMs) {return null;} + if (!targetMs) return null; return formatCountdown(targetMs - now); } +const TABS: { id: CronDashboardView; label: string }[] = [ + { id: "overview", label: "Overview" }, + { id: "calendar", label: "Calendar" }, + { id: "insights", label: "Insights" }, +]; + /* ─── Main component ─── */ export function CronDashboard({ onSelectJob, + onSendCommand, + activeView = "overview", + onViewChange, + calendarMode = "month", + onCalendarModeChange, + calendarDate, + onCalendarDateChange, }: { onSelectJob: (jobId: string) => void; + onSendCommand?: (message: string) => void; + activeView?: CronDashboardView; + onViewChange?: (view: CronDashboardView) => void; + calendarMode?: CalendarMode; + onCalendarModeChange?: (mode: CalendarMode) => void; + calendarDate?: string | null; + onCalendarDateChange?: (date: string | null) => void; }) { const [jobs, setJobs] = useState([]); const [heartbeat, setHeartbeat] = useState({ intervalMs: 30 * 60_000, nextDueEstimateMs: null }); const [cronStatus, setCronStatus] = useState({ enabled: false, nextWakeAtMs: null }); const [loading, setLoading] = useState(true); + const [allRuns, setAllRuns] = useState([]); const fetchData = useCallback(async () => { try { @@ -103,6 +126,17 @@ export function CronDashboard({ setJobs(data.jobs ?? []); setHeartbeat(data.heartbeat ?? { intervalMs: 30 * 60_000, nextDueEstimateMs: null }); setCronStatus(data.cronStatus ?? { enabled: false, nextWakeAtMs: null }); + + const jobIds = (data.jobs ?? []).map((j) => j.id); + const runPromises = jobIds.map(async (id) => { + try { + const r = await fetch(`/api/cron/jobs/${encodeURIComponent(id)}/runs?limit=50`); + const d: CronRunsResponse = await r.json(); + return d.entries ?? []; + } catch { return []; } + }); + const runArrays = await Promise.all(runPromises); + setAllRuns(runArrays.flat().toSorted((a, b) => b.ts - a.ts)); } catch { // ignore } finally { @@ -112,7 +146,7 @@ export function CronDashboard({ useEffect(() => { void fetchData(); - const id = setInterval(() => void fetchData(), 30_000); + const id = setInterval(() => void fetchData(), 15_000); return () => clearInterval(id); }, [fetchData]); @@ -135,153 +169,678 @@ export function CronDashboard({ return (
- {/* Header */} -

- Cron -

-

- Scheduled jobs and heartbeat status -

+ {/* Header + tabs */} +
+
+

+ Cron +

+

+ {enabledJobs.length} active job{enabledJobs.length !== 1 ? "s" : ""} + {disabledJobs.length > 0 && ` / ${disabledJobs.length} disabled`} +

+
+
- {/* Status cards */} + {/* Tab bar */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Tab content */} + {activeView === "overview" && ( + + )} + {activeView === "calendar" && ( + + )} + {activeView === "insights" && ( + + )} +
+ ); +} + +/* ─── Overview tab ─── */ + +function OverviewTab({ + jobs, + enabledJobs, + disabledJobs, + heartbeatCountdown, + heartbeat, + cronWakeCountdown, + onSelectJob, + onSendCommand, +}: { + jobs: CronJob[]; + enabledJobs: CronJob[]; + disabledJobs: CronJob[]; + heartbeatCountdown: string | null; + heartbeat: HeartbeatInfo; + cronWakeCountdown: string | null; + onSelectJob: (jobId: string) => void; + onSendCommand?: (message: string) => void; +}) { + return ( + <>
- {/* Heartbeat card */} } value={heartbeatCountdown ? `in ${heartbeatCountdown}` : "unknown"} subtitle={`Interval: ${formatCountdown(heartbeat.intervalMs)}`} - description="The heartbeat wakes the agent periodically. Cron jobs with wakeMode=next-heartbeat piggyback on this loop." /> - - {/* Cron scheduler card */} } value={cronWakeCountdown ? `next in ${cronWakeCountdown}` : jobs.length === 0 ? "no jobs" : "idle"} subtitle={`${enabledJobs.length} active / ${jobs.length} total jobs`} - description="The cron timer fires every ~60s, checking for due jobs. Isolated jobs run independently; main-session jobs wake the heartbeat." /> - - {/* Running card */} } value={`${jobs.filter((j) => j.state.runningAtMs).length}`} - subtitle={`${jobs.filter((j) => j.state.lastStatus === "error").length} errors`} - description="Jobs currently executing. Errors show consecutive failures." + subtitle={(() => { + const running = jobs.filter((j) => j.state.runningAtMs); + const errorCount = jobs.filter((j) => j.state.lastStatus === "error").length; + if (running.length > 0) return running.map((j) => j.name).join(", "); + return errorCount > 0 ? `${errorCount} with errors` : "All clear"; + })()} />
- {/* Timeline - upcoming runs in next 24h */} - {/* Jobs table */} -
-

- Jobs -

+ + + ); +} - {jobs.length === 0 ? ( -
-

- No cron jobs configured. Use denchclaw cron add to create one. -

+/* ─── Calendar tab ─── */ + +type DayEvent = { kind: "run"; run: CronRunLogEntry; job?: CronJob } | { kind: "scheduled"; job: CronJob }; + +function dayKey(d: Date) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function buildEventsByDay(allRuns: CronRunLogEntry[], jobs: CronJob[], jobMap: Map) { + const map = new Map(); + for (const run of allRuns) { + const k = dayKey(new Date(run.ts)); + const arr = map.get(k) ?? []; + arr.push({ kind: "run", run, job: jobMap.get(run.jobId) }); + map.set(k, arr); + } + for (const job of jobs) { + if (!job.state.nextRunAtMs) continue; + const k = dayKey(new Date(job.state.nextRunAtMs)); + const arr = map.get(k) ?? []; + arr.push({ kind: "scheduled", job }); + map.set(k, arr); + } + return map; +} + +function EventChip({ ev, onSelectJob }: { ev: DayEvent; onSelectJob: (id: string) => void }) { + if (ev.kind === "scheduled") { + return ( + + ); + } + const c = ev.run.status === "ok" ? "var(--color-success, #22c55e)" : ev.run.status === "error" ? "var(--color-error, #ef4444)" : "var(--color-text-muted)"; + return ( + + ); +} + +function CalendarTab({ + jobs, + allRuns, + mode = "month", + onModeChange, + dateAnchor, + onDateChange, + onSelectJob, +}: { + jobs: CronJob[]; + allRuns: CronRunLogEntry[]; + mode?: CalendarMode; + onModeChange?: (mode: CalendarMode) => void; + dateAnchor?: string | null; + onDateChange?: (date: string | null) => void; + onSelectJob: (jobId: string) => void; +}) { + const anchor = dateAnchor ? new Date(dateAnchor) : new Date(); + + const navigate = (delta: number) => { + const d = new Date(anchor); + if (mode === "month") d.setMonth(d.getMonth() + delta); + else if (mode === "week") d.setDate(d.getDate() + delta * 7); + else if (mode === "day") d.setDate(d.getDate() + delta); + else d.setFullYear(d.getFullYear() + delta); + onDateChange?.(d.toISOString().split("T")[0]); + }; + + const jobMap = useMemo(() => { + const m = new Map(); + for (const j of jobs) m.set(j.id, j); + return m; + }, [jobs]); + + const eventsByDay = useMemo(() => buildEventsByDay(allRuns, jobs, jobMap), [allRuns, jobs, jobMap]); + + const headerTitle = useMemo(() => { + if (mode === "day") return anchor.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); + if (mode === "week") { + const start = new Date(anchor); + start.setDate(start.getDate() - start.getDay()); + const end = new Date(start); + end.setDate(end.getDate() + 6); + const sameMonth = start.getMonth() === end.getMonth(); + if (sameMonth) return `${start.toLocaleDateString(undefined, { month: "long" })} ${start.getDate()}–${end.getDate()}, ${start.getFullYear()}`; + return `${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} – ${end.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}`; + } + if (mode === "year") return String(anchor.getFullYear()); + return anchor.toLocaleDateString(undefined, { month: "long", year: "numeric" }); + }, [anchor, mode]); + + const todayStr = dayKey(new Date()); + + return ( +
+ {/* Calendar header */} +
+
+ +

{headerTitle}

+ + +
+
+ {(["day", "week", "month", "year"] as CalendarMode[]).map((m) => ( + + ))} +
+
+ + {mode === "day" && } + {mode === "week" && } + {mode === "month" && } + {mode === "year" && } +
+ ); +} + +/* ─── Day view ─── */ + +function DayView({ anchor, eventsByDay, todayStr, onSelectJob }: { + anchor: Date; eventsByDay: Map; todayStr: string; onSelectJob: (id: string) => void; +}) { + const dk = dayKey(anchor); + const events = eventsByDay.get(dk) ?? []; + const isToday = dk === todayStr; + const hours = Array.from({ length: 24 }, (_, i) => i); + + const eventsByHour = useMemo(() => { + const map = new Map(); + for (const ev of events) { + const h = ev.kind === "run" ? new Date(ev.run.ts).getHours() : ev.job.state.nextRunAtMs ? new Date(ev.job.state.nextRunAtMs).getHours() : 0; + const arr = map.get(h) ?? []; + arr.push(ev); + map.set(h, arr); + } + return map; + }, [events]); + + return ( +
+ {hours.map((h) => { + const hourEvents = eventsByHour.get(h) ?? []; + const nowHour = new Date().getHours(); + const isCurrentHour = isToday && h === nowHour; + return ( +
+
+ {h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`} +
+
+ {hourEvents.map((ev, i) => )} +
- ) : ( -
- - - - - - - - - - - - - {[...enabledJobs, ...disabledJobs].map((job) => ( - onSelectJob(job.id)} /> - ))} - -
NameScheduleStatusNext RunLast RunTarget
-
- )} + ); + })} +
+ ); +} + +/* ─── Week view ─── */ + +function WeekView({ anchor, eventsByDay, todayStr, onSelectJob, onDateChange, onModeChange }: { + anchor: Date; eventsByDay: Map; todayStr: string; onSelectJob: (id: string) => void; + onDateChange?: (d: string | null) => void; onModeChange?: (m: CalendarMode) => void; +}) { + const weekStart = new Date(anchor); + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); + const days = Array.from({ length: 7 }, (_, i) => { + const d = new Date(weekStart); + d.setDate(d.getDate() + i); + return d; + }); + + return ( +
+
+ {days.map((d) => { + const dk = dayKey(d); + const isToday = dk === todayStr; + return ( + + ); + })} +
+
+ {days.map((d) => { + const dk = dayKey(d); + const events = eventsByDay.get(dk) ?? []; + const isToday = dk === todayStr; + return ( +
+
+ {events.slice(0, 8).map((ev, i) => )} + {events.length > 8 &&
+{events.length - 8} more
} +
+
+ ); + })}
); } -/* ─── Status card ─── */ +/* ─── Month view ─── */ -function StatusCard({ - title, - icon, - value, - subtitle, - description, -}: { - title: string; - icon: React.ReactNode; - value: string; - subtitle: string; - description: string; +function MonthView({ anchor, eventsByDay, todayStr, onSelectJob }: { + anchor: Date; eventsByDay: Map; todayStr: string; onSelectJob: (id: string) => void; }) { + const weeks = useMemo(() => { + const year = anchor.getFullYear(); + const month = anchor.getMonth(); + const firstOfMonth = new Date(year, month, 1); + const start = new Date(year, month, 1 - firstOfMonth.getDay()); + const weeksArr: Date[][] = []; + for (let w = 0; w < 6; w++) { + const week: Date[] = []; + for (let d = 0; d < 7; d++) { + const dt = new Date(start); + dt.setDate(dt.getDate() + w * 7 + d); + week.push(dt); + } + weeksArr.push(week); + } + return weeksArr; + }, [anchor]); + return ( -
+
+
+ {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((d) => ( +
{d}
+ ))} +
+ {weeks.map((week, wi) => ( +
+ {week.map((day) => { + const dk = dayKey(day); + const events = eventsByDay.get(dk) ?? []; + const isCurrentMonth = day.getMonth() === anchor.getMonth(); + const isToday = dk === todayStr; + return ( +
+
{day.getDate()}
+
+ {events.slice(0, 3).map((ev, i) => )} + {events.length > 3 &&
+{events.length - 3} more
} +
+
+ ); + })} +
+ ))} +
+ ); +} + +/* ─── Year view ─── */ + +function YearView({ anchor, eventsByDay, todayStr, onDateChange, onModeChange }: { + anchor: Date; eventsByDay: Map; todayStr: string; + onDateChange?: (d: string | null) => void; onModeChange?: (m: CalendarMode) => void; +}) { + const year = anchor.getFullYear(); + const months = Array.from({ length: 12 }, (_, i) => i); + + return ( +
+ {months.map((month) => { + const firstOfMonth = new Date(year, month, 1); + const startDow = firstOfMonth.getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const cells: (number | null)[] = []; + for (let i = 0; i < startDow; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + + return ( + + ); + })} +
+ ); +} + +/* ─── Insights tab ─── */ + +function InsightsTab({ jobs, allRuns, onSelectJob }: { + jobs: CronJob[]; + allRuns: CronRunLogEntry[]; + onSelectJob: (jobId: string) => void; +}) { + const stats = useMemo(() => { + const total = allRuns.length; + const ok = allRuns.filter((r) => r.status === "ok").length; + const errors = allRuns.filter((r) => r.status === "error").length; + const avgDuration = total > 0 + ? allRuns.filter((r) => r.durationMs != null).reduce((s, r) => s + (r.durationMs ?? 0), 0) / + Math.max(1, allRuns.filter((r) => r.durationMs != null).length) + : 0; + const successRate = total > 0 ? Math.round((ok / total) * 100) : 0; + + // Runs by day (last 14 days) + const now = Date.now(); + const dayMs = 86_400_000; + const runsByDay: { day: string; ok: number; error: number; other: number }[] = []; + for (let i = 13; i >= 0; i--) { + const dayStart = now - i * dayMs; + const dayEnd = dayStart + dayMs; + const dayRuns = allRuns.filter((r) => r.ts >= dayStart && r.ts < dayEnd); + const d = new Date(dayStart); + runsByDay.push({ + day: `${d.getMonth() + 1}/${d.getDate()}`, + ok: dayRuns.filter((r) => r.status === "ok").length, + error: dayRuns.filter((r) => r.status === "error").length, + other: dayRuns.filter((r) => r.status !== "ok" && r.status !== "error").length, + }); + } + + // Per-job stats + const jobStats = jobs.map((job) => { + const jobRuns = allRuns.filter((r) => r.jobId === job.id); + const jobOk = jobRuns.filter((r) => r.status === "ok").length; + const jobErrors = jobRuns.filter((r) => r.status === "error").length; + const jobAvgDur = jobRuns.filter((r) => r.durationMs != null).length > 0 + ? jobRuns.filter((r) => r.durationMs != null).reduce((s, r) => s + (r.durationMs ?? 0), 0) / + jobRuns.filter((r) => r.durationMs != null).length + : 0; + return { job, runs: jobRuns.length, ok: jobOk, errors: jobErrors, avgDuration: jobAvgDur }; + }).toSorted((a, b) => b.runs - a.runs); + + return { total, ok, errors, avgDuration, successRate, runsByDay, jobStats }; + }, [allRuns, jobs]); + + return ( +
+ {/* Summary cards */} +
+ + = 90 ? "var(--color-success, #22c55e)" : stats.successRate >= 70 ? "var(--color-warning, #f59e0b)" : "var(--color-error, #ef4444)"} /> + 0 ? "var(--color-error, #ef4444)" : undefined} /> + +
+ + {/* Runs chart (last 14 days) */} +
+

+ Runs (Last 14 Days) +

+
+ {stats.runsByDay.map((day) => { + const maxVal = Math.max(...stats.runsByDay.map((d) => d.ok + d.error + d.other), 1); + const total = day.ok + day.error + day.other; + const height = Math.max(2, (total / maxVal) * 100); + const okPct = total > 0 ? (day.ok / total) * 100 : 100; + return ( +
+
+
50 + ? "color-mix(in srgb, var(--color-success, #22c55e) 60%, var(--color-error, #ef4444))" + : "var(--color-error, #ef4444)", + opacity: total === 0 ? 0.3 : 0.7, + }} + title={`${day.day}: ${day.ok} ok, ${day.error} errors`} + /> +
+ {day.day} +
+ ); + })} +
+
+
+ + Success +
+
+ + Error +
+
+
+ + {/* Per-job breakdown */} +
+
+

+ Per-Job Breakdown +

+
+ {stats.jobStats.map((js) => { + const rate = js.runs > 0 ? Math.round((js.ok / js.runs) * 100) : 0; + return ( + + ); + })} +
+
+ ); +} + +/* ─── Shared subcomponents ─── */ + +function MetricCard({ label, value, accent }: { label: string; value: string; accent?: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function StatusCard({ title, icon, value, subtitle }: { title: string; icon: React.ReactNode; value: string; subtitle: string }) { + return ( +
{icon} - - {title} - -
-
- {value} -
-
- {subtitle} -
-
- {description} + {title}
+
{value}
+
{subtitle}
); } -/* ─── Timeline ─── */ - function TimelineSection({ jobs }: { jobs: CronJob[] }) { const [now, setNow] = useState(Date.now()); useEffect(() => { @@ -289,59 +848,35 @@ function TimelineSection({ jobs }: { jobs: CronJob[] }) { return () => clearInterval(id); }, []); - const horizon = 24 * 60 * 60 * 1000; // 24h const upcoming = jobs - .filter((j) => j.state.nextRunAtMs && j.state.nextRunAtMs > now && j.state.nextRunAtMs < now + horizon) + .filter((j) => j.state.nextRunAtMs && j.state.nextRunAtMs > now && j.state.nextRunAtMs < now + 86_400_000) .toSorted((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0)); - if (upcoming.length === 0) {return null;} + if (upcoming.length === 0) return null; return (
-

+

Upcoming (next 24h)

-
+
- {/* Timeline bar */} -
+
- {upcoming.map((job) => { - const timeUntil = (job.state.nextRunAtMs ?? 0) - now; - return ( -
-
-
-
-
- - {job.name} - - - in {formatCountdown(timeUntil)} - -
- - {new Date(job.state.nextRunAtMs!).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - + {upcoming.map((job) => ( +
+
+
- ); - })} +
+ {job.name} + in {formatCountdown((job.state.nextRunAtMs ?? 0) - now)} +
+ + {new Date(job.state.nextRunAtMs!).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + +
+ ))}
@@ -349,9 +884,40 @@ function TimelineSection({ jobs }: { jobs: CronJob[] }) { ); } -/* ─── Job row ─── */ +function JobsTable({ jobs, onSelectJob, onSendCommand }: { jobs: CronJob[]; onSelectJob: (jobId: string) => void; onSendCommand?: (msg: string) => void }) { + return ( +
+

Jobs

+ {jobs.length === 0 ? ( +
+

+ No cron jobs configured. +

+
+ ) : ( +
+ + + + + + + + + + + + + {jobs.map((job) => onSelectJob(job.id)} onSendCommand={onSendCommand} />)} + +
NameScheduleStatusNext RunLast Run
+
+ )} +
+ ); +} -function JobRow({ job, onClick }: { job: CronJob; onClick: () => void }) { +function JobRow({ job, onClick, onSendCommand }: { job: CronJob; onClick: () => void; onSendCommand?: (msg: string) => void }) { const status = jobStatusLabel(job); const statusColor = jobStatusColor(status); const [now, setNow] = useState(Date.now()); @@ -360,57 +926,60 @@ function JobRow({ job, onClick }: { job: CronJob; onClick: () => void }) { return () => clearInterval(id); }, []); - const nextRunStr = job.state.nextRunAtMs - ? job.state.nextRunAtMs > now - ? `in ${formatCountdown(job.state.nextRunAtMs - now)}` - : "overdue" - : "-"; - - const lastRunStr = job.state.lastRunAtMs - ? `${formatTimeAgo(job.state.lastRunAtMs)}${job.state.lastDurationMs ? ` (${formatDuration(job.state.lastDurationMs)})` : ""}` - : "-"; - return ( { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }} > -
{job.name}
- {job.description && ( -
- {job.description} -
- )} - - - {formatSchedule(job.schedule)} - - - - {status === "running" && ( - +
+
{job.name}
+ {job.state.consecutiveErrors != null && job.state.consecutiveErrors > 0 && ( + + {job.state.consecutiveErrors} err + )} +
+ {job.description &&
{job.description}
} + + {formatSchedule(job.schedule)} + + + {status === "running" && } {status} - {nextRunStr} + {job.state.nextRunAtMs ? (job.state.nextRunAtMs > now ? `in ${formatCountdown(job.state.nextRunAtMs - now)}` : "overdue") : "-"} - - {lastRunStr} + +
+ {job.state.lastStatus && } + + {job.state.lastRunAtMs ? `${formatTimeAgo(job.state.lastRunAtMs)}${job.state.lastDurationMs ? ` (${formatDuration(job.state.lastDurationMs)})` : ""}` : "-"} + +
- - {job.sessionTarget} + +
+ {!job.state.runningAtMs && job.enabled && ( + + )} + +
); diff --git a/apps/web/app/components/cron/cron-job-detail.tsx b/apps/web/app/components/cron/cron-job-detail.tsx index 7a227979d0c..2b235d82808 100644 --- a/apps/web/app/components/cron/cron-job-detail.tsx +++ b/apps/web/app/components/cron/cron-job-detail.tsx @@ -14,9 +14,9 @@ function formatSchedule(schedule: CronJob["schedule"]): string { return schedule.expr + (schedule.tz ? ` (${schedule.tz})` : ""); case "every": { const ms = schedule.everyMs; - if (ms >= 86_400_000) {return `every ${Math.round(ms / 86_400_000)} day(s)`;} - if (ms >= 3_600_000) {return `every ${Math.round(ms / 3_600_000)} hour(s)`;} - if (ms >= 60_000) {return `every ${Math.round(ms / 60_000)} minute(s)`;} + if (ms >= 86_400_000) return `every ${Math.round(ms / 86_400_000)} day(s)`; + if (ms >= 3_600_000) return `every ${Math.round(ms / 3_600_000)} hour(s)`; + if (ms >= 60_000) return `every ${Math.round(ms / 60_000)} minute(s)`; return `every ${Math.round(ms / 1000)} second(s)`; } case "at": @@ -27,25 +27,35 @@ function formatSchedule(schedule: CronJob["schedule"]): string { } function formatCountdown(ms: number): string { - if (ms <= 0) {return "now";} + if (ms <= 0) return "now"; const totalSec = Math.ceil(ms / 1000); - if (totalSec < 60) {return `${totalSec}s`;} + if (totalSec < 60) return `${totalSec}s`; const min = Math.floor(totalSec / 60); const sec = totalSec % 60; - if (min < 60) {return sec > 0 ? `${min}m ${sec}s` : `${min}m`;} + if (min < 60) return sec > 0 ? `${min}m ${sec}s` : `${min}m`; const hr = Math.floor(min / 60); const remMin = min % 60; return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`; } function formatDuration(ms: number): string { - if (ms < 1000) {return `${ms}ms`;} - if (ms < 60_000) {return `${(ms / 1000).toFixed(1)}s`;} + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`; } +function formatRelativeTime(ts: number): string { + const diff = Date.now() - ts; + if (diff < 0) return "just now"; + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}d ago`; + return new Date(ts).toLocaleDateString(); +} + function payloadSummary(payload: CronJob["payload"]): string { - if (payload.kind === "systemEvent") {return payload.text.slice(0, 120);} + if (payload.kind === "systemEvent") return payload.text.slice(0, 120); return payload.message.slice(0, 120); } @@ -54,13 +64,24 @@ function payloadSummary(payload: CronJob["payload"]): string { export function CronJobDetail({ job, onBack, + onSendCommand, + runFilter = "all", + onRunFilterChange, + expandedRunTs: expandedRunTsProp, + onExpandedRunChange, }: { job: CronJob; onBack: () => void; + onSendCommand?: (message: string) => void; + runFilter?: import("@/lib/workspace-links").CronRunStatusFilter; + onRunFilterChange?: (filter: import("@/lib/workspace-links").CronRunStatusFilter) => void; + expandedRunTs?: number | null; + onExpandedRunChange?: (ts: number | null) => void; }) { const [runs, setRuns] = useState([]); const [loadingRuns, setLoadingRuns] = useState(true); - const [expandedRunTs, setExpandedRunTs] = useState(null); + const expandedRunTs = expandedRunTsProp ?? null; + const setExpandedRunTs = onExpandedRunChange ?? (() => {}); const fetchRuns = useCallback(async () => { try { @@ -101,7 +122,7 @@ export function CronJobDetail({ Back to Cron - {/* Job header */} + {/* Job header + action bar */}

{job.description && ( -

+

{job.description}

)} +
{/* Config + countdown grid */}
- {/* Next run countdown */} - {/* Job config */}
{/* Error streak */} - {job.state.consecutiveErrors && job.state.consecutiveErrors > 0 && ( + {job.state.consecutiveErrors != null && job.state.consecutiveErrors > 0 && (
-

- Run History -

+
+

+ Run History +

+
+ {(["all", "ok", "error", "running"] as const).map((f) => ( + + ))} +
+
{loadingRuns ? (
@@ -202,7 +241,9 @@ export function CronJobDetail({
) : (
- {runs.toReversed().map((run) => ( + {runs.toReversed() + .filter((run) => runFilter === "all" || run.status === runFilter) + .map((run) => ( - {/* Timestamp */} - - {new Date(run.ts).toLocaleString()} + {/* Relative time + absolute on hover */} + + {formatRelativeTime(run.ts)} {/* Status badge */} + {formatDuration(run.durationMs)} )} @@ -337,10 +384,16 @@ function RunCard({ )} - {/* Has session indicator */} - {run.sessionId && ( - - chat + {/* Session badge */} + {hasSession && ( + + + + + session )} @@ -366,10 +419,22 @@ function RunCard({ className="px-4 pb-4" style={{ borderTop: "1px solid var(--color-border)" }} > + {/* Meta row */} +
+ + {new Date(run.ts).toLocaleString()} + + {run.durationMs != null && ( + + Duration: {formatDuration(run.durationMs)} + + )} +
+ {/* Error message */} {run.error && (
)} - {/* Session transcript */} + {/* Session transcript preview */} {run.sessionId ? ( -
- -
+ ) : ( -
- -
+ )}
)} @@ -428,6 +489,66 @@ function RunTranscriptOrSummary({ run }: { run: CronRunLogEntry }) { ); } +/* ─── Action bar ─── */ + +function JobActionBar({ job, onSendCommand }: { job: CronJob; onSendCommand?: (msg: string) => void }) { + return ( +
+ {!job.state.runningAtMs && job.enabled && ( + onSendCommand?.(`Run cron job "${job.name}" (${job.id}) now with --force`)} accent> + + + + Run now + + )} + {job.state.runningAtMs && ( + + + Running... + + )} + onSendCommand?.(`${job.enabled ? "Disable" : "Enable"} cron job "${job.name}" (${job.id})`)}> + {job.enabled ? "Disable" : "Enable"} + + onSendCommand?.(`Delete cron job "${job.name}" (${job.id})`)} danger> + Delete + +
+ ); +} + +function ActionButton({ children, onClick, disabled, accent, danger }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + accent?: boolean; + danger?: boolean; +}) { + const bg = accent + ? "var(--color-accent)" + : danger + ? "color-mix(in srgb, var(--color-error, #ef4444) 12%, transparent)" + : "var(--color-surface-hover)"; + const fg = accent + ? "white" + : danger + ? "var(--color-error, #ef4444)" + : "var(--color-text-muted)"; + + return ( + + ); +} + /* ─── Subcomponents ─── */ function StatusBadge({ status }: { status: string }) { diff --git a/apps/web/app/components/cron/cron-run-chat.tsx b/apps/web/app/components/cron/cron-run-chat.tsx index 9a50ae79d05..5050a9f5d13 100644 --- a/apps/web/app/components/cron/cron-run-chat.tsx +++ b/apps/web/app/components/cron/cron-run-chat.tsx @@ -1,25 +1,82 @@ "use client"; import { useEffect, useState, useCallback } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import type { SessionMessage, SessionMessagePart, CronRunSessionResponse } from "../../types/cron"; +import type { UIMessage } from "ai"; +import type { SessionMessage, CronRunSessionResponse } from "../../types/cron"; +import { ChatMessage } from "../chat-message"; + +/* ─── ChatLine → UIMessage conversion (same format as web chat) ─── */ + +type ChatLine = { + id: string; + role: "user" | "assistant"; + content: string; + parts?: Array>; + timestamp: string; +}; + +function chatLineToUIMessage(line: ChatLine): UIMessage { + return { + id: line.id, + role: line.role, + parts: (line.parts ?? [{ type: "text" as const, text: line.content }]) as UIMessage["parts"], + } as UIMessage; +} /* ─── Main component ─── */ export function CronRunChat({ sessionId }: { sessionId: string }) { - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const fetchSession = useCallback(async () => { try { - const res = await fetch(`/api/cron/runs/${encodeURIComponent(sessionId)}`); + // Use web-sessions API which has agent session fallback, + // returning messages in UIMessage-compatible format + const res = await fetch(`/api/web-sessions/${encodeURIComponent(sessionId)}`); if (!res.ok) { - setError(res.status === 404 ? "Session transcript not found" : "Failed to load session"); + // Fallback to cron-specific API for legacy compatibility + const cronRes = await fetch(`/api/cron/runs/${encodeURIComponent(sessionId)}`); + if (!cronRes.ok) { + setError(cronRes.status === 404 ? "Session transcript not found" : "Failed to load session"); + return; + } + const cronData: CronRunSessionResponse = await cronRes.json(); + // Convert legacy SessionMessage format to ChatLine format + const converted: ChatLine[] = (cronData.messages ?? []) + .filter((m: SessionMessage) => m.role !== "system") + .map((m: SessionMessage) => ({ + id: m.id, + role: m.role as "user" | "assistant", + content: m.parts + .filter((p) => p.type === "text") + .map((p) => (p as { text: string }).text) + .join("\n"), + parts: m.parts.map((p) => { + if (p.type === "text") return { type: "text", text: p.text }; + if (p.type === "thinking") return { type: "reasoning", text: p.thinking }; + if (p.type === "tool-call") { + const result = p.output != null + ? (() => { try { return JSON.parse(p.output); } catch { return { output: p.output }; } })() + : undefined; + const part: Record = { + type: "tool-invocation", + toolCallId: p.toolCallId, + toolName: p.toolName, + args: p.args ?? {}, + }; + if (result != null) part.result = result; + return part; + } + return { type: "text", text: "" }; + }), + timestamp: m.timestamp, + })); + setMessages(converted); return; } - const data: CronRunSessionResponse = await res.json(); + const data = await res.json() as { messages?: ChatLine[] }; setMessages(data.messages ?? []); } catch { setError("Failed to load session"); @@ -64,12 +121,12 @@ export function CronRunChat({ sessionId }: { sessionId: string }) { } return ( -
+
Session Transcript
- {messages.map((msg) => ( - + {messages.map((line) => ( + ))}
); @@ -88,7 +145,7 @@ export function CronRunTranscriptSearch({ summary?: string; fallback?: React.ReactNode; }) { - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); @@ -109,9 +166,50 @@ export function CronRunTranscriptSearch({ setNotFound(true); return; } - const data = await res.json() as { messages?: SessionMessage[] }; + const data = await res.json() as { sessionId?: string; messages?: SessionMessage[] }; + // If we got a sessionId back, try to load via web-sessions for better formatting + if (data.sessionId) { + const wsRes = await fetch(`/api/web-sessions/${encodeURIComponent(data.sessionId)}`); + if (wsRes.ok) { + const wsData = await wsRes.json() as { messages?: ChatLine[] }; + if (wsData.messages && wsData.messages.length > 0) { + setMessages(wsData.messages); + return; + } + } + } + // Fallback to SessionMessage conversion if (data.messages && data.messages.length > 0) { - setMessages(data.messages); + const converted: ChatLine[] = data.messages + .filter((m) => m.role !== "system") + .map((m) => ({ + id: m.id, + role: m.role as "user" | "assistant", + content: m.parts + .filter((p) => p.type === "text") + .map((p) => (p as { text: string }).text) + .join("\n"), + parts: m.parts.map((p) => { + if (p.type === "text") return { type: "text", text: p.text }; + if (p.type === "thinking") return { type: "reasoning", text: p.thinking }; + if (p.type === "tool-call") { + const result = p.output != null + ? (() => { try { return JSON.parse(p.output); } catch { return { output: p.output }; } })() + : undefined; + const part: Record = { + type: "tool-invocation", + toolCallId: p.toolCallId, + toolName: p.toolName, + args: p.args ?? {}, + }; + if (result != null) part.result = result; + return part; + } + return { type: "text", text: "" }; + }), + timestamp: m.timestamp, + })); + setMessages(converted); } else { setNotFound(true); } @@ -143,303 +241,13 @@ export function CronRunTranscriptSearch({ } return ( -
+
Session Transcript
- {messages.map((msg) => ( - + {messages.map((line) => ( + ))}
); } - -/* ─── Message rendering ─── */ - -function CronChatMessage({ message }: { message: SessionMessage }) { - const isUser = message.role === "user"; - const isSystem = message.role === "system"; - - // Group parts into segments - const segments = groupPartsIntoSegments(message.parts); - - if (isSystem) { - const textContent = message.parts - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("\n"); - return ( -
- system: {textContent.slice(0, 500)} -
- ); - } - - if (isUser) { - const textContent = message.parts - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("\n"); - return ( -
-
-

{textContent}

-
-
- ); - } - - // Assistant message - return ( -
- {segments.map((segment, idx) => { - if (segment.type === "text") { - return ( -
- - {segment.text} - -
- ); - } - - if (segment.type === "thinking") { - return ; - } - - if (segment.type === "tool-group") { - return ; - } - - return null; - })} -
- ); -} - -/* ─── Part grouping ─── */ - -type ChatSegment = - | { type: "text"; text: string } - | { type: "thinking"; thinking: string } - | { type: "tool-group"; tools: Array }; - -function groupPartsIntoSegments(parts: SessionMessagePart[]): ChatSegment[] { - const segments: ChatSegment[] = []; - let toolBuffer: Array = []; - - const flushTools = () => { - if (toolBuffer.length > 0) { - segments.push({ type: "tool-group", tools: [...toolBuffer] }); - toolBuffer = []; - } - }; - - for (const part of parts) { - if (part.type === "text") { - flushTools(); - segments.push({ type: "text", text: part.text }); - } else if (part.type === "thinking") { - flushTools(); - segments.push({ type: "thinking", thinking: part.thinking }); - } else if (part.type === "tool-call") { - toolBuffer.push(part as SessionMessagePart & { type: "tool-call" }); - } - } - flushTools(); - return segments; -} - -/* ─── Thinking block (always expanded for historical runs) ─── */ - -function ThinkingBlock({ text }: { text: string }) { - const [expanded, setExpanded] = useState(true); - const isLong = text.length > 600; - - return ( -
- - {expanded && ( -
- {text} -
- )} -
- ); -} - -/* ─── Tool group ─── */ - -function ToolGroup({ tools }: { tools: Array }) { - return ( -
- {/* Timeline connector */} -
-
- {tools.map((tool) => ( - - ))} -
-
- ); -} - -/* ─── Tool call step ─── */ - -function ToolCallStep({ tool }: { tool: SessionMessagePart & { type: "tool-call" } }) { - const [showOutput, setShowOutput] = useState(false); - const label = buildToolLabel(tool.toolName, tool.args); - - return ( -
-
- -
-
-
- {label} -
- {tool.output && ( -
- - {showOutput && ( -
-                {tool.output.length > 3000 ? tool.output.slice(0, 3000) + "\n..." : tool.output}
-              
- )} -
- )} -
-
- ); -} - -/* ─── Tool label builder ─── */ - -function buildToolLabel(toolName: string, args?: unknown): string { - const a = args as Record | undefined; - const strVal = (key: string) => { - const v = a?.[key]; - return typeof v === "string" && v.length > 0 ? v : null; - }; - - const n = toolName.toLowerCase().replace(/[_-]/g, ""); - - if (["websearch", "search", "googlesearch"].some((k) => n.includes(k))) { - const q = strVal("query") ?? strVal("search_query") ?? strVal("q"); - return q ? `Searching: ${q.slice(0, 80)}` : "Searching..."; - } - if (["fetchurl", "fetch", "webfetch"].some((k) => n.includes(k))) { - const u = strVal("url") ?? strVal("path"); - return u ? `Fetching: ${u.slice(0, 60)}` : "Fetching page"; - } - if (["read", "readfile", "getfile"].some((k) => n.includes(k))) { - const p = strVal("path") ?? strVal("file"); - return p ? `Reading: ${p.split("/").pop()}` : "Reading file"; - } - if (["bash", "shell", "execute", "exec", "terminal"].some((k) => n.includes(k))) { - const cmd = strVal("command") ?? strVal("cmd"); - return cmd ? `Running: ${cmd.slice(0, 60)}` : "Running command"; - } - if (["write", "create", "edit", "str_replace", "save"].some((k) => n.includes(k))) { - const p = strVal("path") ?? strVal("file"); - return p ? `Editing: ${p.split("/").pop()}` : "Editing file"; - } - - return toolName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); -} - -/* ─── Tool icon ─── */ - -function ToolIcon({ toolName }: { toolName: string }) { - const color = "var(--color-text-muted)"; - const n = toolName.toLowerCase().replace(/[_-]/g, ""); - - if (["search", "websearch"].some((k) => n.includes(k))) { - return ( - - - - ); - } - if (["bash", "shell", "exec", "terminal"].some((k) => n.includes(k))) { - return ( - - - - ); - } - if (["write", "edit", "create", "save"].some((k) => n.includes(k))) { - return ( - - - - - ); - } - // Default: file/read icon - return ( - - - - - ); -} diff --git a/apps/web/app/components/cron/cron-session-view.tsx b/apps/web/app/components/cron/cron-session-view.tsx new file mode 100644 index 00000000000..8c42935997f --- /dev/null +++ b/apps/web/app/components/cron/cron-session-view.tsx @@ -0,0 +1,98 @@ +"use client"; + +import type { CronJob, CronRunLogEntry } from "../../types/cron"; +import { ChatPanel } from "../chat-panel"; + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`; +} + +export function CronSessionView({ + job, + run, + sessionId, + onBack, + onBackToJob, +}: { + job: CronJob; + run: CronRunLogEntry; + sessionId: string; + onBack: () => void; + onBackToJob: () => void; +}) { + const statusColor = run.status === "ok" + ? "var(--color-success, #22c55e)" + : run.status === "error" + ? "var(--color-error, #ef4444)" + : "var(--color-warning, #f59e0b)"; + + return ( +
+ {/* Header bar */} +
+ + +
+
+ + + {run.status ?? "unknown"} + +
+
+ {new Date(run.ts).toLocaleString()} + {run.durationMs != null && ( + {formatDuration(run.durationMs)} + )} + {run.summary && ( + {run.summary} + )} +
+
+
+ + {/* ChatPanel loads the session via initialSessionId. + The web-sessions API falls back to agent sessions, so this + transparently loads cron run transcripts. */} +
+ +
+
+ ); +} diff --git a/apps/web/app/workspace/workspace-content.tsx b/apps/web/app/workspace/workspace-content.tsx index b4115c24ee0..7cb0bd8565e 100644 --- a/apps/web/app/workspace/workspace-content.tsx +++ b/apps/web/app/workspace/workspace-content.tsx @@ -32,6 +32,7 @@ import { parseWorkspaceLink, isWorkspaceLink, parseUrlState, buildUrl, type Work import { isCodeFile } from "@/lib/report-utils"; import { CronDashboard } from "../components/cron/cron-dashboard"; import { CronJobDetail } from "../components/cron/cron-job-detail"; +import { CronSessionView } from "../components/cron/cron-session-view"; import type { CronJob, CronJobsResponse } from "../types/cron"; import { useIsMobile } from "../hooks/use-mobile"; import { ObjectFilterBar } from "../components/workspace/object-filter-bar"; @@ -120,6 +121,7 @@ type ContentState = | { kind: "directory"; node: TreeNode } | { kind: "cron-dashboard" } | { kind: "cron-job"; jobId: string; job: CronJob } + | { kind: "cron-session"; jobId: string; job: CronJob; sessionId: string; run: import("../types/cron").CronRunLogEntry } | { kind: "duckdb-missing" } | { kind: "richDocument"; html: string; filePath: string; mode: "docx" | "txt" }; @@ -447,6 +449,13 @@ function WorkspacePageInner() { // Cron jobs state const [cronJobs, setCronJobs] = useState([]); + // Cron URL-backed view state + const [cronView, setCronView] = useState("overview"); + const [cronCalMode, setCronCalMode] = useState("month"); + const [cronDate, setCronDate] = useState(null); + const [cronRunFilter, setCronRunFilter] = useState("all"); + const [cronRun, setCronRun] = useState(null); + // Entry detail modal state const [entryModal, setEntryModal] = useState<{ objectName: string; @@ -1162,6 +1171,16 @@ function WorkspacePageInner() { const entry = current.get("entry"); if (entry) params.set("entry", entry); if (fileChatSessionId) params.set("fileChat", fileChatSessionId); + + // Cron-specific URL params (only when on a cron path) + if (activePath === "~cron") { + if (cronView !== "overview") params.set("cronView", cronView); + if (cronView === "calendar" && cronCalMode !== "month") params.set("cronCalMode", cronCalMode); + if ((cronView === "calendar" || cronView === "timeline") && cronDate) params.set("cronDate", cronDate); + } else if (activePath.startsWith("~cron/")) { + if (cronRunFilter !== "all") params.set("cronRunFilter", cronRunFilter); + if (cronRun != null) params.set("cronRun", String(cronRun)); + } } else if (activeSessionId) { params.set("chat", activeSessionId); if (activeSubagentKey) params.set("subagent", activeSubagentKey); @@ -1179,7 +1198,7 @@ function WorkspacePageInner() { router.push(url, { scroll: false }); } // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excludes searchParams to avoid infinite loop - }, [activePath, activeSessionId, activeSubagentKey, fileChatSessionId, browseDir, showHidden, chatSidebarPreview, router]); + }, [activePath, activeSessionId, activeSubagentKey, fileChatSessionId, browseDir, showHidden, chatSidebarPreview, router, cronView, cronCalMode, cronDate, cronRunFilter, cronRun]); // Open entry modal handler const handleOpenEntry = useCallback( @@ -1217,11 +1236,14 @@ function WorkspacePageInner() { } else if (urlState.path === "~cron") { setActivePath("~cron"); setContent({ kind: "cron-dashboard" }); + if (urlState.cronView) setCronView(urlState.cronView); + if (urlState.cronCalMode) setCronCalMode(urlState.cronCalMode); + if (urlState.cronDate) setCronDate(urlState.cronDate); } else if (urlState.path.startsWith("~cron/")) { - // Set the path immediately; the job detail will be resolved - // by a separate effect once cronJobs have loaded. setActivePath(urlState.path); setContent({ kind: "cron-dashboard" }); + if (urlState.cronRunFilter) setCronRunFilter(urlState.cronRunFilter); + if (urlState.cronRun != null) setCronRun(urlState.cronRun); } else if (isAbsolutePath(urlState.path) || isHomeRelativePath(urlState.path)) { const name = urlState.path.split("/").pop() || urlState.path; const syntheticNode: TreeNode = { name, path: urlState.path, type: "file" }; @@ -1439,9 +1461,19 @@ function WorkspacePageInner() { const handleBackToCronDashboard = useCallback(() => { setActivePath("~cron"); setContent({ kind: "cron-dashboard" }); + setCronRunFilter("all"); + setCronRun(null); router.replace("/", { scroll: false }); }, [router]); + const handleCronSendCommand = useCallback((message: string) => { + setActivePath(null); + setContent({ kind: "none" }); + requestAnimationFrame(() => { + void chatRef.current?.sendNewMessage(message); + }); + }, []); + // Derive the active session's title for the header / right sidebar const activeSessionTitle = useMemo(() => { if (!activeSessionId) {return undefined;} @@ -1797,6 +1829,17 @@ function WorkspacePageInner() { searchFn={searchIndex} onSelectCronJob={handleSelectCronJob} onBackToCronDashboard={handleBackToCronDashboard} + cronView={cronView} + onCronViewChange={setCronView} + cronCalMode={cronCalMode} + onCronCalModeChange={setCronCalMode} + cronDate={cronDate} + onCronDateChange={setCronDate} + cronRunFilter={cronRunFilter} + onCronRunFilterChange={setCronRunFilter} + cronRun={cronRun} + onCronRunChange={setCronRun} + onSendCommand={handleCronSendCommand} />
@@ -2201,15 +2244,24 @@ function ContentRenderer({ searchFn, onSelectCronJob, onBackToCronDashboard, + cronView, + onCronViewChange, + cronCalMode, + onCronCalModeChange, + cronDate, + onCronDateChange, + cronRunFilter, + onCronRunFilterChange, + cronRun, + onCronRunChange, + onSendCommand, }: { content: ContentState; workspaceExists: boolean; expectedPath?: string | null; tree: TreeNode[]; activePath: string | null; - /** Current browse directory (absolute path), or null in workspace mode. */ browseDir?: string | null; - /** Whether the tree is currently being fetched. */ treeLoading?: boolean; members?: Array<{ id: string; name: string; email: string; role: string }>; onNodeSelect: (node: TreeNode) => void; @@ -2221,6 +2273,17 @@ function ContentRenderer({ searchFn: (query: string, limit?: number) => import("@/lib/search-index").SearchIndexItem[]; onSelectCronJob: (jobId: string) => void; onBackToCronDashboard: () => void; + cronView: import("@/lib/workspace-links").CronDashboardView; + onCronViewChange: (view: import("@/lib/workspace-links").CronDashboardView) => void; + cronCalMode: import("@/lib/object-filters").CalendarMode; + onCronCalModeChange: (mode: import("@/lib/object-filters").CalendarMode) => void; + cronDate: string | null; + onCronDateChange: (date: string | null) => void; + cronRunFilter: import("@/lib/workspace-links").CronRunStatusFilter; + onCronRunFilterChange: (filter: import("@/lib/workspace-links").CronRunStatusFilter) => void; + cronRun: number | null; + onCronRunChange: (run: number | null) => void; + onSendCommand: (message: string) => void; }) { switch (content.kind) { case "loading": @@ -2342,6 +2405,13 @@ function ContentRenderer({ return ( ); @@ -2350,6 +2420,22 @@ function ContentRenderer({ + ); + + case "cron-session": + return ( + onBackToCronDashboard()} + onBackToJob={() => onSelectCronJob(content.jobId)} /> );