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
const subAgentViewRef = useRef<SubAgentView | null>(null)
// 同步追踪 subAgentStack供 exit/navigate 在回调中读取最新栈计算新栈顶
// ref 必须在事件处理器返回前同步更新,避免 WebSocket 消息路由竞态
const subAgentStackRef = useRef<SubAgentView[]>([])
const schedulerViewRef = useRef<SchedulerJobView | null>(null)
const topicsRef = useRef<Topic[]>([])
const selectedTopicRef = useRef<string | null>(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 }
}, [])
// 记忆方法