# Task List

> The agent's plan as a todo list: segmented progress, shimmering current task, live and final durations, failure details, sub-tasks and a folded Completed group.

- **Slug:** `task-list` · **Category:** agent · **Status:** beta
- **Import:** `import { TaskList, TaskListItem } from "@kura-ui/react/task-list";`
- **Docs:** https://kuro.abstergo.fyi/components/task-list
- **Registry item:** https://kuro.abstergo.fyi/r/task-list.json
- **Tags:** agent, plan, todo, tasks, checklist, progress, claude-code, run

## Install

```bash
# shadcn registry (copies source into your project)
npx shadcn@latest add https://kuro.abstergo.fyi/r/task-list.json
# Kura CLI (same, plus dependency resolution and --json output)
npx @kura-ui/cli add task-list
# npm package
npm install @kura-ui/react
```

## When to use

- Show the todo list a coding agent maintains while it works (Claude Code TodoWrite, Codex plan, Cursor tasks) in a run sidebar or panel.
- Give a dense, scannable answer to 'what is the agent doing and what is left?' — '3 of 7' plus one row per task.
- Let the user click a task to show its output or diff next to the list (`onSelect` + `selectedId`).
- Show a failed task with its reason (`detail`) and a task blocked on the human (`waiting`).

## When not to use

- A narrative step timeline with connectors and inline tool calls under each step — use agent-timeline.
- Parallel subagents working at the same time — use agent-fleet.
- Timing analysis of nested spans on a shared time axis — use trace-waterfall.
- A single tool invocation with arguments and result — use tool-call.

## Props

| Prop | Type | Default | Required | Description |
| --- | --- | --- | --- | --- |
| `items` | `readonly TaskListItem[]` | — | yes | The plan, in order. TaskListItem = { id, title, status: "queued" \| "running" \| "waiting" \| "done" \| "failed" \| "skipped" \| "paused", detail?, durationMs?, startedAt?, children? }. |
| `title` | `React.ReactNode` | `"Plan"` | no | Heading in the panel header (panel variant only). |
| `actions` | `React.ReactNode` | — | no | Controls on the right of the header, after the progress bar (e.g. an IconButton). |
| `variant` | `"panel" \| "plain"` | `"panel"` | no | panel = card with header, '3 of 7' and segmented progress; plain = rows only, for embedding in your own sidebar. |
| `collapseCompleted` | `boolean` | `true` | no | Fold the leading run of finished (done/skipped) items into a 'Completed (n)' row while work remains. Needs at least three finished items; a fully finished plan is shown in full. |
| `completedOpen` | `boolean` | — | no | Whether the Completed group is expanded (controlled). |
| `defaultCompletedOpen` | `boolean` | `false` | no | Initial expanded state of the Completed group when uncontrolled. |
| `onCompletedOpenChange` | `(open: boolean) => void` | — | no | Called when the Completed group is toggled. |
| `selectedId` | `string \| null` | — | no | Highlighted item (bg-kura-active plus a 2px accent bar). Purely visual; pair with onSelect. |
| `onSelect` | `(id: string, item: TaskListItem) => void` | — | no | Makes rows buttons (with Up/Down/Home/End navigation) and reports the clicked item. Without it rows are static. |
| `empty` | `React.ReactNode` | — | no | Content when `items` is empty. Default: a quiet 'No plan yet' state. |
| `label` | `string` | — | no | Accessible name of the list. Default: `title` when it is a string, else 'Plan'. |

Native attributes of the root element, `className` and `ref` are passed through.

## Anatomy

- **TaskList** (`data-slot="task-list"`) — Root. `data-variant`, `data-progress="done/total"`; contains a polite status region.
- **Header** (`data-slot="task-list-header"`) — h-10 header: title, mono 'n of m', segmented progress, actions.
- **Progress** (`data-slot="task-list-progress"`) — One 4px segment per top-level item, coloured by status (neutral done, blue running, amber waiting, red failed).
- **Completed** (`data-slot="task-list-completed"`) — The folded group of finished items. `data-state="open" | "closed"`; its button has aria-expanded.
- **Item** (`data-slot="task-list-item"`) — <li> with `data-status` and `data-selected`; the running item has aria-current="step".
- **Row** (`data-slot="task-list-row"`) — The row: StatusGlyph, title, detail, duration. A <button> when onSelect is set.
- **Title** (`data-slot="task-list-title"`) — Shimmers while running; muted when done; struck through when skipped. Status is spoken after it.
- **Detail** (`data-slot="task-list-detail"`) — Second line. Red for failed, amber for waiting, subtle otherwise.
- **Duration** (`data-slot="task-list-duration"`) — Mono, tabular, width-reserved. Live clock (0:42) while running, final duration (1m 12s) after.
- **Children** (`data-slot="task-list-children"`) — Nested <ul> of sub-tasks with a hairline guide; the parent shows 'done/total'.
- **Empty** (`data-slot="task-list-empty"`) — Rendered when there are no items.

## Accessibility

- Role: `list`
- A <ul> labelled by `label`/`title`; each item speaks its status after the title (“Run pnpm test (failed)”), and the running item has aria-current="step".
- A polite status region announces the current task (“Task 4 of 7: Patch src/middleware.ts”), a failure, “Waiting for you” or “All tasks complete”. Live timers are never announced.
- With onSelect, rows are buttons with aria-pressed reflecting selectedId; the Completed toggle has aria-expanded/aria-controls.
- Status is never colour-only: the glyph shape and the spoken status carry it.
- Reduced motion: Rows fade in without rising, the title shimmer and running segment pulse stop, and the chevron turns without animating.

| Keys | Action |
| --- | --- |
| Tab | Moves into the list (rows are buttons when onSelect is set). |
| ArrowDown / ArrowUp | Moves focus to the next / previous row. |
| Home / End | Moves focus to the first / last row. |
| Enter / Space | Selects the focused task, or toggles the Completed group. |

## Notes for agents

- Map a model-maintained todo list directly: pending → queued, in_progress → running, completed → done. Keep ids stable so rows don't remount.
- Pass `startedAt` for the running item (from your server) so the live timer survives reloads; pass `durationMs` once it finishes.
- Only one top-level item should be `running`; it carries the blue signal and the shimmer.
- Use `waiting` (amber) only when the agent is blocked on the human, and put the ask in `detail`.
- The Completed fold only collapses the *leading* finished items, so a failed task in the middle is never hidden.

## Examples

### A plan in progress (minimal)

Two done, one running with a live timer, one queued.

```tsx
import { TaskList } from "@kura-ui/react/task-list";

export function TaskListMinimal() {
  return (
    <TaskList
      className="w-full max-w-md"
      items={[
        { id: "read", title: "Read routes in src/app", status: "done", durationMs: 21_400 },
        { id: "map", title: "Map call sites of fetchUser()", status: "done", durationMs: 48_200 },
        {
          id: "patch",
          title: "Patch src/auth/client.ts",
          status: "running",
          startedAt: Date.now() - 34_000,
        },
        { id: "test", title: "Run pnpm test", status: "queued" },
      ]}
    />
  );
}
```

### Live run (full)

The auth-client migration plays out: finished tasks fold into Completed, sub-tasks tick, the test run fails, a fix task appears, the re-run passes, the reviewer signs off and the PR opens.

```tsx
"use client";

import { RotateCcw } from "lucide-react";
import * as React from "react";
import { IconButton } from "@kura-ui/react/icon-button";
import { TaskList, type TaskListItem } from "@kura-ui/react/task-list";

const PLAN: TaskListItem[] = [
  { id: "routes", title: "Read routes in src/app", status: "done", durationMs: 21_400 },
  {
    id: "map",
    title: "Map call sites of fetchUser()",
    status: "done",
    durationMs: 48_200,
    detail: "14 call sites across 9 files",
  },
  {
    id: "client",
    title: "Patch src/auth/client.ts",
    status: "done",
    durationMs: 72_900,
  },
  { id: "middleware", title: "Patch src/middleware.ts", status: "running" },
  {
    id: "tests",
    title: "Update tests",
    status: "queued",
    children: [
      { id: "tests-login", title: "login.test.ts", status: "queued" },
      { id: "tests-session", title: "session.test.ts", status: "queued" },
    ],
  },
  { id: "run", title: "Run pnpm test", status: "queued" },
  { id: "review", title: "Review the diff", status: "queued" },
  { id: "pr", title: "Open pull request", status: "queued" },
];

function set(items: TaskListItem[], id: string, patch: Partial<TaskListItem>): TaskListItem[] {
  return items.map((item) =>
    item.id === id
      ? { ...item, ...patch }
      : item.children
        ? { ...item, children: set([...item.children], id, patch) }
        : item,
  );
}

const SCRIPT: Beat[] = [
  {
    at: 2600,
    patch: (s) =>
      set(
        set(set(s, "middleware", { status: "done", durationMs: 41_300 }), "tests", {
          status: "running",
          startedAt: Date.now(),
        }),
        "tests-login",
        { status: "running", startedAt: Date.now() },
      ),
  },
  {
    at: 4400,
    patch: (s) =>
      set(set(s, "tests-login", { status: "done", durationMs: 18_200 }), "tests-session", {
        status: "running",
        startedAt: Date.now(),
      }),
  },
  {
    at: 6000,
    patch: (s) =>
      set(
        set(set(s, "tests-session", { status: "done", durationMs: 12_700 }), "tests", {
          status: "done",
          durationMs: 30_900,
        }),
        "run",
        { status: "running", startedAt: Date.now(), detail: "pnpm test --filter @acme/web" },
      ),
  },
  {
    at: 8400,
    patch: (s) =>
      insertAfter(
        set(s, "run", {
          status: "failed",
          durationMs: 38_400,
          detail: "login.test.ts › refreshes an expired session — expected 200, received 401",
        }),
        "run",
        {
          id: "fix",
          title: "Retry refresh with the rotated token",
          status: "running",
          startedAt: Date.now(),
          detail: "src/auth/client.ts:88",
        },
      ),
  },
  {
    at: 11_000,
    patch: (s) =>
      set(set(s, "fix", { status: "done", durationMs: 52_100, detail: undefined }), "run", {
        status: "running",
        startedAt: Date.now(),
        detail: "pnpm test --filter @acme/web",
      }),
  },
  {
    at: 13_200,
    patch: (s) =>
      set(set(s, "run", { status: "done", durationMs: 36_800, detail: "142 passed" }), "review", {
        status: "running",
        startedAt: Date.now(),
        detail: "reviewer subagent",
      }),
  },
  {
    at: 15_400,
    patch: (s) =>
      set(
        set(s, "review", { status: "done", durationMs: 186_000, detail: "2 nits, 0 blocking" }),
        "pr",
        { status: "running", startedAt: Date.now() },
      ),
  },
  {
    at: 17_000,
    patch: (s) => set(s, "pr", { status: "done", durationMs: 48_100, detail: "acme/web#482" }),
  },
];

function insertAfter(items: TaskListItem[], id: string, next: TaskListItem): TaskListItem[] {
  const i = items.findIndex((item) => item.id === id);
  return [...items.slice(0, i + 1), next, ...items.slice(i + 1)];
}

export function TaskListFull() {
  const [items, setItems] = React.useState<TaskListItem[]>(() =>
    set(PLAN, "middleware", { startedAt: Date.now() - 27_000 }),
  );
  const [selectedId, setSelectedId] = React.useState<string | null>(null);
  const [run, setRun] = React.useState(0);

  // biome-ignore lint/correctness/useExhaustiveDependencies: `run` replays the script
  React.useEffect(() => {
    setItems(set(PLAN, "middleware", { startedAt: Date.now() - 27_000 }));
    const timers = SCRIPT.map((beat) => setTimeout(() => setItems(beat.patch), beat.at));
    return () => timers.forEach(clearTimeout);
  }, [run]);

  return (
    <TaskList
      className="w-full max-w-md"
      items={items}
      label="Migrate to @acme/auth client"
      selectedId={selectedId}
      onSelect={(id) => setSelectedId((cur) => (cur === id ? null : id))}
      actions={
        <IconButton
          label="Replay"
          icon={<RotateCcw />}
          variant="ghost"
          size="xs"
          className="-mr-1.5"
          onClick={() => setRun((r) => r + 1)}
        />
      }
    />
  );
}
```

### Every state (state)

Done, skipped, waiting for approval, failed with a reason, paused and queued.

```tsx
import { TaskList } from "@kura-ui/react/task-list";

export function TaskListStates() {
  return (
    <TaskList
      className="w-full max-w-md"
      title="Release checklist"
      collapseCompleted={false}
      items={[
        { id: "a", title: "Bump version to 2.4.0", status: "done", durationMs: 3_200 },
        {
          id: "b",
          title: "Regenerate API types",
          status: "skipped",
          detail: "Schema unchanged since 2.3.1",
        },
        {
          id: "c",
          title: "Run database migration 0042_sessions",
          status: "waiting",
          detail: "Needs your approval — touches production",
        },
        {
          id: "d",
          title: "Build preview deployment",
          status: "failed",
          durationMs: 84_100,
          detail: "next build exited with code 1 — Type error in src/middleware.ts:31",
        },
        { id: "e", title: "Notify #releases", status: "paused" },
        { id: "f", title: "Tag release", status: "queued" },
      ]}
    />
  );
}
```

### Plan beside output (composition)

The plain variant in a sidebar; selecting a task shows its output on the right.

```tsx
"use client";

import * as React from "react";
import { TaskList, type TaskListItem } from "@kura-ui/react/task-list";

const OUTPUT: Record<string, string[]> = {
  routes: [
    "$ rg --files src/app | rg route",
    "src/app/api/session/route.ts",
    "src/app/login/page.tsx",
    "src/app/(app)/layout.tsx",
  ],
  map: [
    "explorer · 14 call sites of fetchUser()",
    "src/app/(app)/layout.tsx:12",
    "src/components/nav/user-menu.tsx:8",
    "src/lib/server/session.ts:44",
    "… 11 more",
  ],
  client: [
    "Edited src/auth/client.ts  +64 −22",
    "Replaced legacy REST session calls",
    "with createAuthClient({ baseUrl })",
  ],
  run: [
    "$ pnpm test",
    "✕ login.test.ts › refreshes an expired session",
    "  expected 200, received 401",
    "141 passed, 1 failed (38.4s)",
  ],
};

export function TaskListComposition() {
  const [selectedId, setSelectedId] = React.useState("run");
  const [t0] = React.useState(() => Date.now());
  const items: TaskListItem[] = [
    { id: "routes", title: "Read routes", status: "done", durationMs: 21_400 },
    { id: "map", title: "Map fetchUser() calls", status: "done", durationMs: 48_200 },
    { id: "client", title: "Patch auth client", status: "done", durationMs: 72_900 },
    {
      id: "run",
      title: "Run pnpm test",
      status: "failed",
      durationMs: 38_400,
      detail: "1 failed",
    },
    { id: "fix", title: "Fix token refresh", status: "running", startedAt: t0 - 19_000 },
  ];
  const lines = OUTPUT[selectedId] ?? ["No output yet."];
  return (
    <div className="grid w-full max-w-2xl overflow-hidden rounded-kura-xl bg-kura-surface shadow-kura-sm sm:grid-cols-[240px_1fr]">
      <div className="border-kura-border-subtle border-b sm:border-r sm:border-b-0">
        <div className="flex h-10 items-center px-3.5 font-medium text-[13px]">Plan</div>
        <TaskList
          variant="plain"
          collapseCompleted={false}
          items={items}
          selectedId={selectedId}
          onSelect={(id) => setSelectedId(id)}
          label="Plan"
        />
      </div>
      <div className="flex min-w-0 flex-col">
        <div className="flex h-10 items-center gap-2 border-kura-border-subtle border-b px-3.5">
          <span className="truncate font-medium text-[13px]">
            {items.find((i) => i.id === selectedId)?.title}
          </span>
          <span className="ml-auto font-kura-mono text-[12px] text-kura-fg-subtle">output</span>
        </div>
        <pre className="m-0 min-h-40 flex-1 overflow-auto bg-kura-surface-sunken px-3.5 py-3 font-kura-mono text-[12px] text-kura-fg-muted leading-5">
          {lines.join("\n")}
        </pre>
      </div>
    </div>
  );
}
```

## Dependencies

- npm: `lucide-react`
- Kura registry items: `kura-theme`, `kura-format`, `shimmer-text`, `status-glyph`, `use-controllable-state`, `use-elapsed`, `utils`
- Related: `agent-timeline`, `agent-fleet`, `run-header`, `trace-waterfall`, `status-glyph`
