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 */}
+
+
+ );
+}
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 (
+
+ );
+ }
+
+ 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 (