feat: 优化话题选择逻辑,避免用户手动选择后自动切换;重构消息列表滚动逻辑,提升用户体验

This commit is contained in:
oudecheng 2026-06-08 18:01:32 +08:00
parent 3f9bb22097
commit d0741ef4fc
3 changed files with 34 additions and 112 deletions

View File

@ -166,11 +166,15 @@ function App() {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [topicRefreshTrigger]) }, [topicRefreshTrigger])
// Topics 加载后,自动选择第一个并通知后端切换,以便加载历史消息 // Topics 加载后,自动选择第一个(仅当用户尚未手动选择 topic 时)
useEffect(() => { useEffect(() => {
if (topics.length === 0 || status !== 'connected') { if (topics.length === 0 || status !== 'connected') {
return return
} }
// 用户已经选中了某个 topic → 不要抢走
if (selectedTopic) {
return
}
const firstTopic = topics[0] const firstTopic = topics[0]
if (lastAutoSwitchedTopicRef.current === firstTopic.id) { if (lastAutoSwitchedTopicRef.current === firstTopic.id) {
@ -182,7 +186,7 @@ function App() {
const cmd = switchTopic(firstTopic.id) const cmd = switchTopic(firstTopic.id)
handleCommand(cmd) handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
}, [topics, status, selectTopic, switchTopic, handleCommand, sendMessage]); }, [topics, status, selectedTopic, selectTopic, switchTopic, handleCommand, sendMessage]);
const handleSendMessage = useCallback( const handleSendMessage = useCallback(
(content: string, attachments: Attachment[] = []) => { (content: string, attachments: Attachment[] = []) => {
@ -573,6 +577,7 @@ function App() {
)} )}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
<ChatContainer <ChatContainer
key={selectedTopic ?? 'no-topic'}
messages={chatMessages} messages={chatMessages}
isLoading={isLoading} isLoading={isLoading}
isReadOnly={subAgentView || schedulerView ? true : isReadOnly} isReadOnly={subAgentView || schedulerView ? true : isReadOnly}

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'
import { MessageBubble } from './MessageBubble' import { MessageBubble } from './MessageBubble'
import type { ChatMessage } from '../../types/protocol' import type { ChatMessage } from '../../types/protocol'
import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react' import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react'
@ -11,142 +11,73 @@ interface MessageListProps {
export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps) { export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const isStickyRef = useRef(true) const isAtBottomRef = useRef(true)
const prevShowBottomRef = useRef(false) const prevShowBottomRef = useRef(false)
const prevShowTopRef = 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 [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [showScrollToTop, setShowScrollToTop] = useState(false) const [showScrollToTop, setShowScrollToTop] = useState(false)
const [hasNewMessage, setHasNewMessage] = 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 handleScroll = useCallback(() => {
const el = containerRef.current const el = containerRef.current
if (!el) return 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 distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
const nearBottom = distanceFromBottom < 120 const nearBottom = distanceFromBottom < 120
isStickyRef.current = nearBottom isAtBottomRef.current = nearBottom
// 编程式滚动进行中(按钮点击触发),跳过按钮状态更新,避免闪烁
if (isProgrammaticScrollRef.current) return
// 回到顶部按钮:滚过 0.8 个视口时显示
const shouldShowTop = el.scrollTop > el.clientHeight * 0.8 const shouldShowTop = el.scrollTop > el.clientHeight * 0.8
if (shouldShowTop !== prevShowTopRef.current) { if (shouldShowTop !== prevShowTopRef.current) {
prevShowTopRef.current = shouldShowTop prevShowTopRef.current = shouldShowTop
setShowScrollToTop(shouldShowTop) setShowScrollToTop(shouldShowTop)
} }
// 回到底部按钮:离开底部时显示
const shouldShowBottom = !nearBottom const shouldShowBottom = !nearBottom
if (shouldShowBottom !== prevShowBottomRef.current) { if (shouldShowBottom !== prevShowBottomRef.current) {
prevShowBottomRef.current = shouldShowBottom prevShowBottomRef.current = shouldShowBottom
setShowScrollToBottom(shouldShowBottom) setShowScrollToBottom(shouldShowBottom)
} }
// 用户手动滚回底部时清除"有新消息"标记
if (nearBottom && hasNewMessage) { if (nearBottom && hasNewMessage) {
setHasNewMessage(false) setHasNewMessage(false)
} }
}, [hasNewMessage]) }, [hasNewMessage])
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { // ---- auto-scroll: useLayoutEffect runs before browser processes scroll events ----
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(() => { useLayoutEffect(() => {
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) { if (messages.length === 0) {
prevMessageCountRef.current = 0 isAtBottomRef.current = true
lastMessageCountRef.current = 0
isStickyRef.current = true
return return
} }
const lastMessage = messages[messages.length - 1] const lastMessage = messages[messages.length - 1]
const isFreshLoad = prevMessageCountRef.current === 0 && messages.length > 0
// 用户自己发的消息 → 始终滚到底部 if (lastMessage.role === 'user' || isAtBottomRef.current) {
if (lastMessage.role === 'user') { bottomRef.current?.scrollIntoView({ behavior: 'instant' })
scrollToBottom('instant') } else {
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) setHasNewMessage(true)
} }
}, [messages])
lastMessageCountRef.current = messages.length // ---- mount: always scroll to bottom if messages already loaded ----
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(() => { useEffect(() => {
if (messages.length > 0) { if (messages.length > 0) {
@ -155,12 +86,6 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// ---- 清理定时器 ----
useEffect(() => {
return () => clearTimeout(scrollTimerRef.current)
}, [])
// ---- empty state ---- // ---- empty state ----
if (messages.length === 0) { if (messages.length === 0) {
@ -188,7 +113,7 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
<div <div
ref={containerRef} ref={containerRef}
onScroll={handleScroll} 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) => ( {messages.map((message) => (
<MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} /> <MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} />
@ -196,9 +121,8 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
{/* 浮动导航按钮 */} {/* 浮动按钮 */}
<div className="absolute right-4 bottom-4 z-10 flex flex-col items-center gap-2 pointer-events-none"> <div className="absolute right-4 bottom-4 z-10 flex flex-col items-center gap-2 pointer-events-none">
{/* 回到顶部 */}
{showScrollToTop && ( {showScrollToTop && (
<button <button
onClick={scrollToTop} onClick={scrollToTop}
@ -217,7 +141,6 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
</button> </button>
)} )}
{/* 回到底部 */}
{showScrollToBottom && ( {showScrollToBottom && (
<button <button
onClick={() => scrollToBottom('smooth')} 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' : ''}`} /> <ArrowDown className={`h-4 w-4 transition-transform duration-300 group-hover:translate-y-0.5 ${hasNewMessage ? 'animate-bounce' : ''}`} />
{/* 未读消息指示点 */}
{hasNewMessage && ( {hasNewMessage && (
<span className="absolute -top-0.5 -right-0.5 flex h-2.5 w-2.5"> <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="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--accent-cyan)]/60"></span>

View File

@ -73,11 +73,6 @@ export function TopicList({
[topics, currentPage, pageSize] [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) // Clamp currentPage when it exceeds totalPages (e.g., after deletion on last page)
useEffect(() => { useEffect(() => {
if (currentPage >= totalPages) { if (currentPage >= totalPages) {