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 { X } from "lucide-react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useChatPanel } from "../context/ChatPanelContext";
|
import { useChatPanel } from "../context/ChatPanelContext";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { ChatInput } from "./ChatInput";
|
import { ChatInput } from "./ChatInput";
|
||||||
|
import { ChatConversationList } from "./ChatConversationList";
|
||||||
|
import { ChatMessageList } from "./ChatMessageList";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { chatApi } from "../api/chat";
|
||||||
|
import { useChatMessages } from "../hooks/useChatMessages";
|
||||||
|
|
||||||
export function ChatPanel() {
|
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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
|
@ -29,32 +61,34 @@ export function ChatPanel() {
|
||||||
|
|
||||||
{/* Two-column layout: conversation list (left) + thread (right) */}
|
{/* Two-column layout: conversation list (left) + thread (right) */}
|
||||||
<div className="flex flex-1 min-h-0 min-w-[380px]">
|
<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="w-[160px] flex-shrink-0 border-r border-border bg-card overflow-hidden">
|
||||||
<div className="p-3 text-center text-xs text-muted-foreground">
|
{selectedCompanyId ? (
|
||||||
No conversations yet
|
<ChatConversationList companyId={selectedCompanyId} />
|
||||||
</div>
|
) : (
|
||||||
|
<div className="p-3 text-center text-xs text-muted-foreground">
|
||||||
|
No workspace selected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column: message thread + input */}
|
{/* Right column: message thread + input */}
|
||||||
<div className="flex flex-1 flex-col min-w-0">
|
<div className="flex flex-1 flex-col min-w-0">
|
||||||
<ScrollArea className="flex-1 p-3">
|
<ScrollArea className="flex-1 p-0">
|
||||||
{/* Messages placeholder -- wired in Plan 05 */}
|
{activeConversationId ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<ChatMessageList conversationId={activeConversationId} />
|
||||||
<p className="text-sm text-muted-foreground">
|
) : (
|
||||||
Send a message to start this conversation.
|
<div className="flex items-center justify-center h-full p-3">
|
||||||
</p>
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
</div>
|
Send a message to start this conversation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div className="border-t border-border px-3 py-2">
|
<div className="border-t border-border px-3 py-2">
|
||||||
<ChatInput
|
<ChatInput onSend={handleSend} isSubmitting={isSending} />
|
||||||
onSend={(content) => {
|
|
||||||
// TODO: Wire to API in Plan 05
|
|
||||||
console.log("send:", content);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue