"use client"; import { ChevronRightIcon } from "lucide-react"; import { type FC, useEffect, useMemo, useState } from "react"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { getToolDisplayName } from "@/contracts/enums/toolIcons"; import { HitlApprovalCard, usePendingInterrupt } from "@/features/chat-messages/hitl"; import { cn } from "@/lib/utils"; import { groupItems } from "./grouping"; import { resolveItemTitle } from "./subagent-rename"; import { TimelineGroupRow } from "./timeline-group-row"; import type { ItemStatus, TimelineItem } from "./types"; /** * Force a stale "running" to read as "completed" once the thread * stops, so the chrome doesn't keep pulsing forever after a stream * is aborted or disconnected. */ function effectiveStatus(status: ItemStatus, isThreadRunning: boolean): ItemStatus { if (status === "running" && !isThreadRunning) return "completed"; return status; } /** * The "process" surface in the body | timeline split. Pure consumer * of ``TimelineItem[]`` — owns the collapsible chrome and tree * indent only. Pending HITL interrupts mount ``HitlApprovalCard`` at * the bottom; the card owns its own decision/pager state. */ export const Timeline: FC<{ items: readonly TimelineItem[]; isThreadRunning?: boolean; }> = ({ items, isThreadRunning = true }) => { const pendingValue = usePendingInterrupt(); const pendingInterrupt = pendingValue?.pendingInterrupt ?? null; const onSubmit = pendingValue?.onSubmit; const hasPending = pendingInterrupt !== null; // Apply the override here so downstream (grouping, headers, dots) // sees the corrected status without threading a callback. Keeps // ``buildTimeline`` pure. const effectiveItems = useMemo( () => items.map((it) => ({ ...it, status: effectiveStatus(it.status, isThreadRunning), })), [items, isThreadRunning] ); const inProgressItem = useMemo( () => effectiveItems.find((it) => it.status === "running"), [effectiveItems] ); const inProgressTitle = useMemo( () => (inProgressItem ? resolveItemTitle(inProgressItem, getToolDisplayName) : undefined), [inProgressItem] ); // "Settled" includes cancelled/errored, not just completed — // rejecting an interrupt leaves items in ``cancelled`` and the // timeline still needs to auto-collapse. const allSettled = useMemo( () => effectiveItems.length > 0 && !isThreadRunning && !hasPending && effectiveItems.every( (it) => it.status === "completed" || it.status === "cancelled" || it.status === "error" ), [effectiveItems, isThreadRunning, hasPending] ); const isProcessing = (isThreadRunning || hasPending) && !allSettled; const [isOpen, setIsOpen] = useState(() => isProcessing); useEffect(() => { if (isProcessing) { setIsOpen(true); return; } if (allSettled) { setIsOpen(false); } }, [allSettled, isProcessing]); const groups = useMemo(() => groupItems(effectiveItems), [effectiveItems]); if (effectiveItems.length === 0 && !hasPending) return null; const headerText = (() => { if (allSettled) return "Reviewed"; if (hasPending) return "Awaiting your decision"; if (inProgressTitle) return inProgressTitle; if (isProcessing) return "Processing"; return "Reviewed"; })(); return (
{groups.map((group, idx) => { const showLine = idx < groups.length - 1 || hasPending; return ( ); })} {pendingInterrupt && onSubmit && (
)}
); };