{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"Proposed code or data edit where added lines wipe in from the left and rejected lines collapse to nothing.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useState } from \"react\";\n\nconst SPRING_DEFAULT = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst LINE_STAGGER = 0.02;\nconst WIPE_DURATION = 0.28;\nconst FLASH_DURATION = 0.35;\nconst SUCCESS_TINT = \"oklch(72% 0.17 150 / 0.14)\";\nconst DANGER_TINT = \"oklch(63% 0.21 25 / 0.12)\";\n\nexport type AIDiffLineKind = \"added\" | \"removed\" | \"context\";\n\nexport type AIDiffLine = {\n  content: string;\n  kind: AIDiffLineKind;\n  /** Line number in the file. Omit for tabular data rather than code. */\n  number?: number;\n};\n\nexport type AIDiffProps = {\n  className?: string;\n  lines: AIDiffLine[];\n  onAccept?: () => void;\n  onReject?: () => void;\n  /** File path or a description of what is being changed. */\n  title?: string;\n};\n\nconst PREFIX: Record<AIDiffLineKind, string> = {\n  added: \"+\",\n  context: \" \",\n  removed: \"-\",\n};\n\n/**\n * A proposed edit awaiting a human yes or no.\n *\n * Added lines wipe in from the left with a clip path rather than fading: a wipe\n * has a direction, and direction is what tells you the change was *written*\n * rather than always having been there. Rejecting collapses the lines to zero\n * height so the space is reclaimed — a rejected edit should stop occupying the\n * page.\n */\nconst AIDiff = ({\n  className,\n  lines,\n  onAccept,\n  onReject,\n  title,\n}: AIDiffProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const [decision, setDecision] = useState<\"accepted\" | \"rejected\" | null>(\n    null\n  );\n\n  const accept = () => {\n    setDecision(\"accepted\");\n    onAccept?.();\n  };\n\n  const reject = () => {\n    setDecision(\"rejected\");\n    onReject?.();\n  };\n\n  const added = lines.filter((line) => line.kind === \"added\").length;\n  const removed = lines.filter((line) => line.kind === \"removed\").length;\n\n  return (\n    <motion.div\n      // One flash on accept, then it settles. Anything more celebratory turns an\n      // ordinary code review into an event.\n      animate={\n        decision && !shouldReduceMotion\n          ? {\n              backgroundColor: [\n                decision === \"accepted\" ? SUCCESS_TINT : DANGER_TINT,\n                \"rgb(0 0 0 / 0)\",\n              ],\n            }\n          : undefined\n      }\n      className={cn(\n        \"w-full overflow-hidden rounded-xl border border-border bg-background\",\n        className\n      )}\n      layout={!shouldReduceMotion}\n      transition={\n        shouldReduceMotion\n          ? { duration: 0 }\n          : {\n              backgroundColor: { duration: FLASH_DURATION, ease: EASE_OUT },\n              layout: SPRING_DEFAULT,\n            }\n      }\n    >\n      <div className=\"flex items-center gap-2 border-border border-b px-3 py-2\">\n        {title ? (\n          <span className=\"min-w-0 truncate font-mono text-foreground text-xs\">\n            {title}\n          </span>\n        ) : null}\n        <span className=\"ml-auto flex shrink-0 items-center gap-1.5 text-xs tabular-nums\">\n          <span className=\"text-[oklch(58%_0.17_150)]\">+{added}</span>\n          <span className=\"text-destructive\">-{removed}</span>\n        </span>\n      </div>\n\n      <AnimatePresence initial={false}>\n        {decision !== \"rejected\" && (\n          <motion.div\n            className=\"overflow-hidden\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { height: 0, opacity: 0 }\n            }\n            transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n          >\n            <pre className=\"overflow-x-auto py-1 font-mono text-xs leading-relaxed\">\n              {lines.map((line, index) => (\n                <motion.div\n                  animate={\n                    shouldReduceMotion\n                      ? undefined\n                      : { clipPath: \"inset(0 0% 0 0)\" }\n                  }\n                  className={cn(\n                    \"flex gap-3 px-3\",\n                    line.kind === \"added\" && \"bg-[oklch(72%_0.17_150_/_0.12)]\",\n                    line.kind === \"removed\" && \"bg-[oklch(63%_0.21_25_/_0.1)]\"\n                  )}\n                  initial={\n                    // Only the added lines wipe. Context lines were already\n                    // there, so animating them would misrepresent the change.\n                    line.kind === \"added\" && !shouldReduceMotion\n                      ? { clipPath: \"inset(0 100% 0 0)\" }\n                      : false\n                  }\n                  // biome-ignore lint/suspicious/noArrayIndexKey: diff lines are positional and may repeat verbatim\n                  key={index}\n                  transition={\n                    shouldReduceMotion\n                      ? { duration: 0 }\n                      : {\n                          delay: index * LINE_STAGGER,\n                          duration: WIPE_DURATION,\n                          ease: EASE_OUT,\n                        }\n                  }\n                >\n                  {line.number !== undefined && (\n                    <span className=\"w-6 shrink-0 select-none text-right text-muted-foreground tabular-nums\">\n                      {line.number}\n                    </span>\n                  )}\n                  <span\n                    className={cn(\n                      \"w-2 shrink-0 select-none\",\n                      line.kind === \"added\" && \"text-[oklch(52%_0.17_150)]\",\n                      line.kind === \"removed\" && \"text-destructive\",\n                      line.kind === \"context\" && \"text-muted-foreground\"\n                    )}\n                  >\n                    {PREFIX[line.kind]}\n                  </span>\n                  <span className=\"whitespace-pre text-foreground\">\n                    {line.content}\n                  </span>\n                </motion.div>\n              ))}\n            </pre>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {(onAccept || onReject) && !decision && (\n        <div className=\"flex items-center justify-end gap-2 border-border border-t px-3 py-2\">\n          {onReject ? (\n            <button\n              className=\"cursor-pointer rounded-lg px-2.5 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground\"\n              onClick={reject}\n              type=\"button\"\n            >\n              Reject\n            </button>\n          ) : null}\n          {onAccept ? (\n            <button\n              className=\"cursor-pointer rounded-lg bg-foreground px-2.5 py-1 text-background text-xs\"\n              onClick={accept}\n              type=\"button\"\n            >\n              Accept\n            </button>\n          ) : null}\n        </div>\n      )}\n\n      {decision ? (\n        <motion.p\n          animate={{ opacity: 1 }}\n          className=\"border-border border-t px-3 py-2 text-muted-foreground text-xs capitalize\"\n          initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }}\n          transition={\n            shouldReduceMotion\n              ? { duration: 0 }\n              : { duration: 0.2, ease: EASE_OUT }\n          }\n        >\n          {decision}\n        </motion.p>\n      ) : null}\n    </motion.div>\n  );\n};\n\nexport default AIDiff;\n","path":"index.tsx","target":"components/smoothui/ai-diff/index.tsx","type":"registry:ui"}],"name":"ai-diff","registryDependencies":[],"title":"Ai Diff","type":"registry:ui"}