diff --git a/web/src/hooks/chat/useTopics.ts b/web/src/hooks/chat/useTopics.ts index 7d93ae2..10cc052 100644 --- a/web/src/hooks/chat/useTopics.ts +++ b/web/src/hooks/chat/useTopics.ts @@ -20,8 +20,10 @@ export interface UseTopicsReturn { topicsRef: MutableRefObject; selectedTopicRef: MutableRefObject; pendingNewTopicRef: MutableRefObject; - /** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */ - handleTopicList: (msg: TopicList) => boolean; + /** 处理 topic_list 消息:校验归属后映射格式,按 pendingNewTopic 自动聚焦。 + * currentSessionId 用于丢弃其他 session 的迟到响应(乱序覆盖防护)。 + * 返回是否自动聚焦了新话题 */ + handleTopicList: (msg: TopicList, currentSessionId: string | null) => boolean; /** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic) */ handleTopicRenamed: (msg: TopicRenamed) => void; createTopic: (title?: string) => Command; @@ -67,20 +69,36 @@ export function useTopics(): UseTopicsReturn { setTopicRefreshTrigger((n) => n + 1); }, []); - const handleTopicList = useCallback((msg: TopicList): boolean => { - const newTopics = mapTopicSummaries(msg.topics); - setTopics(newTopics); - - // 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新) - if (pendingNewTopicRef.current) { - pendingNewTopicRef.current = false; - if (newTopics.length > 0) { - setSelectedTopic(newTopics[0].id); - return true; + const handleTopicList = useCallback( + (msg: TopicList, currentSessionId: string | null): boolean => { + // 归属校验:topic_list 有多个发射源(初始加载/token 防抖刷新/手动刷新/ + // 重连恢复),切换 session 瞬间旧 session 的在途响应可能晚到。 + // 协议携带 session_id,不匹配当前 session 的响应直接丢弃, + // 防止旧列表覆盖新列表并触发链式的错误自动选中。 + if (msg.session_id && currentSessionId && msg.session_id !== currentSessionId) { + return false; } - } - return false; - }, []); + + const newTopics = mapTopicSummaries(msg.topics); + setTopics(newTopics); + + // 新建话题后自动聚焦:用差分找出本响应新增的话题(而非盲取第一项)—— + // 仅当响应确实携带新话题时才消费 pending 标志,避免被恰好撞上的 + // token 刷新/重连刷新响应错误消费。 + if (pendingNewTopicRef.current) { + const knownIds = new Set(topicsRef.current.map((t) => t.id)); + const fresh = newTopics.find((t) => !knownIds.has(t.id)); + if (fresh) { + pendingNewTopicRef.current = false; + setSelectedTopic(fresh.id); + selectedTopicRef.current = fresh.id; + return true; + } + } + return false; + }, + [], + ); const handleTopicRenamed = useCallback((msg: TopicRenamed): void => { // 后端返回刷新后的完整列表,直接替换;selectedTopic 基于 id 不变,无需调整 diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index 9967af6..de29a7b 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -23,6 +23,7 @@ import { useTopics } from './chat/useTopics'; import { useMessages } from './chat/useMessages'; import { useSubAgentView } from './chat/useSubAgentView'; import { useSchedulerView } from './chat/useSchedulerView'; +import { apiGetSilent } from '../api/client'; // 简化后的层级状态 interface UseChatReturn { @@ -142,13 +143,11 @@ interface UseChatReturn { async function reconcileProcessingTopics( setProcessingTopicIds: Dispatch>>, ) { - try { - const res = await fetch('/api/executions'); - if (!res.ok) return; - const data = (await res.json()) as { topic_ids?: string[] }; + // 走统一 apiGetSilent:自动注入认证头(远程部署带 token 时裸 fetch 会 401, + // 导致对账静默失败、话题永久卡 loading)。返回 null 时保留前端现有状态。 + const data = await apiGetSilent<{ topic_ids?: string[] }>('/api/executions'); + if (data) { setProcessingTopicIds(new Set(data.topic_ids ?? [])); - } catch { - // 查询失败:保留前端现有状态 } } @@ -198,6 +197,11 @@ export function useChat(): UseChatReturn { return; case 'session_list': { + // 归属校验:切换通道后旧通道的在途 session_list 可能晚到, + // 不得用旧通道的会话列表覆盖当前通道(协议携带 channel_name) + if (message.channel_name && message.channel_name !== selectedChannelRef.current) { + return; + } // 重连恢复:重连前已有选中 topic,且原 session 仍存在于新列表中 // 保留断连前的 messages(用户仍可查看之前的对话),仅刷新 session/topic 列表 // 注意:不发 load_chat_messages——历史消息不带 topic_id(ChatMessage 结构无此字段), @@ -238,7 +242,10 @@ export function useChat(): UseChatReturn { return; case 'topic_list': { - const autoFocused = topics.handleTopicList(message); + const autoFocused = topics.handleTopicList( + message, + sessions.selectedSessionIdRef.current, + ); if (autoFocused) messages.clearMessages(); return; } @@ -311,6 +318,10 @@ export function useChat(): UseChatReturn { // ---- selectTopic: 切换话题,清空消息和子智能体栈 ---- const selectTopic = useCallback((topicId: string) => { topics.setSelectedTopic(topicId); + // 同步双写 ref:setState 后的 effect 同步存在竞态窗口,窗口内到达的 + // 旧话题 stream_delta/tool_* 消息会按旧 ref 过滤,污染刚清空的消息列表 + // (与 enter/exitSubAgentView 的既有模式一致) + topics.selectedTopicRef.current = topicId; messages.clearMessages(); // ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致) subAgent.subAgentViewRef.current = null; @@ -328,6 +339,10 @@ export function useChat(): UseChatReturn { sessions.setSelectedSessionId(null); topics.setTopics([]); topics.setSelectedTopic(null); + topics.selectedTopicRef.current = null; + // 丢弃未消费的新建话题聚焦标志:新通道的首个 topic_list 中所有话题 + // 都是"新话题",残留标志会被差分逻辑错误消费 + topics.pendingNewTopicRef.current = false; messages.clearMessages(); subAgent.subAgentViewRef.current = null; subAgent.subAgentStackRef.current = []; @@ -346,6 +361,10 @@ export function useChat(): UseChatReturn { sessions.setSelectedSessionId(sessionId); topics.setTopics([]); topics.setSelectedTopic(null); + topics.selectedTopicRef.current = null; + // 丢弃未消费的新建话题聚焦标志:新 session 的首个 topic_list 中所有 + // 话题都是"新话题",残留标志会被差分逻辑错误消费 + topics.pendingNewTopicRef.current = false; messages.clearMessages(); subAgent.subAgentViewRef.current = null; subAgent.subAgentStackRef.current = [];