diff --git a/web/src/components/Chat/MessageBubble.tsx b/web/src/components/Chat/MessageBubble.tsx index 9b94fda..2771919 100644 --- a/web/src/components/Chat/MessageBubble.tsx +++ b/web/src/components/Chat/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, memo } from 'react'; import { User, Bot, @@ -349,7 +349,7 @@ function parseTaskResult(content: string): TaskToolResult | null { } } -export function MessageBubble({ +export const MessageBubble = memo(function MessageBubble({ message, onNavigateToSubAgent, showThinking = true, @@ -964,4 +964,4 @@ export function MessageBubble({ {lightboxElement} ); -} +}); diff --git a/web/src/components/Chat/MessageList.tsx b/web/src/components/Chat/MessageList.tsx index 98b6ede..2b1d4ed 100644 --- a/web/src/components/Chat/MessageList.tsx +++ b/web/src/components/Chat/MessageList.tsx @@ -28,6 +28,11 @@ export function MessageList({ const viewKeyRef = useRef(viewKey); viewKeyRef.current = viewKey; + // 追踪上次的消息条数,用于计算真正新增的消息数(而非 messages 引用变化次数)。 + // 流式输出时每个 delta 都会产生新的 messages 数组引用,但消息条数不变, + // 不应计入 newMessageCount。 + const prevMessageCountRef = useRef(messages.length); + // Per-view scroll position memory const scrollPositionsRef = useRef>(new Map()); @@ -86,11 +91,13 @@ export function MessageList({ if (messages.length === 0) { isAtBottomRef.current = true; + prevMessageCountRef.current = 0; return; } if (viewChanged) { // View switched (e.g. breadcrumb navigation): restore saved scroll position + prevMessageCountRef.current = messages.length; const key = viewKey ?? ''; const savedPos = scrollPositionsRef.current.get(key); if (savedPos !== undefined && containerRef.current) { @@ -108,11 +115,18 @@ export function MessageList({ // Same view, messages changed: normal auto-scroll logic const lastMessage = messages[messages.length - 1]; + const newCount = messages.length - prevMessageCountRef.current; + prevMessageCountRef.current = messages.length; if (lastMessage.role === 'user' || isAtBottomRef.current) { bottomRef.current?.scrollIntoView({ behavior: 'instant' }); - } else { - setNewMessageCount((prev) => prev + 1); + // 用户自己发消息或已在底部时,不需要计数 + if (newCount > 0) { + setNewMessageCount(0); + } + } else if (newCount > 0) { + // 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数) + setNewMessageCount((prev) => prev + newCount); } }, [messages, viewKey]); diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts index c2b19ff..dbac39d 100644 --- a/web/src/hooks/useWebSocket.ts +++ b/web/src/hooks/useWebSocket.ts @@ -39,6 +39,17 @@ export function useWebSocket({ const isManualDisconnectRef = useRef(false); const pingIntervalRef = useRef | null>(null); + // 用 ref 存储回调的最新引用,避免回调变化导致 connect 重建 → useEffect 重跑 → 无谓断连重连。 + // 调用方即使未 useCallback 包裹回调,也不会触发重连。 + const onMessageRef = useRef(onMessage); + const onConnectRef = useRef(onConnect); + const onDisconnectRef = useRef(onDisconnect); + const onErrorRef = useRef(onError); + onMessageRef.current = onMessage; + onConnectRef.current = onConnect; + onDisconnectRef.current = onDisconnect; + onErrorRef.current = onError; + const clearPing = useCallback(() => { if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); @@ -73,13 +84,13 @@ export function useWebSocket({ } } }, PING_INTERVAL_MS); - onConnect?.(); + onConnectRef.current?.(); }; ws.onmessage = (event) => { try { const message = JSON.parse(event.data) as WsOutbound; - onMessage?.(message); + onMessageRef.current?.(message); } catch (error) { console.error('Failed to parse message:', error); } @@ -87,13 +98,13 @@ export function useWebSocket({ ws.onerror = (error) => { setStatus('error'); - onError?.(error); + onErrorRef.current?.(error); }; ws.onclose = () => { setStatus('disconnected'); clearPing(); - onDisconnect?.(); + onDisconnectRef.current?.(); // 指数退避自动重连:3s → 6s → 12s → 24s → 60s 封顶 if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) { @@ -109,7 +120,7 @@ export function useWebSocket({ setStatus('error'); console.error('WebSocket connection error:', error); } - }, [url, onMessage, onConnect, onDisconnect, onError, reconnectInterval, maxReconnectAttempts, clearPing]); + }, [url, reconnectInterval, maxReconnectAttempts, clearPing]); const disconnect = useCallback(() => { isManualDisconnectRef.current = true;