PicoBot/web/src/components/Chat/MessageList.tsx

250 lines
9.1 KiB
TypeScript

import { useEffect, 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) => void
}
export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const isStickyRef = useRef(true)
const prevShowBottomRef = useRef(false)
const prevShowTopRef = useRef(false)
const lastMessageCountRef = useRef(0)
const prevMessageCountRef = useRef(0)
const stickyLockUntilRef = useRef(0)
const isProgrammaticScrollRef = useRef(false)
const scrollTimerRef = useRef<number>(0)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [showScrollToTop, setShowScrollToTop] = useState(false)
const [hasNewMessage, setHasNewMessage] = useState(false)
// ---- scroll handlers ----
const handleScroll = useCallback(() => {
const el = containerRef.current
if (!el) return
// sticky lock 期间:防止中间 DOM 状态的 scroll 事件打断历史加载
if (Date.now() < stickyLockUntilRef.current) {
isStickyRef.current = true
return
}
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
const nearBottom = distanceFromBottom < 120
isStickyRef.current = nearBottom
// 编程式滚动进行中(按钮点击触发),跳过按钮状态更新,避免闪烁
if (isProgrammaticScrollRef.current) return
// 回到顶部按钮:滚过 0.8 个视口时显示
const shouldShowTop = el.scrollTop > el.clientHeight * 0.8
if (shouldShowTop !== prevShowTopRef.current) {
prevShowTopRef.current = shouldShowTop
setShowScrollToTop(shouldShowTop)
}
// 回到底部按钮:离开底部时显示
const shouldShowBottom = !nearBottom
if (shouldShowBottom !== prevShowBottomRef.current) {
prevShowBottomRef.current = shouldShowBottom
setShowScrollToBottom(shouldShowBottom)
}
// 用户手动滚回底部时清除"有新消息"标记
if (nearBottom && hasNewMessage) {
setHasNewMessage(false)
}
}, [hasNewMessage])
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
isStickyRef.current = true
prevShowBottomRef.current = false
setShowScrollToBottom(false)
setHasNewMessage(false)
isProgrammaticScrollRef.current = true
clearTimeout(scrollTimerRef.current)
scrollTimerRef.current = setTimeout(() => {
isProgrammaticScrollRef.current = false
}, 500)
bottomRef.current?.scrollIntoView({ behavior })
}, [])
const scrollToTop = useCallback(() => {
isProgrammaticScrollRef.current = true
clearTimeout(scrollTimerRef.current)
scrollTimerRef.current = setTimeout(() => {
isProgrammaticScrollRef.current = false
}, 500)
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
}, [])
// ---- auto-scroll effect ----
useEffect(() => {
// 消息清空 → 重置所有追踪状态(话题/会话切换)
if (messages.length === 0) {
prevMessageCountRef.current = 0
lastMessageCountRef.current = 0
isStickyRef.current = true
return
}
const lastMessage = messages[messages.length - 1]
const isFreshLoad = prevMessageCountRef.current === 0 && messages.length > 0
// 用户自己发的消息 → 始终滚到底部
if (lastMessage.role === 'user') {
scrollToBottom('instant')
prevMessageCountRef.current = messages.length
return
}
// 新加载(话题切换后首次收到消息)→ 强制滚到底部
if (isFreshLoad) {
isStickyRef.current = true
lastMessageCountRef.current = 0
stickyLockUntilRef.current = Date.now() + 1500
scrollToBottom('instant')
prevMessageCountRef.current = messages.length
return
}
// 用户在底部 → 自动跟随新消息
if (isStickyRef.current && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'instant' })
}
// 用户不在底部且有新消息 → 标记未读
if (!isStickyRef.current && messages.length > lastMessageCountRef.current) {
setHasNewMessage(true)
}
lastMessageCountRef.current = messages.length
prevMessageCountRef.current = messages.length
}, [messages, scrollToBottom])
// ---- ResizeObserver: 窗口大小变化时保持底部对齐 ----
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new ResizeObserver(() => {
if (isStickyRef.current && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'instant' })
}
})
observer.observe(el)
return () => observer.disconnect()
}, [])
// ---- 组件挂载时滚动到底部 ----
useEffect(() => {
if (messages.length > 0) {
scrollToBottom('instant')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ---- 清理定时器 ----
useEffect(() => {
return () => clearTimeout(scrollTimerRef.current)
}, [])
// ---- empty state ----
if (messages.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center animate-fade-in">
<div className="mb-6 inline-flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-[var(--accent-cyan)]/20 to-[var(--accent-blue)]/20 shadow-2xl shadow-[var(--accent-cyan)]/20">
<Sparkles className="h-10 w-10 text-[var(--accent-cyan)]" />
</div>
<h2 className="mb-2 text-2xl font-bold text-[var(--text-primary)]"></h2>
<p className="text-[var(--text-muted)]"> AI </p>
<div className="mt-8 flex items-center justify-center gap-4 text-sm text-[var(--text-muted)]">
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">/new </span>
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">/list </span>
</div>
</div>
</div>
)
}
// ---- main render ----
return (
<div className="relative h-full">
<div
ref={containerRef}
onScroll={handleScroll}
className="h-full overflow-y-auto p-6 space-y-6 scroll-smooth"
>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} />
))}
<div ref={bottomRef} />
</div>
{/* 浮动导航按钮组 */}
<div className="absolute right-4 bottom-4 z-10 flex flex-col items-center gap-2 pointer-events-none">
{/* 回到顶部 */}
{showScrollToTop && (
<button
onClick={scrollToTop}
className="pointer-events-auto group relative flex items-center justify-center
w-9 h-9 rounded-full
bg-[var(--bg-tertiary)]/80 backdrop-blur-md
border border-[var(--border-color)]
text-[var(--text-muted)] hover:text-[var(--accent-cyan)]
hover:border-[var(--accent-cyan)]/30
hover:shadow-[0_0_20px_var(--shadow-glow-sm)]
transition-all duration-300 ease-out
animate-fade-in"
aria-label="回到顶部"
>
<ArrowUp className="h-4 w-4 transition-transform duration-300 group-hover:-translate-y-0.5" />
</button>
)}
{/* 回到底部 */}
{showScrollToBottom && (
<button
onClick={() => scrollToBottom('smooth')}
className="pointer-events-auto group relative flex items-center justify-center
w-9 h-9 rounded-full
bg-[var(--bg-tertiary)]/80 backdrop-blur-md
border border-[var(--border-color)]
text-[var(--text-muted)] hover:text-[var(--accent-cyan)]
hover:border-[var(--accent-cyan)]/30
hover:shadow-[0_0_20px_var(--shadow-glow-sm)]
transition-all duration-300 ease-out
animate-fade-in"
aria-label="回到底部"
>
<ArrowDown className={`h-4 w-4 transition-transform duration-300 group-hover:translate-y-0.5 ${hasNewMessage ? 'animate-bounce' : ''}`} />
{/* 未读消息指示点 */}
{hasNewMessage && (
<span className="absolute -top-0.5 -right-0.5 flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--accent-cyan)]/60"></span>
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--accent-cyan)]"></span>
</span>
)}
</button>
)}
</div>
</div>
)
}