# Task List

> A live, agent-authored plan — an ordered list of steps marked pending, in progress, or completed as the agent works through them.

- Category: agents
- Job: Oversee — Show progress and reasoning while the AI works, and let people approve, pause or stop it.
- Slug: `task-list`
- HTML: https://ai-patterns.app/patterns/agents/task-list
- JSON: https://ai-patterns.app/api/patterns/agents/task-list
- Seen in: [Claude](https://code.claude.com/docs/en/interactive-mode#task-list), [Cursor](https://cursor.com/changelog/1-2)

## Summary
A compact, agent-authored plan: a short ordered list of steps the agent intends to take, each marked with a status glyph — a dashed ring while pending, a spinning arc while in progress, and a filled check once completed. It updates live as the agent works, so the user can see at a glance what's done, what's happening now, and what's next.

## When to use
- An agent is executing a multi-step task (coding, research, a migration) and has broken it into a plan up front.
- The user benefits from seeing the remaining scope — to judge how long the run will take, or to interrupt before a step they disagree with.
- Steps are coarse (3–8 items), each a meaningful unit of work rather than an individual tool call.

## When not to use
- A single undifferentiated operation — use Thinking Loader.
- Showing the raw sequence of tool calls or reasoning — use Expandable Trace; a task list is the plan, not the log.
- Steps the user must complete themselves — use Setup Checklist, which makes rows actionable.
- Parallel sub-agents each with their own steps — use Multi-Agent Trace.

## Anatomy
- Container: a bordered, rounded card holding an ordered list.
- Row: status glyph + label, top-aligned so wrapped labels stay readable.
- Status glyph — pending: a dashed, muted ring ("not started", without implying failure).
- Status glyph — in progress: a spinning partial arc over a faint track, in the foreground color.
- Status glyph — completed: a filled muted circle with a check.
- Label: one line where possible; the in-progress row is emphasised (medium weight) and completed rows fade to muted text.

## Behavior
- At most one task is in progress at a time in a sequential plan; the next pending task flips to in progress as the previous one completes.
- Status changes swap the glyph with a short spring (scale + fade) in place — rows never reorder or shift.
- The agent may revise the plan mid-run: new tasks are appended or inserted where they'll execute, and obsolete ones removed, without resetting completed rows.
- Optionally show a present-continuous label ("Running the build") while a task is in progress, reverting to the imperative label ("Run the build") once done.
- When every task is completed, the list stays visible as a record of what was done; no extra success state is needed.

## Content guidelines
- Write each label as an imperative verb phrase describing an outcome: "Add a Task List pattern", not "Adding stuff" or "Step 2".
- Keep labels concrete and scoped — the user should be able to tell what changes when a row turns complete.
- Prefer 3–8 tasks. Fewer means the plan isn't worth showing; more means the steps are too granular.
- Don't put timing, file paths, or tool names in the label — those belong in the trace.

## Accessibility
- Render as an ordered list (`<ol>`) with an accessible name that includes progress ("Agent plan, 1 of 3 completed").
- Each glyph is decorative (`aria-hidden`); expose status as visually hidden text before the label ("Completed: …", "In progress: …").
- Mark the in-progress row with `aria-current="step"`.
- Don't make the whole list a live region — announce only meaningful transitions (a task completing) if needed, to avoid chatter.
- Respect `prefers-reduced-motion`: stop the arc spinning; the partial arc alone still reads as "in progress".
- Status is never conveyed by color alone — each state has a distinct glyph shape.

## Related patterns
- **Setup Checklist** — Use it instead when the rows are steps the person completes rather than steps the agent works through.
- **Multi-Agent Trace** — Use it instead when the plan is split across parallel sub-agents.
- **Expandable Trace** — Use it as well when people need the detailed log of what happened during each step.
- **Background Tasks Drawer** — Use it instead when the work runs long and outside the conversation.

## Implementation

Reference implementation (React, TypeScript, Tailwind v4, Motion, Radix). Keep every rule from the spec above; if the host project has its own design system, re-express the markup and motion in it rather than copying these classes.

npm dependencies: `lucide-react`, `motion`

Local imports to provide: `@/lib/utils` (`cn` is the usual `clsx` + `tailwind-merge` helper).

```tsx title="component.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check } from "lucide-react";

import { cn } from "@/lib/utils";

export type TaskStatus = "pending" | "in_progress" | "completed";

export interface Task {
  id: string;
  /** Imperative label, e.g. "Run the build". */
  label: string;
  /** Optional present-continuous label shown while the task is in progress, e.g. "Running the build". */
  activeLabel?: string;
  status: TaskStatus;
}

export interface TaskListProps {
  tasks: Task[];
  /** Accessible name for the list. */
  label?: string;
  className?: string;
}

const STATUS_TEXT: Record<TaskStatus, string> = {
  pending: "Pending",
  in_progress: "In progress",
  completed: "Completed",
};

export function TaskList({ tasks, label = "Task list", className }: TaskListProps) {
  const done = tasks.filter((t) => t.status === "completed").length;

  return (
    <div className={cn("w-full rounded-2xl border bg-card px-4 py-3", className)}>
      <ol aria-label={`${label}, ${done} of ${tasks.length} completed`} className="space-y-2.5">
        {tasks.map((task) => (
          <TaskRow key={task.id} task={task} />
        ))}
      </ol>
    </div>
  );
}

function TaskRow({ task }: { task: Task }) {
  const completed = task.status === "completed";
  const active = task.status === "in_progress";
  const text = active && task.activeLabel ? task.activeLabel : task.label;

  return (
    <li
      aria-current={active ? "step" : undefined}
      className="flex items-start gap-2.5"
    >
      <span className="relative mt-px flex size-4 shrink-0 items-center justify-center">
        <AnimatePresence initial={false} mode="popLayout">
          <motion.span
            key={task.status}
            initial={{ scale: 0.5, opacity: 0 }}
            animate={{ scale: 1, opacity: 1 }}
            exit={{ scale: 0.5, opacity: 0 }}
            transition={{ type: "spring", stiffness: 420, damping: 26 }}
            className="absolute inset-0 flex items-center justify-center"
          >
            <StatusIcon status={task.status} />
          </motion.span>
        </AnimatePresence>
        <span className="sr-only">{STATUS_TEXT[task.status]}: </span>
      </span>

      <span
        className={cn(
          "min-w-0 flex-1 text-sm leading-snug transition-colors duration-300",
          completed ? "text-muted-foreground" : "text-foreground",
          active && "font-medium"
        )}
      >
        {text}
      </span>
    </li>
  );
}

function StatusIcon({ status }: { status: TaskStatus }) {
  if (status === "completed") {
    return (
      <span className="flex size-4 items-center justify-center rounded-full bg-muted-foreground" aria-hidden>
        <Check className="size-2.5 text-background" strokeWidth={3.5} />
      </span>
    );
  }

  if (status === "in_progress") {
    return (
      // A CSS spin, not a Motion `animate`: inside `AnimatePresence initial={false}`
      // Motion skips the first animation of every nested motion element, so a
      // task that is already in progress on mount would never start spinning.
      <svg
        viewBox="0 0 16 16"
        className="size-4 text-foreground motion-safe:animate-spin motion-safe:[animation-duration:0.9s]"
        fill="none"
        aria-hidden
      >
        <circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeOpacity={0.15} strokeWidth="1.5" />
        <path d="M8 1.75a6.25 6.25 0 0 1 6.25 6.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
      </svg>
    );
  }

  return (
    <svg viewBox="0 0 16 16" className="size-4 text-muted-foreground/60" fill="none" aria-hidden>
      <circle
        cx="8"
        cy="8"
        r="6.25"
        stroke="currentColor"
        strokeWidth="1.25"
        strokeDasharray="2 2"
        strokeLinecap="round"
      />
    </svg>
  );
}
```
