- Add ChatConversationItem with DropdownMenu actions (Rename, Pin/Unpin, Archive, Delete) and active highlight - Add ChatConversationList with IntersectionObserver infinite scroll, loading skeletons, delete confirmation dialog, and CRUD handlers - Add ChatMessageList with auto-scroll-to-bottom on new messages and empty state - Update ChatPanel to render ChatConversationList (left column) and ChatMessageList (right column); handleSend uses two paths: direct chatApi for new conversations, hook mutation for existing ones
105 lines
3.3 KiB
TypeScript
105 lines
3.3 KiB
TypeScript
import { MoreHorizontal, Pin } from "lucide-react";
|
|
import type { ChatConversationListItem } from "@paperclipai/shared";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "../lib/utils";
|
|
|
|
interface ChatConversationItemProps {
|
|
conversation: ChatConversationListItem;
|
|
isActive: boolean;
|
|
onSelect: (id: string) => void;
|
|
onRename: (id: string, title: string) => void;
|
|
onPin: (id: string, pinned: boolean) => void;
|
|
onArchive: (id: string) => void;
|
|
onDelete: (id: string) => void;
|
|
}
|
|
|
|
export function ChatConversationItem({
|
|
conversation,
|
|
isActive,
|
|
onSelect,
|
|
onRename,
|
|
onPin,
|
|
onArchive,
|
|
onDelete,
|
|
}: ChatConversationItemProps) {
|
|
const isPinned = !!conversation.pinnedAt;
|
|
const title = conversation.title ?? "New Conversation";
|
|
|
|
const handleRename = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
const newTitle = window.prompt("Rename conversation", title);
|
|
if (newTitle && newTitle.trim()) {
|
|
onRename(conversation.id, newTitle.trim());
|
|
}
|
|
};
|
|
|
|
const handlePin = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onPin(conversation.id, !isPinned);
|
|
};
|
|
|
|
const handleArchive = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onArchive(conversation.id);
|
|
};
|
|
|
|
const handleDelete = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onDelete(conversation.id);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => onSelect(conversation.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") onSelect(conversation.id);
|
|
}}
|
|
className={cn(
|
|
"group flex flex-col gap-0.5 rounded px-2 py-1.5 cursor-pointer relative",
|
|
isActive ? "bg-accent/60" : "hover:bg-accent",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-1 min-w-0">
|
|
{isPinned && <Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />}
|
|
<span className="text-xs font-medium truncate flex-1">{title}</span>
|
|
{/* Action menu -- visible on hover */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-5 w-5 flex-shrink-0 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label="Conversation actions"
|
|
>
|
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-40">
|
|
<DropdownMenuItem onClick={handleRename}>Rename</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handlePin}>
|
|
{isPinned ? "Unpin" : "Pin"}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleArchive}>Archive</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleDelete} className="text-destructive">
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
{conversation.lastMessagePreview && (
|
|
<span className="text-xs text-muted-foreground truncate">
|
|
{conversation.lastMessagePreview}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|