feat: 优化话题选择逻辑,避免用户手动选择后自动切换;重构消息列表滚动逻辑,提升用户体验
This commit is contained in:
parent
3f9bb22097
commit
d0741ef4fc
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user