fix: 同步前置 subAgentViewRef/subAgentStackRef,消除子智能体视图间歇性丢消息

根因: subAgentViewRef.current 在 setSubAgentStack updater 内部更新,updater 在 React render 阶段才执行,不是同步的。从 enterSubAgentView() 返回到 React render 之间存在竞态窗口,WebSocket 消息读到过时的 ref 被错误路由到主视图并丢弃。

修复: ref 赋值从 updater 内部移到 setSubAgentStack 之前同步执行。新增 subAgentStackRef 供 exit/navigate 在空依赖闭包中读取最新栈计算新栈顶。selectTopic/selectChannel/selectSession 清空栈时也同步设置 ref。updater 变纯函数,StrictMode 双调用无副作用。

Plan: fix-subagent-view-ref-race
This commit is contained in:
oudecheng 2026-07-07 14:12:21 +08:00
parent 6df87fe399
commit b9c880d823

View File

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