"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) => (
))}
);
}