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
return newStack subAgentStackRef.current = [...subAgentStackRef.current, newView]
}) // 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([])
return null
} }
const newStack = prev.slice(0, -1) const newStack = current.slice(0, -1)
const newTop = newStack[newStack.length - 1] const newTop = newStack[newStack.length - 1]
// 同步设置 ref消除竞态窗口
subAgentViewRef.current = newTop subAgentViewRef.current = newTop
// 清空目标层 messages + status 置 loading准备重新拉取命令 // 清空目标层 messages + status 置 loading准备重新拉取
const clearedStack = [...newStack] const clearedStack = [...newStack]
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' } clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' }
command = { type: 'load_task_messages', task_id: newTop.taskId } subAgentStackRef.current = clearedStack
return clearedStack setSubAgentStack(clearedStack)
}) return { type: 'load_task_messages', task_id: newTop.taskId }
return command
}, []) }, [])
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
return [] subAgentStackRef.current = []
setSubAgentStack([])
return null
} }
if (index >= prev.length) return prev if (index >= current.length) return null
const newStack = prev.slice(0, index + 1) const newStack = current.slice(0, index + 1)
const newTop = newStack[newStack.length - 1] const newTop = newStack[newStack.length - 1]
// 同步设置 ref消除竞态窗口
subAgentViewRef.current = newTop subAgentViewRef.current = newTop
const clearedStack = [...newStack] const clearedStack = [...newStack]
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' } clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' }
command = { type: 'load_task_messages', task_id: newTop.taskId } subAgentStackRef.current = clearedStack
return clearedStack setSubAgentStack(clearedStack)
}) return { type: 'load_task_messages', task_id: newTop.taskId }
return command
}, []) }, [])
// 记忆方法 // 记忆方法