From df3945af8a0cb55229cfec7fc37ba4fbc6043eea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Tue, 29 Sep 2026 10:25:33 +0000 Subject: [PATCH] fix(editor): Correct the n8n Assistant onboarding greeting animations and actions (no-changelog) (#39829) --- .../modules/instance-ai/onboarding-opening.ts | 6 +-- .../components/N8nChatMessage/ChatMessage.vue | 3 ++ .../__tests__/InstanceAiMessage.test.ts | 51 +++++++++++++++++++ .../components/InstanceAiConversation.vue | 18 ++++--- .../components/InstanceAiMessage.vue | 30 ++++++++++- .../__tests__/InstanceAiConversation.test.ts | 24 +++++++++ 6 files changed, 120 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/modules/instance-ai/onboarding-opening.ts b/packages/cli/src/modules/instance-ai/onboarding-opening.ts index 4ed8d7e87ee..e86011dcb99 100644 --- a/packages/cli/src/modules/instance-ai/onboarding-opening.ts +++ b/packages/cli/src/modules/instance-ai/onboarding-opening.ts @@ -6,8 +6,8 @@ interface OnboardingOpening { title: string; /** * First assistant message. Markdown: a blank line starts a paragraph, and the chat shows the - * paragraphs one after the other before the card. `{{firstName}}` becomes the user's first - * name, or "there". + * paragraphs one after the other before the card. A new paragraph needs its own animation rule + * in `InstanceAiConversation.vue`. `{{firstName}}` becomes the user's first name, or "there". */ greeting: string; /** @@ -33,7 +33,7 @@ export const ONBOARDING_OPENING: OnboardingOpening = { greeting: [ "Hi {{firstName}}, I'm your Assistant.", 'Think of me as your n8n expert.', - "Two final questions, then I'll suggest automations that fit how you work.", + 'Two final questions, so I can suggest automations for you.', ].join('\n\n'), followUp: 'Got it. Finally, tell me a little about how you use {{apps}}.', questions: [ diff --git a/packages/frontend/@n8n/design-system/src/components/N8nChatMessage/ChatMessage.vue b/packages/frontend/@n8n/design-system/src/components/N8nChatMessage/ChatMessage.vue index 0c7a11a63e0..06efe3a4255 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nChatMessage/ChatMessage.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nChatMessage/ChatMessage.vue @@ -33,6 +33,8 @@ useResizeObserver(message, shrinkWrapUserMessage); defineSlots<{ default(): unknown; actions?(): unknown; + /** Content below the actions row, outside the area whose hover shows the actions. */ + 'after-actions'?(): unknown; }>(); @@ -47,6 +49,7 @@ defineSlots<{
+ diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMessage.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMessage.test.ts index 278de662210..d2893a20339 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMessage.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMessage.test.ts @@ -380,4 +380,55 @@ describe('InstanceAiMessage', () => { expect(queryByTestId('instance-ai-run-cancelled')).not.toBeInTheDocument(); }); + + describe('answered questions', () => { + const answeredCall = { + toolCallId: 'tc-1', + toolName: 'ask-user', + args: {}, + isLoading: false, + confirmation: { + requestId: 'req-1', + severity: 'info', + message: '', + inputType: 'questions', + questions: [{ id: 'apps', question: 'Which apps?', type: 'multi' }], + }, + result: { answered: true, answers: [{ questionId: 'apps', selectedOptions: ['HubSpot'] }] }, + } as InstanceAiAgentNode['toolCalls'][number]; + const greeting = { type: 'text', content: 'Hi' } as const; + const answer = { type: 'tool-call', toolCallId: 'tc-1' } as const; + + it('should render answers that end the timeline below the message actions', () => { + const { getByRole, getByTestId } = renderComponent({ + props: { + message: makeMessage({ + content: 'Hi', + agentTree: makeAgentTree({ toolCalls: [answeredCall], timeline: [greeting, answer] }), + }), + }, + }); + + const copy = getByRole('button', { name: 'Copy' }); + const bubble = getByTestId('instance-ai-answered-questions'); + expect(bubble).toHaveTextContent('HubSpot'); + expect(copy.compareDocumentPosition(bubble) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('should leave answers in the timeline when text follows them', () => { + const { queryByTestId } = renderComponent({ + props: { + message: makeMessage({ + content: 'Hi', + agentTree: makeAgentTree({ + toolCalls: [answeredCall], + timeline: [greeting, answer, { type: 'text', content: 'Thanks' }], + }), + }), + }, + }); + + expect(queryByTestId('instance-ai-answered-questions')).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue index 29d2a7db719..4a474753aa2 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue @@ -219,8 +219,8 @@ const hasAssistantResponse = computed(() => displayedMessages.some((m) => m.role // ponytail: the host-seeded onboarding greeting shows line by line (CSS below), then the shared // thinking block plays a thinking beat, then the apps card takes the input slot. Once per // mount, so a reload replays it. `isStreaming` on the greeting copy hides the message actions. -/** The second line has risen at ~1.3 s, the card lands at ~2.3 s. */ -const GREETING_LINES_MS = 1400; +/** The third line has risen at ~1.7 s, the card lands at ~2.7 s. */ +const GREETING_LINES_MS = 1760; const GREETING_THINKING_MS = 940; const greetingPhase = ref<'lines' | 'thinking' | null>(null); let greetingShown = false; @@ -1010,9 +1010,7 @@ defineExpose({ kind="floating" /> }), ); +/** + * Answered questions that end the timeline, like the onboarding card that the host answers with + * no model turn. They render below the actions, so copy and read aloud stay under the assistant + * text and not under the user's answer. + */ +const trailingAnswer = computed(() => { + const tree = props.message.agentTree; + const last = tree?.timeline.at(-1); + if (last?.type !== 'tool-call') return undefined; + const toolCall = tree?.toolCalls.find((tc) => tc.toolCallId === last.toolCallId); + return toolCall?.confirmation?.inputType === 'questions' && !toolCall.isLoading + ? toolCall + : undefined; +}); + +const activityTree = computed(() => { + const tree = props.message.agentTree; + if (!tree || !trailingAnswer.value) return tree; + return { ...tree, timeline: tree.timeline.slice(0, -1) }; +}); + /** Transient status message from the backend (e.g. "Recalling conversation..."). */ const statusMessage = computed(() => { if (!isStreaming.value || !props.message.agentTree) return ''; @@ -181,8 +203,8 @@ function formatJson(value: unknown): string { + + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiConversation.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiConversation.test.ts index 73d3e342640..4447f51d8a4 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiConversation.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiConversation.test.ts @@ -82,6 +82,7 @@ describe('InstanceAiConversation', () => { }); afterEach(() => { + vi.useRealTimers(); vi.clearAllMocks(); localStorage.clear(); }); @@ -212,6 +213,29 @@ describe('InstanceAiConversation', () => { expect(getByTestId('inline-offers-slot')).toBeInTheDocument(); }); + it('keeps the chat input while the onboarding follow-up is held', async () => { + vi.useFakeTimers(); + store.isOnboardingChromeHidden.mockReturnValue(true); + const createdAt = '2026-04-01T00:00:00.000Z'; + thread.messages = [ + { id: 'greeting', role: 'assistant', content: 'Hi there', createdAt }, + ] as InstanceAiMessage[]; + const wrapper = mountConversation(); + // Past the greeting's lines and thinking beats. + await vi.advanceTimersByTimeAsync(5000); + + thread.messages = [ + ...thread.messages, + { id: 'follow-up', role: 'assistant', content: 'Got it.', createdAt }, + ] as InstanceAiMessage[]; + await nextTick(); + await nextTick(); + + expect(wrapper.find('[data-test-id="instance-ai-onboarding-thinking"]').exists()).toBe(true); + expect(wrapper.text()).not.toContain('Got it.'); + expect(wrapper.findComponent(InstanceAiInputStub).exists()).toBe(true); + }); + it('emits thread-missing when the thread cannot be found', async () => { store.threads = []; const notFound = new ResponseError('Not found');