beui
componentagent-activitysource: ok · refreshed
Agent Activity
One adaptive activity stream for reasoning, searches, tool calls, structured execution traces, or a chronological mix.
npx shadcn add "https://beui.dev/r/agent-activity.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumiconslucide-react
animationmotion
Files9 · source cached 10/8/2026, 8:06:59 AM
components/agents/agent-activity/index.tsx
"use client";
// beui.dev/components/agents/agent-activity
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 <span className="tabular-nums">{formatDuration(duration)}</span>
</>
);
}
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,
renderWorkingStatus,
renderCompletedStatus,
maxHeight = 208,
className,
contentClassName,
}: AgentActivityProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const triggerId = `${baseId}-trigger`;
const contentId = `${baseId}-content`;
const contentRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(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 (
<div
data-state={working ? "working" : expanded ? "open" : "closed"}
data-content={contentType}
aria-busy={working}
className={cn("w-full text-sm", className)}
>
{working ? (
<div
id={triggerId}
role="status"
className="flex h-7 min-w-0 items-center text-muted-foreground"
>
{renderWorkingStatus
? renderWorkingStatus({ label: liveLabel, duration })
: <ThinkingShimmer>{liveLabel}</ThinkingShimmer>}
</div>
) : (
<button
id={triggerId}
type="button"
aria-expanded={expanded}
aria-controls={contentId}
onClick={toggle}
className="group flex h-7 min-w-0 items-center gap-1.5 rounded-md text-left font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<span className="truncate">
{renderCompletedStatus
? renderCompletedStatus({ summary: completedSummary, duration })
: completedSummary}
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: expanded ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="inline-flex shrink-0 text-muted-foreground/70 group-hover:text-foreground"
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
)}
<AgentDisclosure
id={contentId}
role="region"
aria-labelledby={triggerId}
open={expanded}
openHeight={viewportHeight}
>
<div
ref={viewportRef}
className={cn(
"scrollbar-hide pr-1",
capped && expanded && !working ? "overflow-y-auto" : "overflow-y-hidden",
)}
style={{ height: viewportHeight, maskImage, WebkitMaskImage: maskImage }}
>
<motion.div
ref={contentRef}
role="list"
initial={false}
animate={{ y: streamOffset }}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn("space-y-0.5 py-2", contentClassName)}
>
<AnimatePresence mode="popLayout">
{items.map((item) => (
<motion.div
layout="position"
key={item.id}
role="listitem"
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}
transition={
reduce
? { duration: 0 }
: {
opacity: { duration: 0.18, ease: EASE_OUT },
y: SPRING_LAYOUT,
layout: SPRING_LAYOUT,
}
}
>
<ActivityRow item={item} />
</motion.div>
))}
</AnimatePresence>
</motion.div>
</div>
</AgentDisclosure>
</div>
);
}
components/agents/agent-activity/activity-row.tsx
import {
Check,
Circle,
FileText,
Globe2,
ImageIcon,
MessageSquare,
PencilLine,
Search,
Sparkles,
SquareTerminal,
Wrench,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import type {
AgentActivityItem,
AgentActivitySearch,
AgentActivityStep,
AgentActivityText,
AgentActivityTool,
AgentActivityTrace,
AgentSearchResult,
} from "./types";
function StepRow({ item }: { item: AgentActivityStep }) {
const state = item.status ?? "complete";
return (
<div className="flex min-h-7 items-start gap-2.5 rounded-md px-1.5 py-1">
<span
aria-hidden="true"
className="mt-0.5 grid size-4 shrink-0 place-items-center text-muted-foreground/70"
>
{state === "complete" ? (
<Check className="size-4" strokeWidth={1.8} />
) : state === "active" ? (
<span className="relative grid size-3 place-items-center">
<motion.span
className="absolute inset-0 rounded-full bg-foreground/10"
animate={{ opacity: [0.35, 0.8, 0.35] }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
/>
<span className="size-1.5 rounded-full bg-foreground/60" />
</span>
) : (
<Circle className="size-3" strokeWidth={1.5} />
)}
</span>
<span
className={cn(
"min-w-0 flex-1 leading-5",
state === "pending" ? "text-muted-foreground/55" : "text-foreground/90",
)}
>
{item.label}
</span>
{item.meta ? (
<span className="shrink-0 leading-5 text-muted-foreground/55">
{item.meta}
</span>
) : null}
</div>
);
}
function TextRow({ item }: { item: AgentActivityText }) {
return (
<div className="rounded-md px-1.5 py-1 leading-5 text-muted-foreground">
{item.content}
</div>
);
}
function SearchResultRow({
result,
}: {
result: AgentSearchResult;
}) {
const content = (
<>
<span
aria-hidden="true"
className="grid size-5 shrink-0 place-items-center text-muted-foreground"
>
{result.icon ?? <Globe2 className="size-3" strokeWidth={2} />}
</span>
<span className="min-w-0 truncate font-medium text-foreground/90">
{result.title}
</span>
{result.domain ? (
<span className="min-w-0 truncate text-muted-foreground/55">
{result.domain}
</span>
) : null}
</>
);
const className = cn(
"flex min-h-7 items-center gap-2 rounded-md px-1.5 py-1 text-left outline-none transition-colors",
result.url && "focus-visible:ring-2 focus-visible:ring-ring",
);
return result.url ? (
<a href={result.url} className={className}>
{content}
</a>
) : (
<div className={className}>{content}</div>
);
}
function SearchRow({ item }: { item: AgentActivitySearch }) {
const reduce = useReducedMotion() ?? false;
const enter = reduce ? { opacity: 1 } : { opacity: 0, y: 6 };
const visible = { opacity: 1, y: 0 };
const exit = reduce ? { opacity: 0 } : { opacity: 0, y: -3 };
const transition = reduce
? { duration: 0 }
: {
opacity: { duration: 0.18, ease: EASE_OUT },
y: SPRING_LAYOUT,
layout: SPRING_LAYOUT,
};
return (
<div className="space-y-0.5">
<div className="flex min-h-7 items-center gap-2.5 rounded-md px-1.5 py-1 text-muted-foreground">
<Search aria-hidden="true" className="size-4 shrink-0" strokeWidth={1.7} />
<span className="min-w-0 truncate">{item.query}</span>
</div>
{item.results?.length ? (
<div className="space-y-0.5 pl-4">
<AnimatePresence initial mode="popLayout">
{item.results.map((result) => (
<motion.div
layout="position"
key={result.id}
initial={enter}
animate={visible}
exit={exit}
transition={transition}
>
<SearchResultRow result={result} />
</motion.div>
))}
</AnimatePresence>
</div>
) : null}
<AnimatePresence initial>
{item.moreCount ? (
<motion.div
key="more-results"
initial={enter}
animate={visible}
exit={exit}
transition={transition}
className="px-1.5 py-1 pl-8 text-muted-foreground/55"
>
+{item.moreCount} more
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
function ActionIcon({ action }: { action: string }) {
if (action === "read") return <FileText className="size-4" />;
if (action === "edit" || action === "write") {
return <PencilLine className="size-4" />;
}
if (action === "run") return <SquareTerminal className="size-4" />;
return <Wrench className="size-4" />;
}
function ToolRow({ item }: { item: AgentActivityTool }) {
const action = item.action.charAt(0).toUpperCase() + item.action.slice(1);
return (
<div className="flex min-h-8 min-w-0 items-center gap-2.5 rounded-md px-1.5 py-0.5 leading-5">
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center text-muted-foreground/70"
>
<ActionIcon action={item.action} />
</span>
<span className="shrink-0 font-medium text-foreground/90">{action}</span>
<span className="min-w-0 flex-1 truncate rounded-lg bg-muted/80 px-2.5 py-1 font-mono text-xs text-muted-foreground/70">
{item.target}
</span>
{typeof item.additions === "number" || typeof item.deletions === "number" ? (
<span className="flex shrink-0 items-center gap-2 font-mono tabular-nums">
{typeof item.additions === "number" ? (
<span className="text-emerald-500">+{item.additions}</span>
) : null}
{typeof item.deletions === "number" ? (
<span className="text-rose-500">−{item.deletions}</span>
) : null}
</span>
) : null}
</div>
);
}
function TraceIcon({ kind }: { kind: AgentActivityTrace["kind"] }) {
if (kind === "thinking") return <Sparkles className="size-4" />;
if (kind === "message") return <MessageSquare className="size-4" />;
if (kind === "write") return <PencilLine className="size-4" />;
if (kind === "run") return <SquareTerminal className="size-4" />;
if (kind === "read") return <ImageIcon className="size-4" />;
return <Wrench className="size-4" />;
}
function TraceRow({ item }: { item: AgentActivityTrace }) {
return (
<div className="grid min-h-8 grid-cols-[1rem_auto_minmax(0,1fr)] items-center gap-2.5 rounded-md px-1.5 py-0.5">
<span
aria-hidden="true"
className="grid size-4 place-items-center text-muted-foreground/70"
>
{item.icon ?? <TraceIcon kind={item.kind} />}
</span>
<span className="font-medium text-foreground/90">{item.label}</span>
{item.detail ? (
<span className="min-w-0 truncate rounded-lg bg-muted/80 px-2.5 py-1 font-mono text-xs text-muted-foreground/70">
{item.detail}
</span>
) : (
<span />
)}
</div>
);
}
export function ActivityRow({ item }: { item: AgentActivityItem }) {
if (item.type === "text") return <TextRow item={item} />;
if (item.type === "search") return <SearchRow item={item} />;
if (item.type === "tool") return <ToolRow item={item} />;
if (item.type === "trace") return <TraceRow item={item} />;
return <StepRow item={item} />;
}
components/agents/agent-activity/types.ts
import type { ReactNode } from "react";
export type AgentActivityStatus = "working" | "complete";
export type AgentStepStatus = "pending" | "active" | "complete";
export interface AgentActivityStep {
id: string;
type: "step";
label: ReactNode;
status?: AgentStepStatus;
meta?: ReactNode;
}
export interface AgentActivityText {
id: string;
type: "text";
content: ReactNode;
}
export interface AgentSearchResult {
id: string;
title: ReactNode;
domain?: ReactNode;
url?: string;
icon?: ReactNode;
}
export interface AgentActivitySearch {
id: string;
type: "search";
query: ReactNode;
results?: AgentSearchResult[];
moreCount?: number;
}
export interface AgentActivityTool {
id: string;
type: "tool";
action: "read" | "edit" | "run" | (string & {});
target: ReactNode;
additions?: number;
deletions?: number;
}
export type AgentTraceKind =
| "thinking"
| "message"
| "write"
| "run"
| "read"
| (string & {});
export interface AgentActivityTrace {
id: string;
type: "trace";
kind: AgentTraceKind;
label: ReactNode;
detail?: ReactNode;
icon?: ReactNode;
}
export type AgentActivityItem =
| AgentActivityStep
| AgentActivityText
| AgentActivitySearch
| AgentActivityTool
| AgentActivityTrace;
export type AgentActivityContentType = AgentActivityItem["type"] | "mixed";
export interface AgentActivityProps {
/** Chronological activity entries. Append or update items as events stream. */
items: AgentActivityItem[];
/** Expected activity kind before the first streamed item arrives. */
contentType?: AgentActivityContentType;
/** Current run phase. Active runs always stay expanded. */
status?: AgentActivityStatus;
/** Elapsed run time, in seconds. Used by the step-only summary. */
duration?: number;
/** Controlled expanded state used after the run completes. */
open?: boolean;
/** Initial expanded state used after the run completes. */
defaultOpen?: boolean;
/** Called when the completed activity disclosure changes state. */
onOpenChange?: (open: boolean) => void;
/** Collapse the disclosure when status changes from working to complete. */
collapseOnComplete?: boolean;
/** Optional label shown while the run is active. */
activeLabel?: ReactNode;
/** Optional completed summary. Derived from the item types by default. */
summary?: ReactNode;
/** Optional renderer for the contents of the active status row. */
renderWorkingStatus?: (context: {
label: ReactNode;
duration: number;
}) => ReactNode;
/** Optional renderer for the contents before the built-in disclosure chevron. */
renderCompletedStatus?: (context: {
summary: ReactNode;
duration: number;
}) => ReactNode;
/** Maximum visible activity height before the stream begins gliding. */
maxHeight?: number;
className?: string;
contentClassName?: string;
}
components/agents/agent-disclosure.tsx
"use client";
import { motion, type HTMLMotionProps, useReducedMotion } from "motion/react";
import type { CSSProperties } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface AgentDisclosureProps
extends Omit<HTMLMotionProps<"div">, "animate" | "initial"> {
open: boolean;
openHeight?: CSSProperties["height"];
}
/** Shared transform-only reveal for collapsible agent content. */
export function AgentDisclosure({
open,
openHeight = "auto",
className,
style,
transition,
...props
}: AgentDisclosureProps) {
const reduce = useReducedMotion() ?? false;
return (
<motion.div
{...props}
aria-hidden={!open}
inert={!open}
initial={false}
animate={
reduce
? { opacity: open ? 1 : 0 }
: {
opacity: open ? 1 : 0,
clipPath: open ? "inset(0 0 0% 0)" : "inset(0 0 100% 0)",
y: open ? 0 : -4,
}
}
transition={
transition ?? {
duration: reduce ? 0 : open ? 0.22 : 0.14,
ease: EASE_OUT,
}
}
className={cn("overflow-hidden", className)}
style={{
...style,
height: open ? openHeight : 0,
pointerEvents: open ? undefined : "none",
transformOrigin: "top",
}}
/>
);
}
components/agents/loading-states/thinking-shimmer.tsx
import type { ReactNode } from "react";
import { TextShimmer } from "@/components/motion/text-shimmer";
import { cn } from "@/lib/utils";
export interface ThinkingShimmerProps {
/** Loading message shown to the user. */
children?: ReactNode;
/** Seconds taken for one shimmer pass. */
duration?: number;
className?: string;
}
export function ThinkingShimmer({
children = "Thinking…",
duration = 1.8,
className,
}: ThinkingShimmerProps) {
return (
<TextShimmer
as="span"
duration={duration}
className={cn("font-medium", className)}
>
{children}
</TextShimmer>
);
}
lib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
components/motion/text-shimmer.tsx
import { cn } from "@/lib/utils";
import type { ElementType, ReactNode } from "react";
import {
TEXT_SHIMMER_CLASS_NAME,
TEXT_SHIMMER_KEYFRAMES,
textShimmerStyle,
} from "@/lib/text-shimmer";
export interface TextShimmerProps {
children: ReactNode;
as?: ElementType;
duration?: number;
className?: string;
}
export function TextShimmer({ children, as: Comp = "span", duration = 2.5, className }: TextShimmerProps) {
return (
<>
<style>
{TEXT_SHIMMER_KEYFRAMES}
</style>
<Comp
style={textShimmerStyle(duration)}
className={cn(
"inline-block",
TEXT_SHIMMER_CLASS_NAME,
className,
)}
>
{children}
</Comp>
</>
);
}
lib/text-shimmer.ts
import type { CSSProperties } from "react";
// The reduced-motion rule travels with the component. app/globals.css calms CSS
// animation globally, but the registry bundles imports only, so an installed
// copy never receives that reset.
//
// `!important` because the sweep is an inline style, which outranks a plain rule
// in a media query. It selects a marker class carried by TEXT_SHIMMER_CLASS_NAME
// so it also reaches consumers that build their own span out of these exports.
export const TEXT_SHIMMER_KEYFRAMES =
"@keyframes beui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}" +
"@media (prefers-reduced-motion: reduce){.beui-text-shimmer{animation:none !important}}";
export const TEXT_SHIMMER_CLASS_NAME =
"beui-text-shimmer bg-[length:200%_100%] bg-clip-text text-transparent bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]";
export function textShimmerStyle(duration: number): CSSProperties {
return {
animation: `beui-text-shimmer ${duration}s linear infinite`,
};
}