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