diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue index d540f40e5aa..ecddceedb17 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue @@ -4,7 +4,7 @@ import { computed, nextTick, onUnmounted, ref, useCssModule, watch } from 'vue'; import MessageWrapper from './messages/MessageWrapper.vue'; import { useI18n } from '../../composables/useI18n'; import type { ChatUI, RatingFeedback, WorkflowSuggestion } from '../../types/assistant'; -import { isToolMessage } from '../../types/assistant'; +import { isTaskAbortedMessage, isToolMessage } from '../../types/assistant'; import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue'; import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoadingMessage.vue'; import AssistantText from '../AskAssistantText/AssistantText.vue'; @@ -374,11 +374,8 @@ function getMessageStyles(message: ChatUI.AssistantMessage, messageCount: number } function getMessageColor(message: ChatUI.AssistantMessage): string | undefined { - if (message.type === 'text' && message.role === 'assistant') { - const isTaskAbortedMessage = message.content === t('aiAssistant.builder.streamAbortedMessage'); - if (isTaskAbortedMessage) { - return 'var(--color--text)'; - } + if (isTaskAbortedMessage(message)) { + return 'var(--color--text)'; } return undefined; } diff --git a/packages/frontend/@n8n/design-system/src/types/assistant.ts b/packages/frontend/@n8n/design-system/src/types/assistant.ts index bad7efb344e..e67237d7e77 100644 --- a/packages/frontend/@n8n/design-system/src/types/assistant.ts +++ b/packages/frontend/@n8n/design-system/src/types/assistant.ts @@ -7,6 +7,11 @@ export namespace ChatUI { codeSnippet?: string; } + export interface TaskAbortedMessage extends Omit { + role: 'assistant'; + aborted: true; + } + export interface SummaryBlock { role: 'assistant'; type: 'block'; @@ -107,6 +112,7 @@ export namespace ChatUI { export type AssistantMessage = ( | TextMessage + | TaskAbortedMessage | MessagesWithReplies | ErrorMessage | EndSessionMessage @@ -131,7 +137,13 @@ export type RatingFeedback = { rating?: 'up' | 'down'; feedback?: string }; export function isTextMessage( msg: ChatUI.AssistantMessage, ): msg is ChatUI.TextMessage & { id?: string; read?: boolean; quickReplies?: ChatUI.QuickReply[] } { - return msg.type === 'text'; + return msg.type === 'text' && !('aborted' in msg); +} + +export function isTaskAbortedMessage( + msg: ChatUI.AssistantMessage, +): msg is ChatUI.TaskAbortedMessage & { id?: string; read?: boolean } { + return msg.type === 'text' && 'aborted' in msg && msg.aborted; } export function isSummaryBlock(msg: ChatUI.AssistantMessage): msg is ChatUI.SummaryBlock & { diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.test.ts b/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.test.ts index ae46bb80276..05fb016a6d9 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.test.ts @@ -647,9 +647,9 @@ describe('AI Builder store', () => { expect(builderStore.chatMessages[0].role).toBe('user'); expect(builderStore.chatMessages[1].role).toBe('assistant'); expect(builderStore.chatMessages[1].type).toBe('text'); - expect((builderStore.chatMessages[1] as ChatUI.TextMessage).content).toBe( - 'aiAssistant.builder.streamAbortedMessage', - ); + const abortedMessage = builderStore.chatMessages[1] as ChatUI.TaskAbortedMessage; + expect(abortedMessage.content).toBe('aiAssistant.builder.streamAbortedMessage'); + expect(abortedMessage.aborted).toBe(true); // Verify streaming state was reset expect(builderStore.streaming).toBe(false); @@ -774,9 +774,9 @@ describe('AI Builder store', () => { const assistantMessages = builderStore.chatMessages.filter((msg) => msg.role === 'assistant'); expect(assistantMessages).toHaveLength(1); expect(assistantMessages[0].type).toBe('text'); - expect((assistantMessages[0] as ChatUI.TextMessage).content).toBe( - 'aiAssistant.builder.streamAbortedMessage', - ); + const abortedMessage = assistantMessages[0] as ChatUI.TaskAbortedMessage; + expect(abortedMessage.content).toBe('aiAssistant.builder.streamAbortedMessage'); + expect(abortedMessage.aborted).toBe(true); }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts b/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts index 4b787b8814d..516e7ed5ae4 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts @@ -150,6 +150,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => { const userMsg = createAssistantMessage( locale.baseText('aiAssistant.builder.streamAbortedMessage'), 'aborted-streaming', + { aborted: true }, ); chatMessages.value = [...chatMessages.value, userMsg]; return; diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.test.ts b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.test.ts index 0df50029b3b..321de325dc3 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.test.ts @@ -675,8 +675,7 @@ describe('AskAssistantBuild', () => { }, }); - // User cancels generation - this adds a locale message for aborted task - // In tests, i18n.baseText returns the key itself + // User cancels generation - this adds an aborted message builderStore.$patch({ chatMessages: [ { id: '1', role: 'user', type: 'text', content: testMessage }, @@ -684,7 +683,8 @@ describe('AskAssistantBuild', () => { id: '2', role: 'assistant', type: 'text', - content: 'aiAssistant.builder.streamAbortedMessage', + content: 'Task aborted', + aborted: true, }, ], }); @@ -1025,7 +1025,6 @@ describe('AskAssistantBuild', () => { }); // Add cancellation message to chat - // In tests, i18n.baseText returns the key itself builderStore.$patch({ chatMessages: [ { id: '1', role: 'user', type: 'text', content: 'Create workflow from canvas' }, @@ -1033,7 +1032,8 @@ describe('AskAssistantBuild', () => { id: '2', role: 'assistant', type: 'text', - content: 'aiAssistant.builder.streamAbortedMessage', + content: 'Task aborted', + aborted: true, }, ], }); @@ -1299,6 +1299,53 @@ describe('AskAssistantBuild', () => { // Verify the ExecuteMessage component should NOT be rendered expect(queryByTestId('execute-message-component')).not.toBeInTheDocument(); }); + + it('should hide ExecuteMessage component when task is aborted after workflow update', async () => { + // Setup: workflow with nodes + workflowsStore.$patch({ + workflow: { + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }, + }); + + const { queryByTestId } = renderComponent(); + + // Simulate workflow update message followed by task aborted message + builderStore.$patch({ + streaming: false, + chatMessages: [ + { id: '1', role: 'user', type: 'text', content: 'Create a workflow' }, + { + id: '2', + role: 'assistant', + type: 'workflow-updated', + codeSnippet: JSON.stringify({ nodes: [], connections: {} }), + }, + { + id: '3', + role: 'assistant', + type: 'text', + content: 'Task aborted', + aborted: true, + }, + ], + }); + + await flushPromises(); + + // Verify the ExecuteMessage component should NOT be rendered + expect(queryByTestId('execute-message-component')).not.toBeInTheDocument(); + }); }); it('should handle multiple canvas generations correctly', async () => { diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue index bbdfad12b3b..93a530da225 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue @@ -8,7 +8,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store'; import { useRoute, useRouter } from 'vue-router'; import { useWorkflowSaving } from '@/composables/useWorkflowSaving'; import type { RatingFeedback, WorkflowSuggestion } from '@n8n/design-system/types/assistant'; -import { isWorkflowUpdatedMessage } from '@n8n/design-system/types/assistant'; +import { isTaskAbortedMessage, isWorkflowUpdatedMessage } from '@n8n/design-system/types/assistant'; import { nodeViewEventBus } from '@/event-bus'; import ExecuteMessage from './ExecuteMessage.vue'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; @@ -61,16 +61,19 @@ const showExecuteMessage = computed(() => { (msg.type === 'tool' && msg.toolName === 'update_node_parameters'), ); - // Check if there's an error message after the last workflow update - const hasErrorAfterUpdate = builderStore.chatMessages - .slice(builderUpdatedWorkflowMessageIndex + 1) - .some((msg) => msg.type === 'error'); + // Check if there's an error message or task aborted message after the last workflow update + const messagesAfterUpdate = builderStore.chatMessages.slice( + builderUpdatedWorkflowMessageIndex + 1, + ); + const hasErrorAfterUpdate = messagesAfterUpdate.some((msg) => msg.type === 'error'); + const hasTaskAbortedAfterUpdate = messagesAfterUpdate.some((msg) => isTaskAbortedMessage(msg)); return ( !builderStore.streaming && workflowsStore.workflow.nodes.length > 0 && builderUpdatedWorkflowMessageIndex > -1 && - !hasErrorAfterUpdate + !hasErrorAfterUpdate && + !hasTaskAbortedAfterUpdate ); }); const creditsQuota = computed(() => builderStore.creditsQuota); @@ -251,12 +254,7 @@ watch( // Check if the generation completed successfully (no error or cancellation) const lastMessage = builderStore.chatMessages[builderStore.chatMessages.length - 1]; const successful = - lastMessage && - lastMessage.type !== 'error' && - !( - lastMessage.type === 'text' && - lastMessage.content === i18n.baseText('aiAssistant.builder.streamAbortedMessage') - ); + lastMessage && lastMessage.type !== 'error' && !isTaskAbortedMessage(lastMessage); builderStore.initialGeneration = false; diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderMessages.ts b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderMessages.ts index 59b76e2708f..76c304ccb7c 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderMessages.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderMessages.ts @@ -318,7 +318,31 @@ export function useBuilderMessages() { }; } - function createAssistantMessage(content: string, id: string): ChatUI.AssistantMessage { + function createAssistantMessage( + content: string, + id: string, + options: { aborted: true }, + ): ChatUI.TaskAbortedMessage; + function createAssistantMessage( + content: string, + id: string, + options?: { aborted?: false }, + ): ChatUI.TextMessage; + function createAssistantMessage( + content: string, + id: string, + options?: { aborted?: boolean }, + ): ChatUI.AssistantMessage { + if (options?.aborted) { + return { + id, + role: 'assistant', + type: 'text', + content, + read: true, + aborted: true, + }; + } return { id, role: 'assistant',