AI & Chat

Agent Status

An agent run status pill whose working state pulses one soft ring and rolls status verbs vertically, blur-swapping to a check or alert when the run settles.

Install

npx shadcn@latest add @paragon/agent-status

Also installs: pulse-ring

agent-status.tsx

"use client";

import * as React from "react";
import {
  AnimatePresence,
  motion,
  useInView,
  useReducedMotion,
} from "motion/react";
import { Check, CircleAlert } from "lucide-react";
import { PulseRing } from "@/registry/paragon/ui/pulse-ring";
import { cn } from "@/lib/utils";

/** Mirrors var(--ease-out). */
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
/** Mirrors var(--ease-exit). */
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

export type AgentStatusState = "idle" | "working" | "done" | "error";

export interface AgentStatusProps
  extends Omit<
    React.ComponentProps<"div">,
    "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
  > {
  state?: AgentStatusState;
  /** Status verbs rolled through while working. */
  verbs?: string[];
  /** Seconds each verb holds before rolling to the next. */
  hold?: number;
  idleLabel?: string;
  doneLabel?: string;
  errorLabel?: string;
  /** Disables rolling, pulsing, and swap motion. */
  static?: boolean;
}

/**
 * Agent run status pill. The working state shows one soft pulse ring while
 * status verbs roll vertically — a 120ms roll after each 2s hold — then the
 * indicator blur-swaps to a check (or alert) when the run settles. The verb
 * column is sized by its longest entry so the pill never jitters mid-roll;
 * state changes animate the pill's width via layout.
 */
export function AgentStatus({
  state = "idle",
  verbs = ["Analyzing sources", "Drafting response", "Running checks"],
  hold = 2,
  idleLabel = "Standing by",
  doneLabel = "Completed",
  errorLabel = "Needs attention",
  static: isStatic = false,
  className,
  ...props
}: AgentStatusProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref);
  const reducedMotion = useReducedMotion();
  const [verbIndex, setVerbIndex] = React.useState(0);

  // Roll verbs only while working and on screen; reset on each new run.
  React.useEffect(() => {
    if (state !== "working") {
      setVerbIndex(0);
      return;
    }
    if (isStatic || verbs.length < 2 || !inView) return;
    const id = setInterval(() => {
      if (document.hidden) return;
      setVerbIndex((i) => (i + 1) % verbs.length);
    }, hold * 1000);
    return () => clearInterval(id);
  }, [state, verbs.length, hold, inView, isStatic]);

  const animate = !isStatic && !reducedMotion;
  const blur = animate ? "blur(4px)" : "blur(0px)";

  const label =
    state === "idle"
      ? idleLabel
      : state === "done"
        ? doneLabel
        : state === "error"
          ? errorLabel
          : (verbs[verbIndex % verbs.length] ?? "Working");

  return (
    <motion.div
      ref={ref}
      data-slot="agent-status"
      role="status"
      layout={animate}
      transition={{ type: "spring", duration: 0.35, bounce: 0 }}
      className={cn(
        "inline-flex h-8 items-center gap-2 rounded-full bg-card pr-3.5 pl-3 text-[13px] text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <motion.span
        layout={animate ? "position" : false}
        className="flex size-4 shrink-0 items-center justify-center"
      >
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span
            key={state === "working" ? "working" : state}
            className="flex items-center justify-center"
            initial={{ opacity: 0, scale: 0.25, filter: blur }}
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, scale: 0.25, filter: blur }}
            transition={{ type: "spring", duration: 0.3, bounce: 0 }}
          >
            {state === "working" ? (
              <PulseRing
                size={7}
                rings={1}
                duration={2}
                scale={2.4}
                static={isStatic}
                className="text-primary"
              />
            ) : state === "done" ? (
              <Check className="size-3.5 text-emerald-600 dark:text-emerald-500" />
            ) : state === "error" ? (
              <CircleAlert className="size-3.5 text-destructive" />
            ) : (
              <span className="size-[7px] rounded-full bg-muted-foreground/40" />
            )}
          </motion.span>
        </AnimatePresence>
      </motion.span>
      {state === "working" ? (
        <motion.span
          layout={animate ? "position" : false}
          className="inline-grid items-baseline overflow-hidden"
        >
          {/* Longest verb sizes the cell so rolls never resize the pill. */}
          {verbs.map((verb) => (
            <span
              key={verb}
              aria-hidden
              className="invisible whitespace-nowrap [grid-area:1/1]"
            >
              {verb}
            </span>
          ))}
          <AnimatePresence initial={false}>
            <motion.span
              key={verbIndex}
              className="whitespace-nowrap [grid-area:1/1]"
              initial={{ opacity: 0, y: animate ? 10 : 0 }}
              animate={{
                opacity: 1,
                y: 0,
                transition: { duration: 0.12, ease: EASE_OUT },
              }}
              exit={{
                opacity: 0,
                y: animate ? -10 : 0,
                transition: { duration: 0.12, ease: EASE_EXIT },
              }}
            >
              {label}
            </motion.span>
          </AnimatePresence>
        </motion.span>
      ) : (
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span
            key={state}
            layout={animate ? "position" : false}
            className="whitespace-nowrap"
            initial={{ opacity: 0, filter: blur }}
            animate={{ opacity: 1, filter: "blur(0px)" }}
            exit={{
              opacity: 0,
              filter: blur,
              transition: { duration: 0.15, ease: EASE_EXIT },
            }}
            transition={{ duration: 0.25, ease: EASE_OUT }}
          >
            {label}
          </motion.span>
        </AnimatePresence>
      )}
    </motion.div>
  );
}