"use client"; import { useEffect, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { ArrowLeft, Loader2, CircleCheck, CircleAlert, Clock, UserPlus, PlayCircle, PauseCircle, ChevronDown, ChevronRight, } from "lucide-react"; import { toast } from "sonner"; import { format, formatDistanceToNow } from "date-fns"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; /** * Run history viewer. * * Lists the 50 most recent runs for a flow, newest first. Each row * collapses to a one-liner (contact + status + time); expanding shows * the full `flow_run_events` timeline for that run — useful for * debugging "why didn't my flow advance?" by surfacing the engine's * own log. */ interface RunRow { id: string; status: | "active" | "completed" | "handed_off" | "timed_out" | "paused_by_agent" | "failed"; current_node_key: string | null; started_at: string; last_advanced_at: string; ended_at: string | null; end_reason: string | null; vars: Record; reprompt_count: number; contact: { id: string; name: string | null; phone: string } | null; } interface EventRow { flow_run_id: string; event_type: string; node_key: string | null; payload: Record; created_at: string; } const STATUS_META: Record< RunRow["status"], { label: string; classes: string; icon: typeof Clock } > = { active: { label: "Active", classes: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300", icon: PlayCircle, }, completed: { label: "Completed", classes: "border-border bg-muted text-muted-foreground", icon: CircleCheck, }, handed_off: { label: "Handed off", classes: "border-amber-600/40 bg-amber-500/10 text-amber-300", icon: UserPlus, }, timed_out: { label: "Timed out", classes: "border-border bg-muted/60 text-muted-foreground", icon: Clock, }, paused_by_agent: { label: "Paused by agent", classes: "border-border bg-muted text-muted-foreground", icon: PauseCircle, }, failed: { label: "Failed", classes: "border-red-600/40 bg-red-500/10 text-red-300", icon: CircleAlert, }, }; export default function FlowRunsPage() { const router = useRouter(); const params = useParams<{ id: string }>(); const [flow, setFlow] = useState<{ id: string; name: string } | null>(null); const [runs, setRuns] = useState([]); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [expanded, setExpanded] = useState>(new Set()); const [notFound, setNotFound] = useState(false); useEffect(() => { if (!params.id) return; let cancelled = false; (async () => { try { const res = await fetch(`/api/flows/${params.id}/runs`); if (res.status === 404) { if (!cancelled) setNotFound(true); return; } if (!res.ok) throw new Error(`Failed: ${res.status}`); const json = (await res.json()) as { flow: { id: string; name: string }; runs: RunRow[]; events: EventRow[]; }; if (!cancelled) { setFlow(json.flow); setRuns(json.runs ?? []); setEvents(json.events ?? []); } } catch (err) { if (!cancelled) { console.error(err); toast.error("Couldn't load runs."); } } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [params.id]); function toggle(runId: string) { setExpanded((prev) => { const next = new Set(prev); if (next.has(runId)) next.delete(runId); else next.add(runId); return next; }); } if (loading) { return (
); } if (notFound || !flow) { return (

Flow not found.

); } return (

Runs

The 50 most recent times this flow ran. Expand a row to see the engine's per-step log.

{runs.length === 0 ? (
No runs yet. Trigger the flow from a personal WhatsApp number to see it appear here.
) : (
{runs.map((run) => ( e.flow_run_id === run.id)} expanded={expanded.has(run.id)} onToggle={() => toggle(run.id)} /> ))}
)}
); } function RunCard({ run, events, expanded, onToggle, }: { run: RunRow; events: EventRow[]; expanded: boolean; onToggle: () => void; }) { const meta = STATUS_META[run.status]; const StatusIcon = meta.icon; const contactLabel = run.contact?.name?.trim() || run.contact?.phone || "Unknown contact"; const duration = run.ended_at ? formatDistanceToNow(new Date(run.ended_at), { addSuffix: false, }) : null; return (
{expanded && (
{Object.keys(run.vars).length > 0 && (
Captured vars ({Object.keys(run.vars).length})
                {JSON.stringify(run.vars, null, 2)}
              
)}
{events.length === 0 ? (

No events recorded for this run.

) : ( events.map((ev, ix) => ) )}
)}
); } const EVENT_COLOR: Record = { started: "text-emerald-300", node_entered: "text-muted-foreground", message_sent: "text-sky-300", reply_received: "text-primary", fallback_fired: "text-amber-300", handoff: "text-amber-300", timeout: "text-muted-foreground", error: "text-red-300", completed: "text-emerald-300", }; function EventLine({ ev }: { ev: EventRow }) { const cls = EVENT_COLOR[ev.event_type] ?? "text-muted-foreground"; return (
{format(new Date(ev.created_at), "HH:mm:ss")} {ev.event_type} {ev.node_key && ( {ev.node_key} )} {Object.keys(ev.payload).length > 0 && ( {summarizePayload(ev.payload)} )}
); } function summarizePayload(payload: Record): string { // Show the keys that matter most to a human debugger; full JSON is // available via the "Captured vars" details panel for the run. const keys = ["reply_id", "captured_key", "reason", "advancing_to"]; for (const k of keys) { if (k in payload && payload[k] !== null && payload[k] !== undefined) { return `${k}=${String(payload[k]).slice(0, 80)}`; } } return ""; }