Todos os componentes

AI Task List

AI Task List é um componente de dados para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

SmoothUI MIT data

O que é

AI Task List é um componente de dados para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i lucide-react motion

Licença

Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

AI Task List

index.tsx
"use client";

import { cn } from "@/lib/utils";
import { motion, useReducedMotion } from "motion/react";
import { Fragment } from "react";

const SPRING_DEFAULT = {
  bounce: 0.1,
  duration: 0.25,
  type: "spring" as const,
};
const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;
const CHECK_PATH = "M 3.5 7.5 L 6 10 L 10.5 4.5";
const BOX_SIZE = 14;
const UNDERLINE_SECONDS = 1.4;

export type AITaskStatus = "pending" | "running" | "done" | "failed";

export type AITask = {
  /** Nested sub-steps, one level. */
  children?: AITask[];
  id: string;
  label: string;
  /** Short right-aligned note, e.g. "12/12" or "3 files". */
  note?: string;
  status: AITaskStatus;
};

export type AITaskListProps = {
  className?: string;
  /** Heading text. The counts are derived, never passed in. */
  label?: string;
  tasks: AITask[];
};

const flatten = (tasks: AITask[]): AITask[] =>
  tasks.flatMap((task) => [task, ...flatten(task.children ?? [])]);

const SUCCESS_COLOR = "oklch(72% 0.17 150)";
const DANGER_COLOR = "oklch(63% 0.21 25)";

const boxStroke = (status: AITaskStatus): string => {
  if (status === "failed") {
    return DANGER_COLOR;
  }
  if (status === "done") {
    return SUCCESS_COLOR;
  }
  return "currentColor";
};

const TaskBox = ({
  status,
  shouldReduceMotion,
}: {
  shouldReduceMotion: boolean;
  status: AITaskStatus;
}) => {
  const isDone = status === "done";
  const isFailed = status === "failed";

  return (
    <span className="mt-0.5 flex size-3.5 shrink-0 items-center justify-center">
      <svg
        aria-hidden="true"
        className="size-3.5"
        viewBox={`0 0 ${BOX_SIZE} ${BOX_SIZE}`}
      >
        <motion.rect
          animate={{
            fillOpacity: isDone ? 0.12 : 0,
            stroke: boxStroke(status),
          }}
          fill={isFailed ? DANGER_COLOR : SUCCESS_COLOR}
          height={12}
          rx={3.5}
          strokeWidth={1.4}
          transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.2 }}
          width={12}
          x={1}
          y={1}
        />
        {isDone && (
          // Drawn, not faded: a check that draws itself reads as the act of
          // ticking the box rather than a state that was always there.
          // Drawn with a CSS keyframe rather than a motion value.
          //
          // Neither motion's `pathLength` shorthand nor an explicit
          // `strokeDashoffset` animation resolved on these paths — the dash
          // stayed pinned at its initial value and the check rendered as a stub.
          // A keyframe on mount is deterministic, and `prefers-reduced-motion`
          // handles the accessible case in CSS with no JS branch at all.
          <path
            className="ai-task-draw"
            d={CHECK_PATH}
            fill="none"
            pathLength={1}
            stroke={SUCCESS_COLOR}
            strokeDasharray="1 1"
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={1.8}
          />
        )}
        {isFailed && (
          <path
            className="ai-task-draw"
            d="M 5 5 L 9 9 M 9 5 L 5 9"
            fill="none"
            pathLength={1}
            stroke={DANGER_COLOR}
            strokeDasharray="1 1"
            strokeLinecap="round"
            strokeWidth={1.8}
          />
        )}
      </svg>
    </span>
  );
};

const TaskRow = ({
  depth,
  shouldReduceMotion,
  task,
}: {
  depth: number;
  shouldReduceMotion: boolean;
  task: AITask;
}) => {
  const isRunning = task.status === "running";
  const isDone = task.status === "done";

  return (
    <motion.li
      // Completed rows settle down a pixel and lose a little contrast: they go
      // quiet so whatever is running is the only thing asking for attention.
      animate={{
        opacity: isDone ? 0.65 : 1,
        y: isDone && !shouldReduceMotion ? 1 : 0,
      }}
      className="list-none"
      style={{ paddingLeft: depth * 20 }}
      transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}
    >
      <span className="relative flex items-start gap-2 py-1">
        <TaskBox shouldReduceMotion={shouldReduceMotion} status={task.status} />

        <span className="min-w-0 flex-1 text-foreground text-sm leading-snug">
          {task.label}
        </span>

        {task.note ? (
          <span className="shrink-0 text-muted-foreground text-xs tabular-nums">
            {task.note}
          </span>
        ) : null}

        {isRunning && !shouldReduceMotion && (
          // A travelling underline under the active row only. One moving thing
          // at a time is what makes "which step is live" readable at a glance.
          <motion.span
            animate={{ backgroundPositionX: ["0%", "200%"] }}
            className="pointer-events-none absolute inset-x-0 bottom-0 h-px"
            style={{
              backgroundImage:
                "linear-gradient(90deg, transparent 0%, currentColor 50%, transparent 100%)",
              backgroundSize: "50% 100%",
              opacity: 0.5,
            }}
            transition={{
              duration: UNDERLINE_SECONDS,
              ease: EASE_IN_OUT,
              repeat: Number.POSITIVE_INFINITY,
            }}
          />
        )}
      </span>
    </motion.li>
  );
};

/**
 * A plan an agent works through.
 *
 * Header counts are derived from the tasks, so the summary can never disagree
 * with the rows — a "3/7" that has drifted from what is on screen destroys trust
 * in the whole panel.
 */
const AITaskList = ({ className, label = "Plan", tasks }: AITaskListProps) => {
  const shouldReduceMotion = Boolean(useReducedMotion());
  const all = flatten(tasks);
  const done = all.filter((task) => task.status === "done").length;

  return (
    <div
      className={cn(
        "w-full rounded-xl border border-border bg-background p-3",
        className
      )}
    >
      <div className="mb-1.5 flex items-baseline justify-between">
        <p className="font-medium text-foreground text-sm">{label}</p>
        <p className="text-muted-foreground text-xs tabular-nums">
          {done}/{all.length}
        </p>
      </div>

      <style>{`
        .ai-task-draw { stroke-dashoffset: 0; }
        @media (prefers-reduced-motion: no-preference) {
          .ai-task-draw {
            animation: ai-task-draw 200ms cubic-bezier(0.23, 1, 0.32, 1) both;
          }
        }
        @keyframes ai-task-draw {
          from { stroke-dashoffset: 1; }
          to { stroke-dashoffset: 0; }
        }
      `}</style>

      <ul className="list-none">
        {tasks.map((task) => (
          <Fragment key={task.id}>
            <TaskRow
              depth={0}
              shouldReduceMotion={shouldReduceMotion}
              task={task}
            />
            {task.children?.map((child) => (
              <TaskRow
                depth={1}
                key={child.id}
                shouldReduceMotion={shouldReduceMotion}
                task={child}
              />
            ))}
          </Fragment>
        ))}
      </ul>
    </div>
  );
};

export default AITaskList;

Componentes parecidos