mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-30 19:35:36 +08:00
feat(editor): Collapse AI Assistant thinking traces into thinking blocks (#34274)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
@@ -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' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
+7
-2
@@ -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.
|
||||
|
||||
+7
-7
@@ -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",
|
||||
|
||||
+354
@@ -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();
|
||||
});
|
||||
});
|
||||
+232
-77
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
-141
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+9
-84
@@ -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>
|
||||
|
||||
+56
-122
@@ -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)"
|
||||
|
||||
+1
-5
@@ -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">
|
||||
|
||||
+52
-77
@@ -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">·</span>
|
||||
<span v-if="activity.detail" :class="$style.detail">{{ activity.detail }}</span>
|
||||
<template v-if="!thread.isAwaitingConfirmation">
|
||||
<span :class="$style.separator">·</span>
|
||||
<span :class="$style.elapsed">{{ formattedElapsed }}</span>
|
||||
</template>
|
||||
<span v-if="activity.detail">·</span>
|
||||
<span v-if="activity.detail">{{ activity.detail }}</span>
|
||||
<span>·</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>
|
||||
|
||||
|
||||
+12
-8
@@ -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>
|
||||
+1
-3
@@ -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"> · {{ 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];
|
||||
}
|
||||
+4
-1
@@ -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();
|
||||
|
||||
+5
-1
@@ -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);
|
||||
|
||||
+7
-2
@@ -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');
|
||||
});
|
||||
|
||||
+5
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user