From d0741ef4fceca0fcba4b12a079dcc172228b4c0c Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Mon, 8 Jun 2026 18:01:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=AF=9D=E9=A2=98?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E9=80=BB=E8=BE=91=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=89=8B=E5=8A=A8=E9=80=89=E6=8B=A9=E5=90=8E?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=87=E6=8D=A2=EF=BC=9B=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=88=97=E8=A1=A8=E6=BB=9A=E5=8A=A8=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E6=8F=90=E5=8D=87=E7=94=A8=E6=88=B7=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/App.tsx | 9 +- web/src/components/Chat/MessageList.tsx | 132 +++++------------------ web/src/components/Sidebar/TopicList.tsx | 5 - 3 files changed, 34 insertions(+), 112 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 9c14f06..e58a531 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { )}
(null) const containerRef = useRef(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(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
{messages.map((message) => ( @@ -196,9 +121,8 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
- {/* 浮动导航按钮组 */} + {/* 浮动按钮 */}
- {/* 回到顶部 */} {showScrollToTop && (