diff --git a/ui/src/components/ChatConversationList.tsx b/ui/src/components/ChatConversationList.tsx new file mode 100644 index 00000000..fac9453d --- /dev/null +++ b/ui/src/components/ChatConversationList.tsx @@ -0,0 +1,322 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { MoreHorizontal, Pin, Plus, X } from "lucide-react"; +import { useChatConversations, useConversationActions } from "../hooks/useChatConversations"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "../lib/utils"; +import type { ChatConversation } from "@paperclipai/shared"; + +interface ChatConversationListProps { + companyId: string; + activeId: string | null; + onSelect: (id: string) => void; + onNew: () => void; + onClose: () => void; +} + +function formatTimestamp(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } else if (diffDays === 1) { + return "Yesterday"; + } else if (diffDays < 7) { + return date.toLocaleDateString([], { weekday: "short" }); + } else { + return date.toLocaleDateString([], { month: "short", day: "numeric" }); + } +} + +interface ConversationItemProps { + conversation: ChatConversation; + isActive: boolean; + onSelect: (id: string) => void; + actions: ReturnType; +} + +function ConversationItem({ conversation, isActive, onSelect, actions }: ConversationItemProps) { + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(conversation.title ?? ""); + const [confirmDelete, setConfirmDelete] = useState(false); + const renameInputRef = useRef(null); + + const title = conversation.title ?? "New conversation"; + const isPinned = conversation.pinnedAt !== null; + const isArchived = conversation.archivedAt !== null; + + const handleRenameStart = useCallback(() => { + setRenameValue(conversation.title ?? ""); + setIsRenaming(true); + setTimeout(() => renameInputRef.current?.focus(), 0); + }, [conversation.title]); + + const handleRenameConfirm = useCallback(() => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== conversation.title) { + actions.rename.mutate({ id: conversation.id, title: trimmed }); + } + setIsRenaming(false); + }, [renameValue, conversation.id, conversation.title, actions.rename]); + + const handleRenameKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleRenameConfirm(); + } else if (e.key === "Escape") { + e.preventDefault(); + setIsRenaming(false); + } + }, + [handleRenameConfirm], + ); + + return ( +
!isRenaming && onSelect(conversation.id)} + onDoubleClick={() => !isRenaming && handleRenameStart()} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" && !isRenaming) onSelect(conversation.id); + }} + aria-current={isActive ? "true" : undefined} + > +
+
+ {isPinned && } + {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={handleRenameConfirm} + onKeyDown={handleRenameKeyDown} + className="flex-1 min-w-0 text-[13px] bg-transparent border-b border-primary outline-none" + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {title} + )} + {isArchived && ( + archived + )} +
+ + {formatTimestamp(conversation.updatedAt)} + +
+ + {!isRenaming && ( +
+ {confirmDelete ? ( +
e.stopPropagation()} + > + + Delete this conversation? + + + +
+ ) : ( + + + + + e.stopPropagation()}> + handleRenameStart()} + > + Rename conversation + + + isPinned + ? actions.unpin.mutate(conversation.id) + : actions.pin.mutate(conversation.id) + } + > + {isPinned ? "Unpin conversation" : "Pin conversation"} + + + isArchived + ? actions.unarchive.mutate(conversation.id) + : actions.archive.mutate(conversation.id) + } + > + {isArchived ? "Unarchive conversation" : "Archive conversation"} + + setConfirmDelete(true)} + > + Delete conversation + + + + )} +
+ )} +
+ ); +} + +export function ChatConversationList({ + companyId, + activeId, + onSelect, + onNew, + onClose, +}: ChatConversationListProps) { + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = + useChatConversations(companyId); + const actions = useConversationActions(); + const sentinelRef = useRef(null); + + const allConversations = data?.pages.flatMap((page) => page.items) ?? []; + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + return ( + + ); +} diff --git a/ui/src/components/ChatMessageList.tsx b/ui/src/components/ChatMessageList.tsx new file mode 100644 index 00000000..37977da6 --- /dev/null +++ b/ui/src/components/ChatMessageList.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef } from "react"; +import { useChatMessages } from "../hooks/useChatMessages"; +import { ChatMarkdownMessage } from "./ChatMarkdownMessage"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "../lib/utils"; + +interface ChatMessageListProps { + conversationId: string; +} + +export function ChatMessageList({ conversationId }: ChatMessageListProps) { + const { data, isLoading } = useChatMessages(conversationId); + const bottomRef = useRef(null); + + const allMessages = data?.pages.flatMap((page) => page.items) ?? []; + + useEffect(() => { + if (bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [allMessages.length]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (allMessages.length === 0) { + return ( +
+

+ Send a message to start the conversation. +

+
+ ); + } + + return ( +
+ {allMessages.map((msg) => ( +
+
+ {msg.role === "user" ? ( + {msg.content} + ) : ( + + )} +
+ + {new Date(msg.createdAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + +
+ ))} + + ); +} diff --git a/ui/src/components/ChatPanel.tsx b/ui/src/components/ChatPanel.tsx new file mode 100644 index 00000000..7a5146bf --- /dev/null +++ b/ui/src/components/ChatPanel.tsx @@ -0,0 +1,106 @@ +import { useCallback, useEffect } from "react"; +import { useChatPanel } from "../context/ChatPanelContext"; +import { useCompany } from "../context/CompanyContext"; +import { useCreateConversation } from "../hooks/useChatConversations"; +import { useSendMessage } from "../hooks/useChatMessages"; +import { ChatConversationList } from "./ChatConversationList"; +import { ChatMessageList } from "./ChatMessageList"; +import { ChatInput } from "./ChatInput"; + +export function ChatPanel() { + const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId } = useChatPanel(); + const { selectedCompanyId } = useCompany(); + const createConversation = useCreateConversation(selectedCompanyId); + const sendMessage = useSendMessage(activeConversationId); + + const focusInput = useCallback(() => { + // Small delay to allow render to settle + setTimeout(() => { + const textarea = document.querySelector('[aria-label="Message input"]'); + textarea?.focus(); + }, 50); + }, []); + + useEffect(() => { + if (chatOpen) { + focusInput(); + } + }, [chatOpen, focusInput]); + + const handleNew = useCallback(async () => { + if (!selectedCompanyId) return; + try { + const conversation = await createConversation.mutateAsync(undefined); + setActiveConversationId(conversation.id); + focusInput(); + } catch { + // Ignore errors here — handled by mutation state + } + }, [selectedCompanyId, createConversation, setActiveConversationId, focusInput]); + + const handleSend = useCallback( + async (content: string) => { + if (!activeConversationId) { + // Create conversation first if none selected + if (!selectedCompanyId) return; + try { + const conversation = await createConversation.mutateAsync(undefined); + setActiveConversationId(conversation.id); + // Send after creating + await sendMessage.mutateAsync(content); + } catch { + // Ignore + } + } else { + await sendMessage.mutateAsync(content); + } + }, + [activeConversationId, selectedCompanyId, createConversation, setActiveConversationId, sendMessage], + ); + + const handleClose = useCallback(() => { + setChatOpen(false); + }, [setChatOpen]); + + return ( + + ); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 6a57e5a1..b931d520 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { BookOpen, Moon, Settings, Sun } from "lucide-react"; +import { BookOpen, MessageSquare, Moon, Settings, Sun } from "lucide-react"; import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; import { InstanceSidebar } from "./InstanceSidebar"; import { BreadcrumbBar } from "./BreadcrumbBar"; import { PropertiesPanel } from "./PropertiesPanel"; +import { ChatPanel } from "./ChatPanel"; import { CommandPalette } from "./CommandPalette"; import { NewIssueDialog } from "./NewIssueDialog"; import { NewProjectDialog } from "./NewProjectDialog"; @@ -18,6 +19,7 @@ import { WorktreeBanner } from "./WorktreeBanner"; import { DevRestartBanner } from "./DevRestartBanner"; import { useDialog } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; +import { useChatPanel } from "../context/ChatPanelContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { useTheme, THEME_META } from "../context/ThemeContext"; @@ -49,7 +51,8 @@ function readRememberedInstanceSettingsPath(): string { export function Layout() { const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar(); const { openNewIssue, openOnboarding } = useDialog(); - const { togglePanelVisible } = usePanel(); + const { togglePanelVisible, setPanelVisible } = usePanel(); + const { chatOpen, toggleChat } = useChatPanel(); const { companies, loading: companiesLoading, @@ -144,6 +147,13 @@ export function Layout() { const togglePanel = togglePanelVisible; + // Close PropertiesPanel when chat opens to avoid competing for space + useEffect(() => { + if (chatOpen) { + setPanelVisible(false); + } + }, [chatOpen, setPanelVisible]); + useCompanyPageMemory(); useKeyboardShortcuts({ @@ -400,6 +410,21 @@ export function Layout() { > {isDarkTheme ? : } + + + + + {chatOpen ? "Close chat" : "Open chat"} +
@@ -432,6 +457,7 @@ export function Layout() { )} +