"use client"; import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import { ThinkingShimmer } from "@/components/agents/loading-states/thinking-shimmer"; import { AgentDisclosure } from "@/components/agents/agent-disclosure"; import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP, } from "@/lib/ease"; import { cn } from "@/lib/utils"; import { ActivityRow } from "./activity-row"; import type { AgentActivityContentType, AgentActivityItem, AgentActivityProps, } from "./types"; export type { AgentActivityContentType, AgentActivityItem, AgentActivityProps, AgentActivitySearch, AgentActivityStatus, AgentActivityStep, AgentActivityText, AgentActivityTool, AgentActivityTrace, AgentSearchResult, AgentStepStatus, AgentTraceKind, } from "./types"; function formatDuration(duration: number) { const seconds = Math.max(0, Math.round(duration)); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainder = seconds % 60; return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`; } function useControllableOpen({ open, defaultOpen, onOpenChange, }: { open?: boolean; defaultOpen: boolean; onOpenChange?: (open: boolean) => void; }) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const controlled = open !== undefined; const currentOpen = open ?? internalOpen; const setOpen = useCallback( (next: boolean) => { if (!controlled) setInternalOpen(next); onOpenChange?.(next); }, [controlled, onOpenChange], ); return [currentOpen, setOpen] as const; } function getContentType(items: AgentActivityItem[]): AgentActivityContentType { const first = items[0]?.type; return first && items.every((item) => item.type === first) ? first : "mixed"; } function getActiveLabel(type: AgentActivityContentType) { if (type === "search") return "Searching the web…"; if (type === "tool") return "Running tools…"; if (type === "trace") return "Working through the run…"; if (type === "mixed") return "Working through it…"; return "Thinking…"; } function getSummary( type: AgentActivityContentType, items: AgentActivityItem[], duration: number, ): ReactNode { if (type === "step" || type === "text") { return ( <> Thought for {formatDuration(duration)} ); } if (type === "search") return "Searched the web"; if (type === "tool") { return `Ran ${items.length} ${items.length === 1 ? "tool" : "tools"}`; } if (type === "trace") { const messages = items.filter( (item) => item.type === "trace" && (item.kind === "thinking" || item.kind === "message"), ).length; const tools = items.length - messages; return `${tools} ${tools === 1 ? "tool call" : "tool calls"}, ${messages} ${messages === 1 ? "message" : "messages"}`; } return `Completed ${items.length} ${items.length === 1 ? "step" : "steps"}`; } export function AgentActivity({ items, contentType: initialContentType, status = "working", duration = 0, open, defaultOpen = false, onOpenChange, collapseOnComplete = true, activeLabel, summary, maxHeight = 208, className, contentClassName, }: AgentActivityProps) { const reduce = useReducedMotion() ?? false; const baseId = useId(); const triggerId = `${baseId}-trigger`; const contentId = `${baseId}-content`; const contentRef = useRef(null); const viewportRef = useRef(null); const previousStatus = useRef(status); const [contentHeight, setContentHeight] = useState(0); const [currentOpen, setOpen] = useControllableOpen({ open, defaultOpen, onOpenChange, }); const working = status === "working"; const expanded = working || currentOpen; const contentType = items.length ? getContentType(items) : (initialContentType ?? "mixed"); const cappedHeight = Math.min(contentHeight, Math.max(0, maxHeight)); const viewportHeight = working ? Math.max(0, maxHeight) : cappedHeight; const capped = contentHeight > maxHeight; const streamOffset = working ? Math.min(0, viewportHeight - contentHeight) : 0; useLayoutEffect(() => { const node = contentRef.current; if (!node) return; const measure = () => setContentHeight(node.offsetHeight); measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); observer.observe(node); return () => observer.disconnect(); }, []); useEffect(() => { if (previousStatus.current === "working" && status === "complete") { setOpen(!collapseOnComplete); } previousStatus.current = status; }, [collapseOnComplete, setOpen, status]); const toggle = () => { const next = !currentOpen; setOpen(next); if (next) requestAnimationFrame(() => viewportRef.current?.scrollTo({ top: 0 })); }; const liveLabel = activeLabel ?? getActiveLabel(contentType); const completedSummary = summary ?? getSummary(contentType, items, duration); const maskImage = capped ? working ? "linear-gradient(to bottom, transparent, black 12px)" : "linear-gradient(to bottom, transparent, black 12px, black calc(100% - 12px), transparent)" : undefined; return (
{working ? (
{liveLabel}
) : ( )}
{items.map((item) => ( ))}
); }