feat(editor): Improve feedback buttons behavior (#18247)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Mutasem Aldmour
2025-08-14 10:05:36 +02:00
committed by GitHub
parent 59a08eed72
commit 83c3a98cf4
8 changed files with 925 additions and 75 deletions

View File

@@ -0,0 +1,277 @@
import { render, fireEvent, waitFor } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import { nextTick } from 'vue';
import MessageRating from './MessageRating.vue';
const stubs = ['n8n-button', 'n8n-icon-button', 'n8n-input'];
// Mock i18n to return keys instead of translated text
vi.mock('@n8n/design-system/composables/useI18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));
beforeEach(() => {
setActivePinia(createPinia());
});
describe('MessageRating', () => {
it('should render correctly with default props', () => {
const wrapper = render(MessageRating, {
global: { stubs },
});
expect(
wrapper.container.querySelector('[data-test-id="message-thumbs-up-button"]'),
).toBeTruthy();
expect(
wrapper.container.querySelector('[data-test-id="message-thumbs-down-button"]'),
).toBeTruthy();
expect(wrapper.html()).toMatchSnapshot();
});
describe('rating interactions', () => {
it('should emit feedback when thumbs up is clicked', async () => {
const wrapper = render(MessageRating, {
global: { stubs },
});
const upButton = wrapper.container.querySelector('[data-test-id="message-thumbs-up-button"]');
await fireEvent.click(upButton!);
expect(wrapper.emitted()).toHaveProperty('feedback');
expect(wrapper.emitted().feedback[0]).toEqual([{ rating: 'up' }]);
});
it('should emit feedback when thumbs down is clicked', async () => {
const wrapper = render(MessageRating, {
global: { stubs },
});
const downButton = wrapper.container.querySelector(
'[data-test-id="message-thumbs-down-button"]',
);
await fireEvent.click(downButton!);
expect(wrapper.emitted()).toHaveProperty('feedback');
expect(wrapper.emitted().feedback[0]).toEqual([{ rating: 'down' }]);
});
it('should hide rating buttons and show success after thumbs up', async () => {
const wrapper = render(MessageRating, {
global: { stubs },
});
const upButton = wrapper.container.querySelector('[data-test-id="message-thumbs-up-button"]');
await fireEvent.click(upButton!);
await nextTick();
expect(
wrapper.container.querySelector('[data-test-id="message-thumbs-up-button"]'),
).toBeFalsy();
expect(
wrapper.container.querySelector('[data-test-id="message-thumbs-down-button"]'),
).toBeFalsy();
expect(wrapper.getByText('assistantChat.builder.success')).toBeTruthy();
});
it('should hide rating buttons and show feedback form after thumbs down when showFeedback is true', async () => {
const wrapper = render(MessageRating, {
props: { showFeedback: true },
global: { stubs },
});
const downButton = wrapper.container.querySelector(
'[data-test-id="message-thumbs-down-button"]',
);
await fireEvent.click(downButton!);
await nextTick();
expect(
wrapper.container.querySelector('[data-test-id="message-thumbs-up-button"]'),
).toBeFalsy();
expect(
wrapper.container.querySelector('[data-test-id="message-thumbs-down-button"]'),
).toBeFalsy();
expect(
wrapper.container.querySelector('[data-test-id="message-feedback-input"]'),
).toBeTruthy();
expect(
wrapper.container.querySelector('[data-test-id="message-submit-feedback-button"]'),
).toBeTruthy();
});
it('should show success immediately after thumbs down when showFeedback is false', async () => {
const wrapper = render(MessageRating, {
props: { showFeedback: false },
global: { stubs },
});
const downButton = wrapper.container.querySelector(
'[data-test-id="message-thumbs-down-button"]',
);
await fireEvent.click(downButton!);
await nextTick();
expect(
wrapper.container.querySelector('[data-test-id="message-feedback-input"]'),
).toBeFalsy();
expect(wrapper.getByText('assistantChat.builder.success')).toBeTruthy();
});
});
describe('feedback form interactions', () => {
it('should submit feedback and show success', async () => {
const wrapper = render(MessageRating, {
props: { showFeedback: true },
global: { stubs: ['n8n-button', 'n8n-icon-button'] }, // Don't stub n8n-input
});
const downButton = wrapper.container.querySelector(
'[data-test-id="message-thumbs-down-button"]',
);
await fireEvent.click(downButton!);
await nextTick();
// Find the actual textarea element within the N8nInput component
const textarea = wrapper.container.querySelector(
'textarea[data-test-id="message-feedback-input"]',
);
await fireEvent.update(textarea!, 'This is my feedback about the response');
await nextTick();
const submitButton = wrapper.container.querySelector(
'[data-test-id="message-submit-feedback-button"]',
);
await fireEvent.click(submitButton!);
await nextTick();
expect(wrapper.emitted().feedback).toHaveLength(2);
expect(wrapper.emitted().feedback[1]).toEqual([
{ feedback: 'This is my feedback about the response' },
]);
expect(
wrapper.container.querySelector('[data-test-id="message-feedback-input"]'),
).toBeFalsy();
expect(wrapper.getByText('assistantChat.builder.success')).toBeTruthy();
});
it('should cancel feedback and return to rating buttons', async () => {
const wrapper = render(MessageRating, {
props: { showFeedback: true },
global: { stubs },
});
const downButton = wrapper.container.querySelector(
'[data-test-id="message-thumbs-down-button"]',
);
await fireEvent.click(downButton!);
await nextTick();
const cancelButton = wrapper.container.querySelector(
'n8n-button-stub[label="generic.cancel"]',
);
await fireEvent.click(cancelButton!);
await nextTick();
expect(
wrapper.container.querySelector('[data-test-id="message-feedback-input"]'),
).toBeFalsy();
expect(
wrapper.container.querySelector('[data-test-id="message-thumbs-up-button"]'),
).toBeTruthy();
expect(
wrapper.container.querySelector('[data-test-id="message-thumbs-down-button"]'),
).toBeTruthy();
});
it('should focus feedback input after thumbs down in regular mode', async () => {
const wrapper = render(MessageRating, {
props: { showFeedback: true, style: 'regular' },
global: { stubs },
});
const downButton = wrapper.container.querySelector(
'[data-test-id="message-thumbs-down-button"]',
);
await fireEvent.click(downButton!);
await waitFor(() => {
const feedbackInput = wrapper.container.querySelector(
'[data-test-id="message-feedback-input"]',
);
expect(feedbackInput).toBeTruthy();
});
});
it('should clear feedback text when cancelling', async () => {
const wrapper = render(MessageRating, {
props: { showFeedback: true },
global: { stubs },
});
const downButton = wrapper.container.querySelector(
'[data-test-id="message-thumbs-down-button"]',
);
await fireEvent.click(downButton!);
await nextTick();
const cancelButton = wrapper.container.querySelector(
'n8n-button-stub[label="generic.cancel"]',
);
await fireEvent.click(cancelButton!);
await nextTick();
const downButtonAgain = wrapper.container.querySelector(
'[data-test-id="message-thumbs-down-button"]',
);
await fireEvent.click(downButtonAgain!);
await nextTick();
const feedbackInputAfter = wrapper.container.querySelector(
'[data-test-id="message-feedback-input"]',
);
expect(feedbackInputAfter?.getAttribute('modelvalue')).toBe('');
});
});
describe('textarea rows based on style', () => {
it('should have 5 rows for regular style', async () => {
const wrapper = render(MessageRating, {
props: { showFeedback: true, style: 'regular' },
global: { stubs },
});
const downButton = wrapper.container.querySelector(
'[data-test-id="message-thumbs-down-button"]',
);
await fireEvent.click(downButton!);
await nextTick();
const feedbackInput = wrapper.container.querySelector(
'[data-test-id="message-feedback-input"]',
);
expect(feedbackInput?.getAttribute('rows')).toBe('5');
});
it('should have 3 rows for minimal style', async () => {
const wrapper = render(MessageRating, {
props: { showFeedback: true, style: 'minimal' },
global: { stubs },
});
const downButton = wrapper.container.querySelector(
'[data-test-id="message-thumbs-down-button"]',
);
await fireEvent.click(downButton!);
await nextTick();
const feedbackInput = wrapper.container.querySelector(
'[data-test-id="message-feedback-input"]',
);
expect(feedbackInput?.getAttribute('rows')).toBe('3');
});
});
});

View File

@@ -25,6 +25,7 @@ const emit = defineEmits<{
const { t } = useI18n(); const { t } = useI18n();
const showRatingButtons = ref(true); const showRatingButtons = ref(true);
const showFeedbackArea = ref(false); const showFeedbackArea = ref(false);
const feedbackInput = ref<HTMLInputElement | null>(null);
const showSuccess = ref(false); const showSuccess = ref(false);
const selectedRating = ref<'up' | 'down' | null>(null); const selectedRating = ref<'up' | 'down' | null>(null);
const feedback = ref(''); const feedback = ref('');
@@ -34,8 +35,13 @@ function onRateButton(rating: 'up' | 'down') {
showRatingButtons.value = false; showRatingButtons.value = false;
emit('feedback', { rating }); emit('feedback', { rating });
if (props.showFeedback) { if (props.showFeedback && rating === 'down') {
showFeedbackArea.value = true; showFeedbackArea.value = true;
setTimeout(() => {
if (feedbackInput.value) {
feedbackInput.value.focus();
}
}, 0);
} else { } else {
showSuccess.value = true; showSuccess.value = true;
} }
@@ -100,6 +106,7 @@ function onCancelFeedback() {
<div v-if="showFeedbackArea" :class="$style.feedbackContainer"> <div v-if="showFeedbackArea" :class="$style.feedbackContainer">
<N8nInput <N8nInput
ref="feedbackInput"
v-model="feedback" v-model="feedback"
:class="$style.feedbackInput" :class="$style.feedbackInput"
type="textarea" type="textarea"

View File

@@ -0,0 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`MessageRating > should render correctly with default props 1`] = `
"<div class="rating regular">
<div class="buttons">
<n8n-button-stub icon="thumbs-up" block="false" element="button" label="assistantChat.builder.thumbsUp" square="false" active="false" disabled="false" loading="false" outline="false" size="small" text="false" type="secondary" data-test-id="message-thumbs-up-button"></n8n-button-stub>
<n8n-button-stub icon="thumbs-down" block="false" element="button" label="assistantChat.builder.thumbsDown" square="false" active="false" disabled="false" loading="false" outline="false" size="small" text="false" type="secondary" data-test-id="message-thumbs-down-button"></n8n-button-stub>
</div>
<!--v-if-->
<!--v-if-->
</div>"
`;

View File

@@ -40,7 +40,7 @@ export default {
'assistantChat.builder.configuredNodes': 'Configured nodes', 'assistantChat.builder.configuredNodes': 'Configured nodes',
'assistantChat.builder.thumbsUp': 'Helpful', 'assistantChat.builder.thumbsUp': 'Helpful',
'assistantChat.builder.thumbsDown': 'Not helpful', 'assistantChat.builder.thumbsDown': 'Not helpful',
'assistantChat.builder.feedbackPlaceholder': 'Tell us about your experience', 'assistantChat.builder.feedbackPlaceholder': 'What went wrong?',
'assistantChat.builder.success': 'Thank you for your feedback!', 'assistantChat.builder.success': 'Thank you for your feedback!',
'assistantChat.builder.submit': 'Submit feedback', 'assistantChat.builder.submit': 'Submit feedback',
'assistantChat.builder.workflowGenerated1': 'Your workflow was created successfully!', 'assistantChat.builder.workflowGenerated1': 'Your workflow was created successfully!',

View File

@@ -1,8 +1,15 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useBuilderMessages } from '@/composables/useBuilderMessages'; import { useBuilderMessages } from '@/composables/useBuilderMessages';
import type { ChatUI } from '@n8n/design-system/types/assistant'; import type { ChatUI } from '@n8n/design-system/types/assistant';
import type { ChatRequest } from '@/types/assistant.types'; import type { ChatRequest } from '@/types/assistant.types';
// Mock useI18n to return the keys instead of translations
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
describe('useBuilderMessages', () => { describe('useBuilderMessages', () => {
let builderMessages: ReturnType<typeof useBuilderMessages>; let builderMessages: ReturnType<typeof useBuilderMessages>;
@@ -282,7 +289,7 @@ describe('useBuilderMessages', () => {
); );
expect(result.messages).toHaveLength(1); expect(result.messages).toHaveLength(1);
expect(result.thinkingMessage).toBe('Running tools...'); expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
expect(result.shouldClearThinking).toBe(false); expect(result.shouldClearThinking).toBe(false);
}); });
@@ -309,7 +316,7 @@ describe('useBuilderMessages', () => {
); );
expect(result.messages).toHaveLength(1); expect(result.messages).toHaveLength(1);
expect(result.thinkingMessage).toBe('Processing results...'); expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.processingResults');
expect(result.shouldClearThinking).toBe(false); expect(result.shouldClearThinking).toBe(false);
}); });
@@ -382,8 +389,8 @@ describe('useBuilderMessages', () => {
); );
expect(result.messages).toHaveLength(2); expect(result.messages).toHaveLength(2);
// Should show "Running tools..." for the new running tool, not "Processing results..." // Should show "aiAssistant.thinkingSteps.runningTools" for the new running tool, not "aiAssistant.thinkingSteps.processingResults"
expect(result.thinkingMessage).toBe('Running tools...'); expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
}); });
it('should show processing message when second tool completes', () => { it('should show processing message when second tool completes', () => {
@@ -419,7 +426,7 @@ describe('useBuilderMessages', () => {
); );
expect(result.messages).toHaveLength(2); expect(result.messages).toHaveLength(2);
expect(result.thinkingMessage).toBe('Processing results...'); expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.processingResults');
}); });
it('should keep showing running tools message when parallel tools complete one by one', () => { it('should keep showing running tools message when parallel tools complete one by one', () => {
@@ -466,8 +473,8 @@ describe('useBuilderMessages', () => {
); );
expect(result.messages).toHaveLength(2); expect(result.messages).toHaveLength(2);
// Should still show "Running tools..." because call-456 is still running // Should still show "aiAssistant.thinkingSteps.runningTools" because call-456 is still running
expect(result.thinkingMessage).toBe('Running tools...'); expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
// Verify first tool is now completed // Verify first tool is now completed
const firstTool = result.messages.find( const firstTool = result.messages.find(
@@ -526,8 +533,8 @@ describe('useBuilderMessages', () => {
); );
expect(result.messages).toHaveLength(2); expect(result.messages).toHaveLength(2);
// Should now show "Processing results..." because all tools are completed // Should now show "aiAssistant.thinkingSteps.processingResults" because all tools are completed
expect(result.thinkingMessage).toBe('Processing results...'); expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.processingResults');
}); });
it('should keep processing message when workflow-updated arrives after tools complete', () => { it('should keep processing message when workflow-updated arrives after tools complete', () => {
@@ -561,8 +568,8 @@ describe('useBuilderMessages', () => {
); );
expect(result.messages).toHaveLength(2); expect(result.messages).toHaveLength(2);
// Should still show "Processing results..." because workflow-updated is not a text response // Should still show "aiAssistant.thinkingSteps.processingResults" because workflow-updated is not a text response
expect(result.thinkingMessage).toBe('Processing results...'); expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.processingResults');
// Should NOT clear thinking for workflow updates // Should NOT clear thinking for workflow updates
expect(result.shouldClearThinking).toBe(false); expect(result.shouldClearThinking).toBe(false);
}); });
@@ -1256,7 +1263,7 @@ describe('useBuilderMessages', () => {
]; ];
let result = builderMessages.processAssistantMessages(currentMessages, batch1, 'batch-1'); let result = builderMessages.processAssistantMessages(currentMessages, batch1, 'batch-1');
expect(result.thinkingMessage).toBe('Running tools...'); expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
currentMessages = result.messages; currentMessages = result.messages;
// Second batch: tool completes // Second batch: tool completes
@@ -1272,7 +1279,7 @@ describe('useBuilderMessages', () => {
]; ];
result = builderMessages.processAssistantMessages(currentMessages, batch2, 'batch-2'); result = builderMessages.processAssistantMessages(currentMessages, batch2, 'batch-2');
expect(result.thinkingMessage).toBe('Processing results...'); expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.processingResults');
currentMessages = result.messages; currentMessages = result.messages;
// Third batch: workflow updated // Third batch: workflow updated
@@ -1285,7 +1292,7 @@ describe('useBuilderMessages', () => {
]; ];
result = builderMessages.processAssistantMessages(currentMessages, batch3, 'batch-3'); result = builderMessages.processAssistantMessages(currentMessages, batch3, 'batch-3');
expect(result.thinkingMessage).toBe('Processing results...'); expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.processingResults');
currentMessages = result.messages; currentMessages = result.messages;
// Fourth batch: final text response // Fourth batch: final text response
@@ -1308,4 +1315,460 @@ describe('useBuilderMessages', () => {
expect(result.messages.find((m) => m.type === 'text')).toBeTruthy(); expect(result.messages.find((m) => m.type === 'text')).toBeTruthy();
}); });
}); });
describe('clearRatingLogic', () => {
it('should remove showRating and ratingStyle properties from text messages', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'Hello there!',
showRating: true,
ratingStyle: 'regular',
read: false,
},
{
id: 'msg-2',
role: 'assistant',
type: 'text',
content: 'How can I help?',
showRating: false,
ratingStyle: 'minimal',
read: false,
},
];
const result = builderMessages.clearRatingLogic(messages);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'Hello there!',
read: false,
});
expect(result[0]).not.toHaveProperty('showRating');
expect(result[0]).not.toHaveProperty('ratingStyle');
expect(result[1]).toMatchObject({
id: 'msg-2',
role: 'assistant',
type: 'text',
content: 'How can I help?',
read: false,
});
expect(result[1]).not.toHaveProperty('showRating');
expect(result[1]).not.toHaveProperty('ratingStyle');
});
it('should leave non-text messages unchanged', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'tool-1',
role: 'assistant',
type: 'tool',
toolName: 'add_nodes',
toolCallId: 'call-1',
status: 'completed',
updates: [],
read: false,
},
{
id: 'workflow-1',
role: 'assistant',
type: 'workflow-updated',
codeSnippet: '{}',
read: false,
},
];
const result = builderMessages.clearRatingLogic(messages);
expect(result).toHaveLength(2);
expect(result[0]).toEqual(messages[0]);
expect(result[1]).toEqual(messages[1]);
});
it('should handle text messages without rating properties', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'No rating here',
read: false,
},
];
const result = builderMessages.clearRatingLogic(messages);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(messages[0]);
});
it('should handle empty message array', () => {
const result = builderMessages.clearRatingLogic([]);
expect(result).toHaveLength(0);
});
it('should handle mixed message types with some having rating properties', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'With rating',
showRating: true,
ratingStyle: 'regular',
read: false,
},
{
id: 'tool-1',
role: 'assistant',
type: 'tool',
toolName: 'test_tool',
toolCallId: 'call-1',
status: 'completed',
updates: [],
read: false,
},
{
id: 'msg-2',
role: 'assistant',
type: 'text',
content: 'Without rating',
read: false,
},
];
const result = builderMessages.clearRatingLogic(messages);
expect(result).toHaveLength(3);
expect(result[0]).not.toHaveProperty('showRating');
expect(result[0]).not.toHaveProperty('ratingStyle');
expect(result[1]).toEqual(messages[1]); // tool message unchanged
expect(result[2]).toEqual(messages[2]); // text without rating unchanged
});
});
describe('applyRatingLogic', () => {
it('should apply rating to the last assistant text message after workflow-updated when no tools are running', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'Starting process...',
read: false,
},
{
id: 'workflow-1',
role: 'assistant',
type: 'workflow-updated',
codeSnippet: '{"nodes": [], "connections": {}}',
read: false,
},
{
id: 'msg-2',
role: 'assistant',
type: 'text',
content: 'Process completed!',
read: false,
},
];
const result = builderMessages.applyRatingLogic(messages);
expect(result).toHaveLength(3);
expect(result[0].showRating).toBeUndefined();
expect(result[1].showRating).toBeUndefined();
expect(result[1].type).toBe('workflow-updated');
expect(result[2]).toMatchObject({
id: 'msg-2',
content: 'Process completed!',
showRating: true,
ratingStyle: 'regular',
});
});
it('should not apply rating when tools are still running', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'workflow-1',
role: 'assistant',
type: 'workflow-updated',
codeSnippet: '{}',
read: false,
},
{
id: 'tool-1',
role: 'assistant',
type: 'tool',
toolName: 'add_nodes',
toolCallId: 'call-1',
status: 'running',
updates: [],
read: false,
},
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'Working on it...',
read: false,
},
];
const result = builderMessages.applyRatingLogic(messages);
expect(result).toHaveLength(3);
result.forEach((message) => {
expect(message.showRating).toBeUndefined();
});
});
it('should not apply rating when still thinking (tools completed but no text response)', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'workflow-1',
role: 'assistant',
type: 'workflow-updated',
codeSnippet: '{}',
read: false,
},
{
id: 'tool-1',
role: 'assistant',
type: 'tool',
toolName: 'add_nodes',
toolCallId: 'call-1',
status: 'completed',
updates: [],
read: false,
},
];
const result = builderMessages.applyRatingLogic(messages);
expect(result).toHaveLength(2);
result.forEach((message) => {
expect(message.showRating).toBeUndefined();
});
});
it('should not apply rating when no workflow-updated message exists', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'Hello there!',
read: false,
},
{
id: 'msg-2',
role: 'assistant',
type: 'text',
content: 'How can I help?',
read: false,
},
];
const result = builderMessages.applyRatingLogic(messages);
expect(result).toHaveLength(2);
expect(result[0].showRating).toBeUndefined();
expect(result[1].showRating).toBeUndefined();
});
it('should remove existing ratings when tools are running', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'Previous message',
showRating: true,
ratingStyle: 'regular',
read: false,
},
{
id: 'tool-1',
role: 'assistant',
type: 'tool',
toolName: 'add_nodes',
toolCallId: 'call-1',
status: 'running',
updates: [],
read: false,
},
];
const result = builderMessages.applyRatingLogic(messages);
expect(result).toHaveLength(2);
expect(result[0]).not.toHaveProperty('showRating');
expect(result[0]).not.toHaveProperty('ratingStyle');
});
it('should remove ratings from non-target messages when applying rating to target message', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'Earlier message',
showRating: true,
ratingStyle: 'minimal',
read: false,
},
{
id: 'workflow-1',
role: 'assistant',
type: 'workflow-updated',
codeSnippet: '{}',
read: false,
},
{
id: 'msg-2',
role: 'assistant',
type: 'text',
content: 'Target message',
read: false,
},
];
const result = builderMessages.applyRatingLogic(messages);
expect(result).toHaveLength(3);
expect(result[0]).not.toHaveProperty('showRating');
expect(result[0]).not.toHaveProperty('ratingStyle');
expect(result[2]).toMatchObject({
showRating: true,
ratingStyle: 'regular',
});
});
it('should handle multiple workflow-updated messages and apply rating after the last one', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'workflow-1',
role: 'assistant',
type: 'workflow-updated',
codeSnippet: '{"nodes": []}',
read: false,
},
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'First update done',
read: false,
},
{
id: 'workflow-2',
role: 'assistant',
type: 'workflow-updated',
codeSnippet: '{"nodes": [{"name": "HTTP"}]}',
read: false,
},
{
id: 'msg-2',
role: 'assistant',
type: 'text',
content: 'Final update complete',
read: false,
},
];
const result = builderMessages.applyRatingLogic(messages);
expect(result).toHaveLength(4);
expect(result[1].showRating).toBeUndefined(); // First text message
expect(result[3]).toMatchObject({
content: 'Final update complete',
showRating: true,
ratingStyle: 'regular',
});
});
it('should handle user messages mixed with assistant messages', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'user-1',
role: 'user',
type: 'text',
content: 'Create a workflow',
read: true,
},
{
id: 'workflow-1',
role: 'assistant',
type: 'workflow-updated',
codeSnippet: '{}',
read: false,
},
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'Workflow created!',
read: false,
},
];
const result = builderMessages.applyRatingLogic(messages);
expect(result).toHaveLength(3);
expect(result[0].showRating).toBeUndefined(); // User message
expect(result[2]).toMatchObject({
content: 'Workflow created!',
showRating: true,
ratingStyle: 'regular',
});
});
it('should handle empty message array', () => {
const result = builderMessages.applyRatingLogic([]);
expect(result).toHaveLength(0);
});
it('should apply rating only to assistant text messages, not user text messages', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: 'workflow-1',
role: 'assistant',
type: 'workflow-updated',
codeSnippet: '{}',
read: false,
},
{
id: 'user-1',
role: 'user',
type: 'text',
content: 'Thanks!',
read: true,
},
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'You are welcome!',
read: false,
},
];
const result = builderMessages.applyRatingLogic(messages);
expect(result).toHaveLength(3);
expect(result[1].showRating).toBeUndefined(); // User message should not have rating
expect(result[2]).toMatchObject({
content: 'You are welcome!',
showRating: true,
ratingStyle: 'regular',
});
});
});
}); });

View File

@@ -12,31 +12,32 @@ export interface MessageProcessingResult {
export function useBuilderMessages() { export function useBuilderMessages() {
const locale = useI18n(); const locale = useI18n();
/**
* Clear rating from all messages
*/
function clearRatingLogic(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
return messages.map((message) => {
if (message.type === 'text' && 'showRating' in message) {
// Pick all properties except showRating and ratingStyle
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { showRating, ratingStyle, ...cleanMessage } = message;
return cleanMessage;
}
return message;
});
}
/** /**
* Apply rating logic to messages - only show rating on the last AI text message after workflow-updated * Apply rating logic to messages - only show rating on the last AI text message after workflow-updated
* when no tools are running * when no tools are running
*/ */
function applyRatingLogic(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] { function applyRatingLogic(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
// Check if any tools are still running const { hasAnyRunningTools, isStillThinking } = getThinkingState(messages);
const hasRunningTools = messages.some(
(m) => m.type === 'tool' && (m as ChatUI.ToolMessage).status === 'running',
);
// Don't apply rating if tools are still running // Don't apply rating if tools are still running
if (hasRunningTools) { if (hasAnyRunningTools || isStillThinking) {
// Remove any existing ratings // Remove any existing ratings
return messages.map((message) => { return clearRatingLogic(messages);
if (message.type === 'text' && 'showRating' in message) {
// Pick all properties except showRating and ratingStyle
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { showRating, ratingStyle, ...cleanMessage } = message as ChatUI.TextMessage & {
showRating?: boolean;
ratingStyle?: string;
};
return cleanMessage;
}
return message;
});
} }
// Find the index of the last workflow-updated message // Find the index of the last workflow-updated message
@@ -82,10 +83,7 @@ export function useBuilderMessages() {
// Remove any existing rating from other messages // Remove any existing rating from other messages
if (message.type === 'text' && 'showRating' in message) { if (message.type === 'text' && 'showRating' in message) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { showRating, ratingStyle, ...cleanMessage } = message as ChatUI.TextMessage & { const { showRating, ratingStyle, ...cleanMessage } = message;
showRating?: boolean;
ratingStyle?: string;
};
return cleanMessage; return cleanMessage;
} }
return message; return message;
@@ -109,14 +107,14 @@ export function useBuilderMessages() {
type: 'text', type: 'text',
content: msg.text, content: msg.text,
read: false, read: false,
} as ChatUI.AssistantMessage); } satisfies ChatUI.AssistantMessage);
shouldClearThinking = true; shouldClearThinking = true;
} else if (isWorkflowUpdatedMessage(msg)) { } else if (isWorkflowUpdatedMessage(msg)) {
messages.push({ messages.push({
...msg, ...msg,
id: messageId, id: messageId,
read: false, read: false,
} as ChatUI.AssistantMessage); } satisfies ChatUI.AssistantMessage);
// Don't clear thinking for workflow updates - they're just state changes // Don't clear thinking for workflow updates - they're just state changes
} else if (isToolMessage(msg)) { } else if (isToolMessage(msg)) {
processToolMessage(messages, msg, messageId); processToolMessage(messages, msg, messageId);
@@ -149,9 +147,7 @@ export function useBuilderMessages() {
// Check if we already have this tool message // Check if we already have this tool message
const existingIndex = msg.toolCallId const existingIndex = msg.toolCallId
? messages.findIndex( ? messages.findIndex((m) => m.type === 'tool' && m.toolCallId === msg.toolCallId)
(m) => m.type === 'tool' && (m as ChatUI.ToolMessage).toolCallId === msg.toolCallId,
)
: -1; : -1;
if (existingIndex !== -1) { if (existingIndex !== -1) {
@@ -180,21 +176,35 @@ export function useBuilderMessages() {
} }
/** /**
* Determine the thinking message based on tool states * If any tools are running, then it's still running tools and not done thinking
* If all tools are done and no text response yet, then it's still thinking
* Otherwise, it's done
*
* @param messages
* @returns
*/ */
function determineThinkingMessage(messages: ChatUI.AssistantMessage[]): string | undefined { function getThinkingState(messages: ChatUI.AssistantMessage[]): {
// Check ALL messages to determine state hasAnyRunningTools: boolean;
isStillThinking: boolean;
} {
const allToolMessages = messages.filter( const allToolMessages = messages.filter(
(msg): msg is ChatUI.ToolMessage => msg.type === 'tool', (msg): msg is ChatUI.ToolMessage => msg.type === 'tool',
); );
const hasAnyRunningTools = allToolMessages.some((msg) => msg.status === 'running'); const hasAnyRunningTools = allToolMessages.some((msg) => msg.status === 'running');
if (hasAnyRunningTools) {
return {
hasAnyRunningTools: true,
isStillThinking: false,
};
}
const hasCompletedTools = allToolMessages.some((msg) => msg.status === 'completed'); const hasCompletedTools = allToolMessages.some((msg) => msg.status === 'completed');
// Find the last completed tool message // Find the last completed tool message
let lastCompletedToolIndex = -1; let lastCompletedToolIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]; const msg = messages[i];
if (msg.type === 'tool' && (msg as ChatUI.ToolMessage).status === 'completed') { if (msg.type === 'tool' && msg.status === 'completed') {
lastCompletedToolIndex = i; lastCompletedToolIndex = i;
break; break;
} }
@@ -213,12 +223,21 @@ export function useBuilderMessages() {
} }
} }
// - If any tools are running, show "Running tools..." return {
// - If all tools are done and no text response yet, show "Processing results..." hasAnyRunningTools: false,
// - Otherwise, clear the thinking message isStillThinking: hasCompletedTools && !hasTextAfterTools,
};
}
/**
* Determine the thinking message based on tool states
*/
function determineThinkingMessage(messages: ChatUI.AssistantMessage[]): string | undefined {
const { hasAnyRunningTools, isStillThinking } = getThinkingState(messages);
if (hasAnyRunningTools) { if (hasAnyRunningTools) {
return locale.baseText('aiAssistant.thinkingSteps.runningTools'); return locale.baseText('aiAssistant.thinkingSteps.runningTools');
} else if (hasCompletedTools && !hasTextAfterTools) { } else if (isStillThinking) {
return locale.baseText('aiAssistant.thinkingSteps.processingResults'); return locale.baseText('aiAssistant.thinkingSteps.processingResults');
} }
@@ -259,7 +278,7 @@ export function useBuilderMessages() {
type: 'text', type: 'text',
content, content,
read: true, read: true,
} as ChatUI.AssistantMessage; };
} }
function createAssistantMessage(content: string, id: string): ChatUI.AssistantMessage { function createAssistantMessage(content: string, id: string): ChatUI.AssistantMessage {
@@ -269,7 +288,7 @@ export function useBuilderMessages() {
type: 'text', type: 'text',
content, content,
read: true, read: true,
} as ChatUI.AssistantMessage; };
} }
function createErrorMessage( function createErrorMessage(
@@ -284,7 +303,7 @@ export function useBuilderMessages() {
content, content,
retry, retry,
read: false, read: false,
} as ChatUI.AssistantMessage; };
} }
function clearMessages(): ChatUI.AssistantMessage[] { function clearMessages(): ChatUI.AssistantMessage[] {
@@ -310,7 +329,7 @@ export function useBuilderMessages() {
type: 'text', type: 'text',
content: message.text, content: message.text,
read: false, read: false,
} as ChatUI.AssistantMessage; } satisfies ChatUI.AssistantMessage;
} }
if (isWorkflowUpdatedMessage(message)) { if (isWorkflowUpdatedMessage(message)) {
@@ -318,7 +337,7 @@ export function useBuilderMessages() {
...message, ...message,
id, id,
read: false, read: false,
} as ChatUI.AssistantMessage; } satisfies ChatUI.AssistantMessage;
} }
if (isToolMessage(message)) { if (isToolMessage(message)) {
@@ -331,7 +350,7 @@ export function useBuilderMessages() {
status: message.status, status: message.status,
updates: message.updates || [], updates: message.updates || [],
read: false, read: false,
} as ChatUI.AssistantMessage; } satisfies ChatUI.AssistantMessage;
} }
// Handle event messages // Handle event messages
@@ -340,7 +359,7 @@ export function useBuilderMessages() {
...message, ...message,
id, id,
read: false, read: false,
} as ChatUI.AssistantMessage; } satisfies ChatUI.AssistantMessage;
} }
// Default fallback // Default fallback
@@ -350,7 +369,7 @@ export function useBuilderMessages() {
type: 'text', type: 'text',
content: locale.baseText('aiAssistant.thinkingSteps.thinking'), content: locale.baseText('aiAssistant.thinkingSteps.thinking'),
read: false, read: false,
} as ChatUI.AssistantMessage; } satisfies ChatUI.AssistantMessage;
} }
return { return {
@@ -361,5 +380,7 @@ export function useBuilderMessages() {
clearMessages, clearMessages,
addMessages, addMessages,
mapAssistantMessageToUI, mapAssistantMessageToUI,
applyRatingLogic,
clearRatingLogic,
}; };
} }

View File

@@ -17,6 +17,13 @@ import type { Telemetry } from '@/plugins/telemetry';
import type { ChatUI } from '@n8n/design-system/types/assistant'; import type { ChatUI } from '@n8n/design-system/types/assistant';
import { DEFAULT_CHAT_WIDTH, MAX_CHAT_WIDTH, MIN_CHAT_WIDTH } from './assistant.store'; import { DEFAULT_CHAT_WIDTH, MAX_CHAT_WIDTH, MIN_CHAT_WIDTH } from './assistant.store';
// Mock useI18n to return the keys instead of translations
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
let settingsStore: ReturnType<typeof useSettingsStore>; let settingsStore: ReturnType<typeof useSettingsStore>;
let posthogStore: ReturnType<typeof usePostHog>; let posthogStore: ReturnType<typeof usePostHog>;
@@ -195,8 +202,8 @@ describe('AI Builder store', () => {
builderStore.sendChatMessage({ text: 'Add nodes and connect them' }); builderStore.sendChatMessage({ text: 'Add nodes and connect them' });
// Initially shows "Thinking..." from prepareForStreaming // Initially shows "aiAssistant.thinkingSteps.thinking" from prepareForStreaming
expect(builderStore.assistantThinkingMessage).toBe('Thinking...'); expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
// First tool starts // First tool starts
onMessageCallback({ onMessageCallback({
@@ -212,8 +219,8 @@ describe('AI Builder store', () => {
], ],
}); });
// Should show "Running tools..." // Should show "aiAssistant.thinkingSteps.runningTools"
expect(builderStore.assistantThinkingMessage).toBe('Running tools...'); expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
// Second tool starts (different toolCallId) // Second tool starts (different toolCallId)
onMessageCallback({ onMessageCallback({
@@ -229,8 +236,8 @@ describe('AI Builder store', () => {
], ],
}); });
// Still showing "Running tools..." with multiple tools // Still showing "aiAssistant.thinkingSteps.runningTools" with multiple tools
expect(builderStore.assistantThinkingMessage).toBe('Running tools...'); expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
// First tool completes // First tool completes
onMessageCallback({ onMessageCallback({
@@ -246,8 +253,8 @@ describe('AI Builder store', () => {
], ],
}); });
// Still "Running tools..." because second tool is still running // Still "aiAssistant.thinkingSteps.runningTools" because second tool is still running
expect(builderStore.assistantThinkingMessage).toBe('Running tools...'); expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
// Second tool completes // Second tool completes
onMessageCallback({ onMessageCallback({
@@ -263,15 +270,19 @@ describe('AI Builder store', () => {
], ],
}); });
// Now should show "Processing results..." because all tools completed // Now should show "aiAssistant.thinkingSteps.processingResults" because all tools completed
expect(builderStore.assistantThinkingMessage).toBe('Processing results...'); expect(builderStore.assistantThinkingMessage).toBe(
'aiAssistant.thinkingSteps.processingResults',
);
// Call onDone to stop streaming // Call onDone to stop streaming
onDoneCallback(); onDoneCallback();
// Message should persist after streaming ends // Message should persist after streaming ends
expect(builderStore.streaming).toBe(false); expect(builderStore.streaming).toBe(false);
expect(builderStore.assistantThinkingMessage).toBe('Processing results...'); expect(builderStore.assistantThinkingMessage).toBe(
'aiAssistant.thinkingSteps.processingResults',
);
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -311,14 +322,18 @@ describe('AI Builder store', () => {
builderStore.sendChatMessage({ text: 'Add a node' }); builderStore.sendChatMessage({ text: 'Add a node' });
// Should show "Processing results..." when tool completes // Should show "aiAssistant.thinkingSteps.processingResults" when tool completes
await vi.waitFor(() => await vi.waitFor(() =>
expect(builderStore.assistantThinkingMessage).toBe('Processing results...'), expect(builderStore.assistantThinkingMessage).toBe(
'aiAssistant.thinkingSteps.processingResults',
),
); );
// Should still show "Processing results..." after workflow-updated // Should still show "aiAssistant.thinkingSteps.processingResults" after workflow-updated
await vi.waitFor(() => expect(builderStore.chatMessages).toHaveLength(3)); // user + tool + workflow await vi.waitFor(() => expect(builderStore.chatMessages).toHaveLength(3)); // user + tool + workflow
expect(builderStore.assistantThinkingMessage).toBe('Processing results...'); expect(builderStore.assistantThinkingMessage).toBe(
'aiAssistant.thinkingSteps.processingResults',
);
// Verify streaming has ended // Verify streaming has ended
expect(builderStore.streaming).toBe(false); expect(builderStore.streaming).toBe(false);
@@ -709,4 +724,58 @@ describe('AI Builder store', () => {
expect((assistantMessages[0] as ChatUI.TextMessage).content).toBe('[Task aborted]'); expect((assistantMessages[0] as ChatUI.TextMessage).content).toBe('[Task aborted]');
}); });
}); });
describe('Rating logic integration', () => {
it('should clear ratings from existing messages when preparing for streaming', () => {
const builderStore = useBuilderStore();
// Setup initial messages with ratings
builderStore.chatMessages = [
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'Previous message',
showRating: true,
ratingStyle: 'regular',
read: false,
} satisfies ChatUI.AssistantMessage,
{
id: 'msg-2',
role: 'assistant',
type: 'text',
content: 'Another message',
showRating: true,
ratingStyle: 'minimal',
read: false,
} satisfies ChatUI.AssistantMessage,
];
// Mock API to prevent actual network calls
apiSpy.mockImplementationOnce(() => {});
// Send new message which calls prepareForStreaming
builderStore.sendChatMessage({ text: 'New message' });
// Verify that existing messages no longer have rating properties
expect(builderStore.chatMessages).toHaveLength(3); // 2 existing + 1 new user message
const firstMessage = builderStore.chatMessages[0] as ChatUI.TextMessage;
expect(firstMessage).not.toHaveProperty('showRating');
expect(firstMessage).not.toHaveProperty('ratingStyle');
expect(firstMessage.content).toBe('Previous message');
const secondMessage = builderStore.chatMessages[1] as ChatUI.TextMessage;
expect(secondMessage).not.toHaveProperty('showRating');
expect(secondMessage).not.toHaveProperty('ratingStyle');
expect(secondMessage.content).toBe('Another message');
// New user message should not have rating properties
const userMessage = builderStore.chatMessages[2] as ChatUI.TextMessage;
expect(userMessage.role).toBe('user');
expect(userMessage.content).toBe('New message');
expect(userMessage).not.toHaveProperty('showRating');
expect(userMessage).not.toHaveProperty('ratingStyle');
});
});
}); });

View File

@@ -56,6 +56,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
createErrorMessage, createErrorMessage,
clearMessages, clearMessages,
mapAssistantMessageToUI, mapAssistantMessageToUI,
clearRatingLogic,
} = useBuilderMessages(); } = useBuilderMessages();
// Computed properties // Computed properties
@@ -203,7 +204,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
*/ */
function prepareForStreaming(userMessage: string, messageId: string) { function prepareForStreaming(userMessage: string, messageId: string) {
const userMsg = createUserMessage(userMessage, messageId); const userMsg = createUserMessage(userMessage, messageId);
chatMessages.value = [...chatMessages.value, userMsg]; chatMessages.value = clearRatingLogic([...chatMessages.value, userMsg]);
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking')); addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
streaming.value = true; streaming.value = true;
} }