/* eslint-disable react-refresh/only-export-components -- exports shared helper with component */ // ============================================ // ChatArea - 聊天消息显示区域 // ============================================ // // Normal column layout keeps DOM order and visual order aligned. // - scrollTop=max is bottom // - new messages append at the visual bottom // - a small auto-scroll effect follows streaming only while already at bottom // - IntersectionObserver on the top sentinel triggers history loading import { useRef, useImperativeHandle, forwardRef, memo, useCallback, useEffect, useMemo, useState, type ReactNode, } from 'react' import { useTranslation } from 'react-i18next' import { animate } from 'motion/mini' import { MessageRenderer } from '../message' import { messageStore } from '../../store' import { useTheme } from '../../hooks/useTheme' import type { Message } from '../../types/message' import { RetryStatusInline, type RetryStatusInlineData } from './RetryStatusInline' import { buildVisibleMessageEntries, getVisibleMessageForkTargetId } from './chatAreaVisibility' import { AT_BOTTOM_THRESHOLD_PX } from '../../constants' import { useChatViewport } from './chatViewport' const MESSAGE_RENDER_ROOT_MARGIN = '150% 0px' const STICKY_RENDER_MESSAGE_COUNT = 8 const ESTIMATED_USER_MESSAGE_HEIGHT = 72 const ESTIMATED_ASSISTANT_MESSAGE_HEIGHT = 160 const MAX_MEASURED_MESSAGE_HEIGHT_CACHE = 2000 const measuredMessageHeightCache = new Map() /** Stable no-op to avoid creating a new closure on every render. */ const NOOP = () => {} function estimateMessageHeight(message: Message): number { if (message.info.role === 'user') { return Math.max(ESTIMATED_USER_MESSAGE_HEIGHT, message.parts.length * 40) } return Math.max(ESTIMATED_ASSISTANT_MESSAGE_HEIGHT, message.parts.length * 80) } function rememberMeasuredMessageHeight(cacheKey: string, height: number) { measuredMessageHeightCache.set(cacheKey, height) if (measuredMessageHeightCache.size <= MAX_MEASURED_MESSAGE_HEIGHT_CACHE) return const oldestKey = measuredMessageHeightCache.keys().next().value if (oldestKey) measuredMessageHeightCache.delete(oldestKey) } export function buildTurnDurationMap(messages: Message[], visibleMessages: Message[]): Map { const map = new Map() const visibleAssistantIds = new Set( visibleMessages.filter(message => message.info.role === 'assistant').map(message => message.info.id), ) let currentUserCreated: number | null = null let currentVisibleAssistantId: string | null = null let currentLastCompleted: number | null = null const commitTurn = () => { if (currentUserCreated == null || currentVisibleAssistantId == null || currentLastCompleted == null) return map.set(currentVisibleAssistantId, currentLastCompleted - currentUserCreated) } for (const message of messages) { if (message.info.role === 'user') { commitTurn() currentUserCreated = message.info.time.created currentVisibleAssistantId = null currentLastCompleted = null continue } if (currentUserCreated == null || message.info.role !== 'assistant') continue if (visibleAssistantIds.has(message.info.id)) { currentVisibleAssistantId = message.info.id } if (message.info.time.completed != null) { currentLastCompleted = message.info.time.completed } } commitTurn() return map } interface ChatAreaProps { messages: Message[] sessionId?: string | null isStreaming?: boolean allowStreamingLayoutAnimation?: boolean loadState?: 'idle' | 'loading' | 'loaded' | 'error' hasMoreHistory?: boolean onLoadMore?: () => void | Promise onUndo?: (userMessageId: string) => void onFork?: (message: Message, forkMessageId?: string) => void | Promise canUndo?: boolean registerMessage?: (id: string, element: HTMLElement | null) => void retryStatus?: RetryStatusInlineData | null bottomPadding?: number onVisibleMessageIdsChange?: (ids: string[]) => void onAtBottomChange?: (atBottom: boolean) => void } export type ChatAreaHandle = { scrollToBottom: (instant?: boolean) => void scrollToBottomIfAtBottom: () => void scrollToLastMessage: () => void scrollToMessageIndex: (index: number) => void scrollToMessageId: (messageId: string) => void } export const ChatArea = memo( forwardRef( ( { messages, sessionId, isStreaming: _isStreaming = false, allowStreamingLayoutAnimation = true, loadState = 'idle', onLoadMore, onUndo, onFork, canUndo, hasMoreHistory: _hasMoreHistory = false, registerMessage, retryStatus = null, bottomPadding = 0, onVisibleMessageIdsChange, onAtBottomChange, }, ref, ) => { // ---- Refs ---- const { t } = useTranslation('chat') const scrollRef = useRef(null) const [scrollRoot, setScrollRoot] = useState(null) const topSentinelRef = useRef(null) const isAtBottomRef = useRef(true) const loadMoreRef = useRef(onLoadMore) useEffect(() => { loadMoreRef.current = onLoadMore }, [onLoadMore]) const isLoadingRef = useRef(false) const [isLoadingMore, setIsLoadingMore] = useState(false) // Guard: 防止 session 初始加载时 sentinel 在视口内立即触发 loadMore。 // 只有用户主动滚离底部后解除。 const loadMoreBlockedRef = useRef(true) const { isWideMode } = useTheme() const { presentation } = useChatViewport() const atBottomThreshold = presentation.isCompact ? 150 : AT_BOTTOM_THRESHOLD_PX const messagePaddingClass = presentation.isCompact ? 'px-3' : 'px-5' // ---- Data ---- const visibleMessageEntries = useMemo(() => buildVisibleMessageEntries(messages), [messages]) const visibleMessages = useMemo(() => visibleMessageEntries.map(e => e.message), [visibleMessageEntries]) const forkTargetIdMap = useMemo( () => new Map(visibleMessageEntries.map(entry => [entry.message.info.id, getVisibleMessageForkTargetId(entry)])), [visibleMessageEntries], ) const turnDurationMap = useMemo( () => buildTurnDurationMap(messages, visibleMessages), [messages, visibleMessages], ) const messageMaxWidthClass = isWideMode ? 'max-w-[95%] xl:max-w-6xl' : 'max-w-2xl' const heightCacheScope = `${presentation.surfaceVariant}:${isWideMode ? 'wide' : 'normal'}` const stickyRenderIds = useMemo( () => new Set(visibleMessages.slice(-STICKY_RENDER_MESSAGE_COUNT).map(message => message.info.id)), [visibleMessages], ) const setScrollContainerRef = useCallback((node: HTMLDivElement | null) => { scrollRef.current = node setScrollRoot(prev => (prev === node ? prev : node)) }, []) // ============================================ // Scroll: isAtBottom tracking // ============================================ useEffect(() => { const el = scrollRef.current if (!el) return const onScroll = () => { const hasOverflow = el.scrollHeight > el.clientHeight + 1 const distFromBottom = Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop) const atBottom = !hasOverflow || distFromBottom <= atBottomThreshold const prev = isAtBottomRef.current isAtBottomRef.current = atBottom if (prev !== atBottom) onAtBottomChange?.(atBottom) // 用户滚离底部 → 解除 loadMore guard if (!atBottom) loadMoreBlockedRef.current = false } el.addEventListener('scroll', onScroll, { passive: true }) return () => el.removeEventListener('scroll', onScroll) }, [atBottomThreshold, onAtBottomChange]) // Follow new content while the user is already at the bottom. useEffect(() => { requestAnimationFrame(() => { const el = scrollRef.current if (!el || !isAtBottomRef.current) return el.scrollTop = el.scrollHeight }) }, [visibleMessages, retryStatus, bottomPadding]) // ============================================ // Session switch: snap to bottom // ============================================ const prevSessionIdRef = useRef(sessionId) useEffect(() => { if (sessionId === prevSessionIdRef.current) return prevSessionIdRef.current = sessionId isAtBottomRef.current = true loadMoreBlockedRef.current = true // 重置 guard onAtBottomChange?.(true) requestAnimationFrame(() => { const el = scrollRef.current if (!el) return el.scrollTop = el.scrollHeight // 消息列表整体淡入 — 一次命令式 animate,零 React 开销 animate(el, { opacity: [0, 1] }, { duration: 0.2, ease: 'easeOut' }) }) }, [sessionId, onAtBottomChange]) // 加载完成后 snap to bottom useEffect(() => { if (loadState !== 'loaded') return requestAnimationFrame(() => { const el = scrollRef.current if (el && isAtBottomRef.current) el.scrollTop = el.scrollHeight }) }, [loadState]) // ============================================ // Load more: IntersectionObserver on top sentinel // ============================================ // Normal column layout requires scroll compensation after prepending history. useEffect(() => { const sentinel = topSentinelRef.current const root = scrollRef.current if (!sentinel || !root) return const observer = new IntersectionObserver( ([entry]) => { if (!entry.isIntersecting || isLoadingRef.current) return if (loadMoreBlockedRef.current) return const fn = loadMoreRef.current if (!fn) return const sid = sessionId if (!sid) return const hasMore = messageStore.getSessionState(sid)?.hasMoreHistory ?? false if (!hasMore) return isLoadingRef.current = true setIsLoadingMore(true) const prevScrollHeight = root.scrollHeight Promise.resolve(fn()).finally(() => { requestAnimationFrame(() => { root.scrollTop += root.scrollHeight - prevScrollHeight }) isLoadingRef.current = false setIsLoadingMore(false) }) }, { root, rootMargin: '200px 0px 0px 0px' }, ) observer.observe(sentinel) return () => observer.disconnect() }, [sessionId, visibleMessages]) // History prepend is compensated in the load-more callback above. // ============================================ // Visible message tracking (for outline) // ============================================ const onVisibleIdsChangeRef = useRef(onVisibleMessageIdsChange) useEffect(() => { onVisibleIdsChangeRef.current = onVisibleMessageIdsChange }, [onVisibleMessageIdsChange]) useEffect(() => { const root = scrollRef.current if (!root) return const visibleIds = new Set() const observer = new IntersectionObserver( entries => { let changed = false for (const entry of entries) { const id = entry.target.getAttribute('data-message-id') if (!id) continue if (entry.isIntersecting) { if (!visibleIds.has(id)) { visibleIds.add(id) changed = true } } else if (visibleIds.has(id)) { visibleIds.delete(id) changed = true } } if (changed) { onVisibleIdsChangeRef.current?.(Array.from(visibleIds)) } }, { root, rootMargin: '100% 0px' }, ) // Observe all current message elements const elements = root.querySelectorAll('[data-message-id]') elements.forEach(el => observer.observe(el)) return () => observer.disconnect() }, [visibleMessages]) // ============================================ // Imperative Handle // ============================================ useImperativeHandle( ref, () => ({ scrollToBottom: (instant = false) => { const el = scrollRef.current if (!el) return el.scrollTo({ top: 0, behavior: instant ? 'auto' : 'smooth' }) }, scrollToBottomIfAtBottom: () => { const el = scrollRef.current if (!el) return // 自动跟随使用严格贴底判定,避免用户刚开始向上滚时还在宽松阈值内被抢回去。 const distFromBottom = Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop) if (distFromBottom > 2) return el.scrollTop = el.scrollHeight }, scrollToLastMessage: () => { if (visibleMessages.length === 0) return const lastId = visibleMessages[visibleMessages.length - 1].info.id scrollRef.current ?.querySelector(`[data-message-id="${lastId}"]`) ?.scrollIntoView({ block: 'end', behavior: 'auto' }) }, scrollToMessageIndex: (index: number) => { const msg = visibleMessages[index] if (!msg) return scrollRef.current ?.querySelector(`[data-message-id="${msg.info.id}"]`) ?.scrollIntoView({ block: 'start', behavior: 'smooth' }) }, scrollToMessageId: (messageId: string) => { scrollRef.current ?.querySelector(`[data-message-id="${messageId}"]`) ?.scrollIntoView({ block: 'start', behavior: 'smooth' }) }, }), [visibleMessages], ) // ============================================ // Render // ============================================ // 将连续助手消息分组,共享容器渲染(浑然一体) const messageGroups = useMemo(() => { const groups: Message[][] = [] for (const msg of visibleMessages) { const prev = groups[groups.length - 1] if (prev && msg.info.role === 'assistant' && prev[0].info.role === 'assistant') { prev.push(msg) } else { groups.push([msg]) } } return groups }, [visibleMessages]) const renderMessageGroup = useCallback( (messages: Message[]) => { const isUser = messages[0].info.role === 'user' return (
{messages.map(msg => ( ))}
) }, [ scrollRoot, heightCacheScope, stickyRenderIds, registerMessage, onUndo, onFork, canUndo, messageMaxWidthClass, messagePaddingClass, turnDurationMap, forkTargetIdMap, allowStreamingLayoutAnimation, ], ) return (
{/* Session loading spinner — 延迟 150ms 显示,快速加载时不闪烁 */} {loadState === 'loading' && visibleMessages.length === 0 && (
{t('chatArea.loadingSession')}
)}
{/* Top sentinel for loadMore (视觉最顶部) */}