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 {
@@ -274,6 +296,10 @@ 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');