Agent Activity
One adaptive activity stream for reasoning, searches, tool calls, structured execution traces, or a chronological mix.
Streaming Text
index.tsxStreams freeform reasoning text into the capped viewport and keeps the completed log available behind a timed disclosure.
"use client";
import { RotateCcw } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import {
AgentActivity,
type AgentActivityItem,
} from "@/components/agents/agent-activity";
const REASONING = [
"Reading the request and separating the content model from its presentation.",
"The activity shell can stay consistent while each event supplies its own compact renderer.",
"Text remains freeform so partial tokens can update without recreating the surrounding timeline.",
"As each sentence wraps, the measured stream moves upward through a single transform instead of repeatedly jumping the native scroll position.",
"Older context stays available above the fold while the newest tokens remain crisp at the bottom edge.",
"Once the run finishes, the viewport switches from automatic following to ordinary user-controlled scrolling.",
"Opening the completed disclosure returns to the beginning so the reasoning can be read in order.",
"The capped viewport follows the latest sentence and preserves the full log after completion.",
].join("\n");
const CHARACTERS_PER_SECOND = 90;
const STREAM_SECONDS = REASONING.length / CHARACTERS_PER_SECOND;
function StreamingTextDemo() {
const reduce = useReducedMotion() ?? false;
const [stream, setStream] = useState("");
const [complete, setComplete] = useState(false);
useEffect(() => {
if (reduce) {
setStream(REASONING);
setComplete(true);
return;
}
const startedAt = performance.now();
let frame = 0;
let completionTimer: number | undefined;
const streamNextFrame = (now: number) => {
const cursor = Math.min(
REASONING.length,
Math.floor(((now - startedAt) / 1000) * CHARACTERS_PER_SECOND),
);
const next = REASONING.slice(0, cursor);
setStream((current) => (current === next ? current : next));
if (cursor === REASONING.length) {
completionTimer = window.setTimeout(() => setComplete(true), 500);
} else {
frame = requestAnimationFrame(streamNextFrame);
}
};
frame = requestAnimationFrame(streamNextFrame);
return () => {
cancelAnimationFrame(frame);
if (completionTimer) window.clearTimeout(completionTimer);
};
}, [reduce]);
const items: AgentActivityItem[] = stream
.split("\n")
.filter(Boolean)
.map((content, index) => ({
id: `reasoning-${index}`,
type: "text",
content,
}));
return (
<AgentActivity
items={items}
contentType="text"
status={complete ? "complete" : "working"}
duration={STREAM_SECONDS}
defaultOpen={reduce}
collapseOnComplete={!reduce}
maxHeight={180}
/>
);
}
export function AgentActivityTextPreview() {
const [run, setRun] = useState(0);
return (
<div className="relative h-[330px] w-full max-w-lg">
<StreamingTextDemo key={run} />
<button
type="button"
onClick={() => setRun((current) => current + 1)}
className="absolute bottom-0 left-0 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3" />
Replay
</button>
</div>
);
}
"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,
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"
>
<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">{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>
);
}
Reasoning Steps
index.tsxShows completed, active, and pending reasoning steps with optional trailing metadata.
"use client";
import { RotateCcw } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { AgentActivity } from "@/components/agents/agent-activity";
const STEPS = [
{ id: "brief", label: "Reading the product brief" },
{ id: "patterns", label: "Mapping the interaction patterns" },
{
id: "states",
label: "Connecting the loading and completion states",
meta: "3 states",
},
{ id: "verify", label: "Verifying the final behavior" },
];
function StepsDemo() {
const reduce = useReducedMotion() ?? false;
const [visible, setVisible] = useState(reduce ? STEPS.length : 1);
const [settled, setSettled] = useState(false);
const [complete, setComplete] = useState(false);
useEffect(() => {
if (reduce) {
setVisible(STEPS.length);
setSettled(true);
setComplete(true);
return;
}
const stepTimers = STEPS.slice(1).map((_, index) =>
window.setTimeout(() => setVisible(index + 2), 850 + index * 800),
);
const settleTimer = window.setTimeout(() => setSettled(true), 3300);
const completeTimer = window.setTimeout(() => setComplete(true), 4200);
return () => {
stepTimers.forEach(window.clearTimeout);
window.clearTimeout(settleTimer);
window.clearTimeout(completeTimer);
};
}, [reduce]);
return (
<AgentActivity
status={complete ? "complete" : "working"}
contentType="step"
duration={4.2}
defaultOpen={reduce}
collapseOnComplete={!reduce}
maxHeight={220}
items={STEPS.slice(0, visible).map((step, index) => ({
...step,
type: "step" as const,
status:
settled || index < visible - 1
? ("complete" as const)
: ("active" as const),
}))}
/>
);
}
export function AgentActivityStepsPreview() {
const [run, setRun] = useState(0);
return (
<div className="relative h-[330px] w-full max-w-lg">
<StepsDemo key={run} />
<button
type="button"
onClick={() => setRun((current) => current + 1)}
className="absolute bottom-0 left-0 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3" />
Replay
</button>
</div>
);
}
"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,
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"
>
<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">{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>
);
}
Web Search
index.tsxPresents a search query, progressively rendered result rows, and an overflow count.
"use client";
import { RotateCcw } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import {
AgentActivity,
type AgentSearchResult,
} from "@/components/agents/agent-activity";
function GoogleMark() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-3.5"
fill="currentColor"
>
<path d="M21.35 12.23c0-.65-.06-1.28-.17-1.89H12v3.79h5.25a4.5 4.5 0 0 1-1.95 2.87v2.46h3.16c1.85-1.71 2.89-4.22 2.89-7.23Z" />
<path d="M12 21.75c2.64 0 4.86-.87 6.46-2.29L15.3 17c-.87.58-1.98.92-3.3.92a5.7 5.7 0 0 1-5.35-3.93H3.39v2.54A9.75 9.75 0 0 0 12 21.75Z" />
<path d="M6.65 13.99A5.85 5.85 0 0 1 6.34 12c0-.69.12-1.36.31-1.99V7.47H3.39A9.76 9.76 0 0 0 2.25 12c0 1.63.39 3.18 1.14 4.53l3.26-2.54Z" />
<path d="M12 6.08c1.44 0 2.73.49 3.75 1.47l2.78-2.78A9.34 9.34 0 0 0 12 2.25a9.75 9.75 0 0 0-8.61 5.22l3.26 2.54A5.7 5.7 0 0 1 12 6.08Z" />
</svg>
);
}
function GitHubMark() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-3.5"
fill="currentColor"
>
<path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.87c-2.78.6-3.37-1.18-3.37-1.18-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.35 1.09 2.92.83.09-.65.35-1.09.64-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02A9.58 9.58 0 0 1 12 6.82c.85 0 1.71.11 2.51.34 1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.39.1 2.64.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.85v2.77c0 .27.18.58.69.48A10 10 0 0 0 12 2Z" />
</svg>
);
}
function WikipediaMark() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-3.5"
fill="currentColor"
>
<path d="M1.5 4h6v1.2H6l4.08 10.73 1.26-3.12L8.45 5.2H7.2V4h6v1.2h-1.47l1.66 4.57 1.82-4.57H13.8V4h4.9v1.2h-1.45l-3.19 7.92 1.08 2.81L19.48 5.2H18V4h4.5v1.2h-1.42L15.25 20h-1.2l-1.91-4.86L10.18 20H8.92L3.25 5.2H1.5V4Z" />
</svg>
);
}
function VercelMark() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-3.5"
fill="currentColor"
>
<path d="m12 3 10 18H2L12 3Z" />
</svg>
);
}
const SEARCH_RESULTS: AgentSearchResult[] = [
{
id: "google",
title: "Google for Developers",
domain: "developers.google.com",
icon: <GoogleMark />,
},
{
id: "github",
title: "GitHub",
domain: "github.com",
icon: <GitHubMark />,
},
{
id: "wikipedia",
title: "Wikipedia",
domain: "wikipedia.org",
icon: <WikipediaMark />,
},
{
id: "vercel",
title: "Vercel Docs",
domain: "vercel.com/docs",
icon: <VercelMark />,
},
];
function SearchDemo() {
const reduce = useReducedMotion() ?? false;
const [visible, setVisible] = useState(0);
const [complete, setComplete] = useState(false);
useEffect(() => {
if (reduce) {
setVisible(SEARCH_RESULTS.length);
setComplete(true);
return;
}
const resultTimers = SEARCH_RESULTS.map((_, index) =>
window.setTimeout(() => setVisible(index + 1), 650 + index * 650),
);
const completeTimer = window.setTimeout(() => setComplete(true), 3900);
return () => {
resultTimers.forEach(window.clearTimeout);
window.clearTimeout(completeTimer);
};
}, [reduce]);
return (
<AgentActivity
status={complete ? "complete" : "working"}
contentType="search"
defaultOpen={reduce}
collapseOnComplete={!reduce}
maxHeight={220}
items={[
{
id: "search",
type: "search",
query: "accessible animation patterns for React",
results: SEARCH_RESULTS.slice(0, visible),
moreCount: visible === SEARCH_RESULTS.length ? 7 : undefined,
},
]}
/>
);
}
export function AgentActivitySearchPreview() {
const [run, setRun] = useState(0);
return (
<div className="relative h-[330px] w-full max-w-lg">
<SearchDemo key={run} />
<button
type="button"
onClick={() => setRun((current) => current + 1)}
className="absolute bottom-0 left-0 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3" />
Replay
</button>
</div>
);
}
"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,
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"
>
<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">{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>
);
}
Tool Calls
index.tsxSummarizes read, edit, and run events with monospace targets and optional line-change counts.
"use client";
import { RotateCcw } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import {
AgentActivity,
type AgentActivityItem,
} from "@/components/agents/agent-activity";
const TOOLS: AgentActivityItem[] = [
{
id: "read",
type: "tool",
action: "read",
target: "campaign-notes.md",
},
{
id: "edit",
type: "tool",
action: "edit",
target: "launch-plan.ts",
additions: 42,
deletions: 8,
},
{
id: "run",
type: "tool",
action: "run",
target: "bun test launch",
},
];
function ToolsDemo() {
const reduce = useReducedMotion() ?? false;
const [visible, setVisible] = useState(0);
const [complete, setComplete] = useState(false);
useEffect(() => {
if (reduce) {
setVisible(TOOLS.length);
setComplete(true);
return;
}
const toolTimers = TOOLS.map((_, index) =>
window.setTimeout(() => setVisible(index + 1), 550 + index * 850),
);
const completeTimer = window.setTimeout(() => setComplete(true), 3600);
return () => {
toolTimers.forEach(window.clearTimeout);
window.clearTimeout(completeTimer);
};
}, [reduce]);
return (
<AgentActivity
status={complete ? "complete" : "working"}
contentType="tool"
defaultOpen={reduce}
collapseOnComplete={!reduce}
maxHeight={220}
items={TOOLS.slice(0, visible)}
/>
);
}
export function AgentActivityToolsPreview() {
const [run, setRun] = useState(0);
return (
<div className="relative h-[330px] w-full max-w-lg">
<ToolsDemo key={run} />
<button
type="button"
onClick={() => setRun((current) => current + 1)}
className="absolute bottom-0 left-0 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3" />
Replay
</button>
</div>
);
}
"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,
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"
>
<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">{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>
);
}
Mixed Activity
index.tsxStreams reasoning, search, and tool events in one chronological run while the viewport smoothly follows new work.
"use client";
import { RotateCcw } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import {
AgentActivity,
type AgentActivityItem,
} from "@/components/agents/agent-activity";
const ACTIVE_BRIEF: AgentActivityItem = {
id: "brief",
type: "step",
label: "Reading the launch brief",
status: "active",
};
const COMPLETE_BRIEF: AgentActivityItem = {
...ACTIVE_BRIEF,
status: "complete",
};
const PENDING_SEARCH: AgentActivityItem = {
id: "search",
type: "search",
query: "independent coffee roasters in Portland",
results: [],
};
const COMPLETE_SEARCH: AgentActivityItem = {
...PENDING_SEARCH,
results: [
{ id: "heart", title: "Heart Coffee", domain: "heartroasters.com" },
{
id: "coava",
title: "Coava Coffee",
domain: "coavacoffee.com",
},
{
id: "upper-left",
title: "Upper Left Roasters",
domain: "upperleftroasters.com",
},
],
moreCount: 5,
};
const READ_TOOL: AgentActivityItem = {
id: "read",
type: "tool",
action: "read",
target: "campaign-notes.md",
};
const ACTIVITY_FRAMES: AgentActivityItem[][] = [
[ACTIVE_BRIEF],
[COMPLETE_BRIEF, PENDING_SEARCH],
[COMPLETE_BRIEF, COMPLETE_SEARCH],
[COMPLETE_BRIEF, COMPLETE_SEARCH, READ_TOOL],
[
COMPLETE_BRIEF,
COMPLETE_SEARCH,
READ_TOOL,
{
id: "edit",
type: "tool",
action: "edit",
target: "launch-plan.ts",
additions: 42,
deletions: 8,
},
{ id: "run", type: "tool", action: "run", target: "bun test launch" },
{
id: "verify",
type: "step",
label: "Checking the final campaign plan",
status: "complete",
},
],
];
function ActivityDemo() {
const reduce = useReducedMotion() ?? false;
const [frame, setFrame] = useState(0);
const [complete, setComplete] = useState(false);
useEffect(() => {
if (reduce) {
setFrame(ACTIVITY_FRAMES.length - 1);
setComplete(true);
return;
}
const timers = ACTIVITY_FRAMES.slice(1).map((_, index) =>
window.setTimeout(() => setFrame(index + 1), 850 + index * 1050),
);
const finalFrameAt = 850 + (ACTIVITY_FRAMES.length - 2) * 1050;
const completeTimer = window.setTimeout(
() => setComplete(true),
finalFrameAt + 900,
);
return () => {
timers.forEach(window.clearTimeout);
window.clearTimeout(completeTimer);
};
}, [reduce]);
return (
<AgentActivity
items={ACTIVITY_FRAMES[frame]}
status={complete ? "complete" : "working"}
duration={5.1}
defaultOpen={reduce}
collapseOnComplete={!reduce}
maxHeight={220}
/>
);
}
export function AgentActivityMixedPreview() {
const [run, setRun] = useState(0);
return (
<div className="relative h-[330px] w-full max-w-xl">
<ActivityDemo key={run} />
<button
type="button"
onClick={() => setRun((current) => current + 1)}
className="absolute bottom-0 left-0 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3" />
Replay
</button>
</div>
);
}
"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,
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"
>
<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">{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>
);
}
Agent Trace
index.tsxStreams messages and structured actions into a compact execution ledger, then summarizes the completed run by tool-call and message counts.
"use client";
import { RotateCcw } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import {
AgentActivity,
type AgentActivityItem,
} from "@/components/agents/agent-activity";
const TRACE_ITEMS: AgentActivityItem[] = [
{
id: "plan",
type: "trace",
kind: "thinking",
label: "Thinking",
detail: "Mapping the interaction flow…",
},
{
id: "decision",
type: "trace",
kind: "message",
label: "Decision",
detail: "Use one compact disclosure",
},
{
id: "write",
type: "trace",
kind: "write",
label: "Draft component",
detail: "components/agents/run-log.tsx",
},
{
id: "verify",
type: "trace",
kind: "run",
label: "Validate types",
detail: "bun run typecheck",
},
{
id: "inspect",
type: "trace",
kind: "read",
label: "Inspect preview",
detail: "activity-preview.png",
},
];
function AgentTraceDemo() {
const reduce = useReducedMotion() ?? false;
const [visible, setVisible] = useState(reduce ? TRACE_ITEMS.length : 0);
const [complete, setComplete] = useState(reduce);
useEffect(() => {
if (reduce) return;
const timers = TRACE_ITEMS.map((_, index) =>
window.setTimeout(() => setVisible(index + 1), 250 + index * 650),
);
timers.push(
window.setTimeout(
() => setComplete(true),
250 + TRACE_ITEMS.length * 650,
),
);
return () => timers.forEach(window.clearTimeout);
}, [reduce]);
return (
<AgentActivity
items={TRACE_ITEMS.slice(0, visible)}
contentType="trace"
status={complete ? "complete" : "working"}
activeLabel="Running the agent trace…"
defaultOpen={reduce}
collapseOnComplete={!reduce}
maxHeight={190}
/>
);
}
export function AgentTracePreview() {
const [run, setRun] = useState(0);
return (
<div className="relative h-[300px] w-full max-w-xl">
<AgentTraceDemo key={run} />
<button
type="button"
onClick={() => setRun((value) => value + 1)}
className="absolute bottom-0 left-0 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3" />
Replay
</button>
</div>
);
}
"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,
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"
>
<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">{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>
);
}
API Reference
itemsAgentActivityItem[]Chronological activity entries. Append or update items as events stream.
—contentType?"step" | "text" | "search" | "tool" | "trace" | "mixed"Expected activity kind before the first streamed item arrives.
—status?"working" | "complete"Current run phase. Active runs always stay expanded.
workingduration?numberElapsed run time, in seconds. Used by the step-only summary.
0open?booleanControlled expanded state used after the run completes.
—defaultOpen?booleanInitial expanded state used after the run completes.
falseonOpenChange?((open: boolean) => void)Called when the completed activity disclosure changes state.
—collapseOnComplete?booleanCollapse the disclosure when status changes from working to complete.
trueactiveLabel?ReactNodeOptional label shown while the run is active.
—summary?ReactNodeOptional completed summary. Derived from the item types by default.
—maxHeight?numberMaximum visible activity height before the stream begins gliding.
208className?string—contentClassName?string—Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// 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;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
import type { CSSProperties } from "react";
export const TEXT_SHIMMER_KEYFRAMES =
"@keyframes beui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}";
export const TEXT_SHIMMER_CLASS_NAME =
"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`,
};
}
Copy the source code
"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,
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"
>
<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">{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>
);
}
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} />;
}
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;
/** Maximum visible activity height before the stream begins gliding. */
maxHeight?: number;
className?: string;
contentClassName?: string;
}
"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",
}}
/>
);
}
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>
);
}
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>
</>
);
}
Composition
Render progressive work as message content so it remains attached to the turn that produced it.
Message
└── MessageContent
└── AgentActivityNote: Agent Loading States covers the interval before the first structured event. Todo List separates durable planned work from transient events. Tool Result expands one completed execution into inspectable output.
How it works
Agent activity explains how work is progressing without exposing an unfiltered internal transcript. Events should arrive chronologically, remain easy to scan, and reduce to a useful summary when the run finishes.
Updated