Skip to content

Commit 64498cd

Browse files
authored
fix(editor): Make AI assistant tool steps visible during streaming (n8n-io#23898)
- Steps visible during generation: When the AI assistant is working, users can now see each step (tool calls) as they happen in real-time, rather than having them hidden behind a collapsed section - Auto-collapse on completion: Once the assistant finishes responding (or the user stops it), the steps automatically collapse to keep the final view clean and focused on the result - Consistent loading state: The initial "thinking" state now uses the same visual component as the step list, providing a smoother transition as steps appear - Cleaner copy: Removed trailing ellipsis from status text ("Thinking" instead of "Thinking...", "Processing" instead of "Processing...") - Smoother transitions: Fixed a visual glitch where "Workflow generated" would briefly flash before the next step appeared
1 parent 5076909 commit 64498cd

14 files changed

Lines changed: 269 additions & 359 deletions

File tree

packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ AssistantThinkingChat.args = {
281281
firstName: 'Max',
282282
lastName: 'Test',
283283
},
284-
loadingMessage: 'Thinking...',
284+
loadingMessage: 'Thinking',
285285
};
286286

287287
export const WithCodeSnippet = Template.bind({});
@@ -804,7 +804,7 @@ ToolCallsWithThinking.args = {
804804
read: true,
805805
},
806806
]),
807-
loadingMessage: 'Thinking...',
807+
loadingMessage: 'Thinking',
808808
streaming: true,
809809
};
810810

packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ const stubs = [
4848
'AssistantIcon',
4949
'AssistantText',
5050
'InlineAskAssistantButton',
51-
'AssistantLoadingMessage',
5251
];
5352

5453
// Stub MessageWrapper to render message as stringified JSON
@@ -293,12 +292,32 @@ describe('AskAssistantChat', () => {
293292
// Tool messages are now grouped into thinking-group messages and rendered by ThinkingMessage component
294293
// instead of MessageWrapper. These tests verify the grouping behavior.
295294

296-
const ThinkingMessageMock = vi.fn(() => ({
295+
const thinkingMessageProps: Array<{
296+
items: ChatUI.ThinkingItem[];
297+
latestStatusText: string;
298+
defaultExpanded?: boolean;
299+
isStreaming?: boolean;
300+
}> = [];
301+
let thinkingMessageCallCount = 0;
302+
const ThinkingMessageMock = {
303+
name: 'ThinkingMessage',
304+
props: ['items', 'latestStatusText', 'isStreaming', 'defaultExpanded'],
305+
setup(props: Record<string, unknown>) {
306+
thinkingMessageCallCount++;
307+
thinkingMessageProps.push({
308+
items: props.items as ChatUI.ThinkingItem[],
309+
latestStatusText: props.latestStatusText as string,
310+
defaultExpanded: props.defaultExpanded as boolean | undefined,
311+
isStreaming: props.isStreaming as boolean | undefined,
312+
});
313+
return {};
314+
},
297315
template: '<div data-testid="thinking-message-mock"></div>',
298-
}));
316+
};
299317
const MessageWrapperMock = vi.fn(() => ({
300318
template: '<div data-testid="message-wrapper-mock"></div>',
301319
}));
320+
302321
const stubsWithMocks = {
303322
...Object.fromEntries(stubs.map((stub) => [stub, true])),
304323
MessageWrapper: MessageWrapperMock,
@@ -320,7 +339,8 @@ describe('AskAssistantChat', () => {
320339

321340
const renderWithMessages = (messages: ChatUI.AssistantMessage[], extraProps = {}) => {
322341
MessageWrapperMock.mockClear();
323-
ThinkingMessageMock.mockClear();
342+
thinkingMessageCallCount = 0;
343+
thinkingMessageProps.length = 0;
324344
return render(AskAssistantChat, {
325345
global: { stubs: stubsWithMocks },
326346
props: {
@@ -333,7 +353,8 @@ describe('AskAssistantChat', () => {
333353

334354
const renderWithDirectives = (messages: ChatUI.AssistantMessage[], extraProps = {}) => {
335355
MessageWrapperMock.mockClear();
336-
ThinkingMessageMock.mockClear();
356+
thinkingMessageCallCount = 0;
357+
thinkingMessageProps.length = 0;
337358
return render(AskAssistantChat, {
338359
global: {
339360
directives: { n8nHtml },
@@ -348,9 +369,8 @@ describe('AskAssistantChat', () => {
348369
};
349370

350371
const getThinkingMessageProps = (callIndex = 0) => {
351-
const mockCall = ThinkingMessageMock.mock.calls[callIndex];
352-
expect(mockCall).toBeDefined();
353-
return mockCall as unknown as [{ items: ChatUI.ThinkingItem[]; latestStatusText: string }];
372+
expect(thinkingMessageProps[callIndex]).toBeDefined();
373+
return thinkingMessageProps[callIndex];
354374
};
355375

356376
const getMessageWrapperProps = (callIndex = 0): MessageWrapperProps => {
@@ -369,10 +389,10 @@ describe('AskAssistantChat', () => {
369389
renderWithMessages([message]);
370390

371391
// Tool messages are rendered as ThinkingMessage, not MessageWrapper
372-
expect(ThinkingMessageMock).toHaveBeenCalledTimes(1);
392+
expect(thinkingMessageCallCount).toBe(1);
373393
expect(MessageWrapperMock).toHaveBeenCalledTimes(0);
374394

375-
const props = getThinkingMessageProps()[0];
395+
const props = getThinkingMessageProps();
376396
expect(props.items).toHaveLength(1);
377397
expect(props.items[0].displayTitle).toBe('Search Results');
378398
});
@@ -403,10 +423,10 @@ describe('AskAssistantChat', () => {
403423
renderWithMessages(messages);
404424

405425
// All tool messages with same toolName should be grouped into one thinking-group
406-
expect(ThinkingMessageMock).toHaveBeenCalledTimes(1);
426+
expect(thinkingMessageCallCount).toBe(1);
407427
expect(MessageWrapperMock).toHaveBeenCalledTimes(0);
408428

409-
const props = getThinkingMessageProps()[0];
429+
const props = getThinkingMessageProps();
410430
// Should have 1 item after deduplication by toolName
411431
expect(props.items).toHaveLength(1);
412432
});
@@ -443,7 +463,7 @@ describe('AskAssistantChat', () => {
443463
renderWithMessages(messages);
444464

445465
// Hidden messages are filtered out, so tool messages are grouped together
446-
expect(ThinkingMessageMock).toHaveBeenCalledTimes(1);
466+
expect(thinkingMessageCallCount).toBe(1);
447467
expect(MessageWrapperMock).toHaveBeenCalledTimes(0);
448468
});
449469

@@ -466,10 +486,10 @@ describe('AskAssistantChat', () => {
466486
renderWithMessages(messages);
467487

468488
// Both tools should be in the same thinking-group but as separate items
469-
expect(ThinkingMessageMock).toHaveBeenCalledTimes(1);
489+
expect(thinkingMessageCallCount).toBe(1);
470490
expect(MessageWrapperMock).toHaveBeenCalledTimes(0);
471491

472-
const props = getThinkingMessageProps()[0];
492+
const props = getThinkingMessageProps();
473493
expect(props.items).toHaveLength(2);
474494
});
475495

@@ -493,7 +513,7 @@ describe('AskAssistantChat', () => {
493513
renderWithMessages(messages);
494514

495515
// Should render as ThinkingMessage with running tool
496-
expect(ThinkingMessageMock).toHaveBeenCalledTimes(1);
516+
expect(thinkingMessageCallCount).toBe(1);
497517
expect(MessageWrapperMock).toHaveBeenCalledTimes(0);
498518
});
499519

@@ -522,7 +542,7 @@ describe('AskAssistantChat', () => {
522542
renderWithDirectives(messages);
523543

524544
// Should have 2 thinking-groups and 1 text message
525-
expect(ThinkingMessageMock).toHaveBeenCalledTimes(2);
545+
expect(thinkingMessageCallCount).toBe(2);
526546
expect(MessageWrapperMock).toHaveBeenCalledTimes(1);
527547

528548
const textMessageProps = getMessageWrapperProps(0);
@@ -567,7 +587,7 @@ describe('AskAssistantChat', () => {
567587

568588
// 2 text messages via MessageWrapper, 1 thinking-group via ThinkingMessage
569589
expect(MessageWrapperMock).toHaveBeenCalledTimes(2);
570-
expect(ThinkingMessageMock).toHaveBeenCalledTimes(1);
590+
expect(thinkingMessageCallCount).toBe(1);
571591

572592
const firstProps = getMessageWrapperProps(0);
573593
expect(firstProps.message).toEqual(
@@ -590,16 +610,35 @@ describe('AskAssistantChat', () => {
590610
);
591611
});
592612

593-
it('should show initial thinking-group when streaming with no tool messages', () => {
594-
renderWithMessages([], { streaming: true, loadingMessage: 'Thinking...' });
613+
it('should show ThinkingMessage with placeholder item when streaming with no tool messages', () => {
614+
renderWithMessages([], { streaming: true, loadingMessage: 'Thinking' });
595615

596-
// Should create an initial thinking-group
597-
expect(ThinkingMessageMock).toHaveBeenCalledTimes(1);
616+
// Should render ThinkingMessage with a single "Thinking" item
617+
expect(thinkingMessageCallCount).toBe(1);
598618

599-
const props = getThinkingMessageProps()[0];
619+
const props = getThinkingMessageProps();
600620
expect(props.items).toHaveLength(1);
601-
expect(props.items[0].displayTitle).toBe('Thinking...');
621+
expect(props.items[0].id).toBe('thinking-placeholder');
622+
expect(props.items[0].displayTitle).toBe('Thinking');
602623
expect(props.items[0].status).toBe('running');
624+
expect(props.latestStatusText).toBe('Thinking');
625+
expect(props.defaultExpanded).toBe(true);
626+
expect(props.isStreaming).toBe(true);
627+
});
628+
629+
it('should pass defaultExpanded as true to ThinkingMessage', () => {
630+
const message = createToolMessage({
631+
id: '1',
632+
displayTitle: 'Search Results',
633+
updates: [{ type: 'output', data: { result: 'Found items' } }],
634+
});
635+
636+
renderWithMessages([message]);
637+
638+
expect(thinkingMessageCallCount).toBe(1);
639+
640+
const props = getThinkingMessageProps();
641+
expect(props.defaultExpanded).toBe(true);
603642
});
604643
});
605644

packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue

Lines changed: 30 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { useI18n } from '../../composables/useI18n';
77
import type { ChatUI, RatingFeedback, WorkflowSuggestion } from '../../types/assistant';
88
import { isTaskAbortedMessage, isToolMessage, isThinkingGroupMessage } from '../../types/assistant';
99
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
10-
import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoadingMessage.vue';
1110
import AssistantText from '../AskAssistantText/AssistantText.vue';
1211
import InlineAskAssistantButton from '../InlineAskAssistantButton/InlineAskAssistantButton.vue';
1312
import N8nButton from '../N8nButton';
@@ -88,7 +87,11 @@ function filterOutHiddenMessages(messages: ChatUI.AssistantMessage[]): ChatUI.As
8887
8988
function groupToolMessagesIntoThinking(
9089
messages: ChatUI.AssistantMessage[],
91-
options: { streaming?: boolean; loadingMessage?: string } = {},
90+
options: {
91+
streaming?: boolean;
92+
loadingMessage?: string;
93+
t: (key: string) => string;
94+
},
9295
): ChatUI.AssistantMessage[] {
9396
const result: ChatUI.AssistantMessage[] = [];
9497
let i = 0;
@@ -145,7 +148,7 @@ function groupToolMessagesIntoThinking(
145148
}));
146149
147150
// If this is the last group, all tools completed, and we're still streaming,
148-
// add a "Thinking..." item to show the AI is processing
151+
// add a "Thinking" item to show the AI is processing
149152
if (isLastToolGroup && allToolsCompleted && options.streaming && options.loadingMessage) {
150153
items.push({
151154
id: 'thinking-item',
@@ -166,7 +169,7 @@ function groupToolMessagesIntoThinking(
166169
.split('_')
167170
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
168171
.join(' ') ||
169-
'Processing...';
172+
options.t('assistantChat.thinking.processing');
170173
} else if (
171174
isLastToolGroup &&
172175
allToolsCompleted &&
@@ -175,11 +178,12 @@ function groupToolMessagesIntoThinking(
175178
) {
176179
// Still streaming after tools completed - show thinking message
177180
latestStatus = options.loadingMessage;
181+
} else if (allToolsCompleted && !options.streaming) {
182+
latestStatus = options.t('assistantChat.thinking.workflowGenerated');
178183
} else if (allToolsCompleted) {
179-
// All tools completed and not streaming - show "Workflow generated"
180-
latestStatus = 'Workflow generated';
184+
latestStatus = options.loadingMessage ?? options.t('assistantChat.thinking.processing');
181185
} else {
182-
latestStatus = 'Processing...';
186+
latestStatus = options.t('assistantChat.thinking.thinking');
183187
}
184188
185189
// Create a ThinkingGroup message with deduplicated items
@@ -196,27 +200,6 @@ function groupToolMessagesIntoThinking(
196200
i = j;
197201
}
198202
199-
// If streaming with a loadingMessage but no thinking-group exists yet (no tool messages received),
200-
// create an initial thinking-group with just the "Thinking..." item
201-
// Use the same stable ID as tool-based thinking-groups so Vue preserves component state
202-
const hasThinkingGroup = result.some((msg) => msg.type === 'thinking-group');
203-
if (options.streaming && options.loadingMessage && !hasThinkingGroup) {
204-
const initialThinkingGroup: ChatUI.ThinkingGroupMessage = {
205-
id: 'thinking-group',
206-
role: 'assistant',
207-
type: 'thinking-group',
208-
items: [
209-
{
210-
id: 'thinking-item',
211-
displayTitle: options.loadingMessage,
212-
status: 'running',
213-
},
214-
],
215-
latestStatusText: options.loadingMessage,
216-
};
217-
result.push(initialThinkingGroup);
218-
}
219-
220203
return result;
221204
}
222205
@@ -226,6 +209,7 @@ const normalizedMessages = computed(() => {
226209
return groupToolMessagesIntoThinking(filterOutHiddenMessages(normalized), {
227210
streaming: props.streaming,
228211
loadingMessage: props.loadingMessage,
212+
t,
229213
});
230214
});
231215
@@ -243,7 +227,6 @@ const textInputValue = ref<string>('');
243227
const promptInputRef = ref<InstanceType<typeof N8nPromptInput>>();
244228
const scrollAreaRef = ref<InstanceType<typeof N8nScrollArea>>();
245229
246-
const messagesRef = ref<HTMLDivElement | null>(null);
247230
const inputWrapperRef = ref<HTMLDivElement | null>(null);
248231
249232
const sessionEnded = computed(() => {
@@ -262,12 +245,16 @@ const showSuggestions = computed(() => {
262245
return showPlaceholder.value && props.suggestions && props.suggestions.length > 0;
263246
});
264247
265-
// Check if we have any thinking group - hides the generic loading message when tool status is shown
266-
// The ThinkingMessage component handles displaying the current status with shimmer animation
248+
// Check if we have any thinking group (tool messages grouped into thinking blocks)
267249
const hasAnyThinkingGroup = computed(() => {
268250
return normalizedMessages.value.some((msg) => msg.type === 'thinking-group');
269251
});
270252
253+
// Show placeholder when streaming with loading message but no tool messages have arrived yet
254+
const showThinkingPlaceholder = computed(() => {
255+
return props.streaming && props.loadingMessage && !hasAnyThinkingGroup.value;
256+
});
257+
271258
const showBottomInput = computed(() => {
272259
// Hide bottom input when showing suggestions (blank state with suggestions)
273260
return !showSuggestions.value;
@@ -418,6 +405,7 @@ defineExpose({
418405
<ThinkingMessage
419406
v-if="isThinkingGroupMessage(message)"
420407
:items="message.items"
408+
:default-expanded="true"
421409
:latest-status-text="message.latestStatusText"
422410
:is-streaming="streaming"
423411
:class="getMessageStyles(message, i)"
@@ -476,16 +464,17 @@ defineExpose({
476464
</data>
477465
<slot name="messagesFooter" />
478466
</div>
479-
<div
480-
v-if="loadingMessage && !hasAnyThinkingGroup"
481-
:class="{
482-
[$style.message]: true,
483-
[$style.loading]: normalizedMessages?.length,
484-
[$style.lastToolMessage]: true,
485-
}"
486-
>
487-
<AssistantLoadingMessage :message="loadingMessage" />
488-
</div>
467+
<!-- Placeholder thinking message when there are no tools yet called -->
468+
<ThinkingMessage
469+
v-if="showThinkingPlaceholder"
470+
:items="[
471+
{ id: 'thinking-placeholder', displayTitle: loadingMessage!, status: 'running' },
472+
]"
473+
:latest-status-text="loadingMessage!"
474+
:default-expanded="true"
475+
:is-streaming="true"
476+
:class="$style.lastToolMessage"
477+
/>
489478
</div>
490479
</N8nScrollArea>
491480
</div>

0 commit comments

Comments
 (0)