'use client' import * as React from 'react' import { cn } from '../../utils/cn' import { ActionsMenuDropdown, type ActionsMenuItem } from '../ui/actions-menu' import { Button } from '../ui/button' import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade' import { SquareAvatar } from '../ui/square-avatar' import { Ellipsis01Icon, SearchXmarkIcon } from '../icons-v2-generated' import { ChatListEmptyState } from './chat-list-empty-state' import type { DialogItem } from './types/component.types' // ============================================================================= // Types // ============================================================================= export interface MingoChatHistoryProps { /** Dialogs to list, assumed already sorted newest-first by the host. */ dialogs: ReadonlyArray /** Currently-open dialog id (highlighted). */ activeDialogId?: string /** Open a dialog. */ onSelectDialog?: (id: string) => void /** Request rename — enables the row "Rename chat" action. The host opens * the Rename modal; the list does no inline editing. */ onRequestRename?: (dialog: DialogItem) => void /** Request archive — enables the row "Archive chat" action. The host opens * the Archive confirmation modal. */ onRequestArchive?: (dialog: DialogItem) => void /** Request a shareable link — enables the row "Copy chat link" action. The host * owns the URL shape and the copy itself (the list knows neither the app's * routes nor whether a clipboard is available). */ onRequestCopyLink?: (dialog: DialogItem) => void /** Current server-side search term. Drives the "No chats found" empty state; * the search INPUT lives in the panel header, not in this list. The host * owns the term and refetches server-side — the list does no filtering. */ searchQuery?: string /** Whether more dialogs remain (cursor pagination). */ hasMore?: boolean /** True while the next page is loading. */ isLoadingMore?: boolean /** Fetch the next page — fired when the bottom sentinel scrolls into view. */ onLoadMore?: () => void /** Appended to the root element. */ className?: string } interface DialogGroup { key: string label: string items: DialogItem[] } // ============================================================================= // Date grouping — Today / Yesterday / Older // ============================================================================= function dialogTime(d: DialogItem): number | null { if (!d.timestamp) return null const t = typeof d.timestamp === 'string' ? Date.parse(d.timestamp) : d.timestamp.getTime() return Number.isNaN(t) ? null : t } function groupDialogs(dialogs: ReadonlyArray): DialogGroup[] { const now = new Date() const startOfToday = new Date( now.getFullYear(), now.getMonth(), now.getDate(), ).getTime() const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000 const today: DialogItem[] = [] const yesterday: DialogItem[] = [] const older: DialogItem[] = [] for (const d of dialogs) { const t = dialogTime(d) if (t !== null && t >= startOfToday) today.push(d) else if (t !== null && t >= startOfYesterday) yesterday.push(d) else older.push(d) // includes timestamp-less dialogs } return [ { key: 'today', label: 'Today', items: today }, { key: 'yesterday', label: 'Yesterday', items: yesterday }, { key: 'older', label: 'Older', items: older }, ].filter((g) => g.items.length > 0) } // ============================================================================= // Row // ============================================================================= interface RowProps { dialog: DialogItem isActive: boolean onSelect?: (id: string) => void onRequestRename?: (dialog: DialogItem) => void onRequestCopyLink?: (dialog: DialogItem) => void onRequestArchive?: (dialog: DialogItem) => void } function MingoChatHistoryRow({ dialog, isActive, onSelect, onRequestCopyLink, onRequestRename, onRequestArchive, }: RowProps) { const title = dialog.title || 'Untitled Chat' const unread = dialog.unreadMessagesCount ?? 0 const hasMenu = !!onRequestRename || !!onRequestArchive || !!onRequestCopyLink const owner = dialog.owner const hasAvatar = !!(owner?.name || owner?.avatarUrl) // Keep the `⋯` visible while its menu is open — once Radix moves focus into // the portalled content the row loses hover/focus-within. const [menuOpen, setMenuOpen] = React.useState(false) const menuItems = [ onRequestCopyLink && { id: 'copy-link', label: 'Copy chat link', onClick: () => onRequestCopyLink(dialog), }, onRequestRename && { id: 'rename', label: 'Rename chat', onClick: () => onRequestRename(dialog), }, onRequestArchive && { id: 'archive', label: 'Archive chat', onClick: () => onRequestArchive(dialog), }, ].filter(Boolean) as ActionsMenuItem[] // The item is the full-width row (inside the group's bordered box). Padding // lives on the inner content button (badge + title) only — the `⋯` is a // SIBLING of that padded content, so it sits at the item's right edge, inside // the item but outside its padded content area. return (
{isActive ? ( ) : null}
onSelect?.(dialog.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onSelect?.(dialog.id) } }} className="flex min-w-0 flex-1 items-center gap-[var(--spacing-system-xsf)] p-[var(--spacing-system-s)] cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-accent" > {unread > 0 ? ( {unread > 99 ? '99+' : unread} ) : null} {title}
{hasMenu || hasAvatar ? ( // Trailing 48px cell (Figma 113:63224): the owner avatar sits centred // by default; hovering the row (or opening the menu) swaps it for the // `⋯` actions button IN PLACE, so the title never reflows. Without an // avatar the cell exists only for the hover menu, so it collapses on // mobile exactly like the old hover-only gutter (no hover on touch). {hasMenu ? ( // Revealed on row hover/its own focus; kept visible while the menu // is open. Hidden on mobile — it's a hover affordance. `peer/menu` // so the avatar layer (a later sibling) can fade when the trigger // holds keyboard focus. e.stopPropagation()} className={cn( 'peer/menu absolute inset-0 transition-opacity max-md:hidden', menuOpen ? 'opacity-100' : 'opacity-0 group-hover/row:opacity-100 focus-within:opacity-100', )} > e.preventDefault()} customTrigger={ } /> ) : null} {hasAvatar ? ( // Avatar layer — clicks fall through to row selection (it covers // the cell whenever the menu trigger is faded out). On desktop it // fades for the menu swap; on mobile it's always shown. onSelect?.(dialog.id)} title={owner?.name || undefined} className={cn( 'absolute inset-0 flex cursor-pointer items-center justify-center transition-opacity', hasMenu && (menuOpen ? 'opacity-0 pointer-events-none' : 'md:group-hover/row:opacity-0 md:group-hover/row:pointer-events-none md:peer-[:focus-within]/menu:opacity-0 md:peer-[:focus-within]/menu:pointer-events-none'), )} > ) : null} ) : null}
) } // ============================================================================= // Skeleton // ============================================================================= /** * Loading placeholder for the dialog history, shaped like the grouped row list * above. Shown while the FIRST page of dialogs is in flight so the Mingo empty * state doesn't flash the new-user greeting+grid before the list arrives (the * two layouts are mutually exclusive — swapping between them mid-load reads as * a flicker). Pure ODS tokens, `animate-pulse`. */ export function MingoChatHistorySkeleton({ className, }: { className?: string }) { // Mirror the real grouped list exactly: a small group label, then a bordered // rounded container of `h-12` rows, each with a single title bar of varying // width (no unread-badge squares — the real rows currently never show one, so // a badge placeholder would mis-promise the layout). `bg-ods-border` matches // the kit's canonical `Skeleton` placeholder colour; `bg-ods-bg` rows read a // touch darker than the `bg-ods-card` panel so the bars stay legible. const groups: ReadonlyArray> = [ ['w-3/5', 'w-4/5', 'w-2/5', 'w-3/4'], ['w-1/2', 'w-2/3'], ] return (
{groups.map((widths, g) => (
{/* Group label (Today / Older). */}
{widths.map((w, i) => (
))}
))}
) } // ============================================================================= // Component // ============================================================================= /** * MingoChatHistory — Figma node `7532:223950`. * * The returning-user variation of the Mingo empty state: the dialog history * rendered inline in the main panel, grouped into Today / Yesterday / Older * sections. Each row shows an optional unread badge, the title, and a `⋯` * menu (Rename / Archive — each gated on the matching handler and surfaced as * a request the host fulfils via a modal). Owns its own scroll + bottom/top * fade and an infinite-scroll * sentinel that fires `onLoadMore`. */ export function MingoChatHistory({ dialogs, activeDialogId, onSelectDialog, onRequestCopyLink, onRequestRename, onRequestArchive, searchQuery, hasMore = false, isLoadingMore = false, onLoadMore, className, }: MingoChatHistoryProps) { const groups = React.useMemo(() => groupDialogs(dialogs), [dialogs]) const noSearchResults = groups.length === 0 && !!searchQuery?.trim() // Scroll-fade affordances — shared ui/scroll-fade (re-measures on resize // and content mutations, so no manual `dialogs` dependency is needed). const { scrollRef, fadeTop, fadeBottom, update: updateFade } = useScrollFade() // Infinite scroll — load the next page when the sentinel enters the // scroll viewport. const sentinelRef = React.useRef(null) const onLoadMoreRef = React.useRef(onLoadMore) onLoadMoreRef.current = onLoadMore const isLoadingMoreRef = React.useRef(isLoadingMore) isLoadingMoreRef.current = isLoadingMore const dialogCount = dialogs.length React.useEffect(() => { const root = scrollRef.current const sentinel = sentinelRef.current if (!root || !sentinel || !hasMore || typeof IntersectionObserver === 'undefined') return const io = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && !isLoadingMoreRef.current) { onLoadMoreRef.current?.() } }, { root, rootMargin: '120px', threshold: 0.1 }, ) io.observe(sentinel) return () => io.disconnect() // `dialogCount`/`isLoadingMore` deps re-arm the observer after every page // lands: `observe()` re-reports the CURRENT intersection, so a sentinel // that stays in view (short host-filtered list — e.g. the "My Chats" // scope keeps a handful of rows out of a 20-row page) keeps chaining // loads until the viewport fills or `hasMore` flips. A continuously // visible sentinel never re-crosses the threshold, so the previous // [hasMore]-only observer fired exactly once per mount and the list // grew by a single page per drawer open. }, [hasMore, isLoadingMore, dialogCount]) return (
{/* Scroll region — the search INPUT now lives in the panel header (Figma 116:51217), so the list is just the grouped rows + fades. */}
{noSearchResults ? ( // No search matches — same centred glyph + title + caption layout as // the other chat-list empty states (Figma 113:60939). } title="No Chats Found" description="Try a different search term" /> ) : ( groups.map((group) => (

{group.label}

{group.items.map((dialog) => ( ))}
)) )} {hasMore ?
: null}
{/* Scroll-fade — only while content is hidden in that direction. */}
) }