import React, { useMemo, useState, useRef, useEffect, createContext, useContext, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { Avatar, Typography, theme } from 'antd'; import { ChevronDown, User } from 'lucide-react'; import { ModelIcon } from '@lobehub/icons'; import { useConversationStore, useProviderStore, useSettingsStore } from '@/stores'; import { useUserProfileStore } from '@/stores/userProfileStore'; import { useResolvedAvatarSrc } from '@/hooks/useResolvedAvatarSrc'; import { stripBoosapiTags } from '@/lib/chatMarkdown'; import type { Message } from '@/types'; // ── Scroll context — provided by ChatView ── type ScrollToFn = (messageId: string) => void; interface MinimapContextValue { scrollTo: ScrollToFn; scrollBoxRef: React.RefObject; /** Set by programmatic scroll — suppresses detection updates */ scrollLockRef: React.MutableRefObject; /** Forced active ID set by click — overrides detection during lock */ forcedActiveRef: React.MutableRefObject; } const MinimapScrollContext = createContext(null); export function MinimapScrollProvider({ children, scrollTo, scrollBoxRef, }: { children: React.ReactNode; scrollTo: ScrollToFn; scrollBoxRef: React.RefObject; }) { const scrollLockRef = useRef(0); const forcedActiveRef = useRef(null); const wrappedScrollTo = useCallback((messageId) => { // Lock detection for 800ms after programmatic scroll scrollLockRef.current = Date.now() + 800; forcedActiveRef.current = messageId; scrollTo(messageId); }, [scrollTo]); const value = useMemo(() => ({ scrollTo: wrappedScrollTo, scrollBoxRef, scrollLockRef, forcedActiveRef }), [wrappedScrollTo, scrollBoxRef]); return {children}; } function useMinimapContext(): MinimapContextValue { const ctx = useContext(MinimapScrollContext); return ctx ?? { scrollTo: () => {}, scrollBoxRef: { current: null }, scrollLockRef: { current: 0 }, forcedActiveRef: { current: null } }; } // ── Types ── interface MinimapEntry { index: number; msg: Message; role: 'user' | 'assistant'; summary: string; modelId?: string | null; providerId?: string | null; } // ── Helpers ── function summarize(content: string, maxLen: number, role: 'user' | 'assistant'): string { const stripped = stripBoosapiTags(content, { stripThink: role === 'assistant' }) .replace(/```[\s\S]*?```/g, '[code]') .replace(/\n+/g, ' ') .trim(); return stripped.length > maxLen ? stripped.slice(0, maxLen) + '…' : stripped; } function useEntries(): MinimapEntry[] { const messages = useConversationStore((s) => s.messages); const hasOlderMessages = useConversationStore((s) => s.hasOlderMessages); const loadingOlder = useConversationStore((s) => s.loadingOlder); // Eagerly load all older messages so the minimap shows the full conversation useEffect(() => { if (hasOlderMessages && !loadingOlder) { useConversationStore.getState().loadOlderMessages(); } }, [hasOlderMessages, loadingOlder, messages]); return useMemo(() => { const active = messages.filter((m) => m.is_active !== false); const entries: MinimapEntry[] = []; // Track assistant dedup: parentKey → index in entries array // Keep the LAST assistant per parent (matches ChatView's assistantByParentId behavior) const parentToIdx = new Map(); let idx = 0; for (const msg of active) { if (msg.role === 'user') { entries.push({ index: idx++, msg, role: 'user', summary: summarize(msg.content, 30, 'user'), }); } else if (msg.role === 'assistant') { const parentKey = msg.parent_message_id || msg.id; const existing = parentToIdx.get(parentKey); const entry: MinimapEntry = { index: existing !== undefined ? entries[existing].index : idx++, msg, role: 'assistant', summary: summarize(msg.content, 30, 'assistant'), modelId: msg.model_id, providerId: msg.provider_id, }; if (existing !== undefined) { entries[existing] = entry; } else { parentToIdx.set(parentKey, entries.length); entries.push(entry); } } } return entries; }, [messages]); } /** Find the bubble wrapper element for a data-boosapi-msg marker */ function findBubbleEl(marker: Element, scrollBox: HTMLElement): Element { let el: Element = marker; // Walk up until we find an element whose parent is the scroll box or its first child container for (;;) { const parent: Element | null = el.parentElement; if (!parent || parent === scrollBox) return el; if (parent.parentElement === scrollBox) return el; el = parent; } } /** Track which message is currently most visible in the viewport */ function useActiveMessageId(entries: MinimapEntry[]): string | null { const { scrollBoxRef, scrollLockRef, forcedActiveRef } = useMinimapContext(); const [activeId, setActiveId] = useState(null); // Set default to last entry initially useEffect(() => { if (entries.length > 0 && !activeId) { setActiveId(entries[entries.length - 1].msg.id); } }, [entries, activeId]); useEffect(() => { if (entries.length === 0) return; const updateActive = () => { // During programmatic scroll lock, use the forced active ID if (Date.now() < scrollLockRef.current) { if (forcedActiveRef.current) { setActiveId(forcedActiveRef.current); } return; } // Clear forced active once lock expires forcedActiveRef.current = null; const scrollBox = scrollBoxRef.current; if (!scrollBox) return; const boxRect = scrollBox.getBoundingClientRect(); // Collect rects for first and last entries to detect scroll extremes const firstMarker = scrollBox.querySelector(`[data-boosapi-msg="${entries[0].msg.id}"]`); const lastMarker = scrollBox.querySelector(`[data-boosapi-msg="${entries[entries.length - 1].msg.id}"]`); // Edge case: scrolled to top — first entry's top is at or below viewport top if (firstMarker) { const firstEl = findBubbleEl(firstMarker, scrollBox); const firstRect = firstEl.getBoundingClientRect(); if (firstRect.top >= boxRect.top - 5) { setActiveId(entries[0].msg.id); return; } } // Edge case: scrolled to bottom — last entry's bottom is at or above viewport bottom if (lastMarker) { const lastEl = findBubbleEl(lastMarker, scrollBox); const lastRect = lastEl.getBoundingClientRect(); if (lastRect.bottom <= boxRect.bottom + 5) { setActiveId(entries[entries.length - 1].msg.id); return; } } // Normal: find entry whose bubble is closest to detection line (25% from top) const detectY = boxRect.top + boxRect.height * 0.25; let best: { id: string; dist: number } | null = null; for (const entry of entries) { const marker = scrollBox.querySelector(`[data-boosapi-msg="${entry.msg.id}"]`); if (!marker) continue; const el = findBubbleEl(marker, scrollBox); const rect = el.getBoundingClientRect(); if (rect.bottom < boxRect.top || rect.top > boxRect.bottom) continue; const dist = Math.abs(rect.top - detectY); if (!best || dist < best.dist) { best = { id: entry.msg.id, dist }; } } if (best) setActiveId(best.id); }; // Wait for scroll box to be available, then attach listener let scrollBox: HTMLElement | null = null; let retryTimer: ReturnType; const attach = () => { scrollBox = scrollBoxRef.current; if (!scrollBox) { retryTimer = setTimeout(attach, 200); return; } scrollBox.addEventListener('scroll', updateActive, { passive: true }); updateActive(); }; attach(); return () => { clearTimeout(retryTimer); scrollBox?.removeEventListener('scroll', updateActive); }; }, [scrollBoxRef, entries]); return activeId; } function useModelName(modelId?: string | null, providerId?: string | null): string { const providers = useProviderStore((s) => s.providers); return useMemo(() => { if (!modelId) return ''; for (const p of providers) { if (providerId && p.id !== providerId) continue; const model = p.models?.find((m) => m.model_id === modelId); if (model) return model.name || model.model_id; } const parts = modelId.split('/'); return parts[parts.length - 1]; }, [modelId, providerId, providers]); } function ModelName({ modelId, providerId }: { modelId?: string | null; providerId?: string | null }) { const name = useModelName(modelId, providerId); return <>{name}; } /** Renders the user avatar matching the chat bubble style */ function UserAvatarIcon({ size }: { size: number }) { const { token } = theme.useToken(); const profile = useUserProfileStore((s) => s.profile); const resolvedSrc = useResolvedAvatarSrc(profile.avatarType, profile.avatarValue); if (profile.avatarType === 'emoji' && profile.avatarValue) { return (
{profile.avatarValue}
); } if ((profile.avatarType === 'url' || profile.avatarType === 'file') && profile.avatarValue) { const src = profile.avatarType === 'file' ? resolvedSrc : profile.avatarValue; return ; } return } style={{ backgroundColor: token.colorPrimary }} />; } // ── Plan C: FAQ Index ── function FaqIndex({ entries }: { entries: MinimapEntry[] }) { const { token } = theme.useToken(); const activeId = useActiveMessageId(entries); if (entries.length === 0) return null; return (
{/* Scrollable dots column */}
{entries.map((entry) => ( ))}
); } function FaqItem({ entry, isActive, token }: { entry: MinimapEntry; isActive: boolean; token: ReturnType['token']; }) { const [hovered, setHovered] = useState(false); const { scrollTo } = useMinimapContext(); const dotRef = useRef(null); const isUser = entry.role === 'user'; // Calculate fixed position for hover card based on dot's position const getCardStyle = (): React.CSSProperties => { if (!dotRef.current) return { display: 'none' }; const rect = dotRef.current.getBoundingClientRect(); return { position: 'fixed', right: window.innerWidth - rect.left + 6, top: rect.top + rect.height / 2, transform: 'translateY(-50%)', background: token.colorBgElevated, border: `1px solid ${token.colorBorderSecondary}`, borderRadius: token.borderRadius, padding: '4px 8px', minWidth: 140, maxWidth: 220, zIndex: 1000, cursor: 'pointer', boxShadow: token.boxShadowSecondary, pointerEvents: 'auto' as const, }; }; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > {/* Dot */}
scrollTo(entry.msg.id)} style={{ width: 16, height: 16, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 600, cursor: 'pointer', color: isActive ? '#fff' : token.colorTextSecondary, backgroundColor: isActive ? token.colorPrimary : token.colorFillQuaternary, border: `1px solid ${isActive ? token.colorPrimary : token.colorBorderSecondary}`, transition: 'all 0.2s', margin: '0 auto', overflow: 'hidden', ...(hovered && !isActive ? { backgroundColor: token.colorPrimaryBg, borderColor: token.colorPrimary, color: token.colorPrimary, } : {}), }} > {isUser ? ( ) : entry.modelId ? ( ) : ( entry.index + 1 )}
{/* Hover card — fixed position to avoid clipping */} {hovered && createPortal(
scrollTo(entry.msg.id)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={getCardStyle()} >
{!isUser && entry.modelId && } {isUser ? 'Q' : }
{entry.summary}
, document.body, )}
); } // ── Plan D: Sticky Header ── function StickyHeader({ entries }: { entries: MinimapEntry[] }) { const { token } = theme.useToken(); const [expanded, setExpanded] = useState(false); const activeId = useActiveMessageId(entries); const containerRef = useRef(null); if (entries.length === 0) return null; const activeIdx = entries.findIndex((e) => e.msg.id === activeId); const current = entries[Math.max(0, activeIdx)]; return (
setExpanded(true)} onMouseLeave={() => setExpanded(false)} style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, backdropFilter: 'blur(12px)', background: `${token.colorBgContainer}e6`, borderBottom: `1px solid ${token.colorBorderSecondary}`, }} >
setExpanded(!expanded)} > {(activeIdx >= 0 ? activeIdx : 0) + 1} / {entries.length} {current.role === 'assistant' && current.modelId && ( )} {current.role === 'user' && ( )} {current.summary}
{expanded && (
{entries.map((entry) => ( ))}
)}
); } function StickyModelName({ modelId, providerId }: { modelId?: string | null; providerId?: string | null }) { const name = useModelName(modelId, providerId); const { token } = theme.useToken(); return {name}; } function StickyDropdownItem({ entry, isActive, token }: { entry: MinimapEntry; isActive: boolean; token: ReturnType['token']; }) { const { scrollTo } = useMinimapContext(); const isUser = entry.role === 'user'; return (
scrollTo(entry.msg.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 16px', cursor: 'pointer', fontSize: 13, transition: 'background 0.15s', backgroundColor: isActive ? token.colorPrimaryBg : 'transparent', borderLeft: isActive ? `2px solid ${token.colorPrimary}` : '2px solid transparent', }} onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = token.colorFillQuaternary; }} onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = isActive ? token.colorPrimaryBg : 'transparent'; }} > #{entry.index + 1} {isUser ? ( ) : entry.modelId ? ( ) : ( AI )} {entry.summary}
); } // ── Main Component ── export function ChatMinimap() { const enabled = useSettingsStore((s) => s.settings.chat_minimap_enabled); const style = useSettingsStore((s) => s.settings.chat_minimap_style ?? 'faq'); const entries = useEntries(); if (!enabled || entries.length < 2) return null; switch (style) { case 'faq': return ; case 'sticky': return ; default: return null; } }