'use client';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { Collapsible } from 'radix-ui';
import { Check, ChevronDown, ChevronRight, Circle, AlertTriangle, X } from 'lucide-react';
import Markdown from 'react-markdown';
import { cn } from '../../../lib/utils.js';
import { Spinner } from '../../ui/spinner.js';
import { ToolBubble, parseToolEvent } from './tool-bubble/index.js';
const markdownComponents = {
    p: ({ children }) => _jsx("p", { className: "mb-2 leading-relaxed last:mb-0", children: children }),
    strong: ({ children }) => _jsx("strong", { className: "font-semibold", children: children }),
    em: ({ children }) => _jsx("em", { className: "italic", children: children }),
    code: ({ children, className }) => className ? (_jsx("code", { className: `${className} text-[11px]`, children: children })) : (_jsx("code", { className: "bg-background/50 rounded-md px-1.5 py-0.5 font-mono text-[11px]", children: children })),
    pre: ({ children }) => (_jsx("pre", { className: "bg-background/50 my-2 overflow-x-auto rounded-md p-3 font-mono text-[11px] leading-relaxed", children: children })),
    ul: ({ children }) => _jsx("ul", { className: "mb-2 list-disc ps-4 last:mb-0", children: children }),
    ol: ({ children }) => _jsx("ol", { className: "mb-2 list-decimal ps-4 last:mb-0", children: children }),
    li: ({ children }) => _jsx("li", { className: "mb-0.5", children: children }),
    h1: ({ children }) => _jsx("h1", { className: "mb-1 text-sm font-bold", children: children }),
    h2: ({ children }) => _jsx("h2", { className: "mb-1 text-sm font-bold", children: children }),
    h3: ({ children }) => _jsx("h3", { className: "mb-1 text-xs font-semibold", children: children }),
    a: ({ children, href }) => (_jsx("a", { href: href, className: "text-blue-500 underline", target: "_blank", rel: "noopener noreferrer", children: children })),
};
/**
 * Vertical list of workflow step cards with a status indicator,
 * friendly title/description, and an expandable body. The expanded
 * view shows the step's metadata summary and any tool-event
 * messages (Bash, Read, Write, Edit, …) that the agent produced
 * while this step was active — the same bubble component the
 * regular thread would have used, just grouped by step.
 */
export function StepTracker({ steps, className, collapsedSummary, activeStepId, liveStatus, onRetry, onForceStop, }) {
    const [expandedOverride, setExpandedOverride] = useState(false);
    if (steps.length === 0)
        return null;
    if (collapsedSummary && !expandedOverride) {
        const doneCount = steps.filter((s) => s.status === 'done').length;
        return (_jsx("div", { className: cn('p-4', className), children: _jsxs("button", { type: "button", onClick: () => setExpandedOverride(true), className: "hover:bg-muted/40 border-border bg-background flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors dark:bg-neutral-800/50", children: [_jsx("div", { className: "flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white", children: _jsx(Check, { className: "h-4 w-4", strokeWidth: 3 }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-sm font-medium", children: "Initial setup complete" }), _jsxs("div", { className: "text-muted-foreground text-xs", children: [doneCount, " of ", steps.length, " steps finished \u2014 click to review"] })] }), _jsx(ChevronRight, { className: "text-muted-foreground h-4 w-4 shrink-0" })] }) }));
    }
    // Aggregate totals: total duration + total cost + total tokens
    // across every step. Rendered once every real step has timing
    // data so the user gets a single bottom-of-tracker summary. The
    // cost/tokens columns are omitted when none of the steps carry
    // usage metadata (e.g. a resumed workflow that finished before
    // usage snapshots were wired in).
    const totals = computeTotals(steps);
    return (_jsxs("ol", { className: cn('flex flex-col gap-2 p-4', className), children: [collapsedSummary ? (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => setExpandedOverride(false), className: "text-muted-foreground hover:text-foreground mb-1 inline-flex items-center gap-1 text-[10px] tracking-wide uppercase", children: [_jsx(ChevronDown, { className: "h-3 w-3" }), "Collapse setup"] }) })) : null, steps.map((step, idx) => (
            // Each card fades + slides in on mount, briefly staggered so
            // the tracker assembles itself instead of popping in all at
            // once. Capped at 6 indexes so very long step lists don't
            // turn into a long cascade.
            _jsx(StepCard, { step: step, liveStatus: step.definition.id === activeStepId ? (liveStatus ?? null) : null, mountIndex: idx, onRetry: step.status === 'interrupted' ? onRetry : undefined, onForceStop: onForceStop }, step.definition.id))), totals ? (_jsxs("li", { className: "border-border/40 text-muted-foreground mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 border-t pt-3 text-[10px] tracking-wide uppercase", children: [_jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "text-muted-foreground/70", children: "Total" }), _jsx("span", { className: "text-foreground font-semibold tracking-normal normal-case", children: formatDuration(totals.durationMs) })] }), totals.costUsd > 0 ? (_jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "text-muted-foreground/70", children: "Cost" }), _jsx("span", { className: "text-foreground font-semibold tracking-normal normal-case", children: formatCost(totals.costUsd) })] })) : null, totals.inputTokens > 0 || totals.outputTokens > 0 ? (_jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "text-muted-foreground/70", children: "Tokens" }), _jsxs("span", { className: "text-foreground font-semibold tracking-normal normal-case", children: [formatTokens(totals.inputTokens), " in \u00B7 ", formatTokens(totals.outputTokens), " out"] })] })) : null] })) : null] }));
}
const OUTPUT_LABEL = 'Output';
const THINKING_LABEL = 'Thinking';
function classifyMessages(messages) {
    // Pre-parse every message once so we can look ahead without
    // re-parsing inside the loop.
    const parsed = messages
        .map((m) => {
        const content = m.content?.trim() ?? '';
        return { message: m, content, event: content ? parseToolEvent(content) : null };
    })
        .filter((row) => row.content.length > 0);
    const items = [];
    for (let i = 0; i < parsed.length; i++) {
        const row = parsed[i];
        // Thinking gets its own bubble-less rendering path.
        if (row.event?.kind === 'tool' && row.event.name === THINKING_LABEL) {
            items.push({ kind: 'thinking', message: row.message });
            continue;
        }
        // A lone Output row (no preceding tool call we could pair it
        // with — already consumed below) renders as an unpaired tool
        // item. This is a fallback path that should rarely fire in
        // practice but keeps the data visible if SSE ordering ever
        // delivers Output before its parent tool.
        if (row.event?.kind === 'tool' && row.event.name === OUTPUT_LABEL) {
            items.push({ kind: 'tool', message: row.message, output: null });
            continue;
        }
        // Tool call — look ahead exactly one message and consume it if
        // it's an Output. This collapses the `Bash` + `Output` pair
        // that used to render as two stacked bubbles into one.
        if (row.event?.kind === 'tool') {
            const next = parsed[i + 1];
            const isOutputNext = next?.event?.kind === 'tool' && next.event.name === OUTPUT_LABEL;
            items.push({
                kind: 'tool',
                message: row.message,
                output: isOutputNext ? next.message : null,
            });
            if (isOutputNext)
                i++; // skip the paired Output on the next iteration
            continue;
        }
        // Label-only events (e.g. "Session started") still render as
        // unpaired tool items.
        if (row.event?.kind === 'label-only') {
            items.push({ kind: 'tool', message: row.message, output: null });
            continue;
        }
        // Plain prose — markdown renderer.
        items.push({ kind: 'text', message: row.message });
    }
    return items;
}
function StepCard({ step, liveStatus, mountIndex, onRetry, onForceStop }) {
    const [optimisticRunning, setOptimisticRunning] = useState(false);
    const [optimisticInterrupted, setOptimisticInterrupted] = useState(false);
    // Clear optimistic override once the real status catches up
    useEffect(() => {
        if (step.status !== 'interrupted')
            setOptimisticRunning(false);
    }, [step.status]);
    useEffect(() => {
        // Clear the force-stop optimistic override as soon as the real
        // status catches up to a terminal state.
        if (step.status !== 'running' && step.status !== 'pending') {
            setOptimisticInterrupted(false);
        }
    }, [step.status]);
    const status = optimisticInterrupted
        ? 'interrupted'
        : optimisticRunning
            ? 'running'
            : step.status;
    const [expanded, setExpanded] = useState(step.status === 'interrupted' || step.status === 'failed');
    const { definition, metadata, toolMessages } = step;
    const details = readStringArray(metadata, 'details');
    const error = readString(metadata, 'error');
    const items = classifyMessages(toolMessages);
    // Live-ticking duration: for the currently running step we want the
    // pill to count up every second so the user sees elapsed time. For
    // finished steps we just render the static diff between startedAt
    // and finishedAt. Pending steps have no duration at all.
    const durationMs = useStepDurationMs(step, status);
    const costUsd = readNumber(metadata, 'costUsd');
    const inputTokens = readNumber(metadata, 'inputTokens');
    const outputTokens = readNumber(metadata, 'outputTokens');
    // Note: the `summary` metadata field is populated by the orchestrator
    // with `definition.title` on every step (see RunWorkflowUseCase), so
    // it is always a duplicate of the card header and is intentionally
    // NOT rendered — otherwise the expanded body would open with a copy
    // of the title right above the real content.
    const hasBody = items.length > 0 || details.length > 0 || !!error || status === 'interrupted';
    return (_jsx(Collapsible.Root, { asChild: true, open: expanded, onOpenChange: setExpanded, children: _jsxs("li", { className: cn(
            // `transition-colors` glides the border + background tint
            // when the status changes (pending → running → done) so the
            // tracker feels alive instead of snapping.
            'rounded-lg border transition-[background-color,border-color] duration-300 ease-out', 
            // Mount animation: each card slides + fades in. The
            // staggered animationDelay below makes the whole tracker
            // assemble itself instead of popping in.
            'animate-in fade-in-0 slide-in-from-top-2', 
            // Green is reserved for the DONE state (the universal "this
            // finished cleanly" signal). The running card uses Shep's
            // indigo/violet brand accent so the in-progress step is
            // visually distinct from anything that has already completed.
            status === 'running' && 'border-violet-500/40 bg-violet-500/5 dark:bg-violet-500/10', status === 'done' && 'border-border bg-background dark:bg-neutral-800/50', status === 'pending' && 'border-border/40 bg-muted/20 dark:bg-neutral-800/30', status === 'failed' && 'border-red-500/40 bg-red-500/5 dark:bg-red-500/10', status === 'interrupted' && 'border-amber-500/40 bg-amber-500/5 dark:bg-amber-500/10'), style: { animationDelay: `${Math.min(mountIndex, 6) * 40}ms`, animationDuration: '300ms' }, children: [_jsx(Collapsible.Trigger, { asChild: true, children: _jsxs("button", { type: "button", disabled: !hasBody, className: cn('flex w-full items-center gap-3 px-3 py-1.5 text-left transition-colors duration-200', hasBody && 'hover:bg-muted/40 cursor-pointer', !hasBody && 'cursor-default'), children: [_jsx(StatusIcon, { status: status }), _jsxs("div", { className: "flex min-w-0 flex-1 items-baseline gap-2", children: [_jsx("span", { className: cn('shrink-0 truncate text-sm font-medium transition-colors duration-300', status === 'pending' && 'text-muted-foreground/60'), children: definition.title }), status === 'interrupted' ? (_jsx("span", { className: "min-w-0 truncate text-xs font-medium text-amber-600 dark:text-amber-400", children: "Interrupted" })) : liveStatus && status === 'running' ? (_jsxs("span", { className: "flex min-w-0 items-baseline gap-1.5 text-xs text-violet-700 dark:text-violet-300", children: [_jsx("span", { className: "inline-flex h-1 w-1 shrink-0 translate-y-[-1px] animate-pulse rounded-full bg-violet-500" }), _jsx("span", { className: "animate-in fade-in-0 slide-in-from-left-1 truncate duration-300", children: liveStatus }, liveStatus)] })) : definition.description ? (_jsx("span", { className: cn('min-w-0 truncate text-xs font-normal', status === 'pending' ? 'text-muted-foreground/40' : 'text-muted-foreground/70'), children: definition.description })) : null] }), durationMs !== null ? (_jsx("span", { className: cn('shrink-0 text-[10px] font-medium tabular-nums transition-colors duration-300', status === 'running'
                                    ? 'text-violet-600 dark:text-violet-300'
                                    : 'text-muted-foreground/70'), title: `Step duration — ${formatDuration(durationMs)}`, children: formatDuration(durationMs) })) : null, onForceStop && (status === 'running' || status === 'pending') ? (_jsx("span", { role: "button", tabIndex: 0, "aria-label": "Force stop this step", title: "Force stop this step (mark as interrupted)", onClick: (e) => {
                                    e.stopPropagation();
                                    e.preventDefault();
                                    setOptimisticInterrupted(true);
                                    onForceStop(step.definition.id);
                                }, onKeyDown: (e) => {
                                    if (e.key === 'Enter' || e.key === ' ') {
                                        e.stopPropagation();
                                        e.preventDefault();
                                        setOptimisticInterrupted(true);
                                        onForceStop(step.definition.id);
                                    }
                                }, className: "text-muted-foreground/50 hover:bg-muted/60 hover:text-foreground focus-visible:ring-ring/50 inline-flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none", children: _jsx(X, { className: "h-3 w-3", strokeWidth: 2.5 }) })) : null, items.length > 0 ? (_jsx("span", { className: "text-muted-foreground/60 animate-in fade-in-0 zoom-in-95 mr-1 shrink-0 text-[10px] tabular-nums duration-200", children: items.length }, items.length)) : null, hasBody ? (_jsx(ChevronRight, { className: cn('text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200 ease-out', expanded && 'rotate-90') })) : null] }) }), _jsx(Collapsible.Content, { className: cn('overflow-hidden', 
                    // The keyframes (defined in globals.css) animate from
                    // height: 0 ↔ var(--radix-collapsible-content-height) so
                    // the body smoothly slides open and back. Both directions
                    // get matching cubic-bezier curves — one ease-out for
                    // open, one ease-in for close — so the motion never feels
                    // lopsided.
                    'data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up'), children: _jsxs("div", { className: "border-border/40 space-y-2 border-t px-3 py-2.5 text-xs", children: [status === 'interrupted' && !error ? (_jsxs("div", { className: "flex items-center justify-between rounded bg-amber-500/10 p-2", children: [_jsx("span", { className: "text-amber-700 dark:text-amber-400", children: "This step was interrupted before it could finish." }), onRetry ? (_jsx("button", { type: "button", onClick: (e) => {
                                            e.stopPropagation();
                                            setOptimisticRunning(true);
                                            setExpanded(false);
                                            onRetry();
                                        }, className: "ms-3 shrink-0 cursor-pointer rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-700 transition-colors hover:bg-amber-500/20 dark:text-amber-400", children: "Continue" })) : null] })) : null, error ? (_jsx("p", { className: "rounded bg-red-500/10 p-2 text-red-600 dark:text-red-400", children: error })) : null, details.length > 0 ? (_jsx("ul", { className: "text-muted-foreground list-disc space-y-0.5 ps-4", children: details.map((d) => (_jsx("li", { children: d }, d))) })) : null, costUsd > 0 || inputTokens > 0 || outputTokens > 0 ? (_jsxs("div", { className: "text-muted-foreground/70 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] tabular-nums", children: [costUsd > 0 ? _jsx("span", { children: formatCost(costUsd) }) : null, inputTokens > 0 || outputTokens > 0 ? (_jsxs("span", { children: [formatTokens(inputTokens), " in \u00B7 ", formatTokens(outputTokens), " out"] })) : null] })) : null, items.length > 0 ? (_jsx("div", { className: "-mx-1 flex flex-col gap-1", children: items.map((item, idx) => {
                                    const animStyle = {
                                        animationDelay: `${Math.min(idx, 6) * 30}ms`,
                                    };
                                    if (item.kind === 'thinking') {
                                        return (_jsx("div", { className: "animate-in fade-in-0 slide-in-from-top-1 duration-200", style: animStyle, children: _jsx(ThinkingRow, { text: item.message.content }) }, item.message.id));
                                    }
                                    if (item.kind === 'tool') {
                                        return (_jsx("div", { className: "animate-in fade-in-0 slide-in-from-top-1 duration-200", style: animStyle, children: _jsx(ToolBubble, { text: item.message.content, outputText: item.output?.content }) }, item.message.id));
                                    }
                                    return (_jsx("div", { className: "animate-in fade-in-0 slide-in-from-top-1 duration-200", style: animStyle, children: _jsx("div", { className: "border-border/60 bg-background text-foreground rounded-md border px-2.5 py-1.5 text-xs leading-relaxed dark:bg-neutral-800/70", children: _jsx(Markdown, { components: markdownComponents, children: item.message.content }) }) }, item.message.id));
                                }) })) : null] }) })] }) }));
}
/**
 * Chrome-less "Thinking" row used for extended-thinking blocks
 * inside a step card. Intentionally NOT wrapped in the same
 * bordered chip as regular tool bubbles — thinking is meta-commentary
 * about the agent's reasoning, not a tool call, and should visually
 * recede into the background so the real tool/output chain stands
 * out. Collapsed by default; click the label + chevron to expand
 * and read the full reasoning block inline without a bordered box.
 */
function ThinkingRow({ text }) {
    const [expanded, setExpanded] = useState(false);
    // Strip the `**Thinking** \`...\`` persistence wrapper so the
    // expanded body shows the raw reasoning text.
    const body = (() => {
        const event = parseToolEvent(text);
        if (event?.kind === 'tool' && event.name === 'Thinking') {
            return event.detail;
        }
        return text;
    })();
    return (_jsxs("div", { className: "flex max-w-full flex-col", children: [_jsxs("button", { type: "button", onClick: () => setExpanded((v) => !v), className: "text-muted-foreground/80 hover:text-foreground inline-flex items-center gap-1 self-start rounded px-1 py-0.5 text-[11px] italic transition-colors", children: [expanded ? (_jsx(ChevronDown, { className: "h-3 w-3 shrink-0 opacity-60" })) : (_jsx(ChevronRight, { className: "h-3 w-3 shrink-0 opacity-60" })), _jsx("span", { children: "Thinking" })] }), expanded ? (_jsx("p", { className: "text-muted-foreground/80 mt-0.5 ml-5 max-w-full text-[11px] leading-relaxed break-words whitespace-pre-wrap italic", children: body })) : null] }));
}
function StatusIcon({ status }) {
    if (status === 'running') {
        return _jsx(Spinner, { size: "sm", className: "shrink-0 text-violet-500" });
    }
    if (status === 'done') {
        return (_jsx("div", { className: "flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white", children: _jsx(Check, { className: "h-3 w-3", strokeWidth: 3 }) }));
    }
    if (status === 'failed') {
        return (_jsx("div", { className: "flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-red-500 text-white", children: _jsx(AlertTriangle, { className: "h-2.5 w-2.5", strokeWidth: 3 }) }));
    }
    if (status === 'interrupted') {
        return (_jsx("div", { className: "flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-amber-500 text-white", children: _jsx(AlertTriangle, { className: "h-2.5 w-2.5", strokeWidth: 3 }) }));
    }
    return _jsx(Circle, { className: "text-muted-foreground/30 h-4 w-4 shrink-0" });
}
function readString(metadata, key) {
    if (!metadata)
        return '';
    const v = metadata[key];
    return typeof v === 'string' ? v : '';
}
function readStringArray(metadata, key) {
    if (!metadata)
        return [];
    const v = metadata[key];
    if (!Array.isArray(v))
        return [];
    return v.filter((x) => typeof x === 'string');
}
function readNumber(metadata, key) {
    if (!metadata)
        return 0;
    const v = metadata[key];
    return typeof v === 'number' && Number.isFinite(v) ? v : 0;
}
/**
 * Millisecond-precision duration for a step. Returns:
 *  - null when pending (no startedAt yet)
 *  - a live-ticking value when running (updates every second so the
 *    user sees elapsed time count up inside the pill)
 *  - a static finishedAt - startedAt value when terminal
 */
function useStepDurationMs(step, status) {
    // Tick state for running steps — forces a re-render every second so
    // `Date.now() - startedAt` advances in the rendered pill.
    const [tick, setTick] = useState(0);
    useEffect(() => {
        if (status !== 'running' || step.startedAt === null)
            return undefined;
        const handle = setInterval(() => setTick((t) => t + 1), 1000);
        return () => clearInterval(handle);
    }, [status, step.startedAt]);
    // Silence unused-var warning without disabling the effect's contribution.
    void tick;
    if (step.startedAt === null)
        return null;
    if (status === 'running')
        return Math.max(0, Date.now() - step.startedAt);
    if (step.finishedAt === null)
        return null;
    return Math.max(0, step.finishedAt - step.startedAt);
}
/**
 * Human-friendly duration. Uses a compact form so the inline pill
 * stays narrow: `850ms`, `12s`, `3m 5s`, `1h 12m`.
 */
function formatDuration(ms) {
    if (!Number.isFinite(ms) || ms < 0)
        return '';
    if (ms < 1_000)
        return `${Math.round(ms)}ms`;
    const totalSeconds = Math.round(ms / 1_000);
    if (totalSeconds < 60)
        return `${totalSeconds}s`;
    const minutes = Math.floor(totalSeconds / 60);
    const seconds = totalSeconds % 60;
    if (minutes < 60)
        return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
    const hours = Math.floor(minutes / 60);
    const remMinutes = minutes % 60;
    return remMinutes === 0 ? `${hours}h` : `${hours}h ${remMinutes}m`;
}
/** USD cost formatted with enough precision to show sub-cent values. */
function formatCost(cost) {
    if (!Number.isFinite(cost) || cost <= 0)
        return '$0';
    if (cost < 0.01)
        return `$${cost.toFixed(4)}`;
    if (cost < 1)
        return `$${cost.toFixed(3)}`;
    return `$${cost.toFixed(2)}`;
}
/** Compact token count: 1.2k, 3.4M. */
function formatTokens(count) {
    if (!Number.isFinite(count) || count <= 0)
        return '0';
    if (count < 1_000)
        return String(Math.round(count));
    if (count < 1_000_000)
        return `${(count / 1_000).toFixed(count < 10_000 ? 1 : 0)}k`;
    return `${(count / 1_000_000).toFixed(1)}M`;
}
/**
 * Sum per-step timing + usage metadata into tracker-level totals.
 * Returns null when no real step has completed yet (no startedAt on
 * any card) so the bottom-of-tracker footer stays hidden for the
 * placeholder tracker and for freshly-booted workflows.
 */
function computeTotals(steps) {
    let earliestStart = null;
    let latestFinish = null;
    let costUsd = 0;
    let inputTokens = 0;
    let outputTokens = 0;
    for (const step of steps) {
        if (step.startedAt !== null) {
            earliestStart =
                earliestStart === null ? step.startedAt : Math.min(earliestStart, step.startedAt);
        }
        // Use `now` for a still-running step so the footer ticks forward
        // with the step card. Terminal steps use their own finishedAt.
        const stepEnd = step.status === 'running' ? Date.now() : (step.finishedAt ?? null);
        if (stepEnd !== null) {
            latestFinish = latestFinish === null ? stepEnd : Math.max(latestFinish, stepEnd);
        }
        costUsd += readNumber(step.metadata, 'costUsd');
        inputTokens += readNumber(step.metadata, 'inputTokens');
        outputTokens += readNumber(step.metadata, 'outputTokens');
    }
    if (earliestStart === null || latestFinish === null)
        return null;
    return {
        durationMs: Math.max(0, latestFinish - earliestStart),
        costUsd,
        inputTokens,
        outputTokens,
    };
}
