nexus/ui/src/components/ChatConversationItem.tsx
Nexus Dev fecab1bcc2 feat(21-05): wire full chat UI with conversation list, message thread, and ChatPanel integration
- 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
2026-04-04 03:55:47 +00:00

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>
);
}