shadimage
Assistant UI
componentagent-statussource: ok · hit

Agent status

A runtime task summary chip with an optional tray for every task in the thread.

npx shadcn add "https://r.assistant-ui.com/agent-status.json"
free

Where it goes

stats

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@assistant-ui/react

Registry dependencies

https://r.assistant-ui.com/elements-agent-status.jsonhttps://r.assistant-ui.com/elements-task-card.jsonhttps://r.assistant-ui.com/elements-task.jsonpopover

Files1 · source cached 10/8/2026, 9:40:07 PM

components/assistant-ui/elements/agent-status.aui.tsx
"use client";

import { useAuiState, type TaskState } from "@assistant-ui/react";
import { ChevronDownIcon } from "lucide-react";
import { type FC, useMemo, useState } from "react";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import {
  AgentStatus as AgentStatusBase,
  type AgentState,
} from "./agent-status";
import { mono } from "./surfaces";
import { TaskStateIcon } from "./task-card";
import {
  formatElapsed,
  TASK_PAGE_SIZE,
  taskLabel,
  taskMeta,
  taskStateOf,
  useTaskElapsed,
} from "../utils/task";

export type TaskSummary = {
  readonly total: number;
  readonly running: number;
  readonly waiting: number;
  readonly failed: number;
  readonly startedAt: number | undefined;
  readonly runningLabel: string | undefined;
};

const summarize = (tasks: readonly TaskState[]): TaskSummary => {
  let running = 0;
  let waiting = 0;
  let failed = 0;
  let startedAt: number | undefined;
  let runningLabel: string | undefined;
  for (const task of tasks) {
    const state = taskStateOf(task.status, task.isError);
    if (state === "working") {
      running += 1;
      runningLabel ??= taskLabel(task.toolName, task.args);
      const taskStartedAt = task.timing?.startedAt;
      if (
        taskStartedAt !== undefined &&
        (startedAt === undefined || taskStartedAt < startedAt)
      ) {
        startedAt = taskStartedAt;
      }
    } else if (state === "waiting") {
      waiting += 1;
    } else if (state === "failed") {
      failed += 1;
    }
  }
  return {
    total: tasks.length,
    running,
    waiting,
    failed,
    startedAt,
    runningLabel,
  };
};

export const useTaskSummary = (): TaskSummary => {
  const tasks = useAuiState((s) => s.thread.tasks);
  return useMemo(() => summarize(tasks), [tasks]);
};

const plural = (count: number, noun: string) =>
  `${count} ${count === 1 ? noun : `${noun}s`}`;

export const summaryState = (summary: TaskSummary): AgentState => {
  if (summary.running > 0) return "working";
  if (summary.waiting > 0) return "waiting";
  return summary.failed > 0 ? "failed" : "done";
};

export const summaryLabel = (summary: TaskSummary) => {
  if (summary.running === 1 && summary.runningLabel !== undefined) {
    return summary.runningLabel;
  }
  if (summary.running > 0) {
    return `${summary.running} of ${plural(summary.total, "task")} running`;
  }
  if (summary.waiting > 0) {
    return `${plural(summary.waiting, "task")} waiting for input`;
  }
  if (summary.failed > 0) {
    return `${plural(summary.total, "task")} done, ${summary.failed} failed`;
  }
  return `${plural(summary.total, "task")} done`;
};

export const AgentStatus: FC<{ className?: string }> = ({ className }) => {
  const summary = useTaskSummary();
  const elapsedMs = useTaskElapsed(
    summary.startedAt === undefined
      ? undefined
      : { startedAt: summary.startedAt },
    summary.running > 0,
  );
  if (summary.running === 0 && summary.waiting === 0) return null;

  return (
    <AgentStatusBase
      className={className}
      state={summaryState(summary)}
      label={summaryLabel(summary)}
      elapsed={elapsedMs === undefined ? undefined : formatElapsed(elapsedMs)}
    />
  );
};

const TaskTrayItem: FC<{ task: TaskState }> = ({ task }) => {
  const state = taskStateOf(task.status, task.isError);
  const meta = taskMeta(task.args);
  const elapsedMs = useTaskElapsed(
    task.timing,
    task.status.type === "running" || task.status.type === "requires-action",
  );

  return (
    <li
      data-slot="aui_task-tray-item"
      data-state={state}
      className="flex items-center gap-2.5 rounded-lg py-2 pe-2.5 text-[13px]"
      style={{ paddingInlineStart: `${0.625 + task.depth * 0.75}rem` }}
    >
      <TaskStateIcon state={state} />
      <span className="sr-only">{state}</span>
      <span className="min-w-0 flex-1 truncate">
        {taskLabel(task.toolName, task.args)}
      </span>
      {meta !== undefined && (
        <span
          className={cn(mono, "text-foreground/35 max-w-24 shrink-0 truncate")}
        >
          {meta}
        </span>
      )}
      {elapsedMs !== undefined && (
        <span className={cn(mono, "text-foreground/30 shrink-0 tabular-nums")}>
          {formatElapsed(elapsedMs)}
        </span>
      )}
    </li>
  );
};

export const TaskTray: FC<{ className?: string }> = ({ className }) => {
  const tasks = useAuiState((s) => s.thread.tasks);
  const summary = useMemo(() => summarize(tasks), [tasks]);
  const elapsedMs = useTaskElapsed(
    summary.startedAt === undefined
      ? undefined
      : { startedAt: summary.startedAt },
    summary.running > 0,
  );
  const [open, setOpen] = useState(false);
  const [visible, setVisible] = useState(TASK_PAGE_SIZE);
  const firstTask = tasks[0];
  const listKey =
    firstTask === undefined ? "" : `${firstTask.messageId}:${firstTask.id}`;
  const [seenListKey, setSeenListKey] = useState(listKey);
  if (seenListKey !== listKey) {
    setSeenListKey(listKey);
    setVisible(TASK_PAGE_SIZE);
  }
  if (summary.total === 0 && open) setOpen(false);
  if (summary.total === 0) return null;
  const hidden = Math.max(0, tasks.length - visible);

  return (
    <Popover
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        if (!next) setVisible(TASK_PAGE_SIZE);
      }}
    >
      <PopoverTrigger
        className={cn(
          "focus-visible:ring-ring/50 cursor-pointer appearance-none rounded-full border-0 bg-transparent p-0 text-start focus-visible:ring-[3px] focus-visible:outline-none",
          className,
        )}
      >
        <AgentStatusBase
          state={summaryState(summary)}
          label={summaryLabel(summary)}
          elapsed={
            elapsedMs === undefined ? undefined : formatElapsed(elapsedMs)
          }
          trailing={
            <ChevronDownIcon
              className={cn(
                "size-3 transition-transform duration-200 motion-reduce:transition-none",
                open && "rotate-180",
              )}
            />
          }
        />
      </PopoverTrigger>
      <PopoverContent align="end" className="w-80 p-1">
        <ul
          data-slot="aui_task-tray"
          aria-label="Tasks"
          className="flex max-h-80 flex-col overflow-y-auto"
        >
          {tasks.slice(0, visible).map((task, index) => (
            <TaskTrayItem key={`${index}:${task.id}`} task={task} />
          ))}
          {hidden > 0 && (
            <li className="flex">
              <button
                type="button"
                data-slot="aui_task-tray-more"
                onClick={() => setVisible((count) => count + TASK_PAGE_SIZE)}
                className="text-muted-foreground hover:text-foreground px-2.5 py-2 text-xs transition-colors"
              >
                Show {Math.min(hidden, TASK_PAGE_SIZE)} more
              </button>
            </li>
          )}
        </ul>
      </PopoverContent>
    </Popover>
  );
};

More from Assistant UI

Browse all components in this library →