PicoBot/web/src/components/Chat/MessageList.tsx
oudecheng 301506a3b1 feat(chat): 支持子智能体导航添加子智能体类型参数
- 扩展 enterSubAgentView 方法,新增 subagentType 可选参数
- 更新相关回调 onNavigateToSubAgent,添加 subagentType 参数支持
- 调整 MessageBubble 组件触发子智能体导航时传递 subagentType
- 优化 MessageList 组件显示新消息计数及底部导航按钮交互
- 美化底部浮动导航按钮样式,增加新消息数字提示和动画
- TodoPanel 添加状态点样式,消息内容排版更紧凑
- 维护滚动位置状态,改进滚动时新消息计数逻辑
2026-06-18 15:34:27 +08:00

208 lines
7.9 KiB
TypeScript

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<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(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<Map<string, number>>(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 (
<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"
>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} />
))}
<div ref={bottomRef} />
</div>
{/* 浮动导航按钮 — 底部居中并排 */}
{showScrollToBottom && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
{/* 回到顶部 */}
<button
onClick={scrollToTop}
className="flex items-center gap-1.5 px-3 py-2 rounded-full
bg-[var(--bg-tertiary)]/90 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" />
<span className="text-sm text-[var(--text-secondary)]"></span>
</button>
{/* 回到底部 */}
<button
onClick={() => scrollToBottom('smooth')}
className="flex items-center gap-2 px-4 py-2 rounded-full
bg-[var(--bg-tertiary)]/90 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 ${newMessageCount > 0 ? 'animate-bounce' : ''}`} />
{newMessageCount > 0 ? (
<span className="text-sm font-medium text-[var(--text-primary)]">
{newMessageCount}
</span>
) : (
<span className="text-sm text-[var(--text-secondary)]"></span>
)}
{newMessageCount > 0 && (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--accent-cyan)]/60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[var(--accent-cyan)]" />
</span>
)}
</button>
</div>
)}
</div>
)
}