From cdf9b4ffb088ad86fb223967f4410957fb700bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Thu, 21 May 2026 12:43:15 +0200 Subject: [PATCH] feat(editor): Open workflow artifact when builder spawns to edit it (#30862) --- .../__tests__/canvasPreview.utils.test.ts | 94 +++++++++++++ .../__tests__/useCanvasPreview.test.ts | 123 +++++++++++++++++ .../__tests__/useResourceRegistry.test.ts | 126 ++++++++++++++++++ .../ai/instanceAi/canvasPreview.utils.ts | 31 +++++ .../ai/instanceAi/useCanvasPreview.ts | 30 +++++ .../ai/instanceAi/useResourceRegistry.ts | 18 +++ 6 files changed, 422 insertions(+) diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/canvasPreview.utils.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/canvasPreview.utils.test.ts index e7602d496fa..e23b8d334fc 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/canvasPreview.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/canvasPreview.utils.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts index 0b1bbbdec09..3093ee41768 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useResourceRegistry.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useResourceRegistry.test.ts index 1b7782b5529..d573af880aa 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useResourceRegistry.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useResourceRegistry.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/canvasPreview.utils.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/canvasPreview.utils.ts index fad708e80c4..c59434ad029 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/canvasPreview.utils.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/canvasPreview.utils.ts @@ -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']); /** diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts index 391c82d92a2..8034cc2bb23 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts @@ -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. diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/useResourceRegistry.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/useResourceRegistry.ts index 9bc3fec85f5..918d8e5ec29 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/useResourceRegistry.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/useResourceRegistry.ts @@ -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); }