From c21bbb6ceabe678495be305baaf7bf2865feb4c1 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Thu, 5 Mar 2026 21:20:18 -0800 Subject: [PATCH] feat(cron): overhaul dashboard with calendar/timeline/insights views and action bar Add tabbed views (overview, calendar, timeline, insights) to the cron dashboard, run status filters and action buttons (run now, disable, delete) to job detail, reuse ChatMessage for session transcripts, and wire URL state through workspace content. --- .../app/components/cron/cron-dashboard.tsx | 981 ++++++++++++++---- .../app/components/cron/cron-job-detail.tsx | 199 +++- .../web/app/components/cron/cron-run-chat.tsx | 420 ++------ .../app/components/cron/cron-session-view.tsx | 98 ++ apps/web/app/workspace/workspace-content.tsx | 96 +- 5 files changed, 1238 insertions(+), 556 deletions(-) create mode 100644 apps/web/app/components/cron/cron-session-view.tsx 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)} /> );