fix(web): 扫描修复——列表响应乱序覆盖与话题切换竞态

- topic_list/session_list 响应增加归属校验(比对 session_id/channel_name),切换瞬间的迟到大响应不再覆盖当前列表
- pendingNewTopic 改为差分消费:仅当响应真正携带新话题时消费聚焦标志,不再被恰好撞上的刷新响应劫持;切换 session/channel 时清除残留标志
- selectTopic 同步双写 selectedTopicRef,消除 setState→effect 同步窗口内旧话题流式消息污染新列表的竞态
- reconcileProcessingTopics 改走 apiGetSilent:裸 fetch 丢认证头导致远程部署 401、对账静默失败话题卡 loading
This commit is contained in:
oudecheng 2026-08-19 00:09:01 +08:00
parent f010006a66
commit 4218c83890
2 changed files with 59 additions and 22 deletions

View File

@ -20,8 +20,10 @@ export interface UseTopicsReturn {
topicsRef: MutableRefObject<Topic[]>; topicsRef: MutableRefObject<Topic[]>;
selectedTopicRef: MutableRefObject<string | null>; selectedTopicRef: MutableRefObject<string | null>;
pendingNewTopicRef: MutableRefObject<boolean>; pendingNewTopicRef: MutableRefObject<boolean>;
/** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */ /** topic_list pendingNewTopic
handleTopicList: (msg: TopicList) => boolean; * currentSessionId session
* */
handleTopicList: (msg: TopicList, currentSessionId: string | null) => boolean;
/** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic */ /** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic */
handleTopicRenamed: (msg: TopicRenamed) => void; handleTopicRenamed: (msg: TopicRenamed) => void;
createTopic: (title?: string) => Command; createTopic: (title?: string) => Command;
@ -67,20 +69,36 @@ export function useTopics(): UseTopicsReturn {
setTopicRefreshTrigger((n) => n + 1); setTopicRefreshTrigger((n) => n + 1);
}, []); }, []);
const handleTopicList = useCallback((msg: TopicList): boolean => { const handleTopicList = useCallback(
const newTopics = mapTopicSummaries(msg.topics); (msg: TopicList, currentSessionId: string | null): boolean => {
setTopics(newTopics); // 归属校验topic_list 有多个发射源(初始加载/token 防抖刷新/手动刷新/
// 重连恢复),切换 session 瞬间旧 session 的在途响应可能晚到。
// 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新) // 协议携带 session_id不匹配当前 session 的响应直接丢弃,
if (pendingNewTopicRef.current) { // 防止旧列表覆盖新列表并触发链式的错误自动选中。
pendingNewTopicRef.current = false; if (msg.session_id && currentSessionId && msg.session_id !== currentSessionId) {
if (newTopics.length > 0) { return false;
setSelectedTopic(newTopics[0].id);
return true;
} }
}
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 => { const handleTopicRenamed = useCallback((msg: TopicRenamed): void => {
// 后端返回刷新后的完整列表直接替换selectedTopic 基于 id 不变,无需调整 // 后端返回刷新后的完整列表直接替换selectedTopic 基于 id 不变,无需调整

View File

@ -23,6 +23,7 @@ import { useTopics } from './chat/useTopics';
import { useMessages } from './chat/useMessages'; import { useMessages } from './chat/useMessages';
import { useSubAgentView } from './chat/useSubAgentView'; import { useSubAgentView } from './chat/useSubAgentView';
import { useSchedulerView } from './chat/useSchedulerView'; import { useSchedulerView } from './chat/useSchedulerView';
import { apiGetSilent } from '../api/client';
// 简化后的层级状态 // 简化后的层级状态
interface UseChatReturn { interface UseChatReturn {
@ -142,13 +143,11 @@ interface UseChatReturn {
async function reconcileProcessingTopics( async function reconcileProcessingTopics(
setProcessingTopicIds: Dispatch<SetStateAction<Set<string>>>, setProcessingTopicIds: Dispatch<SetStateAction<Set<string>>>,
) { ) {
try { // 走统一 apiGetSilent自动注入认证头远程部署带 token 时裸 fetch 会 401
const res = await fetch('/api/executions'); // 导致对账静默失败、话题永久卡 loading。返回 null 时保留前端现有状态。
if (!res.ok) return; const data = await apiGetSilent<{ topic_ids?: string[] }>('/api/executions');
const data = (await res.json()) as { topic_ids?: string[] }; if (data) {
setProcessingTopicIds(new Set(data.topic_ids ?? [])); setProcessingTopicIds(new Set(data.topic_ids ?? []));
} catch {
// 查询失败:保留前端现有状态
} }
} }
@ -198,6 +197,11 @@ export function useChat(): UseChatReturn {
return; return;
case 'session_list': { case 'session_list': {
// 归属校验:切换通道后旧通道的在途 session_list 可能晚到,
// 不得用旧通道的会话列表覆盖当前通道(协议携带 channel_name
if (message.channel_name && message.channel_name !== selectedChannelRef.current) {
return;
}
// 重连恢复:重连前已有选中 topic且原 session 仍存在于新列表中 // 重连恢复:重连前已有选中 topic且原 session 仍存在于新列表中
// 保留断连前的 messages用户仍可查看之前的对话仅刷新 session/topic 列表 // 保留断连前的 messages用户仍可查看之前的对话仅刷新 session/topic 列表
// 注意:不发 load_chat_messages——历史消息不带 topic_idChatMessage 结构无此字段), // 注意:不发 load_chat_messages——历史消息不带 topic_idChatMessage 结构无此字段),
@ -238,7 +242,10 @@ export function useChat(): UseChatReturn {
return; return;
case 'topic_list': { case 'topic_list': {
const autoFocused = topics.handleTopicList(message); const autoFocused = topics.handleTopicList(
message,
sessions.selectedSessionIdRef.current,
);
if (autoFocused) messages.clearMessages(); if (autoFocused) messages.clearMessages();
return; return;
} }
@ -311,6 +318,10 @@ export function useChat(): UseChatReturn {
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ---- // ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
const selectTopic = useCallback((topicId: string) => { const selectTopic = useCallback((topicId: string) => {
topics.setSelectedTopic(topicId); topics.setSelectedTopic(topicId);
// 同步双写 refsetState 后的 effect 同步存在竞态窗口,窗口内到达的
// 旧话题 stream_delta/tool_* 消息会按旧 ref 过滤,污染刚清空的消息列表
// (与 enter/exitSubAgentView 的既有模式一致)
topics.selectedTopicRef.current = topicId;
messages.clearMessages(); messages.clearMessages();
// ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致) // ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致)
subAgent.subAgentViewRef.current = null; subAgent.subAgentViewRef.current = null;
@ -328,6 +339,10 @@ export function useChat(): UseChatReturn {
sessions.setSelectedSessionId(null); sessions.setSelectedSessionId(null);
topics.setTopics([]); topics.setTopics([]);
topics.setSelectedTopic(null); topics.setSelectedTopic(null);
topics.selectedTopicRef.current = null;
// 丢弃未消费的新建话题聚焦标志:新通道的首个 topic_list 中所有话题
// 都是"新话题",残留标志会被差分逻辑错误消费
topics.pendingNewTopicRef.current = false;
messages.clearMessages(); messages.clearMessages();
subAgent.subAgentViewRef.current = null; subAgent.subAgentViewRef.current = null;
subAgent.subAgentStackRef.current = []; subAgent.subAgentStackRef.current = [];
@ -346,6 +361,10 @@ export function useChat(): UseChatReturn {
sessions.setSelectedSessionId(sessionId); sessions.setSelectedSessionId(sessionId);
topics.setTopics([]); topics.setTopics([]);
topics.setSelectedTopic(null); topics.setSelectedTopic(null);
topics.selectedTopicRef.current = null;
// 丢弃未消费的新建话题聚焦标志:新 session 的首个 topic_list 中所有
// 话题都是"新话题",残留标志会被差分逻辑错误消费
topics.pendingNewTopicRef.current = false;
messages.clearMessages(); messages.clearMessages();
subAgent.subAgentViewRef.current = null; subAgent.subAgentViewRef.current = null;
subAgent.subAgentStackRef.current = []; subAgent.subAgentStackRef.current = [];