{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-chat-history",
  "title": "AI Chat History",
  "description": "Display and manage conversation history with search and filtering.",
  "registryDependencies": [
    "button",
    "alert-dialog",
    "card",
    "dropdown-menu",
    "input-group",
    "separator"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/ai/ai-chat-history.tsx",
      "content": "\"use client\";\n\nimport {\n  Archive,\n  Loader2,\n  MessageSquare,\n  MoreVertical,\n  Pencil,\n  Plus,\n  Search,\n  Trash2,\n} from \"lucide-react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/registry/new-york/ui/alert-dialog\";\nimport { Button } from \"@/registry/new-york/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/registry/new-york/ui/card\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/registry/new-york/ui/dropdown-menu\";\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupInput,\n} from \"@/registry/new-york/ui/input-group\";\nimport { Separator } from \"@/registry/new-york/ui/separator\";\n\nexport interface Conversation {\n  id: string;\n  title: string;\n  lastMessage?: string;\n  lastMessageAt?: Date;\n  messageCount?: number;\n  isArchived?: boolean;\n  isActive?: boolean;\n}\n\nexport interface AIChatHistoryProps {\n  conversations: Conversation[];\n  activeConversationId?: string;\n  onSelect?: (conversationId: string) => void;\n  onNewConversation?: () => void;\n  onRename?: (conversationId: string, newTitle: string) => Promise<void>;\n  onDelete?: (conversationId: string) => Promise<void>;\n  onArchive?: (conversationId: string) => Promise<void>;\n  onUnarchive?: (conversationId: string) => Promise<void>;\n  className?: string;\n  showSearch?: boolean;\n  showNewButton?: boolean;\n}\n\nfunction formatDate(date: Date): string {\n  const now = new Date();\n  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());\n  const yesterday = new Date(today);\n  yesterday.setDate(yesterday.getDate() - 1);\n  const thisWeek = new Date(today);\n  thisWeek.setDate(thisWeek.getDate() - 7);\n\n  const dateOnly = new Date(\n    date.getFullYear(),\n    date.getMonth(),\n    date.getDate()\n  );\n\n  if (dateOnly.getTime() === today.getTime()) {\n    return \"Today\";\n  }\n  if (dateOnly.getTime() === yesterday.getTime()) {\n    return \"Yesterday\";\n  }\n  if (dateOnly.getTime() >= thisWeek.getTime()) {\n    return \"This Week\";\n  }\n\n  const month = date.toLocaleString(\"en-US\", { month: \"short\" });\n  const day = date.getDate();\n  const year = date.getFullYear();\n  const currentYear = now.getFullYear();\n\n  if (year === currentYear) {\n    return `${month} ${day}`;\n  }\n  return `${month} ${day}, ${year}`;\n}\n\nfunction groupConversationsByDate(conversations: Conversation[]): {\n  label: string;\n  conversations: Conversation[];\n}[] {\n  const groups: Record<string, Conversation[]> = {};\n\n  conversations.forEach((conv) => {\n    if (!conv.lastMessageAt) {\n      if (!groups[\"Older\"]) {\n        groups[\"Older\"] = [];\n      }\n      groups[\"Older\"].push(conv);\n      return;\n    }\n\n    const label = formatDate(conv.lastMessageAt);\n    if (!groups[label]) {\n      groups[label] = [];\n    }\n    groups[label].push(conv);\n  });\n\n  const orderedLabels = [\"Today\", \"Yesterday\", \"This Week\"];\n  const result: { label: string; conversations: Conversation[] }[] = [];\n\n  orderedLabels.forEach((label) => {\n    if (groups[label]) {\n      result.push({ label, conversations: groups[label] });\n      delete groups[label];\n    }\n  });\n\n  Object.keys(groups)\n    .sort()\n    .forEach((label) => {\n      result.push({ label, conversations: groups[label] });\n    });\n\n  if (groups[\"Older\"]) {\n    result.push({ label: \"Older\", conversations: groups[\"Older\"] });\n  }\n\n  return result;\n}\n\ninterface EmptyStateProps {\n  searchQuery: string;\n  showNewButton: boolean;\n  onNewConversation?: () => void;\n}\n\nfunction EmptyState({\n  searchQuery,\n  showNewButton,\n  onNewConversation,\n}: EmptyStateProps) {\n  return (\n    <div className=\"flex flex-col items-center justify-center gap-4 py-12 text-center\">\n      <div className=\"flex size-12 items-center justify-center rounded-full bg-muted\">\n        <MessageSquare className=\"size-6 text-muted-foreground\" />\n      </div>\n      <div className=\"flex flex-col gap-2\">\n        <p className=\"font-medium text-sm\">\n          {searchQuery ? \"No conversations found\" : \"No conversations\"}\n        </p>\n        <p className=\"text-muted-foreground text-sm\">\n          {searchQuery\n            ? \"Try a different search term\"\n            : \"Start a new conversation to get started\"}\n        </p>\n      </div>\n      {!searchQuery && showNewButton && onNewConversation && (\n        <Button\n          className=\"min-h-[44px] sm:min-h-[24px]\"\n          onClick={onNewConversation}\n          type=\"button\"\n          variant=\"outline\"\n        >\n          <Plus className=\"size-4\" />\n          New Conversation\n        </Button>\n      )}\n    </div>\n  );\n}\n\ninterface ConversationHeaderProps {\n  conversationsCount: number;\n  showNewButton: boolean;\n  onNewConversation?: () => void;\n}\n\nfunction ConversationHeader({\n  conversationsCount,\n  showNewButton,\n  onNewConversation,\n}: ConversationHeaderProps) {\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <div className=\"flex flex-wrap items-center justify-between gap-6\">\n        <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n          <CardTitle>Conversations</CardTitle>\n          <CardDescription>\n            {conversationsCount} conversation\n            {conversationsCount !== 1 ? \"s\" : \"\"}\n          </CardDescription>\n        </div>\n        {showNewButton && onNewConversation && (\n          <Button\n            className=\"min-h-[44px] w-full shrink-0 sm:min-h-[24px]\"\n            onClick={onNewConversation}\n            type=\"button\"\n          >\n            <Plus className=\"size-4\" />\n            <span className=\"whitespace-nowrap\">New Chat</span>\n          </Button>\n        )}\n      </div>\n    </div>\n  );\n}\n\ninterface ConversationSearchProps {\n  value: string;\n  onChange: (value: string) => void;\n  placeholder?: string;\n}\n\nfunction ConversationSearch({\n  value,\n  onChange,\n  placeholder = \"Search conversations…\",\n}: ConversationSearchProps) {\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (\n        (e.metaKey || e.ctrlKey) &&\n        e.key === \"k\" &&\n        !e.shiftKey &&\n        document.activeElement !== inputRef.current\n      ) {\n        e.preventDefault();\n        inputRef.current?.focus();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, []);\n\n  return (\n    <InputGroup>\n      <InputGroupAddon>\n        <Search className=\"size-4\" />\n      </InputGroupAddon>\n      <InputGroupInput\n        aria-label=\"Search conversations\"\n        onChange={(e) => onChange(e.target.value)}\n        onKeyDown={(e) => {\n          if (e.key === \"Escape\") {\n            onChange(\"\");\n            inputRef.current?.blur();\n          }\n        }}\n        placeholder={placeholder}\n        ref={inputRef}\n        type=\"search\"\n        value={value}\n      />\n    </InputGroup>\n  );\n}\n\ninterface ConversationItemProps {\n  conversation: Conversation;\n  isActive: boolean;\n  isEditing: boolean;\n  editValue: string;\n  onSelect: () => void;\n  onRenameStart: () => void;\n  onRenameSubmit: () => void;\n  onRenameCancel: () => void;\n  onEditValueChange: (value: string) => void;\n  onRename?: (conversationId: string, newTitle: string) => Promise<void>;\n  onDelete?: (conversationId: string) => Promise<void>;\n  onArchive?: (conversationId: string) => Promise<void>;\n  onUnarchive?: (conversationId: string) => Promise<void>;\n  isLoading?: boolean;\n}\n\nfunction ConversationItem({\n  conversation,\n  isActive,\n  isEditing,\n  editValue,\n  onSelect,\n  onRenameStart,\n  onRenameSubmit,\n  onRenameCancel,\n  onEditValueChange,\n  onRename,\n  onDelete,\n  onArchive,\n  onUnarchive,\n  isLoading = false,\n}: ConversationItemProps) {\n  const [showDeleteDialog, setShowDeleteDialog] = useState(false);\n  const [isDeleting, setIsDeleting] = useState(false);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    if (isEditing && inputRef.current) {\n      inputRef.current.focus();\n      inputRef.current.select();\n    }\n  }, [isEditing]);\n\n  const handleDelete = useCallback(async () => {\n    if (!onDelete) return;\n    setIsDeleting(true);\n    try {\n      await onDelete(conversation.id);\n      setShowDeleteDialog(false);\n    } catch (error) {\n      console.error(\"Failed to delete conversation:\", error);\n    } finally {\n      setIsDeleting(false);\n    }\n  }, [conversation.id, onDelete]);\n\n  return (\n    <>\n      <div\n        className={cn(\n          \"group relative flex flex-col gap-2 rounded-lg border p-3 transition-colors\",\n          \"min-h-[60px] touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n          isActive\n            ? \"border-primary bg-primary/5 shadow-xs\"\n            : \"border-transparent bg-card focus-within:border-border focus-within:bg-muted/50 hover:border-border hover:bg-muted/50\",\n          isLoading && \"pointer-events-none opacity-50\"\n        )}\n        role=\"listitem\"\n      >\n        <div className=\"flex items-start gap-3\">\n          <button\n            aria-label={`Select conversation ${conversation.title}`}\n            className=\"flex min-h-[44px] min-w-0 flex-1 flex-col gap-1 rounded-sm text-left [-webkit-tap-highlight-color:transparent] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:min-h-[24px]\"\n            disabled={isLoading}\n            onClick={onSelect}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\" || e.key === \" \") {\n                e.preventDefault();\n                onSelect();\n              }\n            }}\n            type=\"button\"\n          >\n            {isEditing ? (\n              <input\n                aria-label=\"Edit conversation title\"\n                className=\"min-h-[44px] w-full rounded-md border bg-background px-2 py-1.5 text-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 sm:min-h-[24px] sm:text-sm\"\n                onBlur={onRenameSubmit}\n                onChange={(e) => onEditValueChange(e.target.value)}\n                onKeyDown={(e) => {\n                  if (e.key === \"Enter\") {\n                    e.preventDefault();\n                    onRenameSubmit();\n                  } else if (e.key === \"Escape\") {\n                    e.preventDefault();\n                    onRenameCancel();\n                  }\n                }}\n                ref={inputRef}\n                value={editValue}\n              />\n            ) : (\n              <>\n                <div className=\"flex flex-wrap items-center gap-2\">\n                  <h4 className=\"wrap-break-word min-w-0 font-medium text-sm\">\n                    {conversation.title}\n                  </h4>\n                </div>\n                {conversation.lastMessage && (\n                  <p className=\"wrap-break-word line-clamp-2 min-w-0 text-muted-foreground text-xs\">\n                    {conversation.lastMessage}\n                  </p>\n                )}\n                <div className=\"flex flex-wrap items-center gap-2 text-muted-foreground text-xs\">\n                  {conversation.lastMessageAt && (\n                    <span className=\"whitespace-nowrap tabular-nums\">\n                      {formatDate(conversation.lastMessageAt)}\n                    </span>\n                  )}\n                  {conversation.messageCount !== undefined && (\n                    <>\n                      <span aria-hidden=\"true\" className=\"shrink-0\">\n                        •\n                      </span>\n                      <span className=\"whitespace-nowrap tabular-nums\">\n                        {conversation.messageCount} message\n                        {conversation.messageCount !== 1 ? \"s\" : \"\"}\n                      </span>\n                    </>\n                  )}\n                </div>\n              </>\n            )}\n          </button>\n          {!isEditing && (\n            <div className=\"absolute top-2 right-2 z-10\">\n              <DropdownMenu>\n                <DropdownMenuTrigger asChild>\n                  <Button\n                    aria-label={`More options for ${conversation.title}`}\n                    className=\"min-h-[44px] min-w-[44px] shrink-0 opacity-0 transition-opacity [-webkit-tap-highlight-color:transparent] focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 sm:min-h-[24px] sm:min-w-[24px]\"\n                    disabled={isLoading}\n                    size=\"icon\"\n                    type=\"button\"\n                    variant=\"ghost\"\n                  >\n                    <MoreVertical className=\"size-4\" />\n                  </Button>\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"end\">\n                  {onRename && (\n                    <DropdownMenuItem\n                      disabled={isLoading}\n                      onClick={onRenameStart}\n                    >\n                      <Pencil className=\"size-4\" />\n                      Rename\n                    </DropdownMenuItem>\n                  )}\n                  <DropdownMenuSeparator />\n                  {conversation.isArchived\n                    ? onUnarchive && (\n                        <DropdownMenuItem\n                          disabled={isLoading}\n                          onClick={() => onUnarchive(conversation.id)}\n                        >\n                          <Archive className=\"size-4\" />\n                          Unarchive\n                        </DropdownMenuItem>\n                      )\n                    : onArchive && (\n                        <DropdownMenuItem\n                          disabled={isLoading}\n                          onClick={() => onArchive(conversation.id)}\n                        >\n                          <Archive className=\"size-4\" />\n                          Archive\n                        </DropdownMenuItem>\n                      )}\n                  {onDelete && (\n                    <>\n                      <DropdownMenuSeparator />\n                      <DropdownMenuItem\n                        disabled={isLoading}\n                        onClick={() => setShowDeleteDialog(true)}\n                        variant=\"destructive\"\n                      >\n                        <Trash2 className=\"size-4\" />\n                        Delete\n                      </DropdownMenuItem>\n                    </>\n                  )}\n                </DropdownMenuContent>\n              </DropdownMenu>\n            </div>\n          )}\n        </div>\n      </div>\n\n      <AlertDialog onOpenChange={setShowDeleteDialog} open={showDeleteDialog}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Delete conversation?</AlertDialogTitle>\n            <AlertDialogDescription>\n              Are you sure you want to delete &quot;{conversation.title}&quot;?\n              This action cannot be undone.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>\n            <AlertDialogAction\n              className=\"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40\"\n              disabled={isDeleting}\n              onClick={handleDelete}\n            >\n              {isDeleting ? (\n                <>\n                  <Loader2 className=\"size-4 animate-spin\" />\n                  Deleting…\n                </>\n              ) : (\n                \"Delete\"\n              )}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n}\n\ninterface ConversationGroupProps {\n  label: string;\n  conversations: Conversation[];\n  activeConversationId?: string;\n  editingId: string | null;\n  editValue: string;\n  onSelect: (conversationId: string) => void;\n  onRenameStart: (conversation: Conversation) => void;\n  onRenameSubmit: (conversationId: string) => void;\n  onRenameCancel: () => void;\n  onEditValueChange: (value: string) => void;\n  onRename?: (conversationId: string, newTitle: string) => Promise<void>;\n  onDelete?: (conversationId: string) => Promise<void>;\n  onArchive?: (conversationId: string) => Promise<void>;\n  onUnarchive?: (conversationId: string) => Promise<void>;\n  isLoading?: Record<string, boolean>;\n}\n\nfunction ConversationGroup({\n  label,\n  conversations,\n  activeConversationId,\n  editingId,\n  editValue,\n  onSelect,\n  onRenameStart,\n  onRenameSubmit,\n  onRenameCancel,\n  onEditValueChange,\n  onRename,\n  onDelete,\n  onArchive,\n  onUnarchive,\n  isLoading = {},\n}: ConversationGroupProps) {\n  return (\n    <div>\n      <div className=\"sticky top-0 z-10 bg-card pb-2\">\n        <h3 className=\"font-medium text-muted-foreground text-xs uppercase tracking-wider\">\n          {label}\n        </h3>\n      </div>\n      <div className=\"flex flex-col gap-1\" role=\"list\">\n        {conversations.map((conversation) => {\n          const isActive = conversation.id === activeConversationId;\n          const isEditing = editingId === conversation.id;\n\n          return (\n            <ConversationItem\n              conversation={conversation}\n              editValue={editValue}\n              isActive={isActive}\n              isEditing={isEditing}\n              isLoading={isLoading[conversation.id]}\n              key={conversation.id}\n              onArchive={onArchive}\n              onDelete={onDelete}\n              onEditValueChange={onEditValueChange}\n              onRename={onRename}\n              onRenameCancel={onRenameCancel}\n              onRenameStart={() => onRenameStart(conversation)}\n              onRenameSubmit={() => onRenameSubmit(conversation.id)}\n              onSelect={() => onSelect(conversation.id)}\n              onUnarchive={onUnarchive}\n            />\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nexport default function AIChatHistory({\n  conversations,\n  activeConversationId,\n  onSelect,\n  onNewConversation,\n  onRename,\n  onDelete,\n  onArchive,\n  onUnarchive,\n  className,\n  showSearch = true,\n  showNewButton = true,\n}: AIChatHistoryProps) {\n  const [searchQuery, setSearchQuery] = useState(\"\");\n  const [editingId, setEditingId] = useState<string | null>(null);\n  const [editValue, setEditValue] = useState(\"\");\n  const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>(\n    {}\n  );\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const filteredConversations = useMemo(() => {\n    if (!searchQuery.trim()) return conversations;\n\n    const query = searchQuery.toLowerCase().trim();\n    return conversations.filter(\n      (conv) =>\n        conv.title.toLowerCase().includes(query) ||\n        conv.lastMessage?.toLowerCase().includes(query)\n    );\n  }, [conversations, searchQuery]);\n\n  const groupedConversations = useMemo(\n    () => groupConversationsByDate(filteredConversations),\n    [filteredConversations]\n  );\n\n  const handleRenameStart = useCallback((conversation: Conversation) => {\n    setEditingId(conversation.id);\n    setEditValue(conversation.title);\n  }, []);\n\n  const handleRenameSubmit = useCallback(\n    async (conversationId: string) => {\n      if (!(onRename && editValue.trim())) {\n        setEditingId(null);\n        return;\n      }\n\n      const trimmedValue = editValue.trim();\n      if (\n        trimmedValue ===\n        conversations.find((c) => c.id === conversationId)?.title\n      ) {\n        setEditingId(null);\n        return;\n      }\n\n      setLoadingStates((prev) => ({ ...prev, [conversationId]: true }));\n      try {\n        await onRename(conversationId, trimmedValue);\n        setEditingId(null);\n      } catch (error) {\n        console.error(\"Failed to rename conversation:\", error);\n      } finally {\n        setLoadingStates((prev) => {\n          const next = { ...prev };\n          delete next[conversationId];\n          return next;\n        });\n      }\n    },\n    [onRename, editValue, conversations]\n  );\n\n  const handleRenameCancel = useCallback(() => {\n    setEditingId(null);\n    setEditValue(\"\");\n  }, []);\n\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (\n        e.target instanceof HTMLInputElement ||\n        e.target instanceof HTMLTextAreaElement\n      ) {\n        return;\n      }\n\n      if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n        e.preventDefault();\n        const items = containerRef.current?.querySelectorAll<HTMLElement>(\n          '[role=\"listitem\"] button'\n        );\n        if (!items || items.length === 0) return;\n\n        const currentIndex = Array.from(items).findIndex(\n          (item) => item === document.activeElement\n        );\n        const nextIndex =\n          e.key === \"ArrowDown\"\n            ? (currentIndex + 1) % items.length\n            : currentIndex === -1\n              ? items.length - 1\n              : (currentIndex - 1 + items.length) % items.length;\n\n        items[nextIndex]?.focus();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, []);\n\n  return (\n    <Card\n      className={cn(\"flex h-fit w-full max-w-sm flex-col shadow-xs\", className)}\n    >\n      <CardHeader className=\"shrink-0\">\n        <div className=\"flex flex-col gap-4\">\n          <ConversationHeader\n            conversationsCount={conversations.length}\n            onNewConversation={onNewConversation}\n            showNewButton={showNewButton}\n          />\n          {showSearch && (\n            <ConversationSearch onChange={setSearchQuery} value={searchQuery} />\n          )}\n        </div>\n      </CardHeader>\n      <CardContent\n        className=\"flex min-h-0 flex-1 flex-col gap-0 overflow-y-auto overscroll-contain\"\n        ref={containerRef}\n      >\n        {filteredConversations.length === 0 ? (\n          <EmptyState\n            onNewConversation={onNewConversation}\n            searchQuery={searchQuery}\n            showNewButton={showNewButton}\n          />\n        ) : (\n          <div className=\"flex flex-col gap-4\">\n            {groupedConversations.map((group, groupIdx) => (\n              <div key={group.label}>\n                <ConversationGroup\n                  activeConversationId={activeConversationId}\n                  conversations={group.conversations}\n                  editingId={editingId}\n                  editValue={editValue}\n                  isLoading={loadingStates}\n                  label={group.label}\n                  onArchive={onArchive}\n                  onDelete={onDelete}\n                  onEditValueChange={setEditValue}\n                  onRename={onRename}\n                  onRenameCancel={handleRenameCancel}\n                  onRenameStart={handleRenameStart}\n                  onRenameSubmit={handleRenameSubmit}\n                  onSelect={onSelect || (() => {})}\n                  onUnarchive={onUnarchive}\n                />\n                {groupIdx < groupedConversations.length - 1 && (\n                  <Separator className=\"my-4\" />\n                )}\n              </div>\n            ))}\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "block",
    "ai"
  ],
  "type": "registry:ui"
}