/** * Floating jump list for long AI chat sessions (user-turn TOC). */ import { ListTree, X } from 'lucide-react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useStickToBottomContext } from 'use-stick-to-bottom'; import { useI18n } from '../../application/i18n/I18nProvider'; import type { ChatJumpEntry } from '../../domain/chatJumpNav'; import { cn } from '../../lib/utils'; export interface ChatJumpNavProps { entries: ChatJumpEntry[]; activeMessageId: string | null; /** When true, ignore transient isAtBottom flips from streaming resize. */ isStreaming?: boolean; onSelect: (messageId: string) => void; /** Fired after the user leaves the jump target and returns to the bottom. */ onReleasePin?: () => void; } const ChatJumpNav: React.FC = ({ entries, activeMessageId, isStreaming = false, onSelect, onReleasePin, }) => { const { t } = useI18n(); const { stopScroll, isAtBottom } = useStickToBottomContext(); const [open, setOpen] = useState(false); const rootRef = useRef(null); // Only release after the viewport has left the bottom; avoids clearing a pin // on the same tick as select while isAtBottom is still true. const leftBottomWhilePinnedRef = useRef(false); useEffect(() => { if (!open) return; const onPointerDown = (event: PointerEvent) => { const target = event.target as Node | null; if (target && rootRef.current && !rootRef.current.contains(target)) { setOpen(false); } }; document.addEventListener('pointerdown', onPointerDown); return () => document.removeEventListener('pointerdown', onPointerDown); }, [open]); useEffect(() => { if (!activeMessageId) { leftBottomWhilePinnedRef.current = false; return; } if (!isAtBottom) { leftBottomWhilePinnedRef.current = true; return; } if (leftBottomWhilePinnedRef.current) { // Streaming content growth / smooth resize can flip isAtBottom without the // user intending to leave the jump target; keep the pin until streaming ends // or they explicitly scroll to bottom via the scroll button. if (isStreaming) return; leftBottomWhilePinnedRef.current = false; onReleasePin?.(); return; } // Jump target was already in the bottom window, so the viewport never left // isAtBottom. Clear the pin after scrollIntoView has had a chance to run. if (isStreaming) return; const timer = window.setTimeout(() => { onReleasePin?.(); }, 100); return () => window.clearTimeout(timer); }, [activeMessageId, isAtBottom, isStreaming, onReleasePin]); const handleSelect = useCallback((messageId: string) => { stopScroll(); onSelect(messageId); setOpen(false); }, [onSelect, stopScroll]); if (entries.length === 0) return null; return (
{open && (
{entries.map((entry) => { const selected = entry.messageId === activeMessageId; return ( ); })}
)}
); }; export default ChatJumpNav;