"use client"; import * as React from "react"; import { useParams } from "next/navigation"; import { Activity, ChevronDown, ChevronRight, Loader2, PlayCircle, } from "lucide-react"; import { api } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; /** * Read-side dashboard for `agent_runs`. Mirrors the audit page's visual * language but is open to all workspace members — runs aren't as * sensitive as the audit log and the value is mostly debugging the * orchestration layer once the MCP write paths land. * * The "Recent runs" section pages with keyset pagination on * `started_at`. Click any row to expand and see the row's `notes` and * `error` text — kept collapsed by default because most runs are * uneventful and the table needs to stay scannable. */ const FILTER_OPTIONS = ["all", "succeeded", "failed", "cancelled", "stalled", "open"] as const; type FilterOption = (typeof FILTER_OPTIONS)[number]; const FILTER_LABEL: Record = { all: "All", succeeded: "Succeeded", failed: "Failed", cancelled: "Cancelled", stalled: "Stalled", open: "Open", }; const OUTCOME_CHIP: Record = { succeeded: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 ring-1 ring-emerald-500/20", failed: "bg-red-500/15 text-red-700 dark:text-red-300 ring-1 ring-red-500/20", cancelled: "bg-amber-500/15 text-amber-700 dark:text-amber-300 ring-1 ring-amber-500/20", stalled: "bg-orange-500/15 text-orange-700 dark:text-orange-300 ring-1 ring-orange-500/20", open: "bg-sky-500/15 text-sky-700 dark:text-sky-300 ring-1 ring-sky-500/20", }; function formatTimestamp(when: Date | string): string { const d = when instanceof Date ? when : new Date(when); return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } /** * Format a duration in milliseconds as a compact string. Returns "—" * for in-flight runs (no `finishedAt`). Below a minute we show seconds * with a single decimal so quick MCP calls don't all look like "0s". */ export function formatDuration( startedAt: Date | string, finishedAt: Date | string | null, ): string { if (!finishedAt) return "—"; const start = startedAt instanceof Date ? startedAt : new Date(startedAt); const end = finishedAt instanceof Date ? finishedAt : new Date(finishedAt); const ms = end.getTime() - start.getTime(); if (!Number.isFinite(ms) || ms < 0) return "—"; const seconds = ms / 1000; if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`; const minutes = seconds / 60; if (minutes < 60) return `${Math.round(minutes)}m`; const hours = minutes / 60; return `${hours.toFixed(hours < 10 ? 1 : 0)}h`; } function formatTokens(n: number | null): string { if (n === null || n === undefined) return "—"; if (n < 1000) return String(n); if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`; return `${(n / 1_000_000).toFixed(1)}M`; } function describeOutcome( outcome: string | null, finishedAt: Date | string | null, ): { label: string; key: keyof typeof OUTCOME_CHIP } { if (!finishedAt) return { label: "open", key: "open" }; const key = outcome && outcome in OUTCOME_CHIP ? outcome : "open"; return { label: outcome ?? "open", key: key as keyof typeof OUTCOME_CHIP }; } /** * Read `client` / `model` out of a run's metadata blob. The column is * JSONB so we're treating it as unstructured here — short-circuit on * null and ignore values that aren't strings (defensive against * hand-inserted rows from psql). */ function describeAgent(metadata: unknown): { client: string | null; model: string | null; label: string | null; } { if (!metadata || typeof metadata !== "object") { return { client: null, model: null, label: null }; } const m = metadata as Record; const client = typeof m.client === "string" ? m.client : null; const model = typeof m.model === "string" ? m.model : null; if (!client && !model) return { client, model, label: null }; if (client && model) return { client, model, label: `${client} · ${model}` }; return { client, model, label: client ?? model }; } export default function RunsPage() { const params = useParams(); const workspaceSlug = params?.workspaceSlug as string | undefined; const [filter, setFilter] = React.useState("all"); const [cursor, setCursor] = React.useState(undefined); const [expanded, setExpanded] = React.useState(null); // Reset pagination + expansion state when the user switches filters so // we never end up showing a cursor that was generated for a different // outcome subset. const onFilterChange = React.useCallback((next: FilterOption) => { setFilter(next); setCursor(undefined); setExpanded(null); }, []); const filterArg = filter === "all" ? undefined : filter; const summaryQuery = api.runs.summary.useQuery( { workspace: workspaceSlug ?? "" }, { enabled: Boolean(workspaceSlug) }, ); const runsQuery = api.runs.listRecent.useQuery( { workspace: workspaceSlug ?? "", limit: 25, cursorStartedAt: cursor, outcome: filterArg, }, { enabled: Boolean(workspaceSlug) }, ); return (

Agent runs

Every agent session against a task. Read-only — write paths land with the orchestrator MCP tools.

{/* Summary card */} {/* Outcome filter pills */}
{FILTER_OPTIONS.map((option) => ( ))}
{/* Recent runs table */}
{runsQuery.isLoading ? (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) : runsQuery.error ? (
Couldn't load runs: {runsQuery.error.message}
) : !runsQuery.data || runsQuery.data.rows.length === 0 ? ( ) : ( <>
{runsQuery.data.rows.map((row) => { const { label, key } = describeOutcome(row.outcome, row.finishedAt); const agent = describeAgent(row.metadata); const isOpen = expanded === row.id; const hasDetail = Boolean(row.notes || row.error); return ( { if (hasDetail) { setExpanded(isOpen ? null : row.id); } }} > {isOpen && hasDetail ? ( ) : null} ); })}
Task Started Duration Actor Outcome Tokens
{hasDetail ? ( isOpen ? ( ) : ( ) ) : null} {row.taskTitle ?? ( {row.backlogItemId.slice(0, 8)} )} {formatTimestamp(row.startedAt)} {formatDuration(row.startedAt, row.finishedAt)}
{row.actorUserId ? ( {row.actorName ?? row.actorEmail ?? row.actorUserId.slice(0, 8)} ) : ( )} {agent.label ? ( {agent.label} ) : null}
{label} {formatTokens(row.tokensTotal)}
{row.notes ? (
Notes

{row.notes}

) : null} {row.error ? (
Error
                                    {row.error}
                                  
) : null}
{runsQuery.data.nextCursor ? (
) : null} )}
); } function EmptyState({ filter }: { filter: FilterOption }) { return (

{filter === "all" ? "No runs yet" : `No ${FILTER_LABEL[filter].toLowerCase()} runs`}

Once an agent session runs against a task — via the orchestrator or a manually-inserted row — it will appear here.

); } type SummaryData = { byOutcome: Record<"succeeded" | "failed" | "cancelled" | "stalled" | "open", number>; tokensTotal: number; }; function SummaryCard({ loading, data, error, }: { loading: boolean; data: SummaryData | undefined; error: string | undefined; }) { if (loading) { return (
{Array.from({ length: 6 }).map((_, i) => ( ))}
); } if (error) { return (
Couldn't load summary: {error}
); } if (!data) return null; const cells: Array<{ label: string; value: string; key: keyof typeof OUTCOME_CHIP | "tokens" }> = [ { label: "Succeeded", value: String(data.byOutcome.succeeded), key: "succeeded" }, { label: "Failed", value: String(data.byOutcome.failed), key: "failed" }, { label: "Cancelled", value: String(data.byOutcome.cancelled), key: "cancelled" }, { label: "Stalled", value: String(data.byOutcome.stalled), key: "stalled" }, { label: "Open", value: String(data.byOutcome.open), key: "open" }, { label: "Tokens (7d)", value: formatTokens(data.tokensTotal), key: "tokens" }, ]; return (
{cells.map((cell) => (
{cell.label}
{cell.value}
))}
); }