"use client"; import { cn } from "@/lib/utils"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { type TamboThread, useTamboThread, useTamboThreadList, } from "@tambo-ai/react"; import { ArrowLeftToLine, ArrowRightToLine, MoreHorizontal, Pencil, PlusIcon, SearchIcon, Sparkles, } from "lucide-react"; import React, { useMemo } from "react"; /** * Context for sharing thread history state and functions */ interface ThreadHistoryContextValue { threads: { items?: TamboThread[] } | null | undefined; isLoading: boolean; error: Error | null; refetch: () => Promise; currentThread: TamboThread; switchCurrentThread: (threadId: string) => void; startNewThread: () => void; searchQuery: string; setSearchQuery: React.Dispatch>; isCollapsed: boolean; setIsCollapsed: React.Dispatch>; onThreadChange?: () => void; contextKey?: string; position?: "left" | "right"; updateThreadName: (newName: string, threadId?: string) => Promise; generateThreadName: (threadId: string) => Promise; } const ThreadHistoryContext = React.createContext(null); const useThreadHistoryContext = () => { const context = React.useContext(ThreadHistoryContext); if (!context) { throw new Error( "ThreadHistory components must be used within ThreadHistory", ); } return context; }; /** * Root component that provides context for thread history */ interface ThreadHistoryProps extends React.HTMLAttributes { contextKey?: string; onThreadChange?: () => void; children?: React.ReactNode; defaultCollapsed?: boolean; position?: "left" | "right"; } const ThreadHistory = React.forwardRef( ( { className, contextKey, onThreadChange, defaultCollapsed = true, position = "left", children, ...props }, ref, ) => { const [searchQuery, setSearchQuery] = React.useState(""); const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed); const [shouldFocusSearch, setShouldFocusSearch] = React.useState(false); const { data: threads, isLoading, error, refetch, } = useTamboThreadList({ contextKey }); const { switchCurrentThread, startNewThread, thread: currentThread, updateThreadName, generateThreadName, } = useTamboThread(); // Update CSS variable when sidebar collapses/expands React.useEffect(() => { const sidebarWidth = isCollapsed ? "3rem" : "16rem"; document.documentElement.style.setProperty( "--sidebar-width", sidebarWidth, ); }, [isCollapsed]); // Focus search input when expanded from collapsed state React.useEffect(() => { if (!isCollapsed && shouldFocusSearch) { setShouldFocusSearch(false); } }, [isCollapsed, shouldFocusSearch]); const contextValue = React.useMemo( () => ({ threads, isLoading, error, refetch, currentThread, switchCurrentThread, startNewThread, searchQuery, setSearchQuery, isCollapsed, setIsCollapsed, onThreadChange, contextKey, position, updateThreadName, generateThreadName, }), [ threads, isLoading, error, refetch, currentThread, switchCurrentThread, startNewThread, searchQuery, isCollapsed, onThreadChange, contextKey, position, updateThreadName, generateThreadName, ], ); return (
{children}
); }, ); ThreadHistory.displayName = "ThreadHistory"; /** * Header component with title and collapse toggle */ const ThreadHistoryHeader = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => { const { isCollapsed, setIsCollapsed, position = "left", } = useThreadHistoryContext(); return (
{!isCollapsed && (

Tambo Conversations

)}
); }); ThreadHistoryHeader.displayName = "ThreadHistory.Header"; /** * Button to create a new thread */ const ThreadHistoryNewButton = React.forwardRef< HTMLButtonElement, React.ButtonHTMLAttributes >(({ ...props }, ref) => { const { isCollapsed, startNewThread, refetch, onThreadChange } = useThreadHistoryContext(); const handleNewThread = React.useCallback( async (e?: React.MouseEvent) => { if (e) e.stopPropagation(); try { await startNewThread(); await refetch(); onThreadChange?.(); } catch (error) { console.error("Failed to create new thread:", error); } }, [startNewThread, refetch, onThreadChange], ); React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.altKey && event.shiftKey && event.key === "n") { event.preventDefault(); handleNewThread(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [handleNewThread]); return ( ); }); ThreadHistoryNewButton.displayName = "ThreadHistory.NewButton"; /** * Search input for filtering threads */ const ThreadHistorySearch = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => { const { isCollapsed, setIsCollapsed, searchQuery, setSearchQuery } = useThreadHistoryContext(); const searchInputRef = React.useRef(null); const expandOnSearch = () => { if (isCollapsed) { setIsCollapsed(false); setTimeout(() => { searchInputRef.current?.focus(); }, 300); // Wait for animation } }; return (
{isCollapsed ? ( ) : ( <>
setSearchQuery(e.target.value)} /> )}
); }); ThreadHistorySearch.displayName = "ThreadHistory.Search"; /** * List of thread items */ const ThreadHistoryList = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => { const { threads, isLoading, error, isCollapsed, searchQuery, currentThread, switchCurrentThread, onThreadChange, updateThreadName, generateThreadName, refetch, } = useThreadHistoryContext(); const [editingThread, setEditingThread] = React.useState( null, ); const [newName, setNewName] = React.useState(""); const inputRef = React.useRef(null); // Handle click outside name editing input React.useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( editingThread && inputRef.current && !inputRef.current.contains(event.target as Node) ) { setEditingThread(null); } }; document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [editingThread]); // Focus input when entering edit mode React.useEffect(() => { if (editingThread) { const timer = setTimeout(() => { inputRef.current?.focus(); }, 100); return () => clearTimeout(timer); } }, [editingThread]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { setEditingThread(null); } }; // Filter threads based on search query const filteredThreads = useMemo(() => { // While collapsed we do not need the list, avoid extra work. if (isCollapsed) return []; if (!threads?.items) return []; const query = searchQuery.toLowerCase(); return threads.items.filter((thread: TamboThread) => { const nameMatches = thread.name?.toLowerCase().includes(query) ?? false; const idMatches = thread.id.toLowerCase().includes(query); return idMatches ? true : nameMatches; }); }, [isCollapsed, threads, searchQuery]); const handleSwitchThread = async (threadId: string, e?: React.MouseEvent) => { if (e) e.stopPropagation(); try { switchCurrentThread(threadId); onThreadChange?.(); } catch (error) { console.error("Failed to switch thread:", error); } }; const handleRename = (thread: TamboThread) => { setEditingThread(thread); setNewName(thread.name ?? ""); }; const handleGenerateName = async (thread: TamboThread) => { try { await generateThreadName(thread.id); await refetch(); } catch (error) { console.error("Failed to generate name:", error); } }; const handleNameSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!editingThread) return; try { await updateThreadName(newName, editingThread.id); await refetch(); setEditingThread(null); } catch (error) { console.error("Failed to rename thread:", error); } }; // Content to show let content; if (isLoading) { content = (
Loading threads...
); } else if (error) { content = (
Error loading threads
); } else if (filteredThreads.length === 0) { content = (
{searchQuery ? "No matching threads" : "No previous threads"}
); } else { content = (
{filteredThreads.map((thread: TamboThread) => (
await handleSwitchThread(thread.id)} className={cn( "p-2 rounded-md hover:bg-backdrop cursor-pointer group flex items-center justify-between", currentThread?.id === thread.id ? "bg-muted" : "", editingThread?.id === thread.id ? "bg-muted" : "", )} >
{editingThread?.id === thread.id ? (
setNewName(e.target.value)} onKeyDown={handleKeyDown} className="w-full bg-background px-1 text-sm font-medium focus:outline-none rounded-sm" onClick={(e) => e.stopPropagation()} placeholder="Thread name..." />

{new Date(thread.createdAt).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", })}

) : ( <> {thread.name ?? `Thread ${thread.id.substring(0, 8)}`}

{new Date(thread.createdAt).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", })}

)}
))}
); } return (
{content}
); }); ThreadHistoryList.displayName = "ThreadHistory.List"; /** * Dropdown menu component for thread actions */ const ThreadOptionsDropdown = ({ thread, onRename, onGenerateName, }: { thread: TamboThread; onRename: (thread: TamboThread) => void; onGenerateName: (thread: TamboThread) => void; }) => { return ( { e.stopPropagation(); onRename(thread); }} > Rename { e.stopPropagation(); onGenerateName(thread); }} > Generate Name ); }; export { ThreadHistory, ThreadHistoryHeader, ThreadHistoryList, ThreadHistoryNewButton, ThreadHistorySearch, ThreadOptionsDropdown, };