Skip to content
loopkit
free128 lines · framer-motion

Agent Status

Who's working, who's stuck, who needs you. An avatar stack of agents with status rings (idle, thinking, working, needs you, error), counts by state, and a detail line on hover.

Use it for
App headers, team views, sidebars.
Themes
Light + dark
License
Commercial use (draft)
Live
  • +2
2 working1 needs you1 error

Hover an agent to see what it is doing.

tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

export type AgentState = "idle" | "thinking" | "working" | "waiting" | "error";

export interface AgentInfo {
  id: string;
  name: string;
  state: AgentState;
  task?: string;
}

export interface AgentStatusProps {
  agents?: AgentInfo[];
  /** Avatars shown before collapsing into +N. */
  max?: number;
  /** Cycle states for demos. */
  autoplay?: boolean;
  size?: "sm" | "md" | "lg";
  className?: string;
}

const STATE: Record<AgentState, { label: string; dot: string; ring: string; text: string }> = {
  idle: { label: "Idle", dot: "bg-muted-foreground/50", ring: "ring-border", text: "text-muted-foreground" },
  thinking: { label: "Thinking", dot: "bg-info", ring: "ring-info", text: "text-info" },
  working: { label: "Working", dot: "bg-ok", ring: "ring-ok", text: "text-ok" },
  waiting: { label: "Needs you", dot: "bg-signal", ring: "ring-signal", text: "text-signal" },
  error: { label: "Error", dot: "bg-destructive", ring: "ring-destructive", text: "text-destructive" },
};

const DEFAULT: AgentInfo[] = [
  { id: "a", name: "triage", state: "working", task: "Labelling 14 issues" },
  { id: "b", name: "coder", state: "waiting", task: "PR #482 needs approval" },
  { id: "c", name: "research", state: "thinking", task: "Reading Stripe docs" },
  { id: "d", name: "support", state: "working", task: "Replying to ticket 9921" },
  { id: "e", name: "deploy", state: "idle" },
  { id: "f", name: "finance", state: "idle" },
  { id: "g", name: "security", state: "error", task: "Scanner timed out" },
];

const ORDER: AgentState[] = ["idle", "thinking", "working", "waiting", "working", "idle"];
const SIZES = { sm: "size-8 text-[10px]", md: "size-10 text-[11px]", lg: "size-12 text-xs" };

export function AgentStatus({ agents: initial = DEFAULT, max = 5, autoplay = true, size = "md", className }: AgentStatusProps) {
  const reduce = useReducedMotion();
  const [agents, setAgents] = React.useState(initial);
  const [open, setOpen] = React.useState<string | null>(null);

  React.useEffect(() => setAgents(initial), [initial]);
  React.useEffect(() => {
    if (!autoplay) return;
    let i = 0;
    const t = setInterval(() => {
      i++;
      setAgents((as) => as.map((a, k) => (k === i % as.length && a.state !== "error" ? { ...a, state: ORDER[(ORDER.indexOf(a.state) + 1) % ORDER.length] } : a)));
    }, 1600);
    return () => clearInterval(t);
  }, [autoplay]);

  const shown = agents.slice(0, max);
  const rest = agents.length - shown.length;
  const counts = agents.reduce<Record<string, number>>((m, a) => ((m[a.state] = (m[a.state] ?? 0) + 1), m), {});
  const active = agents.find((a) => a.id === open);

  return (
    <div className={cn("inline-flex w-full max-w-md flex-col gap-3 rounded-2xl border border-border bg-card p-4 text-card-foreground", className)}>
      <div className="flex items-center gap-4">
        <ul className="flex -space-x-2" aria-label="Agents">
          {shown.map((a) => {
            const s = STATE[a.state];
            return (
              <li key={a.id} className="relative">
                <button
                  type="button"
                  onClick={() => setOpen((o) => (o === a.id ? null : a.id))}
                  onMouseEnter={() => setOpen(a.id)}
                  aria-label={`${a.name}: ${s.label}${a.task ? `, ${a.task}` : ""}`}
                  aria-expanded={open === a.id}
                  className={cn("relative grid place-items-center rounded-full bg-primary font-mono font-bold uppercase text-primary-foreground ring-[3px] ring-offset-2 ring-offset-card transition-transform hover:z-10 hover:-translate-y-0.5", SIZES[size], s.ring)}
                >
                  {a.name.slice(0, 2)}
                  <span className={cn("absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-card", s.dot)} />
                  {!reduce && (a.state === "thinking" || a.state === "waiting") && (
                    <motion.span className={cn("absolute inset-0 rounded-full ring-2", s.ring)} animate={{ scale: [1, 1.35], opacity: [0.7, 0] }} transition={{ repeat: Infinity, duration: 1.5 }} aria-hidden />
                  )}
                </button>
              </li>
            );
          })}
          {rest > 0 && (
            <li className={cn("grid place-items-center rounded-full bg-muted font-mono font-bold text-muted-foreground ring-[3px] ring-card", SIZES[size])} aria-label={`${rest} more agents`}>
              +{rest}
            </li>
          )}
        </ul>
        <div className="ml-auto flex flex-wrap justify-end gap-1.5" aria-live="polite">
          {(["working", "waiting", "error"] as AgentState[]).filter((k) => counts[k]).map((k) => (
            <span key={k} className={cn("inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 font-mono text-[10.5px] font-semibold", STATE[k].text)}>
              <span className={cn("size-1.5 rounded-full", STATE[k].dot)} />
              {counts[k]} {STATE[k].label.toLowerCase()}
            </span>
          ))}
        </div>
      </div>
      <div className="min-h-[2.75rem]">
        <AnimatePresence mode="wait" initial={false}>
          {active ? (
            <motion.div key={active.id + active.state} initial={reduce ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="flex items-center gap-3 rounded-xl bg-muted px-3 py-2">
              <span className="font-mono text-[12px] font-semibold">{active.name}</span>
              <span className={cn("font-mono text-[10.5px] font-bold uppercase tracking-wider", STATE[active.state].text)}>{STATE[active.state].label}</span>
              <span className="truncate text-[12.5px] text-muted-foreground">{active.task ?? "No task"}</span>
            </motion.div>
          ) : (
            <motion.p key="hint" initial={false} animate={{ opacity: 1 }} className="px-1 py-2 text-[12.5px] text-muted-foreground">
              Hover an agent to see what it is doing.
            </motion.p>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

export default AgentStatus;

How it behaves

  • →Avatars overlap; each has a coloured ring and a status dot. Thinking and needs-you rings pulse.
  • →Beyond max avatars, a +N chip collapses the rest.
  • →Summary chips count working / needs you / error.
  • →Hover or click an avatar to show its current task underneath.

Accessibility

  • ✓Each avatar is a button whose label includes name, state and task.
  • ✓Counts are aria-live=polite.
  • ✓Honours prefers-reduced-motion.