From fecab1bcc20e2d80fd2de0cd1731059648daa78e Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 16:58:12 +0000 Subject: [PATCH] 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 --- ui/src/components/ChatConversationItem.tsx | 105 +++++++++++++ ui/src/components/ChatConversationList.tsx | 174 +++++++++++++++++++++ ui/src/components/ChatMessageList.tsx | 42 +++++ ui/src/components/ChatPanel.tsx | 70 ++++++--- 4 files changed, 373 insertions(+), 18 deletions(-) create mode 100644 ui/src/components/ChatConversationItem.tsx create mode 100644 ui/src/components/ChatConversationList.tsx create mode 100644 ui/src/components/ChatMessageList.tsx diff --git a/ui/src/components/ChatConversationItem.tsx b/ui/src/components/ChatConversationItem.tsx new file mode 100644 index 00000000..7cfb8b72 --- /dev/null +++ b/ui/src/components/ChatConversationItem.tsx @@ -0,0 +1,105 @@ +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 ( +
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", + )} + > +
+ {isPinned && } + {title} + {/* Action menu -- visible on hover */} + + + + + + Rename + + {isPinned ? "Unpin" : "Pin"} + + Archive + + Delete + + + +
+ {conversation.lastMessagePreview && ( + + {conversation.lastMessagePreview} + + )} +
+ ); +} diff --git a/ui/src/components/ChatConversationList.tsx b/ui/src/components/ChatConversationList.tsx new file mode 100644 index 00000000..13011e01 --- /dev/null +++ b/ui/src/components/ChatConversationList.tsx @@ -0,0 +1,174 @@ +import { useEffect, useRef, useState } from "react"; +import { Plus } from "lucide-react"; +import { useChatConversations } from "../hooks/useChatConversations"; +import { useChatPanel } from "../context/ChatPanelContext"; +import { ChatConversationItem } from "./ChatConversationItem"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { ChatConversationListItem } from "@paperclipai/shared"; + +interface ChatConversationListProps { + companyId: string; +} + +export function ChatConversationList({ companyId }: ChatConversationListProps) { + const { activeConversationId, setActiveConversationId } = useChatPanel(); + const { data, isLoading, hasNextPage, fetchNextPage, createMutation, updateMutation, deleteMutation } = + useChatConversations(companyId); + + const [deletingId, setDeletingId] = useState(null); + const sentinelRef = useRef(null); + + // Infinite scroll via IntersectionObserver + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && hasNextPage) { + void fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasNextPage, fetchNextPage]); + + const allConversations: ChatConversationListItem[] = + data?.pages.flatMap((p) => p.items) ?? []; + + // Separate pinned from unpinned + const pinned = allConversations + .filter((c) => c.pinnedAt) + .sort((a, b) => (b.pinnedAt! > a.pinnedAt! ? 1 : -1)); + const unpinned = allConversations + .filter((c) => !c.pinnedAt) + .sort((a, b) => (b.updatedAt > a.updatedAt ? 1 : -1)); + + const sorted = [...pinned, ...unpinned]; + + const handleNewConversation = async () => { + try { + const newConvo = await createMutation.mutateAsync(undefined); + setActiveConversationId(newConvo.id); + } catch { + // ignore -- error will surface via mutation state if needed + } + }; + + const handleRename = (id: string, title: string) => { + updateMutation.mutate({ id, title }); + }; + + const handlePin = (id: string, pinned: boolean) => { + updateMutation.mutate({ id, pinnedAt: pinned ? new Date().toISOString() : null }); + }; + + const handleArchive = (id: string) => { + updateMutation.mutate({ id, archivedAt: new Date().toISOString() }); + }; + + const handleDeleteRequest = (id: string) => { + setDeletingId(id); + }; + + const handleDeleteConfirm = () => { + if (!deletingId) return; + deleteMutation.mutate(deletingId, { + onSuccess: () => { + if (activeConversationId === deletingId) { + setActiveConversationId(null); + } + setDeletingId(null); + }, + }); + }; + + return ( +
+ {/* New conversation button */} +
+ +
+ + +
+ {isLoading ? ( + // Loading skeletons + Array.from({ length: 5 }).map((_, i) => ( + + )) + ) : sorted.length === 0 ? ( +
+

No conversations yet

+

+ Start a conversation to get help from your agents. +

+
+ ) : ( + sorted.map((conversation) => ( + + )) + )} + + {/* Infinite scroll sentinel */} +
+
+ + + {/* Delete confirmation dialog */} + !open && setDeletingId(null)}> + + + Delete conversation? + + This conversation and all its messages will be permanently deleted. + + + + + + + + +
+ ); +} diff --git a/ui/src/components/ChatMessageList.tsx b/ui/src/components/ChatMessageList.tsx new file mode 100644 index 00000000..6ffa9708 --- /dev/null +++ b/ui/src/components/ChatMessageList.tsx @@ -0,0 +1,42 @@ +import { useEffect, useRef } from "react"; +import { useChatMessages } from "../hooks/useChatMessages"; +import { ChatMessage } from "./ChatMessage"; + +interface ChatMessageListProps { + conversationId: string; +} + +export function ChatMessageList({ conversationId }: ChatMessageListProps) { + const { messages, isLoading } = useChatMessages(conversationId); + const bottomRef = useRef(null); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages.length]); + + if (isLoading) { + return ( +
+

Loading messages...

+
+ ); + } + + if (messages.length === 0) { + return ( +
+

Send a message to start this conversation.

+
+ ); + } + + return ( +
+ {messages.map((message) => ( + + ))} +
+
+ ); +} diff --git a/ui/src/components/ChatPanel.tsx b/ui/src/components/ChatPanel.tsx index e0f2d646..aa7027d8 100644 --- a/ui/src/components/ChatPanel.tsx +++ b/ui/src/components/ChatPanel.tsx @@ -1,11 +1,43 @@ +import { useState } from "react"; import { X } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; import { useChatPanel } from "../context/ChatPanelContext"; +import { useCompany } from "../context/CompanyContext"; import { ChatInput } from "./ChatInput"; +import { ChatConversationList } from "./ChatConversationList"; +import { ChatMessageList } from "./ChatMessageList"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { chatApi } from "../api/chat"; +import { useChatMessages } from "../hooks/useChatMessages"; export function ChatPanel() { - const { chatOpen, setChatOpen } = useChatPanel(); + const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId } = useChatPanel(); + const { selectedCompanyId } = useCompany(); + const queryClient = useQueryClient(); + const [isSending, setIsSending] = useState(false); + + const { sendMutation } = useChatMessages(activeConversationId); + + const handleSend = async (content: string) => { + if (!selectedCompanyId) return; + + setIsSending(true); + try { + if (!activeConversationId) { + // Path 1: No active conversation -- create one first via direct API call + const newConvo = await chatApi.createConversation(selectedCompanyId, {}); + setActiveConversationId(newConvo.id); + await chatApi.postMessage(newConvo.id, { role: "user", content }); + queryClient.invalidateQueries({ queryKey: ["chat"] }); + } else { + // Path 2: Active conversation -- use hook mutation for automatic invalidation + await sendMutation.mutateAsync({ content }); + } + } finally { + setIsSending(false); + } + }; return (