import React, { useEffect, useRef, useState } from 'react'; import { Check, ChevronDown, MessageSquare, Pencil, Plus, Trash2, X, } from 'lucide-react'; import { THREAD_TITLE_MAX_CHARS } from '../../lib/copilot/thread-turns'; import type { ICopilotThreadSummary } from '../../service/copilot/copilot.interface'; import { cn } from './cn'; /** Label shown when no saved conversation is open. */ const NO_THREAD_LABEL = 'New conversation'; /** * The "select a chat" control: every saved conversation, newest first, plus a * way to start a fresh one. Threads are per merchant and outlive the session, * so a conversation can be picked back up days later, from here or from the * dashboard. * * A title is the merchant's to change: it was only ever the opening question * cut at sixty characters, and a dozen conversations about the same subject * could not be told apart in the list. */ export function ThreadSwitcher({ threads, activeThreadId, onOpen, onNew, onDelete, onRename, }: { threads: ICopilotThreadSummary[]; activeThreadId: string | null; onOpen: (threadId: string) => void; onNew: () => void; onDelete?: (threadId: string) => void; onRename?: (threadId: string, title: string) => void; }): React.ReactElement { const [isOpen, setIsOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [draftTitle, setDraftTitle] = useState(''); const containerRef = useRef(null); const editInputRef = useRef(null); useEffect(() => { if (!isOpen) return undefined; const closeOnOutsideClick = (event: MouseEvent): void => { if (!containerRef.current?.contains(event.target as Node)) { setIsOpen(false); setEditingId(null); } }; document.addEventListener('mousedown', closeOnOutsideClick); return () => document.removeEventListener('mousedown', closeOnOutsideClick); }, [isOpen]); useEffect(() => { if (editingId) editInputRef.current?.select(); }, [editingId]); const activeThread: ICopilotThreadSummary | undefined = threads.find( thread => thread.thread_id === activeThreadId ); const activeLabel: string = activeThread?.title || NO_THREAD_LABEL; const beginRename = (thread: ICopilotThreadSummary): void => { setEditingId(thread.thread_id); setDraftTitle(thread.title || ''); }; const commitRename = (): void => { const title: string = draftTitle.trim(); if (editingId && title && onRename) onRename(editingId, title); setEditingId(null); }; const onRenameKey = (event: React.KeyboardEvent): void => { if (event.key === 'Enter') { event.preventDefault(); commitRename(); } else if (event.key === 'Escape') { event.preventDefault(); setEditingId(null); } }; return ( // min-w-0 here and w-full below are both load-bearing: without the first // the wrapper keeps its content-based minimum, without the second the // button falls back to shrink-to-fit, whose min-content floor no // min-width can lower. Drop either and the title paints over the // header's buttons.
{isOpen && (
{threads.length > 0 && (
{threads.map(thread => (
{editingId === thread.thread_id ? ( <> setDraftTitle(event.target.value)} onKeyDown={onRenameKey} aria-label="Conversation title" className="min-w-0 flex-1 rounded-md border border-gray-300 px-2 py-1 text-sm text-gray-900 outline-none focus:border-[#e85bbd]" /> ) : ( <> {onRename && ( )} {onDelete && ( )} )}
))}
)}
)}
); }