- 扩展 enterSubAgentView 方法,新增 subagentType 可选参数 - 更新相关回调 onNavigateToSubAgent,添加 subagentType 参数支持 - 调整 MessageBubble 组件触发子智能体导航时传递 subagentType - 优化 MessageList 组件显示新消息计数及底部导航按钮交互 - 美化底部浮动导航按钮样式,增加新消息数字提示和动画 - TodoPanel 添加状态点样式,消息内容排版更紧凑 - 维护滚动位置状态,改进滚动时新消息计数逻辑
208 lines
7.9 KiB
TypeScript
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>
|
|
)
|
|
}
|