diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index 12468d6..027415b 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -181,6 +181,9 @@ export function useChat(): UseChatReturn { // Ref to track subAgentView and schedulerView for use in callbacks const subAgentViewRef = useRef(null) + // 同步追踪 subAgentStack,供 exit/navigate 在回调中读取最新栈计算新栈顶 + // ref 必须在事件处理器返回前同步更新,避免 WebSocket 消息路由竞态 + const subAgentStackRef = useRef([]) const schedulerViewRef = useRef(null) const topicsRef = useRef([]) const selectedTopicRef = useRef(null) @@ -1007,6 +1010,8 @@ export function useChat(): UseChatReturn { const selectTopic = useCallback((topicId: string) => { setSelectedTopic(topicId) setMessages([]) + subAgentViewRef.current = null + subAgentStackRef.current = [] setSubAgentStack([]) }, []) @@ -1053,6 +1058,8 @@ export function useChat(): UseChatReturn { setTopics([]) setSelectedTopic(null) setMessages([]) + subAgentViewRef.current = null + subAgentStackRef.current = [] setSubAgentStack([]) setIsLoading(true) }, [selectedChannel]) @@ -1063,6 +1070,8 @@ export function useChat(): UseChatReturn { setTopics([]) setSelectedTopic(null) setMessages([]) + subAgentViewRef.current = null + subAgentStackRef.current = [] setSubAgentStack([]) setIsLoading(true) }, [selectedSessionId]) @@ -1075,11 +1084,15 @@ export function useChat(): UseChatReturn { } }, [sessionId]) - // Keep refs in sync with state + // Keep refs in sync with state (兜底,确保 ref 与 state 最终一致) useEffect(() => { subAgentViewRef.current = subAgentView }, [subAgentView]) + useEffect(() => { + subAgentStackRef.current = subAgentStack + }, [subAgentStack]) + useEffect(() => { schedulerViewRef.current = schedulerView }, [schedulerView]) @@ -1100,12 +1113,12 @@ export function useChat(): UseChatReturn { status: 'loading', messages: [], } - setSubAgentStack((prev) => { - const newStack = [...prev, newView] - // Sync ref immediately so WebSocket response routing works correctly - subAgentViewRef.current = newView - return newStack - }) + // 同步设置 ref,消除竞态窗口:updater 在 React render 阶段才执行, + // 期间 WebSocket 消息会读到过时的 ref。ref 必须在事件处理器返回前同步更新。 + subAgentViewRef.current = newView + subAgentStackRef.current = [...subAgentStackRef.current, newView] + // updater 变纯函数,不再有副作用 + setSubAgentStack((prev) => [...prev, newView]) return { type: 'load_task_messages', task_id: taskId, @@ -1113,42 +1126,45 @@ export function useChat(): UseChatReturn { }, []) const exitSubAgentView = useCallback((): Command | null => { - let command: Command | null = null - setSubAgentStack((prev) => { - if (prev.length <= 1) { - subAgentViewRef.current = null - return [] - } - const newStack = prev.slice(0, -1) - const newTop = newStack[newStack.length - 1] - subAgentViewRef.current = newTop - // 清空目标层 messages + status 置 loading,并准备重新拉取命令 - const clearedStack = [...newStack] - clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' } - command = { type: 'load_task_messages', task_id: newTop.taskId } - return clearedStack - }) - return command + // 从 ref 读取最新栈(useCallback 依赖为空,闭包中的 subAgentStack 是旧值) + const current = subAgentStackRef.current + if (current.length <= 1) { + subAgentViewRef.current = null + subAgentStackRef.current = [] + setSubAgentStack([]) + return null + } + const newStack = current.slice(0, -1) + const newTop = newStack[newStack.length - 1] + // 同步设置 ref,消除竞态窗口 + subAgentViewRef.current = newTop + // 清空目标层 messages + status 置 loading,准备重新拉取 + const clearedStack = [...newStack] + clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' } + subAgentStackRef.current = clearedStack + setSubAgentStack(clearedStack) + return { type: 'load_task_messages', task_id: newTop.taskId } }, []) const navigateToSubAgentLevel = useCallback((index: number): Command | null => { - let command: Command | null = null - setSubAgentStack((prev) => { - if (index < 0) { - // -1 means go back to main session (clear all) - subAgentViewRef.current = null - return [] - } - if (index >= prev.length) return prev - const newStack = prev.slice(0, index + 1) - const newTop = newStack[newStack.length - 1] - subAgentViewRef.current = newTop - const clearedStack = [...newStack] - clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' } - command = { type: 'load_task_messages', task_id: newTop.taskId } - return clearedStack - }) - return command + const current = subAgentStackRef.current + if (index < 0) { + // -1 means go back to main session (clear all) + subAgentViewRef.current = null + subAgentStackRef.current = [] + setSubAgentStack([]) + return null + } + if (index >= current.length) return null + const newStack = current.slice(0, index + 1) + const newTop = newStack[newStack.length - 1] + // 同步设置 ref,消除竞态窗口 + subAgentViewRef.current = newTop + const clearedStack = [...newStack] + clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' } + subAgentStackRef.current = clearedStack + setSubAgentStack(clearedStack) + return { type: 'load_task_messages', task_id: newTop.taskId } }, []) // 记忆方法