{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "comment-thread",
  "type": "registry:ui",
  "title": "Comment thread",
  "description": "Threaded comments with replies, reactions, mentions, inline edit, and resolve.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/comment-thread/comment-thread.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { Check, ChevronRight, CornerDownRight, RotateCcw, SmilePlus, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface CommentAuthor {\n  id: string;\n  name: string;\n  /** Square image URL. Initials are shown without one. */\n  avatar?: string;\n}\n\nexport interface CommentReaction {\n  emoji: string;\n  /** Ids of the people who reacted. */\n  users: string[];\n}\n\nexport interface ThreadComment {\n  id: string;\n  author: CommentAuthor;\n  /** Plain text. \"@Full Name\" mentions of known people are highlighted. */\n  body: string;\n  /** Display label such as \"2h\" or \"18 Sep\". */\n  createdAt: string;\n  edited?: boolean;\n  /** A deleted comment that still has replies keeps its place as a quiet placeholder. */\n  deleted?: boolean;\n  reactions?: CommentReaction[];\n  replies?: ThreadComment[];\n}\n\n/** What changed, for syncing to a server. `comments` on `onCommentsChange` is always the full next tree. */\nexport type CommentThreadEvent =\n  | { type: \"reply\"; comment: ThreadComment; parentId: string | null }\n  | { type: \"edit\"; id: string; body: string }\n  | { type: \"delete\"; id: string }\n  | { type: \"react\"; id: string; emoji: string; added: boolean };\n\n/** Every word the thread shows or announces. Each one has an English default; words with a count or a name are functions, so an app can apply its own plural rules. */\nexport interface CommentThreadLabels {\n  /** Accessible name of the whole thread. */\n  thread?: string;\n  /** Header and resolved chip, such as \"3 comments\". */\n  commentCount?: (count: number) => string;\n  /** Resolved chip, such as \"2 people\". */\n  peopleCount?: (count: number) => string;\n  resolve?: string;\n  /** Title of the resolved chip. */\n  resolved?: string;\n  reopen?: string;\n  /** Shown when there are no comments. */\n  empty?: string;\n  reply?: string;\n  edit?: string;\n  delete?: string;\n  cancel?: string;\n  send?: string;\n  /** Saves an edit. */\n  save?: string;\n  /** Placeholder and accessible name of the edit field. */\n  editComment?: string;\n  /** After the time of an edited comment. */\n  edited?: string;\n  deleteConfirm?: string;\n  /** Placeholder text of a deleted comment that still has replies. */\n  deletedText?: string;\n  /** Accessible name of that placeholder. */\n  deletedComment?: string;\n  /** Above the composer while replying. The name inside it is highlighted. */\n  replyingTo?: (name: string) => string;\n  cancelReply?: string;\n  showReplies?: (count: number) => string;\n  hideReplies?: string;\n  addReaction?: string;\n  closeReactions?: string;\n  /** Accessible name of the reaction picker. */\n  reactions?: string;\n  /** Accessible name of an emoji in the picker. */\n  reactWith?: (emoji: string) => string;\n  /** Accessible name of a reaction under a comment. */\n  reactionCount?: (emoji: string, count: number, includesYou: boolean) => string;\n  /** Accessible name of the @mention suggestions. */\n  mentions?: string;\n}\n\nconst DEFAULT_LABELS: Required<CommentThreadLabels> = {\n  thread: \"Comment thread\",\n  commentCount: (count) => `${count} ${count === 1 ? \"comment\" : \"comments\"}`,\n  peopleCount: (count) => `${count} ${count === 1 ? \"person\" : \"people\"}`,\n  resolve: \"Resolve\",\n  resolved: \"Resolved\",\n  reopen: \"Reopen\",\n  empty: \"No comments yet. Start the conversation below.\",\n  reply: \"Reply\",\n  edit: \"Edit\",\n  delete: \"Delete\",\n  cancel: \"Cancel\",\n  send: \"Send\",\n  save: \"Save\",\n  editComment: \"Edit comment\",\n  edited: \"edited\",\n  deleteConfirm: \"Delete this comment?\",\n  deletedText: \"This comment was deleted\",\n  deletedComment: \"Deleted comment\",\n  replyingTo: (name) => `Replying to ${name}`,\n  cancelReply: \"Cancel reply\",\n  showReplies: (count) => `Show ${count} ${count === 1 ? \"reply\" : \"replies\"}`,\n  hideReplies: \"Hide replies\",\n  addReaction: \"Add reaction\",\n  closeReactions: \"Close reactions\",\n  reactions: \"Reactions\",\n  reactWith: (emoji) => `React with ${emoji}`,\n  reactionCount: (emoji, count, includesYou) => `${emoji} ${count}${includesYou ? \", including you\" : \"\"}`,\n  mentions: \"People\",\n};\n\n/** A key passed as undefined keeps its default too. */\nconst withDefaults = <T extends object>(defaults: Required<T>, given?: T): Required<T> => ({\n  ...defaults,\n  ...Object.fromEntries(Object.entries(given ?? {}).filter(([, value]) => value !== undefined)),\n});\n\n/**\n * A threaded discussion: replies nest under their comment and collapse, reactions toggle with a count that rolls,\n * authors edit and delete their own comments in place, mentions autocomplete in the composer, and resolving folds the\n * whole thread into a single chip that reopens it. Controlled with `comments` and `resolved`, or uncontrolled.\n */\nexport interface CommentThreadProps {\n  comments?: ThreadComment[];\n  defaultComments?: ThreadComment[];\n  onCommentsChange?: (comments: ThreadComment[], event: CommentThreadEvent) => void;\n  /** The person writing. Their comments can be edited and deleted. */\n  currentUser: CommentAuthor;\n  /** People who can be mentioned. Defaults to everyone in the thread. */\n  people?: CommentAuthor[];\n  resolved?: boolean;\n  defaultResolved?: boolean;\n  onResolvedChange?: (resolved: boolean) => void;\n  /** What the thread is about, shown in its header, such as \"Greek bond yields story\". */\n  title?: ReactNode;\n  /** Emoji offered by the reaction picker. */\n  reactions?: string[];\n  placeholder?: string;\n  /** Replies deeper than this attach to the deepest allowed parent. Defaults to 2. */\n  maxDepth?: number;\n  /** Label for comments written now. */\n  nowLabel?: string;\n  /** Words for localization. Leave out any key to keep its English default. */\n  labels?: CommentThreadLabels;\n  className?: string;\n}\n\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\nconst standard = [...motionTokens.ease.standard] as Bezier;\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst HEIGHT = physical(0.42, 0);\n/** Quiet text actions under a comment; they come forward on hover or keyboard focus. */\nconst actionClass =\n  \"inline-flex h-7 cursor-pointer items-center justify-center rounded-button border-0 bg-transparent px-2 text-(length:--text-xs) font-medium text-ink-3 [-webkit-tap-highlight-color:transparent] transition-[background-color,color] duration-fast ease-standard hover:bg-hover hover:text-ink focus-visible:bg-hover focus-visible:text-ink motion-reduce:transition-none\";\nconst iconActionClass = cn(actionClass, \"w-7 p-0\");\nconst pillButtonClass =\n  \"inline-flex h-8 flex-none cursor-pointer items-center gap-1.5 rounded-button border border-line bg-surface pr-3 pl-2.5 text-(length:--text-xs) font-medium text-ink [transition:background-color_var(--duration-fast)_var(--ease-standard),border-color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)] hover:not-disabled:border-line-strong hover:not-disabled:bg-hover active:not-disabled:[transform:scale(.97)] motion-reduce:transition-none\";\n/** Overlapping avatars, each cut out of the surface by a ring. */\nconst stackClass = \"inline-flex flex-none pl-1 [&>*]:-ml-[5px] [&>*]:shadow-[0_0_0_2px_var(--surface)]\";\nconst DEFAULT_REACTIONS = [\"👍\", \"❤️\", \"🎉\", \"👀\", \"🚀\", \"✅\"];\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\n/* Tree helpers. Every change returns a new tree so controlled parents can store it as is. */\nconst mapTree = (\n  list: ThreadComment[],\n  id: string,\n  fn: (comment: ThreadComment) => ThreadComment | null,\n): ThreadComment[] =>\n  list.flatMap((comment) => {\n    if (comment.id === id) {\n      const next = fn(comment);\n      return next ? [next] : [];\n    }\n    return comment.replies?.length ? [{ ...comment, replies: mapTree(comment.replies, id, fn) }] : [comment];\n  });\nconst countTree = (list: ThreadComment[]): number =>\n  list.reduce((sum, comment) => sum + (comment.deleted ? 0 : 1) + countTree(comment.replies ?? []), 0);\nconst authorsOf = (list: ThreadComment[], into = new Map<string, CommentAuthor>()) => {\n  list.forEach((comment) => {\n    if (!comment.deleted) into.set(comment.author.id, comment.author);\n    authorsOf(comment.replies ?? [], into);\n  });\n  return into;\n};\n/** Path of ids from the root to `id`. */\nconst pathTo = (list: ThreadComment[], id: string, trail: string[] = []): string[] | null => {\n  for (const comment of list) {\n    if (comment.id === id) return [...trail, id];\n    const found = pathTo(comment.replies ?? [], id, [...trail, comment.id]);\n    if (found) return found;\n  }\n  return null;\n};\nconst find = (list: ThreadComment[], id: string): ThreadComment | null => {\n  for (const comment of list) {\n    if (comment.id === id) return comment;\n    const found = find(comment.replies ?? [], id);\n    if (found) return found;\n  }\n  return null;\n};\n/** Wraps the first `part` inside `text`, so a translated sentence keeps its highlighted name wherever the language puts it. */\nconst highlight = (text: string, part: string, className?: string): ReactNode => {\n  const at = part ? text.indexOf(part) : -1;\n  return at < 0 ? (\n    text\n  ) : (\n    <>\n      {text.slice(0, at)}\n      <span className={className}>{part}</span>\n      {text.slice(at + part.length)}\n    </>\n  );\n};\nconst initials = (name: string) =>\n  name\n    .split(/\\s+/)\n    .map((part) => part[0])\n    .slice(0, 2)\n    .join(\"\")\n    .toUpperCase();\nlet serial = 0;\nconst newId = () => `c-${Date.now().toString(36)}-${(serial++).toString(36)}`;\n\nfunction Avatar({ author, size = 28, className }: { author: CommentAuthor; size?: number; className?: string }) {\n  const avatarClass = cn(\n    \"inline-grid flex-none place-items-center overflow-hidden rounded-full bg-hover text-(length:--text-xs) font-medium tracking-[0] text-ink-2 object-cover\",\n    className,\n  );\n  return author.avatar ? (\n    // eslint-disable-next-line @next/next/no-img-element\n    <img\n      className={avatarClass}\n      src={author.avatar}\n      alt=\"\"\n      width={size}\n      height={size}\n      style={{ width: size, height: size }}\n    />\n  ) : (\n    <span\n      className={avatarClass}\n      style={{ width: size, height: size, fontSize: Math.round(size * 0.38) }}\n      aria-hidden=\"true\"\n    >\n      {initials(author.name)}\n    </span>\n  );\n}\n\n/** Highlights \"@Full Name\" for known people. */\nfunction Body({ text, people }: { text: string; people: CommentAuthor[] }) {\n  const parts = useMemo(() => {\n    const names = people\n      .map((person) => person.name)\n      .sort((a, b) => b.length - a.length)\n      .map((name) => name.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"));\n    if (!names.length) return [text];\n    return text.split(new RegExp(`(@(?:${names.join(\"|\")}))`, \"g\"));\n  }, [people, text]);\n  return (\n    <p className=\"m-0 text-pretty wrap-anywhere whitespace-pre-wrap text-ink-2\">\n      {parts.map((part, index) =>\n        index % 2 ? (\n          <span key={index} className=\"font-medium text-accent-ink\">\n            {part}\n          </span>\n        ) : (\n          part\n        ),\n      )}\n    </p>\n  );\n}\n\nconst roll: Variants = {\n  hidden: (dir: number) => ({ y: `${dir * 70}%`, opacity: 0 }),\n  shown: { y: 0, opacity: 1 },\n  gone: (dir: number) => ({ y: `${dir * -70}%`, opacity: 0 }),\n};\nconst rollReduced: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1 }, gone: { opacity: 0 } };\n\n/** Rolls a count up or down in place. */\nfunction Count({ value, reduced }: { value: number; reduced: boolean }) {\n  const [last, setLast] = useState(value);\n  const [dir, setDir] = useState(1);\n  if (value !== last) {\n    setDir(value > last ? 1 : -1);\n    setLast(value);\n  }\n  return (\n    <span className=\"relative inline-grid min-w-[1ch] overflow-hidden tabular-nums [&>span]:[grid-area:1/1]\">\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={dir}>\n        <motion.span\n          key={value}\n          custom={dir}\n          variants={reduced ? rollReduced : roll}\n          initial=\"hidden\"\n          animate=\"shown\"\n          exit=\"gone\"\n          transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Follows its content's height exactly, except when `morphKey` changes: then it springs, so the thread and the resolved chip morph into each other. */\nfunction AutoHeight({ children, reduced, morphKey }: { children: ReactNode; reduced: boolean; morphKey: string }) {\n  const inner = useRef<HTMLDivElement>(null);\n  const height = useMotionValue(0);\n  const [measured, setMeasured] = useState(false);\n  const morphing = useRef(false);\n  const lastKey = useRef(morphKey);\n  useLayoutEffect(() => {\n    if (lastKey.current !== morphKey) {\n      lastKey.current = morphKey;\n      morphing.current = true;\n    }\n  }, [morphKey]);\n  useLayoutEffect(() => {\n    const node = inner.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    // The border box keeps subpixel line heights, so the frame never clips a fraction of the last line.\n    const observer = new ResizeObserver(([entry]) => {\n      const next = entry?.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;\n      if (morphing.current && !reduced) {\n        morphing.current = false;\n        animate(height, next, HEIGHT);\n        return;\n      }\n      if (height.isAnimating()) {\n        animate(height, next, HEIGHT);\n        return;\n      }\n      height.jump(next);\n      setMeasured(true);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [height, reduced]);\n  return (\n    <motion.div className=\"overflow-hidden rounded-[inherit]\" style={{ height: measured ? height : \"auto\" }}>\n      <div ref={inner} className=\"relative\">\n        {children}\n      </div>\n    </motion.div>\n  );\n}\n\ninterface ComposerProps {\n  people: CommentAuthor[];\n  initial?: string;\n  placeholder: string;\n  submitLabel: string;\n  focusOnMount?: boolean;\n  focusKey?: number;\n  onSubmit: (body: string) => void;\n  onCancel?: () => void;\n  leading?: ReactNode;\n  compact?: boolean;\n  cancelLabel: string;\n  mentionsLabel: string;\n}\n\n/** A growing text field with @mention suggestions. ⌘ or Ctrl + Enter sends; Escape cancels. */\nfunction Composer({\n  people,\n  initial = \"\",\n  placeholder,\n  submitLabel,\n  focusOnMount,\n  focusKey,\n  onSubmit,\n  onCancel,\n  leading,\n  compact,\n  cancelLabel,\n  mentionsLabel,\n}: ComposerProps) {\n  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const reduced = useReducedFlag();\n  const field = useRef<HTMLTextAreaElement>(null);\n  const [text, setText] = useState(initial);\n  const [query, setQuery] = useState<{ start: number; term: string } | null>(null);\n  const [active, setActive] = useState(0);\n  const suggestions = useMemo(() => {\n    if (!query) return [];\n    const term = query.term.toLowerCase();\n    return people\n      .filter(\n        (person) =>\n          person.name\n            .toLowerCase()\n            .split(\" \")\n            .some((part) => part.startsWith(term)) || person.name.toLowerCase().startsWith(term),\n      )\n      .slice(0, 5);\n  }, [people, query]);\n  const open = !!query && suggestions.length > 0;\n  const composerButton = cn(\n    \"h-[38px] cursor-pointer rounded-button border-0 px-3.5 font-medium [transition:background-color_var(--duration-fast)_var(--ease-standard),opacity_var(--duration-fast)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)] motion-reduce:transition-none\",\n    compact && \"h-8 text-(length:--text-xs)\",\n  );\n\n  useEffect(() => {\n    if (!focusOnMount && !focusKey) return;\n    const node = field.current;\n    if (!node) return;\n    node.focus({ preventScroll: true });\n    node.setSelectionRange(node.value.length, node.value.length);\n  }, [focusOnMount, focusKey]);\n\n  const readQuery = (value: string, caret: number) => {\n    const match = /(^|\\s)@([\\p{L}]*)$/u.exec(value.slice(0, caret));\n    setQuery(match ? { start: caret - match[2]!.length - 1, term: match[2]! } : null);\n    setActive(0);\n  };\n\n  const insert = (person: CommentAuthor) => {\n    const node = field.current;\n    if (!node || !query) return;\n    const caret = node.selectionStart ?? text.length;\n    const next = `${text.slice(0, query.start)}@${person.name} ${text.slice(caret)}`;\n    const at = query.start + person.name.length + 2;\n    setText(next);\n    setQuery(null);\n    requestAnimationFrame(() => {\n      node.focus();\n      node.setSelectionRange(at, at);\n    });\n  };\n\n  const send = () => {\n    const body = text.trim();\n    if (!body) return;\n    onSubmit(body);\n    setText(\"\");\n    setQuery(null);\n  };\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {\n    if (open) {\n      if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n        event.preventDefault();\n        setActive((index) => (index + (event.key === \"ArrowDown\" ? 1 : -1) + suggestions.length) % suggestions.length);\n        return;\n      }\n      if (event.key === \"Enter\" || event.key === \"Tab\") {\n        event.preventDefault();\n        insert(suggestions[active]!);\n        return;\n      }\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        event.stopPropagation();\n        setQuery(null);\n        return;\n      }\n    }\n    if (event.key === \"Enter\" && (event.metaKey || event.ctrlKey)) {\n      event.preventDefault();\n      send();\n      return;\n    }\n    if (event.key === \"Escape\" && onCancel) {\n      event.preventDefault();\n      event.stopPropagation();\n      onCancel();\n    }\n  };\n\n  return (\n    <div\n      className={cn(\n        \"grid items-start gap-x-2.5 gap-y-2\",\n        compact\n          ? \"mt-1 grid-cols-[minmax(0,1fr)]\"\n          : \"grid-cols-[auto_minmax(0,1fr)_auto] max-[420px]:grid-cols-[minmax(0,1fr)_auto]\",\n      )}\n      data-compact={compact || undefined}\n    >\n      {leading}\n      <div className=\"grid min-w-0\">\n        <textarea\n          ref={field}\n          className=\"max-h-40 min-h-[38px] w-full resize-none rounded-[14px] border border-line bg-surface px-3 py-2 text-ink [field-sizing:content] transition-[border-color] duration-fast ease-standard placeholder:text-ink-3 focus:border-line-strong\"\n          value={text}\n          placeholder={placeholder}\n          rows={1}\n          aria-label={placeholder}\n          role=\"combobox\"\n          aria-autocomplete=\"list\"\n          aria-expanded={open}\n          aria-controls={`${uid}-people`}\n          aria-activedescendant={open ? `${uid}-person-${active}` : undefined}\n          onChange={(event) => {\n            setText(event.target.value);\n            readQuery(event.target.value, event.target.selectionStart);\n          }}\n          onSelect={(event) => readQuery(event.currentTarget.value, event.currentTarget.selectionStart)}\n          onBlur={() => setQuery(null)}\n          onKeyDown={onKeyDown}\n        />\n        <AnimatePresence>\n          {open && (\n            <motion.ul\n              key=\"people\"\n              id={`${uid}-people`}\n              role=\"listbox\"\n              aria-label={mentionsLabel}\n              className=\"mt-1.5 grid list-none overflow-clip rounded-[14px] border border-line bg-surface p-1\"\n              initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : { opacity: 0, height: 0, transition: { duration: motionTokens.duration.instant, ease: standard } }\n              }\n              transition={\n                reduced\n                  ? { duration: 0 }\n                  : { height: HEIGHT, opacity: { duration: motionTokens.duration.fast, ease: enter } }\n              }\n            >\n              {suggestions.map((person, index) => (\n                // oxlint-disable-next-line jsx-a11y/click-events-have-key-events -- options of the combobox's listbox; the field keeps focus and picks them with arrow keys and Enter, the pointer with a click\n                <li\n                  key={person.id}\n                  id={`${uid}-person-${index}`}\n                  // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- options of the combobox's listbox; the field keeps focus and picks them with arrow keys and Enter, the pointer with a click\n                  role=\"option\"\n                  aria-selected={index === active}\n                  className=\"flex min-h-8 cursor-pointer items-center gap-2 rounded-[10px] px-2 aria-selected:bg-hover\"\n                  onPointerDown={(event) => event.preventDefault()}\n                  onPointerMove={() => setActive(index)}\n                  onClick={() => insert(person)}\n                >\n                  <Avatar author={person} size={20} />\n                  {person.name}\n                </li>\n              ))}\n            </motion.ul>\n          )}\n        </AnimatePresence>\n      </div>\n      <div className={cn(\"flex items-center gap-1\", compact && \"justify-end\")}>\n        {onCancel && (\n          <button\n            type=\"button\"\n            className={cn(\n              composerButton,\n              \"bg-transparent text-ink-2 hover:bg-hover hover:text-ink active:[transform:scale(.97)]\",\n            )}\n            onClick={onCancel}\n          >\n            {cancelLabel}\n          </button>\n        )}\n        <button\n          type=\"button\"\n          className={cn(\n            composerButton,\n            \"bg-ink text-page hover:not-disabled:bg-[color-mix(in_oklab,var(--ink)_86%,var(--page))] active:not-disabled:[transform:scale(.97)] disabled:cursor-default disabled:opacity-35\",\n          )}\n          disabled={!text.trim() || text.trim() === initial.trim()}\n          onClick={send}\n        >\n          {submitLabel}\n        </button>\n      </div>\n    </div>\n  );\n}\n\ntype Ui = {\n  editing: string | null;\n  confirming: string | null;\n  picker: string | null;\n  collapsed: Record<string, boolean>;\n};\n\nexport const CommentThread = forwardRef<HTMLElement, CommentThreadProps>(function CommentThread(\n  {\n    comments,\n    defaultComments = [],\n    onCommentsChange,\n    currentUser,\n    people,\n    resolved,\n    defaultResolved = false,\n    onResolvedChange,\n    title,\n    reactions = DEFAULT_REACTIONS,\n    placeholder = \"Reply, or @mention someone\",\n    maxDepth = 2,\n    nowLabel = \"Just now\",\n    labels,\n    className,\n  },\n  forwardedRef,\n) {\n  const t = withDefaults(DEFAULT_LABELS, labels);\n  const reduced = useReducedFlag();\n  const rootRef = useRef<HTMLElement>(null);\n  useImperativeHandle(forwardedRef, () => rootRef.current as HTMLElement);\n  const [internal, setInternal] = useState(defaultComments);\n  const list = comments ?? internal;\n  const [internalResolved, setInternalResolved] = useState(defaultResolved);\n  const isResolved = resolved ?? internalResolved;\n  const [ui, setUi] = useState<Ui>({ editing: null, confirming: null, picker: null, collapsed: {} });\n  const [replyTo, setReplyTo] = useState<{ id: string; name: string } | null>(null);\n  const [focusKey, setFocusKey] = useState(0);\n\n  const mentionable = useMemo(\n    () => people ?? [...authorsOf(list, new Map([[currentUser.id, currentUser]])).values()],\n    [currentUser, list, people],\n  );\n  const total = countTree(list);\n  const participants = useMemo(() => [...authorsOf(list).values()], [list]);\n\n  const commit = useCallback(\n    (next: ThreadComment[], event: CommentThreadEvent) => {\n      if (comments === undefined) setInternal(next);\n      onCommentsChange?.(next, event);\n    },\n    [comments, onCommentsChange],\n  );\n\n  const setResolved = (next: boolean) => {\n    if (resolved === undefined) setInternalResolved(next);\n    onResolvedChange?.(next);\n    setUi((current) => ({ ...current, editing: null, confirming: null, picker: null }));\n    setReplyTo(null);\n    requestAnimationFrame(() =>\n      rootRef.current\n        ?.querySelector<HTMLElement>(next ? \"[data-reopen]\" : \"[data-resolve]\")\n        ?.focus({ preventScroll: true }),\n    );\n  };\n\n  const addReply = (body: string) => {\n    const comment: ThreadComment = { id: newId(), author: currentUser, body, createdAt: nowLabel, reactions: [] };\n    let parentId: string | null = null;\n    if (replyTo) {\n      const path = pathTo(list, replyTo.id) ?? [];\n      parentId = path[Math.min(path.length, maxDepth) - 1] ?? null;\n    }\n    const next = parentId\n      ? mapTree(list, parentId, (parent) => ({ ...parent, replies: [...(parent.replies ?? []), comment] }))\n      : [...list, comment];\n    if (parentId) setUi((current) => ({ ...current, collapsed: { ...current.collapsed, [parentId!]: false } }));\n    commit(next, { type: \"reply\", comment, parentId });\n    setReplyTo(null);\n  };\n\n  const edit = (id: string, body: string) => {\n    commit(\n      mapTree(list, id, (comment) => ({ ...comment, body, edited: true })),\n      { type: \"edit\", id, body },\n    );\n    setUi((current) => ({ ...current, editing: null }));\n  };\n\n  const remove = (id: string) => {\n    commit(\n      mapTree(list, id, (comment) =>\n        comment.replies?.length ? { ...comment, deleted: true, body: \"\", reactions: [] } : null,\n      ),\n      { type: \"delete\", id },\n    );\n    setUi((current) => ({ ...current, confirming: null }));\n    if (replyTo?.id === id) setReplyTo(null);\n  };\n\n  const react = (id: string, emoji: string) => {\n    const target = find(list, id);\n    if (!target) return;\n    const existing = target.reactions?.find((reaction) => reaction.emoji === emoji);\n    const added = !existing?.users.includes(currentUser.id);\n    const nextReactions = existing\n      ? (target.reactions ?? [])\n          .map((reaction) =>\n            reaction.emoji !== emoji\n              ? reaction\n              : {\n                  ...reaction,\n                  users: added\n                    ? [...reaction.users, currentUser.id]\n                    : reaction.users.filter((user) => user !== currentUser.id),\n                },\n          )\n          .filter((reaction) => reaction.users.length)\n      : [...(target.reactions ?? []), { emoji, users: [currentUser.id] }];\n    commit(\n      mapTree(list, id, (comment) => ({ ...comment, reactions: nextReactions })),\n      { type: \"react\", id, emoji, added },\n    );\n    setUi((current) => ({ ...current, picker: null }));\n  };\n\n  const grow = reduced\n    ? {\n        initial: { opacity: 0 },\n        animate: { opacity: 1 },\n        exit: { opacity: 0 },\n        transition: { duration: motionTokens.duration.instant },\n      }\n    : {\n        initial: { height: 0, opacity: 0 },\n        animate: { height: \"auto\", opacity: 1 },\n        exit: { height: 0, opacity: 0 },\n        transition: { height: HEIGHT, opacity: { duration: motionTokens.duration.standard, ease: enter } },\n      };\n\n  const swap = {\n    initial: { opacity: 0 },\n    animate: { opacity: 1 },\n    exit: { opacity: 0, transition: { duration: reduced ? 0 : motionTokens.duration.instant, ease: standard } },\n    transition: { duration: reduced ? 0 : motionTokens.duration.fast, ease: enter },\n  };\n\n  // Actions sit quietly after the reactions and come forward on hover or focus; touch screens always show them.\n  const actionsClass =\n    \"flex min-h-7 flex-wrap items-center gap-0.5 transition-opacity duration-fast ease-standard motion-reduce:transition-none\";\n  const renderComment = (comment: ThreadComment, depth: number): ReactNode => {\n    const mine = comment.author.id === currentUser.id;\n    const replies = comment.replies ?? [];\n    const collapsed = !!ui.collapsed[comment.id];\n    const editing = ui.editing === comment.id,\n      confirming = ui.confirming === comment.id,\n      picking = ui.picker === comment.id;\n    return (\n      <motion.li key={comment.id} className=\"overflow-clip [overflow-clip-margin:4px]\" {...grow}>\n        <article\n          className=\"flex gap-2.5 rounded-[16px] p-2.5\"\n          data-comment=\"\"\n          aria-label={comment.deleted ? t.deletedComment : `${comment.author.name}, ${comment.createdAt}`}\n        >\n          {comment.deleted ? (\n            <span\n              className=\"size-7 flex-none rounded-full border border-dashed border-line-strong\"\n              aria-hidden=\"true\"\n            />\n          ) : (\n            <Avatar author={comment.author} />\n          )}\n          <div className=\"grid min-w-0 flex-1 gap-0.5\">\n            {comment.deleted ? (\n              <p className=\"mt-1 mb-0 text-ink-3 italic\">{t.deletedText}</p>\n            ) : (\n              <>\n                <header className=\"flex min-w-0 items-baseline gap-2\">\n                  <span className=\"truncate font-medium\">{comment.author.name}</span>\n                  <span className=\"flex-none text-(length:--text-xs) text-ink-3\">\n                    {comment.createdAt}\n                    {comment.edited && ` · ${t.edited}`}\n                  </span>\n                </header>\n                {/* Editing swaps the body for a composer in place: the two cross-fade while the comment's height springs between them.\n                  The picker and delete prompt can wrap onto a new line on narrow screens, so they spring the height too. */}\n                <AutoHeight\n                  reduced={reduced}\n                  morphKey={editing ? \"edit\" : confirming ? \"confirm\" : picking ? \"picker\" : \"view\"}\n                >\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    {editing ? (\n                      <motion.div key=\"edit\" {...swap}>\n                        <Composer\n                          people={mentionable}\n                          initial={comment.body}\n                          placeholder={t.editComment}\n                          submitLabel={t.save}\n                          cancelLabel={t.cancel}\n                          mentionsLabel={t.mentions}\n                          focusOnMount\n                          compact\n                          onSubmit={(body) => edit(comment.id, body)}\n                          onCancel={() => setUi((current) => ({ ...current, editing: null }))}\n                        />\n                      </motion.div>\n                    ) : (\n                      <motion.div key=\"view\" {...swap}>\n                        <Body text={comment.body} people={mentionable} />\n\n                        <div className=\"mt-1 flex min-h-[30px] flex-wrap items-center gap-x-2 gap-y-1\">\n                          {(comment.reactions ?? []).length > 0 && (\n                            <div className=\"flex flex-wrap gap-1\">\n                              <AnimatePresence initial={false} mode=\"popLayout\">\n                                {(comment.reactions ?? []).map((reaction) => {\n                                  const pressed = reaction.users.includes(currentUser.id);\n                                  return (\n                                    <motion.button\n                                      key={reaction.emoji}\n                                      type=\"button\"\n                                      layout={!reduced}\n                                      className={cn(\n                                        \"inline-flex h-[26px] cursor-pointer items-center gap-[5px] rounded-button border pr-[9px] pl-[7px] text-(length:--text-xs) font-medium transition-[background-color,border-color,color] duration-fast ease-standard hover:border-line-strong active:[transform:scale(.94)] motion-reduce:transition-none\",\n                                        pressed\n                                          ? \"border-[color-mix(in_oklab,var(--accent)_45%,var(--line))] bg-accent-tint text-ink\"\n                                          : \"border-line bg-surface text-ink-2\",\n                                      )}\n                                      aria-pressed={pressed}\n                                      aria-label={t.reactionCount(reaction.emoji, reaction.users.length, pressed)}\n                                      onClick={() => react(comment.id, reaction.emoji)}\n                                      initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.7 }}\n                                      animate={{ opacity: 1, scale: 1 }}\n                                      exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.7 }}\n                                      transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}\n                                    >\n                                      <span aria-hidden=\"true\">{reaction.emoji}</span>\n                                      <Count value={reaction.users.length} reduced={reduced} />\n                                    </motion.button>\n                                  );\n                                })}\n                              </AnimatePresence>\n                            </div>\n                          )}\n\n                          <AnimatePresence mode=\"wait\" initial={false}>\n                            {confirming ? (\n                              <motion.div\n                                key=\"confirm\"\n                                className={actionsClass}\n                                data-open=\"\"\n                                initial={reduced ? { opacity: 0 } : { opacity: 0, x: 6 }}\n                                animate={{ opacity: 1, x: 0 }}\n                                exit={{\n                                  opacity: 0,\n                                  transition: { duration: reduced ? 0 : motionTokens.duration.instant, ease: standard },\n                                }}\n                                transition={{ duration: reduced ? 0 : motionTokens.duration.fast, ease: enter }}\n                                onKeyDown={(event) => {\n                                  if (event.key === \"Escape\") {\n                                    event.stopPropagation();\n                                    setUi((current) => ({ ...current, confirming: null }));\n                                  }\n                                }}\n                              >\n                                <span className=\"mr-1 text-(length:--text-xs) text-ink\">{t.deleteConfirm}</span>\n                                <button\n                                  type=\"button\"\n                                  className={actionClass}\n                                  onClick={() => setUi((current) => ({ ...current, confirming: null }))}\n                                >\n                                  {t.cancel}\n                                </button>\n                                <button\n                                  type=\"button\"\n                                  className={cn(\n                                    actionClass,\n                                    \"text-red hover:bg-[color-mix(in_oklab,var(--red)_9%,transparent)] hover:text-red\",\n                                  )}\n                                  data-tone=\"danger\"\n                                  // oxlint-disable-next-line jsx-a11y/no-autofocus -- the prompt replaces the Delete button that opened it, so focus moves to its confirm action instead of being lost\n                                  autoFocus\n                                  onClick={() => remove(comment.id)}\n                                >\n                                  {t.delete}\n                                </button>\n                              </motion.div>\n                            ) : picking ? (\n                              <motion.div\n                                key=\"picker\"\n                                role=\"group\"\n                                aria-label={t.reactions}\n                                className={actionsClass}\n                                data-open=\"\"\n                                onKeyDown={(event) => {\n                                  if (event.key === \"Escape\") {\n                                    event.stopPropagation();\n                                    setUi((current) => ({ ...current, picker: null }));\n                                  }\n                                }}\n                                initial={reduced ? { opacity: 0 } : { opacity: 0, x: -6 }}\n                                animate={{ opacity: 1, x: 0 }}\n                                exit={{\n                                  opacity: 0,\n                                  transition: { duration: reduced ? 0 : motionTokens.duration.instant, ease: standard },\n                                }}\n                                transition={{ duration: reduced ? 0 : motionTokens.duration.fast, ease: enter }}\n                              >\n                                {reactions.map((emoji, index) => (\n                                  <motion.button\n                                    key={emoji}\n                                    type=\"button\"\n                                    className={cn(\n                                      \"grid size-7 cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-(length:--text-base) leading-none [transition:background-color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)] hover:bg-hover focus-visible:bg-hover focus-visible:text-ink active:[transform:scale(.88)] motion-reduce:transition-none\",\n                                      !!comment.reactions\n                                        ?.find((reaction) => reaction.emoji === emoji)\n                                        ?.users.includes(currentUser.id) && \"bg-accent-tint\",\n                                    )}\n                                    // oxlint-disable-next-line jsx-a11y/no-autofocus -- the picker replaces the button that opened it, so focus moves to its first emoji instead of being lost\n                                    autoFocus={index === 0}\n                                    aria-label={t.reactWith(emoji)}\n                                    aria-pressed={\n                                      !!comment.reactions\n                                        ?.find((reaction) => reaction.emoji === emoji)\n                                        ?.users.includes(currentUser.id)\n                                    }\n                                    onClick={() => react(comment.id, emoji)}\n                                    initial={reduced ? false : { opacity: 0, scale: 0.6 }}\n                                    animate={{ opacity: 1, scale: 1 }}\n                                    transition={\n                                      reduced\n                                        ? { duration: 0 }\n                                        : { ...motionTokens.spring.snappy, delay: index * motionTokens.stagger.item }\n                                    }\n                                  >\n                                    {emoji}\n                                  </motion.button>\n                                ))}\n                                <button\n                                  type=\"button\"\n                                  className={iconActionClass}\n                                  aria-label={t.closeReactions}\n                                  onClick={() => setUi((current) => ({ ...current, picker: null }))}\n                                >\n                                  <X size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n                                </button>\n                              </motion.div>\n                            ) : (\n                              <motion.div\n                                key=\"actions\"\n                                className={cn(\n                                  actionsClass,\n                                  \"[@media(hover:hover)_and_(pointer:fine)]:[[data-comment]:not(:hover,:focus-within)_&]:opacity-0\",\n                                )}\n                                initial={{ opacity: 0 }}\n                                animate={{ opacity: 1 }}\n                                exit={{ opacity: 0 }}\n                                transition={{ duration: reduced ? 0 : motionTokens.duration.instant, ease: standard }}\n                              >\n                                <button\n                                  type=\"button\"\n                                  className={iconActionClass}\n                                  aria-label={t.addReaction}\n                                  onClick={() =>\n                                    setUi((current) => ({ ...current, picker: comment.id, confirming: null }))\n                                  }\n                                >\n                                  <SmilePlus size={15} strokeWidth={1.75} aria-hidden=\"true\" />\n                                </button>\n                                <button\n                                  type=\"button\"\n                                  className={actionClass}\n                                  onClick={() => {\n                                    setReplyTo({ id: comment.id, name: comment.author.name });\n                                    setFocusKey((key) => key + 1);\n                                  }}\n                                >\n                                  {t.reply}\n                                </button>\n                                {mine && (\n                                  <button\n                                    type=\"button\"\n                                    className={actionClass}\n                                    onClick={() =>\n                                      setUi((current) => ({\n                                        ...current,\n                                        editing: comment.id,\n                                        confirming: null,\n                                        picker: null,\n                                      }))\n                                    }\n                                  >\n                                    {t.edit}\n                                  </button>\n                                )}\n                                {mine && (\n                                  <button\n                                    type=\"button\"\n                                    className={actionClass}\n                                    onClick={() =>\n                                      setUi((current) => ({\n                                        ...current,\n                                        confirming: comment.id,\n                                        editing: null,\n                                        picker: null,\n                                      }))\n                                    }\n                                  >\n                                    {t.delete}\n                                  </button>\n                                )}\n                              </motion.div>\n                            )}\n                          </AnimatePresence>\n                        </div>\n                      </motion.div>\n                    )}\n                  </AnimatePresence>\n                </AutoHeight>\n              </>\n            )}\n\n            {replies.length > 0 && (\n              <button\n                type=\"button\"\n                className=\"mt-0.5 -ml-1.5 inline-flex h-7 w-fit cursor-pointer items-center gap-1.5 rounded-button border-0 bg-transparent pr-2 pl-1.5 text-(length:--text-xs) font-medium text-ink-2 transition-[background-color] duration-fast ease-standard hover:bg-hover hover:text-ink focus-visible:bg-hover focus-visible:text-ink\"\n                aria-expanded={!collapsed}\n                onClick={() =>\n                  setUi((current) => ({ ...current, collapsed: { ...current.collapsed, [comment.id]: !collapsed } }))\n                }\n              >\n                <motion.span\n                  className=\"grid place-items-center text-ink-3\"\n                  initial={false}\n                  animate={{ rotate: collapsed ? 0 : 90 }}\n                  transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}\n                  aria-hidden=\"true\"\n                >\n                  <ChevronRight size={13} strokeWidth={1.75} />\n                </motion.span>\n                {collapsed ? t.showReplies(countTree(replies)) : t.hideReplies}\n                {collapsed && (\n                  <span className={stackClass} aria-hidden=\"true\">\n                    {[...authorsOf(replies).values()].slice(0, 3).map((author) => (\n                      <Avatar key={author.id} author={author} size={18} />\n                    ))}\n                  </span>\n                )}\n              </button>\n            )}\n          </div>\n        </article>\n\n        <AnimatePresence initial={false}>\n          {replies.length > 0 && !collapsed && (\n            <motion.div key=\"replies\" className=\"overflow-clip\" {...grow}>\n              {/* Replies hang from a quiet rail under their parent's avatar. */}\n              <ul\n                className=\"relative m-0 ml-6 grid list-none p-0 pl-3 before:absolute before:top-0.5 before:bottom-3.5 before:left-0 before:w-px before:bg-line before:content-[''] max-[420px]:ml-3\"\n                data-depth={depth + 1}\n              >\n                <AnimatePresence initial={false}>\n                  {replies.map((reply) => renderComment(reply, depth + 1))}\n                </AnimatePresence>\n              </ul>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </motion.li>\n    );\n  };\n\n  const face = {\n    initial: reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` },\n    animate: { opacity: 1, filter: \"blur(0px)\" },\n    exit: { opacity: 0, filter: reduced ? \"blur(0px)\" : `blur(${motionTokens.blur.subtle}px)` },\n    transition: { duration: reduced ? 0 : motionTokens.duration.standard, ease: enter },\n  };\n\n  return (\n    <section\n      ref={rootRef}\n      className={cn(\n        \"w-full min-w-0 border border-line bg-surface font-body text-(length:--text-sm) leading-body tracking-body text-ink transition-[border-radius] duration-spring ease-spring motion-reduce:transition-none\",\n        isResolved ? \"rounded-[22px]\" : \"rounded-panel\",\n        className,\n      )}\n      data-resolved={isResolved || undefined}\n      aria-label={t.thread}\n    >\n      <AutoHeight reduced={reduced} morphKey={isResolved ? \"resolved\" : \"thread\"}>\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {isResolved ? (\n            <motion.div key=\"resolved\" className=\"flex items-center gap-3 py-2.5 pr-2.5 pl-3.5\" {...face}>\n              <span\n                className=\"grid size-6 flex-none place-items-center rounded-full bg-[color-mix(in_oklab,var(--green)_14%,transparent)] text-green\"\n                aria-hidden=\"true\"\n              >\n                <Check size={14} strokeWidth={2} />\n              </span>\n              <span className=\"grid min-w-0 flex-1\">\n                <span className=\"truncate font-medium\">\n                  {t.resolved}\n                  {title ? (\n                    <>\n                      {\" \"}\n                      · <span className=\"font-normal text-ink-2\">{title}</span>\n                    </>\n                  ) : null}\n                </span>\n                <span className=\"text-(length:--text-xs) text-ink-3 tabular-nums\">\n                  {t.commentCount(total)} · {t.peopleCount(participants.length)}\n                </span>\n              </span>\n              <span className={stackClass} aria-hidden=\"true\">\n                {participants.slice(0, 3).map((author) => (\n                  <Avatar key={author.id} author={author} size={22} />\n                ))}\n              </span>\n              <button type=\"button\" className={pillButtonClass} data-reopen onClick={() => setResolved(false)}>\n                <RotateCcw size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n                {t.reopen}\n              </button>\n            </motion.div>\n          ) : (\n            <motion.div key=\"thread\" className=\"grid\" {...face}>\n              <header className=\"flex items-center justify-between gap-3 pt-3 pr-3 pb-2 pl-[18px]\">\n                <div className=\"grid min-w-0\">\n                  {title && <h3 className=\"m-0 truncate text-(length:--text-sm) font-medium\">{title}</h3>}\n                  <span className=\"text-(length:--text-xs) text-ink-3 tabular-nums\">{t.commentCount(total)}</span>\n                </div>\n                <button\n                  type=\"button\"\n                  className={cn(pillButtonClass, \"disabled:cursor-default disabled:opacity-45 [&>svg]:text-green\")}\n                  data-resolve\n                  disabled={!total}\n                  onClick={() => setResolved(true)}\n                >\n                  <Check size={15} strokeWidth={1.75} aria-hidden=\"true\" />\n                  {t.resolve}\n                </button>\n              </header>\n\n              {list.length > 0 ? (\n                <ul className=\"m-0 grid list-none px-2 py-0\">\n                  <AnimatePresence initial={false}>{list.map((comment) => renderComment(comment, 0))}</AnimatePresence>\n                </ul>\n              ) : (\n                <p className=\"m-0 px-[18px] pt-2 pb-4 text-ink-3\">{t.empty}</p>\n              )}\n\n              <div className=\"mt-1 grid border-t border-line-soft px-3 pt-2.5 pb-3\">\n                <AnimatePresence initial={false}>\n                  {replyTo && (\n                    <motion.div key=\"replying\" className=\"overflow-clip\" {...grow}>\n                      <span className=\"flex items-center gap-1.5 pt-0 pr-0.5 pb-2 pl-[38px] text-(length:--text-xs) text-ink-2 max-[420px]:pl-0.5\">\n                        <CornerDownRight size={13} strokeWidth={1.75} aria-hidden=\"true\" />\n                        <span>{highlight(t.replyingTo(replyTo.name), replyTo.name, \"font-medium text-ink\")}</span>\n                        <button\n                          type=\"button\"\n                          className={cn(iconActionClass, \"ml-0.5 size-[22px]\")}\n                          aria-label={t.cancelReply}\n                          onClick={() => setReplyTo(null)}\n                        >\n                          <X size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n                        </button>\n                      </span>\n                    </motion.div>\n                  )}\n                </AnimatePresence>\n                <Composer\n                  people={mentionable}\n                  placeholder={placeholder}\n                  submitLabel={t.send}\n                  cancelLabel={t.cancel}\n                  mentionsLabel={t.mentions}\n                  focusKey={focusKey}\n                  onSubmit={addReply}\n                  onCancel={replyTo ? () => setReplyTo(null) : undefined}\n                  leading={<Avatar author={currentUser} className=\"mt-[5px] max-[420px]:hidden\" />}\n                />\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </AutoHeight>\n    </section>\n  );\n});\n\nexport default CommentThread;\n",
      "type": "registry:component",
      "target": "@components/iris/comment-thread/comment-thread.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/comment-thread",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "CommentThread",
    "tags": [
      "data",
      "new"
    ],
    "docs": "https://irisui.dev/components/comment-thread"
  }
}
