{"content":"# Chat List & Management\n\nBuild a chat list page with search, rename, and delete functionality. Uses nuqs for URL-synced filters and deep-linkable modal dialogs.\n\n## Server Actions\n\nCreate server actions for chat management operations:\n\n```ts\n\"use server\";\n\nimport { headers } from \"next/headers\";\nimport { revalidatePath } from \"next/cache\";\nimport { redirect } from \"next/navigation\";\nimport { auth } from \"@/lib/auth/server\";\nimport {\n  deleteChat as deleteChatQuery,\n  renameChat as renameChatQuery,\n} from \"./queries\";\nimport { v7 as uuidv7 } from \"uuid\";\n\nexport async function deleteChatAction(chatId: string) {\n  const session = await auth.api.getSession({\n    headers: await headers(),\n  });\n\n  if (!session) {\n    return { error: \"Unauthorized\" };\n  }\n\n  const success = await deleteChatQuery(chatId, session.user.id);\n\n  if (!success) {\n    return { error: \"Chat not found\" };\n  }\n\n  revalidatePath(\"/chats\");\n  return { success: true };\n}\n\nexport async function createNewChat() {\n  const session = await auth.api.getSession({\n    headers: await headers(),\n  });\n\n  if (!session) {\n    redirect(\"/sign-in\");\n  }\n\n  const chatId = uuidv7();\n  redirect(`/chats/${chatId}`);\n}\n\nexport async function renameChatAction(chatId: string, newTitle: string) {\n  const session = await auth.api.getSession({\n    headers: await headers(),\n  });\n\n  if (!session) {\n    return { error: \"Unauthorized\" };\n  }\n\n  const trimmedTitle = newTitle.trim();\n  if (!trimmedTitle) {\n    return { error: \"Title cannot be empty\" };\n  }\n\n  const success = await renameChatQuery(chatId, session.user.id, trimmedTitle);\n\n  if (!success) {\n    return { error: \"Chat not found\" };\n  }\n\n  revalidatePath(\"/chats\");\n  return { success: true, title: trimmedTitle };\n}\n```\n\n## Chat List Component\n\nThe chat list uses nuqs for URL-synced search and modal state:\n\n```tsx\n\"use client\";\n\nimport { useState, useMemo } from \"react\";\nimport Link from \"next/link\";\nimport { useQueryState, parseAsString } from \"nuqs\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport {\n  MessageSquare,\n  Search,\n  Trash2,\n  Plus,\n  MoreVertical,\n  Pencil,\n} from \"lucide-react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  deleteChatAction,\n  createNewChat,\n  renameChatAction,\n} from \"@/lib/chat/actions\";\nimport { toast } from \"sonner\";\nimport type { ChatWithPreview } from \"@/lib/chat/queries\";\n\nfunction ChatListItem({\n  chat,\n  onRequestDelete,\n  onRequestRename,\n}: {\n  chat: ChatWithPreview;\n  onRequestDelete: (id: string) => void;\n  onRequestRename: (id: string) => void;\n}) {\n  return (\n    <div className=\"flex items-center gap-2 px-3 py-3 rounded-lg hover:ring-2 hover:ring-primary/50 transition-all\">\n      <Link\n        href={`/chats/${chat.id}`}\n        className=\"flex items-center gap-3 flex-1 min-w-0\"\n      >\n        <MessageSquare className=\"size-4 text-muted-foreground shrink-0\" />\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"flex items-center justify-between gap-2\">\n            <span className=\"font-medium truncate text-sm\">{chat.title}</span>\n            <span className=\"text-xs text-muted-foreground shrink-0\">\n              {formatDistanceToNow(new Date(chat.updatedAt), {\n                addSuffix: true,\n              })}\n            </span>\n          </div>\n          <div className=\"flex items-center gap-2 mt-0.5\">\n            <p className=\"text-xs text-muted-foreground truncate flex-1\">\n              {chat.lastMessagePreview || \"No messages yet\"}\n            </p>\n            <span className=\"text-xs text-muted-foreground shrink-0\">\n              {chat.messageCount} {chat.messageCount === 1 ? \"msg\" : \"msgs\"}\n            </span>\n          </div>\n        </div>\n      </Link>\n\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"size-7 text-muted-foreground shrink-0\"\n          >\n            <MoreVertical className=\"size-4\" />\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent align=\"end\">\n          <DropdownMenuItem onSelect={() => onRequestRename(chat.id)}>\n            <Pencil className=\"size-4 mr-2\" />\n            Rename\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onSelect={() => onRequestDelete(chat.id)}\n            className=\"text-destructive focus:text-destructive\"\n          >\n            <Trash2 className=\"size-4 mr-2\" />\n            Delete\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </div>\n  );\n}\n\nexport function ChatList({\n  initialChats,\n}: {\n  initialChats: ChatWithPreview[];\n}) {\n  const [chats, setChats] = useState(initialChats);\n  // URL-synced state for search and modals\n  const [searchQuery, setSearchQuery] = useQueryState(\n    \"q\",\n    parseAsString.withDefault(\"\"),\n  );\n  const [deleteId, setDeleteId] = useQueryState(\"delete\", parseAsString);\n  const [renameId, setRenameId] = useQueryState(\"rename\", parseAsString);\n  const [isDeleting, setIsDeleting] = useState(false);\n  const [isRenaming, setIsRenaming] = useState(false);\n  const [newTitle, setNewTitle] = useState(\"\");\n\n  const chatToDelete = deleteId\n    ? chats.find((chat) => chat.id === deleteId)\n    : null;\n\n  const chatToRename = renameId\n    ? chats.find((chat) => chat.id === renameId)\n    : null;\n\n  const filteredChats = useMemo(() => {\n    if (!searchQuery.trim()) return chats;\n    const query = searchQuery.toLowerCase();\n    return chats.filter(\n      (chat) =>\n        chat.title.toLowerCase().includes(query) ||\n        chat.lastMessagePreview?.toLowerCase().includes(query),\n    );\n  }, [chats, searchQuery]);\n\n  function handleDelete(id: string) {\n    setChats((prev) => prev.filter((chat) => chat.id !== id));\n    setDeleteId(null);\n  }\n\n  function handleRenameSuccess(id: string, title: string) {\n    setChats((prev) =>\n      prev.map((chat) => (chat.id === id ? { ...chat, title } : chat)),\n    );\n    setRenameId(null);\n    setNewTitle(\"\");\n  }\n\n  async function handleConfirmDelete() {\n    if (!deleteId) return;\n    setIsDeleting(true);\n    const result = await deleteChatAction(deleteId);\n    if (result.error) {\n      toast.error(result.error);\n      setIsDeleting(false);\n    } else {\n      handleDelete(deleteId);\n      toast.success(\"Chat deleted\");\n      setIsDeleting(false);\n    }\n  }\n\n  async function handleConfirmRename(e: React.FormEvent) {\n    e.preventDefault();\n    if (!renameId || !newTitle.trim()) {\n      toast.error(\"Title cannot be empty\");\n      return;\n    }\n    setIsRenaming(true);\n    const result = await renameChatAction(renameId, newTitle);\n    setIsRenaming(false);\n    if (result.error) {\n      toast.error(result.error);\n    } else {\n      handleRenameSuccess(renameId, result.title!);\n      toast.success(\"Chat renamed\");\n    }\n  }\n\n  function handleRequestRename(id: string) {\n    const chat = chats.find((c) => c.id === id);\n    if (chat) {\n      setNewTitle(chat.title);\n      setRenameId(id);\n    }\n  }\n\n  function handleSearchChange(value: string) {\n    setSearchQuery(value || null);\n  }\n\n  if (chats.length === 0) {\n    return (\n      <div className=\"flex flex-col items-center justify-center py-16 text-center\">\n        <MessageSquare className=\"size-12 text-muted-foreground/50 mb-4\" />\n        <h3 className=\"text-lg font-medium\">No chats yet</h3>\n        <p className=\"text-muted-foreground mt-1\">\n          Start a new conversation to get going\n        </p>\n        <form action={createNewChat} className=\"mt-4\">\n          <Button type=\"submit\" className=\"gap-2\">\n            <Plus className=\"size-4\" />\n            Start a Chat\n          </Button>\n        </form>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between\">\n        <div className=\"relative flex-1 w-full sm:max-w-sm\">\n          <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground\" />\n          <Input\n            placeholder=\"Search chats...\"\n            value={searchQuery}\n            onChange={(e) => handleSearchChange(e.target.value)}\n            className=\"pl-9\"\n          />\n        </div>\n        <form action={createNewChat}>\n          <Button type=\"submit\" className=\"gap-2 w-full sm:w-auto\">\n            <Plus className=\"size-4\" />\n            New Chat\n          </Button>\n        </form>\n      </div>\n\n      {filteredChats.length === 0 ? (\n        <div className=\"flex flex-col items-center justify-center py-16 text-center\">\n          <MessageSquare className=\"size-12 text-muted-foreground/50 mb-4\" />\n          <h3 className=\"text-lg font-medium\">No chats found</h3>\n          <p className=\"text-muted-foreground mt-1\">\n            Try a different search term\n          </p>\n        </div>\n      ) : (\n        <div className=\"border rounded-lg divide-y\">\n          {filteredChats.map((chat) => (\n            <ChatListItem\n              key={chat.id}\n              chat={chat}\n              onRequestDelete={setDeleteId}\n              onRequestRename={handleRequestRename}\n            />\n          ))}\n        </div>\n      )}\n\n      {/* Delete confirmation - URL: ?delete=chatId */}\n      <AlertDialog\n        open={!!deleteId}\n        onOpenChange={(open) => !open && setDeleteId(null)}\n      >\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Delete chat?</AlertDialogTitle>\n            <AlertDialogDescription>\n              This will permanently delete &quot;{chatToDelete?.title}&quot; and\n              all its messages. This action cannot be undone.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>\n            <AlertDialogAction\n              onClick={handleConfirmDelete}\n              disabled={isDeleting}\n              className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n            >\n              {isDeleting ? \"Deleting...\" : \"Delete\"}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n\n      {/* Rename dialog - URL: ?rename=chatId */}\n      <Dialog\n        open={!!renameId}\n        onOpenChange={(open) => {\n          if (!open) {\n            setRenameId(null);\n            setNewTitle(\"\");\n          }\n        }}\n      >\n        <DialogContent>\n          <form onSubmit={handleConfirmRename}>\n            <DialogHeader>\n              <DialogTitle>Rename chat</DialogTitle>\n              <DialogDescription>\n                Enter a new name for &quot;{chatToRename?.title}&quot;.\n              </DialogDescription>\n            </DialogHeader>\n            <div className=\"py-4\">\n              <Label htmlFor=\"chat-title\" className=\"sr-only\">\n                Chat title\n              </Label>\n              <Input\n                id=\"chat-title\"\n                value={newTitle}\n                onChange={(e) => setNewTitle(e.target.value)}\n                placeholder=\"Chat title\"\n                autoFocus\n              />\n            </div>\n            <DialogFooter>\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                onClick={() => {\n                  setRenameId(null);\n                  setNewTitle(\"\");\n                }}\n                disabled={isRenaming}\n              >\n                Cancel\n              </Button>\n              <Button type=\"submit\" disabled={isRenaming}>\n                {isRenaming ? \"Saving...\" : \"Save\"}\n              </Button>\n            </DialogFooter>\n          </form>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\n```\n\n## Chats Page\n\nCreate a protected page that fetches and displays the user's chats:\n\n```tsx\nimport type { Metadata } from \"next\";\nimport { redirect } from \"next/navigation\";\nimport { headers } from \"next/headers\";\nimport { auth } from \"@/lib/auth/server\";\nimport { getUserChats } from \"@/lib/chat/queries\";\nimport { ChatList } from \"@/components/chats/chat-list\";\n\nexport const metadata: Metadata = {\n  title: \"Your Chats\",\n  description: \"View and manage your AI conversations.\",\n  alternates: {\n    canonical: \"/chats\",\n  },\n};\n\nexport default async function ChatsPage() {\n  const session = await auth.api.getSession({\n    headers: await headers(),\n  });\n\n  if (!session) {\n    redirect(\"/sign-in\");\n  }\n\n  const chats = await getUserChats(session.user.id);\n\n  return (\n    <main className=\"container mx-auto px-4 py-8\">\n      <div className=\"mb-8\">\n        <h1 className=\"text-3xl font-bold tracking-tight\">Your Chats</h1>\n        <p className=\"text-muted-foreground mt-1\">\n          View and manage your conversations\n        </p>\n      </div>\n\n      <ChatList initialChats={chats} />\n    </main>\n  );\n}\n```\n\n## Deep Linkable URLs\n\nThe chat list supports these deep links:\n\n- `/chats` - Default view\n- `/chats?q=react` - Search for \"react\"\n- `/chats?delete=abc123` - Open delete dialog for chat abc123\n- `/chats?rename=abc123` - Open rename dialog for chat abc123\n- `/chats?q=react&delete=abc123` - Combined search and delete dialog\n\nUsers can share these URLs or bookmark specific actions. Browser back/forward navigation preserves the UI state."}