fix(editor): Correct the n8n Assistant onboarding greeting animations and actions (no-changelog) (#39829)

This commit is contained in:
Raúl Gómez Morales 2026-09-29 10:25:33 +00:00 committed by GitHub
parent 212fbfe455
commit df3945af8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 120 additions and 12 deletions

View File

@ -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: [

View File

@ -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;
}>();
</script>
@ -47,6 +49,7 @@ defineSlots<{
<div v-if="$slots.actions" :class="$style.actions">
<slot name="actions" />
</div>
<slot name="after-actions" />
</div>
</template>

View File

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

View File

@ -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"
/>
<InstanceAiInput
v-else-if="
greetingPhase === null && !followUpHeld && !awaitingOnboardingGreeting
"
v-else-if="greetingPhase === null && !awaitingOnboardingGreeting"
ref="chatInputRef"
key="chat-input"
:is-streaming="thread.isStreaming"
@ -1204,12 +1202,17 @@ defineExpose({
}
// The onboarding greeting's paragraphs rise one after the other while `greetingPhase` is set.
// One rule per paragraph of `ONBOARDING_OPENING.greeting` in the backend.
.greetingLines p {
animation: greeting-rise 300ms cubic-bezier(0.2, 0.8, 0.2, 1) 180ms both;
}
.greetingLines p:nth-of-type(2) {
animation: greeting-rise 280ms ease-out 1000ms both;
animation: greeting-rise 280ms ease-out 780ms both;
}
.greetingLines p:nth-of-type(3) {
animation: greeting-rise 280ms ease-out 1380ms both;
}
@keyframes greeting-rise {
@ -1225,7 +1228,8 @@ defineExpose({
}
@media (prefers-reduced-motion: reduce) {
.greetingLines p {
// `:nth-of-type(n)` matches the specificity of the per-line rules, so it overrides them too.
.greetingLines p:nth-of-type(n) {
animation: none;
}
}

View File

@ -18,6 +18,7 @@ import { useAssistantTopUpEligibility } from '@n8n/stores/composables/useAssista
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useInstanceAiStore, useThread } from '../instanceAi.store';
import AgentActivityTree from './AgentActivityTree.vue';
import AnsweredQuestions from './AnsweredQuestions.vue';
import AttachmentPreview from './AttachmentPreview.vue';
import InstanceAiMarkdown from './InstanceAiMarkdown.vue';
@ -111,6 +112,27 @@ const attachments = computed(() =>
}),
);
/**
* 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 {
<template v-else>
<!-- Agent activity tree (handles reasoning, tool calls, sub-agents) -->
<AgentActivityTree
v-if="props.message.agentTree"
:agent-node="props.message.agentTree"
v-if="activityTree"
:agent-node="activityTree"
:message-id="props.message.id"
:run-id="props.message.runId"
/>
@ -274,6 +296,10 @@ function formatJson(value: unknown): string {
</N8nTooltip>
</N8nChatActions>
</template>
<template v-if="trailingAnswer" #after-actions>
<AnsweredQuestions :tool-call="trailingAnswer" />
</template>
</N8nChatMessage>
</template>

View File

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