feat(21-03): add ChatPanel, ChatConversationList, ChatMessageList, and Layout integration

- Create ChatConversationList with infinite scroll (IntersectionObserver), inline rename/delete confirmation, pin/archive/unarchive actions via DropdownMenu
- Create ChatMessageList with role=log, aria-live, ChatMarkdownMessage for assistant messages, auto-scroll to bottom
- Create ChatPanel right-side drawer shell composing conversation list and message area with width transition (chatOpen ? 380 : 0)
- Integrate ChatPanel and MessageSquare toggle button into Layout.tsx
- Add effect to close PropertiesPanel when chat opens
This commit is contained in:
Mikkel Georgsen 2026-04-01 13:12:03 +02:00
parent 2a0724839b
commit 7868b0739b
4 changed files with 537 additions and 2 deletions

View file

@ -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<typeof useConversationActions>;
}
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<HTMLInputElement>(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<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleRenameConfirm();
} else if (e.key === "Escape") {
e.preventDefault();
setIsRenaming(false);
}
},
[handleRenameConfirm],
);
return (
<div
className={cn(
"group relative flex items-center px-3 py-3 cursor-pointer min-h-[48px]",
"hover:bg-sidebar-accent/50 transition-colors",
isActive && "border-l-2 border-primary bg-sidebar-accent",
)}
onClick={() => !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}
>
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-1 min-w-0">
{isPinned && <Pin className="h-3 w-3 text-primary shrink-0" fill="currentColor" />}
{isRenaming ? (
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => 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()}
/>
) : (
<span className="flex-1 min-w-0 truncate text-[13px]">{title}</span>
)}
{isArchived && (
<span className="text-[10px] text-muted-foreground shrink-0">archived</span>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatTimestamp(conversation.updatedAt)}
</span>
</div>
{!isRenaming && (
<div className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity ml-1">
{confirmDelete ? (
<div
className="flex items-center gap-1 bg-popover border border-border rounded-md px-2 py-1 shadow-md"
onClick={(e) => e.stopPropagation()}
>
<span className="text-xs text-muted-foreground whitespace-nowrap">
Delete this conversation?
</span>
<Button
variant="destructive"
size="sm"
className="h-6 text-xs px-2"
onClick={(e) => {
e.stopPropagation();
actions.remove.mutate(conversation.id);
setConfirmDelete(false);
}}
>
Delete conversation
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs px-2"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(false);
}}
>
Keep conversation
</Button>
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => e.stopPropagation()}
aria-label="Conversation actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem
onSelect={() => handleRenameStart()}
>
Rename conversation
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
isPinned
? actions.unpin.mutate(conversation.id)
: actions.pin.mutate(conversation.id)
}
>
{isPinned ? "Unpin conversation" : "Pin conversation"}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
isArchived
? actions.unarchive.mutate(conversation.id)
: actions.archive.mutate(conversation.id)
}
>
{isArchived ? "Unarchive conversation" : "Archive conversation"}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => setConfirmDelete(true)}
>
Delete conversation
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</div>
);
}
export function ChatConversationList({
companyId,
activeId,
onSelect,
onNew,
onClose,
}: ChatConversationListProps) {
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useChatConversations(companyId);
const actions = useConversationActions();
const sentinelRef = useRef<HTMLDivElement>(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 (
<nav aria-label="Conversations" className="flex flex-col h-full">
<div className="flex items-center justify-between px-3 py-2 border-b border-border h-12 shrink-0">
<span className="text-base font-semibold">Chat</span>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onNew}
aria-label="New conversation"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>New conversation</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
aria-label="Close chat"
>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Close chat</TooltipContent>
</Tooltip>
</div>
</div>
{isLoading ? (
<div aria-busy="true" className="flex flex-col gap-1 p-2">
<Skeleton className="h-12 mx-0 my-1" />
<Skeleton className="h-12 mx-0 my-1" />
<Skeleton className="h-12 mx-0 my-1" />
</div>
) : allConversations.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center p-4 text-center gap-3">
<p className="text-sm text-muted-foreground">No conversations yet</p>
<p className="text-xs text-muted-foreground">
Start a conversation to get help with your work.
</p>
<Button size="sm" onClick={onNew}>
New conversation
</Button>
</div>
) : (
<ScrollArea className="flex-1">
<div role="list">
{allConversations.map((conversation) => (
<div key={conversation.id} role="listitem">
<ConversationItem
conversation={conversation}
isActive={conversation.id === activeId}
onSelect={onSelect}
actions={actions}
/>
</div>
))}
</div>
{isFetchingNextPage && (
<div className="flex flex-col gap-1 p-2">
<Skeleton className="h-12 mx-3 my-1" />
<Skeleton className="h-12 mx-3 my-1" />
</div>
)}
<div ref={sentinelRef} className="h-1" aria-hidden="true" />
</ScrollArea>
)}
</nav>
);
}

View file

@ -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<HTMLDivElement>(null);
const allMessages = data?.pages.flatMap((page) => page.items) ?? [];
useEffect(() => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [allMessages.length]);
if (isLoading) {
return (
<div className="p-4">
<Skeleton className="h-16 w-full" />
</div>
);
}
if (allMessages.length === 0) {
return (
<div className="flex flex-1 items-center justify-center p-4 text-center">
<p className="text-sm text-muted-foreground">
Send a message to start the conversation.
</p>
</div>
);
}
return (
<div
role="log"
aria-live="polite"
aria-label="Conversation messages"
className="p-4 gap-4 flex flex-col overflow-y-auto flex-1"
>
{allMessages.map((msg) => (
<div
key={msg.id}
className={cn(
"group flex flex-col gap-1",
msg.role === "user" ? "items-end" : "items-start",
)}
>
<div
className={cn(
"px-4 py-2 rounded-md text-sm",
msg.role === "user"
? "ml-auto bg-secondary text-secondary-foreground max-w-[75%]"
: "max-w-[85%]",
)}
>
{msg.role === "user" ? (
<span>{msg.content}</span>
) : (
<ChatMarkdownMessage content={msg.content} />
)}
</div>
<span className="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
{new Date(msg.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
))}
<div ref={bottomRef} aria-hidden="true" />
</div>
);
}

View file

@ -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<HTMLTextAreaElement>('[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 (
<aside
role="complementary"
aria-label="Chat"
className="flex-shrink-0 border-l border-border overflow-hidden transition-[width] duration-100 ease-out flex flex-col"
style={{ width: chatOpen ? 380 : 0 }}
>
{chatOpen && selectedCompanyId && (
<div className="flex flex-1 min-h-0 overflow-hidden" style={{ width: 380 }}>
{/* Conversation list sidebar */}
<div className="flex flex-col shrink-0 bg-sidebar border-r border-border overflow-hidden" style={{ width: 240 }}>
<ChatConversationList
companyId={selectedCompanyId}
activeId={activeConversationId}
onSelect={setActiveConversationId}
onNew={handleNew}
onClose={handleClose}
/>
</div>
{/* Message area */}
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
{activeConversationId ? (
<ChatMessageList conversationId={activeConversationId} />
) : (
<div className="flex flex-1 items-center justify-center p-4 text-center">
<p className="text-sm text-muted-foreground">
Select a conversation or start a new one.
</p>
</div>
)}
<ChatInput
onSend={handleSend}
onClose={handleClose}
isSubmitting={sendMessage.isPending || createConversation.isPending}
/>
</div>
</div>
)}
</aside>
);
}

View file

@ -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 ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleChat}
aria-label={chatOpen ? "Close chat" : "Open chat"}
>
<MessageSquare className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{chatOpen ? "Close chat" : "Open chat"}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
@ -432,6 +457,7 @@ export function Layout() {
)}
</main>
<PropertiesPanel />
<ChatPanel />
</div>
</div>
</div>