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:
parent
c268f2d03e
commit
fecab1bcc2
4 changed files with 373 additions and 18 deletions
105
ui/src/components/ChatConversationItem.tsx
Normal file
105
ui/src/components/ChatConversationItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
ui/src/components/ChatConversationList.tsx
Normal file
174
ui/src/components/ChatConversationList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
ui/src/components/ChatMessageList.tsx
Normal file
42
ui/src/components/ChatMessageList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue