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: '