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)
}, [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}

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 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>

View File

@ -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) {