diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index d1f00a1d6..bb4d6ac83 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -122,6 +122,7 @@ function createPendingToolCallMessage(): EditorAgentMessage { attachments: [], toolCall: { toolName: 'edit-image', + status: 'not_completed', args: { object_image_id: 'source-image-1', reference_image_ids: ['reference-image-1', 'reference-image-2'], @@ -569,6 +570,7 @@ describe('EditorAgentConversationPanelView', () => { text: 'internal completed tool output that must stay hidden', toolCall: { ...pendingMessage.toolCall!, + status: 'completed', externalJobId: 'task-edit-panel-1', images: [ { @@ -609,7 +611,7 @@ describe('EditorAgentConversationPanelView', () => { text: 'internal cancelled tool output that must stay hidden', toolCall: { ...pendingMessage.toolCall!, - cancelledAt: '2026-07-03T00:00:11.000Z', + status: 'cancelled', }, }); diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index c64f4f79d..0cbff2f9e 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -60,6 +60,8 @@ type EditorAgentConversationPanelViewProps = { layers?: CanvasLayer[]; assets?: EditorAsset[]; onCanvasRefreshRequested?: () => void; + // TODO refactor: move the task list update seperate + onConfirmSent?: () => void; client?: EditorAgentConversationClient; }; @@ -237,6 +239,7 @@ export function EditorAgentConversationPanelView({ layers = [], assets = [], onCanvasRefreshRequested, + onConfirmSent, client, }: EditorAgentConversationPanelViewProps) { const [hasConversationMounted, setHasConversationMounted] = useState(open); @@ -270,6 +273,7 @@ export function EditorAgentConversationPanelView({ projectId: effectiveProjectId, client, onCanvasRefreshRequested, + onConfirmSent, }); const [draftText, setDraftText] = useState(''); const [attachments, setAttachments] = useState( diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index f2c36ebe9..a2bcad114 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -81,6 +81,7 @@ function createClient(): EditorAgentConversationClient { attachments: [], toolCall: { toolName: 'generate_image', + status: 'completed', externalJobId: 'task-generated-1', args: {}, displayArgs: { @@ -168,6 +169,75 @@ describe('useEditorAgentConversation', () => { ); }); + it('replaces a lazily reconciled tool message instead of appending a duplicate', async () => { + const client = createClient(); + const pendingMessage: EditorAgentMessage = { + id: 1, + role: 'system', + text: 'pending edit', + attachments: [], + toolCall: { + toolName: 'edit-image', + status: 'not_completed', + externalJobId: 'task-edit-1', + args: { + object_image_id: 'source-image', + prompt: '换成像素风', + }, + displayArgs: createEditImageDisplayArgs('换成像素风'), + images: [], + error: null, + }, + createdAt: '2026-07-03T00:00:00.000Z', + }; + vi.mocked(client.getConversation).mockResolvedValue({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [pendingMessage], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + }); + vi.mocked(client.sendMessage).mockResolvedValue({ + conversation: { + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + updatedAt: '2026-07-03T00:00:01.000Z', + }, + deltaMessages: [ + { + ...pendingMessage, + text: '[tool_call:edit-image] output: completed', + toolCall: { + ...pendingMessage.toolCall!, + status: 'completed', + images: [{ imageSrc: '/generated/result.png' }], + }, + }, + ], + errorMessage: null, + }); + const { result } = renderHook(() => + useEditorAgentConversation({ projectId: 'project-1', client }), + ); + await waitFor(() => { + expect(result.current.messages).toHaveLength(1); + }); + + await act(async () => { + await result.current.sendMessage('继续'); + }); + + const reconciledMessages = result.current.messages.filter( + (message) => message.id === 1, + ); + expect(reconciledMessages).toHaveLength(1); + expect(reconciledMessages[0]?.toolCall?.images[0]?.imageSrc).toBe( + '/generated/result.png', + ); + }); + it('creates a conversation before sending when the project has no history', async () => { const client = createClient(); vi.mocked(client.listConversations).mockResolvedValueOnce([]); @@ -276,6 +346,7 @@ describe('useEditorAgentConversation', () => { attachments: [], toolCall: { toolName: 'edit-image', + status: 'not_completed', args: { object_image_id: 'source-image', prompt: '换成像素风', @@ -296,6 +367,7 @@ describe('useEditorAgentConversation', () => { attachments: [], toolCall: { toolName: 'edit-image', + status: 'not_completed', externalJobId: 'task-edit-1', args: { object_image_id: 'source-image', @@ -317,11 +389,13 @@ describe('useEditorAgentConversation', () => { createdAt: '2026-07-03T00:00:00.000Z', }); const onCanvasRefreshRequested = vi.fn(); + const onConfirmSent = vi.fn(); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client, onCanvasRefreshRequested, + onConfirmSent, }), ); @@ -353,6 +427,7 @@ describe('useEditorAgentConversation', () => { 'generated/result.png', ); expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1); + expect(onConfirmSent).toHaveBeenCalledTimes(1); }); it('keeps the action busy until confirmation returns an external job id', async () => { @@ -364,6 +439,7 @@ describe('useEditorAgentConversation', () => { attachments: [], toolCall: { toolName: 'edit-image', + status: 'not_completed', args: { object_image_id: 'source-image', prompt: '换成像素风' }, displayArgs: createEditImageDisplayArgs('换成像素风'), images: [], @@ -426,13 +502,14 @@ describe('useEditorAgentConversation', () => { it('cancels a pending tool call and keeps the replacement in the same position', async () => { const client = createClient(); - const pendingMessage = { + const pendingMessage: EditorAgentMessage = { id: 0, role: 'system' as const, text: 'internal pending tool prompt', attachments: [], toolCall: { toolName: 'edit-image', + status: 'not_completed', args: { object_image_id: 'source-image', prompt: '换成像素风' }, displayArgs: createEditImageDisplayArgs('换成像素风'), images: [], @@ -452,8 +529,8 @@ describe('useEditorAgentConversation', () => { ...pendingMessage, text: 'internal cancelled tool output', toolCall: { - ...pendingMessage.toolCall, - cancelledAt: '2026-07-03T00:00:01.000Z', + ...pendingMessage.toolCall!, + status: 'cancelled', }, }); const onCanvasRefreshRequested = vi.fn(); @@ -466,7 +543,7 @@ describe('useEditorAgentConversation', () => { ); await waitFor(() => { - expect(result.current.messages[0]?.toolCall?.cancelledAt).toBeUndefined(); + expect(result.current.messages[0]?.toolCall?.status).toBe('not_completed'); }); await act(async () => { await result.current.cancelToolCall(0); @@ -474,9 +551,7 @@ describe('useEditorAgentConversation', () => { expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 0); expect(result.current.messages).toHaveLength(1); - expect(result.current.messages[0]?.toolCall?.cancelledAt).toBe( - '2026-07-03T00:00:01.000Z', - ); + expect(result.current.messages[0]?.toolCall?.status).toBe('cancelled'); expect(onCanvasRefreshRequested).not.toHaveBeenCalled(); }); diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts index f7119fd24..a219b604c 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -53,6 +53,7 @@ type UseEditorAgentConversationOptions = { projectId?: string | null; client?: EditorAgentConversationClient; onCanvasRefreshRequested?: () => void; + onConfirmSent?: () => void; }; export type EditorAgentToolCallAction = 'confirm' | 'cancel'; @@ -138,6 +139,7 @@ export function useEditorAgentConversation({ projectId, client = defaultEditorAgentConversationClient, onCanvasRefreshRequested, + onConfirmSent, }: UseEditorAgentConversationOptions) { const normalizedProjectId = projectId?.trim() ?? ''; const [conversations, setConversations] = useState< @@ -436,6 +438,9 @@ export function useEditorAgentConversation({ ); if (action === 'confirm') { requestCanvasRefreshForMessages([updatedMessage]); + if (updatedMessage.toolCall?.externalJobId) { + onConfirmSent?.(); + } } return updatedMessage; } catch (error) { @@ -458,7 +463,7 @@ export function useEditorAgentConversation({ } } }, - [client, loadConversation, requestCanvasRefreshForMessages], + [client, loadConversation, onConfirmSent, requestCanvasRefreshForMessages], ); const confirmToolCall = useCallback( diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 7d6516893..43ddec8fc 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -226,6 +226,7 @@ function createEditorAgentDetailWithGeneration( attachments: [], toolCall: { toolName: 'generate_image', + status: 'completed', externalJobId: 'task-generation-1', args: {}, displayArgs: { @@ -2132,6 +2133,7 @@ describe('ImageCanvasEditorView', () => { attachments: [], toolCall: { toolName: 'edit-image', + status: 'not_completed', args: { object_image_id: 'source-image', prompt: '把图片换成像素风', @@ -2154,6 +2156,7 @@ describe('ImageCanvasEditorView', () => { attachments: [], toolCall: { toolName: 'edit-image', + status: 'completed', externalJobId: 'task-edit-canvas-1', args: {}, displayArgs: createEditImageToolCallDisplayArgs('把图片换成像素风'), diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 29cd2b806..078b2f381 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1249,6 +1249,13 @@ export function ImageCanvasEditorView({ applyProjectSnapshot: applyGeneratedProjectSnapshot, onWalletBalanceMayHaveChanged: refreshEditorWalletBalance, }); + const handleEditorAgentConfirmSent = useCallback(() => { + generationSurface.refreshTaskList(); + }, [generationSurface]); + const handleEditorAgentJobCompleted = useCallback(() => { + generationSurface.refreshTaskList(); + handleEditorAgentCanvasRefreshRequested(); + }, [generationSurface, handleEditorAgentCanvasRefreshRequested]); const effectiveIsAgentConversationOpen = isAgentConversationEnabled && isAgentConversationOpen; const toggleAgentConversation = useCallback(() => { @@ -2126,8 +2133,8 @@ export function ImageCanvasEditorView({ onActivateGenerationDialog: activateCanvasGenerationDialog, onFocusExternalTask: focusExternalGenerationTask, onExternalTasksCompleted: handleExternalGenerationTasksCompleted, - onEditorAgentCanvasRefreshRequested: - handleEditorAgentCanvasRefreshRequested, + onEditorAgentCanvasRefreshRequested: handleEditorAgentJobCompleted, + onEditorAgentConfirmSent: handleEditorAgentConfirmSent, onToggleTaskSidebar: toggleTaskSidebar, onToggleAgentConversation: toggleAgentConversation, onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize, diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index a72c45b61..240b4cbf0 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -128,6 +128,7 @@ export type ImageCanvasStageViewProps = { onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void; onEditorAgentCanvasRefreshRequested?: () => void; + onEditorAgentConfirmSent?: () => void; onToggleTaskSidebar: () => void; onToggleAgentConversation: () => void; onCropExpandHandlePointerDown: ( @@ -260,6 +261,7 @@ export function ImageCanvasStageView({ onFocusExternalTask, onExternalTasksCompleted, onEditorAgentCanvasRefreshRequested, + onEditorAgentConfirmSent, onToggleTaskSidebar, onToggleAgentConversation, onCropExpandHandlePointerDown, @@ -481,6 +483,7 @@ export function ImageCanvasStageView({ layers={layers} assets={editorAgentAssets} onCanvasRefreshRequested={onEditorAgentCanvasRefreshRequested} + onConfirmSent={onEditorAgentConfirmSent} /> ) : null} diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 75b120429..9e64ea448 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -612,6 +612,9 @@ export function useImageCanvasGenerationWorkflow({ setIsTaskSidebarOpen(true); setTaskListRefreshKey((key) => key + 1); }, []); + const refreshTaskList = useCallback(() => { + setTaskListRefreshKey((key) => key + 1); + }, []); const previousTaskCountRef = useRef(canvasGenerationDialogs.length); const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] = @@ -2439,6 +2442,7 @@ export function useImageCanvasGenerationWorkflow({ startCropExpandFrameResize, removeSelectedLayerBackground, taskListRefreshKey, + refreshTaskList, isTaskSidebarOpen, toggleTaskSidebar: () => setIsTaskSidebarOpen((open) => !open), extractUiDesignAssets, @@ -2506,6 +2510,7 @@ export function useImageCanvasGenerationWorkflow({ isMusicMenuOpen, isTaskSidebarOpen, taskListRefreshKey, + refreshTaskList, isPublicationMenuOpen, isPublicationReferenceMenuOpen, isSpecMenuOpen,