nexus/ui/src/components/ChatConversationList.tsx
Nexus Dev 0406362e04 feat(21-06): add conversation search input and Cmd+K shortcut
- Add Search/X icons and Input to ChatConversationList with 300ms debounce
- Listen for nexus:focus-chat-search event to focus search input
- Add onSearch handler to useKeyboardShortcuts (fires before input guard)
- Wire Layout Cmd+K to open chat panel and dispatch focus event
2026-04-02 15:08:50 +00:00

215 lines
7 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Plus, Search, X } 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 { Input } from "@/components/ui/input";
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 [searchTerm, setSearchTerm] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(searchTerm), 300);
return () => clearTimeout(timer);
}, [searchTerm]);
// Listen for focus-chat-search custom event (dispatched by Cmd+K in Layout)
useEffect(() => {
const handler = () => searchInputRef.current?.focus();
window.addEventListener("nexus:focus-chat-search", handler);
return () => window.removeEventListener("nexus:focus-chat-search", handler);
}, []);
const { data, isLoading, hasNextPage, fetchNextPage, createMutation, updateMutation, deleteMutation } =
useChatConversations(companyId, { search: debouncedSearch || undefined });
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>
{/* Search input */}
<div className="px-2 pb-1 pt-1">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search conversations..."
className="h-7 pl-7 pr-7 text-xs"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</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>
);
}