{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "thread-list",
  "type": "registry:component",
  "title": "Thread List",
  "description": "Sidebar or dropdown for switching conversations, with search and active selection.",
  "registryDependencies": [
    "button",
    "input",
    "skeleton",
    "https://r.assistant-ui.com/tooltip-icon-button.json"
  ],
  "dependencies": [
    "@assistant-ui/react",
    "lucide-react"
  ],
  "files": [
    {
      "type": "registry:component",
      "path": "components/assistant-ui/thread-list.tsx",
      "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  AuiIf,\n  ThreadListItemMorePrimitive,\n  ThreadListItemPrimitive,\n  ThreadListPrimitive,\n  useAui,\n  useAuiState,\n} from \"@assistant-ui/react\";\nimport {\n  ArchiveIcon,\n  Loader2Icon,\n  MoreHorizontalIcon,\n  PencilIcon,\n  PlusIcon,\n  SearchIcon,\n  TrashIcon,\n} from \"lucide-react\";\nimport {\n  forwardRef,\n  Fragment,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type FC,\n} from \"react\";\n\nexport const ThreadList: FC = () => {\n  const [search, setSearch] = useState(\"\");\n  const hasThreads = useAuiState((s) => s.threads.threadIds.length > 0);\n\n  return (\n    <ThreadListRoot>\n      <ThreadListNew />\n      {hasThreads && (\n        <ThreadListSearch value={search} onValueChange={setSearch} />\n      )}\n      <ThreadListItems searchQuery={hasThreads ? search : \"\"} />\n    </ThreadListRoot>\n  );\n};\n\nexport const ThreadListSearch = forwardRef<\n  HTMLInputElement,\n  Omit<ComponentPropsWithoutRef<typeof Input>, \"value\" | \"onChange\"> & {\n    value: string;\n    onValueChange: (value: string) => void;\n  }\n>(({ className, value, onValueChange, ...props }, ref) => {\n  return (\n    <div data-slot=\"aui_thread-list-search\" className=\"relative px-0.5 py-1\">\n      <SearchIcon\n        data-slot=\"aui_thread-list-search-icon\"\n        className=\"text-muted-foreground pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2\"\n      />\n      <Input\n        ref={ref}\n        type=\"search\"\n        value={value}\n        onChange={(event) => onValueChange(event.target.value)}\n        aria-label=\"Search threads\"\n        placeholder=\"Search threads\"\n        className={cn(\"h-8 ps-8 text-sm\", className)}\n        {...props}\n      />\n    </div>\n  );\n});\n\nThreadListSearch.displayName = \"ThreadListSearch\";\n\nexport const ThreadListRoot: FC<\n  ComponentPropsWithoutRef<typeof ThreadListPrimitive.Root>\n> = ({ className, ...props }) => {\n  return (\n    <ThreadListPrimitive.Root\n      data-slot=\"aui_thread-list-root\"\n      className={cn(\"flex flex-col gap-0.5\", className)}\n      {...props}\n    />\n  );\n};\n\nexport const ThreadListItems: FC<\n  ComponentPropsWithoutRef<\"div\"> & { searchQuery?: string }\n> = ({ className, searchQuery = \"\", ...props }) => {\n  return (\n    <div\n      data-slot=\"aui_thread-list-items\"\n      className={cn(\"flex flex-col gap-0.5\", className)}\n      {...props}\n    >\n      <AuiIf condition={(s) => s.threads.isLoading}>\n        <ThreadListSkeleton />\n      </AuiIf>\n      <AuiIf condition={(s) => !s.threads.isLoading}>\n        <ThreadListItemGroups searchQuery={searchQuery} />\n      </AuiIf>\n    </div>\n  );\n};\n\nconst DAY_IN_MS = 86_400_000;\n\nconst dateGroupLabel = (\n  date: Date | undefined,\n  startOfToday: number,\n): string => {\n  if (!date || date.getTime() >= startOfToday) return \"Today\";\n  if (date.getTime() >= startOfToday - DAY_IN_MS) return \"Yesterday\";\n  return \"Earlier\";\n};\n\ntype ThreadListGroup = { label: string; indices: number[] };\n\nconst ThreadListItemGroups: FC<{ searchQuery?: string }> = ({\n  searchQuery = \"\",\n}) => {\n  const threadIds = useAuiState((s) => s.threads.threadIds);\n  const threadItems = useAuiState((s) => s.threads.threadItems);\n\n  const query = searchQuery.trim().toLowerCase();\n\n  const { filteredIndices, groups } = useMemo(() => {\n    const itemsById = new Map(threadItems.map((item) => [item.id, item]));\n    const dates = threadIds.map((id) => itemsById.get(id)?.lastMessageAt);\n    const filteredIndices = threadIds\n      .map((id, index) => ({ id, index }))\n      .filter(\n        ({ id }) =>\n          !query ||\n          (itemsById.get(id)?.title || \"New Chat\")\n            .toLowerCase()\n            .includes(query),\n      )\n      .map(({ index }) => index);\n    if (!filteredIndices.some((index) => dates[index])) {\n      return { filteredIndices, groups: null };\n    }\n\n    const now = new Date();\n    const startOfToday = new Date(\n      now.getFullYear(),\n      now.getMonth(),\n      now.getDate(),\n    ).getTime();\n    const time = (index: number) =>\n      dates[index]?.getTime() ?? Number.MAX_SAFE_INTEGER;\n    const sorted = [...filteredIndices].sort((a, b) => time(b) - time(a));\n\n    const result: ThreadListGroup[] = [];\n    for (const index of sorted) {\n      const label = dateGroupLabel(dates[index], startOfToday);\n      const lastGroup = result[result.length - 1];\n      if (lastGroup?.label === label) {\n        lastGroup.indices.push(index);\n      } else {\n        result.push({ label, indices: [index] });\n      }\n    }\n    return { filteredIndices, groups: result };\n  }, [threadIds, threadItems, query]);\n\n  if (query && filteredIndices.length === 0) {\n    return (\n      <div\n        data-slot=\"aui_thread-list-empty\"\n        className=\"text-muted-foreground px-2.5 py-4 text-sm\"\n      >\n        No threads found\n      </div>\n    );\n  }\n\n  if (!groups) {\n    return filteredIndices.map((index) => (\n      <ThreadListPrimitive.ItemByIndex\n        key={threadIds[index]}\n        index={index}\n        components={{ ThreadListItem }}\n      />\n    ));\n  }\n\n  return groups.map((group) => (\n    <Fragment key={group.label}>\n      <div\n        data-slot=\"aui_thread-list-group-label\"\n        className=\"text-muted-foreground px-2.5 pt-3 pb-1 text-xs font-medium\"\n      >\n        {group.label}\n      </div>\n      {group.indices.map((index) => (\n        <ThreadListPrimitive.ItemByIndex\n          key={threadIds[index]}\n          index={index}\n          components={{ ThreadListItem }}\n        />\n      ))}\n    </Fragment>\n  ));\n};\n\nexport const ThreadListNew = forwardRef<\n  HTMLButtonElement,\n  ComponentPropsWithoutRef<typeof Button> & { labelClassName?: string }\n>(({ className, labelClassName, children, ...props }, ref) => {\n  return (\n    <ThreadListPrimitive.New asChild>\n      <Button\n        ref={ref}\n        variant=\"ghost\"\n        data-slot=\"aui_thread-list-new\"\n        className={cn(\n          \"hover:bg-muted data-active:bg-muted h-8 justify-start gap-2 rounded-md px-2.5 text-sm font-normal\",\n          className,\n        )}\n        {...props}\n      >\n        {children ?? (\n          <>\n            <PlusIcon\n              data-slot=\"aui_thread-list-new-icon\"\n              className=\"size-4 shrink-0\"\n            />\n            <span\n              data-slot=\"aui_thread-list-new-label\"\n              className={cn(\"whitespace-nowrap\", labelClassName)}\n            >\n              New Thread\n            </span>\n          </>\n        )}\n      </Button>\n    </ThreadListPrimitive.New>\n  );\n});\n\nThreadListNew.displayName = \"ThreadListNew\";\n\nconst ThreadListSkeleton: FC = () => {\n  return (\n    <div className=\"flex flex-col gap-0.5\">\n      {Array.from({ length: 5 }, (_, i) => (\n        <div\n          key={i}\n          role=\"status\"\n          aria-label=\"Loading threads\"\n          data-slot=\"aui_thread-list-skeleton-wrapper\"\n          className=\"flex h-8 items-center px-2.5\"\n        >\n          <Skeleton\n            data-slot=\"aui_thread-list-skeleton\"\n            className=\"h-3.5 w-full\"\n          />\n        </div>\n      ))}\n    </div>\n  );\n};\n\nexport const ThreadListItem: FC = () => {\n  const isRunning = useAuiState((s) => s.threadListItem.isRunning);\n  const [isRenaming, setIsRenaming] = useState(false);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const restoreFocusRef = useRef(false);\n\n  useEffect(() => {\n    if (isRenaming || !restoreFocusRef.current) return;\n    restoreFocusRef.current = false;\n    triggerRef.current?.focus();\n  }, [isRenaming]);\n\n  return (\n    <ThreadListItemPrimitive.Root\n      data-slot=\"aui_thread-list-item\"\n      className=\"group hover:bg-muted focus-visible:bg-muted data-active:bg-muted has-focus-visible:bg-muted has-data-[state=open]:bg-muted relative flex h-8 items-center rounded-md transition-colors focus-visible:outline-none\"\n    >\n      {isRenaming ? (\n        <ThreadListItemRename\n          onDone={(restoreFocus) => {\n            restoreFocusRef.current = restoreFocus;\n            setIsRenaming(false);\n          }}\n        />\n      ) : (\n        <ThreadListItemPrimitive.Trigger\n          ref={triggerRef}\n          data-slot=\"aui_thread-list-item-trigger\"\n          className=\"focus-visible:ring-ring/50 flex h-full min-w-0 flex-1 items-center rounded-md px-2.5 text-start text-sm outline-none group-hover:pe-9 group-has-focus-visible:pe-9 group-has-data-[state=open]:pe-9 group-data-active:pe-9 focus-visible:ring-[3px]\"\n        >\n          {isRunning && (\n            <Loader2Icon\n              aria-hidden\n              data-slot=\"aui_thread-list-item-running\"\n              className=\"text-muted-foreground me-1.5 size-3.5 shrink-0 animate-spin\"\n            />\n          )}\n          <span\n            data-slot=\"aui_thread-list-item-title\"\n            className=\"min-w-0 flex-1 truncate\"\n          >\n            <ThreadListItemPrimitive.Title fallback=\"New Chat\" />\n          </span>\n          {isRunning && <span className=\"sr-only\">Running</span>}\n        </ThreadListItemPrimitive.Trigger>\n      )}\n      <ThreadListItemMore onRename={() => setIsRenaming(true)} />\n    </ThreadListItemPrimitive.Root>\n  );\n};\n\nconst ThreadListItemRename: FC<{\n  onDone: (restoreFocus: boolean) => void;\n}> = ({ onDone }) => {\n  const aui = useAui();\n  const title = useAuiState((s) => s.threadListItem.title) ?? \"\";\n  const [value, setValue] = useState(title);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const settledRef = useRef(false);\n\n  useEffect(() => {\n    inputRef.current?.select();\n  }, []);\n\n  const commit = (restoreFocus: boolean) => {\n    if (settledRef.current) return;\n    settledRef.current = true;\n\n    const next = value.trim();\n    if (!next || next === title) {\n      onDone(restoreFocus);\n      return;\n    }\n\n    // Deferred so a synchronous throw lands on the rejection path too.\n    Promise.resolve()\n      .then(() => aui.threadListItem.rename(next))\n      .then(\n        () => onDone(restoreFocus),\n        () => {\n          settledRef.current = false;\n          if (restoreFocus) inputRef.current?.focus();\n        },\n      );\n  };\n\n  const cancel = () => {\n    if (settledRef.current) return;\n    settledRef.current = true;\n    onDone(true);\n  };\n\n  return (\n    <Input\n      ref={inputRef}\n      autoFocus\n      data-slot=\"aui_thread-list-item-rename\"\n      aria-label=\"Rename thread\"\n      value={value}\n      className=\"h-7 min-w-0 flex-1 ps-2.5 pe-9 text-sm\"\n      onChange={(event) => setValue(event.target.value)}\n      onBlur={() => commit(false)}\n      onKeyDown={(event) => {\n        if (event.key === \"Enter\") {\n          event.preventDefault();\n          commit(true);\n        } else if (event.key === \"Escape\") {\n          event.preventDefault();\n          cancel();\n        }\n      }}\n    />\n  );\n};\n\nconst ThreadListItemMore: FC<{ onRename: () => void }> = ({ onRename }) => {\n  return (\n    <ThreadListItemMorePrimitive.Root sharedFocusGroup>\n      <ThreadListItemMorePrimitive.Trigger asChild>\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          data-slot=\"aui_thread-list-item-more\"\n          className=\"data-[state=open]:bg-accent absolute end-1.5 top-1/2 size-6 -translate-y-1/2 p-0 opacity-0 group-hover:opacity-100 group-has-focus-visible:opacity-100 group-data-active:opacity-100 data-[state=open]:opacity-100\"\n        >\n          <MoreHorizontalIcon className=\"size-3.5\" />\n          <span className=\"sr-only\">More options</span>\n        </Button>\n      </ThreadListItemMorePrimitive.Trigger>\n      <ThreadListItemMorePrimitive.Content\n        side=\"right\"\n        align=\"start\"\n        sideOffset={6}\n        data-slot=\"aui_thread-list-item-more-content\"\n        className=\"bg-popover/95 text-popover-foreground data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:animate-out data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 overflow-hidden rounded-xl border p-1.5 shadow-lg backdrop-blur-sm\"\n      >\n        <ThreadListItemMorePrimitive.Item\n          data-slot=\"aui_thread-list-item-more-item\"\n          className=\"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm outline-none select-none\"\n          onSelect={onRename}\n        >\n          <PencilIcon className=\"size-4\" />\n          Rename\n        </ThreadListItemMorePrimitive.Item>\n        <ThreadListItemPrimitive.Archive asChild>\n          <ThreadListItemMorePrimitive.Item\n            data-slot=\"aui_thread-list-item-more-item\"\n            className=\"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm outline-none select-none\"\n          >\n            <ArchiveIcon className=\"size-4\" />\n            Archive\n          </ThreadListItemMorePrimitive.Item>\n        </ThreadListItemPrimitive.Archive>\n        <ThreadListItemPrimitive.Delete asChild>\n          <ThreadListItemMorePrimitive.Item\n            data-slot=\"aui_thread-list-item-more-item\"\n            className=\"text-destructive hover:bg-destructive/10 hover:text-destructive focus:bg-destructive/10 focus:text-destructive flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm outline-none select-none\"\n          >\n            <TrashIcon className=\"size-4\" />\n            Delete\n          </ThreadListItemMorePrimitive.Item>\n        </ThreadListItemPrimitive.Delete>\n      </ThreadListItemMorePrimitive.Content>\n    </ThreadListItemMorePrimitive.Root>\n  );\n};\n"
    }
  ]
}