From 6d604999bef0d75dacba50c91697930a5e9f8001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 24 Jul 2026 17:48:01 +0800 Subject: [PATCH] fix: refresh can cancel an in-flight activation --- .../useEditorAgentConversation.test.tsx | 87 +++++++++++++++++++ .../useEditorAgentConversation.ts | 25 +++++- 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index 442f01d79..76fadcc9a 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -264,6 +264,93 @@ describe('useEditorAgentConversation', () => { expect(result.current.isLoadingMessages).toBe(false); }); + it('does not let a refresh supersede an in-flight conversation activation', async () => { + const client = createClient(); + let resolveActivation!: (detail: EditorAgentConversationDetail) => void; + let resolveRefresh!: (detail: EditorAgentConversationDetail) => void; + const activationPromise = new Promise( + (resolve) => { + resolveActivation = resolve; + }, + ); + const refreshPromise = new Promise( + (resolve) => { + resolveRefresh = 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 activationPromise; + } + if (conversationId === 'conversation-1') { + return refreshPromise; + } + throw new Error(`Unexpected conversation: ${conversationId}`); + }); + + let pendingActivation!: Promise; + let pendingRefresh!: Promise; + act(() => { + pendingActivation = result.current.selectConversation('conversation-2'); + pendingRefresh = result.current.refreshActiveConversation(); + }); + + await act(async () => { + resolveRefresh({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '旧会话刷新结果', + messages: [ + { + id: 10, + role: 'assistant', + text: '旧会话刷新消息', + attachments: [], + toolCall: null, + createdAt: '2026-07-03T00:02:00.000Z', + }, + ], + 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.messages).toEqual([]); + + await act(async () => { + resolveActivation({ + conversationId: 'conversation-2', + projectId: 'project-1', + title: '第二个会话', + messages: [ + { + id: 20, + 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 pendingActivation; + }); + + expect(result.current.activeConversationId).toBe('conversation-2'); + expect(result.current.messages[0]?.text).toBe('第二个会话消息'); + expect(result.current.isLoadingMessages).toBe(false); + }); + 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 d7ebd1f13..2e2db83ad 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -158,6 +158,7 @@ export function useEditorAgentConversation({ const [errorMessage, setErrorMessage] = useState(null); const normalizedProjectIdRef = useRef(normalizedProjectId); const activeConversationIdRef = useRef(null); + const pendingActivationConversationIdRef = useRef(null); const activeToolCallActionRef = useRef(null); const conversationLoadRequestIdRef = useRef(0); const createConversationRequestIdRef = useRef(0); @@ -234,13 +235,22 @@ export function useEditorAgentConversation({ }, ) => { const requestId = conversationLoadRequestIdRef.current + 1; - conversationLoadRequestIdRef.current = requestId; + const maySupersedeCurrentLoad = + options.mode === 'activate' || + pendingActivationConversationIdRef.current === null; + if (maySupersedeCurrentLoad) { + conversationLoadRequestIdRef.current = requestId; + } + if (options.mode === 'activate') { + pendingActivationConversationIdRef.current = conversationId; + } const showLoading = options.showLoading ?? true; const canApplyResult = () => conversationLoadRequestIdRef.current === requestId && (options.mode === 'activate' || - activeConversationIdRef.current === conversationId); - if (showLoading) { + (pendingActivationConversationIdRef.current === null && + activeConversationIdRef.current === conversationId)); + if (showLoading && maySupersedeCurrentLoad) { setIsLoadingMessages(true); } setErrorMessage(null); @@ -258,6 +268,13 @@ export function useEditorAgentConversation({ } throw error; } finally { + if ( + options.mode === 'activate' && + conversationLoadRequestIdRef.current === requestId && + pendingActivationConversationIdRef.current === conversationId + ) { + pendingActivationConversationIdRef.current = null; + } if (conversationLoadRequestIdRef.current === requestId) { setIsLoadingMessages(false); } @@ -268,6 +285,7 @@ export function useEditorAgentConversation({ useEffect(() => { conversationLoadRequestIdRef.current += 1; + pendingActivationConversationIdRef.current = null; setIsLoadingMessages(false); if (!normalizedProjectId) { setConversations([]); @@ -339,6 +357,7 @@ export function useEditorAgentConversation({ normalizedProjectIdRef.current === requestedProjectId ) { conversationLoadRequestIdRef.current += 1; + pendingActivationConversationIdRef.current = null; setIsLoadingMessages(false); applyConversationDetail(detail); }