Agent Progress

Expandable multi-step agent progress with synchronized status animation.

Steps

Expandable multi-step progress.

5 steps left
Read project files
Update and install light mode tokens
Implement dark mode tokens
Add reusable registered theme toggle
Run registry, lint and production build
function AgentProgressDemo() {
  return <AgentProgress steps={AGENT_PROGRESS_STEPS} />;
}
function AgentProgressDemo() {
  return <AgentProgress steps={AGENT_PROGRESS_STEPS} />;
}

Installation

npx shadcn@latest add https://boardcn.dev/r/agent-progress.json
npx shadcn@latest add https://boardcn.dev/r/agent-progress.json

npm packages

  • motion

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/blocks/agent-progress/agent-progress.tsx
"use client";

import { useEffect, useId, useState } from "react";
import {
  AnimatePresence,
  LayoutGroup,
  MotionConfig,
  motion,
} from "motion/react";
import { usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

const layoutTransition = {
  type: "spring" as const,
  stiffness: 260,
  damping: 30,
  mass: 0.8,
};

const easeOutExpo = [0.22, 1, 0.36, 1] as const;

export interface AgentProgressProps {
  /** Ordered tasks to display. */
  steps: string[];
  /** Time in milliseconds spent on each task. */
  stepDuration?: number;
  /** Delay in milliseconds before `onFinished` runs after the final task. */
  completionDelay?: number;
  /** Called after every task and the completion delay have elapsed. */
  onFinished?: () => void;
  className?: string;
}

interface ProgressRingProps {
  running: boolean;
  duration: number;
  size?: number;
  reducedMotion?: boolean;
}

function LoadingText({ children, className }: { children: string; className?: string }) {
  return (
    <span
      aria-label={children}
      className={cx("agent-progress-loading-text inline-block", className)}
    >
      {children}
    </span>
  );
}

function ProgressRing({
  running,
  duration,
  size = 16,
  reducedMotion = false,
}: ProgressRingProps) {
  return (
    <svg
      aria-hidden
      viewBox="0 0 16 16"
      width={size}
      height={size}
      className="shrink-0 -rotate-90"
    >
      <circle
        cx="8"
        cy="8"
        r="6.75"
        fill="none"
        stroke="var(--color-border-button-default)"
        strokeWidth="2.5"
      />
      <motion.circle
        data-progress-ring
        cx="8"
        cy="8"
        r="6.75"
        fill="none"
        stroke="var(--color-agent-progress-ring)"
        strokeWidth="2.5"
        strokeLinecap="round"
        initial={reducedMotion ? false : { pathLength: 0 }}
        animate={{ pathLength: Number(running) }}
        transition={{
          duration: reducedMotion ? 0 : running ? duration : 0,
          ease: "linear",
        }}
      />
    </svg>
  );
}

function StepProgressRing({
  running,
  duration,
  reducedMotion = false,
}: Omit<ProgressRingProps, "size">) {
  return (
    <svg aria-hidden viewBox="0 0 14 14" className="size-3.5 shrink-0 -rotate-90">
      <circle
        cx="7"
        cy="7"
        r="5.75"
        fill="none"
        stroke="var(--color-border-button-default)"
        strokeWidth="1.5"
      />
      <motion.circle
        data-step-progress
        cx="7"
        cy="7"
        r="5.75"
        fill="none"
        stroke="var(--color-agent-progress-ring)"
        strokeWidth="1.5"
        strokeLinecap="round"
        initial={reducedMotion ? false : { pathLength: 0 }}
        animate={{ pathLength: Number(running) }}
        transition={{
          duration: reducedMotion ? 0 : running ? duration : 0,
          ease: "linear",
        }}
      />
    </svg>
  );
}

function MinimizeIcon() {
  return (
    <span aria-hidden className="relative block size-5 shrink-0">
      <span className="absolute top-px left-px size-[18px] rounded-sm bg-background-quaternary-default" />
      <span className="absolute top-[13px] left-1 h-0.5 w-3 rounded-[3px] bg-foreground-icon-secondary" />
    </span>
  );
}

function CompleteIcon() {
  return (
    <svg aria-hidden viewBox="0 0 14 14" className="size-[15px]">
      <circle cx="7" cy="7" r="7" fill="var(--color-background-quaternary-default)" />
      <path
        d="M4 7.5 5.646 9.146a.5.5 0 0 0 .708 0L10 5.5"
        fill="none"
        stroke="var(--color-foreground-icon-secondary)"
        strokeWidth="1.5"
        strokeLinecap="round"
      />
    </svg>
  );
}

function PendingIcon() {
  return (
    <svg aria-hidden viewBox="0 0 15 15" className="size-[15px]">
      <circle
        cx="7.5"
        cy="7.5"
        r="7"
        fill="none"
        stroke="var(--color-background-quaternary-default)"
        strokeDasharray="2 2"
      />
    </svg>
  );
}

function CurrentStepArrow() {
  return (
    <svg aria-hidden viewBox="0 0 14 14" className="size-3.5 shrink-0">
      <path
        d="M7.47 2.47a.75.75 0 0 1 1.06 0l4.177 4.176a.5.5 0 0 1 0 .708L8.53 11.53a.75.75 0 0 1-1.06-1.06l2.72-2.72H2a.75.75 0 0 1 0-1.5h8.19L7.47 3.53a.75.75 0 0 1 0-1.06Z"
        fill="var(--color-foreground-icon-secondary)"
      />
    </svg>
  );
}

function ExpandIcon() {
  return (
    <svg aria-hidden viewBox="0 0 20 20" className="size-5">
      <rect
        x="1"
        y="1"
        width="18"
        height="18"
        rx="4"
        fill="var(--color-background-quaternary-default)"
      />
      <path
        d="M7.553 6.109a.75.75 0 0 1 .75-.75h5.907a.5.5 0 0 1 .5.5v5.906a.75.75 0 0 1-1.5 0V7.919l-5.79 5.791a.75.75 0 1 1-1.061-1.06l5.79-5.791H8.303a.75.75 0 0 1-.75-.75Z"
        fill="var(--color-foreground-icon-secondary)"
      />
    </svg>
  );
}

function AnimatedLabel({
  label,
  className,
  reducedMotion,
}: {
  label: string;
  className?: string;
  reducedMotion: boolean;
}) {
  return (
    <motion.span
      layout="position"
      transition={reducedMotion ? { duration: 0 } : layoutTransition}
      className={cx("relative inline-flex min-w-0 overflow-hidden", className)}
    >
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={label}
          initial={reducedMotion ? false : { opacity: 0, y: 4, filter: "blur(3px)" }}
          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          exit={{ opacity: 0, y: -4, filter: "blur(3px)" }}
          transition={{ duration: reducedMotion ? 0 : 0.26, ease: easeOutExpo }}
          className="block whitespace-nowrap"
        >
          {label}
        </motion.span>
      </AnimatePresence>
    </motion.span>
  );
}

function StepLabel({
  label,
  complete,
  active,
  reducedMotion,
}: {
  label: string;
  complete: boolean;
  active: boolean;
  reducedMotion: boolean;
}) {
  return (
    <span
      className={cx(
        "relative block min-w-0 max-w-full truncate text-body-medium leading-5 transition-colors duration-300 motion-reduce:transition-none",
        complete || !active ? "text-text-secondary" : "text-text-primary",
      )}
    >
      {active ? <LoadingText>{label}</LoadingText> : label}
      <AnimatePresence>
        {complete ? (
          <motion.span
            aria-hidden
            className="absolute top-1/2 right-0 left-0 h-px origin-left bg-current"
            initial={reducedMotion ? false : { scaleX: 0, opacity: 0 }}
            animate={{ scaleX: 1, opacity: 0.8 }}
            exit={{ scaleX: 0, opacity: 0 }}
            transition={{ duration: reducedMotion ? 0 : 0.38, ease: easeOutExpo }}
          />
        ) : null}
      </AnimatePresence>
    </span>
  );
}

interface ProgressStepProps {
  label: string;
  index: number;
  completedCount: number;
  processingStarted: boolean;
  reopening: boolean;
  stepCount: number;
  stepDuration: number;
  reducedMotion: boolean;
}

function ProgressStep({
  label,
  index,
  completedCount,
  processingStarted,
  reopening,
  stepCount,
  stepDuration,
  reducedMotion,
}: ProgressStepProps) {
  const complete = index < completedCount;
  const active = index === completedCount && completedCount < stepCount;

  return (
    <motion.div
      layout="position"
      initial={reducedMotion ? false : { opacity: 0, y: -4, filter: "blur(6px)" }}
      animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
      transition={{
        delay: reducedMotion ? 0 : reopening ? 0.035 * index : 0.65 + 0.16 * index,
        duration: reducedMotion ? 0 : reopening ? 0.24 : 0.45,
        ease: easeOutExpo,
        layout: reducedMotion ? { duration: 0 } : layoutTransition,
      }}
      className="h-8 w-full"
    >
      <motion.div
        animate={{ paddingLeft: active ? 9 : 4, paddingRight: active ? 13 : 4 }}
        transition={{ duration: reducedMotion ? 0 : 0.36, ease: easeOutExpo }}
        className="relative flex h-full w-full items-center gap-2 rounded-full"
      >
        {active ? (
          <motion.span
            layoutId="active-step-border"
            aria-hidden
            className="pointer-events-none absolute inset-0 rounded-full border border-border-button-default"
            transition={reducedMotion ? { duration: 0 } : layoutTransition}
          />
        ) : null}

        <span className="relative z-10 flex size-3.5 shrink-0 items-center justify-center">
          <AnimatePresence initial={false} mode="popLayout">
            {complete ? (
              <motion.span
                key="complete"
                initial={reducedMotion ? false : { opacity: 0, scale: 0.72, rotate: -18 }}
                animate={{ opacity: 1, scale: 1, rotate: 0 }}
                transition={{ duration: reducedMotion ? 0 : 0.34, ease: easeOutExpo }}
                className="absolute inset-[-0.5px]"
              >
                <CompleteIcon />
              </motion.span>
            ) : active ? (
              <motion.span
                key={`active-${index}`}
                initial={reducedMotion ? false : { opacity: 0, scale: 0.82 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.82 }}
                transition={{ duration: reducedMotion ? 0 : 0.25, ease: "easeOut" }}
                className="absolute inset-0"
              >
                <StepProgressRing
                  running={processingStarted}
                  duration={stepDuration}
                  reducedMotion={reducedMotion}
                />
              </motion.span>
            ) : (
              <motion.span
                key="pending"
                initial={reducedMotion ? false : { opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: reducedMotion ? 0 : 0.2 }}
                className="absolute inset-[-0.5px]"
              >
                <PendingIcon />
              </motion.span>
            )}
          </AnimatePresence>
        </span>

        <span className="relative z-10 flex min-w-0 flex-1 items-center truncate leading-5">
          <StepLabel
            label={label}
            complete={complete}
            active={active}
            reducedMotion={reducedMotion}
          />
        </span>
      </motion.div>
    </motion.div>
  );
}

/**
 * Collapsible progress block for multi-step agent work.
 *
 * The expanded list advances each task in order. Collapsing it preserves both
 * task state and the synchronized overall progress ring.
 */
export function AgentProgress({
  steps,
  stepDuration = 3000,
  completionDelay = 1000,
  onFinished,
  className,
}: AgentProgressProps) {
  const reducedMotion = usePrefersReducedMotion();
  const layoutGroupId = useId();
  const durationMs = Math.max(0, stepDuration);
  const durationSeconds = durationMs / 1000;
  const totalDuration = steps.length * durationSeconds;
  const expandedHeight = Math.max(44, 45 + 38 * steps.length);

  const [completedCount, setCompletedCount] = useState(0);
  const [minimized, setMinimized] = useState(false);
  const [reopening, setReopening] = useState(false);
  const [processingStarted, setProcessingStarted] = useState(false);

  const stepsLeft = steps.length - completedCount;
  const activeStep = steps[Math.min(completedCount, steps.length - 1)];
  const complete = completedCount >= steps.length;

  useEffect(() => {
    const timer = window.setTimeout(() => setProcessingStarted(true), 1100);
    return () => window.clearTimeout(timer);
  }, []);

  useEffect(() => {
    if (!processingStarted || complete) return;

    const timer = window.setTimeout(() => {
      setCompletedCount((count) => Math.min(count + 1, steps.length));
    }, durationMs);

    return () => window.clearTimeout(timer);
  }, [complete, completedCount, durationMs, processingStarted, steps.length]);

  useEffect(() => {
    if (!complete || !onFinished) return;

    const timer = window.setTimeout(onFinished, Math.max(0, completionDelay));
    return () => window.clearTimeout(timer);
  }, [complete, completionDelay, onFinished]);

  const statusLabel = complete
    ? "All steps completed"
    : `${stepsLeft} ${stepsLeft === 1 ? "step" : "steps"} left`;

  return (
    <MotionConfig reducedMotion={reducedMotion ? "always" : "never"}>
      <motion.div
        initial={
          reducedMotion
            ? false
            : { opacity: 0, y: -12, height: 0, filter: "blur(8px)" }
        }
        animate={{
          opacity: 1,
          y: 0,
          height: minimized ? 44 : expandedHeight,
          filter: "blur(0px)",
        }}
        transition={{
          opacity: { duration: reducedMotion ? 0 : 0.35, ease: easeOutExpo },
          y: { duration: reducedMotion ? 0 : 0.5, ease: easeOutExpo },
          filter: { duration: reducedMotion ? 0 : 0.4, ease: easeOutExpo },
          height: {
            duration: reducedMotion
              ? 0
              : minimized
                ? 0.42
                : reopening
                  ? 0.24
                  : 0.65,
            ease: easeOutExpo,
          },
        }}
        onAnimationComplete={() => {
          if (!minimized) setReopening(false);
        }}
        className={cx(
          "relative w-[341px] max-w-full overflow-hidden rounded-2xl border border-border-button-default bg-background-primary-default shadow-xs",
          className,
        )}
        aria-live="polite"
        data-testid="agent-progress"
      >
      <AnimatePresence initial={false}>
        {!complete ? (
          <motion.span
            key="persistent-progress-ring"
            className="pointer-events-none absolute top-[14px] left-[14px] z-20 flex size-4 items-center justify-center"
            initial={reducedMotion ? false : { opacity: 0, scale: 0.82 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.82 }}
            transition={{ duration: reducedMotion ? 0 : 0.25, ease: easeOutExpo }}
          >
            <ProgressRing
              running={processingStarted}
              duration={totalDuration}
              reducedMotion={reducedMotion}
            />
          </motion.span>
        ) : null}
      </AnimatePresence>

      <AnimatePresence mode="sync">
        {minimized ? (
          <motion.button
            key="minimized"
            type="button"
            data-testid="agent-progress-minimized"
            aria-label="Expand steps"
            onClick={() => {
              setReopening(true);
              setMinimized(false);
            }}
            initial={reducedMotion ? false : { opacity: 0, filter: "blur(3px)" }}
            animate={{ opacity: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, filter: "blur(3px)" }}
            transition={{
              duration: reducedMotion ? 0 : 0.2,
              delay: reducedMotion ? 0 : 0.12,
              ease: easeOutExpo,
            }}
            className="group absolute inset-0 flex cursor-pointer items-center gap-2 px-2.5 py-1.5 text-left"
          >
            <span className="flex min-w-0 shrink-0 items-center pl-1">
              <AnimatePresence initial={false}>
                {!complete ? (
                  <motion.span
                    key="progress-ring"
                    className="flex h-4 w-6 shrink-0 items-center overflow-hidden"
                    initial={reducedMotion ? false : { width: 24, opacity: 1 }}
                    animate={{ width: 24, opacity: 1 }}
                    exit={{ width: 0, opacity: 0 }}
                    transition={{ duration: reducedMotion ? 0 : 0.4, ease: easeOutExpo }}
                  />
                ) : null}
              </AnimatePresence>
              <AnimatedLabel
                label={statusLabel}
                className="text-body-medium text-text-secondary"
                reducedMotion={reducedMotion}
              />
            </span>

            {!complete ? (
              <span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-1.5 pr-6 [mask-image:linear-gradient(to_right,#000_0%,#000_100%)] transition-[mask-image] duration-300 group-hover:[mask-image:linear-gradient(to_right,#000_0%,#000_68%,transparent_94%)] motion-reduce:transition-none">
                <CurrentStepArrow />
                <span className="block min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-body-medium">
                  <LoadingText className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap">
                    {activeStep}
                  </LoadingText>
                </span>
              </span>
            ) : null}

            <span className="absolute top-1/2 right-2.5 size-5 -translate-y-1/2 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none">
              <ExpandIcon />
            </span>
          </motion.button>
        ) : (
          <motion.div
            key="expanded"
            data-testid="agent-progress-expanded"
            initial={reducedMotion ? false : { opacity: 0, filter: "blur(3px)" }}
            animate={{ opacity: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, y: -4, filter: "blur(3px)" }}
            transition={{
              duration: reducedMotion ? 0 : reopening ? 0.16 : 0.22,
              ease: easeOutExpo,
            }}
            className="absolute inset-x-0 top-0 px-2.5 pt-2 pb-2.5"
            style={{ height: expandedHeight }}
          >
            <div className="pt-1">
              <div className="flex h-5 w-full items-center pl-1">
                <AnimatePresence initial={false}>
                  {!complete ? (
                    <motion.span
                      key="progress-ring"
                      className="flex h-4 w-6 shrink-0 items-center overflow-hidden"
                      initial={reducedMotion ? false : { width: 24, opacity: 1 }}
                      animate={{ width: 24, opacity: 1 }}
                      exit={{ width: 0, opacity: 0 }}
                      transition={{ duration: reducedMotion ? 0 : 0.4, ease: easeOutExpo }}
                    />
                  ) : null}
                </AnimatePresence>

                <AnimatedLabel
                  label={statusLabel}
                  className="flex-1 text-body-medium text-text-secondary"
                  reducedMotion={reducedMotion}
                />

                <button
                  type="button"
                  aria-label="Minimize steps"
                  onClick={() => setMinimized(true)}
                  className="size-5 cursor-pointer rounded-sm transition-opacity duration-200 hover:opacity-80 motion-reduce:transition-none"
                >
                  <MinimizeIcon />
                </button>
              </div>

              <LayoutGroup id={layoutGroupId}>
                <div className="mt-[9px] flex flex-col gap-1.5">
                  {steps.map((label, index) => (
                    <ProgressStep
                      key={label}
                      label={label}
                      index={index}
                      completedCount={completedCount}
                      processingStarted={processingStarted}
                      reopening={reopening}
                      stepCount={steps.length}
                      stepDuration={durationSeconds}
                      reducedMotion={reducedMotion}
                    />
                  ))}
                </div>
              </LayoutGroup>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
      </motion.div>
    </MotionConfig>
  );
}
"use client";

import { useEffect, useId, useState } from "react";
import {
  AnimatePresence,
  LayoutGroup,
  MotionConfig,
  motion,
} from "motion/react";
import { usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

const layoutTransition = {
  type: "spring" as const,
  stiffness: 260,
  damping: 30,
  mass: 0.8,
};

const easeOutExpo = [0.22, 1, 0.36, 1] as const;

export interface AgentProgressProps {
  /** Ordered tasks to display. */
  steps: string[];
  /** Time in milliseconds spent on each task. */
  stepDuration?: number;
  /** Delay in milliseconds before `onFinished` runs after the final task. */
  completionDelay?: number;
  /** Called after every task and the completion delay have elapsed. */
  onFinished?: () => void;
  className?: string;
}

interface ProgressRingProps {
  running: boolean;
  duration: number;
  size?: number;
  reducedMotion?: boolean;
}

function LoadingText({ children, className }: { children: string; className?: string }) {
  return (
    <span
      aria-label={children}
      className={cx("agent-progress-loading-text inline-block", className)}
    >
      {children}
    </span>
  );
}

function ProgressRing({
  running,
  duration,
  size = 16,
  reducedMotion = false,
}: ProgressRingProps) {
  return (
    <svg
      aria-hidden
      viewBox="0 0 16 16"
      width={size}
      height={size}
      className="shrink-0 -rotate-90"
    >
      <circle
        cx="8"
        cy="8"
        r="6.75"
        fill="none"
        stroke="var(--color-border-button-default)"
        strokeWidth="2.5"
      />
      <motion.circle
        data-progress-ring
        cx="8"
        cy="8"
        r="6.75"
        fill="none"
        stroke="var(--color-agent-progress-ring)"
        strokeWidth="2.5"
        strokeLinecap="round"
        initial={reducedMotion ? false : { pathLength: 0 }}
        animate={{ pathLength: Number(running) }}
        transition={{
          duration: reducedMotion ? 0 : running ? duration : 0,
          ease: "linear",
        }}
      />
    </svg>
  );
}

function StepProgressRing({
  running,
  duration,
  reducedMotion = false,
}: Omit<ProgressRingProps, "size">) {
  return (
    <svg aria-hidden viewBox="0 0 14 14" className="size-3.5 shrink-0 -rotate-90">
      <circle
        cx="7"
        cy="7"
        r="5.75"
        fill="none"
        stroke="var(--color-border-button-default)"
        strokeWidth="1.5"
      />
      <motion.circle
        data-step-progress
        cx="7"
        cy="7"
        r="5.75"
        fill="none"
        stroke="var(--color-agent-progress-ring)"
        strokeWidth="1.5"
        strokeLinecap="round"
        initial={reducedMotion ? false : { pathLength: 0 }}
        animate={{ pathLength: Number(running) }}
        transition={{
          duration: reducedMotion ? 0 : running ? duration : 0,
          ease: "linear",
        }}
      />
    </svg>
  );
}

function MinimizeIcon() {
  return (
    <span aria-hidden className="relative block size-5 shrink-0">
      <span className="absolute top-px left-px size-[18px] rounded-sm bg-background-quaternary-default" />
      <span className="absolute top-[13px] left-1 h-0.5 w-3 rounded-[3px] bg-foreground-icon-secondary" />
    </span>
  );
}

function CompleteIcon() {
  return (
    <svg aria-hidden viewBox="0 0 14 14" className="size-[15px]">
      <circle cx="7" cy="7" r="7" fill="var(--color-background-quaternary-default)" />
      <path
        d="M4 7.5 5.646 9.146a.5.5 0 0 0 .708 0L10 5.5"
        fill="none"
        stroke="var(--color-foreground-icon-secondary)"
        strokeWidth="1.5"
        strokeLinecap="round"
      />
    </svg>
  );
}

function PendingIcon() {
  return (
    <svg aria-hidden viewBox="0 0 15 15" className="size-[15px]">
      <circle
        cx="7.5"
        cy="7.5"
        r="7"
        fill="none"
        stroke="var(--color-background-quaternary-default)"
        strokeDasharray="2 2"
      />
    </svg>
  );
}

function CurrentStepArrow() {
  return (
    <svg aria-hidden viewBox="0 0 14 14" className="size-3.5 shrink-0">
      <path
        d="M7.47 2.47a.75.75 0 0 1 1.06 0l4.177 4.176a.5.5 0 0 1 0 .708L8.53 11.53a.75.75 0 0 1-1.06-1.06l2.72-2.72H2a.75.75 0 0 1 0-1.5h8.19L7.47 3.53a.75.75 0 0 1 0-1.06Z"
        fill="var(--color-foreground-icon-secondary)"
      />
    </svg>
  );
}

function ExpandIcon() {
  return (
    <svg aria-hidden viewBox="0 0 20 20" className="size-5">
      <rect
        x="1"
        y="1"
        width="18"
        height="18"
        rx="4"
        fill="var(--color-background-quaternary-default)"
      />
      <path
        d="M7.553 6.109a.75.75 0 0 1 .75-.75h5.907a.5.5 0 0 1 .5.5v5.906a.75.75 0 0 1-1.5 0V7.919l-5.79 5.791a.75.75 0 1 1-1.061-1.06l5.79-5.791H8.303a.75.75 0 0 1-.75-.75Z"
        fill="var(--color-foreground-icon-secondary)"
      />
    </svg>
  );
}

function AnimatedLabel({
  label,
  className,
  reducedMotion,
}: {
  label: string;
  className?: string;
  reducedMotion: boolean;
}) {
  return (
    <motion.span
      layout="position"
      transition={reducedMotion ? { duration: 0 } : layoutTransition}
      className={cx("relative inline-flex min-w-0 overflow-hidden", className)}
    >
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={label}
          initial={reducedMotion ? false : { opacity: 0, y: 4, filter: "blur(3px)" }}
          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          exit={{ opacity: 0, y: -4, filter: "blur(3px)" }}
          transition={{ duration: reducedMotion ? 0 : 0.26, ease: easeOutExpo }}
          className="block whitespace-nowrap"
        >
          {label}
        </motion.span>
      </AnimatePresence>
    </motion.span>
  );
}

function StepLabel({
  label,
  complete,
  active,
  reducedMotion,
}: {
  label: string;
  complete: boolean;
  active: boolean;
  reducedMotion: boolean;
}) {
  return (
    <span
      className={cx(
        "relative block min-w-0 max-w-full truncate text-body-medium leading-5 transition-colors duration-300 motion-reduce:transition-none",
        complete || !active ? "text-text-secondary" : "text-text-primary",
      )}
    >
      {active ? <LoadingText>{label}</LoadingText> : label}
      <AnimatePresence>
        {complete ? (
          <motion.span
            aria-hidden
            className="absolute top-1/2 right-0 left-0 h-px origin-left bg-current"
            initial={reducedMotion ? false : { scaleX: 0, opacity: 0 }}
            animate={{ scaleX: 1, opacity: 0.8 }}
            exit={{ scaleX: 0, opacity: 0 }}
            transition={{ duration: reducedMotion ? 0 : 0.38, ease: easeOutExpo }}
          />
        ) : null}
      </AnimatePresence>
    </span>
  );
}

interface ProgressStepProps {
  label: string;
  index: number;
  completedCount: number;
  processingStarted: boolean;
  reopening: boolean;
  stepCount: number;
  stepDuration: number;
  reducedMotion: boolean;
}

function ProgressStep({
  label,
  index,
  completedCount,
  processingStarted,
  reopening,
  stepCount,
  stepDuration,
  reducedMotion,
}: ProgressStepProps) {
  const complete = index < completedCount;
  const active = index === completedCount && completedCount < stepCount;

  return (
    <motion.div
      layout="position"
      initial={reducedMotion ? false : { opacity: 0, y: -4, filter: "blur(6px)" }}
      animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
      transition={{
        delay: reducedMotion ? 0 : reopening ? 0.035 * index : 0.65 + 0.16 * index,
        duration: reducedMotion ? 0 : reopening ? 0.24 : 0.45,
        ease: easeOutExpo,
        layout: reducedMotion ? { duration: 0 } : layoutTransition,
      }}
      className="h-8 w-full"
    >
      <motion.div
        animate={{ paddingLeft: active ? 9 : 4, paddingRight: active ? 13 : 4 }}
        transition={{ duration: reducedMotion ? 0 : 0.36, ease: easeOutExpo }}
        className="relative flex h-full w-full items-center gap-2 rounded-full"
      >
        {active ? (
          <motion.span
            layoutId="active-step-border"
            aria-hidden
            className="pointer-events-none absolute inset-0 rounded-full border border-border-button-default"
            transition={reducedMotion ? { duration: 0 } : layoutTransition}
          />
        ) : null}

        <span className="relative z-10 flex size-3.5 shrink-0 items-center justify-center">
          <AnimatePresence initial={false} mode="popLayout">
            {complete ? (
              <motion.span
                key="complete"
                initial={reducedMotion ? false : { opacity: 0, scale: 0.72, rotate: -18 }}
                animate={{ opacity: 1, scale: 1, rotate: 0 }}
                transition={{ duration: reducedMotion ? 0 : 0.34, ease: easeOutExpo }}
                className="absolute inset-[-0.5px]"
              >
                <CompleteIcon />
              </motion.span>
            ) : active ? (
              <motion.span
                key={`active-${index}`}
                initial={reducedMotion ? false : { opacity: 0, scale: 0.82 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.82 }}
                transition={{ duration: reducedMotion ? 0 : 0.25, ease: "easeOut" }}
                className="absolute inset-0"
              >
                <StepProgressRing
                  running={processingStarted}
                  duration={stepDuration}
                  reducedMotion={reducedMotion}
                />
              </motion.span>
            ) : (
              <motion.span
                key="pending"
                initial={reducedMotion ? false : { opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: reducedMotion ? 0 : 0.2 }}
                className="absolute inset-[-0.5px]"
              >
                <PendingIcon />
              </motion.span>
            )}
          </AnimatePresence>
        </span>

        <span className="relative z-10 flex min-w-0 flex-1 items-center truncate leading-5">
          <StepLabel
            label={label}
            complete={complete}
            active={active}
            reducedMotion={reducedMotion}
          />
        </span>
      </motion.div>
    </motion.div>
  );
}

/**
 * Collapsible progress block for multi-step agent work.
 *
 * The expanded list advances each task in order. Collapsing it preserves both
 * task state and the synchronized overall progress ring.
 */
export function AgentProgress({
  steps,
  stepDuration = 3000,
  completionDelay = 1000,
  onFinished,
  className,
}: AgentProgressProps) {
  const reducedMotion = usePrefersReducedMotion();
  const layoutGroupId = useId();
  const durationMs = Math.max(0, stepDuration);
  const durationSeconds = durationMs / 1000;
  const totalDuration = steps.length * durationSeconds;
  const expandedHeight = Math.max(44, 45 + 38 * steps.length);

  const [completedCount, setCompletedCount] = useState(0);
  const [minimized, setMinimized] = useState(false);
  const [reopening, setReopening] = useState(false);
  const [processingStarted, setProcessingStarted] = useState(false);

  const stepsLeft = steps.length - completedCount;
  const activeStep = steps[Math.min(completedCount, steps.length - 1)];
  const complete = completedCount >= steps.length;

  useEffect(() => {
    const timer = window.setTimeout(() => setProcessingStarted(true), 1100);
    return () => window.clearTimeout(timer);
  }, []);

  useEffect(() => {
    if (!processingStarted || complete) return;

    const timer = window.setTimeout(() => {
      setCompletedCount((count) => Math.min(count + 1, steps.length));
    }, durationMs);

    return () => window.clearTimeout(timer);
  }, [complete, completedCount, durationMs, processingStarted, steps.length]);

  useEffect(() => {
    if (!complete || !onFinished) return;

    const timer = window.setTimeout(onFinished, Math.max(0, completionDelay));
    return () => window.clearTimeout(timer);
  }, [complete, completionDelay, onFinished]);

  const statusLabel = complete
    ? "All steps completed"
    : `${stepsLeft} ${stepsLeft === 1 ? "step" : "steps"} left`;

  return (
    <MotionConfig reducedMotion={reducedMotion ? "always" : "never"}>
      <motion.div
        initial={
          reducedMotion
            ? false
            : { opacity: 0, y: -12, height: 0, filter: "blur(8px)" }
        }
        animate={{
          opacity: 1,
          y: 0,
          height: minimized ? 44 : expandedHeight,
          filter: "blur(0px)",
        }}
        transition={{
          opacity: { duration: reducedMotion ? 0 : 0.35, ease: easeOutExpo },
          y: { duration: reducedMotion ? 0 : 0.5, ease: easeOutExpo },
          filter: { duration: reducedMotion ? 0 : 0.4, ease: easeOutExpo },
          height: {
            duration: reducedMotion
              ? 0
              : minimized
                ? 0.42
                : reopening
                  ? 0.24
                  : 0.65,
            ease: easeOutExpo,
          },
        }}
        onAnimationComplete={() => {
          if (!minimized) setReopening(false);
        }}
        className={cx(
          "relative w-[341px] max-w-full overflow-hidden rounded-2xl border border-border-button-default bg-background-primary-default shadow-xs",
          className,
        )}
        aria-live="polite"
        data-testid="agent-progress"
      >
      <AnimatePresence initial={false}>
        {!complete ? (
          <motion.span
            key="persistent-progress-ring"
            className="pointer-events-none absolute top-[14px] left-[14px] z-20 flex size-4 items-center justify-center"
            initial={reducedMotion ? false : { opacity: 0, scale: 0.82 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.82 }}
            transition={{ duration: reducedMotion ? 0 : 0.25, ease: easeOutExpo }}
          >
            <ProgressRing
              running={processingStarted}
              duration={totalDuration}
              reducedMotion={reducedMotion}
            />
          </motion.span>
        ) : null}
      </AnimatePresence>

      <AnimatePresence mode="sync">
        {minimized ? (
          <motion.button
            key="minimized"
            type="button"
            data-testid="agent-progress-minimized"
            aria-label="Expand steps"
            onClick={() => {
              setReopening(true);
              setMinimized(false);
            }}
            initial={reducedMotion ? false : { opacity: 0, filter: "blur(3px)" }}
            animate={{ opacity: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, filter: "blur(3px)" }}
            transition={{
              duration: reducedMotion ? 0 : 0.2,
              delay: reducedMotion ? 0 : 0.12,
              ease: easeOutExpo,
            }}
            className="group absolute inset-0 flex cursor-pointer items-center gap-2 px-2.5 py-1.5 text-left"
          >
            <span className="flex min-w-0 shrink-0 items-center pl-1">
              <AnimatePresence initial={false}>
                {!complete ? (
                  <motion.span
                    key="progress-ring"
                    className="flex h-4 w-6 shrink-0 items-center overflow-hidden"
                    initial={reducedMotion ? false : { width: 24, opacity: 1 }}
                    animate={{ width: 24, opacity: 1 }}
                    exit={{ width: 0, opacity: 0 }}
                    transition={{ duration: reducedMotion ? 0 : 0.4, ease: easeOutExpo }}
                  />
                ) : null}
              </AnimatePresence>
              <AnimatedLabel
                label={statusLabel}
                className="text-body-medium text-text-secondary"
                reducedMotion={reducedMotion}
              />
            </span>

            {!complete ? (
              <span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-1.5 pr-6 [mask-image:linear-gradient(to_right,#000_0%,#000_100%)] transition-[mask-image] duration-300 group-hover:[mask-image:linear-gradient(to_right,#000_0%,#000_68%,transparent_94%)] motion-reduce:transition-none">
                <CurrentStepArrow />
                <span className="block min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-body-medium">
                  <LoadingText className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap">
                    {activeStep}
                  </LoadingText>
                </span>
              </span>
            ) : null}

            <span className="absolute top-1/2 right-2.5 size-5 -translate-y-1/2 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none">
              <ExpandIcon />
            </span>
          </motion.button>
        ) : (
          <motion.div
            key="expanded"
            data-testid="agent-progress-expanded"
            initial={reducedMotion ? false : { opacity: 0, filter: "blur(3px)" }}
            animate={{ opacity: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, y: -4, filter: "blur(3px)" }}
            transition={{
              duration: reducedMotion ? 0 : reopening ? 0.16 : 0.22,
              ease: easeOutExpo,
            }}
            className="absolute inset-x-0 top-0 px-2.5 pt-2 pb-2.5"
            style={{ height: expandedHeight }}
          >
            <div className="pt-1">
              <div className="flex h-5 w-full items-center pl-1">
                <AnimatePresence initial={false}>
                  {!complete ? (
                    <motion.span
                      key="progress-ring"
                      className="flex h-4 w-6 shrink-0 items-center overflow-hidden"
                      initial={reducedMotion ? false : { width: 24, opacity: 1 }}
                      animate={{ width: 24, opacity: 1 }}
                      exit={{ width: 0, opacity: 0 }}
                      transition={{ duration: reducedMotion ? 0 : 0.4, ease: easeOutExpo }}
                    />
                  ) : null}
                </AnimatePresence>

                <AnimatedLabel
                  label={statusLabel}
                  className="flex-1 text-body-medium text-text-secondary"
                  reducedMotion={reducedMotion}
                />

                <button
                  type="button"
                  aria-label="Minimize steps"
                  onClick={() => setMinimized(true)}
                  className="size-5 cursor-pointer rounded-sm transition-opacity duration-200 hover:opacity-80 motion-reduce:transition-none"
                >
                  <MinimizeIcon />
                </button>
              </div>

              <LayoutGroup id={layoutGroupId}>
                <div className="mt-[9px] flex flex-col gap-1.5">
                  {steps.map((label, index) => (
                    <ProgressStep
                      key={label}
                      label={label}
                      index={index}
                      completedCount={completedCount}
                      processingStarted={processingStarted}
                      reopening={reopening}
                      stepCount={steps.length}
                      stepDuration={durationSeconds}
                      reducedMotion={reducedMotion}
                    />
                  ))}
                </div>
              </LayoutGroup>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
      </motion.div>
    </MotionConfig>
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

AgentProgress

Collapsible progress block for multi-step agent work. The expanded list advances each task in order. Collapsing it preserves both task state and the synchronized overall progress ring.

PropTypeDefaultDescription
stepsrequiredstring[]Ordered tasks to display.
classNamestring
completionDelaynumber1000Delay in milliseconds before `onFinished` runs after the final task.
onFinished() => voidCalled after every task and the completion delay have elapsed.
stepDurationnumber3000Time in milliseconds spent on each task.