From 7902832edb1073f14075e7e4ac8045c47f9a054f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 28 Jul 2026 12:05:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8B=86=E5=88=86=E5=8D=95=E8=B0=83?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=BA=8F=E5=8F=B7=E4=B8=8E=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E7=94=9F=E6=95=88=E8=AF=B7=E6=B1=82=20ID=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E8=A2=AB=E5=8E=8B=E5=88=B6=E7=9A=84=20refresh=20?= =?UTF-8?q?=E4=B8=8E=E5=90=8E=E7=BB=AD=E5=88=87=E6=8D=A2=20ID=20=E7=A2=B0?= =?UTF-8?q?=E6=92=9E=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../【编辑器】画布Agent对话面板-2026-07-03.md | 2 +- .../useEditorAgentConversation.test.tsx | 106 ++++++++++++++++++ .../useEditorAgentConversation.ts | 14 ++- 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 0bb39cddd..2b32dd5e1 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -64,7 +64,7 @@ - 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。 - 当前会话没有任何已发送消息时,新建对话按钮置灰且不可点击;输入框草稿和未发送附件不算会话内容。当前会话已有消息时可新建,新建成功后只切换到返回的空白会话,输入文字、附件及附件选择状态与切换历史会话时一样原样保留,旧会话继续保留在历史会话下拉中;创建失败同样不修改草稿。 - 新会话创建请求 pending 时禁用历史会话下拉和发送动作,但输入框与附件仍可编辑;会话列表或历史消息加载期间同样禁用发送。表单提交处理器必须复用相同门禁,不能先清空草稿再由 hook 静默跳过发送。 -- 快速切换会话或会话轮询刷新产生并发详情请求时,前端只允许最后发起的请求更新当前会话、消息、错误和加载态;旧响应不得覆盖用户最新选择。 +- 快速切换会话或会话轮询刷新产生并发详情请求时,每个请求必须获得唯一且单调递增的请求序号;前端只允许最后发起且有权生效的请求更新当前会话、消息、错误和加载态。被正在进行的会话切换压制的旧会话 refresh 不得提前结束新切换的加载态,旧响应也不得覆盖用户最新选择。 - 普通 JSON 消息请求的回包必须绑定发送时的会话:用户在等待期间切换到其他会话后,只更新原会话的列表摘要,不得把原会话的 `deltaMessages` 、错误或画布刷新副作用应用到当前面板。 - 收起对话框只是隐藏面板,不卸载当前会话 hook;普通 JSON 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。 diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index 76fadcc9a..7166868a7 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -351,6 +351,112 @@ describe('useEditorAgentConversation', () => { expect(result.current.isLoadingMessages).toBe(false); }); + it('keeps the latest activation loading while an older suppressed refresh finishes', async () => { + const client = createClient(); + let resolveConversation2!: (detail: EditorAgentConversationDetail) => void; + let resolveRefresh!: (detail: EditorAgentConversationDetail) => void; + let resolveConversation3!: (detail: EditorAgentConversationDetail) => void; + const conversation2Promise = new Promise( + (resolve) => { + resolveConversation2 = resolve; + }, + ); + const refreshPromise = new Promise( + (resolve) => { + resolveRefresh = resolve; + }, + ); + const conversation3Promise = new Promise( + (resolve) => { + resolveConversation3 = resolve; + }, + ); + const { result } = renderHook(() => + useEditorAgentConversation({ projectId: 'project-1', client }), + ); + + await waitFor(() => { + expect(result.current.activeConversationId).toBe('conversation-1'); + }); + vi.mocked(client.getConversation).mockImplementation((conversationId) => { + if (conversationId === 'conversation-2') { + return conversation2Promise; + } + if (conversationId === 'conversation-1') { + return refreshPromise; + } + if (conversationId === 'conversation-3') { + return conversation3Promise; + } + throw new Error(`Unexpected conversation: ${conversationId}`); + }); + + let conversation2Load!: Promise; + let pendingRefresh!: Promise; + let conversation3Load!: Promise; + act(() => { + conversation2Load = result.current.selectConversation('conversation-2'); + pendingRefresh = result.current.refreshActiveConversation(); + conversation3Load = result.current.selectConversation('conversation-3'); + }); + expect(result.current.isLoadingMessages).toBe(true); + + await act(async () => { + resolveRefresh({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '旧会话刷新结果', + messages: [], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:02:00.000Z', + }); + await pendingRefresh; + }); + + expect(result.current.activeConversationId).toBe('conversation-1'); + expect(result.current.isLoadingMessages).toBe(true); + await act(async () => { + await result.current.sendMessage('切换期间不应发送'); + }); + expect(client.sendMessage).not.toHaveBeenCalled(); + + await act(async () => { + resolveConversation3({ + conversationId: 'conversation-3', + projectId: 'project-1', + title: '第三个会话', + messages: [ + { + id: 30, + role: 'assistant', + text: '第三个会话消息', + attachments: [], + toolCall: null, + createdAt: '2026-07-03T00:03:00.000Z', + }, + ], + createdAt: '2026-07-03T00:03:00.000Z', + updatedAt: '2026-07-03T00:03:00.000Z', + }); + await conversation3Load; + }); + expect(result.current.activeConversationId).toBe('conversation-3'); + expect(result.current.isLoadingMessages).toBe(false); + + await act(async () => { + resolveConversation2({ + conversationId: 'conversation-2', + projectId: 'project-1', + title: '第二个会话', + messages: [], + createdAt: '2026-07-03T00:02:00.000Z', + updatedAt: '2026-07-03T00:02:00.000Z', + }); + await conversation2Load; + }); + expect(result.current.activeConversationId).toBe('conversation-3'); + }); + it('does not let a silent refresh replace a newly created conversation', async () => { const client = createClient(); let resolveRefresh!: (detail: EditorAgentConversationDetail) => void; diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts index 2e2db83ad..e8f610dd5 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -160,6 +160,7 @@ export function useEditorAgentConversation({ const activeConversationIdRef = useRef(null); const pendingActivationConversationIdRef = useRef(null); const activeToolCallActionRef = useRef(null); + const conversationLoadRequestSequenceRef = useRef(0); const conversationLoadRequestIdRef = useRef(0); const createConversationRequestIdRef = useRef(0); const isWaitingRef = useRef(false); @@ -234,7 +235,8 @@ export function useEditorAgentConversation({ showLoading?: boolean; }, ) => { - const requestId = conversationLoadRequestIdRef.current + 1; + const requestId = conversationLoadRequestSequenceRef.current + 1; + conversationLoadRequestSequenceRef.current = requestId; const maySupersedeCurrentLoad = options.mode === 'activate' || pendingActivationConversationIdRef.current === null; @@ -284,7 +286,10 @@ export function useEditorAgentConversation({ ); useEffect(() => { - conversationLoadRequestIdRef.current += 1; + const invalidationRequestId = + conversationLoadRequestSequenceRef.current + 1; + conversationLoadRequestSequenceRef.current = invalidationRequestId; + conversationLoadRequestIdRef.current = invalidationRequestId; pendingActivationConversationIdRef.current = null; setIsLoadingMessages(false); if (!normalizedProjectId) { @@ -356,7 +361,10 @@ export function useEditorAgentConversation({ createConversationRequestIdRef.current === requestId && normalizedProjectIdRef.current === requestedProjectId ) { - conversationLoadRequestIdRef.current += 1; + const invalidationRequestId = + conversationLoadRequestSequenceRef.current + 1; + conversationLoadRequestSequenceRef.current = invalidationRequestId; + conversationLoadRequestIdRef.current = invalidationRequestId; pendingActivationConversationIdRef.current = null; setIsLoadingMessages(false); applyConversationDetail(detail);