feat(editor): Open workflow artifact when builder spawns to edit it (#30862)

This commit is contained in:
Raúl Gómez Morales 2026-05-21 12:43:15 +02:00 committed by GitHub
parent b80738bb18
commit cdf9b4ffb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 422 additions and 0 deletions

View File

@ -2,6 +2,7 @@ import { describe, test, expect } from 'vitest';
import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types';
import {
getLatestBuildResult,
getLatestBuilderTarget,
getLatestExecutionId,
getLatestDataTableResult,
getLatestDeletedDataTableId,
@ -185,6 +186,99 @@ describe('getLatestBuildResult', () => {
});
});
describe('getLatestBuilderTarget', () => {
test('returns undefined for node with no children', () => {
expect(getLatestBuilderTarget(makeAgentNode())).toBeUndefined();
});
test('returns undefined when builder child has no targetResource id (create flow)', () => {
const builder = makeAgentNode({
agentId: 'agent-builder-1',
role: 'workflow-builder',
kind: 'builder',
status: 'active',
targetResource: { type: 'workflow' },
});
const parent = makeAgentNode({ children: [builder] });
expect(getLatestBuilderTarget(parent)).toBeUndefined();
});
test('returns agentId and workflowId when builder is spawned with targetResource.id (edit flow)', () => {
const builder = makeAgentNode({
agentId: 'agent-builder-1',
role: 'workflow-builder',
kind: 'builder',
status: 'active',
targetResource: { type: 'workflow', id: 'wf-existing' },
});
const parent = makeAgentNode({ children: [builder] });
expect(getLatestBuilderTarget(parent)).toEqual({
agentId: 'agent-builder-1',
workflowId: 'wf-existing',
});
});
test('detects builder by kind even when role differs', () => {
const builder = makeAgentNode({
agentId: 'agent-builder-2',
role: 'background-task',
kind: 'builder',
status: 'active',
targetResource: { type: 'workflow', id: 'wf-1' },
});
const parent = makeAgentNode({ children: [builder] });
expect(getLatestBuilderTarget(parent)?.workflowId).toBe('wf-1');
});
test('ignores non-workflow targetResource types', () => {
const credSetup = makeAgentNode({
agentId: 'agent-cred-1',
role: 'browser-credential-setup',
kind: 'browser-setup',
status: 'active',
targetResource: { type: 'credential', id: 'cred-1' },
});
const parent = makeAgentNode({ children: [credSetup] });
expect(getLatestBuilderTarget(parent)).toBeUndefined();
});
test('returns the latest builder when multiple are present', () => {
const builderA = makeAgentNode({
agentId: 'agent-builder-a',
role: 'workflow-builder',
kind: 'builder',
status: 'completed',
targetResource: { type: 'workflow', id: 'wf-a' },
});
const builderB = makeAgentNode({
agentId: 'agent-builder-b',
role: 'workflow-builder',
kind: 'builder',
status: 'active',
targetResource: { type: 'workflow', id: 'wf-b' },
});
const parent = makeAgentNode({ children: [builderA, builderB] });
expect(getLatestBuilderTarget(parent)?.workflowId).toBe('wf-b');
});
test('walks nested children depth-first, newest last', () => {
const nestedBuilder = makeAgentNode({
agentId: 'agent-builder-nested',
role: 'workflow-builder',
kind: 'builder',
status: 'active',
targetResource: { type: 'workflow', id: 'wf-nested' },
});
const intermediate = makeAgentNode({
agentId: 'agent-intermediate',
role: 'planner',
children: [nestedBuilder],
});
const parent = makeAgentNode({ children: [intermediate] });
expect(getLatestBuilderTarget(parent)?.workflowId).toBe('wf-nested');
});
});
describe('getLatestExecutionId', () => {
test('returns undefined for node with no tool calls', () => {
expect(getLatestExecutionId(makeAgentNode())).toBeUndefined();

View File

@ -398,6 +398,129 @@ describe('useCanvasPreview', () => {
});
});
describe('auto-open on builder spawn (edit flow)', () => {
test('opens canvas as soon as an edit-mode builder spawns with targetResource.id', async () => {
const ctx = setup();
registerWorkflow(ctx.thread, 'wf-existing', 'Existing WF');
ctx.thread.messages = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder-1',
role: 'workflow-builder',
kind: 'builder',
status: 'active',
targetResource: { type: 'workflow', id: 'wf-existing' },
}),
],
}),
}),
];
await nextTick();
expect(ctx.activeTabId.value).toBe('wf-existing');
expect(ctx.activeWorkflowId.value).toBe('wf-existing');
expect(ctx.isPreviewVisible.value).toBe(true);
});
test('does not open canvas when the builder has no targetResource id (create flow)', async () => {
const ctx = setup();
ctx.thread.messages = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder-1',
role: 'workflow-builder',
kind: 'builder',
status: 'active',
targetResource: { type: 'workflow' },
}),
],
}),
}),
];
await nextTick();
expect(ctx.activeTabId.value).toBeUndefined();
expect(ctx.isPreviewVisible.value).toBe(false);
});
test('does not open canvas while hydrating historical messages', async () => {
const ctx = setup();
ctx.thread.isHydratingThread = true;
registerWorkflow(ctx.thread, 'wf-historical', 'Past WF');
ctx.thread.messages = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder-historical',
role: 'workflow-builder',
kind: 'builder',
status: 'completed',
targetResource: { type: 'workflow', id: 'wf-historical' },
}),
],
}),
}),
];
await nextTick();
expect(ctx.activeTabId.value).toBeUndefined();
expect(ctx.isPreviewVisible.value).toBe(false);
});
test('switches to the latest edit target when a new builder spawns', async () => {
const ctx = setup();
registerWorkflow(ctx.thread, 'wf-a', 'WF A');
registerWorkflow(ctx.thread, 'wf-b', 'WF B');
ctx.thread.messages = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder-a',
role: 'workflow-builder',
kind: 'builder',
status: 'completed',
targetResource: { type: 'workflow', id: 'wf-a' },
}),
],
}),
}),
];
await nextTick();
expect(ctx.activeTabId.value).toBe('wf-a');
ctx.thread.messages = [
...ctx.thread.messages,
makeMessage({
id: 'msg-2',
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder-b',
role: 'workflow-builder',
kind: 'builder',
status: 'active',
targetResource: { type: 'workflow', id: 'wf-b' },
}),
],
}),
}),
];
await nextTick();
expect(ctx.activeTabId.value).toBe('wf-b');
});
});
describe('auto-open data table preview', () => {
test('auto-opens data table preview when streaming', async () => {
const ctx = setup();

View File

@ -158,6 +158,132 @@ describe('useResourceRegistry', () => {
});
});
describe('producedArtifacts — targetResource registration', () => {
test('registers a builder sub-agent targetResource as a produced workflow', async () => {
const { messages, producedArtifacts } = setup();
messages.value = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder-1',
role: 'workflow-builder',
kind: 'builder',
status: 'active',
targetResource: { type: 'workflow', id: 'wf-edit', name: 'Existing WF' },
}),
],
}),
}),
];
await nextTick();
expect(producedArtifacts.value.get('wf-edit')).toEqual(
expect.objectContaining({ type: 'workflow', id: 'wf-edit', name: 'Existing WF' }),
);
});
test('ignores targetResource without an id (create flow)', async () => {
const { messages, producedArtifacts } = setup();
messages.value = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder-1',
role: 'workflow-builder',
kind: 'builder',
status: 'active',
targetResource: { type: 'workflow' },
}),
],
}),
}),
];
await nextTick();
expect(producedArtifacts.value.size).toBe(0);
});
test('ignores credential targetResource (not surfaced in the artifacts panel)', async () => {
const { messages, producedArtifacts } = setup();
messages.value = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-cred-1',
role: 'browser-credential-setup',
kind: 'browser-setup',
status: 'active',
targetResource: { type: 'credential', id: 'cred-1' },
}),
],
}),
}),
];
await nextTick();
expect(producedArtifacts.value.size).toBe(0);
});
test('falls back to Untitled when targetResource has no name', async () => {
const { messages, producedArtifacts } = setup();
messages.value = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder-1',
role: 'workflow-builder',
kind: 'builder',
status: 'active',
targetResource: { type: 'workflow', id: 'wf-edit' },
}),
],
}),
}),
];
await nextTick();
expect(producedArtifacts.value.get('wf-edit')?.name).toBe('Untitled');
});
test('later build-workflow result overwrites the placeholder name', async () => {
const { messages, producedArtifacts } = setup();
messages.value = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder-1',
role: 'workflow-builder',
kind: 'builder',
status: 'completed',
targetResource: { type: 'workflow', id: 'wf-edit' },
toolCalls: [
makeToolCall({
toolName: 'submit-workflow',
result: { workflowId: 'wf-edit', workflowName: 'Renamed' },
}),
],
}),
],
}),
}),
];
await nextTick();
expect(producedArtifacts.value.size).toBe(1);
expect(producedArtifacts.value.get('wf-edit')?.name).toBe('Renamed');
});
});
describe('producedArtifacts — updates and merges', () => {
test('second write to the same workflow id updates the existing entry', async () => {
const { messages, producedArtifacts } = setup();

View File

@ -13,6 +13,12 @@ export interface BuildResult {
toolCallId: string;
}
export interface BuilderTarget {
/** Unique per spawn — changes even when a new builder targets the same workflow. */
agentId: string;
workflowId: string;
}
export interface WorkflowSetupResult {
workflowId: string;
/** Unique per operation — changes even when the same workflow is set up again. */
@ -51,6 +57,31 @@ export function getLatestBuildResult(node: InstanceAiAgentNode): BuildResult | u
return undefined;
}
/**
* Walks an agent tree depth-first (most recent last) and returns the agentId
* and workflowId of the latest workflow-builder sub-agent that was spawned
* with a concrete `targetResource.id` i.e. an edit-mode builder that
* already knows which existing workflow it is modifying. Used to open the
* canvas preview at spawn time, before the first build-workflow tool call
* returns a result.
*/
export function getLatestBuilderTarget(node: InstanceAiAgentNode): BuilderTarget | undefined {
for (let i = node.children.length - 1; i >= 0; i--) {
const child = node.children[i];
const nested = getLatestBuilderTarget(child);
if (nested) return nested;
const isBuilder = child.kind === 'builder' || child.role === 'workflow-builder';
if (
isBuilder &&
child.targetResource?.type === 'workflow' &&
typeof child.targetResource.id === 'string'
) {
return { agentId: child.agentId, workflowId: child.targetResource.id };
}
}
return undefined;
}
const WORKFLOW_SETUP_TOOLS = new Set(['setup-workflow', 'apply-workflow-credentials']);
/**

View File

@ -2,6 +2,7 @@ import { computed, ref, watch, type Ref } from 'vue';
import type { IconName } from '@n8n/design-system';
import {
getLatestBuildResult,
getLatestBuilderTarget,
getLatestExecutionId,
getLatestWorkflowSetupResult,
getLatestDataTableResult,
@ -173,6 +174,35 @@ export function useCanvasPreview({
{ flush: 'sync' },
);
// --- Auto-open canvas when an edit-mode builder spawns ---
// The workflow-builder carries the existing workflow id in
// `targetResource.id` from the moment it is spawned. Opening the preview
// then — instead of waiting for the first build-workflow result — lets the
// user see what is being edited as soon as the sub-agent is called.
// Keyed by agentId so a fresh builder spawn re-triggers the preview.
const latestBuilderTarget = computed(() => {
for (let i = thread.messages.length - 1; i >= 0; i--) {
const msg = thread.messages[i];
if (msg.agentTree) {
const target = getLatestBuilderTarget(msg.agentTree);
if (target) return target;
}
}
return null;
});
watch(
() => latestBuilderTarget.value?.agentId,
(agentId) => {
if (!agentId || !latestBuilderTarget.value) return;
if (thread.isHydratingThread) return;
activeTabId.value = latestBuilderTarget.value.workflowId;
},
{ flush: 'sync' },
);
// --- Refresh preview when setup-workflow / apply-workflow-credentials completes ---
// These tools modify the workflow (credentials, parameters) but aren't detected
// by getLatestBuildResult. Refresh the preview so the iframe shows the latest state.

View File

@ -197,7 +197,25 @@ function extractFromToolCall(tc: InstanceAiToolCallState, col: Collections): voi
}
}
/**
* Register the agent's `targetResource` as a produced artifact when it carries
* a concrete resource id (e.g. a workflow-builder spawned to edit an existing
* workflow). Surfacing this at spawn time before the first build-workflow
* tool result arrives lets the artifacts panel show the workflow as soon as
* the sub-agent starts, instead of waiting for the first edit.
*/
function extractFromTargetResource(node: InstanceAiAgentNode, col: Collections): void {
const target = node.targetResource;
if (!target?.id) return;
if (target.type !== 'workflow' && target.type !== 'data-table') return;
const existing = col.produced.get(target.id);
const name = optionalString(target.name) ?? existing?.name ?? 'Untitled';
recordProduced(col, { type: target.type, id: target.id, name });
}
function collectFromAgentNode(node: InstanceAiAgentNode, col: Collections): void {
extractFromTargetResource(node, col);
for (const tc of node.toolCalls) {
extractFromToolCall(tc, col);
}