{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "review-feed",
  "type": "registry:component",
  "title": "Review Feed",
  "description": "Every comment on a page in one list: filter by open or resolved, tick one off and watch it slide away, or type / to summarise them all or turn them into tasks.",
  "categories": [
    "component",
    "Together"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/tabs.json"
  ],
  "files": [
    {
      "path": "registry/devl/review-feed.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowUpIcon, CheckCheckIcon, CornerDownLeftIcon, ListTodoIcon, SparklesIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useIsPresent, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type ReactNode,\n  type Ref,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Tabs, TabsList, TabsTab } from \"@/components/ui/tabs\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ReviewTone = \"info\" | \"success\" | \"warning\" | \"destructive\" | \"muted\";\n\nexport interface ReviewComment {\n  id: string;\n  author: string;\n  initials: string;\n  tone?: ReviewTone;\n  time: string;\n  body: string;\n  anchor?: string;\n  resolved?: boolean;\n}\n\nexport interface ReviewFeedProps {\n  comments: ReviewComment[];\n  currentUser?: { name: string; initials: string; tone?: ReviewTone };\n  summary?: string;\n  className?: string;\n}\n\ntype Filter = \"all\" | \"open\" | \"resolved\";\n\ntype Result = { id: number; kind: \"summary\" | \"tasks\"; title: string; body: string[] };\n\nconst toneClass: Record<ReviewTone, string> = {\n  info: \"bg-info/12 text-info-foreground\",\n  success: \"bg-success/12 text-success-foreground\",\n  warning: \"bg-warning/14 text-warning-foreground\",\n  destructive: \"bg-destructive/10 text-destructive-foreground\",\n  muted: \"bg-muted text-foreground\",\n};\n\nconst spring = { type: \"spring\", stiffness: 420, damping: 34 } as const;\n\nconst COMMANDS = [\n  { id: \"summarize\", label: \"/summarize\", hint: \"Summarize open comments\", icon: SparklesIcon },\n  { id: \"tasks\", label: \"/tasks\", hint: \"Turn open comments into tasks\", icon: ListTodoIcon },\n  { id: \"resolve-all\", label: \"/resolve-all\", hint: \"Resolve every open comment\", icon: CheckCheckIcon },\n] as const;\n\nfunction Count({ value }: { value: number }) {\n  const reduce = useReducedMotion();\n  const prev = useRef(value);\n  const dir = value >= prev.current ? 1 : -1;\n  useEffect(() => {\n    prev.current = value;\n  }, [value]);\n  return (\n    <span className=\"relative inline-flex h-4 min-w-[1ch] justify-center overflow-hidden tabular-nums\">\n      <AnimatePresence custom={dir} initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ y: 0, opacity: 1 }}\n          custom={dir}\n          exit={reduce ? { opacity: 0 } : { y: `${-dir * 100}%`, opacity: 0 }}\n          initial={reduce ? { opacity: 0 } : { y: `${dir * 100}%`, opacity: 0 }}\n          key={value}\n          transition={spring}\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction FilterTabs({\n  value,\n  onChange,\n  options,\n}: {\n  value: Filter;\n  onChange: (v: Filter) => void;\n  options: { value: Filter; label: string; count: number }[];\n}) {\n  return (\n    <Tabs onValueChange={(v) => onChange(v as Filter)} value={value}>\n      <TabsList aria-label=\"Filter comments\" className=\"text-muted-foreground\" size=\"sm\">\n        {options.map((o) => (\n          <TabsTab className=\"gap-1 px-2 text-xs sm:text-xs\" key={o.value} value={o.value}>\n            {o.label}\n            <span className=\"text-muted-foreground\">\n              <Count value={o.count} />\n            </span>\n          </TabsTab>\n        ))}\n      </TabsList>\n    </Tabs>\n  );\n}\n\nfunction ResolveButton({ resolved, onToggle, label }: { resolved: boolean; onToggle: () => void; label: string }) {\n  const reduce = useReducedMotion();\n  const [burst, setBurst] = useState(0);\n  return (\n    <button\n      aria-label={resolved ? `Reopen comment by ${label}` : `Resolve comment by ${label}`}\n      aria-pressed={resolved}\n      className=\"group/resolve relative flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full outline-none transition-[scale] duration-150 ease-out after:absolute after:-inset-0.5 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.92]\"\n      onClick={() => {\n        if (!resolved) setBurst((b) => b + 1);\n        onToggle();\n      }}\n      title={resolved ? \"Reopen\" : \"Resolve\"}\n      type=\"button\"\n    >\n      {!reduce && burst > 0 && (\n        <span aria-hidden className=\"pointer-events-none absolute inset-0\" key={burst}>\n          {Array.from({ length: 8 }, (_, i) => {\n            const a = (i / 8) * Math.PI * 2;\n            return (\n              <motion.span\n                animate={{ x: Math.cos(a) * 16, y: Math.sin(a) * 16, scale: 0, opacity: 0 }}\n                className=\"absolute top-1/2 left-1/2 -mt-[2px] -ml-[2px] size-1 rounded-full bg-success\"\n                initial={{ x: 0, y: 0, scale: 1, opacity: 1 }}\n                key={i}\n                transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}\n              />\n            );\n          })}\n        </span>\n      )}\n      <motion.span\n        animate={resolved && !reduce ? { scale: [0.8, 1.12, 1] } : { scale: 1 }}\n        className={cn(\n          \"flex size-5 items-center justify-center rounded-full border transition-colors duration-200\",\n          resolved\n            ? \"border-success bg-success text-white\"\n            : \"border-input text-transparent group-hover/resolve:border-success/60 group-hover/resolve:text-success/60\",\n        )}\n        transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}\n      >\n        <svg aria-hidden className=\"size-3\" fill=\"none\" viewBox=\"0 0 16 16\">\n          <motion.path\n            animate={{ pathLength: resolved ? [0, 1] : 1 }}\n            d=\"M3.5 8.5l3 3 6-7\"\n            initial={false}\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth={2.4}\n            transition={reduce ? { duration: 0 } : { duration: 0.3, delay: 0.08 }}\n          />\n        </svg>\n      </motion.span>\n    </button>\n  );\n}\n\nfunction ResultCard({ result, onClose }: { result: Result; onClose: () => void }) {\n  const reduce = useReducedMotion();\n  const words = useMemo(() => result.body.map((line) => line.split(\" \")), [result.body]);\n  const total = words.reduce((n, w) => n + w.length, 0);\n  const [shown, setShown] = useState(reduce ? total : 0);\n\n  useEffect(() => {\n    if (reduce) return;\n    const t = setInterval(() => setShown((s) => (s >= total ? s : s + 1)), 28);\n    return () => clearInterval(t);\n  }, [total, reduce]);\n\n  let counter = 0;\n  return (\n    <div className=\"relative overflow-hidden rounded-xl border bg-muted/40 p-3\">\n      <div className=\"flex items-center gap-2 text-xs\">\n        <span className=\"flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground\">\n          {result.kind === \"summary\" ? <SparklesIcon className=\"size-3\" /> : <ListTodoIcon className=\"size-3\" />}\n        </span>\n        <span className=\"font-medium\">{result.title}</span>\n        <button\n          aria-label=\"Dismiss result\"\n          className=\"relative ms-auto flex size-6 cursor-pointer items-center justify-center rounded-full text-muted-foreground outline-none transition-[color,background-color,scale] duration-150 ease-out after:absolute after:-inset-1 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\"\n          onClick={onClose}\n          type=\"button\"\n        >\n          <XIcon className=\"size-3.5\" />\n        </button>\n      </div>\n      <div aria-live=\"polite\" className=\"mt-2 space-y-1.5 text-sm leading-relaxed\">\n        {words.map((line, li) => (\n          <p className={cn(line.length > 0 && words.length > 1 && \"flex gap-2\")} key={li}>\n            {words.length > 1 && (\n              <span aria-hidden className=\"mt-2 size-1.5 shrink-0 rounded-full bg-muted-foreground/50\" />\n            )}\n            <span>\n              {line.map((w, wi) => {\n                const visible = counter++ < shown;\n                return (\n                  <motion.span\n                    animate={{ opacity: visible ? 1 : 0, filter: visible ? \"blur(0px)\" : \"blur(4px)\" }}\n                    initial={false}\n                    key={wi}\n                    transition={{ duration: 0.25 }}\n                  >\n                    {w}{\" \"}\n                  </motion.span>\n                );\n              })}\n            </span>\n          </p>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction CommentRow({\n  comment: c,\n  resolved,\n  onToggle,\n  ref,\n}: {\n  comment: ReviewComment;\n  resolved: boolean;\n  onToggle: () => void;\n  ref?: Ref<HTMLElement>;\n}) {\n  const reduce = useReducedMotion();\n  const present = useIsPresent();\n  return (\n    <motion.article\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      className={cn(\n        \"group/comment flex gap-3 rounded-xl p-2.5 transition-colors hover:bg-accent/60\",\n        resolved && \"text-muted-foreground\",\n        !present && \"pointer-events-none\",\n      )}\n      exit={\n        reduce\n          ? { opacity: 0, transition: { duration: 0.15 } }\n          : { opacity: 0, scale: 0.98, x: 12, transition: { duration: 0.15, ease: [0.23, 1, 0.32, 1] } }\n      }\n      initial={reduce ? false : { opacity: 0, y: 8, scale: 0.98 }}\n      layout={!reduce}\n      ref={ref}\n      transition={spring}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"flex size-7 shrink-0 items-center justify-center rounded-full font-medium text-[11px] transition-opacity\",\n          toneClass[c.tone ?? \"muted\"],\n          resolved && \"opacity-50\",\n        )}\n      >\n        {c.initials}\n      </span>\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex h-7 items-center gap-2\">\n          <span className={cn(\"truncate font-medium text-sm transition-colors\", !resolved && \"text-foreground\")}>\n            {c.author}\n          </span>\n          <span className=\"shrink-0 text-muted-foreground text-xs\">{c.time}</span>\n          <span className=\"ms-auto -me-1\">\n            <ResolveButton\n              label={c.author}\n              onToggle={() => {\n                if (present) onToggle();\n              }}\n              resolved={resolved}\n            />\n          </span>\n        </div>\n        {c.anchor && (\n          <span className=\"flex w-fit max-w-full items-center gap-1.5 truncate text-muted-foreground text-xs\">\n            <span aria-hidden className=\"h-3 w-0.5 shrink-0 rounded-full bg-warning\" />\n            <span className=\"truncate\">{c.anchor}</span>\n          </span>\n        )}\n        <p className=\"mt-1 text-pretty text-sm leading-relaxed transition-colors\">{c.body}</p>\n      </div>\n    </motion.article>\n  );\n}\n\nexport function ReviewFeed({\n  comments: initial,\n  currentUser = { name: \"You\", initials: \"YO\", tone: \"muted\" },\n  summary,\n  className,\n}: ReviewFeedProps) {\n  const reduce = useReducedMotion();\n  const listboxId = useId();\n  const inputId = useId();\n  const [comments, setComments] = useState(initial);\n  const [filter, setFilter] = useState<Filter>(\"open\");\n  const [draft, setDraft] = useState(\"\");\n  const [active, setActive] = useState(0);\n  const [results, setResults] = useState<Result[]>([]);\n  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);\n\n  useEffect(() => () => timers.current.forEach(clearTimeout), []);\n\n  const open = comments.filter((c) => !c.resolved);\n  const counts = { all: comments.length, open: open.length, resolved: comments.length - open.length };\n  const visible = comments.filter((c) => filter === \"all\" || (filter === \"open\" ? !c.resolved : c.resolved));\n\n  const slash = draft.startsWith(\"/\") && !draft.includes(\" \");\n  const matches = slash ? COMMANDS.filter((c) => c.label.startsWith(draft.toLowerCase())) : [];\n  const menuOpen = matches.length > 0;\n\n  const [settling, setSettling] = useState<string[]>([]);\n  const settlingRef = useRef(new Set<string>());\n  const commentsRef = useRef(comments);\n  commentsRef.current = comments;\n\n  const setResolved = (id: string, resolved: boolean) =>\n    setComments((cs) => cs.map((c) => (c.id === id ? { ...c, resolved } : c)));\n\n  const resolveSoon = (id: string, delay = 0) => {\n    if (settlingRef.current.has(id)) return;\n    settlingRef.current.add(id);\n    const start = () => {\n      setSettling((s) => [...s, id]);\n      timers.current.push(\n        setTimeout(\n          () => {\n            setResolved(id, true);\n            settlingRef.current.delete(id);\n            setSettling((s) => s.filter((x) => x !== id));\n          },\n          reduce ? 0 : 650,\n        ),\n      );\n    };\n    if (delay) timers.current.push(setTimeout(start, delay));\n    else start();\n  };\n\n  const toggleRow = (id: string) => {\n    if (settlingRef.current.has(id)) return;\n    const current = commentsRef.current.find((c) => c.id === id);\n    if (!current) return;\n    if (current.resolved) setResolved(id, false);\n    else resolveSoon(id);\n  };\n\n  const run = (cmd: (typeof COMMANDS)[number][\"id\"]) => {\n    setDraft(\"\");\n    setActive(0);\n    if (cmd === \"resolve-all\") {\n      open.forEach((c, i) => resolveSoon(c.id, reduce ? 0 : i * 160));\n      return;\n    }\n    const body =\n      cmd === \"summarize\"\n        ? summary\n          ? [summary]\n          : open.length\n            ? open.map((c) => `${c.author.split(\" \")[0]} on ${c.anchor ?? \"the doc\"}: ${c.body.split(/(?<=[.?!])\\s/)[0]}`)\n            : [\"Nothing open. Every comment is resolved.\"]\n        : open.length\n          ? open.map((c) => `${c.anchor ? `${c.anchor}: ` : \"\"}${c.body}`)\n          : [\"No open comments to turn into tasks.\"];\n    const title =\n      cmd === \"summarize\" ? `Summary of ${open.length} open` : `${open.length} task${open.length === 1 ? \"\" : \"s\"} drafted`;\n    const result: Result = { id: Date.now(), kind: cmd === \"summarize\" ? \"summary\" : \"tasks\", title, body };\n    setResults((r) => [result, ...r].slice(0, 2));\n  };\n\n  const submit = () => {\n    if (menuOpen) return run(matches[Math.min(active, matches.length - 1)].id);\n    const body = draft.trim();\n    if (!body) return;\n    setComments((cs) => [\n      ...cs,\n      { id: `n-${Date.now()}`, author: currentUser.name, initials: currentUser.initials, tone: currentUser.tone, time: \"now\", body },\n    ]);\n    setDraft(\"\");\n    if (filter === \"resolved\") setFilter(\"open\");\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    if (menuOpen && (e.key === \"ArrowDown\" || e.key === \"ArrowUp\")) {\n      e.preventDefault();\n      setActive((a) => (a + (e.key === \"ArrowDown\" ? 1 : -1) + matches.length) % matches.length);\n    } else if (menuOpen && e.key === \"Tab\") {\n      e.preventDefault();\n      setDraft(matches[Math.min(active, matches.length - 1)].label);\n    } else if (e.key === \"Escape\" && draft) {\n      setDraft(\"\");\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      submit();\n    }\n  };\n\n  let empty: ReactNode = null;\n  if (visible.length === 0) {\n    empty = (\n      <motion.div\n        animate={{ opacity: 1, scale: 1 }}\n        className=\"my-auto flex flex-col items-center gap-2 pb-6 text-center\"\n        initial={reduce ? false : { opacity: 0, scale: 0.96 }}\n        key=\"empty\"\n        transition={spring}\n      >\n        <span className=\"flex size-9 items-center justify-center rounded-full bg-success/12 text-success-foreground\">\n          <CheckCheckIcon className=\"size-4\" />\n        </span>\n        <p className=\"font-medium text-sm\">{filter === \"resolved\" ? \"Nothing resolved yet\" : \"All clear\"}</p>\n        <p className=\"text-muted-foreground text-xs\">\n          {filter === \"resolved\" ? \"Resolved comments land here.\" : \"Every comment is resolved.\"}\n        </p>\n      </motion.div>\n    );\n  }\n\n  return (\n    <section\n      aria-label=\"Comments\"\n      className={cn(\n        \"flex w-full max-w-[400px] flex-col rounded-2xl border bg-card text-card-foreground shadow-xs/5\",\n        className,\n      )}\n    >\n      <header className=\"flex flex-wrap items-center justify-between gap-2 border-b px-4 py-3\">\n        <h2 className=\"font-medium text-sm\">Comments</h2>\n        <FilterTabs\n          onChange={setFilter}\n          options={[\n            { value: \"all\", label: \"All\", count: counts.all },\n            { value: \"open\", label: \"Open\", count: counts.open },\n            { value: \"resolved\", label: \"Resolved\", count: counts.resolved },\n          ]}\n          value={filter}\n        />\n      </header>\n\n      <div\n        className=\"flex h-[340px] flex-col gap-1 overflow-y-auto p-2 [mask-image:linear-gradient(to_bottom,black_calc(100%-20px),transparent)]\"\n        role=\"tabpanel\"\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {results.map((r) => (\n            <motion.div\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.96 }}\n              initial={reduce ? false : { opacity: 0, y: -8, scale: 0.98 }}\n              key={r.id}\n              layout={!reduce}\n              transition={spring}\n            >\n              <ResultCard onClose={() => setResults((rs) => rs.filter((x) => x.id !== r.id))} result={r} />\n            </motion.div>\n          ))}\n          {visible.map((c) => (\n            <CommentRow\n              comment={c}\n              key={c.id}\n              onToggle={() => toggleRow(c.id)}\n              resolved={!!c.resolved || settling.includes(c.id)}\n            />\n          ))}\n          {empty}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"relative border-t p-3\">\n        <AnimatePresence>\n          {menuOpen && (\n            <motion.ul\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              className=\"absolute inset-x-3 bottom-full z-10 mb-1 rounded-xl border bg-popover p-1 text-popover-foreground shadow-md/5\"\n              exit={{ opacity: 0, y: 4, scale: 0.98, transition: { duration: 0.12 } }}\n              id={listboxId}\n              initial={reduce ? false : { opacity: 0, y: 6, scale: 0.98 }}\n              role=\"listbox\"\n              style={{ transformOrigin: \"16px 100%\" }}\n              transition={spring}\n            >\n              {matches.map((m, i) => {\n                const Icon = m.icon;\n                const selected = i === Math.min(active, matches.length - 1);\n                return (\n                  <li\n                    aria-selected={selected}\n                    className={cn(\n                      \"flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm\",\n                      selected && \"bg-accent\",\n                    )}\n                    id={`${listboxId}-${m.id}`}\n                    key={m.id}\n                    onMouseDown={(e) => {\n                      e.preventDefault();\n                      run(m.id);\n                    }}\n                    onMouseEnter={() => setActive(i)}\n                    role=\"option\"\n                  >\n                    <Icon className=\"size-4 text-muted-foreground\" />\n                    <span className=\"font-medium font-mono text-xs\">{m.label}</span>\n                    <span className=\"truncate text-muted-foreground text-xs\">{m.hint}</span>\n                  </li>\n                );\n              })}\n            </motion.ul>\n          )}\n        </AnimatePresence>\n        <label className=\"sr-only\" htmlFor={inputId}>\n          Add a comment\n        </label>\n        <div className=\"flex items-center gap-2 rounded-full border bg-background ps-3.5 pe-1 transition-shadow focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/24 dark:bg-input/32\">\n          <input\n            aria-activedescendant={menuOpen ? `${listboxId}-${matches[Math.min(active, matches.length - 1)].id}` : undefined}\n            aria-autocomplete=\"list\"\n            aria-controls={listboxId}\n            aria-expanded={menuOpen}\n            autoComplete=\"off\"\n            className={cn(\n              \"h-9 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/72\",\n              slash && \"font-mono\",\n            )}\n            id={inputId}\n            onChange={(e) => {\n              setDraft(e.target.value);\n              setActive(0);\n            }}\n            onKeyDown={onKeyDown}\n            placeholder=\"Comment, or type / for commands\"\n            role=\"combobox\"\n            value={draft}\n          />\n          <motion.button\n            animate={{ scale: draft.trim() ? 1 : 0.85, opacity: draft.trim() ? 1 : 0.4 }}\n            aria-label={menuOpen ? \"Run command\" : \"Post comment\"}\n            className=\"relative flex size-7 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground outline-none transition-[scale] duration-150 ease-out after:absolute after:-inset-0.5 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background active:scale-[0.97] disabled:cursor-default\"\n            disabled={!draft.trim()}\n            onClick={submit}\n            transition={reduce ? { duration: 0 } : spring}\n            type=\"button\"\n          >\n            {slash ? <CornerDownLeftIcon className=\"size-3.5\" /> : <ArrowUpIcon className=\"size-3.5\" />}\n          </motion.button>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
    }
  ]
}