feat: 优化话题选择逻辑,避免用户手动选择后自动切换;重构消息列表滚动逻辑,提升用户体验
This commit is contained in:
parent
3f9bb22097
commit
d0741ef4fc
@ -166,11 +166,15 @@ function App() {
|
||||
return () => clearTimeout(timer)
|
||||
}, [topicRefreshTrigger])
|
||||
|
||||
// Topics 加载后,自动选择第一个并通知后端切换,以便加载历史消息
|
||||
// Topics 加载后,自动选择第一个(仅当用户尚未手动选择 topic 时)
|
||||
useEffect(() => {
|
||||
if (topics.length === 0 || status !== 'connected') {
|
||||
return
|
||||
}
|
||||
// 用户已经选中了某个 topic → 不要抢走
|
||||
if (selectedTopic) {
|
||||
return
|
||||
}
|
||||
|
||||
const firstTopic = topics[0]
|
||||
if (lastAutoSwitchedTopicRef.current === firstTopic.id) {
|
||||
@ -182,7 +186,7 @@ function App() {
|
||||
const cmd = switchTopic(firstTopic.id)
|
||||
handleCommand(cmd)
|
||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
|
||||
}, [topics, status, selectTopic, switchTopic, handleCommand, sendMessage]);
|
||||
}, [topics, status, selectedTopic, selectTopic, switchTopic, handleCommand, sendMessage]);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(content: string, attachments: Attachment[] = []) => {
|
||||
@ -573,6 +577,7 @@ function App() {
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ChatContainer
|
||||
key={selectedTopic ?? 'no-topic'}
|
||||
messages={chatMessages}
|
||||
isLoading={isLoading}
|
||||
isReadOnly={subAgentView || schedulerView ? true : isReadOnly}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
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'
|
||||
@ -11,142 +11,73 @@ interface MessageListProps {
|
||||
export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isStickyRef = useRef(true)
|
||||
const isAtBottomRef = 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 ----
|
||||
// ---- scroll helpers ----
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||
isAtBottomRef.current = true
|
||||
setShowScrollToBottom(false)
|
||||
setHasNewMessage(false)
|
||||
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
|
||||
|
||||
// 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
|
||||
isAtBottomRef.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 })
|
||||
}, [])
|
||||
// ---- auto-scroll: useLayoutEffect runs before browser processes scroll events ----
|
||||
|
||||
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(() => {
|
||||
// 消息清空 → 重置所有追踪状态(话题/会话切换)
|
||||
useLayoutEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
prevMessageCountRef.current = 0
|
||||
lastMessageCountRef.current = 0
|
||||
isStickyRef.current = true
|
||||
isAtBottomRef.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) {
|
||||
if (lastMessage.role === 'user' || isAtBottomRef.current) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'instant' })
|
||||
} else {
|
||||
setHasNewMessage(true)
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
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()
|
||||
}, [])
|
||||
|
||||
// ---- 组件挂载时滚动到底部 ----
|
||||
// ---- mount: always scroll to bottom if messages already loaded ----
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
@ -155,12 +86,6 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// ---- 清理定时器 ----
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(scrollTimerRef.current)
|
||||
}, [])
|
||||
|
||||
// ---- empty state ----
|
||||
|
||||
if (messages.length === 0) {
|
||||
@ -188,7 +113,7 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-full overflow-y-auto p-6 space-y-6 scroll-smooth"
|
||||
className="h-full overflow-y-auto p-6 space-y-6"
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} />
|
||||
@ -196,9 +121,8 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
|
||||
<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}
|
||||
@ -217,7 +141,6 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 回到底部 */}
|
||||
{showScrollToBottom && (
|
||||
<button
|
||||
onClick={() => scrollToBottom('smooth')}
|
||||
@ -234,7 +157,6 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
|
||||
>
|
||||
<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>
|
||||
|
||||
@ -73,11 +73,6 @@ export function TopicList({
|
||||
[topics, currentPage, pageSize]
|
||||
)
|
||||
|
||||
// Reset page when topics list changes (e.g., new data loaded)
|
||||
useEffect(() => {
|
||||
setCurrentPage(0)
|
||||
}, [topics])
|
||||
|
||||
// Clamp currentPage when it exceeds totalPages (e.g., after deletion on last page)
|
||||
useEffect(() => {
|
||||
if (currentPage >= totalPages) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user