{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"task-log","type":"registry:component","title":"Task log","description":"What the agent did, task by task, as it does it: steps arrive on a hairline trail, files sit in chips, titles turn past tense.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/task-log.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AlertCircle, ChevronDown, FileCode2, RotateCcw } from \"lucide-react\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * TASK LOG: what the agent did, task by task, as it does it\n *\n *   running   the task's title carries a sweep of light; its\n *             steps arrive one at a time on a hairline trail,\n *             each opening to its height and rising out of a\n *             light blur, the newest one shimmering\n *   done      the title morphs into the past tense (\"Finding\n *             project files\" becomes \"Found project files\") and\n *             the sweep stops; the steps can fold away\n *   error     the title turns red behind an alert, keeping the\n *             steps that ran\n *   rerun     once every task is settled, \"Run again\" folds in\n *\n * Files in a step sit in small chips; counts and outcomes trail\n * after in a quieter grey. Feed it the tasks you have so far.\n * ───────────────────────────────────────────────────────── */\n\nexport type TaskLogStatus = \"running\" | \"done\" | \"error\";\n\nexport interface TaskLogStep {\n  id: string;\n  /** What happened, e.g. \"Read\" or \"Searching \\\"app/page.tsx\\\"\". */\n  text: string;\n  /** A file it touched, shown as a chip after the text. */\n  file?: string;\n  /** A quieter outcome after it, e.g. \"0 errors\". */\n  detail?: string;\n}\n\nexport interface TaskLogTask {\n  id: string;\n  /** While it runs, e.g. \"Finding project files\". */\n  title: string;\n  /** Once it's done, e.g. \"Found project files\". Defaults to title. */\n  doneTitle?: string;\n  icon?: React.ReactNode;\n  status: TaskLogStatus;\n  steps: TaskLogStep[];\n}\n\nexport interface TaskLogProps extends React.HTMLAttributes<HTMLDivElement> {\n  tasks: TaskLogTask[];\n  /** Shows \"Run again\" once every task is settled. */\n  onRerun?: () => void;\n  rerunLabel?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n// A light that sweeps across the label: a moving mask, so it also reaches letters that are mid-morph.\nconst SHEEN =\n  \"text-foreground [mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none]\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Opens to its height and rises out of a blur the first time it appears (not when the log first renders). */\nfunction Arrive({ animate, reduced, children }: { animate: boolean; reduced: boolean; children: React.ReactNode }) {\n  const [shown, setShown] = React.useState(!animate || reduced);\n  React.useEffect(() => {\n    if (shown) return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setShown(true))));\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [shown]);\n  return (\n    <div\n      className=\"grid\"\n      style={{\n        gridTemplateRows: shown ? \"1fr\" : \"0fr\",\n        opacity: shown ? 1 : 0,\n        filter: shown ? \"none\" : \"blur(4px)\",\n        transform: shown ? \"none\" : \"translateY(4px)\",\n        transition: reduced ? \"none\" : `grid-template-rows 380ms ${EASE}, opacity 320ms ${EASE} 60ms, filter 320ms ${EASE} 60ms, transform 380ms ${EASE}`,\n      }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">{children}</div>\n    </div>\n  );\n}\n\nfunction FileChip({ name }: { name: string }) {\n  return (\n    <span className=\"inline-flex h-5 max-w-full items-center gap-1 rounded-md bg-muted px-1.5 font-mono text-[11px] text-foreground/80\">\n      <FileCode2 aria-hidden=\"true\" className=\"size-3 shrink-0 text-muted-foreground\" />\n      <span className=\"truncate\">{name}</span>\n    </span>\n  );\n}\n\nfunction Task({ task, animate, mounted, reduced }: { task: TaskLogTask; animate: boolean; mounted: boolean; reduced: boolean }) {\n  const [open, setOpen] = React.useState(true);\n  const listId = React.useId();\n  const running = task.status === \"running\";\n  const failed = task.status === \"error\";\n  const title = running ? task.title : task.doneTitle ?? task.title;\n  return (\n    <li>\n      <Arrive animate={animate} reduced={reduced}>\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          aria-controls={listId}\n          onClick={() => setOpen((o) => !o)}\n          className={`group flex min-h-7 w-full items-center gap-2 rounded-md py-0.5 text-left text-[13px] ${FOCUS}`}\n        >\n          <span aria-hidden=\"true\" className={`flex size-4 shrink-0 items-center justify-center [&_svg]:size-4 [&_svg]:stroke-[1.8] ${failed ? \"text-red-500\" : \"text-muted-foreground\"}`}>\n            {failed ? <AlertCircle /> : task.icon}\n          </span>\n          <span className={`min-w-0 font-medium transition-colors duration-300 ${failed ? \"text-red-600\" : running ? SHEEN : \"text-foreground/80\"}`}>\n            <TextMorph>{title}</TextMorph>\n          </span>\n          <ChevronDown\n            aria-hidden=\"true\"\n            className=\"ml-auto size-3.5 shrink-0 text-muted-foreground opacity-60 transition-opacity group-hover:opacity-100\"\n            style={{ transform: open ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 320ms ${EASE}, opacity 200ms` }}\n          />\n        </button>\n      </Arrive>\n      <div\n        id={listId}\n        inert={!open}\n        className=\"grid\"\n        style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", opacity: open ? 1 : 0, transition: reduced ? \"none\" : `grid-template-rows 380ms ${EASE}, opacity ${open ? \"280ms\" : \"140ms\"} ${EASE}` }}\n      >\n        <ul className=\"min-h-0 overflow-hidden pb-1 pl-[24px]\">\n          {task.steps.map((step, i) => {\n            const last = i === task.steps.length - 1;\n            const live = running && last;\n            return (\n              <li key={step.id} className=\"relative\">\n                <Arrive animate={mounted} reduced={reduced}>\n                  {/* The trail: a hairline down from the task, branching to each step, the last one turning the corner. */}\n                  <span aria-hidden=\"true\" className=\"absolute -left-[16px] top-0 w-px bg-border\" style={last ? { height: 10 } : { bottom: 0 }} />\n                  <span aria-hidden=\"true\" className={`absolute -left-[16px] top-[3px] h-[8px] w-[9px] border-b border-border ${last ? \"rounded-bl-[5px] border-l\" : \"\"}`} />\n                  <div className=\"flex min-h-[26px] flex-wrap items-center gap-x-1.5 gap-y-1 py-[3px] text-[12.5px] leading-5 text-muted-foreground\">\n                    <span className={live ? SHEEN : \"\"}>{step.text}</span>\n                    {step.file && <FileChip name={step.file} />}\n                    {step.detail && <span className=\"text-muted-foreground/70\">{step.detail}</span>}\n                  </div>\n                </Arrive>\n              </li>\n            );\n          })}\n        </ul>\n      </div>\n    </li>\n  );\n}\n\nexport function TaskLog({ tasks, onRerun, rerunLabel = \"Run again\", className = \"\", ...props }: TaskLogProps) {\n  const reduced = useReducedMotion();\n  // Tasks and steps that exist on the first render just sit there; later ones arrive.\n  const [mounted, setMounted] = React.useState(false);\n  React.useEffect(() => setMounted(true), []);\n  const settled = tasks.length > 0 && tasks.every((t) => t.status !== \"running\");\n  const live = tasks.find((t) => t.status === \"running\");\n  const lastStep = live?.steps[live.steps.length - 1];\n\n  return (\n    <div className={`w-full ${className}`} {...props}>\n      <ol className=\"space-y-1\">\n        {tasks.map((t) => (\n          <Task key={t.id} task={t} animate={mounted} mounted={mounted} reduced={reduced} />\n        ))}\n      </ol>\n\n      {onRerun && (\n        <div\n          className=\"grid\"\n          style={{ gridTemplateRows: settled ? \"1fr\" : \"0fr\", opacity: settled ? 1 : 0, transition: reduced ? \"none\" : `grid-template-rows 380ms ${EASE}, opacity 300ms ${EASE}` }}\n        >\n          <div className=\"min-h-0 overflow-hidden\">\n            <button\n              type=\"button\"\n              inert={!settled}\n              onClick={onRerun}\n              className={`mt-2.5 inline-flex h-8 items-center gap-1.5 rounded-lg px-3 text-[12.5px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent ${FOCUS}`}\n            >\n              <RotateCcw aria-hidden=\"true\" className=\"size-3.5 text-muted-foreground\" />\n              {rerunLabel}\n            </button>\n          </div>\n        </div>\n      )}\n\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {live ? `${live.title}${lastStep ? `: ${lastStep.text}${lastStep.file ? ` ${lastStep.file}` : \"\"}` : \"\"}` : settled ? \"All tasks finished\" : \"\"}\n      </p>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/task-log"}