feat(editor): Collapse AI Assistant thinking traces into thinking blocks (#34274)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
oleg
2026-07-16 08:56:19 +00:00
committed by GitHub
parent 0a4dddeb29
commit 97446258af
32 changed files with 1678 additions and 883 deletions
@@ -470,6 +470,90 @@ describe('agent-run-reducer', () => {
expect(state.toolCallsById['tc-skill'].renderHint).toBe('skill');
});
it('tool-input-start announces a pending tool call before its args stream', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(state, {
type: 'tool-input-start',
runId: 'run-1',
agentId: 'root',
responseId: 'resp-1',
payload: { toolCallId: 'tc-1', toolName: 'build-workflow' },
});
const tc = state.toolCallsById['tc-1'];
expect(tc).toBeDefined();
expect(tc.args).toEqual({});
expect(tc.isLoading).toBe(true);
expect(tc.renderHint).toBe('builder');
expect(state.agentsById['root'].timeline).toContainEqual({
type: 'tool-call',
toolCallId: 'tc-1',
responseId: 'resp-1',
});
});
it('tool-call fills args on an announced call without duplicating it', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(state, {
type: 'tool-input-start',
runId: 'run-1',
agentId: 'root',
payload: { toolCallId: 'tc-1', toolName: 'build-workflow' },
});
reduceEvent(state, {
type: 'tool-call',
runId: 'run-1',
agentId: 'root',
payload: { toolCallId: 'tc-1', toolName: 'build-workflow', args: { filePath: 'a.ts' } },
});
expect(state.toolCallsById['tc-1'].args).toEqual({ filePath: 'a.ts' });
expect(state.agentsById['root'].toolCalls).toHaveLength(1);
expect(state.agentsById['root'].timeline.filter((e) => e.type === 'tool-call')).toHaveLength(
1,
);
});
it('duplicate tool-input-start events are idempotent', () => {
const state = stateWithRun('run-1', 'root');
const event = {
type: 'tool-input-start',
runId: 'run-1',
agentId: 'root',
payload: { toolCallId: 'tc-1', toolName: 'some-tool' },
} as const;
reduceEvent(state, event);
reduceEvent(state, event);
expect(state.agentsById['root'].toolCalls).toHaveLength(1);
expect(state.agentsById['root'].timeline.filter((e) => e.type === 'tool-call')).toHaveLength(
1,
);
});
it('uses the event publish timestamp for tool timing when present', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(state, {
type: 'tool-call',
runId: 'run-1',
agentId: 'root',
ts: Date.parse('2026-01-01T00:00:00Z'),
payload: { toolCallId: 'tc-1', toolName: 'some-tool', args: {} },
});
reduceEvent(state, {
type: 'tool-result',
runId: 'run-1',
agentId: 'root',
ts: Date.parse('2026-01-01T00:01:30Z'),
payload: { toolCallId: 'tc-1', result: {} },
});
// Replayed events must reconstruct the original timing, not "now".
const tc = state.toolCallsById['tc-1'];
expect(tc.startedAt).toBe('2026-01-01T00:00:00.000Z');
expect(tc.completedAt).toBe('2026-01-01T00:01:30.000Z');
});
it('tool-result resolves tool call', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'some-tool'));
@@ -110,6 +110,13 @@ function ensureAgent(state: AgentRunState, agentId: string): InstanceAiAgentNode
return state.agentsById[agentId];
}
/** When the event was published — falls back to "now" for live events and
* old persisted events that predate the `ts` envelope field. Replays must
* use the publish time or tool durations collapse to processing time. */
function eventTimestamp(event: { ts?: number }): string {
return (event.ts !== undefined ? new Date(event.ts) : new Date()).toISOString();
}
/** Append text to timeline — merges consecutive text entries within the same responseId. */
function appendTimelineText(
timeline: InstanceAiTimelineEntry[],
@@ -241,6 +248,32 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
break;
}
case 'tool-input-start': {
// Announces a tool call whose arguments are still streaming — surfaces
// the pending call immediately; `tool-call` fills the args in later.
if (!isSafeObjectKey(event.payload.toolCallId)) break;
if (state.toolCallsById[event.payload.toolCallId]) break;
const agent = ensureAgent(state, event.agentId);
if (agent) {
const tc: InstanceAiToolCallState = {
toolCallId: event.payload.toolCallId,
toolName: event.payload.toolName,
args: {},
isLoading: true,
renderHint: getRenderHint(event.payload.toolName),
startedAt: eventTimestamp(event),
};
state.toolCallsById[event.payload.toolCallId] = tc;
agent.toolCalls.push(tc);
agent.timeline.push({
type: 'tool-call',
toolCallId: event.payload.toolCallId,
...(event.responseId ? { responseId: event.responseId } : {}),
});
}
break;
}
case 'text-block': {
// Coalesced segment from the durable log (replay path). When the last
// timeline entry is this segment's streamed deltas (mid-block reconnect:
@@ -304,6 +337,13 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
case 'tool-call': {
if (!isSafeObjectKey(event.payload.toolCallId)) break;
// Announced by a preceding tool-input-start: fill in the streamed args
// on the existing entry instead of duplicating it.
const announced = state.toolCallsById[event.payload.toolCallId];
if (announced) {
announced.args = event.payload.args;
break;
}
const agent = ensureAgent(state, event.agentId);
if (agent) {
const tc: InstanceAiToolCallState = {
@@ -312,7 +352,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
args: event.payload.args,
isLoading: true,
renderHint: getRenderHint(event.payload.toolName),
startedAt: new Date().toISOString(),
startedAt: eventTimestamp(event),
};
state.toolCallsById[event.payload.toolCallId] = tc;
agent.toolCalls.push(tc);
@@ -331,7 +371,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
if (tc) {
tc.result = event.payload.result;
tc.isLoading = false;
tc.completedAt = new Date().toISOString();
tc.completedAt = eventTimestamp(event);
}
break;
}
@@ -342,7 +382,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
if (tc) {
tc.error = event.payload.error;
tc.isLoading = false;
tc.completedAt = new Date().toISOString();
tc.completedAt = eventTimestamp(event);
}
break;
}
@@ -355,7 +395,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
if (tc) {
tc.error = event.payload.error;
tc.isLoading = false;
tc.completedAt = new Date().toISOString();
tc.completedAt = eventTimestamp(event);
}
break;
}
@@ -104,6 +104,7 @@ export const instanceAiEventTypeSchema = z.enum([
'reasoning-delta',
'text-block',
'reasoning-block',
'tool-input-start',
'tool-call',
'tool-result',
'tool-error',
@@ -263,6 +264,14 @@ export const toolCallPayloadSchema = z.object({
args: z.record(z.unknown()),
});
/** Emitted when a tool call's arguments BEGIN streaming — args arrive later
* via the `tool-call` event. Lets the UI surface the pending call while
* large arguments (e.g. generated workflow code) are still streaming. */
export const toolInputStartPayloadSchema = z.object({
toolCallId: z.string(),
toolName: z.string(),
});
export const toolResultPayloadSchema = z.object({
toolCallId: z.string(),
result: z.unknown(),
@@ -734,6 +743,9 @@ const eventBase = {
userId: z.string().optional(),
/** Anthropic API response ID (msg_01...) — groups events from the same LLM response. */
responseId: z.string().optional(),
/** Epoch ms stamped once at publish — replays (SSE reconnect, snapshot
* rebuilds) use it to reconstruct real timing instead of "now". */
ts: z.number().optional(),
};
export const instanceAiEventSchema = z.discriminatedUnion('type', [
@@ -751,6 +763,11 @@ export const instanceAiEventSchema = z.discriminatedUnion('type', [
...eventBase,
payload: reasoningDeltaPayloadSchema,
}),
z.object({
type: z.literal('tool-input-start'),
...eventBase,
payload: toolInputStartPayloadSchema,
}),
// Coalesced full text/reasoning of one streamed segment, produced by the
// durable event log (deltas are live-only and never persisted). On replay the
// reducer REPLACES the segment's streamed deltas, so a client that reconnects
@@ -805,6 +822,7 @@ export type InstanceAiAgentSpawnedEvent = Extract<InstanceAiEvent, { type: 'agen
export type InstanceAiAgentCompletedEvent = Extract<InstanceAiEvent, { type: 'agent-completed' }>;
export type InstanceAiTextDeltaEvent = Extract<InstanceAiEvent, { type: 'text-delta' }>;
export type InstanceAiReasoningDeltaEvent = Extract<InstanceAiEvent, { type: 'reasoning-delta' }>;
export type InstanceAiToolInputStartEvent = Extract<InstanceAiEvent, { type: 'tool-input-start' }>;
export type InstanceAiToolCallEvent = Extract<InstanceAiEvent, { type: 'tool-call' }>;
export type InstanceAiToolResultEvent = Extract<InstanceAiEvent, { type: 'tool-result' }>;
export type InstanceAiToolErrorEvent = Extract<InstanceAiEvent, { type: 'tool-error' }>;
@@ -65,6 +65,7 @@ Every event follows this schema:
type: string; // event type
runId: string; // correlates all events in a single message → response cycle
agentId: string; // agent this event is attributed to in the UI
ts?: number; // epoch ms stamped at publish — replays reconstruct real timing from it
payload?: object; // event-specific data
}
```
@@ -123,9 +124,32 @@ decision-making and supports faster iteration.
reasoning tokens; when a model doesn't support it, no `reasoning-delta` events
are sent. The frontend should handle the absence gracefully.
### `tool-input-start`
A tool call's arguments have started streaming from the model. Sent before
`tool-call` — for tools with large arguments (e.g. `build-workflow` streaming
generated workflow code) this can precede the full `tool-call` event by a
long time, so the frontend can surface the pending call immediately.
```json
{
"type": "tool-input-start",
"runId": "run_abc123",
"agentId": "agent-001",
"payload": {
"toolCallId": "tc_abc123",
"toolName": "build-workflow"
}
}
```
The frontend adds a pending entry to the agent's `toolCalls` with empty `args`
and `isLoading: true`; the subsequent `tool-call` event fills in the args.
### `tool-call`
An agent is invoking a tool. Sent before the tool executes.
An agent is invoking a tool. Sent when the tool's arguments are complete,
before the tool executes.
```json
{
@@ -9,6 +9,7 @@ export function createInMemoryEventBus(): InstanceAiEventBus {
return {
publish(threadId, event) {
const list = storeByThread.get(threadId) ?? [];
if (event.ts === undefined) event = { ...event, ts: Date.now() };
const stored: StoredEvent = { id: list.length + 1, event };
list.push(stored);
storeByThread.set(threadId, list);
@@ -99,6 +99,7 @@ const agentStreamChunkTypes = new Set<string>([
'finish',
'text-delta',
'reasoning-delta',
'tool-input-start',
'tool-call',
'tool-result',
'error',
@@ -458,6 +459,14 @@ export function mapAgentChunkToEvent(
return { type: 'text-delta', ...base, payload: { text: chunk.delta } };
case 'reasoning-delta':
return { type: 'reasoning-delta', ...base, payload: { text: chunk.delta } };
case 'tool-input-start':
// Surfaces the pending tool call while its arguments stream — the
// full `tool-call` event follows once the args are complete.
return {
type: 'tool-input-start',
...base,
payload: { toolCallId: chunk.toolCallId, toolName: chunk.toolName },
};
case 'tool-call':
return mapToolCallChunk(chunk, base);
case 'tool-result':
@@ -267,7 +267,9 @@ describe('parseStoredMessages', () => {
const result = parseStoredMessages(messages);
expect(result[1].content).toBe('Hello');
expect(result[1].agentTree?.timeline).toEqual([{ type: 'text', content: 'Hello' }]);
expect(result[1].agentTree?.timeline).toEqual([
{ type: 'text', content: 'Hello', responseId: 'msg-a' },
]);
});
it('should parse reasoning from native parts', () => {
@@ -295,8 +297,8 @@ describe('parseStoredMessages', () => {
expect(result[1].content).toBe('Answer');
// Reasoning keeps its chronological slot in the timeline
expect(result[1].agentTree?.timeline).toEqual([
{ type: 'reasoning', content: 'Reasoning part' },
{ type: 'text', content: 'Answer' },
{ type: 'reasoning', content: 'Reasoning part', responseId: 'msg-a' },
{ type: 'text', content: 'Answer', responseId: 'msg-a' },
]);
});
@@ -321,7 +323,102 @@ describe('parseStoredMessages', () => {
expect(result[1].reasoning).toBe('Just reasoning');
expect(result[1].content).toBe('');
expect(result[1].agentTree?.timeline).toEqual([
{ type: 'reasoning', content: 'Just reasoning' },
{ type: 'reasoning', content: 'Just reasoning', responseId: 'msg-a' },
]);
});
it('should bracket reconstructed tool calls with adjacent message timestamps', () => {
// Snapshot-less reloads have no real per-call timestamps; the interval
// between stored rows approximates them so thinking blocks can still
// derive a "Thought for Xs" duration.
const messages: StoredAgentMessage[] = [
{
id: 'msg-u',
role: 'user',
content: 'Build it',
createdAt: makeDate(),
},
{
id: 'msg-a1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-done',
toolName: 'search-nodes',
input: {},
state: 'resolved',
output: {},
},
{
type: 'tool-call',
toolCallId: 'tc-pending',
toolName: 'build-workflow',
input: {},
state: 'pending',
},
],
createdAt: makeDate(12_000),
},
];
const result = parseStoredMessages(messages);
const toolCalls = result[1].agentTree?.toolCalls ?? [];
expect(toolCalls[0]).toMatchObject({
toolCallId: 'tc-done',
startedAt: makeDate().toISOString(),
completedAt: makeDate(12_000).toISOString(),
});
// An unresolved call gets no completedAt — it never finished.
expect(toolCalls[1].startedAt).toBe(makeDate().toISOString());
expect(toolCalls[1].completedAt).toBeUndefined();
});
it('should group each assistant row under its own synthetic responseId', () => {
// One stored assistant row = one LLM response. The synthetic per-row
// responseId lets the frontend fold narration (text followed by trace
// content in the same response) into thinking blocks after a reload
// where no snapshot survived.
const messages: StoredAgentMessage[] = [
{
id: 'msg-u',
role: 'user',
content: 'Build it',
createdAt: makeDate(),
},
{
id: 'msg-a1',
role: 'assistant',
content: [
{ type: 'text', text: 'Checking the nodes first.' },
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: 'search-nodes',
input: {},
state: 'resolved',
output: {},
},
],
createdAt: makeDate(1),
},
{
id: 'msg-a2',
role: 'assistant',
content: [{ type: 'text', text: 'Done — here is the workflow.' }],
createdAt: makeDate(2),
},
];
const result = parseStoredMessages(messages);
expect(result[1].agentTree?.timeline).toEqual([
{ type: 'text', content: 'Checking the nodes first.', responseId: 'msg-a1' },
{ type: 'tool-call', toolCallId: 'tc-1', responseId: 'msg-a1' },
]);
expect(result[2].agentTree?.timeline).toEqual([
{ type: 'text', content: 'Done — here is the workflow.', responseId: 'msg-a2' },
]);
});
@@ -427,7 +427,11 @@ describe('InProcessEventBus', () => {
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'relay-instance-ai-event',
payload: { threadId: 'thread-1', storedEvent: { id: 1, event } },
payload: {
threadId: 'thread-1',
// publish() stamps the publish time onto the event
storedEvent: { id: 1, event: { ...event, ts: expect.any(Number) } },
},
});
});
@@ -534,7 +538,8 @@ describe('InProcessEventBus', () => {
bus.publish(threadId, event);
const call = eventLog.publish.mock.calls.at(-1)!;
expect(call[0]).toBe(threadId);
expect(call[1]).toBe(event);
// publish() stamps `ts` onto a copy before handing it to the log
expect(call[1]).toEqual({ ...event, ts: expect.any(Number) });
return call[2] as EmitFn;
}
@@ -102,6 +102,13 @@ export class InProcessEventBus implements InstanceAiEventBus {
* local SSE subscribers, and relayed to sibling mains with its id.
*/
publish(threadId: string, event: InstanceAiEvent): void {
// Stamp publish time once — replays (SSE reconnect, snapshot rebuilds)
// rely on it to reconstruct real timing instead of processing time.
// Before the durable-log branch on purpose: persisted events must carry
// it too.
if (event.ts === undefined) {
event = { ...event, ts: Date.now() };
}
if (this.durableLogEnabled) {
this.eventLog.publish(threadId, event, (drained) => this.onDrained(threadId, drained));
return;
@@ -162,7 +162,24 @@ function extractToolInvocations(content: unknown): StoredToolInvocation[] {
return [];
}
function buildToolCallState(invocation: StoredToolInvocation): InstanceAiToolCallState {
/**
* Coarse per-row timing for reconstructed tool calls: the interval from the
* previous stored message to this one brackets everything the response did.
* Real per-call timestamps only exist in run snapshots; this approximation
* lets a snapshot-less reload still show "Thought for Xs" (derived from
* min-start/max-end across a thinking block) instead of a bare fallback.
* Known coarseness: every call in a row shares the bracket, and an HITL pause
* between rows counts as thinking time.
*/
interface RowTiming {
startedAt: string;
completedAt: string;
}
function buildToolCallState(
invocation: StoredToolInvocation,
timing?: RowTiming,
): InstanceAiToolCallState {
const isCompleted = invocation.state === 'result';
return {
toolCallId: invocation.toolCallId,
@@ -172,6 +189,8 @@ function buildToolCallState(invocation: StoredToolInvocation): InstanceAiToolCal
error: isCompleted ? invocation.error : undefined,
isLoading: !isCompleted,
renderHint: getRenderHint(invocation.toolName),
...(timing ? { startedAt: timing.startedAt } : {}),
...(timing && isCompleted ? { completedAt: timing.completedAt } : {}),
};
}
@@ -179,23 +198,32 @@ function buildToolCallState(invocation: StoredToolInvocation): InstanceAiToolCal
* Build a chronological timeline from native parts (preserves reasoning vs
* tool-call vs text ordering). Falls back to a reasoning-first,
* tool-calls-next heuristic when parts aren't available.
*
* `responseId` is a synthetic per-message id: each stored assistant row is one
* LLM response, and the frontend needs response grouping to tell intermediate
* narration (trace content follows in the same response) from final answers.
* Without it, a reconstructed timeline renders every narration text outside
* the thinking blocks, splitting them.
*/
function buildTimeline(
textContent: string,
reasoning: string,
toolCalls: InstanceAiToolCallState[],
parts?: StoredContentPart[],
responseId?: string,
): InstanceAiTimelineEntry[] {
const responseRef = responseId ? { responseId } : {};
// If parts are available, use their ordering (chronologically accurate)
if (parts?.length) {
const timeline: InstanceAiTimelineEntry[] = [];
for (const part of parts) {
if (part.type === 'text' && part.text) {
timeline.push({ type: 'text', content: part.text });
timeline.push({ type: 'text', content: part.text, ...responseRef });
} else if (part.type === 'reasoning' && part.text) {
timeline.push({ type: 'reasoning', content: part.text });
timeline.push({ type: 'reasoning', content: part.text, ...responseRef });
} else if (part.type === 'tool-call' && part.toolCallId) {
timeline.push({ type: 'tool-call', toolCallId: part.toolCallId });
timeline.push({ type: 'tool-call', toolCallId: part.toolCallId, ...responseRef });
}
}
return timeline;
@@ -205,13 +233,13 @@ function buildTimeline(
// (most common agent pattern)
const timeline: InstanceAiTimelineEntry[] = [];
if (reasoning) {
timeline.push({ type: 'reasoning', content: reasoning });
timeline.push({ type: 'reasoning', content: reasoning, ...responseRef });
}
for (const tc of toolCalls) {
timeline.push({ type: 'tool-call', toolCallId: tc.toolCallId });
timeline.push({ type: 'tool-call', toolCallId: tc.toolCallId, ...responseRef });
}
if (textContent) {
timeline.push({ type: 'text', content: textContent });
timeline.push({ type: 'text', content: textContent, ...responseRef });
}
return timeline;
}
@@ -233,6 +261,7 @@ function buildFlatAgentTree(
toolCalls: InstanceAiToolCallState[],
parts?: StoredContentPart[],
status: InstanceAiAgentNode['status'] = 'completed',
responseId?: string,
): InstanceAiAgentNode {
const resolvedStatus = status === 'active' ? 'completed' : status;
const settledToolCalls =
@@ -247,7 +276,7 @@ function buildFlatAgentTree(
reasoning,
toolCalls: settledToolCalls,
children: [],
timeline: buildTimeline(textContent, reasoning, settledToolCalls, parts),
timeline: buildTimeline(textContent, reasoning, settledToolCalls, parts, responseId),
};
}
@@ -444,7 +473,14 @@ export function parseStoredMessages(
if (msg.role === 'assistant') {
const reasoning = extractReasoningFromContent(msg.content);
const invocations = extractToolInvocations(msg.content);
const toolCalls = invocations.map(buildToolCallState);
const prevMessage = messageIndex > 0 ? conversationMessages[messageIndex - 1] : undefined;
const timing: RowTiming | undefined = prevMessage
? {
startedAt: prevMessage.createdAt.toISOString(),
completedAt: msg.createdAt.toISOString(),
}
: undefined;
const toolCalls = invocations.map((invocation) => buildToolCallState(invocation, timing));
const parts = extractParts(msg.content);
const snapshot = takeSnapshotForAssistant(msg, messageIndex);
@@ -452,9 +488,21 @@ export function parseStoredMessages(
// Use the native runId from the snapshot (matches SSE events),
// falling back to the user-message ID if no snapshot exists.
const runId = snapshot?.runId ?? lastUserMessageId ?? msg.id;
// The message id doubles as the synthetic responseId: one stored
// assistant row = one LLM response, and unlike `runId` (which falls
// back to the user-message id shared by the whole turn) it is unique
// per row, so response grouping survives the flat-tree merge.
const messageFlatTree =
toolCalls.length > 0 || text || reasoning
? buildFlatAgentTree(runId, text, reasoning, toolCalls, parts, snapshot?.tree.status)
? buildFlatAgentTree(
runId,
text,
reasoning,
toolCalls,
parts,
snapshot?.tree.status,
msg.id,
)
: undefined;
// Carry the cancellation cause onto the fallback tree so a stopped run is
// still attributable (user/timeout/shutdown) after the snapshot was lost.
@@ -1,7 +1,6 @@
import type { StoryFn } from '@storybook/vue3-vite';
import { defineComponent } from 'vue';
import ReasoningBlock from '../../../../../editor-ui/src/features/ai/instanceAi/components/ReasoningBlock.vue';
import N8nAiActivityStep from '../N8nAiActivityStep';
import N8nAiActivityStepButton from '../N8nAiActivityStepButton';
import N8nAiActivityStepChevron from '../N8nAiActivityStepChevron';
@@ -134,7 +133,6 @@ const storyComponents = {
N8nAiActivityStepResultSection,
ToolResultJson: StoryToolResultJson,
ToolResultRenderer: StoryToolResultRenderer,
ReasoningBlock,
};
const slotStyles = '';
@@ -170,15 +168,17 @@ export const Group: StoryFn = () => ({
const Template: StoryFn = (args) => ({
components: { N8nAiActivityStep, ...storyComponents },
setup: () => ({ args }),
setup: () => ({ args, jsonTextStyle }),
template: `
<style>${slotStyles}</style>
<div style="max-width: 720px; padding: var(--spacing--m);">
<reasoning-block
<n8n-ai-activity-step
v-if="args.type === 'reasoning'"
:entry="{ content: args.result.content }"
:streaming="args.loading"
/>
:label="args.label"
:loading="args.loading"
>
<pre :style="jsonTextStyle">{{ args.result.content }}</pre>
</n8n-ai-activity-step>
<n8n-ai-activity-step v-else v-bind="args">
<tool-result-renderer
:result="args.result"
@@ -6146,7 +6146,6 @@
"instanceAi.input.placeholder": "Ask anything...",
"instanceAi.input.suspendedPlaceholder": "Complete or skip the setup above to continue",
"instanceAi.input.workflowBuilderUnavailablePlaceholder": "Workflow builder unavailable",
"instanceAi.statusBar.waitingForInput": "Waiting for your input",
"instanceAi.input.send": "Send",
"instanceAi.input.stop": "Stop",
"instanceAi.launch.template.message": "I want to start from the workflow template '{name}' (template id: {id}). Load it and give me a short, plain summary: what triggers it, what it does, and which apps it uses. Keep it to a few sentences. Then ask me a few questions specific to this template's steps and apps so I can adapt it, like which of its apps to swap, what to connect, or what behavior to change. Make each question quick to answer: give me numbered choices or ask for a short reply. Don't build anything until I answer.",
@@ -6305,6 +6304,7 @@
"instanceAi.workflowSetup.triggerError": "Trigger test failed",
"instanceAi.workflowSetup.triggerListening": "Listening for events…",
"instanceAi.workflowSetup.apply": "Apply",
"instanceAi.workflowSetup.configureNode": "Configure '{name}'",
"instanceAi.workflowSetup.credentialTestFailedTooltip": "Credential test failed. Update the credential and try again.",
"instanceAi.workflowSetup.stepIncompleteTooltip": "To continue, complete the required fields above or skip setup for now",
"instanceAi.workflowSetup.applyCompleted": "Apply completed",
@@ -6728,11 +6728,11 @@
"instanceAi.stepTimeline.hideBrief": "Hide brief",
"instanceAi.stepTimeline.done": "Done",
"instanceAi.stepTimeline.craftingWorkflow": "Crafting workflow",
"instanceAi.activitySummary.reasoning": "Reasoning",
"instanceAi.activitySummary.toolCalls": "{count} tool calls",
"instanceAi.activitySummary.messages": "{count} messages",
"instanceAi.activitySummary.questions": "{count} answered questions",
"instanceAi.activitySummary.subagents": "{count} subagents",
"instanceAi.thinking.active": "Thinking",
"instanceAi.thinking.done": "Thought for {duration}",
"instanceAi.thinking.doneFallback": "Finished thinking",
"instanceAi.thinking.waitingForInput": "Waiting for your input",
"projects.menu.instanceAi": "AI Assistant",
"chatInputBase.button.attach": "Attach files",
"chatInputBase.button.dictate": "Dictate",
@@ -0,0 +1,354 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { fireEvent } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createThreadComponentRenderer } from './createThreadComponentRenderer';
import { createTestingPinia } from '@pinia/testing';
import ThinkingBlock from '../components/ThinkingBlock.vue';
import type {
InstanceAiAgentNode,
InstanceAiTimelineEntry,
InstanceAiToolCallState,
} from '@n8n/api-types';
const renderComponent = createThreadComponentRenderer(ThinkingBlock, {
global: {
stubs: {
InstanceAiMarkdown: { template: '<span>{{ content }}</span>', props: ['content'] },
ToolResultJson: true,
ToolResultRenderer: true,
},
},
});
function makeToolCall(overrides: Partial<InstanceAiToolCallState> = {}): InstanceAiToolCallState {
return {
toolCallId: 'tc-1',
toolName: 'search-nodes',
args: {},
isLoading: false,
...overrides,
};
}
function makeAgentNode(overrides: Partial<InstanceAiAgentNode> = {}): InstanceAiAgentNode {
return {
agentId: 'agent-1',
role: 'orchestrator',
status: 'completed',
textContent: '',
reasoning: '',
toolCalls: [],
children: [],
timeline: [],
...overrides,
};
}
const reasoning = (content: string): InstanceAiTimelineEntry => ({ type: 'reasoning', content });
const text = (content: string): InstanceAiTimelineEntry => ({ type: 'text', content });
const toolEntry = (toolCallId: string): InstanceAiTimelineEntry => ({
type: 'tool-call',
toolCallId,
});
describe('ThinkingBlock', () => {
beforeEach(() => {
createTestingPinia({ stubActions: false });
});
it('should show duration from tool-call timestamps when settled', () => {
const tc = makeToolCall({
startedAt: '2026-01-01T00:00:00Z',
completedAt: '2026-01-01T00:00:12Z',
});
const { getByTestId } = renderComponent({
props: {
agentNode: makeAgentNode({ toolCalls: [tc] }),
entries: [reasoning('hmm'), toolEntry('tc-1')],
active: false,
},
});
expect(getByTestId('thinking-block-header')).toHaveTextContent('Thought for 12s');
});
it('should format durations over a minute', () => {
const tc = makeToolCall({
startedAt: '2026-01-01T00:00:00Z',
completedAt: '2026-01-01T00:01:05Z',
});
const { getByTestId } = renderComponent({
props: {
agentNode: makeAgentNode({ toolCalls: [tc] }),
entries: [toolEntry('tc-1')],
active: false,
},
});
expect(getByTestId('thinking-block-header')).toHaveTextContent('Thought for 1m 5s');
});
it('should fall back to a static title when no timestamps exist', () => {
const { getByTestId } = renderComponent({
props: {
agentNode: makeAgentNode(),
entries: [reasoning('only reasoning, no tools')],
active: false,
},
});
expect(getByTestId('thinking-block-header')).toHaveTextContent('Finished thinking');
});
it('should be collapsed by default when settled', () => {
const { getByTestId } = renderComponent({
props: {
agentNode: makeAgentNode(),
entries: [reasoning('hmm')],
active: false,
},
});
expect(getByTestId('thinking-block-header').getAttribute('aria-expanded')).toBe('false');
});
it('should stay collapsed while active and stream the latest segment first sentence', () => {
const { getByTestId } = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active' }),
entries: [
reasoning('Reasoning start. More reasoning.'),
text('Checking the schema first. Then building.'),
],
active: true,
},
});
const header = getByTestId('thinking-block-header');
expect(header.getAttribute('aria-expanded')).toBe('false');
expect(header).toHaveTextContent('Checking the schema first.');
});
it('should keep the previous status line while a new segment is still blank', () => {
const { getByTestId } = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active' }),
entries: [
text('Checking the schema first. Then building.'),
reasoning('\n\n'), // reasoning streams often open with whitespace
],
active: true,
},
});
expect(getByTestId('thinking-block-header')).toHaveTextContent('Checking the schema first.');
});
it('should replace the status line when a new trace segment starts', () => {
const { getByTestId } = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active' }),
entries: [
text('Checking the schema first. Then building.'),
reasoning('The classifier needs rewiring. Fixing it.'),
],
active: true,
},
});
expect(getByTestId('thinking-block-header')).toHaveTextContent(
'The classifier needs rewiring.',
);
});
it('should show the reasoning first sentence before any narration exists', () => {
const { getByTestId } = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active' }),
entries: [reasoning('The user wants a Gmail workflow. Let me check the nodes.')],
active: true,
},
});
expect(getByTestId('thinking-block-header')).toHaveTextContent(
'The user wants a Gmail workflow.',
);
});
it('should keep the status line unchanged when expanded mid-stream', async () => {
const { getByTestId } = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active' }),
entries: [reasoning('Some reasoning. More.')],
active: true,
},
});
const header = getByTestId('thinking-block-header');
await userEvent.click(header);
expect(header.getAttribute('aria-expanded')).toBe('true');
expect(header).toHaveTextContent('Some reasoning.');
});
it('should title the block "Waiting for your input" while paused on a confirmation', () => {
const { getByTestId } = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active' }),
entries: [reasoning('Some reasoning. More.')],
active: true,
awaitingInput: true,
},
});
expect(getByTestId('thinking-block-header')).toHaveTextContent('Waiting for your input');
});
it('should show the latest tool call on the subline while it is the tail entry', () => {
const tc = makeToolCall({ toolCallId: 'tc-1', toolName: 'search-nodes', isLoading: true });
const { getByTestId } = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active', toolCalls: [tc] }),
entries: [reasoning('Looking for nodes. Now searching.'), toolEntry('tc-1')],
active: true,
},
});
expect(getByTestId('thinking-block-subline')).toBeInTheDocument();
expect(getByTestId('thinking-block-subline')).not.toHaveTextContent('Thinking');
});
it('should show "Thinking" on the subline when no tool call is the tail', () => {
// Covers the reasoning-only phases (run start, between tool calls) —
// the block must always carry a live signal while active.
const tc = makeToolCall({ toolCallId: 'tc-1' });
const noTools = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active' }),
entries: [reasoning('Figuring out the trigger. Hmm.')],
active: true,
},
});
expect(noTools.getByTestId('thinking-block-subline')).toHaveTextContent('Thinking');
noTools.unmount();
const toolThenReasoning = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active', toolCalls: [tc] }),
entries: [toolEntry('tc-1'), reasoning('Got the results. Next step.')],
active: true,
},
});
expect(toolThenReasoning.getByTestId('thinking-block-subline')).toHaveTextContent('Thinking');
});
it('should show the elapsed in the header while expanded', async () => {
vi.useFakeTimers();
try {
const { getByTestId } = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active' }),
entries: [reasoning('Figuring out the trigger. Hmm.')],
active: true,
},
});
await vi.advanceTimersByTimeAsync(5000);
const header = getByTestId('thinking-block-header');
await fireEvent.click(header);
// The subline is hidden while expanded — the timer moves to the header.
expect(header).toHaveTextContent('5s');
} finally {
vi.useRealTimers();
}
});
it('should reset the subline timer after an HITL pause', async () => {
vi.useFakeTimers();
try {
const { getByTestId, rerender } = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active' }),
entries: [reasoning('Figuring out the trigger. Hmm.')],
active: true,
},
});
await vi.advanceTimersByTimeAsync(5000);
expect(getByTestId('thinking-block-subline')).toHaveTextContent('5s');
// Waiting for the user isn't thinking time — resume restarts at zero.
await rerender({ awaitingInput: true });
await rerender({ awaitingInput: false });
await vi.advanceTimersByTimeAsync(1000);
expect(getByTestId('thinking-block-subline')).toHaveTextContent('1s');
expect(getByTestId('thinking-block-subline')).not.toHaveTextContent('6s');
} finally {
vi.useRealTimers();
}
});
it('should hide the subline when the block settles or pauses for input', () => {
const tc = makeToolCall({ toolCallId: 'tc-1' });
const settled = renderComponent({
props: {
agentNode: makeAgentNode({ toolCalls: [tc] }),
entries: [toolEntry('tc-1')],
active: false,
},
});
expect(settled.queryByTestId('thinking-block-subline')).not.toBeInTheDocument();
settled.unmount();
const paused = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active', toolCalls: [tc] }),
entries: [toolEntry('tc-1')],
active: true,
awaitingInput: true,
},
});
expect(paused.queryByTestId('thinking-block-subline')).not.toBeInTheDocument();
});
it('should collapse a user-expanded block and settle the title when streaming ends', async () => {
const tc = makeToolCall({
startedAt: '2026-01-01T00:00:00Z',
completedAt: '2026-01-01T00:00:03Z',
});
const { getByTestId, rerender } = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active', toolCalls: [tc] }),
entries: [reasoning('hmm'), toolEntry('tc-1')],
active: true,
},
});
await userEvent.click(getByTestId('thinking-block-header'));
await rerender({ active: false });
const header = getByTestId('thinking-block-header');
expect(header.getAttribute('aria-expanded')).toBe('false');
expect(header).toHaveTextContent('Thought for 3s');
});
it('should render narration, reasoning, and tool rows inside the rail when expanded', async () => {
const tc = makeToolCall({ toolCallId: 'tc-1', toolName: 'credentials' });
const { getByTestId, getAllByText, getByText } = renderComponent({
props: {
agentNode: makeAgentNode({ status: 'active', toolCalls: [tc] }),
entries: [reasoning('deep thoughts'), text('Checking credentials now.'), toolEntry('tc-1')],
active: true,
},
});
await userEvent.click(getByTestId('thinking-block-header'));
// Appears twice: as the header status line and as the narration paragraph
expect(getAllByText('Checking credentials now.')).toHaveLength(2);
// Reasoning rows are labeled with their own first sentence
expect(getByText('deep thoughts')).toBeInTheDocument();
});
});
@@ -1,9 +1,13 @@
import { describe, test, expect } from 'vitest';
import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types';
import type {
InstanceAiAgentNode,
InstanceAiTimelineEntry,
InstanceAiToolCallState,
} from '@n8n/api-types';
import {
buildTimelineBlocks,
extractArtifacts,
isStreamingTimelineEntry,
isVisibleTimelineEntry,
} from '../agentTimeline.utils';
function makeToolCall(overrides: Partial<InstanceAiToolCallState>): InstanceAiToolCallState {
@@ -263,109 +267,260 @@ describe('extractArtifacts', () => {
});
});
describe('isVisibleTimelineEntry', () => {
const toolCallEntry = { type: 'tool-call' as const, toolCallId: 'tc-1' };
describe('buildTimelineBlocks', () => {
const reasoning = (responseId?: string): InstanceAiTimelineEntry => ({
type: 'reasoning',
content: 'thinking...',
responseId,
});
const text = (content: string, responseId?: string): InstanceAiTimelineEntry => ({
type: 'text',
content,
responseId,
});
const toolEntry = (toolCallId: string, responseId?: string): InstanceAiTimelineEntry => ({
type: 'tool-call',
toolCallId,
responseId,
});
function visibilityOf(tc: InstanceAiToolCallState): boolean {
return isVisibleTimelineEntry(toolCallEntry, { [tc.toolCallId]: tc }, {});
function blocksOf(
entries: InstanceAiTimelineEntry[],
toolCalls: InstanceAiToolCallState[] = [],
status: InstanceAiAgentNode['status'] = 'completed',
children: InstanceAiAgentNode[] = [],
) {
const toolCallsById = Object.fromEntries(toolCalls.map((tc) => [tc.toolCallId, tc]));
const childrenById = Object.fromEntries(children.map((c) => [c.agentId, c]));
return buildTimelineBlocks(entries, toolCallsById, childrenById, status);
}
test('text entries are always visible', () => {
expect(isVisibleTimelineEntry({ type: 'text', content: 'hi' }, {}, {})).toBe(true);
test('merges consecutive reasoning and generic tool calls into one thinking block', () => {
const blocks = blocksOf(
[reasoning('r1'), toolEntry('tc-1', 'r1'), toolEntry('tc-2', 'r1'), reasoning('r2')],
[makeToolCall({ toolCallId: 'tc-1' }), makeToolCall({ toolCallId: 'tc-2' })],
);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe('thinking');
expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(4);
});
test('reasoning entries are always visible', () => {
expect(isVisibleTimelineEntry({ type: 'reasoning', content: 'hmm' }, {}, {})).toBe(true);
test('text followed by same-response trace content joins the thinking block', () => {
const blocks = blocksOf(
[reasoning('r1'), text('Let me check the schema.', 'r1'), toolEntry('tc-1', 'r1')],
[makeToolCall({ toolCallId: 'tc-1' })],
);
expect(blocks).toHaveLength(1);
expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(3);
});
test('tool-call entries without a matching tool call are hidden', () => {
expect(isVisibleTimelineEntry(toolCallEntry, {}, {})).toBe(false);
test('trailing text of a response is user-facing and splits blocks', () => {
const blocks = blocksOf(
[reasoning('r1'), toolEntry('tc-1', 'r1'), text('Here is your answer.', 'r1')],
[makeToolCall({ toolCallId: 'tc-1' })],
);
expect(blocks.map((b) => b.type)).toEqual(['thinking', 'text']);
});
test('internal bookkeeping tools are hidden', () => {
expect(visibilityOf(makeToolCall({ toolName: 'updateWorkingMemory' }))).toBe(false);
test('short streaming tail text after same-response trace stays inside the block', () => {
const blocks = blocksOf(
[reasoning('r1'), text('Now building the workflow.', 'r1')],
[],
'active',
);
expect(blocks).toHaveLength(1);
expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(2);
});
test('builder/data-table/eval-setup hints are hidden (represented by artifact cards)', () => {
expect(visibilityOf(makeToolCall({ renderHint: 'builder' }))).toBe(false);
expect(visibilityOf(makeToolCall({ renderHint: 'data-table' }))).toBe(false);
expect(visibilityOf(makeToolCall({ renderHint: 'eval-setup' }))).toBe(false);
test('answer-length streaming tail text promotes out of the block', () => {
const longText = 'This is the final answer. '.repeat(10); // > 200 chars
const blocks = blocksOf([reasoning('r1'), text(longText, 'r1')], [], 'active');
expect(blocks.map((b) => b.type)).toEqual(['thinking', 'text']);
});
test('builder hint stays hidden even with a plan-review confirmation (template order)', () => {
expect(
visibilityOf(
test('streaming tail text without same-response trace renders outside', () => {
const blocks = blocksOf([reasoning('r1'), text('Quick answer.', 'r2')], [], 'active');
expect(blocks.map((b) => b.type)).toEqual(['thinking', 'text']);
});
test('short trailing text promotes out once the run settles', () => {
const entries = [reasoning('r1'), text('Done, workflow created.', 'r1')];
const streaming = blocksOf(entries, [], 'active');
expect(streaming).toHaveLength(1);
const settled = blocksOf(entries, [], 'completed');
expect(settled.map((b) => b.type)).toEqual(['thinking', 'text']);
});
test('text without responseId is always user-facing (old snapshots)', () => {
const blocks = blocksOf(
[reasoning(), text('narration'), toolEntry('tc-1')],
[makeToolCall({ toolCallId: 'tc-1' })],
);
expect(blocks.map((b) => b.type)).toEqual(['thinking', 'text', 'thinking']);
});
test('hidden tool calls are dropped without splitting a thinking run', () => {
const blocks = blocksOf(
[
toolEntry('tc-1', 'r1'),
toolEntry('tc-hidden', 'r1'),
toolEntry('tc-builder', 'r1'),
toolEntry('tc-2', 'r1'),
],
[
makeToolCall({ toolCallId: 'tc-1' }),
makeToolCall({ toolCallId: 'tc-hidden', toolName: 'updateWorkingMemory' }),
makeToolCall({
toolCallId: 'tc-builder',
toolName: 'build-workflow-with-agent',
renderHint: 'builder',
confirmation: {
requestId: 'r1',
severity: 'info',
message: 'Review plan',
inputType: 'plan-review',
},
}),
),
).toBe(false);
makeToolCall({ toolCallId: 'tc-2' }),
],
);
expect(blocks).toHaveLength(1);
expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(2);
});
test('plan-review confirmations render a panel', () => {
expect(
visibilityOf(
test('in-thread build-workflow renders as a trace row; agent-delegated builds stay hidden', () => {
const blocks = blocksOf(
[toolEntry('tc-build', 'r1'), toolEntry('tc-delegated', 'r1')],
[
makeToolCall({ toolCallId: 'tc-build', toolName: 'build-workflow', renderHint: 'builder' }),
makeToolCall({
renderHint: 'planner',
confirmation: {
requestId: 'r1',
severity: 'info',
message: 'Review plan',
inputType: 'plan-review',
},
toolCallId: 'tc-delegated',
toolName: 'build-workflow-with-agent',
renderHint: 'builder',
}),
),
).toBe(true);
],
);
expect(blocks).toHaveLength(1);
expect(blocks[0].type === 'thinking' && blocks[0].entries).toEqual([
expect.objectContaining({ toolCallId: 'tc-build' }),
]);
});
test('planner hint without a plan review renders nothing', () => {
expect(visibilityOf(makeToolCall({ renderHint: 'planner' }))).toBe(false);
});
test('question forms are suppressed while pending, visible once answered', () => {
const questions = (isLoading: boolean) =>
makeToolCall({
isLoading,
confirmation: {
requestId: 'r1',
severity: 'info',
message: 'Answer questions',
inputType: 'questions',
},
});
expect(visibilityOf(questions(true))).toBe(false);
expect(visibilityOf(questions(false))).toBe(true);
});
test('tasks, default, and generic tool calls are visible', () => {
expect(visibilityOf(makeToolCall({ renderHint: 'tasks' }))).toBe(true);
expect(visibilityOf(makeToolCall({ renderHint: 'default' }))).toBe(true);
expect(visibilityOf(makeToolCall({ renderHint: 'skill' }))).toBe(true);
expect(visibilityOf(makeToolCall({}))).toBe(true);
});
test('child entries are visible unless the child is a hoisted active builder', () => {
const entry = { type: 'child' as const, agentId: 'sub-1' };
const completedBuilder = makeAgentNode({
agentId: 'sub-1',
role: 'workflow-builder',
status: 'completed',
test('user-facing tool calls split thinking runs', () => {
const answeredQuestions = makeToolCall({
toolCallId: 'tc-q',
isLoading: false,
confirmation: { requestId: 'r1', severity: 'info', message: 'q', inputType: 'questions' },
});
const blocks = blocksOf(
[toolEntry('tc-1', 'r1'), toolEntry('tc-q', 'r1'), toolEntry('tc-2', 'r2')],
[
makeToolCall({ toolCallId: 'tc-1' }),
answeredQuestions,
makeToolCall({ toolCallId: 'tc-2' }),
],
);
expect(blocks.map((b) => b.type)).toEqual(['thinking', 'questions', 'thinking']);
});
test('pending question forms are dropped without splitting', () => {
const pendingQuestions = makeToolCall({
toolCallId: 'tc-q',
isLoading: true,
confirmation: { requestId: 'r1', severity: 'info', message: 'q', inputType: 'questions' },
});
const blocks = blocksOf(
[toolEntry('tc-1', 'r1'), toolEntry('tc-q', 'r1'), toolEntry('tc-2', 'r1')],
[
makeToolCall({ toolCallId: 'tc-1' }),
pendingQuestions,
makeToolCall({ toolCallId: 'tc-2' }),
],
);
expect(blocks).toHaveLength(1);
expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(2);
});
test('child agents split thinking runs; hoisted active builders are dropped', () => {
const completedChild = makeAgentNode({ agentId: 'sub-1' });
const activeBuilder = makeAgentNode({
agentId: 'sub-1',
agentId: 'sub-2',
role: 'workflow-builder',
status: 'active',
});
const childEntry = (agentId: string): InstanceAiTimelineEntry => ({ type: 'child', agentId });
expect(isVisibleTimelineEntry(entry, {}, { 'sub-1': completedBuilder })).toBe(true);
expect(isVisibleTimelineEntry(entry, {}, { 'sub-1': activeBuilder })).toBe(false);
expect(isVisibleTimelineEntry(entry, {}, {})).toBe(false);
const split = blocksOf(
[reasoning('r1'), childEntry('sub-1'), reasoning('r2')],
[],
'completed',
[completedChild],
);
expect(split.map((b) => b.type)).toEqual(['thinking', 'child', 'thinking']);
const merged = blocksOf(
[reasoning('r1'), childEntry('sub-2'), reasoning('r2')],
[],
'completed',
[activeBuilder],
);
expect(merged).toHaveLength(1);
});
test('flags only the trailing thinking block as active while the agent streams', () => {
const streaming = blocksOf(
[reasoning('r1'), text('Answer.', 'r1'), reasoning('r2')],
[],
'active',
);
expect(streaming.map((b) => b.type)).toEqual(['thinking', 'text', 'thinking']);
expect(streaming[0].type === 'thinking' && streaming[0].active).toBe(false);
expect(streaming[2].type === 'thinking' && streaming[2].active).toBe(true);
});
test('the trailing thinking block stays active while tentative tail text streams', () => {
// Tail text may still fold back into the block (if same-response trace
// content follows), so the block must not settle to "Thought for Xs" yet.
const tailText = blocksOf([reasoning('r1'), text('Answer...', 'r2')], [], 'active');
expect(tailText.map((b) => b.type)).toEqual(['thinking', 'text']);
expect(tailText[0].type === 'thinking' && tailText[0].active).toBe(true);
});
test('trailing text past the narration cap settles the thinking block', () => {
// Answer-length text is a committed answer — a block still "thinking"
// behind a streaming answer reads as lag.
const longAnswer = 'A'.repeat(240) + '.';
const blocks = blocksOf([reasoning('r1'), text(longAnswer, 'r2')], [], 'active');
expect(blocks.map((b) => b.type)).toEqual(['thinking', 'text']);
expect(blocks[0].type === 'thinking' && blocks[0].active).toBe(false);
});
test('real user-facing interruptions settle the thinking block immediately', () => {
const answeredQuestions = makeToolCall({
toolCallId: 'tc-q',
isLoading: false,
confirmation: { requestId: 'r1', severity: 'info', message: 'q', inputType: 'questions' },
});
const blocks = blocksOf(
[reasoning('r1'), toolEntry('tc-q', 'r1')],
[answeredQuestions],
'active',
);
expect(blocks.map((b) => b.type)).toEqual(['thinking', 'questions']);
expect(blocks[0].type === 'thinking' && blocks[0].active).toBe(false);
});
test('no block is active once the agent has settled', () => {
const completed = blocksOf([reasoning('r1')], [], 'completed');
expect(completed[0].type === 'thinking' && completed[0].active).toBe(false);
});
});
@@ -54,7 +54,7 @@ describe('getToolIcon', () => {
});
test('returns default icon for removed delegate tool', () => {
expect(getToolIcon('delegate')).toBe('settings');
expect(getToolIcon('delegate')).toBe('wrench');
});
test('returns share for tools ending with -with-agent', () => {
@@ -84,7 +84,7 @@ describe('getToolIcon', () => {
});
test('treats removed plan tool as an ordinary unknown icon', () => {
expect(getToolIcon('plan')).toBe('settings');
expect(getToolIcon('plan')).toBe('wrench');
});
test('returns key-round for credential tools', () => {
@@ -110,8 +110,8 @@ describe('getToolIcon', () => {
expect(getToolIcon('n8n-docs')).toBe('book-open');
});
test('returns settings as default', () => {
expect(getToolIcon('unknown-tool')).toBe('settings');
test('returns wrench as default', () => {
expect(getToolIcon('unknown-tool')).toBe('wrench');
});
});
@@ -1,141 +0,0 @@
import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types';
import { describe, expect, test } from 'vitest';
import { ref } from 'vue';
import { useTimelineGrouping } from '../useTimelineGrouping';
function makeToolCall(overrides: Partial<InstanceAiToolCallState>): InstanceAiToolCallState {
return {
toolCallId: 'tc-1',
toolName: 'build-workflow',
args: {},
isLoading: false,
...overrides,
};
}
function makeAgentNode(overrides: Partial<InstanceAiAgentNode>): InstanceAiAgentNode {
return {
agentId: 'agent-1',
role: 'orchestrator',
status: 'completed',
textContent: '',
reasoning: '',
toolCalls: [],
children: [],
timeline: [],
...overrides,
};
}
describe('useTimelineGrouping', () => {
test('surfaces artifacts from root tool calls', () => {
const segments = useTimelineGrouping(
ref(
makeAgentNode({
toolCalls: [
makeToolCall({
renderHint: 'builder',
result: { workflowId: 'wf-1', workflowName: 'Built workflow' },
completedAt: '2026-01-01T00:00:00Z',
}),
],
timeline: [{ type: 'tool-call', toolCallId: 'tc-1', responseId: 'r-1' }],
}),
),
).value;
if (!segments || segments[0].kind !== 'response-group') {
throw new Error('Expected a response group');
}
expect(segments[0].artifacts).toEqual([
{
type: 'workflow',
resourceId: 'wf-1',
name: 'Built workflow',
completedAt: '2026-01-01T00:00:00Z',
},
]);
});
test('groups reasoning with its step tool calls and counts it', () => {
const segments = useTimelineGrouping(
ref(
makeAgentNode({
toolCalls: [makeToolCall({ toolCallId: 'tc-1', toolName: 'search' })],
timeline: [
{ type: 'reasoning', content: 'plan the search', responseId: 'r-1' },
{ type: 'tool-call', toolCallId: 'tc-1', responseId: 'r-1' },
{ type: 'text', content: 'the answer', responseId: 'r-2' },
],
}),
),
).value;
if (!segments) throw new Error('Expected segments');
expect(segments).toHaveLength(2);
const group = segments[0];
if (group.kind !== 'response-group') throw new Error('Expected a response group');
expect(group.reasoningCount).toBe(1);
expect(group.entries).toEqual([
{ type: 'reasoning', content: 'plan the search', responseId: 'r-1' },
{ type: 'tool-call', toolCallId: 'tc-1', responseId: 'r-1' },
]);
expect(segments[1]).toEqual({ kind: 'trailing-text', content: 'the answer' });
});
test('keeps reasoning-only groups', () => {
const segments = useTimelineGrouping(
ref(
makeAgentNode({
timeline: [
{ type: 'reasoning', content: 'final thoughts', responseId: 'r-1' },
{ type: 'text', content: 'the answer', responseId: 'r-1' },
],
}),
),
).value;
if (!segments) throw new Error('Expected segments');
expect(segments).toHaveLength(2);
const group = segments[0];
if (group.kind !== 'response-group') throw new Error('Expected a response group');
expect(group.reasoningCount).toBe(1);
expect(group.entries).toEqual([
{ type: 'reasoning', content: 'final thoughts', responseId: 'r-1' },
]);
expect(segments[1]).toEqual({ kind: 'trailing-text', content: 'the answer' });
});
test('deduplicates artifacts across root tool calls', () => {
const segments = useTimelineGrouping(
ref(
makeAgentNode({
toolCalls: [
makeToolCall({
toolCallId: 'tc-1',
renderHint: 'builder',
result: { workflowId: 'wf-1', workflowName: 'Built workflow' },
completedAt: '2026-01-01T00:00:00Z',
}),
makeToolCall({
toolCallId: 'tc-2',
renderHint: 'builder',
result: { workflowId: 'wf-1', workflowName: 'Built workflow' },
completedAt: '2026-01-01T00:00:01Z',
}),
],
timeline: [
{ type: 'tool-call', toolCallId: 'tc-1', responseId: 'r-1' },
{ type: 'tool-call', toolCallId: 'tc-2', responseId: 'r-1' },
],
}),
),
).value;
if (!segments || segments[0].kind !== 'response-group') {
throw new Error('Expected a response group');
}
expect(segments[0].artifacts).toHaveLength(1);
expect(segments[0].artifacts[0].resourceId).toBe('wf-1');
});
});
@@ -10,38 +10,191 @@ export const HIDDEN_TOOLS = new Set(['updateWorkingMemory']);
/** Render hints whose tool calls produce no output in the timeline — they are
* represented elsewhere (child agent sections, artifact cards). */
const INVISIBLE_RENDER_HINTS = new Set(['builder', 'data-table', 'eval-setup']);
const INVISIBLE_RENDER_HINTS = new Set(['data-table', 'eval-setup']);
/** Streamed tail text up to this length is treated as thinking narration;
* beyond it, it is likely the final answer and renders outside the block. */
const TAIL_NARRATION_MAX_LENGTH = 200;
type TextEntry = Extract<InstanceAiTimelineEntry, { type: 'text' }>;
/** First sentence of a streamed markdown-ish text, for trace status lines. */
export function firstSentence(content: string): string {
const plain = content.replace(/[*_`#]/g, '').trim();
const match = plain.match(/^.*?[.!?](?=\s|$)/s);
return (match ? match[0] : plain).trim();
}
/**
* True when a timeline entry produces visible output in `AgentTimeline`.
* The timeline reduced to renderable blocks.
*
* Mirrors the template's branch chain (same order): hidden tools and
* builder/data-table/eval-setup hints render nothing; plan-review
* confirmations render a panel; bare planner calls render nothing; pending
* question forms are suppressed until answered; hoisted active builder
* children are rendered elsewhere. Used to skip the timeline wrapper
* entirely when a segment has no visible entries (avoids empty divs and
* their phantom flex-gap spacing).
* A `thinking` block is a maximal run of trace content — reasoning segments,
* generic tool calls, and the intermediate narration text the model emits
* between them — split only by user-facing content (answer text, plan
* reviews, answered questions, task checklists, child agents). Invisible
* entries (hidden tools, builder/planner hints, pending questions) are
* dropped without splitting a run.
*/
export function isVisibleTimelineEntry(
entry: InstanceAiTimelineEntry,
export type TimelineBlock =
| { type: 'thinking'; key: string; entries: InstanceAiTimelineEntry[]; active: boolean }
| { type: 'text'; key: string; entry: TextEntry }
| { type: 'tasks'; key: string; toolCall: InstanceAiToolCallState }
| { type: 'plan-review'; key: string; toolCall: InstanceAiToolCallState }
| { type: 'questions'; key: string; toolCall: InstanceAiToolCallState }
| { type: 'child'; key: string; child: InstanceAiAgentNode };
type ToolCallKind =
| 'hidden'
| 'tasks'
| 'plan-review'
| 'questions'
| 'questions-pending'
| 'trace';
/**
* How a tool call renders in the timeline. `trace` rows join thinking blocks;
* `tasks`/`plan-review`/`questions` render standalone UI; `hidden` calls are
* dropped without splitting a run.
*
* Builder calls delegated to a sub-agent (`*-with-agent`) are hidden — the
* child agent section represents them. In-thread builds (`build-workflow`)
* render as trace rows so the build step is visible inside the thinking block.
*/
function classifyToolCall(tc: InstanceAiToolCallState): ToolCallKind {
if (HIDDEN_TOOLS.has(tc.toolName)) return 'hidden';
if (tc.renderHint === 'tasks') return 'tasks';
if (tc.renderHint === 'builder' && tc.toolName.endsWith('-with-agent')) return 'hidden';
if (tc.renderHint && INVISIBLE_RENDER_HINTS.has(tc.renderHint)) return 'hidden';
if (tc.confirmation?.inputType === 'plan-review') return 'plan-review';
if (tc.renderHint === 'planner') return 'hidden';
if (tc.confirmation?.inputType === 'questions') {
return tc.isLoading ? 'questions-pending' : 'questions';
}
return 'trace';
}
export function buildTimelineBlocks(
entries: InstanceAiTimelineEntry[],
toolCallsById: Record<string, InstanceAiToolCallState>,
childrenById: Record<string, InstanceAiAgentNode>,
): boolean {
if (entry.type === 'text' || entry.type === 'reasoning') return true;
agentStatus: InstanceAiAgentNode['status'],
): TimelineBlock[] {
// A text entry is intermediate narration (part of the thinking trace) when
// trace content from the SAME model response follows it — i.e. the model
// kept working after writing it. Trailing text of a response (and text
// without a responseId, from old snapshots) is user-facing.
const lastTraceIdxByResponse = new Map<string, number>();
entries.forEach((entry, idx) => {
if (entry.responseId === undefined) return;
if (entry.type === 'reasoning') {
lastTraceIdxByResponse.set(entry.responseId, idx);
} else if (entry.type === 'tool-call') {
const tc = toolCallsById[entry.toolCallId];
if (tc && classifyToolCall(tc) === 'trace') {
lastTraceIdxByResponse.set(entry.responseId, idx);
}
}
});
const isIntermediateText = (entry: TextEntry, idx: number): boolean => {
if (entry.responseId === undefined) return false;
const lastTraceIdx = lastTraceIdxByResponse.get(entry.responseId);
if (lastTraceIdx !== undefined && lastTraceIdx > idx) return true;
// Streaming tail text is ambiguous — narration before the next tool call
// and the final answer look identical until either a tool call follows
// or the run ends. Rendering it outside and folding it back in reads as
// an answer flashing and vanishing, so short tail text following this
// response's trace content is optimistically kept INSIDE the block (its
// first sentence feeds the status line). It promotes out — an additive,
// non-jarring motion — once it grows answer-length or the run settles.
return (
agentStatus === 'active' &&
idx === entries.length - 1 &&
lastTraceIdx !== undefined &&
entry.content.length <= TAIL_NARRATION_MAX_LENGTH
);
};
const blocks: TimelineBlock[] = [];
let run: Extract<TimelineBlock, { type: 'thinking' }> | null = null;
const pushStandalone = (block: TimelineBlock) => {
run = null;
blocks.push(block);
};
const pushTrace = (entry: InstanceAiTimelineEntry, idx: number) => {
if (!run) {
run = { type: 'thinking', key: `thinking-${idx}`, entries: [], active: false };
blocks.push(run);
}
run.entries.push(entry);
};
entries.forEach((entry, idx) => {
if (entry.type === 'reasoning') {
pushTrace(entry, idx);
return;
}
if (entry.type === 'text') {
if (isIntermediateText(entry, idx)) pushTrace(entry, idx);
else pushStandalone({ type: 'text', key: `text-${idx}`, entry });
return;
}
if (entry.type === 'child') {
const child = childrenById[entry.agentId];
// Running builder sub-agents are extracted and rendered at the bottom
// of the conversation by InstanceAiView; once a builder finishes it
// reappears here in its chronological slot.
if (child && !isActiveBuilderAgent(child)) {
pushStandalone({ type: 'child', key: `child-${idx}`, child });
}
return;
}
if (entry.type === 'tool-call') {
const tc = toolCallsById[entry.toolCallId];
if (!tc || HIDDEN_TOOLS.has(tc.toolName)) return false;
if (tc.renderHint && INVISIBLE_RENDER_HINTS.has(tc.renderHint)) return false;
if (tc.confirmation?.inputType === 'plan-review') return true;
if (tc.renderHint === 'planner') return false;
if (tc.confirmation?.inputType === 'questions') return !tc.isLoading;
return true;
if (!tc) return;
switch (classifyToolCall(tc)) {
case 'tasks':
pushStandalone({ type: 'tasks', key: `tasks-${idx}`, toolCall: tc });
return;
case 'plan-review':
pushStandalone({ type: 'plan-review', key: `plan-${idx}`, toolCall: tc });
return;
case 'questions':
pushStandalone({ type: 'questions', key: `questions-${idx}`, toolCall: tc });
return;
case 'trace':
pushTrace(entry, idx);
return;
// hidden and pending question forms drop without splitting the run
default:
return;
}
});
// While the agent streams, the trailing thinking block stays active as long
// as only (tentative) text follows it — tail text may still fold back into
// the block if same-response trace content arrives, so settling the block
// early would flicker "Thought for Xs" → "Thinking". Real interruptions
// (cards, questions, child agents) settle it immediately, and so does text
// that outgrew the narration cap: it is a committed answer, so keeping the
// block behind it "thinking" reads as lag.
if (agentStatus === 'active') {
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i];
if (block.type === 'thinking') {
block.active = true;
break;
}
if (block.type !== 'text') break;
if (block.entry.content.length > TAIL_NARRATION_MAX_LENGTH) break;
}
}
const child = childrenById[entry.agentId];
return child !== undefined && !isActiveBuilderAgent(child);
return blocks;
}
/**
@@ -1,92 +1,17 @@
<script lang="ts" setup>
import type { InstanceAiAgentNode } from '@n8n/api-types';
import { N8nText } from '@n8n/design-system';
import { computed, toRef } from 'vue';
import type { ArtifactInfo } from '../agentTimeline.utils';
import { useThread } from '../instanceAi.store';
import { useTimelineGrouping } from '../useTimelineGrouping';
import AgentTimeline from './AgentTimeline.vue';
import ArtifactCard from './ArtifactCard.vue';
import InstanceAiMarkdown from './InstanceAiMarkdown.vue';
import ResponseGroup from './ResponseGroup.vue';
const props = withDefaults(
defineProps<{
agentNode: InstanceAiAgentNode;
isRoot?: boolean;
}>(),
{
isRoot: false,
},
);
const thread = useThread();
const segments = useTimelineGrouping(toRef(props, 'agentNode'));
/** Whether to show grouped/collapsed view (root + grouping available). */
const showGrouped = computed(() => props.isRoot && segments.value !== null);
/** Index of the last response-group segment (for isLast prop). */
const lastGroupIdx = computed(() => {
if (!segments.value) return -1;
for (let i = segments.value.length - 1; i >= 0; i--) {
if (segments.value[i].kind === 'response-group') return i;
}
return -1;
});
function resolveArtifactName(artifact: ArtifactInfo): string {
const entry = thread.producedArtifacts.get(artifact.resourceId);
return entry?.name ?? artifact.name;
}
/**
* Root agent activity renderer. Thinking-trace grouping and collapsing is
* handled inside AgentTimeline (see buildTimelineBlocks), so live and
* completed runs render identically.
*/
const props = defineProps<{
agentNode: InstanceAiAgentNode;
}>();
</script>
<template>
<!-- eslint-disable vue/no-multiple-template-root -->
<!-- Completed with responseId grouping: collapsed response groups + artifacts + trailing text -->
<template v-if="showGrouped">
<template v-for="(segment, idx) in segments" :key="idx">
<ResponseGroup
v-if="segment.kind === 'response-group'"
:group="segment"
:agent-node="props.agentNode"
:is-last="idx === lastGroupIdx"
/>
<!-- Artifacts from child agents in this group, rendered in-place after the group -->
<template v-if="segment.kind === 'response-group' && segment.artifacts.length > 0">
<ArtifactCard
v-for="artifact in segment.artifacts"
:key="artifact.resourceId"
:type="artifact.type"
:name="resolveArtifactName(artifact)"
:resource-id="artifact.resourceId"
:project-id="artifact.projectId"
:archived="thread.producedArtifacts.get(artifact.resourceId)?.archived"
:class="$style.artifactCard"
/>
</template>
<!-- Trailing text (the actual answer) — always visible -->
<N8nText v-if="segment.kind === 'trailing-text'" size="large">
<InstanceAiMarkdown :content="segment.content" />
</N8nText>
</template>
</template>
<!-- Active / no grouping available: show timeline directly -->
<AgentTimeline v-else :agent-node="props.agentNode" />
<AgentTimeline :agent-node="props.agentNode" />
</template>
<style lang="scss" module>
.artifactCard {
max-width: 90%;
margin: var(--spacing--sm) 0;
+ .artifactCard {
margin-top: 0;
}
}
</style>
@@ -8,33 +8,26 @@ import type {
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import {
buildTimelineBlocks,
extractArtifacts,
isStreamingTimelineEntry,
isVisibleTimelineEntry,
HIDDEN_TOOLS,
type ArtifactInfo,
} from '../agentTimeline.utils';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useThread } from '../instanceAi.store';
import { isActiveBuilderAgent } from '../builderAgents';
import AgentSection from './AgentSection.vue';
import AnsweredQuestions from './AnsweredQuestions.vue';
import ArtifactCard from './ArtifactCard.vue';
import PlanReviewPanel, { type PlannedTaskArg, type PlanReviewStatus } from './PlanReviewPanel.vue';
import ReasoningBlock from './ReasoningBlock.vue';
import TaskChecklist from './TaskChecklist.vue';
import ThinkingBlock from './ThinkingBlock.vue';
import TimelineTextSegment from './TimelineTextSegment.vue';
import ToolResultJson from './ToolResultJson.vue';
import ToolResultRenderer from './ToolResultRenderer.vue';
import { N8nAiActivityStep as ToolCallStep } from '@n8n/design-system';
import { useToolLabel } from '../toolLabels';
const i18n = useI18n();
const thread = useThread();
const telemetry = useTelemetry();
const rootStore = useRootStore();
const { getToolLabel } = useToolLabel();
/** Resolve artifact name from the enriched registry (falls back to extracted name). */
function resolveArtifactName(artifact: ArtifactInfo): string {
@@ -83,7 +76,7 @@ function formatArtifactMetadata(artifact: ArtifactInfo): string {
parts.push(i18n.baseText('instanceAi.artifactCard.updatedJustNow'));
}
return parts.join(' \u2502 ');
return parts.join(' │ ');
}
const props = withDefaults(
@@ -110,6 +103,24 @@ const toolCallsById = computed(() => {
return map;
});
/** Index children by agentId for O(1) lookup and proper reactivity tracking. */
const childrenById = computed(() => {
const map: Record<string, InstanceAiAgentNode> = {};
for (const child of props.agentNode.children) {
map[child.agentId] = child;
}
return map;
});
const renderBlocks = computed(() =>
buildTimelineBlocks(
timelineEntries.value,
toolCallsById.value,
childrenById.value,
props.agentNode.status,
),
);
function getPlanTasks(tc: InstanceAiToolCallState): PlannedTaskArg[] {
return (
tc.confirmation?.planItems ??
@@ -153,26 +164,6 @@ function isPlanCardReadOnly(tc: InstanceAiToolCallState): boolean {
return false;
}
/** Index children by agentId for O(1) lookup and proper reactivity tracking. */
const childrenById = computed(() => {
const map: Record<string, InstanceAiAgentNode> = {};
for (const child of props.agentNode.children) {
map[child.agentId] = child;
}
return map;
});
/**
* Whether any entry renders visible output. Segments made up entirely of
* hidden tool calls (e.g. builder calls represented by artifact cards) skip
* the wrapper div — an empty flex item would still add gap spacing.
*/
const hasVisibleEntries = computed(() =>
timelineEntries.value.some((entry) =>
isVisibleTimelineEntry(entry, toolCallsById.value, childrenById.value),
),
);
function handlePlanApprove(tc: InstanceAiToolCallState) {
const requestId = tc.confirmation?.requestId;
if (!requestId) return;
@@ -255,109 +246,52 @@ function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefi
</script>
<template>
<div v-if="hasVisibleEntries" :class="$style.timeline">
<template v-for="(entry, idx) in timelineEntries" :key="idx">
<!-- Text segment (leaf keeps the per-token content read out of this render) -->
<div v-if="renderBlocks.length > 0" :class="$style.timeline">
<template v-for="block in renderBlocks" :key="block.key">
<!-- Collapsible thinking trace: reasoning + narration + tool calls -->
<ThinkingBlock
v-if="block.type === 'thinking'"
:agent-node="props.agentNode"
:entries="block.entries"
:active="block.active"
:awaiting-input="block.active && thread.isAwaitingConfirmation"
/>
<!-- User-facing text (leaf keeps the per-token content read out of this render) -->
<TimelineTextSegment
v-if="entry.type === 'text'"
:entry="entry"
v-else-if="block.type === 'text'"
:entry="block.entry"
:compact="props.compact"
:streaming="isStreamingTimelineEntry(props.agentNode, entry)"
:streaming="isStreamingTimelineEntry(props.agentNode, block.entry)"
:class="$style.timelineItem"
/>
<!-- Reasoning segment — one collapsible block per reasoning stage -->
<ReasoningBlock
v-else-if="entry.type === 'reasoning'"
:entry="entry"
:streaming="isStreamingTimelineEntry(props.agentNode, entry)"
<TaskChecklist v-else-if="block.type === 'tasks'" :tasks="props.agentNode.tasks" />
<PlanReviewPanel
v-else-if="block.type === 'plan-review'"
:key="block.toolCall.confirmation?.requestId"
:planned-tasks="getPlanTasks(block.toolCall)"
:status="getPlanReviewStatus(block.toolCall)"
:updating="isPlanReviewUpdating(block.toolCall)"
:read-only="isPlanCardReadOnly(block.toolCall)"
:expired="block.toolCall.confirmation?.expired"
@approve="handlePlanApprove(block.toolCall)"
@ask-for-edits="handlePlanAskForEdits(block.toolCall)"
@deny="handlePlanDeny(block.toolCall)"
/>
<!-- Tool call (skip internal tools like updateWorkingMemory) -->
<template
v-else-if="
entry.type === 'tool-call' &&
toolCallsById[entry.toolCallId] &&
!HIDDEN_TOOLS.has(toolCallsById[entry.toolCallId].toolName)
"
>
<TaskChecklist
v-if="toolCallsById[entry.toolCallId].renderHint === 'tasks'"
:tasks="props.agentNode.tasks"
/>
<!-- Hidden tool calls (builder/data-table/eval-setup handled by child agent via AgentSection) -->
<template v-else-if="toolCallsById[entry.toolCallId].renderHint === 'builder'" />
<template v-else-if="toolCallsById[entry.toolCallId].renderHint === 'data-table'" />
<template v-else-if="toolCallsById[entry.toolCallId].renderHint === 'eval-setup'" />
<PlanReviewPanel
v-else-if="toolCallsById[entry.toolCallId].confirmation?.inputType === 'plan-review'"
:key="toolCallsById[entry.toolCallId].confirmation?.requestId"
:planned-tasks="getPlanTasks(toolCallsById[entry.toolCallId])"
:status="getPlanReviewStatus(toolCallsById[entry.toolCallId])"
:updating="isPlanReviewUpdating(toolCallsById[entry.toolCallId])"
:read-only="isPlanCardReadOnly(toolCallsById[entry.toolCallId])"
:expired="toolCallsById[entry.toolCallId].confirmation?.expired"
@approve="handlePlanApprove(toolCallsById[entry.toolCallId])"
@ask-for-edits="handlePlanAskForEdits(toolCallsById[entry.toolCallId])"
@deny="handlePlanDeny(toolCallsById[entry.toolCallId])"
/>
<template v-else-if="toolCallsById[entry.toolCallId].renderHint === 'planner'" />
<!-- Answered questions (read-only after resolution) -->
<AnsweredQuestions
v-else-if="
toolCallsById[entry.toolCallId].confirmation?.inputType === 'questions' &&
!toolCallsById[entry.toolCallId].isLoading
"
:tool-call="toolCallsById[entry.toolCallId]"
/>
<!-- Suppress default tool call while questions are pending -->
<template
v-else-if="
toolCallsById[entry.toolCallId].confirmation?.inputType === 'questions' &&
toolCallsById[entry.toolCallId].isLoading
"
/>
<ToolCallStep
v-else
:label="
getToolLabel(
toolCallsById[entry.toolCallId].toolName,
toolCallsById[entry.toolCallId].args,
)
"
:loading="toolCallsById[entry.toolCallId].isLoading"
:error="toolCallsById[entry.toolCallId].error"
>
<ToolResultJson
v-if="toolCallsById[entry.toolCallId].args"
:value="toolCallsById[entry.toolCallId].args"
/>
<ToolResultRenderer
v-if="toolCallsById[entry.toolCallId].result !== undefined"
:result="toolCallsById[entry.toolCallId].result"
:tool-name="toolCallsById[entry.toolCallId].toolName"
:tool-args="toolCallsById[entry.toolCallId].args"
/>
</ToolCallStep>
</template>
<!-- Answered questions (read-only after resolution) -->
<AnsweredQuestions v-else-if="block.type === 'questions'" :tool-call="block.toolCall" />
<!-- Child agent — flat section. Running builder sub-agents are
extracted and rendered at the bottom of the conversation by
InstanceAiView; once a builder finishes it reappears here in its
chronological slot. -->
<template
v-else-if="
entry.type === 'child' &&
childrenById[entry.agentId] &&
!isActiveBuilderAgent(childrenById[entry.agentId])
"
>
<AgentSection :agent-node="childrenById[entry.agentId]" />
<!-- Child agent — flat section -->
<template v-else-if="block.type === 'child'">
<AgentSection :agent-node="block.child" />
<!-- Artifact cards for completed subagents (skip when inside grouped view) -->
<!-- Artifact cards for completed subagents (skip when inside scoped view) -->
<template v-if="!props.visibleEntries">
<ArtifactCard
v-for="artifact in extractArtifacts(childrenById[entry.agentId])"
v-for="artifact in extractArtifacts(block.child)"
:key="artifact.resourceId"
:type="artifact.type"
:name="resolveArtifactName(artifact)"
@@ -134,11 +134,7 @@ function formatJson(value: unknown): string {
<!-- Assistant message -->
<div v-else :class="$style.assistantWrapper" data-test-id="instance-ai-assistant-message">
<!-- Agent activity tree (handles reasoning, tool calls, sub-agents) -->
<AgentActivityTree
v-if="props.message.agentTree"
:agent-node="props.message.agentTree"
:is-root="true"
/>
<AgentActivityTree v-if="props.message.agentTree" :agent-node="props.message.agentTree" />
<!-- Out-of-credits (quota exhausted): tailored state, hides raw provider/status noise -->
<N8nCallout v-if="isQuotaExhausted" theme="warning" data-test-id="instance-ai-out-of-credits">
@@ -1,7 +1,6 @@
<script lang="ts" setup>
import { ref, computed, watch, onUnmounted } from 'vue';
import { useI18n } from '@n8n/i18n';
import { N8nIcon } from '@n8n/design-system';
import type { InstanceAiMessage } from '@n8n/api-types';
import { useThread } from '../instanceAi.store';
import { useToolLabel } from '../toolLabels';
@@ -55,17 +54,38 @@ function deriveActivity(messages: InstanceAiMessage[]): { label: string; detail?
return { label: i18n.baseText('instanceAi.statusBar.thinking') };
}
const activity = computed(() => {
if (thread.isAwaitingConfirmation) {
return { label: i18n.baseText('instanceAi.statusBar.waitingForInput') };
}
return deriveActivity(thread.messages);
});
const activity = computed(() => deriveActivity(thread.messages));
/**
* True while the streaming orchestrator is rendering an active thinking
* block — its streamed status line (and tool subline) already shows the live
* activity, so the bar would say the same thing twice. The bar still covers
* the moments the block can't: run start before any trace exists, and
* background builders working after the orchestrator finished (root no
* longer active).
*/
function hasActiveThinkingTrace(messages: InstanceAiMessage[]): boolean {
// ANY streaming message counts — follow-up chains and mid-run replay can
// briefly append a stub streaming message after the one that renders the
// active block, and checking only the last would re-show the bar next to it.
return messages.some((m) => {
if (m.role !== 'assistant' || !m.isStreaming) return false;
const root = m.agentTree;
if (!root || root.status !== 'active') return false;
return root.timeline.some((entry) => entry.type === 'reasoning' || entry.type === 'tool-call');
});
}
// Stay visible while the orchestrator streams, and also while a builder runs in
// the background after the orchestrator run has ended (isStreaming === false).
// Hidden while a confirmation is pending — the thinking-block header reads
// "Waiting for your input" then, and the pending card itself is the prompt —
// and while an active thinking block is the live activity surface.
const isVisible = computed(
() => thread.isStreaming || collectActiveBuilderAgents(thread.messages).length > 0,
() =>
!thread.isAwaitingConfirmation &&
!hasActiveThinkingTrace(thread.messages) &&
(thread.isStreaming || collectActiveBuilderAgents(thread.messages).length > 0),
);
const formattedElapsed = computed(() => {
@@ -76,18 +96,11 @@ const formattedElapsed = computed(() => {
return `${String(m)}m ${String(remaining).padStart(2, '0')}s`;
});
const isCountingElapsed = computed(() => isVisible.value && !thread.isAwaitingConfirmation);
watch(isVisible, (visible) => {
if (visible) {
elapsed.value = 0;
}
});
watch(
isCountingElapsed,
(counting) => {
if (counting) {
isVisible,
(visible) => {
if (visible) {
elapsed.value = 0;
timer = setInterval(() => {
elapsed.value++;
}, 1000);
@@ -108,25 +121,12 @@ onUnmounted(() => {
<template>
<Transition name="status-bar">
<div
v-if="isVisible && activity"
:class="[$style.bar, { [$style.muted]: thread.isAwaitingConfirmation }]"
data-test-id="instance-ai-status-bar"
>
<N8nIcon
v-if="thread.isAwaitingConfirmation"
:class="$style.glyph"
icon="circle-pause"
size="xsmall"
/>
<span v-else :class="$style.dot" />
<div v-if="isVisible && activity" :class="$style.bar" data-test-id="instance-ai-status-bar">
<span :class="$style.label">{{ activity.label }}</span>
<span v-if="activity.detail" :class="$style.separator">&middot;</span>
<span v-if="activity.detail" :class="$style.detail">{{ activity.detail }}</span>
<template v-if="!thread.isAwaitingConfirmation">
<span :class="$style.separator">&middot;</span>
<span :class="$style.elapsed">{{ formattedElapsed }}</span>
</template>
<span v-if="activity.detail">&middot;</span>
<span v-if="activity.detail">{{ activity.detail }}</span>
<span>&middot;</span>
<span :class="$style.elapsed">{{ formattedElapsed }}</span>
</div>
</Transition>
</template>
@@ -134,57 +134,32 @@ onUnmounted(() => {
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
/* Styled to match the thinking block's subline — the bar hands off to it
* once trace content arrives, so the two must read as the same element. */
.bar {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
align-items: baseline;
gap: var(--spacing--4xs);
padding: var(--spacing--4xs) var(--spacing--2xs) var(--spacing--2xs) 0;
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
font-size: var(--font-size--sm);
line-height: var(--line-height--lg);
color: var(--text-color--subtler);
pointer-events: none;
}
.muted {
color: var(--color--text--tint-1);
.label {
color: var(--color--text--tint-1);
font-weight: var(--font-weight--regular);
}
}
.glyph {
color: var(--color--text--tint-1);
flex-shrink: 0;
}
.dot {
--animation--opacity-pulse--duration: 1.5s;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color--primary);
@include motion.opacity-pulse;
flex-shrink: 0;
}
.label {
font-weight: var(--font-weight--bold);
color: var(--color--text--tint-1);
}
.separator {
color: var(--color--text--tint-1);
}
.detail {
color: var(--color--text--tint-1);
--animation--shimmer--duration: 1.5s;
--animation--shimmer--background: color-mix(
in srgb,
var(--text-color--subtler) 30%,
var(--background--subtle) 70%
);
--animation--shimmer--foreground: var(--text-color--subtler);
@include motion.shimmer;
}
.elapsed {
font-variant-numeric: tabular-nums;
color: var(--color--text--tint-1);
}
</style>
@@ -1,12 +1,15 @@
<script lang="ts" setup>
import { N8nAiActivityStep } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { firstSentence } from '../agentTimeline.utils';
/**
* Collapsible "Reasoning" block. Takes an entry-like object (not a plain
* string) so the per-token `entry.content` read stays inside this component's
* render — streamed reasoning tokens re-render only this block, not the
* whole timeline.
* Collapsible reasoning block, labeled with the first sentence of the
* reasoning itself (a bare "Reasoning" toggle told the user nothing until
* expanded). Takes an entry-like object (not a plain string) so the per-token
* `entry.content` read stays inside this component's render — streamed
* reasoning tokens re-render only this block, not the whole timeline.
*/
const props = withDefaults(
defineProps<{
@@ -18,13 +21,14 @@ const props = withDefaults(
);
const i18n = useI18n();
const label = computed(
() => firstSentence(props.entry.content) || i18n.baseText('instanceAi.message.reasoning'),
);
</script>
<template>
<N8nAiActivityStep
:label="i18n.baseText('instanceAi.message.reasoning')"
:loading="props.streaming"
>
<N8nAiActivityStep :label="label" :loading="props.streaming">
<div :class="$style.reasoningPanel">
<div :class="$style.reasoningScroll">
<span :class="$style.reasoningContent">{{ props.entry.content }}</span>
@@ -1,94 +0,0 @@
<script lang="ts" setup>
import type { InstanceAiAgentNode } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { N8nAiActivityStepGroup } from '@n8n/design-system';
import { computed } from 'vue';
import type { ResponseGroupSegment } from '../useTimelineGrouping';
import AgentTimeline from './AgentTimeline.vue';
const props = defineProps<{
group: ResponseGroupSegment;
agentNode: InstanceAiAgentNode;
/** Whether this is the last response group in the timeline. */
isLast?: boolean;
}>();
const i18n = useI18n();
const summaryText = computed(() => {
const { toolCallCount, textCount, reasoningCount, questionCount, childCount } = props.group;
const parts: string[] = [];
if (reasoningCount > 0) {
parts.push(i18n.baseText('instanceAi.activitySummary.reasoning'));
}
if (toolCallCount > 0) {
parts.push(
i18n.baseText('instanceAi.activitySummary.toolCalls', {
interpolate: { count: `${toolCallCount}` },
}),
);
}
if (textCount > 0) {
parts.push(
i18n.baseText('instanceAi.activitySummary.messages', {
interpolate: { count: `${textCount}` },
}),
);
}
if (questionCount > 0) {
parts.push(
i18n.baseText('instanceAi.activitySummary.questions', {
interpolate: { count: `${questionCount}` },
}),
);
}
if (childCount > 0) {
parts.push(
i18n.baseText('instanceAi.activitySummary.subagents', {
interpolate: { count: `${childCount}` },
}),
);
}
return parts.join(', ');
});
/** Whether any tool call in this group is still loading. */
const hasLoadingToolCalls = computed(() =>
props.group.entries.some((e) => {
if (e.type !== 'tool-call') return false;
const tc = props.agentNode.toolCalls.find((t) => t.toolCallId === e.toolCallId);
return tc?.isLoading;
}),
);
/** Whether any child agent in this group is still active. */
const hasActiveChildren = computed(() =>
props.group.entries.some((e) => {
if (e.type !== 'child') return false;
const child = props.agentNode.children.find((c) => c.agentId === e.agentId);
return child?.status === 'active';
}),
);
/** Don't collapse the last group while the agent is still streaming. */
const isLastAndStreaming = computed(() => props.isLast && props.agentNode.status === 'active');
/** Whether this group has enough content to justify a collapsible wrapper. */
const isCollapsible = computed(
() =>
!isLastAndStreaming.value &&
!hasLoadingToolCalls.value &&
!hasActiveChildren.value &&
(props.group.toolCallCount > 1 || props.group.childCount > 0),
);
</script>
<template>
<!-- Collapsible: groups with generic tool calls or children -->
<N8nAiActivityStepGroup v-if="isCollapsible" :label="summaryText" size="medium">
<AgentTimeline :agent-node="props.agentNode" :visible-entries="props.group.entries" />
</N8nAiActivityStepGroup>
<!-- Flat: groups with only text + special UI (questions, plan-review) -->
<AgentTimeline v-else :agent-node="props.agentNode" :visible-entries="props.group.entries" />
</template>
@@ -15,6 +15,7 @@ import type {
import { useI18n } from '@n8n/i18n';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import { computed } from 'vue';
import { HIDDEN_TOOLS } from '../agentTimeline.utils';
import { getToolIcon, useToolLabel } from '../toolLabels';
import ButtonLike from './ButtonLike.vue';
import InstanceAiMarkdown from './InstanceAiMarkdown.vue';
@@ -37,9 +38,6 @@ const { getToolLabel, getToggleLabel, getHideLabel } = useToolLabel();
const CODE_BLOCK_PATTERN = /```/;
/** Tool calls that are internal and should not be shown in the step timeline. */
const HIDDEN_TOOLS = new Set(['updateWorkingMemory']);
interface TimelineStep {
type: 'tool-call' | 'text';
icon: IconName;
@@ -0,0 +1,391 @@
<script lang="ts" setup>
import type {
InstanceAiAgentNode,
InstanceAiTimelineEntry,
InstanceAiToolCallState,
} from '@n8n/api-types';
import {
N8nAiActivityStepChevron,
N8nAiActivityStep,
N8nAnimatedCollapsibleContent,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import { computed, onUnmounted, ref, watch } from 'vue';
import { firstSentence, isStreamingTimelineEntry } from '../agentTimeline.utils';
import { useToolLabel } from '../toolLabels';
import InstanceAiMarkdown from './InstanceAiMarkdown.vue';
import ReasoningBlock from './ReasoningBlock.vue';
import ToolResultJson from './ToolResultJson.vue';
import ToolResultRenderer from './ToolResultRenderer.vue';
/**
* Collapsible thinking-trace block. Wraps a maximal run of trace content —
* reasoning segments, intermediate narration text, and generic tool calls —
* behind a single header row (chevron on the right, content indented with a
* left rail like sub-agent sections).
*
* Collapsed by default. While streaming, the header shows a live status
* line — the first sentence of the latest trace segment, replaced (with a
* fade) whenever a new segment starts — and a subline with the current
* activity (running tool call or "Thinking") plus a block-level elapsed
* counter. Expanding moves the counter into the header. Once settled the
* header reads "Thought for Xs".
*/
const props = withDefaults(
defineProps<{
agentNode: InstanceAiAgentNode;
entries: InstanceAiTimelineEntry[];
/** True while this block is still receiving stream deltas. */
active: boolean;
/** True while the run is paused on a confirmation (HITL). */
awaitingInput?: boolean;
}>(),
{ awaitingInput: false },
);
const i18n = useI18n();
const { getToolLabel } = useToolLabel();
/** Manual toggle override; null = follow the default (collapsed). */
const userToggled = ref<boolean | null>(null);
const expanded = computed(() => userToggled.value ?? false);
// When streaming ends, return to the default state — a block expanded
// mid-stream collapses back to "Thought for Xs", like sub-agent sections.
watch(
() => props.active,
() => {
userToggled.value = null;
},
);
const toolCallsById = computed(() => {
const map: Record<string, InstanceAiToolCallState> = {};
for (const tc of props.agentNode.toolCalls) {
map[tc.toolCallId] = tc;
}
return map;
});
function toolCallFor(entry: InstanceAiTimelineEntry): InstanceAiToolCallState | undefined {
return entry.type === 'tool-call' ? toolCallsById.value[entry.toolCallId] : undefined;
}
/**
* The block's latest tool call while it is still the tail entry — it labels
* the subline, naturally handing back to "Thinking" when further trace
* content streams (no longer the tail) and swapping when another tool call
* starts. Cleared when the run settles or pauses for input.
*/
const tailToolCall = computed<InstanceAiToolCallState | undefined>(() => {
if (!props.active || props.awaitingInput) return undefined;
const last = props.entries[props.entries.length - 1];
if (!last || last.type !== 'tool-call') return undefined;
return toolCallsById.value[last.toolCallId];
});
// Block-level elapsed: a single monotonic counter for the counting phase,
// shown on the subline. Deliberately NOT per tool call — parallel or
// back-to-back calls would reset it on every handoff. An HITL pause resets
// it to zero (waiting for the user isn't thinking time); settling freezes it
// so the done title can report the time the user actually watched, which
// tool-call timestamps undercount for reasoning-heavy blocks.
const nowMs = ref(Date.now());
const activeSinceMs = ref<number | null>(null);
const settledElapsedSec = ref(0);
let ticker: ReturnType<typeof setInterval> | null = null;
const blockElapsedSec = computed(() => {
const live =
activeSinceMs.value === null
? 0
: Math.max(0, Math.floor((nowMs.value - activeSinceMs.value) / 1000));
return settledElapsedSec.value + live;
});
const isCounting = computed(() => props.active && !props.awaitingInput);
watch(
isCounting,
(counting) => {
if (counting) {
nowMs.value = Date.now();
activeSinceMs.value = Date.now();
ticker ??= setInterval(() => {
nowMs.value = Date.now();
}, 1000);
} else {
// Settling keeps the observed total for the done title; an HITL
// pause discards it — the timer restarts at zero on resume.
settledElapsedSec.value = props.awaitingInput ? 0 : blockElapsedSec.value;
activeSinceMs.value = null;
if (ticker) {
clearInterval(ticker);
ticker = null;
}
}
},
{ immediate: true },
);
onUnmounted(() => {
if (ticker) clearInterval(ticker);
});
/** Subline text: the running tool call's label, or a plain "Thinking" while
* the model reasons between calls — the block always carries a live signal. */
const sublineLabel = computed<string>(() => {
const tc = tailToolCall.value;
if (tc) return getToolLabel(tc.toolName, tc.args);
return i18n.baseText('instanceAi.thinking.active');
});
/** Live elapsed label — on the subline while collapsed, in the header while
* expanded (the subline is hidden then, but the timing must not vanish). */
const elapsedLabel = computed<string | undefined>(() => {
const sec = blockElapsedSec.value;
return sec >= 1 ? formatDuration(sec) : undefined;
});
/** Total duration from the block's tool-call timestamps, if available. */
const durationSec = computed<number | undefined>(() => {
let start: number | undefined;
let end: number | undefined;
for (const entry of props.entries) {
const tc = toolCallFor(entry);
if (!tc) continue;
const startedAt = tc.startedAt ? Date.parse(tc.startedAt) : NaN;
const completedAt = tc.completedAt ? Date.parse(tc.completedAt) : NaN;
if (!Number.isNaN(startedAt))
start = start === undefined ? startedAt : Math.min(start, startedAt);
if (!Number.isNaN(completedAt))
end = end === undefined ? completedAt : Math.max(end, completedAt);
}
if (start === undefined || end === undefined || end < start) return undefined;
return Math.max(1, Math.round((end - start) / 1000));
});
function formatDuration(totalSec: number): string {
if (totalSec < 60) return `${totalSec}s`;
return `${Math.floor(totalSec / 60)}m ${totalSec % 60}s`;
}
/**
* Header text plus a key that changes only when a DIFFERENT trace segment
* takes over the line — so segment replacements fade while token growth
* within a segment updates in place.
*/
const title = computed<{ key: string; text: string }>(() => {
if (!props.active) {
// Prefer whichever is larger: the tool-call span (survives reloads) or
// the elapsed time this client actually observed (covers reasoning
// before/after tool calls — "watched it think for 8s, says 1s" reads
// as a bug).
const observed = settledElapsedSec.value >= 1 ? settledElapsedSec.value : undefined;
const span = durationSec.value;
const duration =
span === undefined ? observed : observed === undefined ? span : Math.max(span, observed);
const text =
duration === undefined
? i18n.baseText('instanceAi.thinking.doneFallback')
: i18n.baseText('instanceAi.thinking.done', {
interpolate: { duration: formatDuration(duration) },
});
return { key: 'done', text };
}
if (props.awaitingInput) {
return { key: 'waiting', text: i18n.baseText('instanceAi.thinking.waitingForInput') };
}
// While streaming: the first sentence of the LATEST trace segment
// (narration or reasoning), replaced when a new segment starts. The line
// stays put on expand/collapse — swapping it for a generic "Thinking" read
// as instability. Segments whose streamed content is still blank (reasoning
// often opens with whitespace) don't take over the line — the previous
// segment keeps it until real text arrives. Reading entry content here is
// intentional — this is the only branch that re-renders per token.
for (let i = props.entries.length - 1; i >= 0; i--) {
const entry = props.entries[i];
if (entry.type !== 'text' && entry.type !== 'reasoning') continue;
const sentence = firstSentence(entry.content);
if (sentence) return { key: `segment-${i}`, text: sentence };
}
return { key: 'active', text: i18n.baseText('instanceAi.thinking.active') };
});
</script>
<template>
<CollapsibleRoot
:open="expanded"
data-test-id="instance-ai-thinking-block"
@update:open="(value) => (userToggled = value)"
>
<CollapsibleTrigger as-child>
<button
type="button"
:class="$style.header"
:aria-expanded="expanded"
data-test-id="thinking-block-header"
>
<Transition name="thinking-title" mode="out-in">
<span :key="title.key" :class="$style.title">
{{ title.text }}
</span>
</Transition>
<span v-if="expanded && isCounting && elapsedLabel" :class="$style.headerElapsed">
{{ elapsedLabel }}
</span>
<N8nAiActivityStepChevron :open="expanded" />
</button>
</CollapsibleTrigger>
<div
v-if="props.active && !props.awaitingInput && !expanded"
:class="$style.subline"
data-test-id="thinking-block-subline"
>
<!-- No fade on label swaps — a fade blanks the line for a frame,
which reads as flicker on quick tool handoffs. -->
<span :class="$style.sublineLabel">{{ sublineLabel }}</span>
<span v-if="elapsedLabel" :class="$style.sublineElapsed"> &middot; {{ elapsedLabel }} </span>
</div>
<N8nAnimatedCollapsibleContent>
<div :class="$style.content">
<template v-for="(entry, idx) in props.entries" :key="idx">
<!-- Intermediate narration message -->
<div v-if="entry.type === 'text'" :class="$style.thought">
<InstanceAiMarkdown
:content="entry.content"
:streaming="isStreamingTimelineEntry(props.agentNode, entry)"
/>
</div>
<!-- Raw reasoning tokens — nested collapsible, capped scroll -->
<ReasoningBlock
v-else-if="entry.type === 'reasoning'"
:entry="entry"
:streaming="isStreamingTimelineEntry(props.agentNode, entry)"
/>
<!-- Tool call row -->
<N8nAiActivityStep
v-else-if="entry.type === 'tool-call' && toolCallFor(entry)"
:label="getToolLabel(toolCallFor(entry)!.toolName, toolCallFor(entry)!.args)"
:loading="toolCallFor(entry)!.isLoading"
:error="toolCallFor(entry)!.error"
>
<ToolResultJson v-if="toolCallFor(entry)!.args" :value="toolCallFor(entry)!.args" />
<ToolResultRenderer
v-if="toolCallFor(entry)!.result !== undefined"
:result="toolCallFor(entry)!.result"
:tool-name="toolCallFor(entry)!.toolName"
:tool-args="toolCallFor(entry)!.args"
/>
</N8nAiActivityStep>
</template>
</div>
</N8nAnimatedCollapsibleContent>
</CollapsibleRoot>
</template>
<style lang="scss" scoped>
/* Old text drops instantly, new text fades in — with mode="out-in" a leave
* transition would blank the line for its full duration (worse when segment
* handoffs chain), so only the enter side animates. */
.thinking-title-enter-active {
transition: opacity var(--duration--snappy) ease;
}
.thinking-title-enter-from {
opacity: 0;
}
</style>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
width: 100%;
border: 0;
background: transparent;
padding: var(--spacing--4xs) 0;
cursor: pointer;
text-align: left;
color: var(--text-color--subtler);
font-size: var(--font-size--sm);
/* The design-system reset bolds all <button> elements */
font-weight: var(--font-weight--regular);
line-height: var(--line-height--lg);
&:hover {
color: var(--text-color--subtle);
}
}
.title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.headerElapsed {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.subline {
display: flex;
align-items: baseline;
gap: var(--spacing--4xs);
max-width: 90%;
font-size: var(--font-size--sm);
line-height: var(--line-height--lg);
color: var(--text-color--subtler);
}
/* Shimmer covers the label only — the timer stays static, matching the
* pre-trace status bar. */
.sublineLabel {
--animation--shimmer--duration: 1.5s;
--animation--shimmer--background: color-mix(
in srgb,
var(--text-color--subtler) 30%,
var(--background--subtle) 70%
);
--animation--shimmer--foreground: var(--text-color--subtler);
@include motion.shimmer;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sublineElapsed {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
/* Indented rail, like sub-agent sections */
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding: var(--spacing--3xs) 0 var(--spacing--3xs) var(--spacing--2xs);
border-left: var(--border);
margin-left: var(--spacing--xs);
}
.thought {
font-size: var(--font-size--sm);
line-height: var(--line-height--xl);
color: var(--color--text--tint-1);
max-width: 95%;
}
</style>
@@ -154,6 +154,7 @@ function hasSafeEventKeys(event: InstanceAiEvent): boolean {
return event.payload.messageGroupId ? isSafeObjectKey(event.payload.messageGroupId) : true;
case 'agent-spawned':
return isSafeObjectKey(event.payload.parentId);
case 'tool-input-start':
case 'tool-call':
case 'tool-result':
case 'tool-error':
@@ -285,6 +286,7 @@ export function handleEvent(state: InstanceAiReducerState, event: InstanceAiEven
return state.activeRunId;
}
case 'tool-input-start':
case 'tool-call':
case 'tool-result':
case 'tool-error':
@@ -122,7 +122,8 @@ export function getToolIcon(toolName: string): IconName {
return 'workflow';
}
if (toolName.includes('credential')) return 'key-round';
return 'settings';
// Fallback for tools without a dedicated mapping.
return 'wrench';
}
/**
@@ -1,207 +0,0 @@
import type {
InstanceAiAgentNode,
InstanceAiTimelineEntry,
InstanceAiToolCallState,
} from '@n8n/api-types';
import type { IconName } from '@n8n/design-system';
import { computed, type ComputedRef, type Ref } from 'vue';
import { extractArtifacts, HIDDEN_TOOLS, type ArtifactInfo } from './agentTimeline.utils';
/** Render hints for tool calls that show as special UI — not as generic "tool call" steps. */
const SPECIAL_RENDER_HINTS = new Set(['tasks', 'builder', 'data-table', 'eval-setup']);
/** Returns true if a tool call renders as a generic ToolCallStep (not special UI). */
function isGenericToolCall(tc: InstanceAiToolCallState): boolean {
if (HIDDEN_TOOLS.has(tc.toolName)) return false;
if (tc.renderHint && SPECIAL_RENDER_HINTS.has(tc.renderHint)) return false;
if (tc.confirmation?.inputType === 'questions' || tc.confirmation?.inputType === 'plan-review') {
return false;
}
return true;
}
export interface ResponseGroupSegment {
kind: 'response-group';
responseId: string | undefined;
entries: InstanceAiTimelineEntry[];
/** Visible tool call count (excludes hidden and special-render tools). */
toolCallCount: number;
/** Number of text entries inside this group (intermediate thinking text). */
textCount: number;
/** Number of reasoning segments in this group. */
reasoningCount: number;
/** Number of answered question forms in this group. */
questionCount: number;
/** Number of child agent entries in this group. */
childCount: number;
/** Artifacts produced by child agents in this group. */
artifacts: ArtifactInfo[];
}
export interface TrailingTextSegment {
kind: 'trailing-text';
content: string;
}
export type TimelineSegment = ResponseGroupSegment | TrailingTextSegment;
/**
* Groups an agent's timeline for collapsed rendering when the run is complete.
*
* Text entries are always rendered inline (visible). Reasoning segments, tool
* calls, and child agents are grouped into collapsible `response-group`
* segments. Text splits groups — even entries from the same API response are
* separated if text appears between them.
*
* Returns null when grouping is unavailable (no `responseId` data, or nothing to collapse).
*/
export function useTimelineGrouping(
agentNode: Ref<InstanceAiAgentNode> | ComputedRef<InstanceAiAgentNode>,
): ComputedRef<TimelineSegment[] | null> {
return computed(() => {
// Skip grouping while the agent is still running — the result is only
// used for the collapsed/completed view and recomputing on every SSE
// chunk is wasted work.
if (agentNode.value.status === 'active') return null;
const timeline = agentNode.value.timeline;
if (timeline.length === 0) return null;
// Check if any entry has a responseId — if not, skip grouping (old data).
const hasResponseIds = timeline.some((e) => e.responseId !== undefined);
if (!hasResponseIds) return null;
// Build segments: text entries are always inline (visible), non-text
// entries are grouped. Text splits groups — even within the same responseId.
const segments: TimelineSegment[] = [];
let currentGroup: ResponseGroupSegment | null = null;
function newGroup(responseId: string | undefined): ResponseGroupSegment {
return {
kind: 'response-group',
responseId,
entries: [],
toolCallCount: 0,
textCount: 0,
reasoningCount: 0,
questionCount: 0,
childCount: 0,
artifacts: [],
};
}
function appendArtifacts(group: ResponseGroupSegment, artifacts: ArtifactInfo[]) {
for (const artifact of artifacts) {
if (!group.artifacts.some((existing) => existing.resourceId === artifact.resourceId)) {
group.artifacts.push(artifact);
}
}
}
for (const entry of timeline) {
if (entry.type === 'text') {
// Text from the same API response as the current group stays inside
// (intermediate "thinking" text). Otherwise it renders inline.
if (currentGroup && entry.responseId === currentGroup.responseId) {
currentGroup.entries.push(entry);
currentGroup.textCount++;
} else {
currentGroup = null;
segments.push({ kind: 'trailing-text', content: entry.content });
}
} else if (entry.type === 'reasoning') {
// Reasoning opens (or joins) its step's group — it precedes the
// step's tool calls, so they collapse together.
if (!currentGroup || currentGroup.responseId !== entry.responseId) {
currentGroup = newGroup(entry.responseId);
segments.push(currentGroup);
}
currentGroup.entries.push(entry);
currentGroup.reasoningCount++;
} else if (entry.type === 'tool-call') {
if (!currentGroup || currentGroup.responseId !== entry.responseId) {
currentGroup = newGroup(entry.responseId);
segments.push(currentGroup);
}
currentGroup.entries.push(entry);
const tc = agentNode.value.toolCalls.find((t) => t.toolCallId === entry.toolCallId);
if (tc && isGenericToolCall(tc)) {
currentGroup.toolCallCount++;
} else if (tc?.confirmation?.inputType === 'questions' && !tc.isLoading) {
currentGroup.questionCount++;
}
if (tc) {
appendArtifacts(
currentGroup,
extractArtifacts({
...agentNode.value,
targetResource: undefined,
toolCalls: [tc],
children: [],
timeline: [],
}),
);
}
} else if (entry.type === 'child') {
if (!currentGroup || currentGroup.responseId !== entry.responseId) {
currentGroup = newGroup(entry.responseId);
segments.push(currentGroup);
}
currentGroup.entries.push(entry);
currentGroup.childCount++;
const child = agentNode.value.children.find((c) => c.agentId === entry.agentId);
if (child) {
appendArtifacts(currentGroup, extractArtifacts(child));
}
}
}
// Extract trailing text from each response group — the last text entry
// is usually the conclusion after tool calls and should be visible.
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
if (seg.kind !== 'response-group') continue;
const last = seg.entries.at(-1);
if (last?.type === 'text') {
seg.entries.pop();
seg.textCount--;
segments.splice(i + 1, 0, { kind: 'trailing-text', content: last.content });
}
}
// Drop empty response groups (only hidden tool calls, no visible content).
const flattened = segments.filter((seg) => {
if (seg.kind !== 'response-group') return true;
return (
seg.toolCallCount > 0 ||
seg.childCount > 0 ||
seg.artifacts.length > 0 ||
seg.reasoningCount > 0
);
});
// If there are no collapsible response groups, skip grouping entirely.
const hasCollapsibleGroups = flattened.some((s) => s.kind === 'response-group');
if (!hasCollapsibleGroups) return null;
return flattened;
});
}
/** Collect distinct tool icons from tool calls within a group's entries. */
export function getGroupToolIcons(
group: ResponseGroupSegment,
toolCalls: InstanceAiToolCallState[],
getIcon: (toolName: string) => IconName,
): IconName[] {
const icons = new Set<IconName>();
for (const entry of group.entries) {
if (entry.type === 'tool-call') {
const tc = toolCalls.find((t) => t.toolCallId === entry.toolCallId);
if (tc && !HIDDEN_TOOLS.has(tc.toolName)) {
icons.add(getIcon(tc.toolName));
}
}
}
return [...icons];
}
@@ -32,6 +32,9 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
if (key === 'instanceAi.credential.setupTitle' && opts?.interpolate?.name) {
return `Set up ${opts.interpolate.name}`;
}
if (key === 'instanceAi.workflowSetup.configureNode' && opts?.interpolate?.name) {
return `Configure '${opts.interpolate.name}'`;
}
return key;
},
}),
@@ -127,7 +130,7 @@ describe('WorkflowSetupCard', () => {
props: { section },
});
expect(getByText('HTTP Request')).toBeInTheDocument();
expect(getByText("Configure 'HTTP Request'")).toBeInTheDocument();
expect(queryByText('Set up Header Auth')).not.toBeInTheDocument();
expect(getByTestId('node-icon')).toBeInTheDocument();
expect(queryByTestId('credential-icon')).not.toBeInTheDocument();
@@ -35,7 +35,11 @@ const isCredentialOnlySection = computed(
const displayName = computed(() => {
const credentialTypeName = credentialType.value;
if (!isCredentialOnlySection.value || !credentialTypeName) return props.section.node.name;
if (!isCredentialOnlySection.value || !credentialTypeName) {
return i18n.baseText('instanceAi.workflowSetup.configureNode', {
interpolate: { name: props.section.node.name },
});
}
const raw =
credentialsStore.getCredentialTypeByName(credentialTypeName)?.displayName ?? credentialTypeName;
const appName = getAppNameFromCredType(raw);
@@ -28,7 +28,12 @@ vi.mock('@/app/stores/nodeTypes.store', () => ({
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({
baseText: (key: string) => key,
baseText: (key: string, opts?: { interpolate?: Record<string, string> }) => {
if (key === 'instanceAi.workflowSetup.configureNode' && opts?.interpolate?.name) {
return `Configure '${opts.interpolate.name}'`;
}
return key;
},
}),
}));
@@ -110,7 +115,7 @@ describe('WorkflowSetupGroupCard', () => {
const { getByText, getAllByTestId } = renderComponent({ props: { group } });
expect(getByText('Agent')).toBeInTheDocument();
expect(getByText("Configure 'Agent'")).toBeInTheDocument();
const icons = getAllByTestId('node-icon');
expect(icons[0].getAttribute('data-node-type')).toBe('agentType');
});
@@ -40,7 +40,11 @@ function getSectionNodeType(section: WorkflowSetupSection) {
<header :class="$style.header">
<NodeIcon :node-type="subnodeRootNodeType" :size="16" />
<N8nText :class="$style.title" size="medium" color="text-dark" bold>
{{ group.subnodeRootNode.name }}
{{
i18n.baseText('instanceAi.workflowSetup.configureNode', {
interpolate: { name: group.subnodeRootNode.name },
})
}}
</N8nText>
<N8nText
v-if="isGroupComplete"