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