From 042d16718ef74f0f73505f0ebfc868c480261ef5 Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Wed, 23 Sep 2026 12:25:19 +0000 Subject: [PATCH] feat(editor): Standardize Agent Builder modals (#39220) --- .agents/skills/agent-design-language/SKILL.md | 24 + .../plugins/n8n/skills/agent-design-language | 1 + .../frontend/@n8n/i18n/src/locales/en.json | 60 +- .../editor-ui/src/features/agents/AGENTS.md | 12 + .../agents/__tests__/AgentBuilderView.test.ts | 67 +- .../AgentCapabilitiesSection.test.ts | 59 +- .../__tests__/AgentChannelListItem.test.ts | 7 +- .../__tests__/AgentChannelModal.test.ts | 63 +- .../__tests__/AgentDuplicateModal.test.ts | 28 +- .../__tests__/AgentJsonImportModal.spec.ts | 12 +- .../agents/__tests__/AgentModal.test.ts | 208 +++++++ .../agents/__tests__/AgentSkillModal.test.ts | 75 ++- .../__tests__/AgentSubAgentsModal.spec.ts | 188 ++++-- .../agents/__tests__/AgentTaskModal.test.ts | 42 +- .../__tests__/AgentToolConfigModal.test.ts | 107 +++- .../AgentToolsConnectionModalWrapper.test.ts | 314 +++++++--- .../__tests__/AgentVectorStoresModal.test.ts | 48 +- .../useAgentCapabilitiesActions.test.ts | 155 ++++- .../__tests__/utils/AgentModalTestStub.ts | 117 ++++ .../features/agents/agents-design-language.md | 152 +++++ .../components/AgentCapabilitiesSection.vue | 39 +- .../components/AgentChannelListItem.vue | 131 ++-- .../agents/components/AgentChannelModal.vue | 428 ++++--------- .../components/AgentConfirmationModal.vue | 66 +- .../agents/components/AgentDuplicateModal.vue | 97 ++- .../components/AgentJsonImportModal.vue | 83 ++- .../agents/components/AgentPreviewButton.vue | 4 +- .../agents/components/AgentSkillFileNav.vue | 10 + .../agents/components/AgentSkillModal.vue | 186 +++--- .../agents/components/AgentSkillViewer.vue | 46 +- .../agents/components/AgentSubAgentsModal.vue | 414 +++++-------- .../agents/components/AgentTaskModal.vue | 526 ++++++++-------- .../agents/components/AgentToolConfigForm.vue | 400 ++++++++++++ .../components/AgentToolConfigModal.vue | 577 +++--------------- .../components/AgentToolConfigModalHeader.vue | 93 --- .../AgentToolsConnectionModalWrapper.vue | 254 +++++--- .../components/AgentVectorStoresModal.vue | 505 ++++++++------- .../components/WorkflowToolConfigContent.vue | 1 - .../agents/components/modals/AgentModal.vue | 375 ++++++++++++ .../components/modals/AgentModalMultiStep.vue | 123 ++++ .../useAgentCapabilitiesActions.ts | 57 +- .../agents/views/AgentBuilderView.vue | 157 ++++- .../projects/components/ProjectSharing.vue | 10 +- .../CredentialEdit/CredentialEdit.test.ts | 49 +- .../CredentialEdit/CredentialEdit.vue | 403 ++++++------ .../CredentialEdit/CredentialSharing.ee.vue | 1 + .../CredentialEdit/TypeToConfirmDialog.vue | 1 + .../shared/toolsConnection/ToolRow.vue | 28 +- .../toolsConnection/ToolsConnectionModal.vue | 134 ++-- .../__tests__/ToolsConnectionModal.test.ts | 27 +- .../features/shared/toolsConnection/types.ts | 5 +- .../pages/components/CredentialModal.ts | 11 +- 52 files changed, 4223 insertions(+), 2757 deletions(-) create mode 100644 .agents/skills/agent-design-language/SKILL.md create mode 120000 .claude/plugins/n8n/skills/agent-design-language create mode 100644 packages/frontend/editor-ui/src/features/agents/AGENTS.md create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentModal.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/utils/AgentModalTestStub.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/agents-design-language.md create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentToolConfigForm.vue delete mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentToolConfigModalHeader.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/modals/AgentModal.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/modals/AgentModalMultiStep.vue diff --git a/.agents/skills/agent-design-language/SKILL.md b/.agents/skills/agent-design-language/SKILL.md new file mode 100644 index 00000000000..b7bac28ee27 --- /dev/null +++ b/.agents/skills/agent-design-language/SKILL.md @@ -0,0 +1,24 @@ +--- +name: n8n:agent-design-language +description: Design or review Agent Builder interfaces. Use for Agent configuration patterns, modal flows, responsive behavior, status treatments, and other Agent-only UI work. +--- + +# Agent design language + +Use this skill only for interface work in +`packages/frontend/editor-ui/src/features/agents`. + +Before you change code: + +1. Read `packages/frontend/editor-ui/src/features/agents/AGENTS.md`. +2. Read `packages/frontend/editor-ui/src/features/agents/agents-design-language.md` + completely. +3. Use [ui-design](../ui-design/SKILL.md) for visual implementation rules. +4. Use [content-design](../content-design/SKILL.md) when you add or change UI + text. + +Keep Agent-specific components in the Agent feature. Do not change the Design +System unless the user explicitly asks for this change. + +Update the design language document when you establish a reusable Agent +interface pattern. Add or update tests for the behavior that you change. diff --git a/.claude/plugins/n8n/skills/agent-design-language b/.claude/plugins/n8n/skills/agent-design-language new file mode 120000 index 00000000000..0f1022fb150 --- /dev/null +++ b/.claude/plugins/n8n/skills/agent-design-language @@ -0,0 +1 @@ +../../../../.agents/skills/agent-design-language \ No newline at end of file diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 7952f2a6ef1..f4a1661e842 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -7963,8 +7963,9 @@ "agents.list.actions.duplicate": "Duplicate", "agents.list.availableInMCP": "Available in MCP", "agents.list.readonly": "Read only", - "agents.duplicate.modal.name": "Duplicate Agent", + "agents.duplicate.modal.name": "Duplicate agent", "agents.duplicate.modal.enterName": "Enter a name for the duplicated agent", + "agents.duplicate.modal.nameRequired": "Enter a name.", "agents.duplicate.modal.success": "Agent duplicated", "agents.duplicate.modal.error": "Could not duplicate agent", "agents.duplicate.modal.unconfigured": "Configure the agent before duplicating it", @@ -8047,6 +8048,8 @@ "agents.tools.workflow.disabled.noSupportedTrigger": "Needs a '{trigger}' trigger", "agents.tools.workflow.notPublished": "Not published", "agents.tools.workflow.search.placeholder": "Search workflows", + "agents.tools.workflow.empty.title": "No workflows available", + "agents.tools.workflow.empty.noResults": "No matching workflows", "agents.tools.workflow.createFailed.title": "Couldn't create workflow", "agents.tools.workflow.createFailed.message": "Try creating the workflow again.", "agents.tools.workflow.fetchFailed.title": "Couldn't check workflow compatibility", @@ -8077,8 +8080,7 @@ "agents.toolConfig.workflow.inputs.value.placeholder.boolean": "true or false", "agents.toolConfig.workflow.inputs.value.placeholder.array": "Enter a JSON array, e.g. [\"a\", \"b\"]", "agents.toolConfig.workflow.inputs.value.placeholder.object": "Enter a JSON object, e.g. {'{'}\"key\": \"value\"{'}'}", - "agents.toolConfig.save": "Save", - "agents.toolConfig.cancel": "Cancel", + "agents.toolConfig.validation.fixFields": "Fix the highlighted fields before saving.", "agents.toolConfig.approval.label": "Require approval", "agents.toolConfig.approval.hint": "Pause before this tool runs until a human approves it.", "agents.toolConfig.mcpApproval.label": "Require approval", @@ -8206,21 +8208,19 @@ "agents.builder.vectorStores.panel.empty.title": "No vector stores connected", "agents.builder.vectorStores.panel.empty.description": "Connect a vector store so this agent can search data you've already indexed.", "agents.builder.vectorStores.panel.edit": "Edit", - "agents.builder.vectorStores.panel.remove": "Remove connection", - "agents.builder.vectorStores.panel.removeModal.title": "Remove {name}?", - "agents.builder.vectorStores.panel.removeModal.description": "This removes {name} from the agent's connected vector stores.", - "agents.builder.vectorStores.panel.removeModal.button.remove": "Remove connection", + "agents.builder.vectorStores.panel.remove": "Remove vector store", "agents.builder.vectorStores.modal.title": "Connect vector store", + "agents.builder.vectorStores.modal.defaultName": "new_vector_store", "agents.builder.vectorStores.modal.description": "Choose a vector database to search for relevant context during this agent's runs.", "agents.builder.vectorStores.modal.connect": "Connect", - "agents.builder.vectorStores.modal.remove": "Remove connection", - "agents.builder.vectorStores.modal.back": "Back", - "agents.builder.vectorStores.modal.name.label": "Name", + "agents.builder.vectorStores.modal.remove": "Remove vector store", "agents.builder.vectorStores.modal.name.placeholder": "product-docs", + "agents.builder.vectorStores.modal.name.validation.required": "Enter a name.", "agents.builder.vectorStores.modal.name.validation.pattern": "Use only letters, numbers, underscores, and hyphens.", "agents.builder.vectorStores.modal.name.validation.duplicate": "A vector store with this name is already connected.", "agents.builder.vectorStores.modal.credential.label": "Credential", "agents.builder.vectorStores.modal.credential.placeholder": "Select credential", + "agents.builder.vectorStores.modal.credential.required": "Select a credential.", "agents.builder.vectorStores.modal.indexName.label": "Index name", "agents.builder.vectorStores.modal.indexName.placeholder": "product-docs", "agents.builder.vectorStores.modal.namespace.label": "Namespace", @@ -8233,6 +8233,7 @@ "agents.builder.vectorStores.modal.queryName.placeholder": "match_documents", "agents.builder.vectorStores.modal.embeddingModel.label": "Embedding model", "agents.builder.vectorStores.modal.embeddingModel.hint": "Must match the model used to index the data in this store. A different model produces meaningless results, even at the same dimension.", + "agents.builder.vectorStores.modal.embeddingModel.required": "Select an embedding model and credential.", "agents.builder.vectorStores.modal.embeddingModel.dimensions": "{dimensions} dimensions", "agents.builder.vectorStores.modal.test.successTitle": "Connected {name}", "agents.builder.vectorStores.modal.test.failedTitle": "Couldn't connect vector store", @@ -8268,16 +8269,17 @@ "agents.builder.subAgents.modelsByDifficulty.high.description": "Complex tasks that need deeper reasoning or detailed synthesis.", "agents.builder.subAgents.modelsByDifficulty.clear": "Use parent model", "agents.builder.subAgents.modal.title": "Sub-agents", - "agents.builder.subAgents.modal.description": "Select agents from this project to let this agent delegate work to them.", "agents.builder.subAgents.modal.selectAgent": "Select {name}", - "agents.builder.subAgents.modal.empty.title": "No agents to add", - "agents.builder.subAgents.modal.empty.description": "Agents from this project show here. Already added agents are hidden.", + "agents.builder.subAgents.modal.empty.title": "No sub-agents available", + "agents.builder.subAgents.modal.empty.description": "Other agents from this project show here.", "agents.builder.subAgents.modal.search.placeholder": "Search agents", "agents.builder.subAgents.modal.noResults.title": "No matching agents", "agents.builder.subAgents.modal.noResults.description": "Try another search term.", "agents.builder.subAgents.modal.cancel": "Cancel", - "agents.builder.subAgents.modal.add": "Add agent", - "agents.builder.subAgents.modal.remove": "Remove sub agent", + "agents.builder.subAgents.modal.add": "Add sub-agent", + "agents.builder.subAgents.modal.added": "Added", + "agents.builder.subAgents.modal.addAriaLabel": "Add {name} as a sub-agent", + "agents.builder.subAgents.modal.remove": "Remove sub-agent", "agents.builder.memory.recallModel.label": "Memory model", "agents.builder.memory.recallModel.hint": "Choose the model that creates, reviews, and retrieves memories. Uses the agent model by default.", "agents.builder.editor.copy": "Copy to clipboard", @@ -8287,6 +8289,9 @@ "agents.builder.tools.type.workflow": "Workflow", "agents.builder.tools.type.custom": "Custom tool", "agents.builder.tools.remove": "Remove tool", + "agents.builder.tools.workflow.remove": "Remove workflow", + "agents.builder.tools.mcp.remove": "Remove MCP", + "agents.builder.tools.mcp.add": "Add MCP", "agents.builder.tools.back": "Back to tools", "agents.builder.skills.title": "Skills", "agents.builder.skills.description": "How this agent handles specific tasks", @@ -8340,11 +8345,8 @@ "agents.builder.skills.references.maxCount": "Skills can have up to {max} reference files.", "agents.builder.skills.references.invalidSummary": "Fix reference files before saving.", "agents.builder.skills.references.characterCount": "{count} / {max} characters", - "agents.builder.skills.create.title": "Add skill", - "agents.builder.skills.create.cancel": "Cancel", - "agents.builder.skills.create.save": "Save skill", + "agents.builder.skills.defaultName": "New skill", "agents.builder.skills.create.error": "Could not create skill", - "agents.builder.skills.added": "Skill added", "agents.builder.skills.saveError": "Could not save skill", "agents.builder.skills.duplicateName.error": "Agent already has a skill named \"{name}\"", "agents.builder.skills.validation.nameRequired": "Enter a name.", @@ -8372,17 +8374,12 @@ "agents.builder.tasks.status.title": "Schedule status", "agents.builder.tasks.status.success": "Success", "agents.builder.tasks.status.error": "Failed", - "agents.builder.tasks.delete": "Delete schedule", - "agents.builder.tasks.deleteConfirm.title": "Delete schedule?", - "agents.builder.tasks.deleteConfirm.description": "This schedule will stop running and be removed. This can't be undone.", - "agents.builder.tasks.deleteConfirm.confirm": "Delete", + "agents.builder.tasks.delete": "Remove schedule", "agents.builder.tasks.loadError": "Could not load schedules", - "agents.builder.tasks.deleteError": "Could not delete schedule", - "agents.builder.tasks.create.title": "Add schedule", - "agents.builder.tasks.edit.title": "Edit schedule", - "agents.builder.tasks.name.label": "Name", + "agents.builder.tasks.removeError": "Could not remove schedule", + "agents.builder.tasks.defaultName": "New schedule", "agents.builder.tasks.name.placeholder": "Daily Slack summary", - "agents.builder.tasks.objective.label": "Objective", + "agents.builder.tasks.objective.label": "What to do", "agents.builder.tasks.objective.placeholder": "Summarize today's #general messages and post the summary.", "agents.builder.tasks.schedule.label": "Schedule", "agents.builder.tasks.schedule.frequency.hourly": "Hourly", @@ -8401,16 +8398,14 @@ "agents.builder.tasks.schedule.nextOccurrence": "Next execution {occurrence}", "agents.builder.tasks.schedule.summary": "{description} · {execution}", "agents.builder.tasks.schedule.executionPaused": "Execution paused", - "agents.builder.tasks.cancel": "Cancel", - "agents.builder.tasks.save": "Save schedule", "agents.builder.tasks.saveError": "Could not save schedule", "agents.builder.tasks.execute": "Run schedule", "agents.builder.tasks.executeStarted": "Schedule started", "agents.builder.tasks.executeError": "Could not start the schedule", "agents.builder.tasks.validation.nameRequired": "Enter a name.", "agents.builder.tasks.validation.nameMaxLength": "Name must be {max} characters or fewer.", - "agents.builder.tasks.validation.objectiveRequired": "Enter an objective.", - "agents.builder.tasks.validation.objectiveMaxLength": "Objective must be {max} characters or fewer.", + "agents.builder.tasks.validation.objectiveRequired": "Enter what to do.", + "agents.builder.tasks.validation.objectiveMaxLength": "What to do must be {max} characters or fewer.", "agents.builder.tasks.validation.cronRequired": "Enter a schedule.", "agents.builder.tasks.validation.cronInvalid": "This isn't a valid cron expression", "agents.builder.editor.invalidJson": "Invalid JSON", @@ -8807,6 +8802,7 @@ "agents.channels.modal.notRunning": "Not running", "agents.channels.modal.notRunning.tooltip": "This channel couldn't be started. n8n keeps retrying it.", "agents.channels.modal.removeChannel": "Remove channel", + "agents.channels.modal.credentialRequired": "Select a credential.", "agents.channels.modal.saveChannelError": "Channel couldn't be saved. Try again.", "agents.channels.modal.removeChannelError": "Channel couldn't be removed. Try again.", "agents.channels.modal.slackAppNotDeleted.title": "Slack app wasn't removed", diff --git a/packages/frontend/editor-ui/src/features/agents/AGENTS.md b/packages/frontend/editor-ui/src/features/agents/AGENTS.md new file mode 100644 index 00000000000..0c5e850414f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/AGENTS.md @@ -0,0 +1,12 @@ +# Agent frontend + +Read `agents-design-language.md` before you change Agent interface behavior or +visuals. It is the source of truth for reusable Agent-specific patterns. + +Keep feature-specific components in this module. Use the shared Design System +for primitives. Do not change the Design System for an Agent-only need unless +the user explicitly asks for this change. + +For modal work, use `components/modals/AgentModal.vue`. Use +`components/modals/AgentModalMultiStep.vue` when the user selects an item before +they configure it. 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 0595e76390e..23c3ecd4efb 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 @@ -3028,6 +3028,63 @@ describe('AgentBuilderView — three-column shell', () => { expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(true); }); + it('closes the preview when opening the AI panel would make the editor too narrow', async () => { + localStorage.setItem('N8N_AGENT_PREVIEW_OPEN:p1:a1', 'true'); + const wrapper = await renderView(); + const vm = wrapper.vm as unknown as { + builderContainerWidth: number; + toggleAiPanel: () => void; + }; + vm.builderContainerWidth = 1100; + await nextTick(); + expect(wrapper.findComponent({ name: 'AgentPreviewDock' }).props('isOpen')).toBe(true); + + vm.toggleAiPanel(); + await nextTick(); + + expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true); + expect(wrapper.findComponent({ name: 'AgentPreviewDock' }).props('isOpen')).toBe(false); + }); + + it('closes the AI panel when opening the preview would make the editor too narrow', async () => { + localStorage.setItem('N8N_AGENT_AI_PANEL_OPEN:p1:a1', 'true'); + const wrapper = await renderView(); + (wrapper.vm as unknown as { builderContainerWidth: number }).builderContainerWidth = 1100; + await nextTick(); + expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true); + + await (wrapper.vm as unknown as { onOpenPreview: () => Promise }).onOpenPreview(); + await flushPromises(); + + expect(wrapper.findComponent({ name: 'AgentPreviewDock' }).props('isOpen')).toBe(true); + expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(false); + }); + + it('shrinks both side panels before closing either one', async () => { + localStorage.setItem('N8N_AGENT_PREVIEW_OPEN:p1:a1', 'true'); + const wrapper = await renderView(); + const vm = wrapper.vm as unknown as { + builderContainerWidth: number; + toggleAiPanel: () => void; + }; + vm.builderContainerWidth = 1200; + await nextTick(); + + vm.toggleAiPanel(); + await nextTick(); + + expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true); + expect(wrapper.findComponent({ name: 'AgentPreviewDock' }).props('isOpen')).toBe(true); + const builder = wrapper.get('[data-testid="agent-builder-container"]').element as HTMLElement; + const aiWidth = Number.parseFloat(builder.style.getPropertyValue('--agent-ai-panel-width')); + const previewWidth = Number.parseFloat( + builder.style.getPropertyValue('--agent-preview-chat-column-width'), + ); + expect(aiWidth).toBeGreaterThanOrEqual(320); + expect(previewWidth).toBeGreaterThanOrEqual(320); + expect(aiWidth + previewWidth).toBeCloseTo(720); + }); + it('routes to the assistant setup instead of opening the panel when setup is unfinished', async () => { instanceAiReadyRef.value = false; const wrapper = await renderView(); @@ -4291,7 +4348,7 @@ describe('AgentBuilderView — three-column shell', () => { expect(wrapper.findComponent({ name: 'AgentSkillsSection' }).props('skills')).toEqual([]); }); - it('opens the add skill modal and applies the created skill', async () => { + it('adds a skill without a duplicate config save or success toast', async () => { const skill = { name: 'Summarize Meetings', description: 'Use when summarizing meeting notes', @@ -4318,6 +4375,7 @@ describe('AgentBuilderView — three-column shell', () => { ); const wrapper = await renderView(); + fetchConfigMock.mockClear(); wrapper.findComponent({ name: 'AgentSkillsSection' }).vm.$emit('add-skill'); await nextTick(); @@ -4346,10 +4404,9 @@ describe('AgentBuilderView — three-column shell', () => { expect(wrapper.findComponent({ name: 'AgentSkillsSection' }).props('skills')).toEqual([ { id: 'skill_0Ab9ZkLm3Pq7Xy2N', skill }, ]); - expect(showMessageMock).toHaveBeenCalledWith({ - title: 'agents.builder.skills.added', - type: 'success', - }); + expect(fetchConfigMock).toHaveBeenCalledWith('p1', 'a1'); + expect(updateConfigMock).not.toHaveBeenCalled(); + expect(showMessageMock).not.toHaveBeenCalled(); }); it('applies skill modal edits to the local config and agent resource', async () => { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts index 06e555c9098..a865b84d2a2 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts @@ -40,6 +40,16 @@ vi.mock('@/app/stores/ui.store', () => ({ useUIStore: () => ({ openModalWithData: openModalWithDataSpy }), })); +const createAgentSpy = vi.fn(); +vi.mock('../composables/useCreateAgent', () => ({ + useCreateAgent: () => ({ createAgent: createAgentSpy }), +})); + +const canCreateAgentRef = ref(true); +vi.mock('../composables/useAgentPermissions', () => ({ + useAgentPermissions: () => ({ canCreate: canCreateAgentRef }), +})); + const showErrorSpy = vi.fn(); vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showError: showErrorSpy }), @@ -164,6 +174,7 @@ describe('AgentCapabilitiesSection', () => { ensureProjectAgentsLoadedSpy.mockImplementation(async () => projectAgentsListRef.value ?? []); refreshProjectAgentsSpy.mockImplementation(async () => projectAgentsListRef.value ?? []); integrationsCatalogRef.value = []; + canCreateAgentRef.value = true; }); it('renders web search and forwards its config updates', () => { @@ -390,16 +401,44 @@ describe('AgentCapabilitiesSection', () => { name: AGENT_SUB_AGENTS_MODAL_KEY, data: expect.objectContaining({ agents: [ - { id: 'agent-3', name: 'Research Agent' }, - { id: 'agent-4', name: 'Draft Agent' }, + { + id: 'agent-2', + name: 'Helper Agent', + added: true, + useWhen: 'Use for billing support requests.', + invalidReasons: [], + agentHref: '/projects/project-id/agents/agent-2', + }, + { + id: 'agent-3', + name: 'Research Agent', + added: false, + useWhen: undefined, + invalidReasons: [], + agentHref: '/projects/project-id/agents/agent-3', + }, + { + id: 'agent-4', + name: 'Draft Agent', + added: false, + useWhen: undefined, + invalidReasons: [], + agentHref: '/projects/project-id/agents/agent-4', + }, ], }), }), ); const modalCall = openModalWithDataSpy.mock.calls[0]?.[0] as { - data: { onConfirm: (payload: { agentId: string; useWhen?: string }) => void }; + data: { + onCreateAgent?: () => void; + onConfirm: (payload: { agentId: string; useWhen?: string }) => void; + }; }; + modalCall.data.onCreateAgent?.(); + expect(createAgentSpy).toHaveBeenCalledWith('button', 'project-id'); + modalCall.data.onConfirm({ agentId: 'agent-4', useWhen: 'Use for draft research requests.', @@ -418,6 +457,20 @@ describe('AgentCapabilitiesSection', () => { ]); }); + it('omits agent creation when the project does not allow it', async () => { + canCreateAgentRef.value = false; + const wrapper = mountSection([], {}, null, [], [makeAgent()]); + await flushPromises(); + + await wrapper.find('[data-testid="agent-capabilities-add-sub-agent"]').trigger('click'); + await flushPromises(); + + const modalCall = openModalWithDataSpy.mock.calls[0]?.[0] as { + data: { onCreateAgent?: () => void }; + }; + expect(modalCall.data.onCreateAgent).toBeUndefined(); + }); + it('refreshes a stale project-agent cache and renders only the sub-agent name', async () => { const child = makeAgent({ id: 'agent-new', name: 'Notion Research Agent' }); refreshProjectAgentsSpy.mockImplementationOnce(async () => { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelListItem.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelListItem.test.ts index 3d97fb154c5..b7356e5816d 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelListItem.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelListItem.test.ts @@ -33,7 +33,7 @@ function mountItem( N8nDropdownMenu: { template: '
', }, - N8nIcon: { template: '' }, + N8nIcon: { props: ['icon'], template: '' }, N8nText: { template: '' }, N8nTooltip: { props: ['content', 'disabled'], @@ -54,8 +54,11 @@ describe('AgentChannelListItem', () => { expect(wrapper.text()).toContain(label); expect(wrapper.find('[data-testid="agent-channel-connected-indicator"]').exists()).toBe( - connected, + configured, ); + if (configured) { + expect(wrapper.find('[data-icon="check"]').exists()).toBe(true); + } }); describe('a channel that failed to start', () => { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts index 6670e53f5a5..c26cc39833b 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts @@ -253,7 +253,7 @@ function mountModal(view: ChannelView = 'example_setup', isPublished = false) { 'runtimeError', 'connectAction', ], - emits: ['setup', 'disconnect'], + emits: ['setup', 'edit'], template: `
  • `, }, @@ -349,8 +349,8 @@ describe('AgentChannelModal', () => { await flushPromises(); expect(wrapper.get('[data-testid="channel-list-item"]').attributes()).toMatchObject({ - // Still set up, so the row keeps its Edit/Disconnect menu rather than - // offering to connect a channel that already exists. + // Still set up, so the row keeps its Edit action instead of offering to + // connect a channel that already exists. 'data-configured': 'true', 'data-connected': 'false', 'data-not-running': 'true', @@ -415,9 +415,10 @@ describe('AgentChannelModal', () => { const release = deferPersistence(); statuses.value.example = 'configured'; connectedCredentials.value.example = 'credential-old'; - selectedCredentials.value.example = 'credential-new'; - const wrapper = mountModal('example_edit'); + const wrapper = mountModal('list'); await flushPromises(); + await wrapper.get('[data-testid="edit-channel"]').trigger('click'); + await wrapper.get('[data-testid="select-credential"]').trigger('click'); await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click'); await flushPromises(); @@ -425,9 +426,7 @@ describe('AgentChannelModal', () => { await wrapper.get('[data-testid="close-dialog"]').trigger('click'); expect(wrapper.emitted('update:open')).toBeUndefined(); - expect( - wrapper.get('[data-testid="agent-channel-back"]').attributes('disabled'), - ).toBeDefined(); + expect(wrapper.get('[data-testid="agent-modal-back"]').attributes('disabled')).toBeDefined(); await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click'); expect(mocks.disconnect).not.toHaveBeenCalled(); @@ -609,10 +608,11 @@ describe('AgentChannelModal', () => { statuses.value.example = 'connected'; connectedCredentials.value.example = 'credential-old'; integrationApproval.value.example = { mode: 'global' }; - const wrapper = mountModal('example_edit'); + const wrapper = mountModal('list'); await flushPromises(); - await wrapper.get('[data-testid="agent-channel-back"]').trigger('click'); + await wrapper.get('[data-testid="edit-channel"]').trigger('click'); + await wrapper.get('[data-testid="agent-modal-back"]').trigger('click'); await wrapper .get('[data-testid="channel-list-item"]:last-child [data-testid="setup-channel"]') .trigger('click'); @@ -686,15 +686,13 @@ describe('AgentChannelModal', () => { }); }); - it('uses the same managed removal confirmation from the list menu', async () => { - statuses.value.example = 'connected'; + it('confirms managed removal from the edit view and removes the Slack app when selected', async () => { + statuses.value.example = 'configured'; connectedCredentials.value.example = 'credential-managed'; - const wrapper = mountModal('list', true); + const wrapper = mountModal('example_edit', true); await flushPromises(); - await wrapper.get('[data-testid="disconnect-channel"]').trigger('click'); - expect(mocks.disconnect).not.toHaveBeenCalled(); - + await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click'); await wrapper.get('[data-testid="confirm-delete-app"]').trigger('click'); await flushPromises(); @@ -703,6 +701,37 @@ describe('AgentChannelModal', () => { }); }); + it('does not expose removal from the channel list', async () => { + statuses.value.example = 'connected'; + connectedCredentials.value.example = 'credential-managed'; + const wrapper = mountModal('list', true); + await flushPromises(); + + expect(wrapper.find('[data-testid="disconnect-channel"]').exists()).toBe(false); + expect(mocks.disconnect).not.toHaveBeenCalled(); + expect(wrapper.find('[data-testid="disconnect-confirmation"]').exists()).toBe(false); + }); + + it('shows no Back action when editing a channel directly', async () => { + connectedCredentials.value.example = 'credential-old'; + const wrapper = mountModal('example_edit'); + await flushPromises(); + + expect(wrapper.find('[data-testid="agent-modal-back"]').exists()).toBe(false); + }); + + it('shows an inline error when Save has no selected credential', async () => { + const wrapper = mountModal('example_edit'); + await flushPromises(); + + await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click'); + + expect(wrapper.get('[data-testid="agent-channel-credential-required"]').text()).toBe( + 'agents.channels.modal.credentialRequired', + ); + expect(mocks.connect).not.toHaveBeenCalled(); + }); + it('disconnects managed credentials without confirmation when the agent is unpublished', async () => { statuses.value.example = 'configured'; connectedCredentials.value.example = 'credential-managed'; diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentDuplicateModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentDuplicateModal.test.ts index 1f09adcf340..65983eea10a 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentDuplicateModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentDuplicateModal.test.ts @@ -8,6 +8,7 @@ import { useUIStore } from '@/app/stores/ui.store'; import AgentDuplicateModal from '../components/AgentDuplicateModal.vue'; import type { AgentDuplicateModalData } from '../components/AgentDuplicateModal.vue'; +import { AgentModalTestStub } from './utils/AgentModalTestStub'; // Components use `data-testid`; the global setup configures `data-test-id`. configure({ testIdAttribute: 'data-testid' }); @@ -18,11 +19,7 @@ vi.mock('@n8n/i18n', () => { }); const stubs = { - Modal: { - props: ['name', 'width', 'closeOnClickModal', 'closeOnPressEscape', 'showClose'], - template: - '
    ', - }, + AgentModal: AgentModalTestStub, N8nHeading: { template: '

    ' }, N8nText: { template: '' }, N8nButton: { @@ -80,21 +77,23 @@ describe('AgentDuplicateModal', () => { expect(getByTestId('agent-duplicate-name-input')).toHaveValue('Support Agent (copy)'); }); - it('disables confirm and shows a name-taken error when the name matches an existing agent', async () => { + it('keeps confirm available and shows a name-taken error when the name matches', async () => { const { getByTestId, getByText } = renderModal(); await fireEvent.update(getByTestId('agent-duplicate-name-input'), 'Support Agent'); expect(getByText('agents.duplicate.modal.button.nameTaken')).toBeInTheDocument(); - expect(getByTestId('agent-duplicate-confirm')).toBeDisabled(); + expect(getByTestId('agent-duplicate-confirm')).not.toBeDisabled(); }); - it('disables confirm when the name is empty', async () => { - const { getByTestId } = renderModal(); + it('reveals the error when confirming an empty name', async () => { + const { getByTestId, getByText, onConfirm } = renderModal(); await fireEvent.update(getByTestId('agent-duplicate-name-input'), ' '); + await fireEvent.click(getByTestId('agent-duplicate-confirm')); - expect(getByTestId('agent-duplicate-confirm')).toBeDisabled(); + expect(getByText('agents.duplicate.modal.nameRequired')).toBeInTheDocument(); + expect(onConfirm).not.toHaveBeenCalled(); }); it('calls onConfirm with the trimmed name and closes the modal', async () => { @@ -133,16 +132,13 @@ describe('AgentDuplicateModal', () => { const { getByTestId, getByRole } = renderModal({ onConfirm }); const dialog = getByRole('dialog'); - // Before submit, dismissal is allowed. - expect(dialog).toHaveAttribute('data-show-close', 'true'); + expect(dialog).toHaveAttribute('data-busy', 'false'); await fireEvent.update(getByTestId('agent-duplicate-name-input'), 'Triage Bot'); await fireEvent.click(getByTestId('agent-duplicate-confirm')); await waitFor(() => expect(onConfirm).toHaveBeenCalled()); - // While submitting, all dismissal paths are disabled. - expect(dialog).toHaveAttribute('data-show-close', 'false'); - expect(dialog).toHaveAttribute('data-close-on-click-modal', 'false'); - expect(dialog).toHaveAttribute('data-close-on-press-escape', 'false'); + expect(dialog).toHaveAttribute('data-busy', 'true'); + expect(getByTestId('dialog-close-button')).toBeDisabled(); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentJsonImportModal.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentJsonImportModal.spec.ts index c29a714b0c6..478732bf7bd 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentJsonImportModal.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentJsonImportModal.spec.ts @@ -9,6 +9,7 @@ const closeModalMock = vi.fn(); vi.mock('@/app/stores/ui.store', () => ({ useUIStore: () => ({ closeModal: closeModalMock, + modalsById: { agentJsonImportModal: { open: true } }, }), })); @@ -27,13 +28,8 @@ vi.mock('@n8n/i18n', () => ({ }), })); -vi.mock('@/app/components/Modal.vue', () => ({ - default: { - name: 'Modal', - template: - '
    ', - props: ['name', 'width', 'customClass'], - }, +vi.mock('../components/modals/AgentModal.vue', async () => ({ + default: (await import('./utils/AgentModalTestStub')).AgentModalTestStub, })); vi.mock('@n8n/design-system', () => ({ @@ -123,7 +119,7 @@ describe('AgentJsonImportModal', () => { ); }); - await wrapper.findAll('button')[0].trigger('click'); + await wrapper.get('[data-testid="dialog-close-button"]').trigger('click'); expect(wrapper.text()).not.toContain('agent.json'); expect(wrapper.find('[data-testid="agent-json-import-confirm"]').attributes('disabled')).toBe( diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModal.test.ts new file mode 100644 index 00000000000..212e385cfa4 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModal.test.ts @@ -0,0 +1,208 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; + +import AgentModal from '../components/modals/AgentModal.vue'; +import AgentModalMultiStep from '../components/modals/AgentModalMultiStep.vue'; + +vi.mock('@n8n/design-system', async () => { + const { defineComponent, nextTick, onMounted, ref } = await import('vue'); + return { + N8nDialog: defineComponent({ + props: { + open: Boolean, + size: String, + trapFocus: Boolean, + disableOutsidePointerEvents: Boolean, + }, + emits: ['update:open', 'escapeKeyDown', 'interactOutside', 'openAutoFocus'], + setup(_, { emit }) { + const dialog = ref(); + const escapePrevented = ref(false); + onMounted(() => { + const event = new Event('open-auto-focus', { cancelable: true }); + emit('openAutoFocus', event); + if (!event.defaultPrevented) { + void nextTick(() => + dialog.value?.querySelector('button:not([disabled])')?.focus(), + ); + } + }); + function pressEscape() { + const event = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }); + emit('escapeKeyDown', event); + escapePrevented.value = event.defaultPrevented; + } + return { dialog, escapePrevented, pressEscape }; + }, + template: ` +
    + +
    + `, + }), + N8nDialogHeader: defineComponent({ template: '
    ' }), + N8nDialogFooter: defineComponent({ template: '
    ' }), + N8nDialogTitle: defineComponent({ template: '
    ' }), + N8nButton: defineComponent({ + props: ['disabled'], + emits: ['click'], + template: '', + }), + N8nInlineTextEdit: defineComponent({ + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: ` + + `, + }), + N8nIcon: defineComponent({ template: '' }), + N8nIconButton: defineComponent({ + inheritAttrs: false, + props: ['disabled', 'icon'], + emits: ['click'], + template: + '' }); + expect(withFooter.get('footer').text()).toContain('Save'); + withFooter.unmount(); + + const hiddenFooter = mountModal( + { showFooter: false }, + { footerActions: '' }, + ); + expect(hiddenFooter.find('footer').exists()).toBe(false); + }); + + it('preserves inferred footer visibility through the multi-step shell', () => { + const wrapper = mount(AgentModalMultiStep, { + props: { + open: true, + step: 'configure', + title: 'Configure tool', + }, + slots: { + footerActions: '', + }, + }); + + expect(wrapper.get('footer').text()).toContain('Save'); + }); + + it.each(['select', 'list'])('keeps the outer modal body fixed for the %s picker step', (step) => { + const wrapper = mount(AgentModalMultiStep, { + props: { + open: true, + step, + title: 'Add asset', + }, + }); + + expect(wrapper.getComponent(AgentModal).props('bodyScrollable')).toBe(false); + }); + + it('keeps the outer modal body scrollable for configuration steps', () => { + const wrapper = mount(AgentModalMultiStep, { + props: { + open: true, + step: 'configure', + title: 'Configure asset', + }, + }); + + expect(wrapper.getComponent(AgentModal).props('bodyScrollable')).toBe(true); + }); + + it('edits the title and focuses the first body field', async () => { + const wrapper = mountModal({ editableTitle: true }); + await flushPromises(); + + const title = wrapper.get('[data-test-id="agent-modal-title-input"]'); + await title.setValue('Slack messages'); + + expect(wrapper.emitted('update:title')).toEqual([['Slack messages']]); + expect(document.activeElement).toBe(wrapper.get('[data-test-id="first-body-field"]').element); + }); + + it('uses the dialog fallback focus when the body has no interactive control', async () => { + const wrapper = mountModal({}, { default: 'Delete this agent?' }); + await flushPromises(); + + expect(document.activeElement).toBe(wrapper.get('[data-testid="dialog-close-button"]').element); + }); + + it('blocks parent dismissal while a nested credential dialog is open', async () => { + const wrapper = mountModal({ trapFocus: false, showBack: true }); + + await wrapper.get('[data-test-id="dialog-escape"]').trigger('click'); + await wrapper.get('[data-test-id="dialog-dismiss"]').trigger('click'); + + expect(wrapper.get('[role="dialog"]').attributes('data-escape-prevented')).toBe('true'); + expect(wrapper.get('[data-testid="agent-modal-back"]').attributes('disabled')).toBeDefined(); + expect(wrapper.get('[data-testid="dialog-close-button"]').attributes('disabled')).toBeDefined(); + expect(wrapper.emitted('update:open')).toBeUndefined(); + }); + + it('emits Back and Close when the modal is idle', async () => { + const wrapper = mountModal({ showBack: true }); + + await wrapper.get('[data-testid="agent-modal-back"]').trigger('click'); + await wrapper.get('[data-testid="dialog-close-button"]').trigger('click'); + + expect(wrapper.emitted('back')).toHaveLength(1); + expect(wrapper.emitted('update:open')).toEqual([[false]]); + }); + + it('keeps Back, Close, and dismiss disabled while busy', async () => { + const wrapper = mountModal({ showBack: true, busy: true }); + + expect(wrapper.get('[data-testid="agent-modal-back"]').attributes('disabled')).toBeDefined(); + expect(wrapper.get('[data-testid="dialog-close-button"]').attributes('disabled')).toBeDefined(); + await wrapper.get('[data-test-id="dialog-dismiss"]').trigger('click'); + + expect(wrapper.emitted('back')).toBeUndefined(); + expect(wrapper.emitted('update:open')).toBeUndefined(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts index 5b2321101c8..9e69afb3de2 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts @@ -12,6 +12,7 @@ import { import AgentSkillModal from '../components/AgentSkillModal.vue'; import type { AgentSkill } from '../types'; +import { AgentModalTestStub } from './utils/AgentModalTestStub'; vi.mock('@n8n/i18n', () => { const i18n = { baseText: (key: string) => key }; @@ -28,17 +29,6 @@ vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showMessage }), })); -const ModalStub = defineComponent({ - props: ['name', 'customClass', 'width'], - template: ` -
    - - - -
    - `, -}); - const SkillViewerStub = defineComponent({ emits: ['import:skill', 'update:skill', 'update:valid'], props: ['skill', 'selectedPath', 'showValidationWarnings', 'errors', 'scrollable'], @@ -60,7 +50,14 @@ const SkillViewerStub = defineComponent({ () => emit('update:valid', computeValid()), ); return () => - h('div', { 'data-testid': 'agent-skill-viewer-stub' }, [h('span', props.selectedPath)]); + h('div', { 'data-testid': 'agent-skill-viewer-stub' }, [ + h('span', props.selectedPath), + h( + 'span', + { 'data-testid': 'agent-skill-instructions-error' }, + props.errors?.instructions ?? '', + ), + ]); }, }); @@ -71,16 +68,18 @@ function renderModal({ skill, skillId, availableTools, + existingSkillNames, }: { onConfirm?: (payload: { id?: string; skill: AgentSkill }) => void; skill?: AgentSkill; skillId?: string; availableTools?: Array<{ name: string; label: string }>; + existingSkillNames?: string[]; } = {}) { const renderComponent = createComponentRenderer(AgentSkillModal, { global: { stubs: { - Modal: ModalStub, + AgentModal: AgentModalTestStub, AgentSkillViewer: SkillViewerStub, N8nButton: { template: '', @@ -102,6 +101,7 @@ function renderModal({ skill, skillId, availableTools, + existingSkillNames, onConfirm, }, }, @@ -119,11 +119,26 @@ describe('AgentSkillModal', () => { uiStore.closeModal = vi.fn(); }); - it('does not call createAgentSkill when the user cancels before saving', async () => { - const onConfirm = vi.fn(); - const { getByText } = renderModal({ onConfirm }); + it('uses the wider fit-content Agent modal width', () => { + const { container } = renderModal(); - await fireEvent.click(getByText('agents.builder.skills.create.cancel')); + expect(container.querySelector('[data-testid="agent-skill-modal"]')).toHaveAttribute( + 'data-size', + 'fit', + ); + expect(container.querySelector('[data-testid="agent-skill-modal"]')).toHaveAttribute( + 'data-body-flush', + 'true', + ); + }); + + it('does not call createAgentSkill when the user closes before saving', async () => { + const onConfirm = vi.fn(); + const { container } = renderModal({ onConfirm }); + + await fireEvent.click( + container.querySelector('[data-testid="dialog-close-button"]') as Element, + ); expect(apiCreateSpy).not.toHaveBeenCalled(); expect(onConfirm).not.toHaveBeenCalled(); @@ -148,11 +163,20 @@ describe('AgentSkillModal', () => { expect(onConfirm).not.toHaveBeenCalled(); expect(uiStore.closeModal).not.toHaveBeenCalled(); - expect(showMessage).toHaveBeenCalledWith({ - title: 'agents.builder.skills.saveError', - message: 'agents.builder.skills.validation.descriptionRequired', - type: 'error', + expect(showMessage).not.toHaveBeenCalled(); + }); + + it('uses the next available default name for a new skill', () => { + const { container } = renderModal({ + existingSkillNames: [ + 'agents.builder.skills.defaultName', + 'agents.builder.skills.defaultName 2', + ], }); + + expect(container.querySelector('[data-testid="agent-modal-title-input"]')).toHaveValue( + 'agents.builder.skills.defaultName 3', + ); }); it('explains why overlong instructions cannot be saved', async () => { @@ -172,11 +196,10 @@ describe('AgentSkillModal', () => { expect(onConfirm).not.toHaveBeenCalled(); expect(uiStore.closeModal).not.toHaveBeenCalled(); - expect(showMessage).toHaveBeenCalledWith({ - title: 'agents.builder.skills.saveError', - message: 'agents.builder.skills.validation.instructionsMaxLength', - type: 'error', - }); + expect(showMessage).not.toHaveBeenCalled(); + expect( + container.querySelector('[data-testid="agent-skill-instructions-error"]'), + ).toHaveTextContent('agents.builder.skills.validation.instructionsMaxLength'); }); it('adds and removes references from the file navigation', async () => { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSubAgentsModal.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSubAgentsModal.spec.ts index b395ec5935a..5722aea4a47 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSubAgentsModal.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSubAgentsModal.spec.ts @@ -11,15 +11,18 @@ vi.mock('@n8n/i18n', () => ({ baseText: (key: string, options?: { interpolate?: Record }) => ({ 'agents.builder.subAgents.modal.title': 'Sub-agents', - 'agents.builder.subAgents.modal.description': 'Select published agents', 'agents.builder.subAgents.modal.selectAgent': `Select ${options?.interpolate?.name ?? ''}`, - 'agents.builder.subAgents.modal.empty.title': 'No agents to add', + 'agents.builder.subAgents.modal.empty.title': 'No sub-agents available', 'agents.builder.subAgents.modal.empty.description': 'Published agents show here', 'agents.builder.subAgents.modal.search.placeholder': 'Search agents', 'agents.builder.subAgents.modal.noResults.title': 'No matching agents', 'agents.builder.subAgents.modal.noResults.description': 'Try another search term.', - 'agents.builder.subAgents.modal.add': 'Add agent', - 'agents.builder.subAgents.modal.remove': 'Remove sub agent', + 'agents.builder.subAgents.modal.add': 'Add sub-agent', + 'agents.builder.subAgents.modal.added': 'Added', + 'agents.builder.subAgents.modal.addAriaLabel': `Add ${options?.interpolate?.name ?? ''} as a sub-agent`, + 'agents.builder.subAgents.modal.remove': 'Remove sub-agent', + 'projects.header.create.agent': 'Create agent', + 'projectRoles.agent:create.tooltip': 'Create new agents', 'agents.builder.subAgents.useWhen.label': 'When should this agent be used?', 'agents.builder.subAgents.useWhen.hint': 'Tell the parent agent when to delegate work.', 'agents.builder.subAgents.useWhen.placeholder': 'Use for billing questions', @@ -33,15 +36,66 @@ vi.mock('@n8n/i18n', () => ({ })); vi.mock('@/app/stores/ui.store', () => ({ - useUIStore: () => ({ closeModal: closeModalMock }), + useUIStore: () => ({ + closeModal: closeModalMock, + modalsById: { agentSubAgentsModal: { open: true } }, + }), })); -vi.mock('@/app/components/Modal.vue', () => ({ +vi.mock('../components/modals/AgentModalMultiStep.vue', async () => ({ + default: (await import('./utils/AgentModalTestStub')).AgentModalMultiStepTestStub, +})); + +vi.mock('@/features/shared/toolsConnection/ToolsConnectionModal.vue', () => ({ default: { - name: 'Modal', - props: ['name', 'width', 'customClass'], - template: - '
    ', + name: 'ToolsConnectionModal', + props: [ + 'items', + 'categories', + 'detailItem', + 'searchPlaceholder', + 'emptyMessage', + 'noResultsMessage', + 'createAction', + 'connectLabel', + 'connectAriaLabel', + 'connectedLabel', + ], + emits: ['connect', 'open-detail', 'create'], + template: ` +
    + +
    {{ emptyMessage }}
    +
    + {{ item.title }} + + +
    +
    + `, }, })); @@ -101,7 +155,8 @@ describe('AgentSubAgentsModal', () => { const addButtons = wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]'); expect(addButtons).toHaveLength(2); - expect(addButtons[0].text()).toBe('Add agent'); + expect(addButtons[0].text()).toBe('Add sub-agent'); + expect(addButtons[1].attributes('aria-label')).toBe('Add Research Agent as a sub-agent'); await addButtons[1].trigger('click'); @@ -117,7 +172,9 @@ describe('AgentSubAgentsModal', () => { await wrapper.find('[data-testid="agent-sub-agents-modal-use-when"]').setValue('a'.repeat(513)); expect( wrapper.find('[data-testid="agent-sub-agents-modal-confirm"]').attributes('disabled'), - ).toBeDefined(); + ).toBeUndefined(); + await confirmButton.trigger('click'); + expect(onConfirm).not.toHaveBeenCalled(); await wrapper .find('[data-testid="agent-sub-agents-modal-use-when"]') @@ -154,7 +211,49 @@ describe('AgentSubAgentsModal', () => { expect(closeModalMock).toHaveBeenCalledWith('agentSubAgentsModal'); }); - it('filters available agents by name before configuring one', async () => { + it('keeps an added sub-agent visible and opens its existing configuration', async () => { + const onConfirm = vi.fn(); + const onRemove = vi.fn(); + const wrapper = mount(AgentSubAgentsModal, { + props: { + modalName: 'agentSubAgentsModal', + data: { + agents: [ + { + id: 'agent-2', + name: 'Billing Agent', + added: true, + useWhen: 'Use for invoice questions.', + }, + ], + onConfirm, + onRemove, + }, + }, + }); + + const addedButton = wrapper.find('[data-testid="agent-sub-agents-modal-added"]'); + expect(addedButton.text()).toBe('Added'); + expect(wrapper.find('[data-testid="agent-sub-agents-modal-add"]').exists()).toBe(false); + + await addedButton.trigger('click'); + + expect(wrapper.find('h2').text()).toBe('Billing Agent'); + expect(wrapper.find('[data-testid="agent-sub-agents-modal-use-when"]').element).toHaveProperty( + 'value', + 'Use for invoice questions.', + ); + expect(wrapper.find('[data-testid="agent-sub-agents-modal-remove"]').exists()).toBe(true); + + await wrapper.find('[data-testid="agent-sub-agents-modal-confirm"]').trigger('click'); + + expect(onConfirm).toHaveBeenCalledWith({ + agentId: 'agent-2', + useWhen: 'Use for invoice questions.', + }); + }); + + it('passes the agents and asset-specific copy to the shared picker', async () => { const wrapper = mount(AgentSubAgentsModal, { props: { modalName: 'agentSubAgentsModal', @@ -168,28 +267,16 @@ describe('AgentSubAgentsModal', () => { }, }); - const searchInput = wrapper.find('[data-testid="agent-sub-agents-modal-search"]'); - expect(searchInput.exists()).toBe(true); + const picker = wrapper.getComponent({ name: 'ToolsConnectionModal' }); + expect(picker.props('searchPlaceholder')).toBe('Search agents'); + expect(picker.props('emptyMessage')).toBe('No sub-agents available'); + expect(picker.props('noResultsMessage')).toBe('No matching agents'); + expect(picker.props('items')).toEqual([ + expect.objectContaining({ agentId: 'agent-2', title: 'Billing Agent' }), + expect.objectContaining({ agentId: 'agent-3', title: 'Research Agent' }), + ]); - await searchInput.setValue(' bill '); - - let rows = wrapper.findAll('[data-testid="agent-sub-agents-modal-row"]'); - expect(rows).toHaveLength(1); - expect(rows[0].text()).toContain('Billing Agent'); - expect(wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')).toHaveLength(1); - - await searchInput.setValue('unknown'); - - expect(wrapper.find('[data-testid="agent-sub-agents-modal-no-results"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="agent-sub-agents-modal-empty"]').exists()).toBe(false); - expect(wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')).toHaveLength(0); - - await searchInput.setValue('RESEARCH'); - rows = wrapper.findAll('[data-testid="agent-sub-agents-modal-row"]'); - expect(rows).toHaveLength(1); - expect(rows[0].text()).toContain('Research Agent'); - - await wrapper.find('[data-testid="agent-sub-agents-modal-add"]').trigger('click'); + await wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')[1].trigger('click'); expect(wrapper.find('h2').text()).toBe('Research Agent'); }); @@ -208,7 +295,7 @@ describe('AgentSubAgentsModal', () => { await wrapper.find('[data-testid="agent-sub-agents-modal-add"]').trigger('click'); expect(wrapper.find('h2').text()).toBe('Billing Agent'); - await wrapper.find('[data-testid="agent-sub-agents-modal-back"]').trigger('click'); + await wrapper.find('[data-testid="agent-modal-back"]').trigger('click'); expect(wrapper.find('h2').text()).toBe('Sub-agents'); expect(wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')).toHaveLength(1); @@ -230,7 +317,7 @@ describe('AgentSubAgentsModal', () => { }); expect(wrapper.find('h2').text()).toBe('Billing Agent'); - expect(wrapper.find('[data-testid="agent-sub-agents-modal-back"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="agent-modal-back"]').exists()).toBe(false); expect(wrapper.find('[data-testid="agent-sub-agents-modal-use-when"]').element).toHaveProperty( 'value', 'Use for invoice questions.', @@ -294,6 +381,35 @@ describe('AgentSubAgentsModal', () => { }); expect(wrapper.find('[data-testid="agent-sub-agents-modal-empty"]').exists()).toBe(true); + expect(wrapper.get('[data-testid="agent-sub-agents-modal-empty"]').text()).toBe( + 'No sub-agents available', + ); expect(wrapper.find('[data-testid="agent-sub-agents-modal-add"]').exists()).toBe(false); }); + + it('shows the create agent subtitle and opens agent creation', async () => { + const onCreateAgent = vi.fn(); + const wrapper = mount(AgentSubAgentsModal, { + props: { + modalName: 'agentSubAgentsModal', + data: { + agents: [], + onCreateAgent, + onConfirm: vi.fn(), + }, + }, + }); + + expect(wrapper.get('[data-testid="agent-sub-agents-modal-create"]').text()).toContain( + 'Create agent', + ); + expect(wrapper.get('[data-testid="agent-sub-agents-modal-create-description"]').text()).toBe( + 'Create new agents', + ); + + await wrapper.get('[data-testid="agent-sub-agents-modal-create"]').trigger('click'); + + expect(closeModalMock).toHaveBeenCalledWith('agentSubAgentsModal'); + expect(onCreateAgent).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTaskModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTaskModal.test.ts index c482bafaa6e..a50ce10295d 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTaskModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTaskModal.test.ts @@ -6,12 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createComponentRenderer } from '@/__tests__/render'; import { mockedStore } from '@/__tests__/utils'; -import { MODAL_CONFIRM } from '@/app/constants'; import { useUIStore } from '@/app/stores/ui.store'; import { useSettingsStore } from '@n8n/stores/settings.store'; import AgentTaskModal from '../components/AgentTaskModal.vue'; import { formatScheduleDateTime } from '../utils/scheduleBuilder'; +import { AgentModalTestStub } from './utils/AgentModalTestStub'; // Components use `data-testid`; the global setup configures `data-test-id`. configure({ testIdAttribute: 'data-testid' }); @@ -59,11 +59,6 @@ vi.mock('../composables/useAgentApi', () => ({ updateAgentTask: (...args: unknown[]) => updateAgentTaskSpy(...args), })); -const confirmSpy = vi.fn(); -vi.mock('../composables/useAgentConfirmationModal', () => ({ - useAgentConfirmationModal: () => ({ openAgentConfirmationModal: confirmSpy }), -})); - const MODAL_NAME = 'AgentTaskModal'; interface FormInputStubValidator { @@ -124,11 +119,7 @@ const N8nFormInputStub = defineComponent({ // Modal + Select/Option use filename-inferred names (no N8n prefix). const stubs = { - Modal: { - props: ['name', 'width'], - template: - '
    ', - }, + AgentModal: AgentModalTestStub, N8nHeading: { template: '

    ' }, N8nText: { template: '' }, N8nTooltip: { template: '' }, @@ -146,10 +137,10 @@ const stubs = { '', + '', }, N8nInput: { props: ['modelValue'], @@ -213,7 +204,6 @@ describe('AgentTaskModal', () => { uiStore = mockedStore(useUIStore); uiStore.openModal(MODAL_NAME); uiStore.closeModal = vi.fn(); - confirmSpy.mockResolvedValue(MODAL_CONFIRM); }); afterEach(() => { @@ -229,7 +219,7 @@ describe('AgentTaskModal', () => { 'data-show-toolbar', 'floating', ); - await fireEvent.update(getByTestId('agent-task-name-input'), 'My Task'); + await fireEvent.update(getByTestId('agent-modal-title-input'), 'My Task'); await fireEvent.update(getByTestId('agent-task-objective-input'), 'Do the thing'); await fireEvent.click(getByTestId('agent-task-save')); @@ -253,7 +243,7 @@ describe('AgentTaskModal', () => { createAgentTaskSpy.mockResolvedValue({}); const { getByTestId } = renderModal({ isPublished: false }); - await fireEvent.update(getByTestId('agent-task-name-input'), 'My Task'); + await fireEvent.update(getByTestId('agent-modal-title-input'), 'My Task'); await fireEvent.update(getByTestId('agent-task-objective-input'), 'Do the thing'); await fireEvent.click(getByTestId('agent-task-save')); @@ -278,7 +268,7 @@ describe('AgentTaskModal', () => { ensureAgentPersisted, }); - await fireEvent.update(getByTestId('agent-task-name-input'), 'My Task'); + await fireEvent.update(getByTestId('agent-modal-title-input'), 'My Task'); await fireEvent.update(getByTestId('agent-task-objective-input'), 'Do the thing'); await fireEvent.click(getByTestId('agent-task-save')); @@ -309,7 +299,7 @@ describe('AgentTaskModal', () => { ensureAgentPersisted, }); - await fireEvent.update(getByTestId('agent-task-name-input'), 'My Task'); + await fireEvent.update(getByTestId('agent-modal-title-input'), 'My Task'); await fireEvent.update(getByTestId('agent-task-objective-input'), 'Do the thing'); await fireEvent.click(getByTestId('agent-task-save')); @@ -334,7 +324,7 @@ describe('AgentTaskModal', () => { it('does not save when objective exceeds the maximum length', async () => { const { getByTestId, getByText } = renderModal(); - await fireEvent.update(getByTestId('agent-task-name-input'), 'My Task'); + await fireEvent.update(getByTestId('agent-modal-title-input'), 'My Task'); await fireEvent.update( getByTestId('agent-task-objective-input'), 'x'.repeat(AGENT_TASK_OBJECTIVE_MAX_LENGTH + 1), @@ -373,7 +363,7 @@ describe('AgentTaskModal', () => { updateAgentTaskSpy.mockResolvedValue({}); const { getByTestId } = renderModal({ task: makeTask() }); - await fireEvent.update(getByTestId('agent-task-name-input'), 'Renamed'); + await fireEvent.update(getByTestId('agent-modal-title-input'), 'Renamed'); await fireEvent.click(getByTestId('agent-task-save')); await waitFor(() => expect(updateAgentTaskSpy).toHaveBeenCalled()); @@ -490,7 +480,7 @@ describe('AgentTaskModal', () => { const { getByTestId } = renderModal({ task: makeTask({ timezone: null }) }); - await fireEvent.update(getByTestId('agent-task-name-input'), 'Renamed'); + await fireEvent.update(getByTestId('agent-modal-title-input'), 'Renamed'); await fireEvent.click(getByTestId('agent-task-save')); // Editing anything else must not pin the task, or a later change to the @@ -573,7 +563,7 @@ describe('AgentTaskModal', () => { expect(uiStore.closeModal).not.toHaveBeenCalled(); }); - it('deletes an existing task after confirmation', async () => { + it('removes an existing task immediately', async () => { deleteAgentTaskSpy.mockResolvedValue({ success: true }); const { getByTestId, onSaved } = renderModal({ task: makeTask(), @@ -594,11 +584,17 @@ describe('AgentTaskModal', () => { isRunnable: true, }); - expect(getByTestId('agent-task-delete')).toHaveTextContent('generic.delete'); + expect(getByTestId('agent-task-delete')).toHaveTextContent('agents.builder.tasks.delete'); expect(getByTestId('agent-task-pause-control')).toBeInTheDocument(); expect(getByTestId('agent-task-toggle')).toBeInTheDocument(); expect(getByTestId('agent-task-preview')).toHaveTextContent('Preview'); + expect(getByTestId('agent-task-preview')).toHaveAttribute('data-variant', 'ghost'); expect(getByTestId('agent-task-save')).toHaveTextContent('generic.save'); + expect( + Array.from(getByTestId('agent-modal-footer-actions').querySelectorAll('button')).map( + (button) => button.dataset.testid, + ), + ).toEqual(['agent-task-preview', 'agent-modal-cancel', 'agent-task-save']); expect(queryByTestId('agent-task-run')).not.toBeInTheDocument(); await rerender({ diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolConfigModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolConfigModal.test.ts index be822d79656..8056ae06603 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolConfigModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolConfigModal.test.ts @@ -7,6 +7,7 @@ import { useUIStore } from '@/app/stores/ui.store'; import { fireEvent, waitFor } from '@testing-library/vue'; import { defineComponent, onMounted, nextTick } from 'vue'; import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants'; +import { AgentModalTestStub } from './utils/AgentModalTestStub'; import AgentToolConfigModal from '../components/AgentToolConfigModal.vue'; import type { AgentJsonToolRef, CustomToolEntry } from '../types'; @@ -138,6 +139,17 @@ function createWorkflowToolConfigStub(emitValid: boolean) { const MODAL_NAME = 'AgentToolConfigModal'; +function getNativeTestId(container: ParentNode, id: string): HTMLElement { + const element = container.querySelector(`[data-testid="${id}"]`); + if (!(element instanceof HTMLElement)) throw new Error(`Missing data-testid: ${id}`); + return element; +} + +function queryNativeTestId(container: ParentNode, id: string): HTMLElement | null { + const element = container.querySelector(`[data-testid="${id}"]`); + return element instanceof HTMLElement ? element : null; +} + function toolRef( overrides: Partial['node']> = {}, ): Extract { @@ -158,6 +170,7 @@ function toolRef( function renderModal({ valid = false, onConfirm = vi.fn(), + onRemove, ref = toolRef(), customTool, projectId, @@ -166,6 +179,7 @@ function renderModal({ }: { valid?: boolean; onConfirm?: (updated: AgentJsonToolRef) => void; + onRemove?: () => void; ref?: AgentJsonToolRef; customTool?: CustomToolEntry; projectId?: string; @@ -175,6 +189,7 @@ function renderModal({ const renderComponent = createComponentRenderer(AgentToolConfigModal, { global: { stubs: { + AgentModal: AgentModalTestStub, NodeIcon: { template: '
    ' }, FocusScope: { template: '
    ', @@ -205,6 +220,7 @@ function renderModal({ agentId, validationIssues, onConfirm, + onRemove, }, }, }); @@ -226,20 +242,32 @@ describe('AgentToolConfigModal', () => { expect(getByTestId('node-tool-settings-content')).toBeTruthy(); }); + it('does not open for a persisted node tool without node data', () => { + const invalidRef = { + type: 'node', + name: 'Unavailable tool', + description: 'Node data is missing', + } as AgentJsonToolRef; + + const { queryByRole } = renderModal({ ref: invalidRef }); + + expect(queryByRole('dialog')).toBeNull(); + }); + it('releases dialog focus handling while the credential modal is open', async () => { - const { getByRole, getByTestId, queryByTestId } = renderModal(); + const { container, getByRole } = renderModal(); const dialog = getByRole('dialog'); expect(dialog).toHaveAttribute('data-trap-focus', 'true'); expect(dialog).toHaveAttribute('data-disable-outside-pointer-events', 'true'); - expect(queryByTestId('nested-credential-focus-scope')).toBeNull(); + expect(queryNativeTestId(container, 'nested-credential-focus-scope')).toBeNull(); uiStore.openModal(CREDENTIAL_EDIT_MODAL_KEY); await nextTick(); expect(dialog).toHaveAttribute('data-trap-focus', 'false'); expect(dialog).toHaveAttribute('data-disable-outside-pointer-events', 'false'); - expect(getByTestId('nested-credential-focus-scope')).toBeInTheDocument(); + expect(getNativeTestId(container, 'nested-credential-focus-scope')).toBeInTheDocument(); }); it('passes agent project context to the node-tool settings content', () => { @@ -258,24 +286,30 @@ describe('AgentToolConfigModal', () => { expect(queryByTestId('agent-tool-config-permissions-tab')).toBeNull(); }); - it('disables Save until the content emits valid=true', async () => { - const { getByTestId } = renderModal({ valid: false }); + it('keeps Save available and does not confirm invalid content', async () => { + const onConfirm = vi.fn(); + const { container } = renderModal({ valid: false, onConfirm }); await nextTick(); - const saveBtn = getByTestId('agent-tool-config-save') as HTMLButtonElement; - expect(saveBtn.disabled).toBe(true); + const saveBtn = getNativeTestId(container, 'agent-tool-config-save') as HTMLButtonElement; + expect(saveBtn.disabled).toBe(false); + + await fireEvent.click(saveBtn); + + expect(onConfirm).not.toHaveBeenCalled(); + expect(getNativeTestId(container, 'agent-tool-config-validation-error')).toBeInTheDocument(); }); it('enables Save once valid and round-trips the node back into the toolRef on confirm', async () => { const onConfirm = vi.fn(); const initial = toolRef(); - const { getByTestId } = renderModal({ valid: true, onConfirm, ref: initial }); + const { container } = renderModal({ valid: true, onConfirm, ref: initial }); await waitFor(() => { - const saveBtn = getByTestId('agent-tool-config-save') as HTMLButtonElement; + const saveBtn = getNativeTestId(container, 'agent-tool-config-save') as HTMLButtonElement; expect(saveBtn.disabled).toBe(false); }); - await fireEvent.click(getByTestId('agent-tool-config-save')); + await fireEvent.click(getNativeTestId(container, 'agent-tool-config-save')); expect(onConfirm).toHaveBeenCalledTimes(1); const [updated] = onConfirm.mock.calls[0]; @@ -289,7 +323,7 @@ describe('AgentToolConfigModal', () => { }); it('shows the HTTP Request URL error and blocks Save for a model override', async () => { - const { getByTestId, queryByTestId } = renderModal({ + const { container, getByTestId, queryByTestId } = renderModal({ valid: true, ref: toolRef({ nodeType: 'n8n-nodes-base.httpRequestTool', @@ -311,15 +345,17 @@ describe('AgentToolConfigModal', () => { 'agents.builder.validation.issue.httpRequestUrlFromAi', ); expect(queryByTestId('from-ai-override-button')).not.toBeInTheDocument(); - expect(getByTestId('agent-tool-config-save')).toBeDisabled(); + expect(getNativeTestId(container, 'agent-tool-config-save')).not.toBeDisabled(); + await fireEvent.click(getNativeTestId(container, 'agent-tool-config-save')); + expect(getNativeTestId(container, 'agent-tool-config-validation-error')).toBeInTheDocument(); }); it('saves the approval requirement on node tool refs', async () => { const onConfirm = vi.fn(); - const { getByTestId } = renderModal({ valid: true, onConfirm, ref: toolRef() }); + const { container, getByTestId } = renderModal({ valid: true, onConfirm, ref: toolRef() }); await fireEvent.click(getByTestId('agent-tool-approval-toggle')); - await fireEvent.click(getByTestId('agent-tool-config-save')); + await fireEvent.click(getNativeTestId(container, 'agent-tool-config-save')); expect(onConfirm).toHaveBeenCalledTimes(1); const [updated] = onConfirm.mock.calls[0]; @@ -338,14 +374,11 @@ describe('AgentToolConfigModal', () => { ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); }); - it('closes the modal on Cancel without calling onConfirm', async () => { + it('closes the modal from the header without calling onConfirm', async () => { const onConfirm = vi.fn(); - const { getAllByRole } = renderModal({ valid: true, onConfirm }); + const { container } = renderModal({ valid: true, onConfirm }); - const buttons = getAllByRole('button'); - const cancelBtn = buttons.find((b) => b.textContent?.includes('agents.toolConfig.cancel')); - expect(cancelBtn).toBeTruthy(); - await fireEvent.click(cancelBtn!); + await fireEvent.click(getNativeTestId(container, 'dialog-close-button')); expect(onConfirm).not.toHaveBeenCalled(); expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME); @@ -368,14 +401,14 @@ describe('AgentToolConfigModal', () => { }, }; - const { getByTestId, queryByTestId } = renderModal({ + const { container, getByTestId, queryByTestId } = renderModal({ ref: { type: 'custom', id: 'custom-tool-1' }, customTool, }); expect(getByTestId('agent-custom-tool-viewer').textContent).toContain(customTool.code); expect(queryByTestId('node-tool-settings-content')).toBeNull(); expect(queryByTestId('workflow-tool-config-content')).toBeNull(); - expect(queryByTestId('agent-tool-config-save')).not.toBeNull(); + expect(queryNativeTestId(container, 'agent-tool-config-save')).not.toBeNull(); }); it('saves the approval requirement on custom tool refs', async () => { @@ -395,7 +428,7 @@ describe('AgentToolConfigModal', () => { providerOptions: null, }, }; - const { getByTestId } = renderModal({ + const { container, getByTestId } = renderModal({ valid: true, onConfirm, ref: { type: 'custom', id: 'custom-tool-1' }, @@ -403,7 +436,7 @@ describe('AgentToolConfigModal', () => { }); await fireEvent.click(getByTestId('agent-tool-approval-toggle')); - await fireEvent.click(getByTestId('agent-tool-config-save')); + await fireEvent.click(getNativeTestId(container, 'agent-tool-config-save')); expect(onConfirm).toHaveBeenCalledTimes(1); const [updated] = onConfirm.mock.calls[0]; @@ -412,7 +445,7 @@ describe('AgentToolConfigModal', () => { it('preserves the stable workflow id when saving a workflow tool', async () => { const onConfirm = vi.fn(); - const { getByTestId, queryByTestId } = renderModal({ + const { container, getByTestId, queryByTestId } = renderModal({ valid: true, onConfirm, ref: { @@ -428,9 +461,9 @@ describe('AgentToolConfigModal', () => { expect(queryByTestId('node-tool-settings-content')).toBeNull(); await waitFor(() => { - expect(getByTestId('agent-tool-config-save')).not.toBeDisabled(); + expect(getNativeTestId(container, 'agent-tool-config-save')).not.toBeDisabled(); }); - await fireEvent.click(getByTestId('agent-tool-config-save')); + await fireEvent.click(getNativeTestId(container, 'agent-tool-config-save')); expect(onConfirm).toHaveBeenCalledWith( expect.objectContaining({ @@ -440,4 +473,24 @@ describe('AgentToolConfigModal', () => { }), ); }); + + it('uses an explicit Remove workflow action', async () => { + const onRemove = vi.fn(); + const { container, getByText } = renderModal({ + onRemove, + ref: { + type: 'workflow', + workflowId: 'wf-1', + workflow: 'My Workflow', + name: 'My Workflow Tool', + description: 'Does something', + }, + }); + + expect(getByText('agents.builder.tools.workflow.remove')).toBeInTheDocument(); + await fireEvent.click(getNativeTestId(container, 'agent-tool-config-remove')); + + expect(onRemove).toHaveBeenCalledOnce(); + expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolsConnectionModalWrapper.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolsConnectionModalWrapper.test.ts index 1ed8e5b9e77..e00d17a0ebd 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolsConnectionModalWrapper.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolsConnectionModalWrapper.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { defineComponent } from 'vue'; +import { defineComponent, watchEffect } from 'vue'; import { createTestingPinia } from '@pinia/testing'; import { flushPromises } from '@vue/test-utils'; import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow'; @@ -24,6 +24,7 @@ import type { IWorkflowDb } from '@/Interface'; import type { ToolPickerMode } from '../components/AgentCapabilitiesSection.types'; import AgentToolsConnectionModalWrapper from '../components/AgentToolsConnectionModalWrapper.vue'; +import type { AgentToolConfigModalData } from '../components/AgentToolConfigForm.vue'; import type { AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types'; const showMessageMock = vi.fn(); @@ -128,12 +129,80 @@ const MCP_TOOL: INodeTypeDescription = { }; let modalAttrs: Record = {}; +let multiStepAttrs: Record = {}; +let configFormData: AgentToolConfigModalData | null = null; +let configuredResult: AgentJsonToolRef | AgentJsonMcpServerConfig | null = null; + +const AgentModalMultiStepStub = defineComponent({ + name: 'AgentModalMultiStep', + inheritAttrs: false, + props: { + open: Boolean, + step: String, + title: String, + showBack: Boolean, + showFooter: Boolean, + }, + emits: ['update:open', 'update:title', 'back', 'interactOutside'], + setup(props, { attrs }) { + watchEffect(() => { + multiStepAttrs = { ...attrs, ...props }; + }); + return {}; + }, + template: ` +
    +
    +
    + +
    + + + +
    +
    + `, +}); + +const AgentToolConfigFormStub = defineComponent({ + name: 'AgentToolConfigForm', + props: ['data'], + setup(props, { expose }) { + watchEffect(() => { + configFormData = props.data as AgentToolConfigModalData; + }); + expose({ + confirm: () => { + const data = props.data as AgentToolConfigModalData; + const result = + configuredResult ?? (data.kind === 'mcpServer' ? data.mcpServer : data.toolRef); + if (data.kind === 'mcpServer') { + data.onConfirm(result as AgentJsonMcpServerConfig); + } else { + data.onConfirm(result as AgentJsonToolRef); + } + return true; + }, + remove: () => (props.data as AgentToolConfigModalData).onRemove?.(), + changeTitle: vi.fn(), + }); + return {}; + }, + template: '
    ', +}); const ToolsConnectionModalStub = defineComponent({ name: 'ToolsConnectionModal', inheritAttrs: false, - setup(_, { attrs }) { - modalAttrs = attrs; + props: { + persistentScrollbar: Boolean, + }, + setup(props, { attrs }) { + watchEffect(() => { + modalAttrs = { ...attrs, ...props }; + }); return {}; }, template: @@ -169,8 +238,8 @@ function emitSearch(query: string) { } function emitCreateWorkflow() { - const listener = modalAttrs.onCreateWorkflow; - if (typeof listener !== 'function') throw new Error('Missing onCreateWorkflow'); + const listener = modalAttrs.onCreate; + if (typeof listener !== 'function') throw new Error('Missing onCreate'); (listener as () => void)(); } @@ -180,6 +249,8 @@ const PROJECT_ID = 'project-1'; const renderComponent = createComponentRenderer(AgentToolsConnectionModalWrapper, { global: { stubs: { + AgentModalMultiStep: AgentModalMultiStepStub, + AgentToolConfigForm: AgentToolConfigFormStub, ToolsConnectionModal: ToolsConnectionModalStub, McpRegistrySuggestionFooter: McpRegistrySuggestionFooterStub, }, @@ -199,6 +270,9 @@ describe('AgentToolsConnectionModalWrapper', () => { vi.clearAllMocks(); uuidMockState.counter = 0; modalAttrs = {}; + multiStepAttrs = {}; + configFormData = null; + configuredResult = null; createTestingPinia({ stubActions: false }); nodeTypesStore = mockedStore(useNodeTypesStore); @@ -278,18 +352,41 @@ describe('AgentToolsConnectionModalWrapper', () => { }); } + function getConfigData(): AgentToolConfigModalData { + if (!configFormData) throw new Error('The configure step is not open'); + return configFormData; + } + + async function saveConfiguration( + result?: AgentJsonToolRef | AgentJsonMcpServerConfig, + ): Promise { + configuredResult = result ?? null; + const button = document.querySelector('[data-testid="agent-tool-config-save"]'); + if (!(button instanceof HTMLButtonElement)) throw new Error('Missing Save button'); + button.click(); + await flushPromises(); + } + + async function removeConfiguration(): Promise { + const button = document.querySelector('[data-testid="agent-tool-config-remove"]'); + if (!(button instanceof HTMLButtonElement)) throw new Error('Missing Remove button'); + button.click(); + await flushPromises(); + } + it('configures the suggestion footer copy', () => { const { getByText } = render(); expect(getByText('Need another capability?')).toBeInTheDocument(); expect(getByText('Suggest a tool')).toBeInTheDocument(); + expect(modalAttrs.persistentScrollbar ?? modalAttrs['persistent-scrollbar']).toBe(true); }); // DynamicModalLoader passes `open`/`active`/`mode`/`activeId` on top of the // declared props. If those fall through onto ToolsConnectionModal the // inherited `open` is always true while mounted and would pin the dialog // open, so mount the way the loader does and drive it from the store. - it('drives the dialog from the store, not the inherited loader attrs', async () => { + it('does not forward inherited loader attributes to the picker', async () => { renderComponent({ props: { modalName: MODAL_NAME, @@ -299,26 +396,30 @@ describe('AgentToolsConnectionModalWrapper', () => { }); await flushPromises(); expect(modalAttrs.open).toBe(true); - - uiStore.modalStateById[MODAL_NAME].open = false; - await flushPromises(); - expect(modalAttrs.open).toBe(false); + expect(modalAttrs.active).toBeUndefined(); + expect(modalAttrs.mode).toBeUndefined(); }); - // The two dialogs are sequential, not stacked: this one steps aside for the - // config modal, then comes back when it closes, so cancelling returns to the - // list rather than dead-ending. - it('steps aside while the tool config modal is up, then returns', async () => { + it('opens configuration as the next step and returns with Back', async () => { render(); await flushPromises(); expect(modalAttrs.open).toBe(true); - uiStore.modalStateById.agentToolConfigModal.open = true; + const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`); + emitConnect(slack!); await flushPromises(); - expect(modalAttrs.open).toBe(false); - uiStore.modalStateById.agentToolConfigModal.open = false; + expect(multiStepAttrs.step).toBe('configure'); + expect(getConfigData()).toMatchObject({ + toolRef: { type: 'node', node: { nodeType: SLACK.name } }, + }); + + const back = document.querySelector('[data-test-id="agent-modal-back"]'); + if (!(back instanceof HTMLButtonElement)) throw new Error('Missing Back button'); + back.click(); await flushPromises(); + + expect(multiStepAttrs.step).toBe('select'); expect(modalAttrs.open).toBe(true); }); @@ -366,9 +467,14 @@ describe('AgentToolsConnectionModalWrapper', () => { expect(workflow?.category).toBe('workflows'); expect(getItems().every((item) => item.category === 'workflows')).toBe(true); expect(modalAttrs.categories).toEqual(['workflows']); - expect(modalAttrs.title).toBe('Workflows'); - expect(modalAttrs.searchPlaceholder).toBe('Search workflows'); - expect(modalAttrs.allowWorkflowCreation).toBe(true); + expect(modalAttrs.title).toBe('Add workflow'); + expect(modalAttrs.searchPlaceholder ?? modalAttrs['search-placeholder']).toBe( + 'Search workflows', + ); + expect(modalAttrs.createAction ?? modalAttrs['create-action']).toMatchObject({ + category: 'workflows', + label: 'Create workflow', + }); }); it('installs an uninstalled community tool before adding it, and adds the installed type', async () => { @@ -407,6 +513,11 @@ describe('AgentToolsConnectionModalWrapper', () => { }), ); + expect(getConfigData()).toMatchObject({ + toolRef: { type: 'node', node: { nodeType: COMMUNITY_INSTALLED.name } }, + }); + await saveConfiguration(); + // The tool that gets added is the installed type, not the preview. const [{ tools }] = onConfirm.mock.calls[0]; expect(tools[0].node.nodeType).toBe(COMMUNITY_INSTALLED.name); @@ -480,7 +591,35 @@ describe('AgentToolsConnectionModalWrapper', () => { expect(availableSlack).toHaveLength(1); }); - it('opens the config modal when Connect is clicked on a node that needs setup', async () => { + it('excludes workflow refs from tool mode and preserves them when a node tool is added', async () => { + const workflowRef: AgentJsonToolRef = { + type: 'workflow', + workflowId: 'workflow-1', + workflow: 'Daily sales digest', + name: 'Daily sales digest', + description: 'Build the daily sales digest', + allOutputs: false, + }; + const onConfirm = vi.fn(); + render([workflowRef], onConfirm); + await flushPromises(); + + expect(getItems().some((item) => item.kind === 'workflow')).toBe(false); + + const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`); + emitConnect(slack!); + await flushPromises(); + + const configuredRef = toolRef(SLACK.name); + await saveConfiguration(configuredRef); + + expect(onConfirm).toHaveBeenCalledWith({ + tools: [workflowRef, configuredRef], + mcpServers: [], + }); + }); + + it('opens the inline configure step when Add is clicked', async () => { const onConfirm = vi.fn(); render([], onConfirm); await flushPromises(); @@ -488,18 +627,17 @@ describe('AgentToolsConnectionModalWrapper', () => { const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`); expect(slack).toBeDefined(); emitConnect(slack!); + await flushPromises(); expect(onConfirm).not.toHaveBeenCalled(); - expect(uiStore.openModalWithData).toHaveBeenCalledTimes(1); - const [payload] = (uiStore.openModalWithData as ReturnType).mock.calls[0]; - expect(payload.name).toBe('agentToolConfigModal'); - expect(payload.data.toolRef).toMatchObject({ - type: 'node', - node: { nodeType: SLACK.name }, + expect(uiStore.openModalWithData).not.toHaveBeenCalled(); + expect(multiStepAttrs.step).toBe('configure'); + expect(getConfigData()).toMatchObject({ + toolRef: { type: 'node', node: { nodeType: SLACK.name } }, }); }); - it('adds setup-less tools directly and commits them via onConfirm', async () => { + it('uses the configure step for setup-less tools and commits on Save', async () => { nodeTypesStore.visibleNodeTypesByOutputConnectionTypeNames = { [NodeConnectionTypes.AiTool]: [WIKIPEDIA.name], }; @@ -510,8 +648,15 @@ describe('AgentToolsConnectionModalWrapper', () => { const wikipedia = getItems().find((item) => item.id === `nodeType:${WIKIPEDIA.name}`); expect(wikipedia).toBeDefined(); emitConnect(wikipedia!); + await flushPromises(); expect(uiStore.openModalWithData).not.toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + expect(getConfigData()).toMatchObject({ + toolRef: { type: 'node', name: 'Wikipedia' }, + }); + await saveConfiguration(); + expect(onConfirm).toHaveBeenCalledTimes(1); const [{ tools }] = onConfirm.mock.calls[0]; expect(tools).toEqual([ @@ -537,15 +682,17 @@ describe('AgentToolsConnectionModalWrapper', () => { const connected = getItems().find((item) => item.status === 'connected'); emitConnect(connected!); - // Activating a connected node-tool row opens the config modal for a NEW + // Activating a connected node-tool row opens configuration for a new // instance (fresh ref, empty parameters) — not an edit of the existing one. - const [payload] = (uiStore.openModalWithData as ReturnType).mock.calls[0]; - expect(payload.name).toBe('agentToolConfigModal'); - expect(payload.data.toolRef).toMatchObject({ - type: 'node', - node: { nodeType: SLACK.name, nodeParameters: {} }, + await flushPromises(); + const data = getConfigData(); + expect(data).toMatchObject({ + toolRef: { + type: 'node', + node: { nodeType: SLACK.name, nodeParameters: {} }, + }, }); - expect(payload.data.existingToolNames).toContain(existing.name); + expect(data.existingToolNames).toContain(existing.name); const configuredRef: AgentJsonToolRef = { type: 'node', @@ -557,7 +704,7 @@ describe('AgentToolsConnectionModalWrapper', () => { credentials: { slackApi: { id: 'c-2', name: 'Other Slack' } }, }, }; - payload.data.onConfirm(configuredRef); + await saveConfiguration(configuredRef); expect(onConfirm).toHaveBeenCalledWith({ tools: [existing, configuredRef], @@ -576,21 +723,21 @@ describe('AgentToolsConnectionModalWrapper', () => { emitConnect(connected!); // The connected row now opens config for a new instance; there is no - // remove callback on that flow (removal happens via the capabilities chips). - const [payload] = (uiStore.openModalWithData as ReturnType).mock.calls[0]; - expect(payload.data.onRemove).toBeUndefined(); + // Remove callback on that flow. Removal happens through the capability pills. + await flushPromises(); + expect(getConfigData().onRemove).toBeUndefined(); expect(onConfirm).not.toHaveBeenCalled(); }); - it('appends a configured tool once the config modal saves', async () => { + it('appends a configured tool once the configure step saves', async () => { const onConfirm = vi.fn(); render([], onConfirm); await flushPromises(); const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`); emitConnect(slack!); + await flushPromises(); - const [payload] = (uiStore.openModalWithData as ReturnType).mock.calls[0]; const configuredRef: AgentJsonToolRef = { type: 'node', name: 'Slack', @@ -601,7 +748,7 @@ describe('AgentToolsConnectionModalWrapper', () => { credentials: { slackApi: { id: 'c-1', name: 'Prod Slack' } }, }, }; - payload.data.onConfirm(configuredRef); + await saveConfiguration(configuredRef); expect(onConfirm).toHaveBeenCalledWith({ tools: [configuredRef], @@ -624,6 +771,12 @@ describe('AgentToolsConnectionModalWrapper', () => { await flushPromises(); emitConnect(getItems().find((item) => item.id === `nodeType:${WIKIPEDIA.name}`)!); + await flushPromises(); + + expect(getConfigData()).toMatchObject({ + toolRef: { type: 'node', name: 'Wikipedia (1)' }, + }); + await saveConfiguration(); const [{ tools }] = onConfirm.mock.calls[0]; expect(tools.map((tool: Extract) => tool.name)).toEqual([ @@ -727,27 +880,27 @@ describe('AgentToolsConnectionModalWrapper', () => { ); expect(onConfirm).not.toHaveBeenCalled(); expect(uiStore.closeModal).not.toHaveBeenCalled(); - const [payload] = (uiStore.openModalWithData as ReturnType).mock.calls[0]; - expect(payload).toMatchObject({ - name: 'agentToolConfigModal', - data: { - projectId: PROJECT_ID, - toolRef: { - type: 'workflow', - workflowId: 'new-workflow-id', - workflow: 'My workflow 1', - name: 'My workflow 1', - description: '', - allOutputs: false, - }, + const data = getConfigData(); + expect(data).toMatchObject({ + projectId: PROJECT_ID, + toolRef: { + type: 'workflow', + workflowId: 'new-workflow-id', + workflow: 'My workflow 1', + name: 'My workflow 1', + description: '', + allOutputs: false, }, }); + if (data.kind === 'mcpServer' || data.toolRef.type !== 'workflow') { + throw new Error('Expected a workflow tool'); + } const configuredRef: AgentJsonToolRef = { - ...payload.data.toolRef, + ...data.toolRef, description: 'Create the daily sales digest', }; - payload.data.onConfirm(configuredRef); + await saveConfiguration(configuredRef); expect(onConfirm).toHaveBeenCalledWith({ tools: [existingTool, configuredRef], @@ -882,24 +1035,24 @@ describe('AgentToolsConnectionModalWrapper', () => { }; }); - it('commits an added MCP server to the host once its config modal saves', async () => { + it('commits an added MCP server to the host once its configure step saves', async () => { const onConfirm = vi.fn(); render([], onConfirm); await flushPromises(); emitConnect(getItems().find((item) => item.id === `nodeType:${MCP_TOOL.name}`)!); + await flushPromises(); expect(onConfirm).not.toHaveBeenCalled(); - const [payload] = (uiStore.openModalWithData as ReturnType).mock.calls[0]; - expect(payload.data.kind).toBe('mcpServer'); + expect(getConfigData().kind).toBe('mcpServer'); - payload.data.onConfirm(SERVER); + await saveConfiguration(SERVER); expect(onConfirm).toHaveBeenCalledWith({ tools: [], mcpServers: [SERVER] }); expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME); }); - it('removes a connected MCP server when its config modal asks to', async () => { + it('removes a connected MCP server from its configure step', async () => { const onConfirm = vi.fn(); render([], onConfirm, [SERVER]); await flushPromises(); @@ -908,8 +1061,9 @@ describe('AgentToolsConnectionModalWrapper', () => { expect(connected?.title).toBe(SERVER.name); emitConnect(connected!); - const [payload] = (uiStore.openModalWithData as ReturnType).mock.calls[0]; - payload.data.onRemove(); + await flushPromises(); + expect(getConfigData().onRemove).toBeTypeOf('function'); + await removeConfiguration(); expect(onConfirm).toHaveBeenCalledWith({ tools: [], mcpServers: [] }); }); @@ -964,36 +1118,39 @@ describe('AgentToolsConnectionModalWrapper', () => { expect(modalAttrs.categories as string[]).not.toContain('n8n-connect'); }); - it('opens the config modal with the managed credential pre-selected', async () => { + it('opens the configure step with the managed credential pre-selected', async () => { const onConfirm = vi.fn(); render([], onConfirm); await flushPromises(); const gateway = getItems().find((item) => item.id === `n8n-connect:${SLACK.name}`); emitConnect(gateway!); + await flushPromises(); - // Behaves like any other node tool — the config modal opens so the user + // Behaves like any other node tool. The configure step opens so the user // can pick the operation — only the credential is handled for them. expect(onConfirm).not.toHaveBeenCalled(); - expect(uiStore.openModalWithData).toHaveBeenCalledTimes(1); + expect(uiStore.openModalWithData).not.toHaveBeenCalled(); - const [payload] = (uiStore.openModalWithData as ReturnType).mock.calls[0]; - expect(payload.name).toBe('agentToolConfigModal'); - expect(payload.data.toolRef.node.credentials).toEqual({ + const data = getConfigData(); + if (data.kind === 'mcpServer' || data.toolRef.type !== 'node') { + throw new Error('Expected a node tool'); + } + expect(data.toolRef.node.credentials).toEqual({ slackApi: { id: null, name: '', __aiGatewayManaged: true }, }); }); - it('commits the gateway tool once its config modal saves', async () => { + it('commits the gateway tool once its configure step saves', async () => { const onConfirm = vi.fn(); render([], onConfirm); await flushPromises(); const gateway = getItems().find((item) => item.id === `n8n-connect:${SLACK.name}`); emitConnect(gateway!); + await flushPromises(); - const [payload] = (uiStore.openModalWithData as ReturnType).mock.calls[0]; - payload.data.onConfirm(payload.data.toolRef); + await saveConfiguration(); expect(onConfirm).toHaveBeenCalledTimes(1); const [{ tools }] = onConfirm.mock.calls[0]; @@ -1020,15 +1177,18 @@ describe('AgentToolsConnectionModalWrapper', () => { const connected = getItems().find((item) => item.status === 'connected'); emitConnect(connected!); + await flushPromises(); // Activating a connected managed tool routes through the managed add // path, so the new instance keeps the __aiGatewayManaged credential. - const [payload] = (uiStore.openModalWithData as ReturnType).mock.calls[0]; - expect(payload.name).toBe('agentToolConfigModal'); - expect(payload.data.toolRef.node.credentials).toEqual({ + const data = getConfigData(); + if (data.kind === 'mcpServer' || data.toolRef.type !== 'node') { + throw new Error('Expected a node tool'); + } + expect(data.toolRef.node.credentials).toEqual({ slackApi: { id: null, name: '', __aiGatewayManaged: true }, }); - expect(payload.data.existingToolNames).toContain(existing.name); + expect(data.existingToolNames).toContain(existing.name); }); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVectorStoresModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVectorStoresModal.test.ts index 58486e70c7f..44177588503 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVectorStoresModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVectorStoresModal.test.ts @@ -15,7 +15,11 @@ const showErrorMock = vi.fn(); vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ baseText: (key: string, options?: { interpolate?: Record }) => - options?.interpolate ? `${key}:${Object.values(options.interpolate).join(',')}` : key, + key === 'agents.builder.vectorStores.modal.defaultName' + ? 'new_vector_store' + : options?.interpolate + ? `${key}:${Object.values(options.interpolate).join(',')}` + : key, }), })); @@ -36,6 +40,7 @@ vi.mock('@/app/stores/ui.store', () => ({ closeModal: closeModalMock, openNewCredential: openNewCredentialMock, isModalActiveById: {}, + modalsById: { agentVectorStoresModal: { open: true } }, }), })); @@ -58,13 +63,8 @@ vi.mock('../composables/useAgentApi', () => ({ testAgentVectorStore: (...args: unknown[]) => testAgentVectorStoreMock(...args), })); -vi.mock('@/app/components/Modal.vue', () => ({ - default: { - name: 'Modal', - props: ['name', 'width', 'customClass'], - template: - '
    ', - }, +vi.mock('../components/modals/AgentModalMultiStep.vue', async () => ({ + default: (await import('./utils/AgentModalTestStub')).AgentModalMultiStepTestStub, })); vi.mock('@/features/credentials/components/CredentialIcon.vue', () => ({ @@ -131,9 +131,7 @@ async function selectQdrantProvider(wrapper: ReturnType) { } async function fillQdrantConnection(wrapper: ReturnType) { - await wrapper - .find('[data-testid="agent-vector-stores-modal-name"] input') - .setValue('product_docs'); + await wrapper.find('[data-testid="agent-modal-title-input"]').setValue('product_docs'); await wrapper .find('[data-testid="agent-vector-stores-modal-collection-name"] input') .setValue('docs'); @@ -205,7 +203,7 @@ describe('AgentVectorStoresModal', () => { expect(useWhenBlock.text()).toMatch(/required/i); }); - it('disables Connect until required fields are filled, then tests and confirms', async () => { + it('reveals validation until required fields are filled, then tests and confirms', async () => { testAgentVectorStoreMock.mockResolvedValue({ success: true }); const onConfirm = vi.fn(); @@ -219,7 +217,12 @@ describe('AgentVectorStoresModal', () => { await selectQdrantProvider(wrapper); const confirmButton = wrapper.find('[data-testid="agent-vector-stores-modal-confirm"]'); - expect(confirmButton.attributes('disabled')).toBeDefined(); + expect(confirmButton.attributes('disabled')).toBeUndefined(); + await confirmButton.trigger('click'); + expect(onConfirm).not.toHaveBeenCalled(); + expect( + wrapper.get('[data-testid="agent-vector-stores-modal-credential-required"]').text(), + ).toBe('agents.builder.vectorStores.modal.credential.required'); await fillQdrantConnection(wrapper); expect(confirmButton.attributes('disabled')).toBeUndefined(); @@ -230,9 +233,7 @@ describe('AgentVectorStoresModal', () => { expect(testAgentVectorStoreMock).toHaveBeenCalledWith({}, 'p1', expectedVectorStore); expect(onConfirm).toHaveBeenCalledWith(expectedVectorStore); expect(closeModalMock).toHaveBeenCalledWith('agentVectorStoresModal'); - expect(showMessageMock).toHaveBeenCalledWith( - expect.objectContaining({ type: 'success', title: expect.stringContaining('product_docs') }), - ); + expect(showMessageMock).not.toHaveBeenCalled(); expect(showErrorMock).not.toHaveBeenCalled(); }); @@ -348,7 +349,10 @@ describe('AgentVectorStoresModal', () => { }); await flushPromises(); - expect(wrapper.find('[data-testid="agent-vector-stores-modal-name"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="agent-modal-title-input"]').element).toHaveProperty( + 'value', + 'product_docs', + ); }); it('clears provider-specific fields when going back to the provider picker', async () => { @@ -377,21 +381,19 @@ describe('AgentVectorStoresModal', () => { .setValue('pinecone-cred-1'); // Back to the picker, then pick Qdrant instead. - await wrapper.find('[data-testid="agent-vector-stores-modal-back"]').trigger('click'); + await wrapper.find('[data-testid="agent-modal-back"]').trigger('click'); await wrapper.findAll('[data-testid="agent-vector-stores-modal-connect"]')[2].trigger('click'); // Pinecone leftovers must be gone. - const nameInput = wrapper.find( - '[data-testid="agent-vector-stores-modal-name"] input', - ); - expect(nameInput.element.value).toBe(''); + const nameInput = wrapper.find('[data-testid="agent-modal-title-input"]'); + expect(nameInput.element.value).toBe('new_vector_store'); const credentialSelect = wrapper.find( '[data-test-id="agent-vector-stores-modal-credential"]', ); expect(credentialSelect.element.value).toBe(''); // Returning to Pinecone must not resurrect the old index name. - await wrapper.find('[data-testid="agent-vector-stores-modal-back"]').trigger('click'); + await wrapper.find('[data-testid="agent-modal-back"]').trigger('click'); await wrapper.findAll('[data-testid="agent-vector-stores-modal-connect"]')[0].trigger('click'); const indexInput = wrapper.find( '[data-testid="agent-vector-stores-modal-index-name"] input', diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts index e10aa61ea22..e62413acc0d 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts @@ -24,9 +24,12 @@ vi.mock('@n8n/stores/useRootStore', () => ({ useRootStore: () => ({ restApiContext: { baseUrl: 'http://localhost:5678' } }), })); -const { showMessageSpy } = vi.hoisted(() => ({ showMessageSpy: vi.fn() })); +const { showErrorSpy, showMessageSpy } = vi.hoisted(() => ({ + showErrorSpy: vi.fn(), + showMessageSpy: vi.fn(), +})); vi.mock('@n8n/composables/useToast', () => ({ - useToast: () => ({ showError: vi.fn(), showMessage: showMessageSpy }), + useToast: () => ({ showError: showErrorSpy, showMessage: showMessageSpy }), })); vi.mock('../composables/useAgentApi', () => ({ @@ -48,7 +51,11 @@ function setup( overrides: { supportsToolApproval?: boolean; ensureAgentPersisted?: () => Promise; + beforeAgentMutation?: () => Promise; + refreshAgentAfterMutation?: (projectId: string, agentId: string) => Promise; agent?: Ref; + agentId?: Ref; + projectId?: Ref; } = {}, ) { setActivePinia(createTestingPinia({ stubActions: false })); @@ -56,19 +63,21 @@ function setup( const localConfig = ref(makeConfig()); const scheduleConfigUpdate = vi.fn(); + const agentId = overrides.agentId ?? ref('inline:node-1'); + const projectId = overrides.projectId ?? ref('project-1'); const actions = useAgentCapabilitiesActions({ localConfig, agent: ref(null), - projectId: computed(() => 'project-1'), - agentId: computed(() => 'inline:node-1'), + projectId: computed(() => projectId.value), + agentId: computed(() => agentId.value), connectedTriggers: ref([]), scheduleConfigUpdate, scheduleSkillSave: vi.fn(), ...overrides, }); - return { uiStore, actions, scheduleConfigUpdate, localConfig }; + return { uiStore, actions, scheduleConfigUpdate, localConfig, agentId, projectId }; } type ToolsModalData = { @@ -240,6 +249,142 @@ describe('useAgentCapabilitiesActions — localSkills host seam', () => { expect(agent.value?.skillHashes).toEqual({ skill_new: 'hash-new' }); }); + it('flushes pending edits and refreshes the config before applying a created skill ref', async () => { + const agent = ref({ + id: 'inline:node-1', + skills: {}, + skillHashes: {}, + } as unknown as AgentResource); + const beforeAgentMutation = vi.fn().mockResolvedValue(undefined); + const refreshAgentAfterMutation = vi.fn().mockResolvedValue(true); + vi.mocked(createAgentSkill).mockResolvedValue({ + id: 'skill_new', + skill: { ...triage, name: 'New Skill' }, + skillHash: 'hash-new', + versionId: 'v2', + }); + const { uiStore, actions, localConfig, scheduleConfigUpdate } = setup({ + agent, + beforeAgentMutation, + refreshAgentAfterMutation, + }); + + actions.onOpenAddSkillModal(); + const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData; + modalData.onConfirm({ skill: { ...triage, name: 'New Skill' } }); + await flushPromises(); + + expect(beforeAgentMutation).toHaveBeenCalledOnce(); + expect(beforeAgentMutation.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(createAgentSkill).mock.invocationCallOrder[0], + ); + expect(refreshAgentAfterMutation).toHaveBeenCalledWith('project-1', 'inline:node-1'); + expect(localConfig.value?.skills).toEqual([{ type: 'skill', id: 'skill_new' }]); + expect(scheduleConfigUpdate).not.toHaveBeenCalled(); + expect(showMessageSpy).not.toHaveBeenCalled(); + }); + + it('does not apply a created skill ref after the active agent changes during refresh', async () => { + let releaseRefresh: (refreshed: boolean) => void = () => {}; + const refresh = new Promise((resolve) => { + releaseRefresh = resolve; + }); + const agentId = ref('inline:node-1'); + const agent = ref({ + id: 'inline:node-1', + skills: {}, + skillHashes: {}, + } as unknown as AgentResource); + vi.mocked(createAgentSkill).mockResolvedValue({ + id: 'skill_new', + skill: { ...triage, name: 'New Skill' }, + skillHash: 'hash-new', + versionId: 'v2', + }); + const { uiStore, actions, localConfig } = setup({ + agent, + agentId, + refreshAgentAfterMutation: async () => await refresh, + }); + + actions.onOpenAddSkillModal(); + const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData; + modalData.onConfirm({ skill: { ...triage, name: 'New Skill' } }); + await flushPromises(); + agentId.value = 'inline:node-2'; + agent.value = { id: 'inline:node-2', skills: {} } as unknown as AgentResource; + localConfig.value = makeConfig(); + releaseRefresh(true); + await flushPromises(); + + expect(localConfig.value.skills).toBeUndefined(); + expect(agent.value.skills).toEqual({}); + }); + + it('does not apply a created skill ref after the active project changes during refresh', async () => { + let releaseRefresh: (refreshed: boolean) => void = () => {}; + const refresh = new Promise((resolve) => { + releaseRefresh = resolve; + }); + const projectId = ref('project-1'); + const agent = ref({ + id: 'inline:node-1', + skills: {}, + skillHashes: {}, + } as unknown as AgentResource); + vi.mocked(createAgentSkill).mockResolvedValue({ + id: 'skill_new', + skill: { ...triage, name: 'New Skill' }, + skillHash: 'hash-new', + versionId: 'v2', + }); + const { uiStore, actions, localConfig } = setup({ + agent, + projectId, + refreshAgentAfterMutation: async () => await refresh, + }); + + actions.onOpenAddSkillModal(); + const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData; + modalData.onConfirm({ skill: { ...triage, name: 'New Skill' } }); + await flushPromises(); + projectId.value = 'project-2'; + agent.value = { id: 'inline:node-1', skills: {} } as unknown as AgentResource; + localConfig.value = makeConfig(); + releaseRefresh(true); + await flushPromises(); + + expect(localConfig.value.skills).toBeUndefined(); + expect(agent.value.skills).toEqual({}); + }); + + it('reports a refresh failure without applying a created skill ref to stale config', async () => { + const agent = ref({ + id: 'inline:node-1', + skills: {}, + skillHashes: {}, + } as unknown as AgentResource); + const refreshError = new Error('refresh failed'); + vi.mocked(createAgentSkill).mockResolvedValue({ + id: 'skill_new', + skill: { ...triage, name: 'New Skill' }, + skillHash: 'hash-new', + versionId: 'v2', + }); + const { uiStore, actions, localConfig } = setup({ + agent, + refreshAgentAfterMutation: async () => await Promise.reject(refreshError), + }); + + actions.onOpenAddSkillModal(); + const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData; + modalData.onConfirm({ skill: { ...triage, name: 'New Skill' } }); + await flushPromises(); + + expect(showErrorSpy).toHaveBeenCalledWith(refreshError, 'agents.builder.loadError'); + expect(localConfig.value?.skills).toBeUndefined(); + }); + it('does not create the skill when persisting the agent fails', async () => { const { uiStore, actions } = setup({ ensureAgentPersisted: async () => await Promise.reject(new Error('create failed')), diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/utils/AgentModalTestStub.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/utils/AgentModalTestStub.ts new file mode 100644 index 00000000000..c24a2e8305e --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/utils/AgentModalTestStub.ts @@ -0,0 +1,117 @@ +import { defineComponent } from 'vue'; + +export const AgentModalTestStub = defineComponent({ + name: 'AgentModal', + inheritAttrs: false, + props: { + open: { type: Boolean, default: true }, + title: { type: String, default: '' }, + editableTitle: { type: Boolean, default: false }, + showBack: { type: Boolean, default: false }, + showFooter: { type: Boolean, default: undefined }, + showCancel: { type: Boolean, default: true }, + bodyFlush: { type: Boolean, default: false }, + busy: { type: Boolean, default: false }, + size: { type: String, default: '2xlarge' }, + trapFocus: { type: Boolean, default: true }, + disableOutsidePointerEvents: { type: Boolean, default: true }, + }, + emits: ['update:open', 'update:title', 'back', 'interactOutside'], + template: ` +
    +
    +
    +
    +
    +
    + + +
    + + + +
    +
    +
    +
    + `, +}); + +export const AgentModalMultiStepTestStub = defineComponent({ + name: 'AgentModalMultiStep', + inheritAttrs: false, + components: { AgentModalTestStub }, + props: { + open: { type: Boolean, default: true }, + title: { type: String, default: '' }, + editableTitle: { type: Boolean, default: false }, + showBack: { type: Boolean, default: false }, + showFooter: { type: Boolean, default: undefined }, + showCancel: { type: Boolean, default: true }, + bodyFlush: { type: Boolean, default: false }, + busy: { type: Boolean, default: false }, + size: { type: String, default: '2xlarge' }, + trapFocus: { type: Boolean, default: true }, + disableOutsidePointerEvents: { type: Boolean, default: true }, + }, + emits: ['update:open', 'update:title', 'back', 'interactOutside'], + template: ` + + + + + + + + + `, +}); diff --git a/packages/frontend/editor-ui/src/features/agents/agents-design-language.md b/packages/frontend/editor-ui/src/features/agents/agents-design-language.md new file mode 100644 index 00000000000..5d929ee023f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/agents-design-language.md @@ -0,0 +1,152 @@ +# Agent design language + +This document is the source of truth for Agent-only interface patterns in this +module. It starts with modal patterns. Add other reusable Agent patterns as the +interface develops. + +## Scope + +- Keep Agent-specific components and behavior in this module. +- Use Design System components and CSS variables. +- Do not add or change a global Design System primitive for an Agent-only need. +- Put all user-facing text in i18n. +- Test responsive layouts at 375 by 667 pixels and in light and dark themes. + +## Modal patterns + +### Canonical components + +Use these components: + +- `components/modals/AgentModal.vue` +- `components/modals/AgentModalMultiStep.vue` + +`AgentModal` owns the dialog shell. It owns the title, Back, Close, Cancel, body +scroll, and footer layout. `AgentModalMultiStep` adds stable picker layout and a +subtle step transition. The feature component owns its data and step history. + +Do not migrate `AgentMemoryPanel.vue` into this pattern. Memory needs separate +design work. + +### Layout contract + +| Area | Rule | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Width | Use `2xlarge` by default. Keep one width for all steps. | +| Header | Keep the header's bottom divider. Put Back on the left and Close on the right. | +| Title | Use an editable local name when the asset supports one. Do not add an asset icon. | +| Body | Let `AgentModal` own the outer body inset. Do not repeat it on the first content wrapper. Focus the first body field. If there is no body control, use the dialog's default focus. Keep the title out of the initial focus order. Scroll the body only. Keep its scrollbar visible. | +| Footer | Do not add a divider. Put ghost Remove on the left. Put Cancel before the primary action on the right. | +| Responsive | Support 375 by 667 pixels. Stack footer actions when necessary. | + +Use CSS variables for all sizes, spacing, colors, and motion. Do not add a new +global dialog primitive. + +Use `fit` for Skills. Use a 52rem content width. Keep it narrower than `full`. +Use `full` only when the user explicitly asks for the extra workspace. + +Keep the header and footer fixed. Let the body scroll. Keep scrollbars visible +when the body or nested content can scroll. The Agent shell is the only scroll +owner for normal configuration forms. Do not put fixed heights or nested +scrollbars on MCP, node, or workflow configuration content. A picker can use a +stable minimum height. A configuration step must use its natural height. + +Do not add top padding or a top margin to a modal's first content wrapper. The +shell supplies that space. Use the flush body only for a full-bleed workspace. +The workspace must then own all of its edge spacing. + +When a nested credential dialog is open, release the parent focus trap and +block parent dismissal. The nested dialog owns Escape until it closes. + +### Title contract + +Use the configured local name for schedules, skills, node tools, MCP servers, +workflow tools, and vector stores. Give new items a valid default name. Show the +pencil on hover. Keep the title clickable. + +Do not show a second Name field in the body. Show title validation next to the +title after the user selects Save. + +Keep the title read-only for external entities. Channels use the integration +label. Sub-agents use the selected Agent name. Custom tools stay read-only +because their reference schema has no local name. + +### Action contract + +Normal configuration has a Cancel button before the primary action. Cancel, +Close, Escape, and outside click discard changes. Back discards the current +step and returns to the preserved picker. + +Put a non-destructive secondary action before Cancel. For schedules, use the +order `Preview`, `Cancel`, `Save`, and use the ghost variant for Preview. + +Save stays enabled until a request starts. An invalid Save shows inline errors. +Successful Save, Add, and Remove actions close silently. Use a toast for a +server error. Keep a warning toast when the warning contains information that +the user must act on. + +Existing removable items use a ghost bottom-left button with `trash-2`. Use an +explicit label for the asset. Examples include `Remove schedule`, `Remove MCP`, +and `Remove workflow`. New items and picker rows do not show Remove. + +### Status contract + +Use the same status treatment in Agent picker rows. Show a success check before +`Connected` or `Added`. Use `Added` for selected Sub-agents. Use muted `2xs` +text and `3xs` spacing. Keep warning and failure indicators distinct. + +### Multi-step contract + +Use the multi-step component for Channels, Sub-agents, Tools, MCP servers, +Workflows, and Vector stores. + +Keep the picker mounted. Preserve its search query, selected category, and +scroll position. Always show the search input. Put a create row first when the +user can create the asset. Workflows use `Create workflow`. Sub-agents use +`Create agent`. Give the create row a short subtitle that describes the action. +Keep the outer modal body fixed on picker steps. Show a list scrollbar only +when the list content overflows. Do not show a scrollbar for an empty state. + +Put an explicit action in every selectable row. The action must name the +outcome. Use `Add node`, `Add MCP`, `Add workflow`, or the matching asset label. +When the list has no items or no search matches, show a centered, asset-specific +message. Do not use a drop zone or a decorative empty-state icon for a picker. +Use less space above the embedded search input and more space between the input +and the tabs. + +Show Back for an add flow after the user leaves the picker. Do not show Back for +a direct pill edit. Save, Cancel, and Close exit the complete flow. + +### Exceptions + +| Case | Exception | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Skills | Use `fit` with a 52rem content width. Keep it narrower than `full`. Use the flush Agent body so the file workspace does not get double padding. Keep the file navigation and editor inside the scrolling body. | +| Dangerous confirmation | Keep its explicit Cancel and primary actions. Examples are Agent, file, and session deletion; unpublish; revert; eval regeneration; LangSmith export; and managed Slack app removal. | +| Managed Slack app removal | Keep the stacked confirmation because it can delete an external resource. | +| Small utility dialog | Keep an appropriate small size. Use Cancel and one bottom-right primary action. JSON import and Agent duplication use this rule. | +| Channel platform setup | A platform-owned setup action can stay in the inline modal content. The Agent shell still owns navigation and dismissal. | +| Memory | Do not migrate Memory into this pattern in this change. | + +### Review checklist + +- Confirm the modal uses `AgentModal` or `AgentModalMultiStep`. +- Confirm the title has no asset icon. +- Confirm an editable title replaces a duplicate Name field. +- Confirm Back appears only when a previous step exists. +- Confirm Close is top-right and disabled during a request. +- Confirm Cancel appears before the primary action. +- Confirm Remove is explicit, uses ghost styling, has `trash-2`, and appears only for existing items. +- Confirm Save is bottom-right and reveals inline errors. +- Confirm picker state survives Back. +- Confirm search stays visible and empty copy names the asset. +- Confirm a permitted create action appears before the list. +- Confirm success closes silently. +- Confirm UI text uses i18n. +- Confirm the layout works at 375 by 667 pixels in light and dark themes. + +## Extend this document + +Add a section when an Agent-specific pattern applies to two or more Agent +surfaces. Keep implementation details with the owning pattern. Do not duplicate +global Design System guidance. diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue index e87c0859374..42447c1e74d 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue @@ -12,6 +12,8 @@ import { computed, onMounted, watch } from 'vue'; import type { AgentJsonConfig, AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types'; import type { AgentSkill, CustomToolEntry } from '../types'; import { useProjectAgentsList } from '../composables/useProjectAgentsList'; +import { useAgentPermissions } from '../composables/useAgentPermissions'; +import { useCreateAgent } from '../composables/useCreateAgent'; import { useAgentCapabilityIssueMessages } from '../composables/useAgentCapabilityIssueMessages'; import { toolRefToNode } from '../composables/useAgentToolRefAdapter'; import { AGENT_SUB_AGENTS_MODAL_KEY } from '../constants'; @@ -85,8 +87,10 @@ const i18n = useI18n(); const toast = useToast(); const nodeTypesStore = useNodeTypesStore(); const uiStore = useUIStore(); +const { createAgent } = useCreateAgent(); const projectIdRef = computed(() => props.projectId); +const { canCreate: canCreateAgent } = useAgentPermissions(projectIdRef); const { list: projectAgents, ensureLoaded: ensureProjectAgentsLoaded, @@ -100,9 +104,7 @@ const selectedSubAgentIds = computed(() => ); const selectedSubAgentIdSet = computed(() => new Set(selectedSubAgentIds.value)); const availableSubAgents = computed(() => - (projectAgents.value ?? []).filter( - (agent) => agent.id !== props.agentId && !selectedSubAgentIdSet.value.has(agent.id), - ), + (projectAgents.value ?? []).filter((agent) => agent.id !== props.agentId), ); const selectedSubAgents = computed(() => selectedSubAgentRefs.value.map(({ agentId, useWhen }) => { @@ -396,14 +398,33 @@ async function openSubAgentsModal() { uiStore.openModalWithData({ name: AGENT_SUB_AGENTS_MODAL_KEY, data: { - agents: availableSubAgents.value.map(({ id, name }) => ({ - id, - name, - })), + agents: availableSubAgents.value.map(({ id, name }) => { + const selectedRef = selectedSubAgentRefs.value.find((ref) => ref.agentId === id); + return { + id, + name, + added: Boolean(selectedRef), + useWhen: selectedRef?.useWhen, + invalidReasons: subAgentIssueMessages.value.get(id) ?? [], + agentHref: `/projects/${encodeURIComponent(props.projectId)}/agents/${encodeURIComponent(id)}`, + }; + }), + onCreateAgent: canCreateAgent.value + ? () => createAgent('button', props.projectId) + : undefined, onConfirm: ({ agentId, useWhen }: { agentId: string; useWhen?: string }) => { - if (selectedSubAgentIdSet.value.has(agentId)) return; + const nextRef = toSubAgentRef(agentId, useWhen); + if (selectedSubAgentIdSet.value.has(agentId)) { + emitSubAgentRefs( + selectedSubAgentRefs.value.map((ref) => (ref.agentId === agentId ? nextRef : ref)), + ); + return; + } - emitSubAgentRefs([...selectedSubAgentRefs.value, toSubAgentRef(agentId, useWhen)]); + emitSubAgentRefs([...selectedSubAgentRefs.value, nextRef]); + }, + onRemove: (agentId: string) => { + emitSubAgentRefs(selectedSubAgentRefs.value.filter((ref) => ref.agentId !== agentId)); }, }, }); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelListItem.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelListItem.vue index c1f6d0d138a..93503a4a39d 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelListItem.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelListItem.vue @@ -1,13 +1,11 @@ - - diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentConfirmationModal.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentConfirmationModal.vue index f13405292fb..776cac625ce 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentConfirmationModal.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentConfirmationModal.vue @@ -1,8 +1,8 @@