250 lines
9.1 KiB
TypeScript
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>
|
|
)
|
|
}
|