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.
This commit is contained in:
kumarabhirup 2026-03-05 21:20:18 -08:00
parent 7cb654ea6a
commit c21bbb6cea
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
5 changed files with 1238 additions and 556 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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<CronRunLogEntry[]>([]);
const [loadingRuns, setLoadingRuns] = useState(true);
const [expandedRunTs, setExpandedRunTs] = useState<number | null>(null);
const expandedRunTs = expandedRunTsProp ?? null;
const setExpandedRunTs = onExpandedRunChange ?? (() => {});
const fetchRuns = useCallback(async () => {
try {
@ -101,7 +122,7 @@ export function CronJobDetail({
Back to Cron
</button>
{/* Job header */}
{/* Job header + action bar */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-1">
<h1
@ -113,18 +134,17 @@ export function CronJobDetail({
<StatusBadge status={status} />
</div>
{job.description && (
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
<p className="text-sm mb-3" style={{ color: "var(--color-text-muted)" }}>
{job.description}
</p>
)}
<JobActionBar job={job} onSendCommand={onSendCommand} />
</div>
{/* Config + countdown grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
{/* Next run countdown */}
<NextRunCard job={job} />
{/* Job config */}
<div
className="rounded-2xl p-4"
style={{
@ -148,7 +168,7 @@ export function CronJobDetail({
</div>
{/* Error streak */}
{job.state.consecutiveErrors && job.state.consecutiveErrors > 0 && (
{job.state.consecutiveErrors != null && job.state.consecutiveErrors > 0 && (
<div
className="rounded-2xl p-4 mb-6"
style={{
@ -174,12 +194,31 @@ export function CronJobDetail({
{/* Run history */}
<div>
<h2
className="text-sm font-medium uppercase tracking-wider mb-3"
style={{ color: "var(--color-text-muted)" }}
>
Run History
</h2>
<div className="flex items-center justify-between mb-3">
<h2
className="text-sm font-medium uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
>
Run History
</h2>
<div className="flex items-center gap-1 rounded-lg p-0.5" style={{ background: "var(--color-surface-hover)" }}>
{(["all", "ok", "error", "running"] as const).map((f) => (
<button
key={f}
type="button"
onClick={() => onRunFilterChange?.(f)}
className="px-2.5 py-1 rounded-md text-[11px] font-medium cursor-pointer"
style={{
background: runFilter === f ? "var(--color-surface)" : "transparent",
color: runFilter === f ? "var(--color-text)" : "var(--color-text-muted)",
boxShadow: runFilter === f ? "var(--shadow-sm)" : "none",
}}
>
{f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
</div>
{loadingRuns ? (
<div className="flex items-center justify-center p-8">
@ -202,7 +241,9 @@ export function CronJobDetail({
</div>
) : (
<div className="space-y-2">
{runs.toReversed().map((run) => (
{runs.toReversed()
.filter((run) => runFilter === "all" || run.status === runFilter)
.map((run) => (
<RunCard
key={`${run.ts}-${run.jobId}`}
run={run}
@ -285,6 +326,8 @@ function RunCard({
? "var(--color-error, #ef4444)"
: "var(--color-warning, #f59e0b)";
const hasSession = !!run.sessionId;
return (
<div
className="rounded-xl overflow-hidden"
@ -307,14 +350,18 @@ function RunCard({
style={{ background: statusColor }}
/>
{/* Timestamp */}
<span className="text-sm" style={{ color: "var(--color-text)" }}>
{new Date(run.ts).toLocaleString()}
{/* Relative time + absolute on hover */}
<span
className="text-sm flex-shrink-0"
style={{ color: "var(--color-text)" }}
title={new Date(run.ts).toLocaleString()}
>
{formatRelativeTime(run.ts)}
</span>
{/* Status badge */}
<span
className="text-xs px-2 py-0.5 rounded-full"
className="text-xs px-2 py-0.5 rounded-full flex-shrink-0"
style={{
background: `color-mix(in srgb, ${statusColor} 12%, transparent)`,
color: statusColor,
@ -325,7 +372,7 @@ function RunCard({
{/* Duration */}
{run.durationMs != null && (
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
<span className="text-xs flex-shrink-0" style={{ color: "var(--color-text-muted)" }}>
{formatDuration(run.durationMs)}
</span>
)}
@ -337,10 +384,16 @@ function RunCard({
</span>
)}
{/* Has session indicator */}
{run.sessionId && (
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: "var(--color-surface-hover)", color: "var(--color-text-muted)" }}>
chat
{/* Session badge */}
{hasSession && (
<span
className="text-[10px] px-1.5 py-0.5 rounded flex-shrink-0 flex items-center gap-1"
style={{ background: "var(--color-surface-hover)", color: "var(--color-text-muted)" }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
session
</span>
)}
@ -366,10 +419,22 @@ function RunCard({
className="px-4 pb-4"
style={{ borderTop: "1px solid var(--color-border)" }}
>
{/* Meta row */}
<div className="flex items-center gap-4 mt-3 mb-3 flex-wrap">
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{new Date(run.ts).toLocaleString()}
</span>
{run.durationMs != null && (
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
Duration: {formatDuration(run.durationMs)}
</span>
)}
</div>
{/* Error message */}
{run.error && (
<div
className="mt-3 text-xs font-mono rounded-lg px-3 py-2"
className="text-xs font-mono rounded-lg px-3 py-2 mb-3"
style={{
color: "var(--color-error, #ef4444)",
background: "color-mix(in srgb, var(--color-error, #ef4444) 6%, var(--color-surface))",
@ -379,15 +444,11 @@ function RunCard({
</div>
)}
{/* Session transcript */}
{/* Session transcript preview */}
{run.sessionId ? (
<div className="mt-4">
<CronRunChat sessionId={run.sessionId} />
</div>
<CronRunChat sessionId={run.sessionId} />
) : (
<div className="mt-4">
<RunTranscriptOrSummary run={run} />
</div>
<RunTranscriptOrSummary run={run} />
)}
</div>
)}
@ -428,6 +489,66 @@ function RunTranscriptOrSummary({ run }: { run: CronRunLogEntry }) {
);
}
/* ─── Action bar ─── */
function JobActionBar({ job, onSendCommand }: { job: CronJob; onSendCommand?: (msg: string) => void }) {
return (
<div className="flex items-center gap-2 flex-wrap">
{!job.state.runningAtMs && job.enabled && (
<ActionButton onClick={() => onSendCommand?.(`Run cron job "${job.name}" (${job.id}) now with --force`)} accent>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="6 3 20 12 6 21 6 3" />
</svg>
Run now
</ActionButton>
)}
{job.state.runningAtMs && (
<ActionButton disabled accent>
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "currentColor" }} />
Running...
</ActionButton>
)}
<ActionButton onClick={() => onSendCommand?.(`${job.enabled ? "Disable" : "Enable"} cron job "${job.name}" (${job.id})`)}>
{job.enabled ? "Disable" : "Enable"}
</ActionButton>
<ActionButton onClick={() => onSendCommand?.(`Delete cron job "${job.name}" (${job.id})`)} danger>
Delete
</ActionButton>
</div>
);
}
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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="text-xs font-medium px-3 py-1.5 rounded-lg flex items-center gap-1.5 cursor-pointer transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
style={{ background: bg, color: fg }}
>
{children}
</button>
);
}
/* ─── Subcomponents ─── */
function StatusBadge({ status }: { status: string }) {

View File

@ -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<Record<string, unknown>>;
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<SessionMessage[]>([]);
const [messages, setMessages] = useState<ChatLine[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string, unknown> = {
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 (
<div className="space-y-3">
<div className="space-y-1">
<div className="text-[11px] uppercase tracking-wider font-medium mb-2" style={{ color: "var(--color-text-muted)" }}>
Session Transcript
</div>
{messages.map((msg) => (
<CronChatMessage key={msg.id} message={msg} />
{messages.map((line) => (
<ChatMessage key={line.id} message={chatLineToUIMessage(line)} />
))}
</div>
);
@ -88,7 +145,7 @@ export function CronRunTranscriptSearch({
summary?: string;
fallback?: React.ReactNode;
}) {
const [messages, setMessages] = useState<SessionMessage[]>([]);
const [messages, setMessages] = useState<ChatLine[]>([]);
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<string, unknown> = {
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 (
<div className="space-y-3">
<div className="space-y-1">
<div className="text-[11px] uppercase tracking-wider font-medium mb-2" style={{ color: "var(--color-text-muted)" }}>
Session Transcript
</div>
{messages.map((msg) => (
<CronChatMessage key={msg.id} message={msg} />
{messages.map((line) => (
<ChatMessage key={line.id} message={chatLineToUIMessage(line)} />
))}
</div>
);
}
/* ─── 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 (
<div
className="text-xs rounded-lg px-3 py-2 font-mono"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text-muted)",
border: "1px dashed var(--color-border)",
}}
>
<span className="font-medium">system:</span> {textContent.slice(0, 500)}
</div>
);
}
if (isUser) {
const textContent = message.parts
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n");
return (
<div className="flex justify-end py-1">
<div
className="max-w-[80%] rounded-2xl rounded-br-sm px-4 py-2.5 text-sm"
style={{
background: "var(--color-user-bubble)",
color: "var(--color-user-bubble-text)",
}}
>
<p className="whitespace-pre-wrap">{textContent}</p>
</div>
</div>
);
}
// Assistant message
return (
<div className="py-2 space-y-2">
{segments.map((segment, idx) => {
if (segment.type === "text") {
return (
<div
key={idx}
className="chat-prose text-sm"
style={{ color: "var(--color-text)" }}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{segment.text}
</ReactMarkdown>
</div>
);
}
if (segment.type === "thinking") {
return <ThinkingBlock key={idx} text={segment.thinking} />;
}
if (segment.type === "tool-group") {
return <ToolGroup key={idx} tools={segment.tools} />;
}
return null;
})}
</div>
);
}
/* ─── Part grouping ─── */
type ChatSegment =
| { type: "text"; text: string }
| { type: "thinking"; thinking: string }
| { type: "tool-group"; tools: Array<SessionMessagePart & { type: "tool-call" }> };
function groupPartsIntoSegments(parts: SessionMessagePart[]): ChatSegment[] {
const segments: ChatSegment[] = [];
let toolBuffer: Array<SessionMessagePart & { type: "tool-call" }> = [];
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 (
<div className="my-2">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-2 py-1 text-[13px] cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
>
<svg
width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className="opacity-60"
>
<path d="M12 2a7 7 0 0 0-7 7c0 2.38 1.19 4.47 3 5.74V17a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-2.26c1.81-1.27 3-3.36 3-5.74a7 7 0 0 0-7-7z" />
<path d="M10 21h4" />
</svg>
<span className="font-medium">Thinking</span>
<svg
width="12" height="12" viewBox="0 0 12 12" fill="none"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
className={`transition-transform duration-200 ${expanded ? "" : "-rotate-90"}`}
>
<path d="M3 4.5L6 7.5L9 4.5" />
</svg>
</button>
{expanded && (
<div
className={`text-[13px] whitespace-pre-wrap leading-relaxed pl-6 ${isLong && !expanded ? "max-h-24 overflow-hidden" : ""}`}
style={{ color: "var(--color-text-secondary)" }}
>
{text}
</div>
)}
</div>
);
}
/* ─── Tool group ─── */
function ToolGroup({ tools }: { tools: Array<SessionMessagePart & { type: "tool-call" }> }) {
return (
<div className="my-2 relative">
{/* Timeline connector */}
<div
className="absolute w-px"
style={{ left: 9, top: 8, bottom: 8, background: "var(--color-border)" }}
/>
<div className="space-y-1">
{tools.map((tool) => (
<ToolCallStep key={tool.toolCallId} tool={tool} />
))}
</div>
</div>
);
}
/* ─── Tool call step ─── */
function ToolCallStep({ tool }: { tool: SessionMessagePart & { type: "tool-call" } }) {
const [showOutput, setShowOutput] = useState(false);
const label = buildToolLabel(tool.toolName, tool.args);
return (
<div className="flex items-start gap-2.5 py-1">
<div
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
style={{ background: "var(--color-bg)" }}
>
<ToolIcon toolName={tool.toolName} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] leading-snug" style={{ color: "var(--color-text-secondary)" }}>
{label}
</div>
{tool.output && (
<div className="mt-0.5">
<button
type="button"
onClick={() => setShowOutput((v) => !v)}
className="text-[11px] hover:underline cursor-pointer"
style={{ color: "var(--color-accent)" }}
>
{showOutput ? "Hide output" : "Show output"}
</button>
{showOutput && (
<pre
className="mt-1 text-[11px] font-mono rounded-lg px-2.5 py-2 overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto leading-relaxed"
style={{ color: "var(--color-text-muted)", background: "var(--color-bg)" }}
>
{tool.output.length > 3000 ? tool.output.slice(0, 3000) + "\n..." : tool.output}
</pre>
)}
</div>
)}
</div>
</div>
);
}
/* ─── Tool label builder ─── */
function buildToolLabel(toolName: string, args?: unknown): string {
const a = args as Record<string, unknown> | 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 (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
);
}
if (["bash", "shell", "exec", "terminal"].some((k) => n.includes(k))) {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" />
</svg>
);
}
if (["write", "edit", "create", "save"].some((k) => n.includes(k))) {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
</svg>
);
}
// Default: file/read icon
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
);
}

View File

@ -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 (
<div className="h-full flex flex-col">
{/* Header bar */}
<div
className="px-4 py-3 flex items-center gap-3 flex-shrink-0"
style={{
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-glass)",
}}
>
<button
type="button"
onClick={onBack}
className="p-1.5 rounded-lg flex-shrink-0 cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
title="Back to Cron"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={onBackToJob}
className="text-sm font-semibold truncate cursor-pointer hover:underline"
style={{ color: "var(--color-text)" }}
title="Back to job detail"
>
{job.name}
</button>
<span
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
style={{
background: `color-mix(in srgb, ${statusColor} 12%, transparent)`,
color: statusColor,
}}
>
{run.status ?? "unknown"}
</span>
</div>
<div className="flex items-center gap-3 text-xs" style={{ color: "var(--color-text-muted)" }}>
<span>{new Date(run.ts).toLocaleString()}</span>
{run.durationMs != null && (
<span>{formatDuration(run.durationMs)}</span>
)}
{run.summary && (
<span className="truncate">{run.summary}</span>
)}
</div>
</div>
</div>
{/* ChatPanel loads the session via initialSessionId.
The web-sessions API falls back to agent sessions, so this
transparently loads cron run transcripts. */}
<div className="flex-1 min-h-0">
<ChatPanel
initialSessionId={sessionId}
sessionTitle={`${job.name} - Run ${new Date(run.ts).toLocaleString()}`}
/>
</div>
</div>
);
}

View File

@ -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<CronJob[]>([]);
// Cron URL-backed view state
const [cronView, setCronView] = useState<import("@/lib/workspace-links").CronDashboardView>("overview");
const [cronCalMode, setCronCalMode] = useState<import("@/lib/object-filters").CalendarMode>("month");
const [cronDate, setCronDate] = useState<string | null>(null);
const [cronRunFilter, setCronRunFilter] = useState<import("@/lib/workspace-links").CronRunStatusFilter>("all");
const [cronRun, setCronRun] = useState<number | null>(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}
/>
</div>
@ -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 (
<CronDashboard
onSelectJob={onSelectCronJob}
onSendCommand={onSendCommand}
activeView={cronView}
onViewChange={onCronViewChange}
calendarMode={cronCalMode}
onCalendarModeChange={onCronCalModeChange}
calendarDate={cronDate}
onCalendarDateChange={onCronDateChange}
/>
);
@ -2350,6 +2420,22 @@ function ContentRenderer({
<CronJobDetail
job={content.job}
onBack={onBackToCronDashboard}
onSendCommand={onSendCommand}
runFilter={cronRunFilter}
onRunFilterChange={onCronRunFilterChange}
expandedRunTs={cronRun}
onExpandedRunChange={onCronRunChange}
/>
);
case "cron-session":
return (
<CronSessionView
job={content.job}
run={content.run}
sessionId={content.sessionId}
onBack={() => onBackToCronDashboard()}
onBackToJob={() => onSelectCronJob(content.jobId)}
/>
);