fix(ai-builder): Hide the excute and refine dialog in the workflow builder if task was aborted (#21355)

This commit is contained in:
Michael Drury 2025-10-30 11:15:27 +00:00 committed by GitHub
parent 589c072b67
commit f79d968151
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 110 additions and 31 deletions

View File

@ -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;
}

View File

@ -7,6 +7,11 @@ export namespace ChatUI {
codeSnippet?: string;
}
export interface TaskAbortedMessage extends Omit<TextMessage, 'role' | 'codeSnippet'> {
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 & {

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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 () => {

View File

@ -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;

View File

@ -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',