import React, { useEffect, useRef, useState } from 'react'; import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; import { Conversation, useRenameConversation } from '../../contexts/ChatContext'; import useRenderTracker from '../../hooks/useRenderTracker'; import { cn } from '../../utils/classNames'; interface ConversationGroupProps { category: string; conversations: Conversation[]; currentConversationId: string | null; onSelectConversation: (id: string) => void; onDeleteConversation: (conversationName: string, conversationId: string) => void; className?: string; } const AnimatedConversationTitle = ({ title }: { title: string }) => { const [displayTitle, setDisplayTitle] = useState(title); const previousTitleRef = useRef(title); useEffect(() => { if (previousTitleRef.current === title) return; previousTitleRef.current = title; if (!title) { setDisplayTitle(''); return; } let index = 0; setDisplayTitle(''); const timerId = window.setInterval(() => { index += 1; setDisplayTitle(title.slice(0, index)); if (index >= title.length) { window.clearInterval(timerId); } }, 24); return () => window.clearInterval(timerId); }, [title]); return <>{displayTitle}; }; const ConversationGroup: React.FC = ({ category, conversations, currentConversationId, onSelectConversation, onDeleteConversation, className, }) => { const renameConversation = useRenameConversation(); const [menuId, setMenuId] = useState(null); const [editingId, setEditingId] = useState(null); const [draft, setDraft] = useState(''); const menuRef = useRef(null); // Close the options menu on any click outside it, or on Escape. (A `fixed` // overlay can't be relied on here — an ancestor transform would scope it to // the sidebar instead of the viewport.) useEffect(() => { if (!menuId) return; const handlePointerDown = (event: PointerEvent) => { const menu = menuRef.current; if (!menu) return; // The app mounts in a Shadow DOM, so a document-level event's `target` is // retargeted to the shadow host. Use composedPath() to see the real path // through the shadow boundary and detect clicks inside the menu. const path = event.composedPath(); if (path.length ? !path.includes(menu) : !menu.contains(event.target as Node)) { setMenuId(null); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setMenuId(null); }; document.addEventListener('pointerdown', handlePointerDown); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('pointerdown', handlePointerDown); document.removeEventListener('keydown', handleKeyDown); }; }, [menuId]); useRenderTracker('ConversationGroup', { category, conversations, currentConversationId, onSelectConversation, onDeleteConversation, }); const startRename = (conv: Conversation) => { setMenuId(null); setDraft(conv.title || 'New Chat'); setEditingId(conv.conversation_id); }; const commitRename = (conv: Conversation) => { const trimmed = draft.trim().replace(/\s+/g, ' '); // Only send the rename if the name actually changed — clicking off an // untouched field shouldn't fire a request. Baseline matches what // startRename seeds, normalized the same way. const original = (conv.title || 'New Chat').trim().replace(/\s+/g, ' '); if (trimmed && trimmed !== original) void renameConversation(conv.conversation_id, trimmed); setEditingId(null); }; return (
{category}
{conversations .sort((a, b) => b.updated_at - a.updated_at) .map((conv) => { const isActive = conv.conversation_id === currentConversationId; const title = conv.title || 'New Chat'; if (editingId === conv.conversation_id) { return ( setDraft(e.target.value)} onBlur={() => commitRename(conv)} onKeyDown={(e) => { if (e.key === 'Enter') commitRename(conv); else if (e.key === 'Escape') setEditingId(null); }} aria-label="Rename conversation" className="block w-full rounded-md border border-brand bg-app-surface px-2 py-2 text-sm text-app-text outline-none ring-2 ring-brand/25" /> ); } const menuOpen = menuId === conv.conversation_id; return (
{menuOpen && (
)}
); })}
); }; export default React.memo(ConversationGroup);