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