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:
parent
7cb654ea6a
commit
c21bbb6cea
File diff suppressed because it is too large
Load Diff
@ -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 }) {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
98
apps/web/app/components/cron/cron-session-view.tsx
Normal file
98
apps/web/app/components/cron/cron-session-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user