wui
组件

AI 任务清单 AI Todo

用于清晰展示 AI Agent 的执行计划分解、当前执行阶段与子任务实时进度。

第三方依赖 · class-variance-authority第三方依赖 · lucide-react第三方依赖 · motion

基础用法

在 Agent 执行复杂多步骤任务时,展示结构化的待办清单与进度指示:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/ai-todo
安装基础依赖与图标库
pnpm add lucide-react class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/ai-todo.tsx
components/ui/ai-todo.tsx
"use client"

import * as React from "react"
import { cva } from "class-variance-authority"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import {
  BanIcon,
  ListTodoIcon,
  LoaderCircleIcon,
} from "lucide-react"

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

export type AiTodoStatus =
  | "pending"
  | "in-progress"
  | "completed"
  | "cancelled"

const easeOut = [0.22, 1, 0.36, 1] as const
const popSpring = {
  type: "spring",
  stiffness: 520,
  damping: 30,
  mass: 0.6,
} as const

const aiTodoIndicatorVariants = cva(
  "relative flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors duration-300",
  {
    variants: {
      status: {
        pending: "border-border text-muted-foreground",
        "in-progress": "border-info-border bg-info-subtle text-info",
        completed: "border-success bg-success text-success-foreground",
        cancelled: "border-border bg-muted text-muted-foreground",
      },
    },
    defaultVariants: { status: "pending" },
  }
)

const statusLabels: Record<AiTodoStatus, string> = {
  pending: "待处理",
  "in-progress": "进行中",
  completed: "已完成",
  cancelled: "已取消",
}

function AiTodo({ className, ...props }: React.ComponentProps<"section">) {
  return (
    <section
      data-slot="ai-todo"
      className={cn("overflow-hidden rounded-md border bg-background", className)}
      {...props}
    />
  )
}

export interface AiTodoHeaderProps extends React.ComponentProps<"header"> {
  /** Leading icon. Pass `null` to hide it. Defaults to a checklist icon. */
  icon?: React.ReactNode
}

function AiTodoHeader({
  className,
  icon,
  children,
  ...props
}: AiTodoHeaderProps) {
  return (
    <header
      data-slot="ai-todo-header"
      className={cn(
        "flex items-center gap-2 border-b px-3 py-2.5 text-sm font-medium",
        className
      )}
      {...props}
    >
      {icon === undefined ? (
        <ListTodoIcon className="size-4 shrink-0 text-muted-foreground" />
      ) : (
        icon
      )}
      {children}
    </header>
  )
}

export interface AiTodoProgressProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** Completed amount. */
  value: number
  /** Total amount. @default 100 */
  max?: number
}

/** A hairline progress bar that springs to the latest completion ratio. */
function AiTodoProgress({
  className,
  value,
  max = 100,
  ...props
}: AiTodoProgressProps) {
  const reduceMotion = useReducedMotion()
  const ratio = max > 0 ? Math.min(Math.max(value / max, 0), 1) : 0

  return (
    <div
      role="progressbar"
      aria-valuemin={0}
      aria-valuemax={max}
      aria-valuenow={value}
      data-slot="ai-todo-progress"
      data-complete={ratio === 1 ? "true" : "false"}
      className={cn("h-0.5 w-full overflow-hidden bg-muted", className)}
      {...props}
    >
      <motion.div
        className={cn(
          "h-full origin-left transition-colors duration-300",
          ratio === 1 ? "bg-success" : "bg-primary"
        )}
        initial={false}
        animate={{ scaleX: ratio }}
        transition={
          reduceMotion
            ? { duration: 0 }
            : { type: "spring", stiffness: 260, damping: 32 }
        }
      />
    </div>
  )
}

function AiTodoList({ className, ...props }: React.ComponentProps<"ol">) {
  return (
    <ol
      data-slot="ai-todo-list"
      className={cn("divide-y", className)}
      {...props}
    />
  )
}

function TodoStatusIcon({
  status,
  reduceMotion,
}: {
  status: AiTodoStatus
  reduceMotion: boolean
}) {
  if (status === "completed") {
    return (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={3}
        strokeLinecap="round"
        strokeLinejoin="round"
        className="size-3"
        aria-hidden
      >
        <motion.path
          d="M20 6 9 17l-5-5"
          initial={reduceMotion ? false : { pathLength: 0 }}
          animate={{ pathLength: 1 }}
          transition={
            reduceMotion
              ? { duration: 0 }
              : { duration: 0.32, ease: easeOut, delay: 0.08 }
          }
        />
      </svg>
    )
  }

  if (status === "pending") return null

  const Icon = status === "in-progress" ? LoaderCircleIcon : BanIcon

  return (
    <Icon
      className={cn(
        "size-3",
        status === "in-progress" && "motion-safe:animate-spin"
      )}
    />
  )
}

export interface AiTodoItemProps
  extends Omit<React.ComponentProps<"li">, "title"> {
  /** Main task label. */
  title: React.ReactNode
  /** Optional supporting detail. */
  description?: React.ReactNode
  /** Task progression state. @default "pending" */
  status?: AiTodoStatus
  /** Called when the status control is pressed. */
  onStatusChange?: (status: AiTodoStatus) => void
}

/** A readable task row with optional status interaction. */
function AiTodoItem({
  className,
  title,
  description,
  status = "pending",
  onStatusChange,
  ...props
}: AiTodoItemProps) {
  const reduceMotion = !!useReducedMotion()
  const nextStatus = status === "completed" ? "pending" : "completed"
  const done = status === "completed" || status === "cancelled"

  const indicatorContent = (
    <AnimatePresence initial={false} mode="popLayout">
      <motion.span
        key={status}
        className="flex items-center justify-center"
        initial={reduceMotion ? false : { opacity: 0, scale: 0.5 }}
        animate={{ opacity: 1, scale: 1 }}
        exit={reduceMotion ? undefined : { opacity: 0, scale: 0.5 }}
        transition={reduceMotion ? { duration: 0 } : popSpring}
      >
        <TodoStatusIcon status={status} reduceMotion={reduceMotion} />
      </motion.span>
    </AnimatePresence>
  )

  return (
    <li
      data-slot="ai-todo-item"
      data-status={status}
      className={cn(
        "flex items-start gap-3 px-3 py-3 transition-colors duration-300 data-[status=in-progress]:bg-muted/40",
        className
      )}
      {...props}
    >
      {onStatusChange ? (
        <button
          type="button"
          data-slot="ai-todo-indicator"
          aria-label={status === "completed" ? "标记为未完成" : "标记为已完成"}
          className={cn(
            aiTodoIndicatorVariants({ status }),
            "cursor-pointer outline-none hover:border-foreground/40 focus-visible:ring-[3px] focus-visible:ring-ring/35 active:scale-90 motion-safe:transition-[color,background-color,border-color,scale]"
          )}
          onClick={() => onStatusChange(nextStatus)}
        >
          {indicatorContent}
        </button>
      ) : (
        <span
          role="img"
          data-slot="ai-todo-indicator"
          aria-label={statusLabels[status]}
          className={aiTodoIndicatorVariants({ status })}
        >
          {indicatorContent}
        </span>
      )}
      <div className="min-w-0 flex-1">
        <div
          data-slot="ai-todo-title"
          className={cn(
            "text-sm font-medium leading-5 transition-colors duration-300",
            done && "text-muted-foreground"
          )}
        >
          <span
            className="box-decoration-clone transition-[background-size] duration-400 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
            style={{
              backgroundImage: "linear-gradient(currentColor, currentColor)",
              backgroundRepeat: "no-repeat",
              backgroundPosition: "0 55%",
              backgroundSize: done ? "100% 1px" : "0% 1px",
            }}
          >
            {title}
          </span>
        </div>
        {description ? (
          <div
            data-slot="ai-todo-description"
            className="mt-0.5 text-xs leading-5 text-muted-foreground"
          >
            {description}
          </div>
        ) : null}
      </div>
    </li>
  )
}

export {
  AiTodo,
  AiTodoHeader,
  AiTodoItem,
  AiTodoList,
  AiTodoProgress,
  aiTodoIndicatorVariants,
}

属性 Props

AiTodo Props

继承原生 HTML <section> 元素的全部属性,支持通过 className 扩展样式。

AiTodoHeader Props

继承原生 HTML <header> 元素的全部属性,通常包含任务标题与右侧完成度统计。

属性类型默认值说明
iconReact.ReactNode—标题前的图标,默认为清单图标;传入 `null` 可隐藏。

AiTodoProgress Props

放在 AiTodoHeader 下方的细进度条,完成度变化时以弹簧动画推进,全部完成后切换为成功色。

属性类型默认值说明
valuenumber—已完成数量。
maxnumber100总数量。

AiTodoList Props

继承原生 HTML <ol> 元素的全部属性,内含具体的 AiTodoItem 任务条目。

AiTodoItem Props

属性类型默认值说明
titleReact.ReactNode—任务条目的主要标题文案。
descriptionReact.ReactNode—任务条目的补充说明、执行细节或错误信息。
status"pending" | "in-progress" | "completed" | "cancelled""pending"任务当前的生命周期状态。pending 为待执行,in-progress 为执行中(带旋转动效与行高亮),completed 为已完成(勾选描边绘制、删除线从左向右划过),cancelled 为已取消。
onStatusChange(status: AiTodoStatus) => void—当传入该回调时,左侧状态图标会变成可交互按钮,点击可在 completed 与 pending 之间切换;未传入时渲染为带状态文案的只读图标。
classNamestring—应用于任务条目外层 <li> 元素的额外 CSS 类名。

事件 Events

属性类型默认值说明
onStatusChange(status: AiTodoStatus) => void—用户点击任务条目前置状态图标按钮时触发,返回即将切换的目标状态字符串。
onClick(event: React.MouseEvent<HTMLLIElement>) => void—整个任务行点击事件透传。

使用场景与设计规范

AiTodo 专为大模型复杂规划与工具调度过程可视化而生:

  • 减少用户黑盒等待焦虑:当 Agent 接收到复杂指令(如“重构组件并跑通测试”)时,先输出任务规划列表,并在执行过程中将对应子任务逐一置为 in-progress 与 completed,用户能明确获知当前程序卡点或执行进度。
  • 只读 vs 可交互:
    • 只读模式:省略 onStatusChange 属性,状态图标为纯静态展示,适用于模型自主执行流水线。
    • 可交互模式:传入 onStatusChange 属性,允许用户手动标记完成、勾选确认或干预任务流。
  • 状态语义区分:遇到被跳过的分支步骤使用 cancelled(灰色删除线),不要与执行失败混淆。

场景示例

实时自动化执行演示

模拟 Agent 逐步分析并推进任务,支持暂停、重置与用户手动切换状态:

Loading…

只读式发布部署工作流

在自动化 CI/CD 或部署流程中,通过只读任务流呈现清晰的执行轨迹与状态标识:

Loading…

无障碍与交互 Accessibility

  • 有序列表语义:底层使用 <ol> 与 <li> 构建,屏幕阅读器会自动播报“第 X 项,共 Y 项”。
  • 可交互状态指示器:当提供 onStatusChange 时,状态图标渲染为原生 <button> 并带有动态 aria-label(如“标记为已完成”)。
  • 旋转动效适配:in-progress 状态的旋转动画使用 motion-safe:animate-spin,在开启 prefers-reduced-motion 时自动静止。