From 208f000cc50bda35eb6509eb696ecd85347cbf60 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Thu, 30 Jul 2026 13:58:32 +0530 Subject: [PATCH 01/33] feat: add agent loading states --- AGENTS.md | 10 +- app/components/[category]/page.tsx | 9 + .../agents/loading-states/agent-progress.tsx | 105 +++++++ components/agents/loading-states/index.ts | 13 + .../agents/loading-states/reasoning-text.tsx | 276 ++++++++++++++++++ .../loading-states/thinking-shimmer.tsx | 27 ++ components/app/chrome/header-tabs.tsx | 9 +- components/app/chrome/mobile-nav.tsx | 14 +- components/app/chrome/site-header.tsx | 17 +- components/app/chrome/site-sidebar.tsx | 16 +- components/motion/text-shimmer.tsx | 13 +- .../agents/agent-progress.preview.tsx | 13 + .../agents/reasoning-text.preview.tsx | 57 ++++ .../agents/thinking-shimmer.preview.tsx | 11 + components/previews/index.tsx | 15 + lib/component-dates.ts | 4 + lib/component-markdown.ts | 8 +- lib/registry.ts | 71 +++++ lib/text-shimmer.ts | 13 + tests/a11y.test.tsx | 20 ++ 20 files changed, 704 insertions(+), 17 deletions(-) create mode 100644 components/agents/loading-states/agent-progress.tsx create mode 100644 components/agents/loading-states/index.ts create mode 100644 components/agents/loading-states/reasoning-text.tsx create mode 100644 components/agents/loading-states/thinking-shimmer.tsx create mode 100644 components/previews/agents/agent-progress.preview.tsx create mode 100644 components/previews/agents/reasoning-text.preview.tsx create mode 100644 components/previews/agents/thinking-shimmer.preview.tsx create mode 100644 lib/text-shimmer.ts diff --git a/AGENTS.md b/AGENTS.md index df568d5..d65777c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,10 +17,10 @@ Prefer `typecheck` + `lint` for quick verification. Do not start the dev server ## Layout -- `components/motion/` — the library. One file per component; multi-file widgets get a folder (`swap/`, `button/`). +- `components/motion/` and `components/agents/` — the library. One file per component; multi-file widgets get a folder (`swap/`, `button/`, `loading-states/`). - `components/previews/` — demo per component, registered in `components/previews/index.tsx`. Previews ship through the registry too. - `components/app/` — site chrome (header, hero, dock, code blocks). Not part of the library. -- `lib/registry.ts` — component catalog (slugs, files, examples). Two categories: `motion` (display name "Components", primitives) and `blocks` (composed widgets: swap, dynamic island, command palette, expandable action bar). Blocks emit `registry:block` shadcn items. Preview files live under `components/previews//`. `lib/registry-server.ts` builds registry items by following each file's `@/` and relative imports and bundling everything it finds. Internal imports are therefore safe and encouraged; a component that imports `@/lib/ease` ships `lib/ease.ts` with it. +- `lib/registry.ts` — component catalog (slugs, files, examples). Three categories: `motion` (display name "Components", primitives), `agents` (AI and agent interface primitives), and `blocks` (composed widgets: swap, dynamic island, command palette, expandable action bar). Blocks emit `registry:block` shadcn items. Preview files live under `components/previews//`. `lib/registry-server.ts` builds registry items by following each file's `@/` and relative imports and bundling everything it finds. Internal imports are therefore safe and encouraged; a component that imports `@/lib/ease` ships `lib/ease.ts` with it. - `app/r/*` — registry endpoints (shadcn items, raw source, index). beUI is listed in shadcn's official registry directory as the `@beui` namespace with URL template `https://beui.dev/r/{name}.json` — that path shape and existing install slugs are public contract; never break or rename them. - `lib/ease.ts` — all motion tokens. - `scripts/check-registry.ts` — validates the catalog. @@ -70,6 +70,12 @@ Before building a new component, check this list. If it exists, import it. If it | `magnetic` | `components/motion/magnetic.tsx` | Cursor-attracted magnetic pull wrapper | | `scroll-animation` | `components/motion/smooth-scroll.tsx`, `scroll-progress.tsx`, `parallax.tsx`, `scroll-to.tsx`, `scroll-reveal.tsx` | Scroll-driven motion group (variants install as `@beui/smooth-scroll`, `@beui/scroll-progress`, `@beui/parallax`, `@beui/scroll-to`, `@beui/scroll-reveal`). **Smooth Scroll**: Lenis provider (`root` page / `root={false}` contained) + `useSmoothScroll` hook (offset/progress/velocity, `scrollTo`), reduced-motion native. **Scroll Progress**: bar or ring reading `useSmoothScroll().progress`. **Parallax**: drifts children at a speed factor across the viewport, either axis. **Scroll To**: button that smooth-scrolls to a target via the provider. **Scroll Reveal**: spring slide + blur reveal on viewport enter | +### AI Agents (`agents` category — agent interface primitives) + +| slug | file | what it does | +|---|---|---| +| `loading-states` | `components/agents/loading-states/` | Three agent loading states: `ThinkingShimmer`, compact live-timed `AgentProgress`, and `ReasoningText` with an ASCII loader, shimmering phrases, and cascade/swap/scramble styles; each variant is independently installable and reduced-motion safe | + ### Blocks (`blocks` category — composed product widgets) | slug | file | what it does | diff --git a/app/components/[category]/page.tsx b/app/components/[category]/page.tsx index 5f01ea4..86a45e6 100644 --- a/app/components/[category]/page.tsx +++ b/app/components/[category]/page.tsx @@ -25,6 +25,15 @@ const categoryContent = { "Browse composed interfaces for uploads, navigation, trading, scheduling, notifications, and more. Every block includes a live preview, install command, TypeScript source, and implementation details.", allLabel: "All animated blocks", }, + agents: { + title: "AI Agent Components — Animated React AI Interfaces", + heading: "Animated AI agent components", + description: + "Build clear, responsive AI experiences with open-source React components for agent reasoning, progress, tool activity, and conversation states.", + supportingText: + "Each agent component is designed for long-running, interruptible AI work and includes a live preview, shadcn install command, TypeScript source, API reference, and reduced-motion support.", + allLabel: "All agent components", + }, } as const; export function generateStaticParams() { diff --git a/components/agents/loading-states/agent-progress.tsx b/components/agents/loading-states/agent-progress.tsx new file mode 100644 index 0000000..f5b337e --- /dev/null +++ b/components/agents/loading-states/agent-progress.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { motion, useReducedMotion } from "motion/react"; +import { useEffect, useState } from "react"; +import { EASE_IN_OUT } from "@/lib/ease"; +import { cn } from "@/lib/utils"; + +const GRID_CELLS = [ + { id: "top-left", delay: 0 }, + { id: "top-center", delay: 0.14 }, + { id: "top-right", delay: 0.28 }, + { id: "middle-left", delay: 0.42 }, + { id: "middle-center", delay: 0.56 }, + { id: "middle-right", delay: 0.7 }, + { id: "bottom-left", delay: 0.84 }, + { id: "bottom-center", delay: 0.98 }, + { id: "bottom-right", delay: 1.12 }, +]; + +export interface AgentProgressProps { + /** Verb describing the agent's current activity. */ + label?: string; + /** Controlled elapsed time in seconds. */ + elapsedSeconds?: number; + /** Starting time for the internal timer, in seconds. */ + initialSeconds?: number; + /** Whether the internal timer should advance. Ignored when elapsedSeconds is provided. */ + running?: boolean; + className?: string; +} + +function formatElapsed(totalSeconds: number) { + const safeSeconds = Math.max(0, totalSeconds); + const minutes = Math.floor(safeSeconds / 60); + const seconds = (safeSeconds % 60).toFixed(1); + return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; +} + +export function AgentProgress({ + label = "Churning", + elapsedSeconds, + initialSeconds = 0, + running = true, + className, +}: AgentProgressProps) { + const reduce = useReducedMotion() ?? false; + const [internalSeconds, setInternalSeconds] = useState(initialSeconds); + + useEffect(() => { + if (elapsedSeconds !== undefined || !running) return; + + const startedAt = performance.now() - initialSeconds * 1000; + const timer = window.setInterval(() => { + setInternalSeconds((performance.now() - startedAt) / 1000); + }, 100); + + return () => window.clearInterval(timer); + }, [elapsedSeconds, initialSeconds, running]); + + const elapsed = elapsedSeconds ?? internalSeconds; + + return ( + + + {label} + + + ); +} diff --git a/components/agents/loading-states/index.ts b/components/agents/loading-states/index.ts new file mode 100644 index 0000000..b918837 --- /dev/null +++ b/components/agents/loading-states/index.ts @@ -0,0 +1,13 @@ +export { + AgentProgress, + type AgentProgressProps, +} from "./agent-progress"; +export { + ReasoningText, + type ReasoningTextProps, + type ReasoningTextVariant, +} from "./reasoning-text"; +export { + ThinkingShimmer, + type ThinkingShimmerProps, +} from "./thinking-shimmer"; diff --git a/components/agents/loading-states/reasoning-text.tsx b/components/agents/loading-states/reasoning-text.tsx new file mode 100644 index 0000000..f571961 --- /dev/null +++ b/components/agents/loading-states/reasoning-text.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { Loader } from "@/components/motion/loader"; +import { EASE_OUT, SPRING_SWAP } from "@/lib/ease"; +import { + TEXT_SHIMMER_CLASS_NAME, + TEXT_SHIMMER_KEYFRAMES, + textShimmerStyle, +} from "@/lib/text-shimmer"; +import { cn } from "@/lib/utils"; + +const DEFAULT_PHRASES = [ + "Thinking", + "Reading the context", + "Connecting the details", + "Forming a response", +]; + +const SCRAMBLE_GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/"; +const CASCADE_STAGGER = 0.025; + +export type ReasoningTextVariant = "cascade" | "swap" | "scramble"; + +export interface ReasoningTextProps { + /** Phrases cycled through while the agent works. */ + phrases?: string[]; + /** Animation used when the active phrase changes. */ + variant?: ReasoningTextVariant; + /** Milliseconds each phrase remains visible. */ + interval?: number; + /** Seconds taken for one shimmer pass. */ + shimmerDuration?: number; + /** Optional leading visual. Defaults to a terminal-style ASCII loader. */ + indicator?: ReactNode; + className?: string; +} + +type PhraseProps = { + phrase: string; + reduce: boolean; + shimmerDuration: number; +}; + +function CascadePhrase({ + phrase, + reduce, + shimmerDuration, +}: PhraseProps) { + const text = `${phrase}…`; + + if (reduce) { + return ( + + {text} + + ); + } + + return ( + + + {text.split("").map((character, characterIndex) => ( + ({ + opacity: 1, + y: "0%", + transition: { ...SPRING_SWAP, delay }, + }), + exit: (delay: number) => ({ + opacity: 0, + y: "-100%", + transition: { + duration: 0.14, + ease: EASE_OUT, + delay: delay * 0.45, + }, + }), + }} + className={cn( + "inline-block whitespace-pre will-change-[opacity,transform]", + TEXT_SHIMMER_CLASS_NAME, + )} + style={textShimmerStyle(shimmerDuration)} + > + {character} + + ))} + + + ); +} + +function SwapPhrase({ phrase, reduce, shimmerDuration }: PhraseProps) { + return ( + + + {phrase}… + + + ); +} + +function ScramblePhrase({ + phrase, + reduce, + shimmerDuration, +}: PhraseProps) { + const target = `${phrase}…`; + const [display, setDisplay] = useState(target); + const mounted = useRef(false); + + useEffect(() => { + if (!mounted.current) { + mounted.current = true; + return; + } + + if (reduce) { + setDisplay(target); + return; + } + + const characters = target.split(""); + const startedAt = performance.now(); + const duration = Math.min(760, Math.max(420, characters.length * 32)); + let frame = 0; + let lastUpdate = 0; + + const animate = (now: number) => { + if (now - lastUpdate >= 40) { + lastUpdate = now; + const progress = Math.min((now - startedAt) / duration, 1); + const settled = Math.floor(progress * characters.length); + + setDisplay( + characters + .map((character, characterIndex) => { + if (characterIndex < settled || character === " ") { + return character; + } + return SCRAMBLE_GLYPHS[ + Math.floor(Math.random() * SCRAMBLE_GLYPHS.length) + ]; + }) + .join(""), + ); + } + + if (now - startedAt < duration) { + frame = requestAnimationFrame(animate); + } else { + setDisplay(target); + } + }; + + frame = requestAnimationFrame(animate); + return () => cancelAnimationFrame(frame); + }, [reduce, target]); + + return ( + + {display} + + ); +} + +export function ReasoningText({ + phrases = DEFAULT_PHRASES, + variant = "cascade", + interval = 1800, + shimmerDuration = 2.2, + indicator, + className, +}: ReasoningTextProps) { + const reduce = useReducedMotion() ?? false; + const [index, setIndex] = useState(0); + const statusId = useId(); + const safePhrases = phrases.length > 0 ? phrases : DEFAULT_PHRASES; + const phrase = safePhrases[index % safePhrases.length]; + const longestPhrase = safePhrases.reduce((longest, current) => + current.length > longest.length ? current : longest, + ); + const phraseProps = { phrase, reduce, shimmerDuration }; + + useEffect(() => { + if (safePhrases.length < 2) return; + + const timer = window.setInterval(() => { + setIndex((current) => (current + 1) % safePhrases.length); + }, Math.max(600, interval)); + + return () => window.clearInterval(timer); + }, [interval, safePhrases.length]); + + return ( + <> + + + + + + + + {phrase} + + + + ); +} diff --git a/components/agents/loading-states/thinking-shimmer.tsx b/components/agents/loading-states/thinking-shimmer.tsx new file mode 100644 index 0000000..d12d3e9 --- /dev/null +++ b/components/agents/loading-states/thinking-shimmer.tsx @@ -0,0 +1,27 @@ +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 ( + + {children} + + ); +} diff --git a/components/app/chrome/header-tabs.tsx b/components/app/chrome/header-tabs.tsx index 9011459..effc1bd 100644 --- a/components/app/chrome/header-tabs.tsx +++ b/components/app/chrome/header-tabs.tsx @@ -6,6 +6,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/motion/tabs"; const SPACES = [ { value: "components", label: "Components", href: "/components/motion" }, { value: "blocks", label: "Blocks", href: "/components/blocks" }, + { value: "agents", label: "Agents", href: "/components/agents" }, ]; /** Top-level space switcher — the library's own Tabs in controlled mode, driven by the route. */ @@ -14,9 +15,11 @@ export function HeaderTabs({ onNavigate }: { onNavigate?: () => void }) { const router = useRouter(); const active = pathname.startsWith("/components/blocks") ? "blocks" - : pathname.startsWith("/components") - ? "components" - : ""; + : pathname.startsWith("/components/agents") + ? "agents" + : pathname.startsWith("/components") + ? "components" + : ""; return ( setOpen(false)} className={cn( "rounded-md px-3 py-1.5 text-sm transition-colors", - pathname.startsWith("/components/motion") || (pathname.startsWith("/components") && !pathname.startsWith("/components/blocks")) + pathname.startsWith("/components/motion") ? "text-foreground" : "text-muted-foreground hover:text-foreground", )} @@ -64,6 +64,18 @@ export function MobileNav() { > Blocks + setOpen(false)} + className={cn( + "rounded-md px-3 py-1.5 text-sm transition-colors", + pathname.startsWith("/components/agents") + ? "text-foreground" + : "text-muted-foreground hover:text-foreground", + )} + > + Agents + setOpen(false)} diff --git a/components/app/chrome/site-header.tsx b/components/app/chrome/site-header.tsx index f3b0872..222ab89 100644 --- a/components/app/chrome/site-header.tsx +++ b/components/app/chrome/site-header.tsx @@ -32,11 +32,9 @@ export function SiteHeader({ const [scrolled, setScrolled] = useState(false); const { setPanelOpen } = usePreferences(); const pathname = usePathname(); - const isComponents = - pathname.startsWith("/components/motion") || - (pathname.startsWith("/components") && - !pathname.startsWith("/components/blocks")); + const isComponents = pathname.startsWith("/components/motion"); const isBlocks = pathname.startsWith("/components/blocks"); + const isAgents = pathname.startsWith("/components/agents"); const isComponentsRoute = pathname.startsWith("/components"); const isPlayground = pathname.startsWith("/playground"); const isSponsors = pathname.startsWith("/sponsors"); @@ -106,6 +104,17 @@ export function SiteHeader({ > Blocks + + Agents + = { + agents: 0, + motion: 1, + blocks: 2, +}; + +const SIDEBAR_CATEGORIES = [...registry].sort( + (a, b) => + (SIDEBAR_CATEGORY_ORDER[a.slug] ?? Number.MAX_SAFE_INTEGER) - + (SIDEBAR_CATEGORY_ORDER[b.slug] ?? Number.MAX_SAFE_INTEGER), +); + function moveFirstItemsToBottom(items: T[], count: number) { return [...items.slice(count), ...items.slice(0, count)]; } @@ -69,7 +81,7 @@ export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) { ))} - {registry.map((cat) => ( + {SIDEBAR_CATEGORIES.map((cat) => (
diff --git a/components/previews/agents/agent-progress.preview.tsx b/components/previews/agents/agent-progress.preview.tsx new file mode 100644 index 0000000..a7ce1e9 --- /dev/null +++ b/components/previews/agents/agent-progress.preview.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { AgentProgress } from "@/components/agents/loading-states/agent-progress"; + +export function AgentProgressPreview() { + return ( + + ); +} diff --git a/components/previews/agents/reasoning-text.preview.tsx b/components/previews/agents/reasoning-text.preview.tsx new file mode 100644 index 0000000..02a3654 --- /dev/null +++ b/components/previews/agents/reasoning-text.preview.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { + ReasoningText, + type ReasoningTextVariant, +} from "@/components/agents/loading-states/reasoning-text"; + +const EXAMPLES: { + label: string; + variant: ReasoningTextVariant; + phrases: string[]; +}[] = [ + { + label: "Cascade", + variant: "cascade", + phrases: [ + "Thinking", + "Reading the request", + "Working through the details", + "Preparing the answer", + ], + }, + { + label: "Swap", + variant: "swap", + phrases: [ + "Thinking", + "Reading the request", + "Working through the details", + "Preparing the answer", + ], + }, + { + label: "Scramble", + variant: "scramble", + phrases: ["Thinking", "Searching", "Reasoning", "Composing"], + }, +]; + +export function ReasoningTextPreview() { + return ( +
+ {EXAMPLES.map(({ label, variant, phrases }) => ( +
+ + {label} + + +
+ ))} +
+ ); +} diff --git a/components/previews/agents/thinking-shimmer.preview.tsx b/components/previews/agents/thinking-shimmer.preview.tsx new file mode 100644 index 0000000..c09a312 --- /dev/null +++ b/components/previews/agents/thinking-shimmer.preview.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { ThinkingShimmer } from "@/components/agents/loading-states/thinking-shimmer"; + +export function ThinkingShimmerPreview() { + return ( + + Thinking… + + ); +} diff --git a/components/previews/index.tsx b/components/previews/index.tsx index 69c5e25..a247f58 100644 --- a/components/previews/index.tsx +++ b/components/previews/index.tsx @@ -4,6 +4,21 @@ import type { ComponentType } from "react"; // Every preview is a client component dragging the library + motion with it. // Lazy chunks keep a page's JS limited to the previews it actually renders. export const previews: Record = { + "agents/thinking-shimmer": dynamic(() => + import("./agents/thinking-shimmer.preview").then( + (m) => m.ThinkingShimmerPreview, + ), + ), + "agents/agent-progress": dynamic(() => + import("./agents/agent-progress.preview").then( + (m) => m.AgentProgressPreview, + ), + ), + "agents/reasoning-text": dynamic(() => + import("./agents/reasoning-text.preview").then( + (m) => m.ReasoningTextPreview, + ), + ), "blocks/infinite-masonry": dynamic(() => import("./blocks/infinite-masonry.preview").then( (m) => m.InfiniteMasonryPreview, diff --git a/lib/component-dates.ts b/lib/component-dates.ts index d6c5878..53389c6 100644 --- a/lib/component-dates.ts +++ b/lib/component-dates.ts @@ -51,6 +51,10 @@ const COMPONENT_DATES = { "motion/shader-background": { publishedAt: "2026-07-02", updatedAt: "2026-07-13" }, "motion/cylinder-carousel": { publishedAt: "2026-07-04", updatedAt: "2026-07-13" }, "motion/loader": { publishedAt: "2026-07-04", updatedAt: "2026-07-13" }, + "agents/loading-states": { + publishedAt: "2026-07-30", + updatedAt: "2026-07-30", + }, "blocks/infinite-masonry": { publishedAt: "2026-07-15", updatedAt: "2026-07-15" }, "blocks/notification-stack": { publishedAt: "2026-07-14", updatedAt: "2026-07-14" }, "blocks/knockout-bracket": { publishedAt: "2026-07-12", updatedAt: "2026-07-27" }, diff --git a/lib/component-markdown.ts b/lib/component-markdown.ts index 2cebccd..fdf1411 100644 --- a/lib/component-markdown.ts +++ b/lib/component-markdown.ts @@ -75,7 +75,13 @@ export async function buildComponentMarkdown( "---", `title: ${JSON.stringify(component.name)}`, `description: ${JSON.stringify(component.description)}`, - `category: ${JSON.stringify(categorySlug === "blocks" ? "Blocks" : "Components")}`, + `category: ${JSON.stringify( + categorySlug === "blocks" + ? "Blocks" + : categorySlug === "agents" + ? "AI Agents" + : "Components", + )}`, `publishedAt: ${JSON.stringify(dates.publishedAt)}`, `updatedAt: ${JSON.stringify(dates.updatedAt)}`, `documentation: ${JSON.stringify(pageUrl)}`, diff --git a/lib/registry.ts b/lib/registry.ts index b8f723b..7d6dfcc 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -731,6 +731,77 @@ export const registry: CategoryEntry[] = [ }, ], }, + { + slug: "agents", + name: "AI Agents", + description: + "Animated React components for agent reasoning, progress, tool activity, and conversational AI interfaces.", + components: [ + { + slug: "loading-states", + name: "Agent Loading States", + description: + "Three thoughtful loading states for AI interfaces: shimmering status text, live agent progress, and cycling reasoning phrases.", + file: "components/agents/loading-states/index.ts", + extraFiles: [ + "components/agents/loading-states/thinking-shimmer.tsx", + "components/agents/loading-states/agent-progress.tsx", + "components/agents/loading-states/reasoning-text.tsx", + ], + badge: "new", + launchedAt: "2026-07-30", + keywords: [ + "AI agent loading state", + "agent thinking animation", + "Claude thinking animation", + "AI reasoning indicator", + "LLM loading component", + "chatbot thinking indicator", + ], + examples: [ + { + slug: "reasoning-text", + name: "Reasoning Text", + description: + "Claude-inspired reasoning copy with an ASCII loader, shimmering text, and cascade, phrase-swap, or per-letter scramble transitions.", + badge: "new", + launchedAt: "2026-07-30", + installSlug: "reasoning-text", + file: "components/agents/loading-states/reasoning-text.tsx", + previewKey: "agents/reasoning-text", + previewFile: + "components/previews/agents/reasoning-text.preview.tsx", + }, + { + slug: "thinking-shimmer", + name: "Thinking Shimmer", + description: + "A quiet shimmer that keeps the agent's current status readable while work continues.", + badge: "new", + launchedAt: "2026-07-30", + installSlug: "thinking-shimmer", + file: "components/agents/loading-states/thinking-shimmer.tsx", + previewKey: "agents/thinking-shimmer", + previewFile: + "components/previews/agents/thinking-shimmer.preview.tsx", + }, + { + slug: "agent-progress", + name: "Agent Progress", + description: + "A compact activity glyph, action verb, and live tabular timer for longer-running agent work.", + badge: "new", + launchedAt: "2026-07-30", + installSlug: "agent-progress", + file: "components/agents/loading-states/agent-progress.tsx", + previewKey: "agents/agent-progress", + previewFile: + "components/previews/agents/agent-progress.preview.tsx", + }, + ], + }, + ], + }, { slug: "blocks", name: "Blocks", diff --git a/lib/text-shimmer.ts b/lib/text-shimmer.ts new file mode 100644 index 0000000..a067c48 --- /dev/null +++ b/lib/text-shimmer.ts @@ -0,0 +1,13 @@ +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`, + }; +} diff --git a/tests/a11y.test.tsx b/tests/a11y.test.tsx index 98e83b0..ec443f4 100644 --- a/tests/a11y.test.tsx +++ b/tests/a11y.test.tsx @@ -3,6 +3,9 @@ import { cleanup, render } from "@testing-library/react"; import { axe } from "jest-axe"; import type { ReactElement } from "react"; +import { AgentProgress } from "@/components/agents/loading-states/agent-progress"; +import { ReasoningText } from "@/components/agents/loading-states/reasoning-text"; +import { ThinkingShimmer } from "@/components/agents/loading-states/thinking-shimmer"; import { AnimatedBadge } from "@/components/motion/animated-badge"; import { AnimatedSidebar, @@ -59,6 +62,23 @@ afterEach(cleanup); // no violations. Add a row here when you ship a new interactive component. // Render thunks (not bare JSX) keep these out of an iterable literal. const cases: Array<[name: string, render: () => ReactElement]> = [ + ["ThinkingShimmer", () => ], + [ + "AgentProgress", + () => , + ], + [ + "ReasoningText cascade", + () => , + ], + [ + "ReasoningText swap", + () => , + ], + [ + "ReasoningText scramble", + () => , + ], ["Button", () => ], ["Button disabled", () => ], ["Button ripple", () => ], From c7bea420160ce7f8c7275a6f53e7fef9e1fe7ed1 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 1 Aug 2026 00:38:48 +0530 Subject: [PATCH 02/33] feat: add collapsible agent reasoning --- AGENTS.md | 1 + components/agents/agent-reasoning.tsx | 230 ++++++++++++++++++ .../agents/agent-reasoning.preview.tsx | 59 +++++ components/previews/index.tsx | 5 + lib/component-dates.ts | 4 + lib/registry.ts | 29 +++ tests/a11y.test.tsx | 9 + 7 files changed, 337 insertions(+) create mode 100644 components/agents/agent-reasoning.tsx create mode 100644 components/previews/agents/agent-reasoning.preview.tsx diff --git a/AGENTS.md b/AGENTS.md index 2ada6f9..7f7a8cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,7 @@ Before building a new component, check this list. If it exists, import it. If it | slug | file | what it does | |---|---|---| | `loading-states` | `components/agents/loading-states/` | Three agent loading states: `ThinkingShimmer`, compact live-timed `AgentProgress`, and `ReasoningText` with an ASCII loader, shimmering phrases, and cascade/swap/scramble styles; each variant is independently installable and reduced-motion safe | +| `agent-reasoning` | `components/agents/agent-reasoning.tsx` | Live agent reasoning disclosure (`AgentReasoning`): stays expanded while thinking, streams appended reasoning lines, then folds into a “Thought for Ns” summary that can be reopened; controlled/uncontrolled, capped scrolling, reduced-motion safe | ### Blocks (`blocks` category — composed product widgets) diff --git a/components/agents/agent-reasoning.tsx b/components/agents/agent-reasoning.tsx new file mode 100644 index 0000000..7b19a6b --- /dev/null +++ b/components/agents/agent-reasoning.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import { + Children, + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { ThinkingShimmer } from "@/components/agents/loading-states/thinking-shimmer"; +import { EASE_OUT, SPRING_PANEL, SPRING_SWAP } from "@/lib/ease"; +import { cn } from "@/lib/utils"; + +export type AgentReasoningStatus = "thinking" | "complete"; + +export interface AgentReasoningProps { + /** Current reasoning phase. Active reasoning always stays expanded. */ + status?: AgentReasoningStatus; + /** Elapsed reasoning time, in seconds. */ + duration?: number; + /** Controlled expanded state used after reasoning completes. */ + open?: boolean; + /** Initial expanded state used after reasoning completes. */ + defaultOpen?: boolean; + /** Called when the completed reasoning disclosure changes state. */ + onOpenChange?: (open: boolean) => void; + /** Collapse the disclosure when status changes from thinking to complete. */ + collapseOnComplete?: boolean; + /** Label displayed while reasoning is active. */ + thinkingLabel?: ReactNode; + /** Optional completed summary. Defaults to “Thought for Ns”. */ + summary?: ReactNode; + /** Maximum visible reasoning height before the content scrolls. */ + maxHeight?: number; + children: ReactNode; + className?: string; + contentClassName?: string; +} + +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; +} + +export function AgentReasoning({ + status = "thinking", + duration = 0, + open, + defaultOpen = false, + onOpenChange, + collapseOnComplete = true, + thinkingLabel = "Thinking…", + summary, + maxHeight = 192, + children, + className, + contentClassName, +}: AgentReasoningProps) { + const reduce = useReducedMotion() ?? false; + const baseId = useId(); + const triggerId = `${baseId}-trigger`; + const contentId = `${baseId}-content`; + const contentRef = useRef(null); + const viewportRef = useRef(null); + const previousStatus = useRef(status); + const childCount = Children.count(children); + const [contentHeight, setContentHeight] = useState(0); + const [currentOpen, setOpen] = useControllableOpen({ + open, + defaultOpen, + onOpenChange, + }); + const thinking = status === "thinking"; + const expanded = thinking || currentOpen; + const cappedHeight = Math.min(contentHeight, Math.max(0, maxHeight)); + const capped = contentHeight > maxHeight; + + 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 === "thinking" && status === "complete") { + setOpen(!collapseOnComplete); + } + previousStatus.current = status; + }, [collapseOnComplete, setOpen, status]); + + useEffect(() => { + const viewport = viewportRef.current; + if (thinking && viewport && childCount > 0) { + viewport.scrollTop = viewport.scrollHeight; + } + }, [childCount, thinking]); + + const toggle = () => { + const next = !currentOpen; + setOpen(next); + if (next) requestAnimationFrame(() => viewportRef.current?.scrollTo({ top: 0 })); + }; + + const completedSummary = summary ?? ( + <> + Thought for {formatDuration(duration)} + + ); + + return ( +
+ {thinking ? ( +
+ {thinkingLabel} +
+ ) : ( + + )} + + +
+
+ {Children.toArray(children).map((child, index) => ( + + {child} + + ))} +
+
+
+
+ ); +} diff --git a/components/previews/agents/agent-reasoning.preview.tsx b/components/previews/agents/agent-reasoning.preview.tsx new file mode 100644 index 0000000..3a218fe --- /dev/null +++ b/components/previews/agents/agent-reasoning.preview.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import { useEffect, useState } from "react"; +import { AgentReasoning } from "@/components/agents/agent-reasoning"; + +const REASONING = [ + "Reading the request and identifying the interaction states.", + "Checking the existing agent components for shared motion and type conventions.", + "Separating the live reasoning stream from the completed disclosure state.", + "Preparing an accessible controlled API with reduced-motion behavior.", +]; + +function ReasoningDemo() { + const [visible, setVisible] = useState(0); + const [complete, setComplete] = useState(false); + + useEffect(() => { + setVisible(0); + setComplete(false); + + const timers = REASONING.map((_, index) => + window.setTimeout(() => setVisible(index + 1), 650 + index * 900), + ); + timers.push(window.setTimeout(() => setComplete(true), 4500)); + + return () => timers.forEach(window.clearTimeout); + }, []); + + return ( + + {REASONING.slice(0, visible).map((line) => ( +

{line}

+ ))} +
+ ); +} + +export function AgentReasoningPreview() { + const [run, setRun] = useState(0); + + return ( +
+ + + +
+ ); +} diff --git a/components/previews/index.tsx b/components/previews/index.tsx index 4f3f791..c3208c0 100644 --- a/components/previews/index.tsx +++ b/components/previews/index.tsx @@ -4,6 +4,11 @@ import type { ComponentType } from "react"; // Every preview is a client component dragging the library + motion with it. // Lazy chunks keep a page's JS limited to the previews it actually renders. export const previews: Record = { + "agents/agent-reasoning": dynamic(() => + import("./agents/agent-reasoning.preview").then( + (m) => m.AgentReasoningPreview, + ), + ), "agents/thinking-shimmer": dynamic(() => import("./agents/thinking-shimmer.preview").then( (m) => m.ThinkingShimmerPreview, diff --git a/lib/component-dates.ts b/lib/component-dates.ts index 44a2cad..38c16d8 100644 --- a/lib/component-dates.ts +++ b/lib/component-dates.ts @@ -55,6 +55,10 @@ const COMPONENT_DATES = { publishedAt: "2026-07-30", updatedAt: "2026-07-30", }, + "agents/agent-reasoning": { + publishedAt: "2026-08-01", + updatedAt: "2026-08-01", + }, "blocks/infinite-masonry": { publishedAt: "2026-07-15", updatedAt: "2026-07-15" }, "blocks/notification-stack": { publishedAt: "2026-07-14", updatedAt: "2026-07-14" }, "blocks/knockout-bracket": { publishedAt: "2026-07-12", updatedAt: "2026-07-27" }, diff --git a/lib/registry.ts b/lib/registry.ts index 369ed02..f0776bc 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -805,6 +805,35 @@ export const registry: CategoryEntry[] = [ description: "Animated React components for agent reasoning, progress, tool activity, and conversational AI interfaces.", components: [ + { + slug: "agent-reasoning", + name: "Agent Reasoning", + description: + "A live reasoning stream that stays visible while an agent works, then folds into an elapsed-time disclosure the user can revisit.", + file: "components/agents/agent-reasoning.tsx", + badge: "new", + launchedAt: "2026-08-01", + keywords: [ + "AI reasoning component", + "agent thinking disclosure", + "collapsible chain of thought UI", + "AI thought process React", + "reasoning stream component", + "Thought for seconds UI", + ], + examples: [ + { + slug: "agent-reasoning", + name: "Agent Reasoning", + description: + "Streams reasoning while work is active, automatically summarizes the elapsed time on completion, and remains available as an accessible disclosure.", + file: "components/agents/agent-reasoning.tsx", + previewKey: "agents/agent-reasoning", + previewFile: + "components/previews/agents/agent-reasoning.preview.tsx", + }, + ], + }, { slug: "loading-states", name: "Agent Loading States", diff --git a/tests/a11y.test.tsx b/tests/a11y.test.tsx index 848798d..a678ea1 100644 --- a/tests/a11y.test.tsx +++ b/tests/a11y.test.tsx @@ -3,6 +3,7 @@ import { cleanup, render } from "@testing-library/react"; import { axe } from "jest-axe"; import type { ReactElement } from "react"; +import { AgentReasoning } from "@/components/agents/agent-reasoning"; import { AgentProgress } from "@/components/agents/loading-states/agent-progress"; import { ReasoningText } from "@/components/agents/loading-states/reasoning-text"; import { ThinkingShimmer } from "@/components/agents/loading-states/thinking-shimmer"; @@ -67,6 +68,14 @@ afterEach(cleanup); // no violations. Add a row here when you ship a new interactive component. // Render thunks (not bare JSX) keep these out of an iterable literal. const cases: Array<[name: string, render: () => ReactElement]> = [ + [ + "AgentReasoning complete", + () => ( + +

Checked the request and prepared a response.

+
+ ), + ], [ "AttachmentUpload", () => ( From d9acbea81e6e5ee0061916cc7091b1bffc6a290e Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 1 Aug 2026 00:45:39 +0530 Subject: [PATCH 03/33] fix: refine agent reasoning preview --- AGENTS.md | 4 +- components/agents/agent-reasoning.tsx | 14 +++-- .../agents/agent-reasoning.preview.tsx | 63 +++++++++++++------ lib/registry.ts | 12 ++-- 4 files changed, 63 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7f7a8cb..8631cd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ Before building a new component, check this list. If it exists, import it. If it | slug | file | what it does | |---|---|---| | `loading-states` | `components/agents/loading-states/` | Three agent loading states: `ThinkingShimmer`, compact live-timed `AgentProgress`, and `ReasoningText` with an ASCII loader, shimmering phrases, and cascade/swap/scramble styles; each variant is independently installable and reduced-motion safe | -| `agent-reasoning` | `components/agents/agent-reasoning.tsx` | Live agent reasoning disclosure (`AgentReasoning`): stays expanded while thinking, streams appended reasoning lines, then folds into a “Thought for Ns” summary that can be reopened; controlled/uncontrolled, capped scrolling, reduced-motion safe | +| `agent-reasoning` | `components/agents/agent-reasoning.tsx` | Live agent reasoning disclosure (`AgentReasoning`): stays expanded while working, streams appended reasoning lines, then settles into a timed summary that can be reopened; controlled/uncontrolled, capped scrolling, reduced-motion safe | ### Blocks (`blocks` category — composed product widgets) @@ -116,6 +116,8 @@ Before building a new component, check this list. If it exists, import it. If it ## Code conventions +- External examples supplied during design work are private context only. Ship original interaction, code, naming, and copy; do not publish comparisons or attribution unless explicitly requested. + - Named exports only. Every component accepts `className` merged via `cn()` from `lib/utils`. Interactive components are `"use client"`. - Larger components support controlled + uncontrolled (`value`/`defaultValue`/`onChange`); simple toggles are controlled-only. - Biome a11y rules are strict: no redundant ARIA roles on divs/spans; use real elements (`
diff --git a/components/previews/agents/agent-reasoning.preview.tsx b/components/previews/agents/agent-reasoning.preview.tsx index df8ab6f..acb4fac 100644 --- a/components/previews/agents/agent-reasoning.preview.tsx +++ b/components/previews/agents/agent-reasoning.preview.tsx @@ -1,6 +1,7 @@ "use client"; import { RotateCcw } from "lucide-react"; +import { useReducedMotion } from "motion/react"; import { useEffect, useState } from "react"; import { AgentReasoning } from "@/components/agents/agent-reasoning"; @@ -17,34 +18,48 @@ const REASONING = [ "Finishing with a compact summary that can be revisited.", ].join("\n"); -const STREAM_CHUNK = 3; -const STREAM_INTERVAL = 32; -const STREAM_SECONDS = - (Math.ceil(REASONING.length / STREAM_CHUNK) * STREAM_INTERVAL) / 1000; +const STREAM_CHARACTERS_PER_SECOND = 90; +const STREAM_SECONDS = REASONING.length / STREAM_CHARACTERS_PER_SECOND; function ReasoningDemo() { + const reduce = useReducedMotion() ?? false; const [stream, setStream] = useState(""); const [complete, setComplete] = useState(false); useEffect(() => { - let cursor = 0; + if (reduce) { + setStream(REASONING); + setComplete(true); + return; + } + + const startedAt = performance.now(); + let frame = 0; let completionTimer: number | undefined; - const streamTimer = window.setInterval(() => { - cursor = Math.min(cursor + STREAM_CHUNK, REASONING.length); - setStream(REASONING.slice(0, cursor)); + const streamNextFrame = (now: number) => { + const elapsed = (now - startedAt) / 1000; + const cursor = Math.min( + REASONING.length, + Math.floor(elapsed * STREAM_CHARACTERS_PER_SECOND), + ); + const next = REASONING.slice(0, cursor); + setStream((current) => (current === next ? current : next)); if (cursor === REASONING.length) { - window.clearInterval(streamTimer); completionTimer = window.setTimeout(() => setComplete(true), 500); + } else { + frame = requestAnimationFrame(streamNextFrame); } - }, STREAM_INTERVAL); + }; + + frame = requestAnimationFrame(streamNextFrame); return () => { - window.clearInterval(streamTimer); + cancelAnimationFrame(frame); if (completionTimer) window.clearTimeout(completionTimer); }; - }, []); + }, [reduce]); const lines = stream.split("\n").filter(Boolean); @@ -60,12 +75,6 @@ function ReasoningDemo() { key={index} > {line} - {!complete && index === lines.length - 1 ? ( -