import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react' import { MessageBubble } from './MessageBubble' import type { ChatMessage } from '../../types/protocol' import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react' interface MessageListProps { messages: ChatMessage[] onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void showThinking?: boolean /** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */ viewKey?: string } export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey }: MessageListProps) { const bottomRef = useRef(null) const containerRef = useRef(null) const isAtBottomRef = useRef(true) const prevShowBottomRef = useRef(false) const prevViewKeyRef = useRef(viewKey) const viewKeyRef = useRef(viewKey) viewKeyRef.current = viewKey // Per-view scroll position memory const scrollPositionsRef = useRef>(new Map()) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [newMessageCount, setNewMessageCount] = useState(0) // ---- scroll helpers ---- const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { isAtBottomRef.current = true setShowScrollToBottom(false) setNewMessageCount(0) bottomRef.current?.scrollIntoView({ behavior }) }, []) const scrollToTop = useCallback(() => { containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) }, []) // ---- scroll event: track whether user is at bottom ---- const handleScroll = useCallback(() => { const el = containerRef.current if (!el) return const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight const nearBottom = distanceFromBottom < 120 // Save scroll position for current view const key = viewKeyRef.current if (key) { scrollPositionsRef.current.set(key, el.scrollTop) } isAtBottomRef.current = nearBottom // 回到底部:距底部 > 200px 时显示(同时显示回到顶部) const shouldShowBottom = distanceFromBottom > 200 if (shouldShowBottom !== prevShowBottomRef.current) { prevShowBottomRef.current = shouldShowBottom setShowScrollToBottom(shouldShowBottom) } // 滚回底部时清除新消息计数 if (nearBottom) { setNewMessageCount(0) } }, []) // ---- auto-scroll: handle view switches and message updates ---- useLayoutEffect(() => { const prevKey = prevViewKeyRef.current const viewChanged = prevKey !== viewKey prevViewKeyRef.current = viewKey if (messages.length === 0) { isAtBottomRef.current = true return } if (viewChanged) { // View switched (e.g. breadcrumb navigation): restore saved scroll position const key = viewKey ?? '' const savedPos = scrollPositionsRef.current.get(key) if (savedPos !== undefined && containerRef.current) { containerRef.current.scrollTop = savedPos const el = containerRef.current const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight isAtBottomRef.current = distanceFromBottom < 120 return } // First time viewing this view: scroll to bottom isAtBottomRef.current = true bottomRef.current?.scrollIntoView({ behavior: 'instant' }) return } // Same view, messages changed: normal auto-scroll logic const lastMessage = messages[messages.length - 1] if (lastMessage.role === 'user' || isAtBottomRef.current) { bottomRef.current?.scrollIntoView({ behavior: 'instant' }) } else { setNewMessageCount((prev) => prev + 1) } }, [messages, viewKey]) // ---- mount: always scroll to bottom if messages already loaded ---- useEffect(() => { if (messages.length > 0) { scrollToBottom('instant') } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // ---- empty state ---- if (messages.length === 0) { return (

开始新的对话

在下方输入消息开始与 AI 助手聊天

/new 创建话题 /list 查看列表
) } // ---- main render ---- return (
{messages.map((message) => ( ))}
{/* 浮动导航按钮 — 底部居中并排 */} {showScrollToBottom && (
{/* 回到顶部 */} {/* 回到底部 */}
)}
) }