import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { IconDots, IconPencil, IconPinned, IconPinnedOff, IconSearch, IconTrash, } from "@tabler/icons-react"; import React, { useMemo, useRef, useState } from "react"; import { cn } from "../utils.js"; /** A single row in a chat history list. Purely data — no fetching, no * assistant-ui context. Formatting (relative time, "Active"/"Open" labels, * status spinners) is the caller's responsibility; hand in the already * rendered node via `timestamp`. */ export interface ChatHistoryItem { id: string; title: React.ReactNode; /** Plain-text form of `title`, used to seed the inline rename input. * Only needed when `onRename` is supplied. */ titleText?: string; /** Secondary line — e.g. a message preview. */ subtitle?: React.ReactNode; /** Tertiary line — e.g. a scope/folder label. */ detail?: React.ReactNode; /** Pre-formatted trailing value: a relative time, "Active"/"Open", or a * status node (spinner, unread dot). This component does not format * dates or compute status itself. */ timestamp?: React.ReactNode; pinned?: boolean; disabled?: boolean; } export interface ChatHistorySection { id: string; label?: React.ReactNode; items: ChatHistoryItem[]; } export interface ChatHistoryListLabels { options: string | ((item: ChatHistoryItem) => string); renameInput: string | ((item: ChatHistoryItem) => string); rename: string; pin: string; unpin: string; delete: string; } export interface ChatHistoryListProps { /** Flat item list. Ignored when `sections` is provided. */ items?: ChatHistoryItem[]; /** Grouped item list (e.g. "This deck" / "All chats", or pinned/recent). */ sections?: ChatHistorySection[]; /** Id of the currently active/selected item — highlighted in the list. */ activeId?: string | null; onSelect: (id: string) => void; /** Fired on double-click / explicit "open" gesture, distinct from select. */ onOpen?: (id: string) => void; /** Presence enables the pin/unpin row action. */ onTogglePin?: (id: string) => void; /** Presence enables the inline "Rename" row action. */ onRename?: (id: string, nextTitle: string) => void; /** Optional character limit for the inline rename input. */ renameMaxLength?: number; /** Presence enables the "Delete" row action. */ onDelete?: (id: string) => void; /** Escape hatch: fully custom row action menu content, replacing the * built-in rename/pin/delete items. Still gated by the trigger button, * which shows whenever this or any of the on* callbacks above is set. */ renderRowActions?: (item: ChatHistoryItem) => React.ReactNode; /** App-specific actions inserted after pin and before delete. Call * `closeMenu` before starting the action. */ renderAdditionalRowActions?: ( item: ChatHistoryItem, closeMenu: () => void, ) => React.ReactNode; /** Controlled search input. Omit `onSearchChange` to hide the search box * entirely and let the host render its own (results are always supplied * by the caller via `items`/`sections` either way). */ searchValue?: string; onSearchChange?: (value: string) => void; searchPlaceholder?: string; searchInputRef?: React.Ref; /** True while results are being fetched — shows `loadingLabel` instead of * the list. */ loading?: boolean; loadingLabel?: React.ReactNode; /** Shown instead of the list when set (takes priority over `loading`). */ error?: React.ReactNode; emptyLabel?: React.ReactNode; /** Shown instead of `emptyLabel` when `searchValue` is non-empty. */ emptySearchLabel?: React.ReactNode; /** Optional localized labels for row actions and their accessible controls. */ labels?: Partial; /** Rendered after the list, inside the scroll container (e.g. a * "Load older chats" button). */ footer?: React.ReactNode; /** "popover" (default) matches core's compact HistoryPopover list. * "rail" is a denser variant with a solid active background, closer to * a sidebar run rail. */ variant?: "popover" | "rail"; className?: string; listClassName?: string; } const DEFAULT_LABELS: ChatHistoryListLabels = { options: "Chat options", renameInput: "Rename chat", rename: "Rename", pin: "Pin to top", unpin: "Unpin from top", delete: "Delete", }; /** * Presentational chat history list shared by the Core `HistoryPopover` * and Agent-Native Code's run rail * (code-agents-ui). Styling is driven by stable `an-chat-history*` class * names (see `@agent-native/toolkit/chat-history.css`) rather than Tailwind utilities * so the same component renders correctly in a Tailwind host and in * code-agents-ui's plain-CSS host. * * This component owns no data: search filtering, pin/rename persistence, * and time formatting all stay with the caller. It only renders whatever * `items`/`sections` it is given, plus the optional search box and * loading/empty/error states. */ export function ChatHistoryList({ items, sections, activeId = null, onSelect, onOpen, onTogglePin, onRename, renameMaxLength, onDelete, renderRowActions, renderAdditionalRowActions, searchValue, onSearchChange, searchPlaceholder = "Search chats...", searchInputRef, loading = false, loadingLabel = "Searching...", error, emptyLabel = "No chats yet", emptySearchLabel = "No matching chats", labels, footer, variant = "popover", className, listClassName, }: ChatHistoryListProps) { const resolvedLabels = useMemo( () => ({ options: labels?.options ?? DEFAULT_LABELS.options, renameInput: labels?.renameInput ?? DEFAULT_LABELS.renameInput, rename: labels?.rename ?? DEFAULT_LABELS.rename, pin: labels?.pin ?? DEFAULT_LABELS.pin, unpin: labels?.unpin ?? DEFAULT_LABELS.unpin, delete: labels?.delete ?? DEFAULT_LABELS.delete, }), [ labels?.delete, labels?.options, labels?.pin, labels?.rename, labels?.renameInput, labels?.unpin, ], ); const resolvedSections: ChatHistorySection[] = sections ?? (items ? [{ id: "default", items }] : []); const totalCount = resolvedSections.reduce( (sum, section) => sum + section.items.length, 0, ); const hasSearchValue = Boolean(searchValue?.trim()); return (
{onSearchChange && (
onSearchChange(event.target.value)} placeholder={searchPlaceholder} aria-label={searchPlaceholder} className="an-chat-history__search-input" />
)}
{error ? (
{error}
) : loading ? (
{loadingLabel}
) : totalCount === 0 ? (
{hasSearchValue ? emptySearchLabel : emptyLabel}
) : ( resolvedSections.map( (section) => section.items.length > 0 && (
{section.label && (
{section.label}
)} {section.items.map((item) => ( ))}
), ) )} {footer}
); } type ChatHistoryRowProps = { item: ChatHistoryItem; active: boolean; onSelect: (id: string) => void; onOpen?: (id: string) => void; onTogglePin?: (id: string) => void; onRename?: (id: string, nextTitle: string) => void; renameMaxLength?: number; onDelete?: (id: string) => void; renderRowActions?: (item: ChatHistoryItem) => React.ReactNode; renderAdditionalRowActions?: ( item: ChatHistoryItem, closeMenu: () => void, ) => React.ReactNode; labels: ChatHistoryListLabels; }; const ChatHistoryRow = React.memo(function ChatHistoryRow({ item, active, onSelect, onOpen, onTogglePin, onRename, renameMaxLength, onDelete, renderRowActions, renderAdditionalRowActions, labels, }: ChatHistoryRowProps) { const [isRenaming, setIsRenaming] = useState(false); const [draftTitle, setDraftTitle] = useState(""); const [menuOpen, setMenuOpen] = useState(false); const renameInputRef = useRef(null); const hasMenu = Boolean( onTogglePin || onRename || onDelete || renderRowActions || renderAdditionalRowActions, ); function startRename() { setDraftTitle(item.titleText ?? ""); setIsRenaming(true); setMenuOpen(false); window.requestAnimationFrame(() => { renameInputRef.current?.select(); }); } function commitRename() { const trimmed = draftTitle.trim(); setIsRenaming(false); if (trimmed && trimmed !== item.titleText) { onRename?.(item.id, trimmed); } } function handleRenameKeyDown(event: React.KeyboardEvent) { if (event.key === "Enter") { event.preventDefault(); commitRename(); } else if (event.key === "Escape") { event.preventDefault(); setIsRenaming(false); } } const optionsLabel = typeof labels.options === "function" ? labels.options(item) : labels.options; const renameInputLabel = typeof labels.renameInput === "function" ? labels.renameInput(item) : labels.renameInput; return (
{ if (!hasMenu) return; event.preventDefault(); setMenuOpen(true); }} > {isRenaming ? (
setDraftTitle(event.target.value)} onKeyDown={handleRenameKeyDown} onBlur={commitRename} autoFocus aria-label={renameInputLabel} />
) : ( )} {!isRenaming && hasMenu && (
{renderRowActions ? ( renderRowActions(item) ) : ( <> {onRename && ( )} {onTogglePin && ( )} {renderAdditionalRowActions?.(item, () => setMenuOpen(false), )} {onDelete && ( )} )}
)}
); });