AI Chat History
AI Chat History é um componente para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.
HextaUI
MIT
other
O que é
AI Chat History é um componente para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Não precisa de pacotes além do que o projeto já tem
Licença
Este componente vem de HextaUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
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>
);
}