mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
fix(editor): Correct the n8n Assistant onboarding greeting animations and actions (no-changelog) (#39829)
This commit is contained in:
parent
212fbfe455
commit
df3945af8a
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user