{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"Live agent plan with nested steps, a drawn checkmark on completion and a travelling underline on whatever is running.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { Fragment } from \"react\";\n\nconst SPRING_DEFAULT = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\nconst EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;\nconst CHECK_PATH = \"M 3.5 7.5 L 6 10 L 10.5 4.5\";\nconst BOX_SIZE = 14;\nconst UNDERLINE_SECONDS = 1.4;\n\nexport type AITaskStatus = \"pending\" | \"running\" | \"done\" | \"failed\";\n\nexport type AITask = {\n  /** Nested sub-steps, one level. */\n  children?: AITask[];\n  id: string;\n  label: string;\n  /** Short right-aligned note, e.g. \"12/12\" or \"3 files\". */\n  note?: string;\n  status: AITaskStatus;\n};\n\nexport type AITaskListProps = {\n  className?: string;\n  /** Heading text. The counts are derived, never passed in. */\n  label?: string;\n  tasks: AITask[];\n};\n\nconst flatten = (tasks: AITask[]): AITask[] =>\n  tasks.flatMap((task) => [task, ...flatten(task.children ?? [])]);\n\nconst SUCCESS_COLOR = \"oklch(72% 0.17 150)\";\nconst DANGER_COLOR = \"oklch(63% 0.21 25)\";\n\nconst boxStroke = (status: AITaskStatus): string => {\n  if (status === \"failed\") {\n    return DANGER_COLOR;\n  }\n  if (status === \"done\") {\n    return SUCCESS_COLOR;\n  }\n  return \"currentColor\";\n};\n\nconst TaskBox = ({\n  status,\n  shouldReduceMotion,\n}: {\n  shouldReduceMotion: boolean;\n  status: AITaskStatus;\n}) => {\n  const isDone = status === \"done\";\n  const isFailed = status === \"failed\";\n\n  return (\n    <span className=\"mt-0.5 flex size-3.5 shrink-0 items-center justify-center\">\n      <svg\n        aria-hidden=\"true\"\n        className=\"size-3.5\"\n        viewBox={`0 0 ${BOX_SIZE} ${BOX_SIZE}`}\n      >\n        <motion.rect\n          animate={{\n            fillOpacity: isDone ? 0.12 : 0,\n            stroke: boxStroke(status),\n          }}\n          fill={isFailed ? DANGER_COLOR : SUCCESS_COLOR}\n          height={12}\n          rx={3.5}\n          strokeWidth={1.4}\n          transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.2 }}\n          width={12}\n          x={1}\n          y={1}\n        />\n        {isDone && (\n          // Drawn, not faded: a check that draws itself reads as the act of\n          // ticking the box rather than a state that was always there.\n          // Drawn with a CSS keyframe rather than a motion value.\n          //\n          // Neither motion's `pathLength` shorthand nor an explicit\n          // `strokeDashoffset` animation resolved on these paths — the dash\n          // stayed pinned at its initial value and the check rendered as a stub.\n          // A keyframe on mount is deterministic, and `prefers-reduced-motion`\n          // handles the accessible case in CSS with no JS branch at all.\n          <path\n            className=\"ai-task-draw\"\n            d={CHECK_PATH}\n            fill=\"none\"\n            pathLength={1}\n            stroke={SUCCESS_COLOR}\n            strokeDasharray=\"1 1\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth={1.8}\n          />\n        )}\n        {isFailed && (\n          <path\n            className=\"ai-task-draw\"\n            d=\"M 5 5 L 9 9 M 9 5 L 5 9\"\n            fill=\"none\"\n            pathLength={1}\n            stroke={DANGER_COLOR}\n            strokeDasharray=\"1 1\"\n            strokeLinecap=\"round\"\n            strokeWidth={1.8}\n          />\n        )}\n      </svg>\n    </span>\n  );\n};\n\nconst TaskRow = ({\n  depth,\n  shouldReduceMotion,\n  task,\n}: {\n  depth: number;\n  shouldReduceMotion: boolean;\n  task: AITask;\n}) => {\n  const isRunning = task.status === \"running\";\n  const isDone = task.status === \"done\";\n\n  return (\n    <motion.li\n      // Completed rows settle down a pixel and lose a little contrast: they go\n      // quiet so whatever is running is the only thing asking for attention.\n      animate={{\n        opacity: isDone ? 0.65 : 1,\n        y: isDone && !shouldReduceMotion ? 1 : 0,\n      }}\n      className=\"list-none\"\n      style={{ paddingLeft: depth * 20 }}\n      transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n    >\n      <span className=\"relative flex items-start gap-2 py-1\">\n        <TaskBox shouldReduceMotion={shouldReduceMotion} status={task.status} />\n\n        <span className=\"min-w-0 flex-1 text-foreground text-sm leading-snug\">\n          {task.label}\n        </span>\n\n        {task.note ? (\n          <span className=\"shrink-0 text-muted-foreground text-xs tabular-nums\">\n            {task.note}\n          </span>\n        ) : null}\n\n        {isRunning && !shouldReduceMotion && (\n          // A travelling underline under the active row only. One moving thing\n          // at a time is what makes \"which step is live\" readable at a glance.\n          <motion.span\n            animate={{ backgroundPositionX: [\"0%\", \"200%\"] }}\n            className=\"pointer-events-none absolute inset-x-0 bottom-0 h-px\"\n            style={{\n              backgroundImage:\n                \"linear-gradient(90deg, transparent 0%, currentColor 50%, transparent 100%)\",\n              backgroundSize: \"50% 100%\",\n              opacity: 0.5,\n            }}\n            transition={{\n              duration: UNDERLINE_SECONDS,\n              ease: EASE_IN_OUT,\n              repeat: Number.POSITIVE_INFINITY,\n            }}\n          />\n        )}\n      </span>\n    </motion.li>\n  );\n};\n\n/**\n * A plan an agent works through.\n *\n * Header counts are derived from the tasks, so the summary can never disagree\n * with the rows — a \"3/7\" that has drifted from what is on screen destroys trust\n * in the whole panel.\n */\nconst AITaskList = ({ className, label = \"Plan\", tasks }: AITaskListProps) => {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n  const all = flatten(tasks);\n  const done = all.filter((task) => task.status === \"done\").length;\n\n  return (\n    <div\n      className={cn(\n        \"w-full rounded-xl border border-border bg-background p-3\",\n        className\n      )}\n    >\n      <div className=\"mb-1.5 flex items-baseline justify-between\">\n        <p className=\"font-medium text-foreground text-sm\">{label}</p>\n        <p className=\"text-muted-foreground text-xs tabular-nums\">\n          {done}/{all.length}\n        </p>\n      </div>\n\n      <style>{`\n        .ai-task-draw { stroke-dashoffset: 0; }\n        @media (prefers-reduced-motion: no-preference) {\n          .ai-task-draw {\n            animation: ai-task-draw 200ms cubic-bezier(0.23, 1, 0.32, 1) both;\n          }\n        }\n        @keyframes ai-task-draw {\n          from { stroke-dashoffset: 1; }\n          to { stroke-dashoffset: 0; }\n        }\n      `}</style>\n\n      <ul className=\"list-none\">\n        {tasks.map((task) => (\n          <Fragment key={task.id}>\n            <TaskRow\n              depth={0}\n              shouldReduceMotion={shouldReduceMotion}\n              task={task}\n            />\n            {task.children?.map((child) => (\n              <TaskRow\n                depth={1}\n                key={child.id}\n                shouldReduceMotion={shouldReduceMotion}\n                task={child}\n              />\n            ))}\n          </Fragment>\n        ))}\n      </ul>\n    </div>\n  );\n};\n\nexport default AITaskList;\n","path":"index.tsx","target":"components/smoothui/ai-task-list/index.tsx","type":"registry:ui"}],"name":"ai-task-list","registryDependencies":[],"title":"Ai Task List","type":"registry:ui"}