diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 663eea0fe99..e0ee3d18213 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -54,6 +54,8 @@ "generic.openResource": "Open {resource}", "generic.add": "Add", "generic.close": "Close", + "generic.expand": "Expand", + "generic.collapse": "Collapse", "generic.clear": "Clear", "generic.confirm": "Confirm", "generic.create": "Create", @@ -8353,6 +8355,19 @@ "agents.builder.sections.triggers": "Triggers", "agents.builder.agent.title": "Model & instructions", "agents.builder.agent.description": "Choose a model and give the agent instructions", + "agents.builder.setupTasks.title": "Finish setup", + "agents.builder.setupTasks.done": "Done!", + "agents.builder.setupTasks.remaining": "{count} remaining", + "agents.builder.setupTasks.tasksDone": "{count} tasks done", + "agents.builder.setupTasks.maximize": "Maximize", + "agents.builder.setupTasks.minimize": "Minimize", + "agents.builder.setupTasks.chooseModel": "Choose a model", + "agents.builder.setupTasks.addTool": "Add tools", + "agents.builder.setupTasks.addInstructions": "Add instructions", + "agents.builder.setupTasks.testAgent": "Test agent", + "agents.builder.setupTasks.addChannel": "Add channels", + "agents.builder.setupTasks.addChannel.description": "Add a channel to let this agent respond to events from a chat platform.", + "agents.builder.setupTasks.publishAgent": "Publish agent", "agents.builder.agent.name.label": "Name", "agents.builder.agent.name.placeholder": "My agent", "agents.builder.agent.personalisation.change": "Change agent icon", 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 2cabf8c7a90..7a7f8e5801c 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 @@ -139,7 +139,7 @@ const globalStubs = { 'configValidationStatus', 'beforePublish', ], - emits: ['published', 'unpublished', 'reverted'], + emits: ['publish-ready', 'published', 'unpublished', 'reverted'], }, }; @@ -315,6 +315,16 @@ describe('AgentBuilderHeader', () => { expect(wrapper.emitted('reverted')).toBeTruthy(); }); + it('forwards publish readiness changes', () => { + const wrapper = mountHeader(); + const publish = wrapper.findComponent({ name: 'AgentPublishButton' }); + + publish.vm.$emit('publish-ready', false); + publish.vm.$emit('publish-ready', true); + + expect(wrapper.emitted('publish-ready')).toEqual([[false], [true]]); + }); + it('forwards header-action from the action menu', () => { const wrapper = mountHeader({ headerActions: [{ id: 'delete', label: 'Delete' }] }); const action = getDropdown(wrapper, 'agent-header-actions'); 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 7f5c6780431..d409960c998 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 @@ -606,11 +606,13 @@ const commonStubs = { 'configValidationStatus', 'saveStatus', 'beforePublish', + 'tasks', ], emits: [ 'header-action', 'open-preview', 'close-preview', + 'publish-ready', 'published', 'unpublished', 'reverted', @@ -2807,6 +2809,73 @@ describe('AgentBuilderView — configuration validation', () => { expect(header.attributes('data-config-validation-status')).toBe('invalid'); }); + it.each(['tool', 'MCP server', 'both'])( + 'completes the add-tool setup task when adding %s', + async (kind) => { + const wrapper = await renderView(); + const header = wrapper.findComponent({ name: 'AgentBuilderHeader' }); + const vm = wrapper.vm as unknown as { + onConfigFieldUpdate: (updates: Partial) => void; + }; + + expect(header.props('tasks')).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'add-tool', state: 'todo' })]), + ); + + vm.onConfigFieldUpdate({ + tools: kind === 'MCP server' ? [] : [{ type: 'custom', id: 'custom_tool' }], + mcpServers: + kind === 'tool' + ? [] + : [ + { + name: 'Example MCP', + url: 'https://mcp.example.com', + authentication: 'none', + transport: 'streamableHttp', + }, + ], + }); + await nextTick(); + + expect(header.props('tasks')).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'add-tool', state: 'complete' })]), + ); + + vm.onConfigFieldUpdate({ tools: [], mcpServers: [] }); + await nextTick(); + + expect(header.props('tasks')).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'add-tool', state: 'todo' })]), + ); + }, + ); + + it('shows the publish setup task for a tested agent only when the publish button is ready', async () => { + fetchedSessionThreads.push({ id: 'thread-tested', updatedAt: '2026-01-02T00:00:00Z' }); + + const wrapper = await renderView(); + const header = wrapper.findComponent({ name: 'AgentBuilderHeader' }); + + expect(header.props('tasks')).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'publish-agent', visible: false })]), + ); + + header.vm.$emit('publish-ready', true); + await nextTick(); + + expect(header.props('tasks')).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'publish-agent', visible: true })]), + ); + + header.vm.$emit('publish-ready', false); + await nextTick(); + + expect(header.props('tasks')).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'publish-agent', visible: false })]), + ); + }); + it('flushes a pending config edit when the builder unmounts', async () => { const wrapper = await renderView(); updateConfigMock.mockClear(); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPublishButton.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPublishButton.test.ts index dac266c91c0..1bdb0144cf3 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPublishButton.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPublishButton.test.ts @@ -105,6 +105,7 @@ interface RenderProps { agent?: AgentResource | null; projectId?: string; agentId?: string; + isSaving?: boolean; beforeRevertToPublished?: () => Promise | void; configValidationStatus?: 'valid' | 'invalid' | null; configValidationIssues?: AgentConfigValidationIssue[]; @@ -462,6 +463,57 @@ describe('AgentPublishButton', () => { ).toBe('true'); }); + it('reports readiness when tool credentials and validation change', async () => { + const wrapper = await renderComponent({ + configValidationStatus: 'invalid', + configValidationIssues: [ + { + code: 'missing_credential', + path: 'tools.0.credential', + capability: { kind: 'tool', toolType: 'node', id: 'Lookup', index: 0 }, + }, + ], + }); + + expect(wrapper.emitted('publish-ready')).toEqual([[false]]); + + await wrapper.setProps({ configValidationStatus: 'valid', configValidationIssues: [] }); + expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]); + + await wrapper.setProps({ configValidationStatus: null }); + expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([false]); + + await wrapper.setProps({ + configValidationStatus: 'invalid', + configValidationIssues: [notPublishedIssue], + }); + expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]); + }); + + it('reports readiness when saving, permissions, and the published version change', async () => { + const wrapper = await renderComponent({ isSaving: true }); + expect(wrapper.emitted('publish-ready')).toEqual([[false]]); + + await wrapper.setProps({ isSaving: false }); + expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]); + + agentPermissionsMock.canPublish.value = false; + await flushPromises(); + expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([false]); + + agentPermissionsMock.canPublish.value = true; + await flushPromises(); + expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]); + + await wrapper.setProps({ agent: createAgent({ activeVersionId: 'v1', activeVersion }) }); + expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([false]); + + await wrapper.setProps({ + agent: createAgent({ versionId: 'v2', activeVersionId: 'v1', activeVersion }), + }); + expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]); + }); + it('aborts the publish request when beforePublish resolves false', async () => { const { publishAgent } = await import('../composables/useAgentApi'); const beforePublish = vi.fn().mockResolvedValue(false); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSetupTasks.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSetupTasks.test.ts new file mode 100644 index 00000000000..4ca2d43a303 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSetupTasks.test.ts @@ -0,0 +1,184 @@ +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + SetupTask, + SetupTaskId, +} from '../components/AgentSetupTasks/agentSetupTasks.registry'; + +vi.mock('@n8n/i18n', () => ({ + useI18n: () => ({ + baseText: (key: string) => key, + }), +})); + +vi.mock('@n8n/design-system', () => ({ + N8nIcon: { + template: '', + props: ['icon'], + }, + N8nPopover: { + template: + '
', + props: ['open'], + emits: ['update:open'], + }, + N8nText: { + template: '', + props: ['tag'], + }, +})); + +import AgentSetupTasks from '../components/AgentSetupTasks/AgentSetupTasks.vue'; + +const tasks: Array> = [ + { + id: 'choose-model', + titleKey: 'agents.builder.setupTasks.chooseModel', + required: true, + state: 'complete', + visible: true, + action: { + labelKey: 'agents.builder.setupTasks.chooseModel', + target: { kind: 'agent' }, + path: 'model', + }, + }, + { + id: 'add-tool', + titleKey: 'agents.builder.tools.add', + required: false, + state: 'todo', + visible: false, + action: { + labelKey: 'agents.builder.tools.add', + target: { kind: 'tool' }, + }, + }, + { + id: 'add-instructions', + titleKey: 'agents.builder.setupTasks.addInstructions', + required: true, + state: 'todo', + visible: true, + action: { + labelKey: 'agents.builder.setupTasks.addInstructions', + target: { kind: 'agent' }, + path: 'instructions', + }, + }, +]; + +describe('AgentSetupTasks', () => { + it('changes selection with the arrow keys without moving focus and activates the selected task', async () => { + const localTasks: Array> = [{ ...tasks[0], state: 'todo' }, tasks[2]]; + const wrapper = mount(AgentSetupTasks, { + props: { tasks: localTasks }, + global: { stubs: { TransitionGroup: false } }, + attachTo: document.body, + }); + try { + await wrapper.get('button').trigger('click'); + await nextTick(); + + const list = wrapper.get('ul'); + const taskItems = wrapper.findAll('[role="menuitem"]'); + (list.element as HTMLElement).focus(); + expect(document.activeElement).toBe(list.element); + for (const taskItem of taskItems) { + expect(taskItem.attributes('tabindex')).toBeUndefined(); + expect(taskItem.attributes('data-selected')).toBe('false'); + } + + await list.trigger('keydown', { key: 'ArrowDown' }); + expect(taskItems[0].attributes('data-selected')).toBe('true'); + expect(document.activeElement).toBe(list.element); + + await list.trigger('keydown', { key: 'ArrowDown' }); + expect(taskItems[0].attributes('data-selected')).toBe('false'); + expect(taskItems[1].attributes('data-selected')).toBe('true'); + expect(document.activeElement).toBe(list.element); + + await list.trigger('keydown', { key: 'Enter' }); + expect(wrapper.emitted('action')?.[0]).toEqual([localTasks[1]]); + + await list.trigger('keydown', { key: 'ArrowUp' }); + expect(taskItems[0].attributes('data-selected')).toBe('true'); + expect(taskItems[1].attributes('data-selected')).toBe('false'); + expect(document.activeElement).toBe(list.element); + await list.trigger('keydown', { key: ' ' }); + expect(wrapper.emitted('action')?.[1]).toEqual([localTasks[0]]); + } finally { + wrapper.unmount(); + } + }); + + it('exposes the active task through the named menu without selection attributes', async () => { + const localTasks: Array> = [{ ...tasks[0], state: 'todo' }, tasks[2]]; + const wrapper = mount(AgentSetupTasks, { props: { tasks: localTasks } }); + try { + await wrapper.get('button').trigger('click'); + const menu = wrapper.get('[role="menu"]'); + const items = wrapper.findAll('[role="menuitem"]'); + expect(menu.attributes('aria-label')).toBe('agents.builder.setupTasks.title'); + expect(menu.attributes('tabindex')).toBe('-1'); + expect(menu.attributes('aria-activedescendant')).toBeUndefined(); + const ids = items.map((item) => item.attributes('id')); + expect(ids.every((id) => Boolean(id))).toBe(true); + expect(new Set(ids).size).toBe(items.length); + for (const item of items) { + expect(item.attributes('aria-selected')).toBeUndefined(); + expect(item.attributes('aria-current')).toBeUndefined(); + } + + await menu.trigger('keydown', { key: 'ArrowDown' }); + expect(menu.attributes('aria-activedescendant')).toBe(ids[0]); + await menu.trigger('keydown', { key: 'ArrowDown' }); + expect(menu.attributes('aria-activedescendant')).toBe(ids[1]); + await menu.trigger('keydown', { key: 'ArrowUp' }); + expect(menu.attributes('aria-activedescendant')).toBe(ids[0]); + await items[1].trigger('mouseenter'); + expect(menu.attributes('aria-activedescendant')).toBe(ids[1]); + + await wrapper.setProps({ tasks: [{ ...localTasks[0], state: 'complete' }] }); + expect(menu.attributes('aria-activedescendant')).toBeUndefined(); + } finally { + wrapper.unmount(); + } + }); + + it.each(['Escape', 'Tab', 'a'])('does not prevent the %s key', async (key) => { + const wrapper = mount(AgentSetupTasks, { props: { tasks } }); + await wrapper.get('button').trigger('click'); + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + wrapper.get('[role="menuitem"]').element.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('only shows visible tasks and puts completed tasks last', async () => { + const wrapper = mount(AgentSetupTasks, { props: { tasks } }); + await wrapper.get('button').trigger('click'); + const taskItems = wrapper.findAll('li'); + + expect(taskItems).toHaveLength(2); + expect(taskItems[0].text()).toContain('agents.builder.setupTasks.addInstructions'); + expect(taskItems[1].text()).toContain('agents.builder.setupTasks.chooseModel'); + expect(taskItems[1].attributes('tabindex')).toBeUndefined(); + expect(taskItems[1].attributes('role')).toBeUndefined(); + expect(taskItems[1].attributes('data-selected')).toBe('false'); + expect(wrapper.text()).not.toContain('agents.builder.tools.add'); + }); + + it('opens and closes the setup popover', async () => { + const wrapper = mount(AgentSetupTasks, { props: { tasks } }); + const trigger = wrapper.get('button'); + expect(trigger.attributes('type')).toBe('button'); + expect(wrapper.find('[data-testid="agent-setup-tasks"]').exists()).toBe(false); + + await trigger.trigger('click'); + expect(wrapper.find('[data-testid="agent-setup-tasks"]').exists()).toBe(true); + await trigger.trigger('click'); + expect(wrapper.find('[data-testid="agent-setup-tasks"]').exists()).toBe(false); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue index 32ab4ca7e87..840474704f7 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue @@ -5,6 +5,7 @@ import { useI18n } from '@n8n/i18n'; import type { AgentConfigValidationIssue, AgentFileDto } from '@n8n/api-types'; import type { AgentBuilderMainTab } from '../composables/useAgentBuilderMainTabs'; +import type { SetupTask } from './AgentSetupTasks/agentSetupTasks.registry'; import type { AgentJsonConfig, AgentJsonVectorStoreConfig, @@ -99,9 +100,42 @@ const emit = defineEmits<{ 'agent-changed': []; 'generate-eval-cases': []; 'open-preview': []; + 'publish-agent': []; + 'setup-task-action': [task: SetupTask]; }>(); const i18n = useI18n(); + +const agentInfoPanel = ref>(); +const agentTriggersSection = ref>(); + +function onSetupTaskAction(task: SetupTask) { + if (task.id === 'publish-agent') { + emit('publish-agent'); + return; + } + + if (task.action.target.kind === 'tool') { + emit('add-tool', 'tools'); + return; + } + + if (task.action.target.kind === 'channel') { + agentTriggersSection.value?.openChannelModal(); + return; + } + + if (task.action.path === 'instructions') { + agentInfoPanel.value?.focusInstructions(); + return; + } + + if (task.action.path === 'model') { + agentInfoPanel.value?.focusModel(); + } +} + +defineExpose({ onSetupTaskAction }); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue index 2f7853e2af3..c694a3ef4ee 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue @@ -6,7 +6,7 @@ * Navigation intents are emitted as events, except for the project breadcrumb * which links back to the owning project/personal page. */ -import { computed, onMounted, useCssModule } from 'vue'; +import { computed, onMounted, ref, useCssModule } from 'vue'; import { useRouter, type RouteLocationRaw } from 'vue-router'; import type { AgentConfigValidationIssue } from '@n8n/api-types'; import { @@ -25,6 +25,8 @@ import ProjectIcon from '@/features/collaboration/projects/components/ProjectIco import AgentPublishButton from './AgentPublishButton.vue'; import AgentPreviewButton from './AgentPreviewButton.vue'; +import AgentSetupTasks from './AgentSetupTasks/AgentSetupTasks.vue'; +import type { SetupTask, SetupTaskId } from './AgentSetupTasks/agentSetupTasks.registry'; import { useCreateAgent } from '../composables/useCreateAgent'; import { useProjectAgentsList } from '../composables/useProjectAgentsList'; import type { AgentResource } from '../types'; @@ -45,22 +47,26 @@ const props = defineProps<{ configValidationStatus?: 'valid' | 'invalid' | null; configValidationIssues?: AgentConfigValidationIssue[]; beforePublish?: () => Promise; + tasks?: Array>; }>(); const emit = defineEmits<{ 'header-action': [item: string]; 'open-preview': []; 'close-preview': []; + 'publish-ready': [ready: boolean]; published: [agent: AgentResource]; unpublished: [agent: AgentResource]; reverted: [agent: AgentResource]; 'switch-agent': [agentId: string]; 'toggle-version-history': []; + 'setup-task-action': [task: SetupTask]; }>(); const i18n = useI18n(); const router = useRouter(); const $style = useCssModule(); +const publishButton = ref>(); const { createAgent } = useCreateAgent(); const { list: agentsList, ensureLoaded } = useProjectAgentsList(computed(() => props.projectId)); @@ -85,6 +91,12 @@ const breadcrumbItems = computed(() => [ const agentDisplayName = computed(() => props.agent?.name ?? '…'); +function publishAgent() { + return publishButton.value?.publish(); +} + +defineExpose({ publishAgent }); + const switcherOptions = computed>>(() => { const list = agentsList.value ?? []; const others = list.filter((a) => a.id !== props.agentId); @@ -224,6 +236,11 @@ function onMenuSelect(id: string) { : i18n.baseText('agents.builder.header.saved') }} + >(); +const modelSelector = ref>(); // Keep the local editor stable while external config updates arrive. watch( @@ -367,6 +369,14 @@ const emitInstructionsDebounced = useDebounceFn(() => { emit('update:config', { instructions: instructions.value }); }, getDebounceTime(DEBOUNCE_TIME.API.HEAVY_OPERATION)); +function focusInstructions() { + instructionsEditor.value?.focus(); +} + +function focusModel() { + modelSelector.value?.open(); +} + function onInstructionsInput(value: string) { instructions.value = value; emit('draft:config'); @@ -376,6 +386,7 @@ function onInstructionsInput(value: string) { } void emitInstructionsDebounced(); } +defineExpose({ focusInstructions, focusModel });