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
This commit is contained in:
Nexus Dev 2026-04-01 16:58:12 +00:00
parent c268f2d03e
commit fecab1bcc2
4 changed files with 373 additions and 18 deletions

View file

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

View file

@ -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<string | null>(null);
const sentinelRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-col h-full">
{/* New conversation button */}
<div className="p-2 border-b border-border">
<Button
variant="ghost"
size="sm"
className="w-full text-xs justify-start gap-1"
onClick={handleNewConversation}
disabled={createMutation.isPending}
>
<Plus className="h-3.5 w-3.5" />
New conversation
</Button>
</div>
<ScrollArea className="flex-1">
<div className="p-1 space-y-0.5">
{isLoading ? (
// Loading skeletons
Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded" />
))
) : sorted.length === 0 ? (
<div className="p-4 text-center">
<p className="text-xs text-muted-foreground">No conversations yet</p>
<p className="text-xs text-muted-foreground mt-1">
Start a conversation to get help from your agents.
</p>
</div>
) : (
sorted.map((conversation) => (
<ChatConversationItem
key={conversation.id}
conversation={conversation}
isActive={activeConversationId === conversation.id}
onSelect={setActiveConversationId}
onRename={handleRename}
onPin={handlePin}
onArchive={handleArchive}
onDelete={handleDeleteRequest}
/>
))
)}
{/* Infinite scroll sentinel */}
<div ref={sentinelRef} className="h-1" />
</div>
</ScrollArea>
{/* Delete confirmation dialog */}
<Dialog open={!!deletingId} onOpenChange={(open) => !open && setDeletingId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete conversation?</DialogTitle>
<DialogDescription>
This conversation and all its messages will be permanently deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>
Keep conversation
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteMutation.isPending}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -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<HTMLDivElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">Loading messages...</p>
</div>
);
}
if (messages.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">Send a message to start this conversation.</p>
</div>
);
}
return (
<div className="space-y-4 p-3">
{messages.map((message) => (
<ChatMessage key={message.id} role={message.role} content={message.content} />
))}
<div ref={bottomRef} />
</div>
);
}

View file

@ -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 (
<aside
@ -29,32 +61,34 @@ export function ChatPanel() {
{/* Two-column layout: conversation list (left) + thread (right) */}
<div className="flex flex-1 min-h-0 min-w-[380px]">
{/* Left column: conversation list -- placeholder for Plan 05 */}
{/* Left column: conversation list */}
<div className="w-[160px] flex-shrink-0 border-r border-border bg-card overflow-hidden">
<div className="p-3 text-center text-xs text-muted-foreground">
No conversations yet
</div>
{selectedCompanyId ? (
<ChatConversationList companyId={selectedCompanyId} />
) : (
<div className="p-3 text-center text-xs text-muted-foreground">
No workspace selected
</div>
)}
</div>
{/* Right column: message thread + input */}
<div className="flex flex-1 flex-col min-w-0">
<ScrollArea className="flex-1 p-3">
{/* Messages placeholder -- wired in Plan 05 */}
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">
Send a message to start this conversation.
</p>
</div>
<ScrollArea className="flex-1 p-0">
{activeConversationId ? (
<ChatMessageList conversationId={activeConversationId} />
) : (
<div className="flex items-center justify-center h-full p-3">
<p className="text-sm text-muted-foreground text-center">
Send a message to start this conversation.
</p>
</div>
)}
</ScrollArea>
{/* Input area */}
<div className="border-t border-border px-3 py-2">
<ChatInput
onSend={(content) => {
// TODO: Wire to API in Plan 05
console.log("send:", content);
}}
/>
<ChatInput onSend={handleSend} isSubmitting={isSending} />
</div>
</div>
</div>