From 9f0e653c5ae656641a2ba5cf9fd0abd052d94b19 Mon Sep 17 00:00:00 2001 From: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:49:17 +0000 Subject: [PATCH] feat(editor): Add the n8n Assistant panel to the agent builder (#38726) --- .../frontend/@n8n/i18n/src/locales/en.json | 3 + .../AgentBuilderEditorColumn.spec.ts | 24 + .../__tests__/AgentBuilderHeader.test.ts | 58 +- .../agents/__tests__/AgentBuilderView.test.ts | 692 ++++++++++++++--- .../agents/__tests__/AgentInfoPanel.spec.ts | 27 + .../__tests__/AgentModelSelector.spec.ts | 20 +- .../__tests__/useAgentConfigAutosave.test.ts | 24 + .../components/AgentBuilderEditorColumn.vue | 4 +- .../agents/components/AgentBuilderHeader.vue | 19 + .../agents/components/AgentInfoPanel.vue | 26 +- .../agents/components/AgentModelSelector.vue | 20 +- .../composables/useAgentConfigAutosave.ts | 8 + .../src/features/agents/constants.ts | 2 + .../agents/views/AgentBuilderView.vue | 372 ++++++--- .../ai/instanceAi/InstanceAiThreadView.vue | 21 +- .../__tests__/useInstanceAiHandoff.test.ts | 60 +- .../components/InstanceAiConversation.vue | 21 +- .../components/InstanceAiThreadList.vue | 101 ++- .../components/InstanceAiViewHeader.vue | 35 +- .../__tests__/InstanceAiConversation.test.ts | 82 ++ .../__tests__/InstanceAiThreadList.test.ts | 139 ++++ .../composables/useInstanceAiHandoff.ts | 82 +- .../src/features/ai/instanceAi/constants.ts | 2 + .../instanceAi/embed/InstanceAiChatPanel.vue | 482 ++++++++++++ .../__tests__/InstanceAiChatPanel.test.ts | 730 ++++++++++++++++++ .../__tests__/instanceAiEmbed.types.test.ts | 46 ++ .../useInstanceAiEmbedThreads.test.ts | 42 + .../instanceAi/embed/instanceAiEmbed.types.ts | 43 ++ .../embed/useInstanceAiEmbedThreads.ts | 17 + .../ai/instanceAi/instanceAi.threadRuntime.ts | 21 + 30 files changed, 2965 insertions(+), 258 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiThreadList.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/embed/InstanceAiChatPanel.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/InstanceAiChatPanel.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/instanceAiEmbed.types.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/useInstanceAiEmbedThreads.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/embed/instanceAiEmbed.types.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/embed/useInstanceAiEmbedThreads.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 26537fc7202..42b32e673b2 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -6930,6 +6930,8 @@ "focusedNodes.unconfirmedTooltip": "Add as context for the assistant", "instanceAi.view.title": "n8n Assistant", "instanceAi.view.subtitle": "Chat with your n8n instance", + "instanceAi.embed.openFullAssistant": "Open in n8n Assistant", + "instanceAi.embed.close": "Close assistant", "instanceAi.askAiAssistant": "Ask n8n Assistant", "instanceAi.canvasActionPopover.title": "Try new n8n Assistant", "instanceAi.canvasActionPopover.body": "Building, questions, and debugging with AI now happen in the n8n Assistant.", @@ -8272,6 +8274,7 @@ "agents.builder.header.switcher.ariaLabel": "Switch agent", "agents.builder.header.saving": "Saving…", "agents.builder.header.saved": "Saved", + "agents.builder.header.editWithAi": "Edit with n8n Assistant", "agents.builder.preview.button": "Preview", "agents.builder.preview.showChat": "Show chat", "agents.builder.preview.disabledTooltip": "Can't preview agent yet", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts index 4d386f72f8e..5eebf699fe4 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts @@ -196,6 +196,7 @@ async function mountColumn( template: '
', props: ['showModel', 'showInstructions'], + emits: ['update:config'], }, AgentAdvancedPanel: true, AgentSessionsListView: true, @@ -278,6 +279,29 @@ describe('AgentBuilderEditorColumn', () => { expect(wrapper.emitted('generate-eval-cases')).toHaveLength(1); }); + it('forwards the auto-applied-default meta from AgentInfoPanel to the host', async () => { + const wrapper = await mountColumn(); + + wrapper + .getComponent({ name: 'AgentInfoPanel' }) + .vm.$emit('update:config', { model: 'openai/gpt-5-mini' }, { source: 'auto' }); + + expect(wrapper.emitted('update:config')?.[0]).toEqual([ + { model: 'openai/gpt-5-mini' }, + { source: 'auto' }, + ]); + }); + + it('forwards a user-driven AgentInfoPanel config change without meta', async () => { + const wrapper = await mountColumn(); + + wrapper + .getComponent({ name: 'AgentInfoPanel' }) + .vm.$emit('update:config', { instructions: 'x' }); + + expect(wrapper.emitted('update:config')?.[0]).toEqual([{ instructions: 'x' }, undefined]); + }); + it('disables the evals CTA for a read-only agent', async () => { const wrapper = await mountColumn({ activeMainTab: 'evals', canEditAgent: false }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts index 09e5dff0938..e0ce5538be0 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts @@ -34,6 +34,11 @@ vi.mock('vue-router', () => ({ })); vi.mock('@n8n/design-system', () => ({ + N8nAssistantIcon: { + name: 'N8nAssistantIcon', + template: '', + props: ['size'], + }, N8nIcon: { template: '', props: ['icon', 'size'] }, N8nButton: { template: @@ -135,6 +140,8 @@ function mountHeader( mode: 'edit' | 'preview'; artifactMode: boolean; isPreviewOpen: boolean; + instanceAiAvailable: boolean; + isAiPanelOpen: boolean; currentSessionTitle: string; sessionOptions: Array<{ id: string; label: string }>; configValidationStatus: 'valid' | 'invalid' | null; @@ -143,7 +150,7 @@ function mountHeader( ) { return mount(AgentBuilderHeader, { props: { - agent: overrides.agent ?? baseAgent, + agent: 'agent' in overrides ? (overrides.agent ?? null) : baseAgent, projectId: 'p1', agentId: 'a1', projectName: 'projectName' in overrides ? (overrides.projectName ?? null) : 'My project', @@ -151,6 +158,8 @@ function mountHeader( mode: overrides.mode, artifactMode: overrides.artifactMode, isPreviewOpen: overrides.isPreviewOpen, + instanceAiAvailable: overrides.instanceAiAvailable, + isAiPanelOpen: overrides.isAiPanelOpen, currentSessionTitle: overrides.currentSessionTitle, sessionOptions: overrides.sessionOptions, configValidationStatus: overrides.configValidationStatus, @@ -168,6 +177,53 @@ describe('AgentBuilderHeader', () => { agentsListRef.value = null; }); + it('shows the Instance AI toggle when available', () => { + const wrapper = mountHeader({ instanceAiAvailable: true, isAiPanelOpen: false }); + const button = wrapper.get('[data-testid="agent-builder-instance-ai-btn"]'); + + expect(button.attributes('aria-label')).toBe('agents.builder.header.editWithAi'); + expect(button.attributes('aria-pressed')).toBe('false'); + }); + + it('reflects isAiPanelOpen as aria-pressed', () => { + const wrapper = mountHeader({ instanceAiAvailable: true, isAiPanelOpen: true }); + + expect( + wrapper.get('[data-testid="agent-builder-instance-ai-btn"]').attributes('aria-pressed'), + ).toBe('true'); + }); + + it('emits toggle-instance-ai on click', async () => { + const wrapper = mountHeader({ instanceAiAvailable: true }); + + await wrapper.get('[data-testid="agent-builder-instance-ai-btn"]').trigger('click'); + + expect(wrapper.emitted('toggle-instance-ai')).toEqual([[]]); + }); + + it.each([ + { label: 'Instance AI is unavailable', instanceAiAvailable: false }, + { label: 'artifact mode is active', instanceAiAvailable: true, artifactMode: true }, + ])('hides the Instance AI toggle when $label', (overrides) => { + const wrapper = mountHeader(overrides); + + expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(false); + }); + + it('stays visible while the preview is open (both docks can coexist)', () => { + const wrapper = mountHeader({ instanceAiAvailable: true, isPreviewOpen: true }); + + expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(true); + }); + + it('disables the Instance AI toggle when no agent is loaded', () => { + const wrapper = mountHeader({ agent: null, instanceAiAvailable: true }); + + expect( + wrapper.get('[data-testid="agent-builder-instance-ai-btn"]').attributes('disabled'), + ).toBeDefined(); + }); + it('renders breadcrumbs, publish and action dropdown', () => { const wrapper = mountHeader({ headerActions: [{ id: 'delete', label: 'Delete' }] }); expect(wrapper.find('[data-testid="stub-breadcrumbs"]').exists()).toBe(true); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index 840fd5e25db..97aad22f5e1 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -14,12 +14,25 @@ import type { } from '../types'; import { getRandomAgentPersonalisationGradient } from '@n8n/api-types'; import { agentsEventBus } from '../agents.eventBus'; -import { NEW_SESSION_PARAM, OPEN_PREVIEW_PARAM } from '../constants'; +import { + AGENT_BUILDER_VIEW, + AGENT_PREVIEW_VIEW, + NEW_SESSION_PARAM, + OPEN_PREVIEW_PARAM, +} from '../constants'; const routerPush = vi.fn(); const routerReplace = vi.fn(); const routeQuery = reactive>({}); const routeParams = reactive({ projectId: 'p1', agentId: 'a1' }); +// A single reactive `route` (rather than a fresh plain object per `useRoute()` +// call) so mutating `.name` — e.g. to the standalone preview route — reacts +// like `params`/`query` mutations already do. +const routeState = reactive({ + name: AGENT_BUILDER_VIEW as string, + params: routeParams, + query: routeQuery, +}); type RouteGuard = (to: { params: Record }) => void | Promise; const routeGuards: { leave?: RouteGuard; update?: RouteGuard } = {}; const openModalWithDataMock = vi.fn(); @@ -28,7 +41,7 @@ const showMessageMock = vi.fn(); const showErrorMock = vi.fn(); const pushConnectMock = vi.fn(); const pushListeners = new Set<(event: PushMessage) => void>(); -const sendPreviewSessionToInstanceAiMock = vi.fn(); +const handoffMock = vi.fn(); let createObjectURLSpy: ReturnType | undefined; let revokeObjectURLSpy: ReturnType | undefined; let anchorClickSpy: ReturnType | undefined; @@ -55,12 +68,9 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush, replace: routerReplace, + resolve: () => ({ href: '#' }), }), - useRoute: () => ({ - name: 'AgentBuilderView', - params: routeParams, - query: routeQuery, - }), + useRoute: () => routeState, onBeforeRouteLeave: (guard: RouteGuard) => { routeGuards.leave = guard; }, @@ -100,7 +110,6 @@ vi.mock('@n8n/composables/useTelemetry', () => ({ vi.mock('@/features/ai/instanceAi/composables/useInstanceAiAgentPreviewHandoff', () => ({ useInstanceAiAgentPreviewHandoff: () => ({ canSendPreviewToInstanceAi: ref(true), - sendPreviewSessionToInstanceAi: sendPreviewSessionToInstanceAiMock, }), })); @@ -340,17 +349,15 @@ vi.mock('../composables/useProjectAgentsList', () => ({ })); const instanceAiAvailableRef = ref(true); +const instanceAiReadyRef = ref(true); vi.mock('@/features/ai/instanceAi/composables/useInstanceAiAvailability', () => ({ useInstanceAiAvailable: () => computed(() => instanceAiAvailableRef.value), + useInstanceAiReady: () => computed(() => instanceAiReadyRef.value), })); -const startInstanceAiThread = vi.fn(); -const openAgentArtifactThread = vi.fn(); -vi.mock('@/features/ai/instanceAi/composables/useInstanceAiHandoff', () => ({ - useInstanceAiHandoff: () => ({ - startThread: startInstanceAiThread, - openAgentArtifactThread, - }), +const persistPendingAgentMock = vi.fn(); +vi.mock('@/features/ai/instanceAi/instanceAi.memory.api', () => ({ + persistPendingAgent: (...args: unknown[]) => persistPendingAgentMock(...args), })); const baseTextFn = ( @@ -500,7 +507,7 @@ const commonStubs = { AgentBuilderHeader: { name: 'AgentBuilderHeader', template: - '
', + '
', props: [ 'agent', 'projectId', @@ -510,6 +517,8 @@ const commonStubs = { 'beforeRevertToPublished', 'artifactMode', 'isPreviewOpen', + 'instanceAiAvailable', + 'isAiPanelOpen', 'configValidationStatus', 'saveStatus', 'beforePublish', @@ -523,6 +532,7 @@ const commonStubs = { 'reverted', 'switch-agent', 'toggle-version-history', + 'toggle-instance-ai', ], }, AgentPreviewDock: { @@ -556,6 +566,24 @@ const commonStubs = { name: 'AgentVersionHistoryPanel', template: '