feat(editor): Standardize Agent Builder modals (#39220)

This commit is contained in:
Michael Drury
2026-09-23 12:25:19 +00:00
committed by GitHub
parent 80e29bf57c
commit 042d16718e
52 changed files with 4223 additions and 2757 deletions
@@ -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.
+1
View File
@@ -0,0 +1 @@
../../../../.agents/skills/agent-design-language
+28 -32
View File
@@ -7963,8 +7963,9 @@
"agents.list.actions.duplicate": "Duplicate", "agents.list.actions.duplicate": "Duplicate",
"agents.list.availableInMCP": "Available in MCP", "agents.list.availableInMCP": "Available in MCP",
"agents.list.readonly": "Read only", "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.enterName": "Enter a name for the duplicated agent",
"agents.duplicate.modal.nameRequired": "Enter a name.",
"agents.duplicate.modal.success": "Agent duplicated", "agents.duplicate.modal.success": "Agent duplicated",
"agents.duplicate.modal.error": "Could not duplicate agent", "agents.duplicate.modal.error": "Could not duplicate agent",
"agents.duplicate.modal.unconfigured": "Configure the agent before duplicating it", "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.disabled.noSupportedTrigger": "Needs a '{trigger}' trigger",
"agents.tools.workflow.notPublished": "Not published", "agents.tools.workflow.notPublished": "Not published",
"agents.tools.workflow.search.placeholder": "Search workflows", "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.title": "Couldn't create workflow",
"agents.tools.workflow.createFailed.message": "Try creating the workflow again.", "agents.tools.workflow.createFailed.message": "Try creating the workflow again.",
"agents.tools.workflow.fetchFailed.title": "Couldn't check workflow compatibility", "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.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.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.workflow.inputs.value.placeholder.object": "Enter a JSON object, e.g. {'{'}\"key\": \"value\"{'}'}",
"agents.toolConfig.save": "Save", "agents.toolConfig.validation.fixFields": "Fix the highlighted fields before saving.",
"agents.toolConfig.cancel": "Cancel",
"agents.toolConfig.approval.label": "Require approval", "agents.toolConfig.approval.label": "Require approval",
"agents.toolConfig.approval.hint": "Pause before this tool runs until a human approves it.", "agents.toolConfig.approval.hint": "Pause before this tool runs until a human approves it.",
"agents.toolConfig.mcpApproval.label": "Require approval", "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.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.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.edit": "Edit",
"agents.builder.vectorStores.panel.remove": "Remove connection", "agents.builder.vectorStores.panel.remove": "Remove vector store",
"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.modal.title": "Connect 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.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.connect": "Connect",
"agents.builder.vectorStores.modal.remove": "Remove connection", "agents.builder.vectorStores.modal.remove": "Remove vector store",
"agents.builder.vectorStores.modal.back": "Back",
"agents.builder.vectorStores.modal.name.label": "Name",
"agents.builder.vectorStores.modal.name.placeholder": "product-docs", "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.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.name.validation.duplicate": "A vector store with this name is already connected.",
"agents.builder.vectorStores.modal.credential.label": "Credential", "agents.builder.vectorStores.modal.credential.label": "Credential",
"agents.builder.vectorStores.modal.credential.placeholder": "Select 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.label": "Index name",
"agents.builder.vectorStores.modal.indexName.placeholder": "product-docs", "agents.builder.vectorStores.modal.indexName.placeholder": "product-docs",
"agents.builder.vectorStores.modal.namespace.label": "Namespace", "agents.builder.vectorStores.modal.namespace.label": "Namespace",
@@ -8233,6 +8233,7 @@
"agents.builder.vectorStores.modal.queryName.placeholder": "match_documents", "agents.builder.vectorStores.modal.queryName.placeholder": "match_documents",
"agents.builder.vectorStores.modal.embeddingModel.label": "Embedding model", "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.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.embeddingModel.dimensions": "{dimensions} dimensions",
"agents.builder.vectorStores.modal.test.successTitle": "Connected {name}", "agents.builder.vectorStores.modal.test.successTitle": "Connected {name}",
"agents.builder.vectorStores.modal.test.failedTitle": "Couldn't connect vector store", "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.high.description": "Complex tasks that need deeper reasoning or detailed synthesis.",
"agents.builder.subAgents.modelsByDifficulty.clear": "Use parent model", "agents.builder.subAgents.modelsByDifficulty.clear": "Use parent model",
"agents.builder.subAgents.modal.title": "Sub-agents", "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.selectAgent": "Select {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": "Agents from this project show here. Already added agents are hidden.", "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.search.placeholder": "Search agents",
"agents.builder.subAgents.modal.noResults.title": "No matching agents", "agents.builder.subAgents.modal.noResults.title": "No matching agents",
"agents.builder.subAgents.modal.noResults.description": "Try another search term.", "agents.builder.subAgents.modal.noResults.description": "Try another search term.",
"agents.builder.subAgents.modal.cancel": "Cancel", "agents.builder.subAgents.modal.cancel": "Cancel",
"agents.builder.subAgents.modal.add": "Add agent", "agents.builder.subAgents.modal.add": "Add sub-agent",
"agents.builder.subAgents.modal.remove": "Remove 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.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.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", "agents.builder.editor.copy": "Copy to clipboard",
@@ -8287,6 +8289,9 @@
"agents.builder.tools.type.workflow": "Workflow", "agents.builder.tools.type.workflow": "Workflow",
"agents.builder.tools.type.custom": "Custom tool", "agents.builder.tools.type.custom": "Custom tool",
"agents.builder.tools.remove": "Remove 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.tools.back": "Back to tools",
"agents.builder.skills.title": "Skills", "agents.builder.skills.title": "Skills",
"agents.builder.skills.description": "How this agent handles specific tasks", "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.maxCount": "Skills can have up to {max} reference files.",
"agents.builder.skills.references.invalidSummary": "Fix reference files before saving.", "agents.builder.skills.references.invalidSummary": "Fix reference files before saving.",
"agents.builder.skills.references.characterCount": "{count} / {max} characters", "agents.builder.skills.references.characterCount": "{count} / {max} characters",
"agents.builder.skills.create.title": "Add skill", "agents.builder.skills.defaultName": "New skill",
"agents.builder.skills.create.cancel": "Cancel",
"agents.builder.skills.create.save": "Save skill",
"agents.builder.skills.create.error": "Could not create 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.saveError": "Could not save skill",
"agents.builder.skills.duplicateName.error": "Agent already has a skill named \"{name}\"", "agents.builder.skills.duplicateName.error": "Agent already has a skill named \"{name}\"",
"agents.builder.skills.validation.nameRequired": "Enter a name.", "agents.builder.skills.validation.nameRequired": "Enter a name.",
@@ -8372,17 +8374,12 @@
"agents.builder.tasks.status.title": "Schedule status", "agents.builder.tasks.status.title": "Schedule status",
"agents.builder.tasks.status.success": "Success", "agents.builder.tasks.status.success": "Success",
"agents.builder.tasks.status.error": "Failed", "agents.builder.tasks.status.error": "Failed",
"agents.builder.tasks.delete": "Delete schedule", "agents.builder.tasks.delete": "Remove 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.loadError": "Could not load schedules", "agents.builder.tasks.loadError": "Could not load schedules",
"agents.builder.tasks.deleteError": "Could not delete schedule", "agents.builder.tasks.removeError": "Could not remove schedule",
"agents.builder.tasks.create.title": "Add schedule", "agents.builder.tasks.defaultName": "New schedule",
"agents.builder.tasks.edit.title": "Edit schedule",
"agents.builder.tasks.name.label": "Name",
"agents.builder.tasks.name.placeholder": "Daily Slack summary", "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.objective.placeholder": "Summarize today's #general messages and post the summary.",
"agents.builder.tasks.schedule.label": "Schedule", "agents.builder.tasks.schedule.label": "Schedule",
"agents.builder.tasks.schedule.frequency.hourly": "Hourly", "agents.builder.tasks.schedule.frequency.hourly": "Hourly",
@@ -8401,16 +8398,14 @@
"agents.builder.tasks.schedule.nextOccurrence": "Next execution {occurrence}", "agents.builder.tasks.schedule.nextOccurrence": "Next execution {occurrence}",
"agents.builder.tasks.schedule.summary": "{description} · {execution}", "agents.builder.tasks.schedule.summary": "{description} · {execution}",
"agents.builder.tasks.schedule.executionPaused": "Execution paused", "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.saveError": "Could not save schedule",
"agents.builder.tasks.execute": "Run schedule", "agents.builder.tasks.execute": "Run schedule",
"agents.builder.tasks.executeStarted": "Schedule started", "agents.builder.tasks.executeStarted": "Schedule started",
"agents.builder.tasks.executeError": "Could not start the schedule", "agents.builder.tasks.executeError": "Could not start the schedule",
"agents.builder.tasks.validation.nameRequired": "Enter a name.", "agents.builder.tasks.validation.nameRequired": "Enter a name.",
"agents.builder.tasks.validation.nameMaxLength": "Name must be {max} characters or fewer.", "agents.builder.tasks.validation.nameMaxLength": "Name must be {max} characters or fewer.",
"agents.builder.tasks.validation.objectiveRequired": "Enter an objective.", "agents.builder.tasks.validation.objectiveRequired": "Enter what to do.",
"agents.builder.tasks.validation.objectiveMaxLength": "Objective must be {max} characters or fewer.", "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.cronRequired": "Enter a schedule.",
"agents.builder.tasks.validation.cronInvalid": "This isn't a valid cron expression", "agents.builder.tasks.validation.cronInvalid": "This isn't a valid cron expression",
"agents.builder.editor.invalidJson": "Invalid JSON", "agents.builder.editor.invalidJson": "Invalid JSON",
@@ -8807,6 +8802,7 @@
"agents.channels.modal.notRunning": "Not running", "agents.channels.modal.notRunning": "Not running",
"agents.channels.modal.notRunning.tooltip": "This channel couldn't be started. n8n keeps retrying it.", "agents.channels.modal.notRunning.tooltip": "This channel couldn't be started. n8n keeps retrying it.",
"agents.channels.modal.removeChannel": "Remove channel", "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.saveChannelError": "Channel couldn't be saved. Try again.",
"agents.channels.modal.removeChannelError": "Channel couldn't be removed. Try again.", "agents.channels.modal.removeChannelError": "Channel couldn't be removed. Try again.",
"agents.channels.modal.slackAppNotDeleted.title": "Slack app wasn't removed", "agents.channels.modal.slackAppNotDeleted.title": "Slack app wasn't removed",
@@ -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.
@@ -3028,6 +3028,63 @@ describe('AgentBuilderView — three-column shell', () => {
expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(true); 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<boolean> }).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 () => { it('routes to the assistant setup instead of opening the panel when setup is unfinished', async () => {
instanceAiReadyRef.value = false; instanceAiReadyRef.value = false;
const wrapper = await renderView(); const wrapper = await renderView();
@@ -4291,7 +4348,7 @@ describe('AgentBuilderView — three-column shell', () => {
expect(wrapper.findComponent({ name: 'AgentSkillsSection' }).props('skills')).toEqual([]); 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 = { const skill = {
name: 'Summarize Meetings', name: 'Summarize Meetings',
description: 'Use when summarizing meeting notes', description: 'Use when summarizing meeting notes',
@@ -4318,6 +4375,7 @@ describe('AgentBuilderView — three-column shell', () => {
); );
const wrapper = await renderView(); const wrapper = await renderView();
fetchConfigMock.mockClear();
wrapper.findComponent({ name: 'AgentSkillsSection' }).vm.$emit('add-skill'); wrapper.findComponent({ name: 'AgentSkillsSection' }).vm.$emit('add-skill');
await nextTick(); await nextTick();
@@ -4346,10 +4404,9 @@ describe('AgentBuilderView — three-column shell', () => {
expect(wrapper.findComponent({ name: 'AgentSkillsSection' }).props('skills')).toEqual([ expect(wrapper.findComponent({ name: 'AgentSkillsSection' }).props('skills')).toEqual([
{ id: 'skill_0Ab9ZkLm3Pq7Xy2N', skill }, { id: 'skill_0Ab9ZkLm3Pq7Xy2N', skill },
]); ]);
expect(showMessageMock).toHaveBeenCalledWith({ expect(fetchConfigMock).toHaveBeenCalledWith('p1', 'a1');
title: 'agents.builder.skills.added', expect(updateConfigMock).not.toHaveBeenCalled();
type: 'success', expect(showMessageMock).not.toHaveBeenCalled();
});
}); });
it('applies skill modal edits to the local config and agent resource', async () => { it('applies skill modal edits to the local config and agent resource', async () => {
@@ -40,6 +40,16 @@ vi.mock('@/app/stores/ui.store', () => ({
useUIStore: () => ({ openModalWithData: openModalWithDataSpy }), 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(); const showErrorSpy = vi.fn();
vi.mock('@n8n/composables/useToast', () => ({ vi.mock('@n8n/composables/useToast', () => ({
useToast: () => ({ showError: showErrorSpy }), useToast: () => ({ showError: showErrorSpy }),
@@ -164,6 +174,7 @@ describe('AgentCapabilitiesSection', () => {
ensureProjectAgentsLoadedSpy.mockImplementation(async () => projectAgentsListRef.value ?? []); ensureProjectAgentsLoadedSpy.mockImplementation(async () => projectAgentsListRef.value ?? []);
refreshProjectAgentsSpy.mockImplementation(async () => projectAgentsListRef.value ?? []); refreshProjectAgentsSpy.mockImplementation(async () => projectAgentsListRef.value ?? []);
integrationsCatalogRef.value = []; integrationsCatalogRef.value = [];
canCreateAgentRef.value = true;
}); });
it('renders web search and forwards its config updates', () => { it('renders web search and forwards its config updates', () => {
@@ -390,16 +401,44 @@ describe('AgentCapabilitiesSection', () => {
name: AGENT_SUB_AGENTS_MODAL_KEY, name: AGENT_SUB_AGENTS_MODAL_KEY,
data: expect.objectContaining({ data: expect.objectContaining({
agents: [ 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 { 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({ modalCall.data.onConfirm({
agentId: 'agent-4', agentId: 'agent-4',
useWhen: 'Use for draft research requests.', 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 () => { 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' }); const child = makeAgent({ id: 'agent-new', name: 'Notion Research Agent' });
refreshProjectAgentsSpy.mockImplementationOnce(async () => { refreshProjectAgentsSpy.mockImplementationOnce(async () => {
@@ -33,7 +33,7 @@ function mountItem(
N8nDropdownMenu: { N8nDropdownMenu: {
template: '<div data-testid="channel-actions"><slot name="trigger" /></div>', template: '<div data-testid="channel-actions"><slot name="trigger" /></div>',
}, },
N8nIcon: { template: '<i />' }, N8nIcon: { props: ['icon'], template: '<i :data-icon="icon" />' },
N8nText: { template: '<span><slot /></span>' }, N8nText: { template: '<span><slot /></span>' },
N8nTooltip: { N8nTooltip: {
props: ['content', 'disabled'], props: ['content', 'disabled'],
@@ -54,8 +54,11 @@ describe('AgentChannelListItem', () => {
expect(wrapper.text()).toContain(label); expect(wrapper.text()).toContain(label);
expect(wrapper.find('[data-testid="agent-channel-connected-indicator"]').exists()).toBe( 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', () => { describe('a channel that failed to start', () => {
@@ -253,7 +253,7 @@ function mountModal(view: ChannelView = 'example_setup', isPublished = false) {
'runtimeError', 'runtimeError',
'connectAction', 'connectAction',
], ],
emits: ['setup', 'disconnect'], emits: ['setup', 'edit'],
template: ` template: `
<li <li
data-testid="channel-list-item" data-testid="channel-list-item"
@@ -264,7 +264,7 @@ function mountModal(view: ChannelView = 'example_setup', isPublished = false) {
:data-runtime-error="runtimeError" :data-runtime-error="runtimeError"
> >
<button data-testid="setup-channel" @click="$emit('setup', integration.type)" /> <button data-testid="setup-channel" @click="$emit('setup', integration.type)" />
<button data-testid="disconnect-channel" @click="$emit('disconnect', integration.type)" /> <button data-testid="edit-channel" @click="$emit('edit', integration.type)" />
</li> </li>
`, `,
}, },
@@ -349,8 +349,8 @@ describe('AgentChannelModal', () => {
await flushPromises(); await flushPromises();
expect(wrapper.get('[data-testid="channel-list-item"]').attributes()).toMatchObject({ expect(wrapper.get('[data-testid="channel-list-item"]').attributes()).toMatchObject({
// Still set up, so the row keeps its Edit/Disconnect menu rather than // Still set up, so the row keeps its Edit action instead of offering to
// offering to connect a channel that already exists. // connect a channel that already exists.
'data-configured': 'true', 'data-configured': 'true',
'data-connected': 'false', 'data-connected': 'false',
'data-not-running': 'true', 'data-not-running': 'true',
@@ -415,9 +415,10 @@ describe('AgentChannelModal', () => {
const release = deferPersistence(); const release = deferPersistence();
statuses.value.example = 'configured'; statuses.value.example = 'configured';
connectedCredentials.value.example = 'credential-old'; connectedCredentials.value.example = 'credential-old';
selectedCredentials.value.example = 'credential-new'; const wrapper = mountModal('list');
const wrapper = mountModal('example_edit');
await flushPromises(); 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 wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click');
await flushPromises(); await flushPromises();
@@ -425,9 +426,7 @@ describe('AgentChannelModal', () => {
await wrapper.get('[data-testid="close-dialog"]').trigger('click'); await wrapper.get('[data-testid="close-dialog"]').trigger('click');
expect(wrapper.emitted('update:open')).toBeUndefined(); expect(wrapper.emitted('update:open')).toBeUndefined();
expect( expect(wrapper.get('[data-testid="agent-modal-back"]').attributes('disabled')).toBeDefined();
wrapper.get('[data-testid="agent-channel-back"]').attributes('disabled'),
).toBeDefined();
await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click'); await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click');
expect(mocks.disconnect).not.toHaveBeenCalled(); expect(mocks.disconnect).not.toHaveBeenCalled();
@@ -609,10 +608,11 @@ describe('AgentChannelModal', () => {
statuses.value.example = 'connected'; statuses.value.example = 'connected';
connectedCredentials.value.example = 'credential-old'; connectedCredentials.value.example = 'credential-old';
integrationApproval.value.example = { mode: 'global' }; integrationApproval.value.example = { mode: 'global' };
const wrapper = mountModal('example_edit'); const wrapper = mountModal('list');
await flushPromises(); 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 await wrapper
.get('[data-testid="channel-list-item"]:last-child [data-testid="setup-channel"]') .get('[data-testid="channel-list-item"]:last-child [data-testid="setup-channel"]')
.trigger('click'); .trigger('click');
@@ -686,15 +686,13 @@ describe('AgentChannelModal', () => {
}); });
}); });
it('uses the same managed removal confirmation from the list menu', async () => { it('confirms managed removal from the edit view and removes the Slack app when selected', async () => {
statuses.value.example = 'connected'; statuses.value.example = 'configured';
connectedCredentials.value.example = 'credential-managed'; connectedCredentials.value.example = 'credential-managed';
const wrapper = mountModal('list', true); const wrapper = mountModal('example_edit', true);
await flushPromises(); await flushPromises();
await wrapper.get('[data-testid="disconnect-channel"]').trigger('click'); await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click');
expect(mocks.disconnect).not.toHaveBeenCalled();
await wrapper.get('[data-testid="confirm-delete-app"]').trigger('click'); await wrapper.get('[data-testid="confirm-delete-app"]').trigger('click');
await flushPromises(); 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 () => { it('disconnects managed credentials without confirmation when the agent is unpublished', async () => {
statuses.value.example = 'configured'; statuses.value.example = 'configured';
connectedCredentials.value.example = 'credential-managed'; connectedCredentials.value.example = 'credential-managed';
@@ -8,6 +8,7 @@ import { useUIStore } from '@/app/stores/ui.store';
import AgentDuplicateModal from '../components/AgentDuplicateModal.vue'; import AgentDuplicateModal from '../components/AgentDuplicateModal.vue';
import type { AgentDuplicateModalData } 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`. // Components use `data-testid`; the global setup configures `data-test-id`.
configure({ testIdAttribute: 'data-testid' }); configure({ testIdAttribute: 'data-testid' });
@@ -18,11 +19,7 @@ vi.mock('@n8n/i18n', () => {
}); });
const stubs = { const stubs = {
Modal: { AgentModal: AgentModalTestStub,
props: ['name', 'width', 'closeOnClickModal', 'closeOnPressEscape', 'showClose'],
template:
'<div role="dialog" :data-close-on-click-modal="closeOnClickModal" :data-close-on-press-escape="closeOnPressEscape" :data-show-close="showClose"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
},
N8nHeading: { template: '<h2><slot /></h2>' }, N8nHeading: { template: '<h2><slot /></h2>' },
N8nText: { template: '<span v-bind="$attrs"><slot /></span>' }, N8nText: { template: '<span v-bind="$attrs"><slot /></span>' },
N8nButton: { N8nButton: {
@@ -80,21 +77,23 @@ describe('AgentDuplicateModal', () => {
expect(getByTestId('agent-duplicate-name-input')).toHaveValue('Support Agent (copy)'); 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(); const { getByTestId, getByText } = renderModal();
await fireEvent.update(getByTestId('agent-duplicate-name-input'), 'Support Agent'); await fireEvent.update(getByTestId('agent-duplicate-name-input'), 'Support Agent');
expect(getByText('agents.duplicate.modal.button.nameTaken')).toBeInTheDocument(); 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 () => { it('reveals the error when confirming an empty name', async () => {
const { getByTestId } = renderModal(); const { getByTestId, getByText, onConfirm } = renderModal();
await fireEvent.update(getByTestId('agent-duplicate-name-input'), ' '); 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 () => { it('calls onConfirm with the trimmed name and closes the modal', async () => {
@@ -133,16 +132,13 @@ describe('AgentDuplicateModal', () => {
const { getByTestId, getByRole } = renderModal({ onConfirm }); const { getByTestId, getByRole } = renderModal({ onConfirm });
const dialog = getByRole('dialog'); const dialog = getByRole('dialog');
// Before submit, dismissal is allowed. expect(dialog).toHaveAttribute('data-busy', 'false');
expect(dialog).toHaveAttribute('data-show-close', 'true');
await fireEvent.update(getByTestId('agent-duplicate-name-input'), 'Triage Bot'); await fireEvent.update(getByTestId('agent-duplicate-name-input'), 'Triage Bot');
await fireEvent.click(getByTestId('agent-duplicate-confirm')); await fireEvent.click(getByTestId('agent-duplicate-confirm'));
await waitFor(() => expect(onConfirm).toHaveBeenCalled()); await waitFor(() => expect(onConfirm).toHaveBeenCalled());
// While submitting, all dismissal paths are disabled. expect(dialog).toHaveAttribute('data-busy', 'true');
expect(dialog).toHaveAttribute('data-show-close', 'false'); expect(getByTestId('dialog-close-button')).toBeDisabled();
expect(dialog).toHaveAttribute('data-close-on-click-modal', 'false');
expect(dialog).toHaveAttribute('data-close-on-press-escape', 'false');
}); });
}); });
@@ -9,6 +9,7 @@ const closeModalMock = vi.fn();
vi.mock('@/app/stores/ui.store', () => ({ vi.mock('@/app/stores/ui.store', () => ({
useUIStore: () => ({ useUIStore: () => ({
closeModal: closeModalMock, closeModal: closeModalMock,
modalsById: { agentJsonImportModal: { open: true } },
}), }),
})); }));
@@ -27,13 +28,8 @@ vi.mock('@n8n/i18n', () => ({
}), }),
})); }));
vi.mock('@/app/components/Modal.vue', () => ({ vi.mock('../components/modals/AgentModal.vue', async () => ({
default: { default: (await import('./utils/AgentModalTestStub')).AgentModalTestStub,
name: 'Modal',
template:
'<section data-testid="agent-json-import-modal"><slot name="header" /><slot name="content" /><slot name="footer" /></section>',
props: ['name', 'width', 'customClass'],
},
})); }));
vi.mock('@n8n/design-system', () => ({ 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.text()).not.toContain('agent.json');
expect(wrapper.find('[data-testid="agent-json-import-confirm"]').attributes('disabled')).toBe( expect(wrapper.find('[data-testid="agent-json-import-confirm"]').attributes('disabled')).toBe(
@@ -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<HTMLElement>();
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<HTMLElement>('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: `
<section
ref="dialog"
v-if="open"
role="dialog"
:data-size="size"
:data-trap-focus="trapFocus"
:data-disable-outside-pointer-events="disableOutsidePointerEvents"
:data-escape-prevented="escapePrevented"
>
<slot />
<button data-test-id="dialog-escape" @click="pressEscape" />
<button data-test-id="dialog-dismiss" @click="$emit('update:open', false)" />
</section>
`,
}),
N8nDialogHeader: defineComponent({ template: '<header><slot /></header>' }),
N8nDialogFooter: defineComponent({ template: '<footer><slot /></footer>' }),
N8nDialogTitle: defineComponent({ template: '<div><slot /></div>' }),
N8nButton: defineComponent({
props: ['disabled'],
emits: ['click'],
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
}),
N8nInlineTextEdit: defineComponent({
props: ['modelValue', 'disabled'],
emits: ['update:modelValue'],
template: `
<input
data-test-id="agent-modal-title-input"
:value="modelValue"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
/>
`,
}),
N8nIcon: defineComponent({ template: '<i />' }),
N8nIconButton: defineComponent({
inheritAttrs: false,
props: ['disabled', 'icon'],
emits: ['click'],
template:
'<button v-bind="$attrs" :data-icon="icon" :disabled="disabled" @click="$emit(\'click\')" />',
}),
N8nText: defineComponent({ template: '<span v-bind="$attrs"><slot /></span>' }),
};
});
function mountModal(
props: Partial<InstanceType<typeof AgentModal>['$props']> = {},
slots: Record<string, string> = { default: '<input data-test-id="first-body-field" />' },
) {
return mount(AgentModal, {
attachTo: document.body,
props: {
open: true,
title: 'Tool settings',
...props,
},
slots,
});
}
describe('AgentModal', () => {
it('uses the large agent size and infers footer visibility from its slots', () => {
const withoutFooter = mountModal();
expect(withoutFooter.get('[role="dialog"]').attributes('data-size')).toBe('2xlarge');
expect(withoutFooter.text()).toContain('Tool settings');
expect(withoutFooter.find('footer').exists()).toBe(false);
withoutFooter.unmount();
const withFooter = mountModal({}, { footerActions: '<button>Save</button>' });
expect(withFooter.get('footer').text()).toContain('Save');
withFooter.unmount();
const hiddenFooter = mountModal(
{ showFooter: false },
{ footerActions: '<button>Save</button>' },
);
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: '<button>Save</button>',
},
});
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<HTMLInputElement>('[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: '<span>Delete this agent?</span>' });
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();
});
});
@@ -12,6 +12,7 @@ import {
import AgentSkillModal from '../components/AgentSkillModal.vue'; import AgentSkillModal from '../components/AgentSkillModal.vue';
import type { AgentSkill } from '../types'; import type { AgentSkill } from '../types';
import { AgentModalTestStub } from './utils/AgentModalTestStub';
vi.mock('@n8n/i18n', () => { vi.mock('@n8n/i18n', () => {
const i18n = { baseText: (key: string) => key }; const i18n = { baseText: (key: string) => key };
@@ -28,17 +29,6 @@ vi.mock('@n8n/composables/useToast', () => ({
useToast: () => ({ showMessage }), useToast: () => ({ showMessage }),
})); }));
const ModalStub = defineComponent({
props: ['name', 'customClass', 'width'],
template: `
<div role="dialog">
<slot name="header" />
<slot name="content" />
<slot name="footer" />
</div>
`,
});
const SkillViewerStub = defineComponent({ const SkillViewerStub = defineComponent({
emits: ['import:skill', 'update:skill', 'update:valid'], emits: ['import:skill', 'update:skill', 'update:valid'],
props: ['skill', 'selectedPath', 'showValidationWarnings', 'errors', 'scrollable'], props: ['skill', 'selectedPath', 'showValidationWarnings', 'errors', 'scrollable'],
@@ -60,7 +50,14 @@ const SkillViewerStub = defineComponent({
() => emit('update:valid', computeValid()), () => emit('update:valid', computeValid()),
); );
return () => 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, skill,
skillId, skillId,
availableTools, availableTools,
existingSkillNames,
}: { }: {
onConfirm?: (payload: { id?: string; skill: AgentSkill }) => void; onConfirm?: (payload: { id?: string; skill: AgentSkill }) => void;
skill?: AgentSkill; skill?: AgentSkill;
skillId?: string; skillId?: string;
availableTools?: Array<{ name: string; label: string }>; availableTools?: Array<{ name: string; label: string }>;
existingSkillNames?: string[];
} = {}) { } = {}) {
const renderComponent = createComponentRenderer(AgentSkillModal, { const renderComponent = createComponentRenderer(AgentSkillModal, {
global: { global: {
stubs: { stubs: {
Modal: ModalStub, AgentModal: AgentModalTestStub,
AgentSkillViewer: SkillViewerStub, AgentSkillViewer: SkillViewerStub,
N8nButton: { N8nButton: {
template: '<button v-bind="$attrs" :disabled="disabled"><slot /></button>', template: '<button v-bind="$attrs" :disabled="disabled"><slot /></button>',
@@ -102,6 +101,7 @@ function renderModal({
skill, skill,
skillId, skillId,
availableTools, availableTools,
existingSkillNames,
onConfirm, onConfirm,
}, },
}, },
@@ -119,11 +119,26 @@ describe('AgentSkillModal', () => {
uiStore.closeModal = vi.fn(); uiStore.closeModal = vi.fn();
}); });
it('does not call createAgentSkill when the user cancels before saving', async () => { it('uses the wider fit-content Agent modal width', () => {
const onConfirm = vi.fn(); const { container } = renderModal();
const { getByText } = renderModal({ onConfirm });
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(apiCreateSpy).not.toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled(); expect(onConfirm).not.toHaveBeenCalled();
@@ -148,11 +163,20 @@ describe('AgentSkillModal', () => {
expect(onConfirm).not.toHaveBeenCalled(); expect(onConfirm).not.toHaveBeenCalled();
expect(uiStore.closeModal).not.toHaveBeenCalled(); expect(uiStore.closeModal).not.toHaveBeenCalled();
expect(showMessage).toHaveBeenCalledWith({ expect(showMessage).not.toHaveBeenCalled();
title: 'agents.builder.skills.saveError', });
message: 'agents.builder.skills.validation.descriptionRequired',
type: 'error', 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 () => { it('explains why overlong instructions cannot be saved', async () => {
@@ -172,11 +196,10 @@ describe('AgentSkillModal', () => {
expect(onConfirm).not.toHaveBeenCalled(); expect(onConfirm).not.toHaveBeenCalled();
expect(uiStore.closeModal).not.toHaveBeenCalled(); expect(uiStore.closeModal).not.toHaveBeenCalled();
expect(showMessage).toHaveBeenCalledWith({ expect(showMessage).not.toHaveBeenCalled();
title: 'agents.builder.skills.saveError', expect(
message: 'agents.builder.skills.validation.instructionsMaxLength', container.querySelector('[data-testid="agent-skill-instructions-error"]'),
type: 'error', ).toHaveTextContent('agents.builder.skills.validation.instructionsMaxLength');
});
}); });
it('adds and removes references from the file navigation', async () => { it('adds and removes references from the file navigation', async () => {
@@ -11,15 +11,18 @@ vi.mock('@n8n/i18n', () => ({
baseText: (key: string, options?: { interpolate?: Record<string, string> }) => baseText: (key: string, options?: { interpolate?: Record<string, string> }) =>
({ ({
'agents.builder.subAgents.modal.title': 'Sub-agents', '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.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.empty.description': 'Published agents show here',
'agents.builder.subAgents.modal.search.placeholder': 'Search agents', 'agents.builder.subAgents.modal.search.placeholder': 'Search agents',
'agents.builder.subAgents.modal.noResults.title': 'No matching agents', 'agents.builder.subAgents.modal.noResults.title': 'No matching agents',
'agents.builder.subAgents.modal.noResults.description': 'Try another search term.', 'agents.builder.subAgents.modal.noResults.description': 'Try another search term.',
'agents.builder.subAgents.modal.add': 'Add agent', 'agents.builder.subAgents.modal.add': 'Add sub-agent',
'agents.builder.subAgents.modal.remove': 'Remove 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.label': 'When should this agent be used?',
'agents.builder.subAgents.useWhen.hint': 'Tell the parent agent when to delegate work.', 'agents.builder.subAgents.useWhen.hint': 'Tell the parent agent when to delegate work.',
'agents.builder.subAgents.useWhen.placeholder': 'Use for billing questions', 'agents.builder.subAgents.useWhen.placeholder': 'Use for billing questions',
@@ -33,15 +36,66 @@ vi.mock('@n8n/i18n', () => ({
})); }));
vi.mock('@/app/stores/ui.store', () => ({ 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: { default: {
name: 'Modal', name: 'ToolsConnectionModal',
props: ['name', 'width', 'customClass'], props: [
template: 'items',
'<section><header><slot name="header" /></header><main><slot name="content" /></main><footer><slot name="footer" /></footer></section>', 'categories',
'detailItem',
'searchPlaceholder',
'emptyMessage',
'noResultsMessage',
'createAction',
'connectLabel',
'connectAriaLabel',
'connectedLabel',
],
emits: ['connect', 'open-detail', 'create'],
template: `
<div>
<button
v-if="createAction"
data-testid="agent-sub-agents-modal-create"
@click="$emit('create')"
>
<span>{{ createAction.label }}</span>
<span data-testid="agent-sub-agents-modal-create-description">{{ createAction.description }}</span>
</button>
<div
v-if="items.length === 0"
data-testid="agent-sub-agents-modal-empty"
>{{ emptyMessage }}</div>
<div
v-for="item in items"
:key="item.id"
data-testid="agent-sub-agents-modal-row"
>
<span>{{ item.title }}</span>
<button
v-if="item.status === 'connected'"
data-testid="agent-sub-agents-modal-added"
@click="$emit('open-detail', item)"
>{{ connectedLabel(item) }}</button>
<button
v-else
:aria-label="connectAriaLabel(item)"
data-testid="agent-sub-agents-modal-add"
@click="$emit('connect', item)"
>{{ connectLabel(item) }}</button>
</div>
</div>
`,
}, },
})); }));
@@ -101,7 +155,8 @@ describe('AgentSubAgentsModal', () => {
const addButtons = wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]'); const addButtons = wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]');
expect(addButtons).toHaveLength(2); 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'); 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)); await wrapper.find('[data-testid="agent-sub-agents-modal-use-when"]').setValue('a'.repeat(513));
expect( expect(
wrapper.find('[data-testid="agent-sub-agents-modal-confirm"]').attributes('disabled'), wrapper.find('[data-testid="agent-sub-agents-modal-confirm"]').attributes('disabled'),
).toBeDefined(); ).toBeUndefined();
await confirmButton.trigger('click');
expect(onConfirm).not.toHaveBeenCalled();
await wrapper await wrapper
.find('[data-testid="agent-sub-agents-modal-use-when"]') .find('[data-testid="agent-sub-agents-modal-use-when"]')
@@ -154,7 +211,49 @@ describe('AgentSubAgentsModal', () => {
expect(closeModalMock).toHaveBeenCalledWith('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, { const wrapper = mount(AgentSubAgentsModal, {
props: { props: {
modalName: 'agentSubAgentsModal', modalName: 'agentSubAgentsModal',
@@ -168,28 +267,16 @@ describe('AgentSubAgentsModal', () => {
}, },
}); });
const searchInput = wrapper.find('[data-testid="agent-sub-agents-modal-search"]'); const picker = wrapper.getComponent({ name: 'ToolsConnectionModal' });
expect(searchInput.exists()).toBe(true); 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 '); await wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')[1].trigger('click');
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');
expect(wrapper.find('h2').text()).toBe('Research Agent'); 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'); await wrapper.find('[data-testid="agent-sub-agents-modal-add"]').trigger('click');
expect(wrapper.find('h2').text()).toBe('Billing Agent'); 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.find('h2').text()).toBe('Sub-agents');
expect(wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')).toHaveLength(1); 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('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( expect(wrapper.find('[data-testid="agent-sub-agents-modal-use-when"]').element).toHaveProperty(
'value', 'value',
'Use for invoice questions.', '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.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); 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();
});
}); });
@@ -6,12 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { MODAL_CONFIRM } from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store'; import { useUIStore } from '@/app/stores/ui.store';
import { useSettingsStore } from '@n8n/stores/settings.store'; import { useSettingsStore } from '@n8n/stores/settings.store';
import AgentTaskModal from '../components/AgentTaskModal.vue'; import AgentTaskModal from '../components/AgentTaskModal.vue';
import { formatScheduleDateTime } from '../utils/scheduleBuilder'; import { formatScheduleDateTime } from '../utils/scheduleBuilder';
import { AgentModalTestStub } from './utils/AgentModalTestStub';
// Components use `data-testid`; the global setup configures `data-test-id`. // Components use `data-testid`; the global setup configures `data-test-id`.
configure({ testIdAttribute: 'data-testid' }); configure({ testIdAttribute: 'data-testid' });
@@ -59,11 +59,6 @@ vi.mock('../composables/useAgentApi', () => ({
updateAgentTask: (...args: unknown[]) => updateAgentTaskSpy(...args), updateAgentTask: (...args: unknown[]) => updateAgentTaskSpy(...args),
})); }));
const confirmSpy = vi.fn();
vi.mock('../composables/useAgentConfirmationModal', () => ({
useAgentConfirmationModal: () => ({ openAgentConfirmationModal: confirmSpy }),
}));
const MODAL_NAME = 'AgentTaskModal'; const MODAL_NAME = 'AgentTaskModal';
interface FormInputStubValidator { interface FormInputStubValidator {
@@ -124,11 +119,7 @@ const N8nFormInputStub = defineComponent({
// Modal + Select/Option use filename-inferred names (no N8n prefix). // Modal + Select/Option use filename-inferred names (no N8n prefix).
const stubs = { const stubs = {
Modal: { AgentModal: AgentModalTestStub,
props: ['name', 'width'],
template:
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
},
N8nHeading: { template: '<h2><slot /></h2>' }, N8nHeading: { template: '<h2><slot /></h2>' },
N8nText: { template: '<span v-bind="$attrs"><slot /></span>' }, N8nText: { template: '<span v-bind="$attrs"><slot /></span>' },
N8nTooltip: { template: '<span><slot /></span>' }, N8nTooltip: { template: '<span><slot /></span>' },
@@ -146,10 +137,10 @@ const stubs = {
'<button v-bind="$attrs" role="switch" :aria-checked="String(modelValue)" @click="$emit(\'update:modelValue\', !modelValue)" />', '<button v-bind="$attrs" role="switch" :aria-checked="String(modelValue)" @click="$emit(\'update:modelValue\', !modelValue)" />',
}, },
AgentPreviewButton: { AgentPreviewButton: {
props: ['isRunnable', 'testId', 'validationIssues'], props: ['isRunnable', 'testId', 'validationIssues', 'variant'],
emits: ['open-preview'], emits: ['open-preview'],
template: template:
'<button :data-testid="testId" :disabled="!isRunnable" @click="isRunnable && $emit(\'open-preview\')">Preview</button>', '<button :data-testid="testId" :data-variant="variant" :disabled="!isRunnable" @click="isRunnable && $emit(\'open-preview\')">Preview</button>',
}, },
N8nInput: { N8nInput: {
props: ['modelValue'], props: ['modelValue'],
@@ -213,7 +204,6 @@ describe('AgentTaskModal', () => {
uiStore = mockedStore(useUIStore); uiStore = mockedStore(useUIStore);
uiStore.openModal(MODAL_NAME); uiStore.openModal(MODAL_NAME);
uiStore.closeModal = vi.fn(); uiStore.closeModal = vi.fn();
confirmSpy.mockResolvedValue(MODAL_CONFIRM);
}); });
afterEach(() => { afterEach(() => {
@@ -229,7 +219,7 @@ describe('AgentTaskModal', () => {
'data-show-toolbar', 'data-show-toolbar',
'floating', '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.update(getByTestId('agent-task-objective-input'), 'Do the thing');
await fireEvent.click(getByTestId('agent-task-save')); await fireEvent.click(getByTestId('agent-task-save'));
@@ -253,7 +243,7 @@ describe('AgentTaskModal', () => {
createAgentTaskSpy.mockResolvedValue({}); createAgentTaskSpy.mockResolvedValue({});
const { getByTestId } = renderModal({ isPublished: false }); 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.update(getByTestId('agent-task-objective-input'), 'Do the thing');
await fireEvent.click(getByTestId('agent-task-save')); await fireEvent.click(getByTestId('agent-task-save'));
@@ -278,7 +268,7 @@ describe('AgentTaskModal', () => {
ensureAgentPersisted, 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.update(getByTestId('agent-task-objective-input'), 'Do the thing');
await fireEvent.click(getByTestId('agent-task-save')); await fireEvent.click(getByTestId('agent-task-save'));
@@ -309,7 +299,7 @@ describe('AgentTaskModal', () => {
ensureAgentPersisted, 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.update(getByTestId('agent-task-objective-input'), 'Do the thing');
await fireEvent.click(getByTestId('agent-task-save')); await fireEvent.click(getByTestId('agent-task-save'));
@@ -334,7 +324,7 @@ describe('AgentTaskModal', () => {
it('does not save when objective exceeds the maximum length', async () => { it('does not save when objective exceeds the maximum length', async () => {
const { getByTestId, getByText } = renderModal(); 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( await fireEvent.update(
getByTestId('agent-task-objective-input'), getByTestId('agent-task-objective-input'),
'x'.repeat(AGENT_TASK_OBJECTIVE_MAX_LENGTH + 1), 'x'.repeat(AGENT_TASK_OBJECTIVE_MAX_LENGTH + 1),
@@ -373,7 +363,7 @@ describe('AgentTaskModal', () => {
updateAgentTaskSpy.mockResolvedValue({}); updateAgentTaskSpy.mockResolvedValue({});
const { getByTestId } = renderModal({ task: makeTask() }); 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 fireEvent.click(getByTestId('agent-task-save'));
await waitFor(() => expect(updateAgentTaskSpy).toHaveBeenCalled()); await waitFor(() => expect(updateAgentTaskSpy).toHaveBeenCalled());
@@ -490,7 +480,7 @@ describe('AgentTaskModal', () => {
const { getByTestId } = renderModal({ task: makeTask({ timezone: null }) }); 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')); await fireEvent.click(getByTestId('agent-task-save'));
// Editing anything else must not pin the task, or a later change to the // 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(); expect(uiStore.closeModal).not.toHaveBeenCalled();
}); });
it('deletes an existing task after confirmation', async () => { it('removes an existing task immediately', async () => {
deleteAgentTaskSpy.mockResolvedValue({ success: true }); deleteAgentTaskSpy.mockResolvedValue({ success: true });
const { getByTestId, onSaved } = renderModal({ const { getByTestId, onSaved } = renderModal({
task: makeTask(), task: makeTask(),
@@ -594,11 +584,17 @@ describe('AgentTaskModal', () => {
isRunnable: true, 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-pause-control')).toBeInTheDocument();
expect(getByTestId('agent-task-toggle')).toBeInTheDocument(); expect(getByTestId('agent-task-toggle')).toBeInTheDocument();
expect(getByTestId('agent-task-preview')).toHaveTextContent('Preview'); 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(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(); expect(queryByTestId('agent-task-run')).not.toBeInTheDocument();
await rerender({ await rerender({
@@ -7,6 +7,7 @@ import { useUIStore } from '@/app/stores/ui.store';
import { fireEvent, waitFor } from '@testing-library/vue'; import { fireEvent, waitFor } from '@testing-library/vue';
import { defineComponent, onMounted, nextTick } from 'vue'; import { defineComponent, onMounted, nextTick } from 'vue';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants'; import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
import { AgentModalTestStub } from './utils/AgentModalTestStub';
import AgentToolConfigModal from '../components/AgentToolConfigModal.vue'; import AgentToolConfigModal from '../components/AgentToolConfigModal.vue';
import type { AgentJsonToolRef, CustomToolEntry } from '../types'; import type { AgentJsonToolRef, CustomToolEntry } from '../types';
@@ -138,6 +139,17 @@ function createWorkflowToolConfigStub(emitValid: boolean) {
const MODAL_NAME = 'AgentToolConfigModal'; 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( function toolRef(
overrides: Partial<Extract<AgentJsonToolRef, { type: 'node' }>['node']> = {}, overrides: Partial<Extract<AgentJsonToolRef, { type: 'node' }>['node']> = {},
): Extract<AgentJsonToolRef, { type: 'node' }> { ): Extract<AgentJsonToolRef, { type: 'node' }> {
@@ -158,6 +170,7 @@ function toolRef(
function renderModal({ function renderModal({
valid = false, valid = false,
onConfirm = vi.fn(), onConfirm = vi.fn(),
onRemove,
ref = toolRef(), ref = toolRef(),
customTool, customTool,
projectId, projectId,
@@ -166,6 +179,7 @@ function renderModal({
}: { }: {
valid?: boolean; valid?: boolean;
onConfirm?: (updated: AgentJsonToolRef) => void; onConfirm?: (updated: AgentJsonToolRef) => void;
onRemove?: () => void;
ref?: AgentJsonToolRef; ref?: AgentJsonToolRef;
customTool?: CustomToolEntry; customTool?: CustomToolEntry;
projectId?: string; projectId?: string;
@@ -175,6 +189,7 @@ function renderModal({
const renderComponent = createComponentRenderer(AgentToolConfigModal, { const renderComponent = createComponentRenderer(AgentToolConfigModal, {
global: { global: {
stubs: { stubs: {
AgentModal: AgentModalTestStub,
NodeIcon: { template: '<div data-test-id="header-node-icon" />' }, NodeIcon: { template: '<div data-test-id="header-node-icon" />' },
FocusScope: { FocusScope: {
template: '<div data-test-id="nested-credential-focus-scope"><slot /></div>', template: '<div data-test-id="nested-credential-focus-scope"><slot /></div>',
@@ -205,6 +220,7 @@ function renderModal({
agentId, agentId,
validationIssues, validationIssues,
onConfirm, onConfirm,
onRemove,
}, },
}, },
}); });
@@ -226,20 +242,32 @@ describe('AgentToolConfigModal', () => {
expect(getByTestId('node-tool-settings-content')).toBeTruthy(); 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 () => { 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'); const dialog = getByRole('dialog');
expect(dialog).toHaveAttribute('data-trap-focus', 'true'); expect(dialog).toHaveAttribute('data-trap-focus', 'true');
expect(dialog).toHaveAttribute('data-disable-outside-pointer-events', '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); uiStore.openModal(CREDENTIAL_EDIT_MODAL_KEY);
await nextTick(); await nextTick();
expect(dialog).toHaveAttribute('data-trap-focus', 'false'); expect(dialog).toHaveAttribute('data-trap-focus', 'false');
expect(dialog).toHaveAttribute('data-disable-outside-pointer-events', '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', () => { 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(); expect(queryByTestId('agent-tool-config-permissions-tab')).toBeNull();
}); });
it('disables Save until the content emits valid=true', async () => { it('keeps Save available and does not confirm invalid content', async () => {
const { getByTestId } = renderModal({ valid: false }); const onConfirm = vi.fn();
const { container } = renderModal({ valid: false, onConfirm });
await nextTick(); await nextTick();
const saveBtn = getByTestId('agent-tool-config-save') as HTMLButtonElement; const saveBtn = getNativeTestId(container, 'agent-tool-config-save') as HTMLButtonElement;
expect(saveBtn.disabled).toBe(true); 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 () => { it('enables Save once valid and round-trips the node back into the toolRef on confirm', async () => {
const onConfirm = vi.fn(); const onConfirm = vi.fn();
const initial = toolRef(); const initial = toolRef();
const { getByTestId } = renderModal({ valid: true, onConfirm, ref: initial }); const { container } = renderModal({ valid: true, onConfirm, ref: initial });
await waitFor(() => { 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); 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); expect(onConfirm).toHaveBeenCalledTimes(1);
const [updated] = onConfirm.mock.calls[0]; 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 () => { 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, valid: true,
ref: toolRef({ ref: toolRef({
nodeType: 'n8n-nodes-base.httpRequestTool', nodeType: 'n8n-nodes-base.httpRequestTool',
@@ -311,15 +345,17 @@ describe('AgentToolConfigModal', () => {
'agents.builder.validation.issue.httpRequestUrlFromAi', 'agents.builder.validation.issue.httpRequestUrlFromAi',
); );
expect(queryByTestId('from-ai-override-button')).not.toBeInTheDocument(); 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 () => { it('saves the approval requirement on node tool refs', async () => {
const onConfirm = vi.fn(); 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-approval-toggle'));
await fireEvent.click(getByTestId('agent-tool-config-save')); await fireEvent.click(getNativeTestId(container, 'agent-tool-config-save'));
expect(onConfirm).toHaveBeenCalledTimes(1); expect(onConfirm).toHaveBeenCalledTimes(1);
const [updated] = onConfirm.mock.calls[0]; const [updated] = onConfirm.mock.calls[0];
@@ -338,14 +374,11 @@ describe('AgentToolConfigModal', () => {
).toBe(Node.DOCUMENT_POSITION_FOLLOWING); ).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 onConfirm = vi.fn();
const { getAllByRole } = renderModal({ valid: true, onConfirm }); const { container } = renderModal({ valid: true, onConfirm });
const buttons = getAllByRole('button'); await fireEvent.click(getNativeTestId(container, 'dialog-close-button'));
const cancelBtn = buttons.find((b) => b.textContent?.includes('agents.toolConfig.cancel'));
expect(cancelBtn).toBeTruthy();
await fireEvent.click(cancelBtn!);
expect(onConfirm).not.toHaveBeenCalled(); expect(onConfirm).not.toHaveBeenCalled();
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME); 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' }, ref: { type: 'custom', id: 'custom-tool-1' },
customTool, customTool,
}); });
expect(getByTestId('agent-custom-tool-viewer').textContent).toContain(customTool.code); expect(getByTestId('agent-custom-tool-viewer').textContent).toContain(customTool.code);
expect(queryByTestId('node-tool-settings-content')).toBeNull(); expect(queryByTestId('node-tool-settings-content')).toBeNull();
expect(queryByTestId('workflow-tool-config-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 () => { it('saves the approval requirement on custom tool refs', async () => {
@@ -395,7 +428,7 @@ describe('AgentToolConfigModal', () => {
providerOptions: null, providerOptions: null,
}, },
}; };
const { getByTestId } = renderModal({ const { container, getByTestId } = renderModal({
valid: true, valid: true,
onConfirm, onConfirm,
ref: { type: 'custom', id: 'custom-tool-1' }, 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-approval-toggle'));
await fireEvent.click(getByTestId('agent-tool-config-save')); await fireEvent.click(getNativeTestId(container, 'agent-tool-config-save'));
expect(onConfirm).toHaveBeenCalledTimes(1); expect(onConfirm).toHaveBeenCalledTimes(1);
const [updated] = onConfirm.mock.calls[0]; const [updated] = onConfirm.mock.calls[0];
@@ -412,7 +445,7 @@ describe('AgentToolConfigModal', () => {
it('preserves the stable workflow id when saving a workflow tool', async () => { it('preserves the stable workflow id when saving a workflow tool', async () => {
const onConfirm = vi.fn(); const onConfirm = vi.fn();
const { getByTestId, queryByTestId } = renderModal({ const { container, getByTestId, queryByTestId } = renderModal({
valid: true, valid: true,
onConfirm, onConfirm,
ref: { ref: {
@@ -428,9 +461,9 @@ describe('AgentToolConfigModal', () => {
expect(queryByTestId('node-tool-settings-content')).toBeNull(); expect(queryByTestId('node-tool-settings-content')).toBeNull();
await waitFor(() => { 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(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({ 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);
});
}); });
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent } from 'vue'; import { defineComponent, watchEffect } from 'vue';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { flushPromises } from '@vue/test-utils'; import { flushPromises } from '@vue/test-utils';
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow'; import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
@@ -24,6 +24,7 @@ import type { IWorkflowDb } from '@/Interface';
import type { ToolPickerMode } from '../components/AgentCapabilitiesSection.types'; import type { ToolPickerMode } from '../components/AgentCapabilitiesSection.types';
import AgentToolsConnectionModalWrapper from '../components/AgentToolsConnectionModalWrapper.vue'; import AgentToolsConnectionModalWrapper from '../components/AgentToolsConnectionModalWrapper.vue';
import type { AgentToolConfigModalData } from '../components/AgentToolConfigForm.vue';
import type { AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types'; import type { AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types';
const showMessageMock = vi.fn(); const showMessageMock = vi.fn();
@@ -128,12 +129,80 @@ const MCP_TOOL: INodeTypeDescription = {
}; };
let modalAttrs: Record<string, unknown> = {}; let modalAttrs: Record<string, unknown> = {};
let multiStepAttrs: Record<string, unknown> = {};
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: `
<section v-if="open" data-test-id="agent-modal-multi-step" :data-step="step">
<header>
<button v-if="showBack" data-test-id="agent-modal-back" @click="$emit('back')" />
<span>{{ title }}</span>
</header>
<slot />
<footer v-if="showFooter">
<slot name="footerLeft" />
<slot name="footerActions" />
<slot name="footer" />
</footer>
</section>
`,
});
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: '<div data-test-id="agent-tool-config-form-stub" />',
});
const ToolsConnectionModalStub = defineComponent({ const ToolsConnectionModalStub = defineComponent({
name: 'ToolsConnectionModal', name: 'ToolsConnectionModal',
inheritAttrs: false, inheritAttrs: false,
setup(_, { attrs }) { props: {
modalAttrs = attrs; persistentScrollbar: Boolean,
},
setup(props, { attrs }) {
watchEffect(() => {
modalAttrs = { ...attrs, ...props };
});
return {}; return {};
}, },
template: template:
@@ -169,8 +238,8 @@ function emitSearch(query: string) {
} }
function emitCreateWorkflow() { function emitCreateWorkflow() {
const listener = modalAttrs.onCreateWorkflow; const listener = modalAttrs.onCreate;
if (typeof listener !== 'function') throw new Error('Missing onCreateWorkflow'); if (typeof listener !== 'function') throw new Error('Missing onCreate');
(listener as () => void)(); (listener as () => void)();
} }
@@ -180,6 +249,8 @@ const PROJECT_ID = 'project-1';
const renderComponent = createComponentRenderer(AgentToolsConnectionModalWrapper, { const renderComponent = createComponentRenderer(AgentToolsConnectionModalWrapper, {
global: { global: {
stubs: { stubs: {
AgentModalMultiStep: AgentModalMultiStepStub,
AgentToolConfigForm: AgentToolConfigFormStub,
ToolsConnectionModal: ToolsConnectionModalStub, ToolsConnectionModal: ToolsConnectionModalStub,
McpRegistrySuggestionFooter: McpRegistrySuggestionFooterStub, McpRegistrySuggestionFooter: McpRegistrySuggestionFooterStub,
}, },
@@ -199,6 +270,9 @@ describe('AgentToolsConnectionModalWrapper', () => {
vi.clearAllMocks(); vi.clearAllMocks();
uuidMockState.counter = 0; uuidMockState.counter = 0;
modalAttrs = {}; modalAttrs = {};
multiStepAttrs = {};
configFormData = null;
configuredResult = null;
createTestingPinia({ stubActions: false }); createTestingPinia({ stubActions: false });
nodeTypesStore = mockedStore(useNodeTypesStore); 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<void> {
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<void> {
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', () => { it('configures the suggestion footer copy', () => {
const { getByText } = render(); const { getByText } = render();
expect(getByText('Need another capability?')).toBeInTheDocument(); expect(getByText('Need another capability?')).toBeInTheDocument();
expect(getByText('Suggest a tool')).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 // DynamicModalLoader passes `open`/`active`/`mode`/`activeId` on top of the
// declared props. If those fall through onto ToolsConnectionModal the // declared props. If those fall through onto ToolsConnectionModal the
// inherited `open` is always true while mounted and would pin the dialog // 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. // 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({ renderComponent({
props: { props: {
modalName: MODAL_NAME, modalName: MODAL_NAME,
@@ -299,26 +396,30 @@ describe('AgentToolsConnectionModalWrapper', () => {
}); });
await flushPromises(); await flushPromises();
expect(modalAttrs.open).toBe(true); expect(modalAttrs.open).toBe(true);
expect(modalAttrs.active).toBeUndefined();
uiStore.modalStateById[MODAL_NAME].open = false; expect(modalAttrs.mode).toBeUndefined();
await flushPromises();
expect(modalAttrs.open).toBe(false);
}); });
// The two dialogs are sequential, not stacked: this one steps aside for the it('opens configuration as the next step and returns with Back', async () => {
// 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 () => {
render(); render();
await flushPromises(); await flushPromises();
expect(modalAttrs.open).toBe(true); expect(modalAttrs.open).toBe(true);
uiStore.modalStateById.agentToolConfigModal.open = true; const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`);
emitConnect(slack!);
await flushPromises(); 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(); await flushPromises();
expect(multiStepAttrs.step).toBe('select');
expect(modalAttrs.open).toBe(true); expect(modalAttrs.open).toBe(true);
}); });
@@ -366,9 +467,14 @@ describe('AgentToolsConnectionModalWrapper', () => {
expect(workflow?.category).toBe('workflows'); expect(workflow?.category).toBe('workflows');
expect(getItems().every((item) => item.category === 'workflows')).toBe(true); expect(getItems().every((item) => item.category === 'workflows')).toBe(true);
expect(modalAttrs.categories).toEqual(['workflows']); expect(modalAttrs.categories).toEqual(['workflows']);
expect(modalAttrs.title).toBe('Workflows'); expect(modalAttrs.title).toBe('Add workflow');
expect(modalAttrs.searchPlaceholder).toBe('Search workflows'); expect(modalAttrs.searchPlaceholder ?? modalAttrs['search-placeholder']).toBe(
expect(modalAttrs.allowWorkflowCreation).toBe(true); '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 () => { 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. // The tool that gets added is the installed type, not the preview.
const [{ tools }] = onConfirm.mock.calls[0]; const [{ tools }] = onConfirm.mock.calls[0];
expect(tools[0].node.nodeType).toBe(COMMUNITY_INSTALLED.name); expect(tools[0].node.nodeType).toBe(COMMUNITY_INSTALLED.name);
@@ -480,7 +591,35 @@ describe('AgentToolsConnectionModalWrapper', () => {
expect(availableSlack).toHaveLength(1); 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(); const onConfirm = vi.fn();
render([], onConfirm); render([], onConfirm);
await flushPromises(); await flushPromises();
@@ -488,18 +627,17 @@ describe('AgentToolsConnectionModalWrapper', () => {
const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`); const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`);
expect(slack).toBeDefined(); expect(slack).toBeDefined();
emitConnect(slack!); emitConnect(slack!);
await flushPromises();
expect(onConfirm).not.toHaveBeenCalled(); expect(onConfirm).not.toHaveBeenCalled();
expect(uiStore.openModalWithData).toHaveBeenCalledTimes(1); expect(uiStore.openModalWithData).not.toHaveBeenCalled();
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0]; expect(multiStepAttrs.step).toBe('configure');
expect(payload.name).toBe('agentToolConfigModal'); expect(getConfigData()).toMatchObject({
expect(payload.data.toolRef).toMatchObject({ toolRef: { type: 'node', node: { nodeType: SLACK.name } },
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 = { nodeTypesStore.visibleNodeTypesByOutputConnectionTypeNames = {
[NodeConnectionTypes.AiTool]: [WIKIPEDIA.name], [NodeConnectionTypes.AiTool]: [WIKIPEDIA.name],
}; };
@@ -510,8 +648,15 @@ describe('AgentToolsConnectionModalWrapper', () => {
const wikipedia = getItems().find((item) => item.id === `nodeType:${WIKIPEDIA.name}`); const wikipedia = getItems().find((item) => item.id === `nodeType:${WIKIPEDIA.name}`);
expect(wikipedia).toBeDefined(); expect(wikipedia).toBeDefined();
emitConnect(wikipedia!); emitConnect(wikipedia!);
await flushPromises();
expect(uiStore.openModalWithData).not.toHaveBeenCalled(); expect(uiStore.openModalWithData).not.toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
expect(getConfigData()).toMatchObject({
toolRef: { type: 'node', name: 'Wikipedia' },
});
await saveConfiguration();
expect(onConfirm).toHaveBeenCalledTimes(1); expect(onConfirm).toHaveBeenCalledTimes(1);
const [{ tools }] = onConfirm.mock.calls[0]; const [{ tools }] = onConfirm.mock.calls[0];
expect(tools).toEqual([ expect(tools).toEqual([
@@ -537,15 +682,17 @@ describe('AgentToolsConnectionModalWrapper', () => {
const connected = getItems().find((item) => item.status === 'connected'); const connected = getItems().find((item) => item.status === 'connected');
emitConnect(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. // instance (fresh ref, empty parameters) — not an edit of the existing one.
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0]; await flushPromises();
expect(payload.name).toBe('agentToolConfigModal'); const data = getConfigData();
expect(payload.data.toolRef).toMatchObject({ expect(data).toMatchObject({
type: 'node', toolRef: {
node: { nodeType: SLACK.name, nodeParameters: {} }, type: 'node',
node: { nodeType: SLACK.name, nodeParameters: {} },
},
}); });
expect(payload.data.existingToolNames).toContain(existing.name); expect(data.existingToolNames).toContain(existing.name);
const configuredRef: AgentJsonToolRef = { const configuredRef: AgentJsonToolRef = {
type: 'node', type: 'node',
@@ -557,7 +704,7 @@ describe('AgentToolsConnectionModalWrapper', () => {
credentials: { slackApi: { id: 'c-2', name: 'Other Slack' } }, credentials: { slackApi: { id: 'c-2', name: 'Other Slack' } },
}, },
}; };
payload.data.onConfirm(configuredRef); await saveConfiguration(configuredRef);
expect(onConfirm).toHaveBeenCalledWith({ expect(onConfirm).toHaveBeenCalledWith({
tools: [existing, configuredRef], tools: [existing, configuredRef],
@@ -576,21 +723,21 @@ describe('AgentToolsConnectionModalWrapper', () => {
emitConnect(connected!); emitConnect(connected!);
// The connected row now opens config for a new instance; there is no // The connected row now opens config for a new instance; there is no
// remove callback on that flow (removal happens via the capabilities chips). // Remove callback on that flow. Removal happens through the capability pills.
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0]; await flushPromises();
expect(payload.data.onRemove).toBeUndefined(); expect(getConfigData().onRemove).toBeUndefined();
expect(onConfirm).not.toHaveBeenCalled(); 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(); const onConfirm = vi.fn();
render([], onConfirm); render([], onConfirm);
await flushPromises(); await flushPromises();
const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`); const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`);
emitConnect(slack!); emitConnect(slack!);
await flushPromises();
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
const configuredRef: AgentJsonToolRef = { const configuredRef: AgentJsonToolRef = {
type: 'node', type: 'node',
name: 'Slack', name: 'Slack',
@@ -601,7 +748,7 @@ describe('AgentToolsConnectionModalWrapper', () => {
credentials: { slackApi: { id: 'c-1', name: 'Prod Slack' } }, credentials: { slackApi: { id: 'c-1', name: 'Prod Slack' } },
}, },
}; };
payload.data.onConfirm(configuredRef); await saveConfiguration(configuredRef);
expect(onConfirm).toHaveBeenCalledWith({ expect(onConfirm).toHaveBeenCalledWith({
tools: [configuredRef], tools: [configuredRef],
@@ -624,6 +771,12 @@ describe('AgentToolsConnectionModalWrapper', () => {
await flushPromises(); await flushPromises();
emitConnect(getItems().find((item) => item.id === `nodeType:${WIKIPEDIA.name}`)!); 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]; const [{ tools }] = onConfirm.mock.calls[0];
expect(tools.map((tool: Extract<AgentJsonToolRef, { type: 'node' }>) => tool.name)).toEqual([ expect(tools.map((tool: Extract<AgentJsonToolRef, { type: 'node' }>) => tool.name)).toEqual([
@@ -727,27 +880,27 @@ describe('AgentToolsConnectionModalWrapper', () => {
); );
expect(onConfirm).not.toHaveBeenCalled(); expect(onConfirm).not.toHaveBeenCalled();
expect(uiStore.closeModal).not.toHaveBeenCalled(); expect(uiStore.closeModal).not.toHaveBeenCalled();
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0]; const data = getConfigData();
expect(payload).toMatchObject({ expect(data).toMatchObject({
name: 'agentToolConfigModal', projectId: PROJECT_ID,
data: { toolRef: {
projectId: PROJECT_ID, type: 'workflow',
toolRef: { workflowId: 'new-workflow-id',
type: 'workflow', workflow: 'My workflow 1',
workflowId: 'new-workflow-id', name: 'My workflow 1',
workflow: 'My workflow 1', description: '',
name: 'My workflow 1', allOutputs: false,
description: '',
allOutputs: false,
},
}, },
}); });
if (data.kind === 'mcpServer' || data.toolRef.type !== 'workflow') {
throw new Error('Expected a workflow tool');
}
const configuredRef: AgentJsonToolRef = { const configuredRef: AgentJsonToolRef = {
...payload.data.toolRef, ...data.toolRef,
description: 'Create the daily sales digest', description: 'Create the daily sales digest',
}; };
payload.data.onConfirm(configuredRef); await saveConfiguration(configuredRef);
expect(onConfirm).toHaveBeenCalledWith({ expect(onConfirm).toHaveBeenCalledWith({
tools: [existingTool, configuredRef], 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(); const onConfirm = vi.fn();
render([], onConfirm); render([], onConfirm);
await flushPromises(); await flushPromises();
emitConnect(getItems().find((item) => item.id === `nodeType:${MCP_TOOL.name}`)!); emitConnect(getItems().find((item) => item.id === `nodeType:${MCP_TOOL.name}`)!);
await flushPromises();
expect(onConfirm).not.toHaveBeenCalled(); expect(onConfirm).not.toHaveBeenCalled();
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0]; expect(getConfigData().kind).toBe('mcpServer');
expect(payload.data.kind).toBe('mcpServer');
payload.data.onConfirm(SERVER); await saveConfiguration(SERVER);
expect(onConfirm).toHaveBeenCalledWith({ tools: [], mcpServers: [SERVER] }); expect(onConfirm).toHaveBeenCalledWith({ tools: [], mcpServers: [SERVER] });
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME); 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(); const onConfirm = vi.fn();
render([], onConfirm, [SERVER]); render([], onConfirm, [SERVER]);
await flushPromises(); await flushPromises();
@@ -908,8 +1061,9 @@ describe('AgentToolsConnectionModalWrapper', () => {
expect(connected?.title).toBe(SERVER.name); expect(connected?.title).toBe(SERVER.name);
emitConnect(connected!); emitConnect(connected!);
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0]; await flushPromises();
payload.data.onRemove(); expect(getConfigData().onRemove).toBeTypeOf('function');
await removeConfiguration();
expect(onConfirm).toHaveBeenCalledWith({ tools: [], mcpServers: [] }); expect(onConfirm).toHaveBeenCalledWith({ tools: [], mcpServers: [] });
}); });
@@ -964,36 +1118,39 @@ describe('AgentToolsConnectionModalWrapper', () => {
expect(modalAttrs.categories as string[]).not.toContain('n8n-connect'); 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(); const onConfirm = vi.fn();
render([], onConfirm); render([], onConfirm);
await flushPromises(); await flushPromises();
const gateway = getItems().find((item) => item.id === `n8n-connect:${SLACK.name}`); const gateway = getItems().find((item) => item.id === `n8n-connect:${SLACK.name}`);
emitConnect(gateway!); 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. // can pick the operation — only the credential is handled for them.
expect(onConfirm).not.toHaveBeenCalled(); expect(onConfirm).not.toHaveBeenCalled();
expect(uiStore.openModalWithData).toHaveBeenCalledTimes(1); expect(uiStore.openModalWithData).not.toHaveBeenCalled();
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0]; const data = getConfigData();
expect(payload.name).toBe('agentToolConfigModal'); if (data.kind === 'mcpServer' || data.toolRef.type !== 'node') {
expect(payload.data.toolRef.node.credentials).toEqual({ throw new Error('Expected a node tool');
}
expect(data.toolRef.node.credentials).toEqual({
slackApi: { id: null, name: '', __aiGatewayManaged: true }, 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(); const onConfirm = vi.fn();
render([], onConfirm); render([], onConfirm);
await flushPromises(); await flushPromises();
const gateway = getItems().find((item) => item.id === `n8n-connect:${SLACK.name}`); const gateway = getItems().find((item) => item.id === `n8n-connect:${SLACK.name}`);
emitConnect(gateway!); emitConnect(gateway!);
await flushPromises();
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0]; await saveConfiguration();
payload.data.onConfirm(payload.data.toolRef);
expect(onConfirm).toHaveBeenCalledTimes(1); expect(onConfirm).toHaveBeenCalledTimes(1);
const [{ tools }] = onConfirm.mock.calls[0]; const [{ tools }] = onConfirm.mock.calls[0];
@@ -1020,15 +1177,18 @@ describe('AgentToolsConnectionModalWrapper', () => {
const connected = getItems().find((item) => item.status === 'connected'); const connected = getItems().find((item) => item.status === 'connected');
emitConnect(connected!); emitConnect(connected!);
await flushPromises();
// Activating a connected managed tool routes through the managed add // Activating a connected managed tool routes through the managed add
// path, so the new instance keeps the __aiGatewayManaged credential. // path, so the new instance keeps the __aiGatewayManaged credential.
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0]; const data = getConfigData();
expect(payload.name).toBe('agentToolConfigModal'); if (data.kind === 'mcpServer' || data.toolRef.type !== 'node') {
expect(payload.data.toolRef.node.credentials).toEqual({ throw new Error('Expected a node tool');
}
expect(data.toolRef.node.credentials).toEqual({
slackApi: { id: null, name: '', __aiGatewayManaged: true }, slackApi: { id: null, name: '', __aiGatewayManaged: true },
}); });
expect(payload.data.existingToolNames).toContain(existing.name); expect(data.existingToolNames).toContain(existing.name);
}); });
}); });
}); });
@@ -15,7 +15,11 @@ const showErrorMock = vi.fn();
vi.mock('@n8n/i18n', () => ({ vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ useI18n: () => ({
baseText: (key: string, options?: { interpolate?: Record<string, string> }) => baseText: (key: string, options?: { interpolate?: Record<string, string> }) =>
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, closeModal: closeModalMock,
openNewCredential: openNewCredentialMock, openNewCredential: openNewCredentialMock,
isModalActiveById: {}, isModalActiveById: {},
modalsById: { agentVectorStoresModal: { open: true } },
}), }),
})); }));
@@ -58,13 +63,8 @@ vi.mock('../composables/useAgentApi', () => ({
testAgentVectorStore: (...args: unknown[]) => testAgentVectorStoreMock(...args), testAgentVectorStore: (...args: unknown[]) => testAgentVectorStoreMock(...args),
})); }));
vi.mock('@/app/components/Modal.vue', () => ({ vi.mock('../components/modals/AgentModalMultiStep.vue', async () => ({
default: { default: (await import('./utils/AgentModalTestStub')).AgentModalMultiStepTestStub,
name: 'Modal',
props: ['name', 'width', 'customClass'],
template:
'<section><header><slot name="header" /></header><main><slot name="content" /></main><footer><slot name="footer" /></footer></section>',
},
})); }));
vi.mock('@/features/credentials/components/CredentialIcon.vue', () => ({ vi.mock('@/features/credentials/components/CredentialIcon.vue', () => ({
@@ -131,9 +131,7 @@ async function selectQdrantProvider(wrapper: ReturnType<typeof mount>) {
} }
async function fillQdrantConnection(wrapper: ReturnType<typeof mount>) { async function fillQdrantConnection(wrapper: ReturnType<typeof mount>) {
await wrapper await wrapper.find('[data-testid="agent-modal-title-input"]').setValue('product_docs');
.find('[data-testid="agent-vector-stores-modal-name"] input')
.setValue('product_docs');
await wrapper await wrapper
.find('[data-testid="agent-vector-stores-modal-collection-name"] input') .find('[data-testid="agent-vector-stores-modal-collection-name"] input')
.setValue('docs'); .setValue('docs');
@@ -205,7 +203,7 @@ describe('AgentVectorStoresModal', () => {
expect(useWhenBlock.text()).toMatch(/required/i); 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 }); testAgentVectorStoreMock.mockResolvedValue({ success: true });
const onConfirm = vi.fn(); const onConfirm = vi.fn();
@@ -219,7 +217,12 @@ describe('AgentVectorStoresModal', () => {
await selectQdrantProvider(wrapper); await selectQdrantProvider(wrapper);
const confirmButton = wrapper.find('[data-testid="agent-vector-stores-modal-confirm"]'); 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); await fillQdrantConnection(wrapper);
expect(confirmButton.attributes('disabled')).toBeUndefined(); expect(confirmButton.attributes('disabled')).toBeUndefined();
@@ -230,9 +233,7 @@ describe('AgentVectorStoresModal', () => {
expect(testAgentVectorStoreMock).toHaveBeenCalledWith({}, 'p1', expectedVectorStore); expect(testAgentVectorStoreMock).toHaveBeenCalledWith({}, 'p1', expectedVectorStore);
expect(onConfirm).toHaveBeenCalledWith(expectedVectorStore); expect(onConfirm).toHaveBeenCalledWith(expectedVectorStore);
expect(closeModalMock).toHaveBeenCalledWith('agentVectorStoresModal'); expect(closeModalMock).toHaveBeenCalledWith('agentVectorStoresModal');
expect(showMessageMock).toHaveBeenCalledWith( expect(showMessageMock).not.toHaveBeenCalled();
expect.objectContaining({ type: 'success', title: expect.stringContaining('product_docs') }),
);
expect(showErrorMock).not.toHaveBeenCalled(); expect(showErrorMock).not.toHaveBeenCalled();
}); });
@@ -348,7 +349,10 @@ describe('AgentVectorStoresModal', () => {
}); });
await flushPromises(); 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 () => { it('clears provider-specific fields when going back to the provider picker', async () => {
@@ -377,21 +381,19 @@ describe('AgentVectorStoresModal', () => {
.setValue('pinecone-cred-1'); .setValue('pinecone-cred-1');
// Back to the picker, then pick Qdrant instead. // 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'); await wrapper.findAll('[data-testid="agent-vector-stores-modal-connect"]')[2].trigger('click');
// Pinecone leftovers must be gone. // Pinecone leftovers must be gone.
const nameInput = wrapper.find<HTMLInputElement>( const nameInput = wrapper.find<HTMLInputElement>('[data-testid="agent-modal-title-input"]');
'[data-testid="agent-vector-stores-modal-name"] input', expect(nameInput.element.value).toBe('new_vector_store');
);
expect(nameInput.element.value).toBe('');
const credentialSelect = wrapper.find<HTMLSelectElement>( const credentialSelect = wrapper.find<HTMLSelectElement>(
'[data-test-id="agent-vector-stores-modal-credential"]', '[data-test-id="agent-vector-stores-modal-credential"]',
); );
expect(credentialSelect.element.value).toBe(''); expect(credentialSelect.element.value).toBe('');
// Returning to Pinecone must not resurrect the old index name. // 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'); await wrapper.findAll('[data-testid="agent-vector-stores-modal-connect"]')[0].trigger('click');
const indexInput = wrapper.find<HTMLInputElement>( const indexInput = wrapper.find<HTMLInputElement>(
'[data-testid="agent-vector-stores-modal-index-name"] input', '[data-testid="agent-vector-stores-modal-index-name"] input',
@@ -24,9 +24,12 @@ vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: { baseUrl: 'http://localhost:5678' } }), 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', () => ({ vi.mock('@n8n/composables/useToast', () => ({
useToast: () => ({ showError: vi.fn(), showMessage: showMessageSpy }), useToast: () => ({ showError: showErrorSpy, showMessage: showMessageSpy }),
})); }));
vi.mock('../composables/useAgentApi', () => ({ vi.mock('../composables/useAgentApi', () => ({
@@ -48,7 +51,11 @@ function setup(
overrides: { overrides: {
supportsToolApproval?: boolean; supportsToolApproval?: boolean;
ensureAgentPersisted?: () => Promise<void>; ensureAgentPersisted?: () => Promise<void>;
beforeAgentMutation?: () => Promise<void>;
refreshAgentAfterMutation?: (projectId: string, agentId: string) => Promise<boolean>;
agent?: Ref<AgentResource | null>; agent?: Ref<AgentResource | null>;
agentId?: Ref<string>;
projectId?: Ref<string>;
} = {}, } = {},
) { ) {
setActivePinia(createTestingPinia({ stubActions: false })); setActivePinia(createTestingPinia({ stubActions: false }));
@@ -56,19 +63,21 @@ function setup(
const localConfig = ref<AgentJsonConfig | null>(makeConfig()); const localConfig = ref<AgentJsonConfig | null>(makeConfig());
const scheduleConfigUpdate = vi.fn(); const scheduleConfigUpdate = vi.fn();
const agentId = overrides.agentId ?? ref('inline:node-1');
const projectId = overrides.projectId ?? ref('project-1');
const actions = useAgentCapabilitiesActions({ const actions = useAgentCapabilitiesActions({
localConfig, localConfig,
agent: ref<AgentResource | null>(null), agent: ref<AgentResource | null>(null),
projectId: computed(() => 'project-1'), projectId: computed(() => projectId.value),
agentId: computed(() => 'inline:node-1'), agentId: computed(() => agentId.value),
connectedTriggers: ref([]), connectedTriggers: ref([]),
scheduleConfigUpdate, scheduleConfigUpdate,
scheduleSkillSave: vi.fn(), scheduleSkillSave: vi.fn(),
...overrides, ...overrides,
}); });
return { uiStore, actions, scheduleConfigUpdate, localConfig }; return { uiStore, actions, scheduleConfigUpdate, localConfig, agentId, projectId };
} }
type ToolsModalData = { type ToolsModalData = {
@@ -240,6 +249,142 @@ describe('useAgentCapabilitiesActions — localSkills host seam', () => {
expect(agent.value?.skillHashes).toEqual({ skill_new: 'hash-new' }); 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<AgentResource | null>({
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<boolean>((resolve) => {
releaseRefresh = resolve;
});
const agentId = ref('inline:node-1');
const agent = ref<AgentResource | null>({
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<boolean>((resolve) => {
releaseRefresh = resolve;
});
const projectId = ref('project-1');
const agent = ref<AgentResource | null>({
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<AgentResource | null>({
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 () => { it('does not create the skill when persisting the agent fails', async () => {
const { uiStore, actions } = setup({ const { uiStore, actions } = setup({
ensureAgentPersisted: async () => await Promise.reject(new Error('create failed')), ensureAgentPersisted: async () => await Promise.reject(new Error('create failed')),
@@ -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: `
<section
v-if="open"
v-bind="$attrs"
role="dialog"
:data-busy="busy"
:data-size="size"
:data-trap-focus="trapFocus"
:data-disable-outside-pointer-events="disableOutsidePointerEvents"
:data-body-flush="bodyFlush"
>
<header>
<button
v-if="showBack"
data-testid="agent-modal-back"
:disabled="busy"
@click="$emit('back')"
/>
<input
v-if="editableTitle"
:value="title"
:disabled="busy"
data-testid="agent-modal-title-input"
@input="$emit('update:title', $event.target.value)"
/>
<h2 v-else>{{ title }}</h2>
<slot name="headerActions" />
<button
data-testid="dialog-close-button"
:disabled="busy"
@click="$emit('update:open', false)"
/>
</header>
<div v-if="!trapFocus" data-testid="nested-credential-focus-scope" />
<main><slot /></main>
<footer
v-if="
showFooter === true ||
(showFooter !== false &&
($slots.footerLeft ||
$slots.footerBeforeCancel ||
$slots.footerActions ||
$slots.footer))
"
>
<slot name="footer">
<slot name="footerLeft" />
<div data-testid="agent-modal-footer-actions">
<slot name="footerBeforeCancel" />
<button
v-if="showCancel"
data-testid="agent-modal-cancel"
:disabled="busy"
@click="$emit('update:open', false)"
>Cancel</button>
<slot name="footerActions" />
</div>
</slot>
</footer>
</section>
`,
});
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: `
<AgentModalTestStub
v-bind="$props"
v-bind="$attrs"
@update:open="$emit('update:open', $event)"
@update:title="$emit('update:title', $event)"
@back="$emit('back')"
>
<template #headerActions><slot name="headerActions" /></template>
<slot />
<template #footerLeft><slot name="footerLeft" /></template>
<template #footerBeforeCancel><slot name="footerBeforeCancel" /></template>
<template #footerActions><slot name="footerActions" /></template>
<template #footer><slot name="footer" /></template>
</AgentModalTestStub>
`,
});
@@ -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.
@@ -12,6 +12,8 @@ import { computed, onMounted, watch } from 'vue';
import type { AgentJsonConfig, AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types'; import type { AgentJsonConfig, AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types';
import type { AgentSkill, CustomToolEntry } from '../types'; import type { AgentSkill, CustomToolEntry } from '../types';
import { useProjectAgentsList } from '../composables/useProjectAgentsList'; import { useProjectAgentsList } from '../composables/useProjectAgentsList';
import { useAgentPermissions } from '../composables/useAgentPermissions';
import { useCreateAgent } from '../composables/useCreateAgent';
import { useAgentCapabilityIssueMessages } from '../composables/useAgentCapabilityIssueMessages'; import { useAgentCapabilityIssueMessages } from '../composables/useAgentCapabilityIssueMessages';
import { toolRefToNode } from '../composables/useAgentToolRefAdapter'; import { toolRefToNode } from '../composables/useAgentToolRefAdapter';
import { AGENT_SUB_AGENTS_MODAL_KEY } from '../constants'; import { AGENT_SUB_AGENTS_MODAL_KEY } from '../constants';
@@ -85,8 +87,10 @@ const i18n = useI18n();
const toast = useToast(); const toast = useToast();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const { createAgent } = useCreateAgent();
const projectIdRef = computed(() => props.projectId); const projectIdRef = computed(() => props.projectId);
const { canCreate: canCreateAgent } = useAgentPermissions(projectIdRef);
const { const {
list: projectAgents, list: projectAgents,
ensureLoaded: ensureProjectAgentsLoaded, ensureLoaded: ensureProjectAgentsLoaded,
@@ -100,9 +104,7 @@ const selectedSubAgentIds = computed(() =>
); );
const selectedSubAgentIdSet = computed(() => new Set(selectedSubAgentIds.value)); const selectedSubAgentIdSet = computed(() => new Set(selectedSubAgentIds.value));
const availableSubAgents = computed(() => const availableSubAgents = computed(() =>
(projectAgents.value ?? []).filter( (projectAgents.value ?? []).filter((agent) => agent.id !== props.agentId),
(agent) => agent.id !== props.agentId && !selectedSubAgentIdSet.value.has(agent.id),
),
); );
const selectedSubAgents = computed(() => const selectedSubAgents = computed(() =>
selectedSubAgentRefs.value.map(({ agentId, useWhen }) => { selectedSubAgentRefs.value.map(({ agentId, useWhen }) => {
@@ -396,14 +398,33 @@ async function openSubAgentsModal() {
uiStore.openModalWithData({ uiStore.openModalWithData({
name: AGENT_SUB_AGENTS_MODAL_KEY, name: AGENT_SUB_AGENTS_MODAL_KEY,
data: { data: {
agents: availableSubAgents.value.map(({ id, name }) => ({ agents: availableSubAgents.value.map(({ id, name }) => {
id, const selectedRef = selectedSubAgentRefs.value.find((ref) => ref.agentId === id);
name, 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 }) => { 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));
}, },
}, },
}); });
@@ -1,13 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
N8nButton, N8nButton,
N8nDropdownMenu,
N8nIcon, N8nIcon,
N8nLoading, N8nLoading,
N8nText, N8nText,
N8nTooltip, N8nTooltip,
updatedIconSet, updatedIconSet,
type DropdownMenuItemProps,
type IconName, type IconName,
} from '@n8n/design-system'; } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
@@ -15,8 +13,6 @@ import type { ChatIntegrationDescriptor } from '@n8n/api-types';
import { computed } from 'vue'; import { computed } from 'vue';
import type { AgentChannelConnectAction } from '../channels/types'; import type { AgentChannelConnectAction } from '../channels/types';
type ChannelAction = 'edit' | 'disconnect';
interface Props { interface Props {
integration: ChatIntegrationDescriptor; integration: ChatIntegrationDescriptor;
configured: boolean; configured: boolean;
@@ -37,26 +33,10 @@ const props = defineProps<Props>();
const emit = defineEmits<{ const emit = defineEmits<{
setup: [channelType: string]; setup: [channelType: string];
edit: [channelType: string]; edit: [channelType: string];
disconnect: [channelType: string];
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
const configuredActions = computed<Array<DropdownMenuItemProps<ChannelAction>>>(() => {
const actions: Array<DropdownMenuItemProps<ChannelAction>> = [
{
id: 'edit',
label: i18n.baseText('generic.edit'),
},
{
id: 'disconnect',
label: i18n.baseText('generic.disconnect'),
},
];
return actions;
});
function isIconName(icon: string): icon is IconName { function isIconName(icon: string): icon is IconName {
return icon in updatedIconSet; return icon in updatedIconSet;
} }
@@ -76,15 +56,6 @@ const statusTooltip = computed(() => {
if (!props.notRunning) return ''; if (!props.notRunning) return '';
return props.runtimeError || i18n.baseText('agents.channels.modal.notRunning.tooltip'); return props.runtimeError || i18n.baseText('agents.channels.modal.notRunning.tooltip');
}); });
function handleConfiguredAction(action: ChannelAction) {
if (action === 'edit') {
emit('edit', props.integration.type);
return;
}
emit('disconnect', props.integration.type);
}
</script> </script>
<template> <template>
@@ -116,36 +87,32 @@ function handleConfiguredAction(action: ChannelAction) {
</div> </div>
<div :class="$style.channelActions"> <div :class="$style.channelActions">
<N8nDropdownMenu <N8nTooltip
v-if="configured" v-if="configured"
:items="configuredActions" :content="statusTooltip"
placement="bottom-end" :disabled="!notRunning"
:modal="false" placement="top"
@select="handleConfiguredAction"
> >
<template #trigger> <button
<N8nTooltip :content="statusTooltip" :disabled="!notRunning" placement="top"> type="button"
<N8nButton variant="ghost" size="medium" :class="$style.connectedTrigger"> :class="$style.connectedTrigger"
<div @click="emit('edit', integration.type)"
v-if="connected" >
:class="$style.connectedDotContainer" <span
data-testid="agent-channel-connected-indicator" v-if="!notRunning"
> :class="$style.connectedIcon"
<span :class="[$style.connectedDot, $style.ping]" /> data-testid="agent-channel-connected-indicator"
<span :class="$style.connectedDot" /> >
</div> <N8nIcon icon="check" :size="14" aria-hidden="true" />
<div </span>
v-else-if="notRunning" <span
:class="$style.connectedDotContainer" v-else-if="notRunning"
data-testid="agent-channel-not-running-indicator" :class="$style.notRunningIndicator"
> data-testid="agent-channel-not-running-indicator"
<span :class="[$style.connectedDot, $style.notRunningDot]" /> />
</div> {{ statusLabel }}
{{ statusLabel }} </button>
</N8nButton> </N8nTooltip>
</N8nTooltip>
</template>
</N8nDropdownMenu>
<N8nButton <N8nButton
v-else v-else
variant="subtle" variant="subtle"
@@ -161,7 +128,7 @@ function handleConfiguredAction(action: ChannelAction) {
</template> </template>
<style module lang="scss"> <style module lang="scss">
@use '@n8n/design-system/css/mixins/motion'; @use '@n8n/design-system/css/mixins/focus';
.channelItem { .channelItem {
display: flex; display: flex;
@@ -172,8 +139,8 @@ function handleConfiguredAction(action: ChannelAction) {
.iconWrapper { .iconWrapper {
flex-shrink: 0; flex-shrink: 0;
width: 32px; width: var(--spacing--xl);
height: 32px; height: var(--spacing--xl);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -188,8 +155,8 @@ function handleConfiguredAction(action: ChannelAction) {
} }
.buttonSkeleton { .buttonSkeleton {
height: 32px; height: var(--spacing--xl);
width: 80px; width: calc(var(--spacing--xl) * 2.5);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -225,27 +192,35 @@ function handleConfiguredAction(action: ChannelAction) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--spacing--3xs); gap: var(--spacing--3xs);
} padding: var(--spacing--4xs) var(--spacing--3xs);
border: 0;
border-radius: var(--radius--2xs);
background: none;
color: var(--color--text--tint-1);
font-family: inherit;
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--regular);
white-space: nowrap;
cursor: pointer;
.connectedDotContainer { &:hover {
display: grid; background: var(--color--background--light-1);
place-content: center;
> * {
grid-area: 1 / 1;
} }
@include focus.focus-visible-ring;
} }
.connectedDot { .connectedIcon {
width: 6px; display: inline-flex;
height: 6px; flex-shrink: 0;
border-radius: var(--radius--full); color: var(--color--success);
background: var(--color--green-500);
} }
.notRunningDot {
.notRunningIndicator {
flex-shrink: 0;
width: var(--spacing--3xs);
height: var(--spacing--3xs);
border-radius: var(--radius--full);
background: var(--color--danger); background: var(--color--danger);
} }
.ping {
@include motion.ping;
}
</style> </style>
@@ -1,19 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useToast } from '@n8n/composables/useToast';
import {
N8nButton,
N8nDialog,
N8nDialogFooter,
N8nDialogHeader,
N8nDialogTitle,
N8nIcon,
N8nIconButton,
updatedIconSet,
type IconName,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { AgentApproval } from '@n8n/api-types'; import type { AgentApproval } from '@n8n/api-types';
import { FocusScope } from 'reka-ui'; import { useToast } from '@n8n/composables/useToast';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { import {
@@ -31,6 +20,7 @@ import { useAgentIntegrationStatus } from '../composables/useAgentIntegrationSta
import { useAgentIntegrationsCatalog } from '../composables/useAgentIntegrationsCatalog'; import { useAgentIntegrationsCatalog } from '../composables/useAgentIntegrationsCatalog';
import AgentChannelApprovalSetting from './AgentChannelApprovalSetting.vue'; import AgentChannelApprovalSetting from './AgentChannelApprovalSetting.vue';
import AgentChannelListItem from './AgentChannelListItem.vue'; import AgentChannelListItem from './AgentChannelListItem.vue';
import AgentModalMultiStep from './modals/AgentModalMultiStep.vue';
export type ChannelView = AgentChannelView; export type ChannelView = AgentChannelView;
@@ -79,9 +69,11 @@ const {
} = useAgentIntegrationStatus(props.projectId, props.agentId); } = useAgentIntegrationStatus(props.projectId, props.agentId);
const currentView = ref<ChannelView>(props.view); const currentView = ref<ChannelView>(props.view);
const openedFromList = ref(props.view === 'list');
const viewSession = ref(0); const viewSession = ref(0);
const credentialIdAtEditOpen = ref(''); const credentialIdAtEditOpen = ref('');
const channelActionInFlight = ref(false); const channelActionInFlight = ref(false);
const saveAttempted = ref(false);
const pendingDisconnect = ref<{ const pendingDisconnect = ref<{
channelType: string; channelType: string;
credentialId: string; credentialId: string;
@@ -224,7 +216,6 @@ const headerContentComponent = computed(() => {
} }
return undefined; return undefined;
}); });
const canClose = computed(() => !actionInFlight.value);
function prepareChannelEdit(channelType: string | null) { function prepareChannelEdit(channelType: string | null) {
captureConnectedCredential(channelType); captureConnectedCredential(channelType);
channelApproval.value = channelType ? integrationApproval.value[channelType] : undefined; channelApproval.value = channelType ? integrationApproval.value[channelType] : undefined;
@@ -248,28 +239,15 @@ const showFooterActions = computed(() => isEditMode.value && selectedChannelType
const currentChannelCredentialId = computed(() => const currentChannelCredentialId = computed(() =>
getChannelCredentialId(selectedChannelType.value), getChannelCredentialId(selectedChannelType.value),
); );
const canSaveChannelConfig = computed(() => {
return (
selectedChannelType.value !== null &&
currentChannelCredentialId.value.length > 0 &&
!channelViewLoading.value &&
!channelViewRef.value?.validationError &&
channelApprovalValid.value
);
});
function isIconName(icon: string): icon is IconName {
return icon in updatedIconSet;
}
const headerText = computed(() => { const headerText = computed(() => {
const isListMode = currentView.value === 'list'; const isListMode = currentView.value === 'list';
const channel = selectedChannelType.value; const channel = selectedChannelType.value;
if (channel && !isListMode) { if (channel && !isListMode) {
return channel; return currentIntegration.value?.label ?? channel;
} }
return i18n.baseText('agents.channels.modal.title'); return i18n.baseText('agents.channels.modal.title');
}); });
const currentStep = computed(() => (currentView.value === 'list' ? 'list' : 'configure'));
function isConnected(channelType: string): boolean { function isConnected(channelType: string): boolean {
return isIntegrationConnected(channelType); return isIntegrationConnected(channelType);
@@ -305,24 +283,25 @@ function connectAction(channelType: string) {
function goToSetup(channelType: string) { function goToSetup(channelType: string) {
clearIntegrationError(channelType); clearIntegrationError(channelType);
openedFromList.value = true;
saveAttempted.value = false;
currentView.value = `${channelType}_setup`; currentView.value = `${channelType}_setup`;
} }
function goToEdit(channelType: string) { function goToEdit(channelType: string) {
prepareChannelEdit(channelType); prepareChannelEdit(channelType);
openedFromList.value = true;
saveAttempted.value = false;
currentView.value = `${channelType}_edit`; currentView.value = `${channelType}_edit`;
} }
function goBackToList() { function goBackToList() {
if (actionInFlight.value) return; if (actionInFlight.value) return;
prepareChannelEdit(null); prepareChannelEdit(null);
saveAttempted.value = false;
currentView.value = 'list'; currentView.value = 'list';
} }
function handleListDisconnect(channelType: string) {
requestDisconnect(channelType, connectedCredentials.value[channelType] ?? '', false);
}
/** /**
* Close once a flow has finished its own work. Separate from `closeModal` * Close once a flow has finished its own work. Separate from `closeModal`
* because a platform reports `connected` from inside its setup flow, while that * because a platform reports `connected` from inside its setup flow, while that
@@ -332,11 +311,6 @@ function completeAndClose() {
emit('update:open', false); emit('update:open', false);
} }
function closeModal() {
if (actionInFlight.value) return;
completeAndClose();
}
function handleModalOpenUpdate(isOpen: boolean) { function handleModalOpenUpdate(isOpen: boolean) {
if (!isOpen && actionInFlight.value) return; if (!isOpen && actionInFlight.value) return;
emit('update:open', isOpen); emit('update:open', isOpen);
@@ -375,10 +349,12 @@ async function runBeforeSave(): Promise<boolean> {
async function saveChannelConfig() { async function saveChannelConfig() {
if (actionInFlight.value) return; if (actionInFlight.value) return;
saveAttempted.value = true;
const channelType = selectedChannelType.value; const channelType = selectedChannelType.value;
const credentialId = currentChannelCredentialId.value; const credentialId = currentChannelCredentialId.value;
if (!channelType || !credentialId) return; if (!channelType || !credentialId) return;
if (channelViewRef.value?.validationError) return; if (channelViewRef.value?.validationError) return;
if (!channelApprovalValid.value) return;
// Swapping the credential of a configured channel is one request: the // Swapping the credential of a configured channel is one request: the
// backend brings the new channel up, swaps both entries in a single write, // backend brings the new channel up, swaps both entries in a single write,
@@ -525,6 +501,8 @@ watch(
if (isOpen) { if (isOpen) {
viewSession.value += 1; viewSession.value += 1;
void loadChannelState(); void loadChannelState();
openedFromList.value = props.view === 'list';
saveAttempted.value = false;
currentView.value = props.view; currentView.value = props.view;
} else { } else {
captureConnectedCredential(null); captureConnectedCredential(null);
@@ -535,180 +513,125 @@ watch(
</script> </script>
<template> <template>
<N8nDialog <AgentModalMultiStep
:open="open" :open="open"
size="2xlarge" :step="currentStep"
:title="headerText"
:show-back="currentView !== 'list' && openedFromList"
:show-footer="showFooterActions"
:busy="actionInFlight"
:trap-focus="!credentialModalOpen" :trap-focus="!credentialModalOpen"
:disable-outside-pointer-events="!credentialModalOpen" :disable-outside-pointer-events="!credentialModalOpen"
:show-close-button="false"
@interact-outside="handleInteractOutside" @interact-outside="handleInteractOutside"
@update:open="handleModalOpenUpdate" @update:open="handleModalOpenUpdate"
@back="goBackToList"
> >
<FocusScope <template #headerActions>
v-if="credentialModalOpen" <component
as-child :is="headerContentComponent"
@mount-auto-focus.prevent v-if="headerContentComponent"
@unmount-auto-focus.prevent :runtime="currentRuntime"
> :disabled="headerContentDisabled"
<span hidden aria-hidden="true" />
</FocusScope>
<N8nDialogHeader :class="$style.customHeader">
<Transition name="channel-header-fade" mode="out-in">
<div v-if="currentView === 'list'" key="list" :class="$style.headerContent">
<div :class="$style.headerTitle">
<N8nDialogTitle>{{ headerText }}</N8nDialogTitle>
</div>
</div>
<div v-else :key="currentView" :class="$style.headerContent">
<N8nIconButton
variant="ghost"
size="small"
icon-size="medium"
icon="arrow-left"
:disabled="actionInFlight"
:class="$style.backButton"
data-testid="agent-channel-back"
@click="goBackToList"
>
<template #icon>
<N8nIcon icon="arrow-left" size="small" />
</template>
</N8nIconButton>
<div :class="$style.headerTitle">
<N8nIcon
v-if="currentIntegration?.icon && isIconName(currentIntegration.icon)"
:icon="currentIntegration.icon"
size="large"
/>
<N8nDialogTitle>{{ headerText }}</N8nDialogTitle>
</div>
<div :class="$style.headerActions">
<component
:is="headerContentComponent"
v-if="headerContentComponent"
:runtime="currentRuntime"
:disabled="headerContentDisabled"
/>
</div>
</div>
</Transition>
<N8nIconButton
variant="ghost"
size="small"
icon-size="medium"
icon="x"
:class="$style.closeButton"
aria-label="Close dialog"
data-test-id="dialog-close-button"
:disabled="!canClose"
@click="closeModal"
/> />
</N8nDialogHeader> </template>
<div data-testid="agent-channel-modal" :class="$style.container"> <div data-testid="agent-channel-modal" :class="$style.container">
<Transition name="channel-view-fade" mode="out-in"> <div v-show="currentView === 'list'" key="list" :class="$style.listView">
<div v-if="currentView === 'list'" key="list" :class="$style.listView"> <ul :class="$style.channelList">
<ul :class="$style.channelList"> <AgentChannelListItem
<AgentChannelListItem v-for="integration in catalog"
v-for="integration in catalog" :key="integration.type"
:key="integration.type" :integration="integration"
:integration="integration" :configured="isConfigured(integration.type)"
:configured="isConfigured(integration.type)" :connected="isConnected(integration.type)"
:connected="isConnected(integration.type)" :not-running="hasRuntimeError(integration.type)"
:not-running="hasRuntimeError(integration.type)" :runtime-error="runtimeErrors[integration.type]"
:runtime-error="runtimeErrors[integration.type]" :loading="listLoading"
:loading="listLoading" :connect-action="connectAction(integration.type)"
:connect-action="connectAction(integration.type)" @setup="goToSetup"
@setup="goToSetup" @edit="goToEdit"
@edit="goToEdit" />
@disconnect="handleListDisconnect" </ul>
/> </div>
</ul>
</div>
<div <div
v-else-if="currentIntegration" v-if="currentView !== 'list' && currentIntegration"
:key="`${isSetupMode ? 'setup' : 'edit'}-${currentView}`" :key="`${isSetupMode ? 'setup' : 'edit'}-${currentView}`"
:class="isSetupMode ? $style.setupView : $style.editView" :class="isSetupMode ? $style.setupView : $style.editView"
>
<component
:is="isSetupMode ? currentPlatform.setupComponent : currentPlatform.editComponent"
:key="viewSession"
ref="channelViewRef"
v-model="selectedCredentials[currentIntegration.type]"
:mode="isSetupMode ? 'setup' : 'edit'"
:integration="currentIntegration"
:credentials="getCredentials(currentIntegration.type)"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading(currentIntegration.type)"
:connected="isConfigured(currentIntegration.type)"
:connected-description="integrationConnectedText(currentIntegration.type)"
:error-message="
hasError(currentIntegration.type) ? errorMessages[currentIntegration.type] : ''
"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:saved-settings="integrationSettings[currentIntegration.type]"
:is-published="isPublished"
:agent-name="agentId"
:project-id="projectId"
:agent-id="agentId"
:force-new-credential="false"
:simple-setup="simpleSetup"
:runtime="currentRuntime"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
@connected="handlePlatformConnected"
/>
<N8nText
v-if="saveAttempted && !currentChannelCredentialId"
size="small"
color="danger"
data-testid="agent-channel-credential-required"
> >
<component {{ i18n.baseText('agents.channels.modal.credentialRequired' as BaseTextKey) }}
:is="isSetupMode ? currentPlatform.setupComponent : currentPlatform.editComponent" </N8nText>
:key="viewSession"
ref="channelViewRef"
v-model="selectedCredentials[currentIntegration.type]"
:mode="isSetupMode ? 'setup' : 'edit'"
:integration="currentIntegration"
:credentials="getCredentials(currentIntegration.type)"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading(currentIntegration.type)"
:connected="isConfigured(currentIntegration.type)"
:connected-description="integrationConnectedText(currentIntegration.type)"
:error-message="
hasError(currentIntegration.type) ? errorMessages[currentIntegration.type] : ''
"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:saved-settings="integrationSettings[currentIntegration.type]"
:is-published="isPublished"
:agent-name="agentId"
:project-id="projectId"
:agent-id="agentId"
:force-new-credential="false"
:simple-setup="simpleSetup"
:runtime="currentRuntime"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
@connected="handlePlatformConnected"
/>
<AgentChannelApprovalSetting <AgentChannelApprovalSetting
v-if="isEditMode && approvableActions.length > 0" v-if="isEditMode && approvableActions.length > 0"
v-model="channelApproval" v-model="channelApproval"
:actions="approvableActions" :actions="approvableActions"
@update:valid="channelApprovalValid = $event" @update:valid="channelApprovalValid = $event"
/> />
</div> </div>
</Transition>
</div> </div>
<Transition name="channel-footer-fade"> <template v-if="showFooterActions" #footerLeft>
<N8nDialogFooter v-if="showFooterActions" :class="$style.customFooter"> <N8nButton
<div :class="$style.footer"> variant="ghost"
<N8nButton size="medium"
variant="ghost" :loading="selectedChannelType ? isLoading(selectedChannelType) : false"
size="medium" :disabled="actionInFlight || !selectedChannelType"
:loading="selectedChannelType ? isLoading(selectedChannelType) : false" data-testid="agent-channel-remove-channel"
:disabled="actionInFlight || !selectedChannelType" @click="removeCurrentChannel"
data-testid="agent-channel-remove-channel" >
@click="removeCurrentChannel" <template #icon><N8nIcon icon="trash-2" :size="16" /></template>
> {{ i18n.baseText('agents.channels.modal.removeChannel') }}
{{ i18n.baseText('agents.channels.modal.removeChannel') }} </N8nButton>
</N8nButton> </template>
<div :class="$style.footerActions"> <template v-if="showFooterActions" #footerActions>
<N8nButton <N8nButton
variant="outline" variant="solid"
size="medium" size="medium"
:disabled="actionInFlight" :loading="actionInFlight"
@click="closeModal" :disabled="!channelApprovalValid || actionInFlight"
> data-testid="agent-channel-save-channel-config"
{{ i18n.baseText('generic.cancel') }} @click="saveChannelConfig"
</N8nButton> >
<N8nButton {{ i18n.baseText('generic.save') }}
variant="solid" </N8nButton>
size="medium" </template>
:loading="actionInFlight"
:disabled="!canSaveChannelConfig || actionInFlight"
data-testid="agent-channel-save-channel-config"
@click="saveChannelConfig"
>
{{ i18n.baseText('generic.save') }}
</N8nButton>
</div>
</div>
</N8nDialogFooter>
</Transition>
<component <component
:is="disconnectConfirmationComponent" :is="disconnectConfirmationComponent"
v-if="pendingDisconnect && disconnectConfirmationComponent" v-if="pendingDisconnect && disconnectConfirmationComponent"
@@ -717,66 +640,14 @@ watch(
@cancel="pendingDisconnect = null" @cancel="pendingDisconnect = null"
@confirm="confirmDisconnect" @confirm="confirmDisconnect"
/> />
</N8nDialog> </AgentModalMultiStep>
</template> </template>
<style lang="scss">
body:has([data-testid='agent-channel-modal'])
.el-overlay:has([data-test-id='editCredential-modal']) {
pointer-events: auto;
}
</style>
<style module lang="scss"> <style module lang="scss">
@use '@n8n/design-system/css/mixins/motion';
.container { .container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing--md); gap: var(--spacing--md);
min-height: var(--height--5xl);
aspect-ratio: 4/3;
overflow-y: auto;
scrollbar-width: thin;
margin-bottom: calc(var(--spacing--lg) * -1);
scrollbar-color: transparent transparent;
}
.customHeader {
flex-direction: row;
align-items: center;
gap: var(--spacing--md);
padding-inline: var(--spacing--lg);
padding-bottom: var(--spacing--md);
height: var(--height--2xl);
border-bottom: var(--border);
margin-inline: calc(var(--spacing--lg) * -1);
}
.headerContent {
display: flex;
align-items: center;
gap: var(--spacing--md);
flex: 1;
min-width: 0;
}
.headerTitle {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
text-transform: capitalize;
}
.headerActions {
display: flex;
align-items: center;
gap: var(--spacing--xs);
margin-left: auto;
}
.closeButton {
flex-shrink: 0;
} }
.listView { .listView {
@@ -787,7 +658,7 @@ body:has([data-testid='agent-channel-modal'])
.channelList { .channelList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: var(--spacing--xs); padding-bottom: var(--spacing--xs);
} }
.setupView, .setupView,
@@ -795,56 +666,5 @@ body:has([data-testid='agent-channel-modal'])
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing--md); gap: var(--spacing--md);
padding: var(--spacing--md) 0;
}
.customFooter {
position: absolute;
inset-inline: 0;
bottom: 0;
padding: var(--spacing--md) var(--spacing--lg);
border-top: var(--border);
background: light-dark(var(--color--neutral-white), var(--color--neutral-800));
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: var(--spacing--2xs);
height: var(--height--md);
}
.footerActions {
display: flex;
gap: var(--spacing--2xs);
}
:global(.channel-view-fade-enter-active) {
--animation--fade-in--duration: var(--duration--snappy);
--animation--fade-in--translate: 0;
@include motion.fade-in;
}
:global(.channel-view-fade-leave-active) {
--animation--fade-out--duration: var(--duration--snappy);
@include motion.fade-out;
}
:global(.channel-header-fade-enter-active),
:global(.channel-footer-fade-enter-active) {
--animation--fade-in--duration: var(--duration--snappy);
--animation--fade-in--translate: 0;
@include motion.fade-in;
}
:global(.channel-header-fade-leave-active),
:global(.channel-footer-fade-leave-active) {
--animation--fade-out--duration: var(--duration--snappy);
@include motion.fade-out;
} }
</style> </style>
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { N8nButton, N8nHeading, N8nIcon, N8nText } from '@n8n/design-system'; import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import Modal from '@/app/components/Modal.vue';
import { useUIStore } from '@/app/stores/ui.store'; import { useUIStore } from '@/app/stores/ui.store';
import AgentModal from './modals/AgentModal.vue';
export type AgentConfirmationModalData = { export type AgentConfirmationModalData = {
title: string; title: string;
@@ -20,6 +20,7 @@ const props = defineProps<{
}>(); }>();
const uiStore = useUIStore(); const uiStore = useUIStore();
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
const submitting = ref(false); const submitting = ref(false);
function closeModal() { function closeModal() {
@@ -43,38 +44,38 @@ async function onConfirm() {
} }
} }
async function onBeforeClose() { async function onOpenChange(open: boolean) {
if (open) return;
const shouldClose = await props.data.onClose?.(); const shouldClose = await props.data.onClose?.();
return shouldClose !== false; if (shouldClose !== false) closeModal();
} }
</script> </script>
<template> <template>
<Modal width="500px" :name="props.modalName" :before-close="onBeforeClose"> <AgentModal
<template #header> :open="modalOpen"
<N8nHeading tag="h2" size="xlarge"> :title="props.data.title"
{{ props.data.title }} :busy="submitting"
</N8nHeading> :show-cancel="false"
size="large"
data-testid="agent-confirmation-modal"
@update:open="onOpenChange"
>
<div :class="$style.content">
<N8nIcon :class="$style.icon" icon="triangle-alert" color="warning" size="xlarge" />
<N8nText size="medium">
{{ props.data.description }}
</N8nText>
</div>
<template #footerActions>
<N8nButton variant="subtle" size="medium" :disabled="submitting" @click="onCancel">
{{ props.data.cancelButtonText }}
</N8nButton>
<N8nButton variant="solid" size="medium" :loading="submitting" @click="onConfirm">
{{ props.data.confirmButtonText }}
</N8nButton>
</template> </template>
<template #content> </AgentModal>
<div :class="$style.content">
<N8nIcon :class="$style.icon" icon="triangle-alert" color="warning" size="xlarge" />
<N8nText size="medium">
{{ props.data.description }}
</N8nText>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton variant="subtle" size="medium" :disabled="submitting" @click="onCancel">
{{ props.data.cancelButtonText }}
</N8nButton>
<N8nButton variant="solid" size="medium" :loading="submitting" @click="onConfirm">
{{ props.data.confirmButtonText }}
</N8nButton>
</div>
</template>
</Modal>
</template> </template>
<style module lang="scss"> <style module lang="scss">
@@ -89,11 +90,4 @@ async function onBeforeClose() {
flex-shrink: 0; flex-shrink: 0;
margin-top: var(--spacing--4xs); margin-top: var(--spacing--4xs);
} }
.footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: var(--spacing--2xs);
}
</style> </style>
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { N8nButton, N8nHeading, N8nInput, N8nText } from '@n8n/design-system'; import { N8nButton, N8nInput, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n'; import { useI18n, type BaseTextKey } from '@n8n/i18n';
import Modal from '@/app/components/Modal.vue';
import { useUIStore } from '@/app/stores/ui.store'; import { useUIStore } from '@/app/stores/ui.store';
import AgentModal from './modals/AgentModal.vue';
export type AgentDuplicateModalData = { export type AgentDuplicateModalData = {
projectId: string; projectId: string;
@@ -21,9 +21,11 @@ const props = defineProps<{
const i18n = useI18n(); const i18n = useI18n();
const uiStore = useUIStore(); const uiStore = useUIStore();
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
const name = ref(''); const name = ref('');
const submitting = ref(false); const submitting = ref(false);
const submitted = ref(false);
const trimmedName = computed(() => name.value.trim()); const trimmedName = computed(() => name.value.trim());
@@ -34,6 +36,16 @@ const isNameTaken = computed(
const canConfirm = computed( const canConfirm = computed(
() => trimmedName.value.length > 0 && !isNameTaken.value && !submitting.value, () => trimmedName.value.length > 0 && !isNameTaken.value && !submitting.value,
); );
const nameError = computed(() => {
if (!trimmedName.value) {
return i18n.baseText('agents.duplicate.modal.nameRequired' as BaseTextKey);
}
if (isNameTaken.value) return i18n.baseText('agents.duplicate.modal.button.nameTaken');
return '';
});
const visibleNameError = computed(() =>
submitted.value || isNameTaken.value ? nameError.value : '',
);
watch( watch(
() => props.data.agentId, () => props.data.agentId,
@@ -48,6 +60,7 @@ function closeModal() {
} }
async function onConfirm() { async function onConfirm() {
submitted.value = true;
if (!canConfirm.value) return; if (!canConfirm.value) return;
submitting.value = true; submitting.value = true;
try { try {
@@ -62,51 +75,39 @@ async function onConfirm() {
</script> </script>
<template> <template>
<Modal <AgentModal
:name="modalName" :open="modalOpen"
width="420px" :title="i18n.baseText('agents.duplicate.modal.name')"
:busy="submitting"
size="medium"
data-testid="agent-duplicate-modal" data-testid="agent-duplicate-modal"
:close-on-click-modal="!submitting" @update:open="!$event && closeModal()"
:close-on-press-escape="!submitting"
:show-close="!submitting"
> >
<template #header> <div :class="$style.content">
<N8nHeading tag="h2" size="xlarge"> <N8nInput
{{ i18n.baseText('agents.duplicate.modal.name') }} v-model="name"
</N8nHeading> :placeholder="i18n.baseText('agents.duplicate.modal.enterName')"
:label="i18n.baseText('agents.duplicate.modal.enterName')"
:required="true"
data-testid="agent-duplicate-name-input"
@enter="onConfirm"
/>
<N8nText v-if="visibleNameError" :class="$style.error" size="small">
{{ visibleNameError }}
</N8nText>
</div>
<template #footerActions>
<N8nButton
variant="solid"
:disabled="submitting"
:loading="submitting"
data-testid="agent-duplicate-confirm"
@click="onConfirm"
>
{{ i18n.baseText('agents.duplicate.modal.button.confirm') }}
</N8nButton>
</template> </template>
<template #content> </AgentModal>
<div :class="$style.content">
<N8nInput
v-model="name"
:placeholder="i18n.baseText('agents.duplicate.modal.enterName')"
:label="i18n.baseText('agents.duplicate.modal.enterName')"
:required="true"
data-testid="agent-duplicate-name-input"
@enter="onConfirm"
/>
<N8nText v-if="isNameTaken" :class="$style.error" size="small">
{{ i18n.baseText('agents.duplicate.modal.button.nameTaken') }}
</N8nText>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton variant="subtle" :disabled="submitting" @click="closeModal">
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<N8nButton
variant="solid"
:disabled="!canConfirm"
:loading="submitting"
data-testid="agent-duplicate-confirm"
@click="onConfirm"
>
{{ i18n.baseText('agents.duplicate.modal.button.confirm') }}
</N8nButton>
</div>
</template>
</Modal>
</template> </template>
<style module lang="scss"> <style module lang="scss">
@@ -119,10 +120,4 @@ async function onConfirm() {
.error { .error {
color: var(--color--danger); color: var(--color--danger);
} }
.footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing--2xs);
}
</style> </style>
@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, useTemplateRef } from 'vue'; import { computed, ref, useTemplateRef } from 'vue';
import { AgentJsonConfigSchema } from '@n8n/api-types'; import { AgentJsonConfigSchema } from '@n8n/api-types';
import { N8nButton, N8nCallout, N8nText } from '@n8n/design-system'; import { N8nButton, N8nCallout, N8nText } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n'; import { useI18n, type BaseTextKey } from '@n8n/i18n';
import Modal from '@/app/components/Modal.vue';
import { useUIStore } from '@/app/stores/ui.store'; import { useUIStore } from '@/app/stores/ui.store';
import type { AgentJsonConfig } from '../types'; import type { AgentJsonConfig } from '../types';
import AgentModal from './modals/AgentModal.vue';
const props = defineProps<{ const props = defineProps<{
modalName: string; modalName: string;
@@ -15,6 +15,7 @@ const props = defineProps<{
const i18n = useI18n(); const i18n = useI18n();
const uiStore = useUIStore(); const uiStore = useUIStore();
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
const parsedConfig = ref<AgentJsonConfig | null>(null); const parsedConfig = ref<AgentJsonConfig | null>(null);
const errorMessage = ref(''); const errorMessage = ref('');
const importing = ref(false); const importing = ref(false);
@@ -68,49 +69,47 @@ async function onConfirm() {
</script> </script>
<template> <template>
<Modal <AgentModal
:name="props.modalName" :open="modalOpen"
:title="i18n.baseText('agents.builder.importJsonModal.title' as BaseTextKey)" :title="i18n.baseText('agents.builder.importJsonModal.title' as BaseTextKey)"
width="520px" :busy="importing"
size="large"
data-testid="agent-json-import-modal" data-testid="agent-json-import-modal"
@update:open="!$event && closeModal()"
> >
<template #content> <div :class="$style.content">
<div :class="$style.content"> <N8nText size="small" color="text-light">
<N8nText size="small" color="text-light"> {{ i18n.baseText('agents.builder.importJsonModal.description' as BaseTextKey) }}
{{ i18n.baseText('agents.builder.importJsonModal.description' as BaseTextKey) }} </N8nText>
<label :class="$style.fileField">
<N8nText size="small" :bold="true">
{{ i18n.baseText('agents.builder.importJsonModal.fileLabel' as BaseTextKey) }}
</N8nText> </N8nText>
<input
<label :class="$style.fileField"> ref="fileInput"
<N8nText size="small" :bold="true"> type="file"
{{ i18n.baseText('agents.builder.importJsonModal.fileLabel' as BaseTextKey) }} accept="application/json,.json"
</N8nText> data-testid="agent-json-import-file-input"
<input @change="onFileChange"
ref="fileInput"
type="file"
accept="application/json,.json"
data-testid="agent-json-import-file-input"
@change="onFileChange"
/>
</label>
<N8nCallout v-if="errorMessage" theme="danger" data-testid="agent-json-import-error">
{{ errorMessage }}
</N8nCallout>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton variant="subtle" :label="i18n.baseText('generic.cancel')" @click="closeModal" />
<N8nButton
:label="i18n.baseText('agents.builder.importJsonModal.import' as BaseTextKey)"
:disabled="!parsedConfig || importing"
data-testid="agent-json-import-confirm"
@click="onConfirm"
/> />
</div> </label>
<N8nCallout v-if="errorMessage" theme="danger" data-testid="agent-json-import-error">
{{ errorMessage }}
</N8nCallout>
</div>
<template #footerActions>
<N8nButton
:label="i18n.baseText('agents.builder.importJsonModal.import' as BaseTextKey)"
:disabled="!parsedConfig || importing"
:loading="importing"
data-testid="agent-json-import-confirm"
@click="onConfirm"
/>
</template> </template>
</Modal> </AgentModal>
</template> </template>
<style module lang="scss"> <style module lang="scss">
@@ -125,10 +124,4 @@ async function onConfirm() {
flex-direction: column; flex-direction: column;
gap: var(--spacing--3xs); gap: var(--spacing--3xs);
} }
.footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing--2xs);
}
</style> </style>
@@ -11,12 +11,14 @@ const props = withDefaults(
isRunnable: boolean; isRunnable: boolean;
isPreviewOpen?: boolean; isPreviewOpen?: boolean;
iconOnly?: boolean; iconOnly?: boolean;
variant?: 'subtle' | 'ghost';
validationIssues?: AgentConfigValidationIssue[]; validationIssues?: AgentConfigValidationIssue[];
testId?: string; testId?: string;
}>(), }>(),
{ {
isPreviewOpen: false, isPreviewOpen: false,
iconOnly: false, iconOnly: false,
variant: 'subtle',
validationIssues: () => [], validationIssues: () => [],
testId: undefined, testId: undefined,
}, },
@@ -68,7 +70,7 @@ function onClick() {
/> />
<N8nButton <N8nButton
v-else v-else
variant="subtle" :variant="props.variant"
size="medium" size="medium"
icon="flask-conical" icon="flask-conical"
:label="label" :label="label"
@@ -216,4 +216,14 @@ function testIdForPath(path: string) {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@media (max-width: 480px) {
.nav {
width: 100%;
max-height: calc(var(--height--5xl) * 2);
border-right: 0;
border-bottom: var(--border-width) var(--border-style) var(--color--foreground);
overflow-y: auto;
}
}
</style> </style>
@@ -4,17 +4,16 @@ import {
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH, AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
AGENT_SKILL_REFERENCE_MAX_COUNT, AGENT_SKILL_REFERENCE_MAX_COUNT,
} from '@n8n/api-types'; } from '@n8n/api-types';
import { N8nButton, N8nCallout, N8nHeading, N8nIcon } from '@n8n/design-system'; import { N8nButton, N8nCallout, N8nIcon } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n'; import { useI18n, type BaseTextKey } from '@n8n/i18n';
import Modal from '@/app/components/Modal.vue';
import { useToast } from '@n8n/composables/useToast';
import { useUIStore } from '@/app/stores/ui.store'; import { useUIStore } from '@/app/stores/ui.store';
import { useAgentTelemetry } from '../composables/useAgentTelemetry'; import { useAgentTelemetry } from '../composables/useAgentTelemetry';
import type { AgentSkill } from '../types'; import type { AgentSkill } from '../types';
import { normalizeAgentSkillForSave } from '../utils/agentSkill'; import { normalizeAgentSkillForSave } from '../utils/agentSkill';
import AgentSkillFileNav from './AgentSkillFileNav.vue'; import AgentSkillFileNav from './AgentSkillFileNav.vue';
import AgentSkillViewer, { type AgentSkillAllowedToolOption } from './AgentSkillViewer.vue'; import AgentSkillViewer, { type AgentSkillAllowedToolOption } from './AgentSkillViewer.vue';
import AgentModal from './modals/AgentModal.vue';
const SKILL_FILE = 'SKILL.md'; const SKILL_FILE = 'SKILL.md';
@@ -41,11 +40,23 @@ const props = defineProps<{
const i18n = useI18n(); const i18n = useI18n();
const uiStore = useUIStore(); const uiStore = useUIStore();
const agentTelemetry = useAgentTelemetry(); const agentTelemetry = useAgentTelemetry();
const { showMessage } = useToast(); const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
function getDefaultSkillName(): string {
const baseName = i18n.baseText('agents.builder.skills.defaultName' as BaseTextKey);
const existingNames = new Set(
(props.data.existingSkillNames ?? []).map((name) => name.trim().toLowerCase()),
);
if (!existingNames.has(baseName.toLowerCase())) return baseName;
let suffix = 2;
while (existingNames.has(`${baseName} ${suffix}`.toLowerCase())) suffix += 1;
return `${baseName} ${suffix}`;
}
const skill = ref<AgentSkill>( const skill = ref<AgentSkill>(
normalizeSkill({ normalizeSkill({
name: props.data.skill?.name ?? '', name: props.data.skill?.name ?? getDefaultSkillName(),
description: props.data.skill?.description ?? '', description: props.data.skill?.description ?? '',
instructions: props.data.skill?.instructions ?? '', instructions: props.data.skill?.instructions ?? '',
...(props.data.skill?.allowedTools ? { allowedTools: props.data.skill.allowedTools } : {}), ...(props.data.skill?.allowedTools ? { allowedTools: props.data.skill.allowedTools } : {}),
@@ -79,6 +90,12 @@ const validationErrors = computed<Partial<Record<keyof AgentSkill, string>>>(()
errors.name = i18n.baseText('agents.builder.skills.validation.nameRequired'); errors.name = i18n.baseText('agents.builder.skills.validation.nameRequired');
} else if (name.length > 128) { } else if (name.length > 128) {
errors.name = i18n.baseText('agents.builder.skills.validation.nameMaxLength'); errors.name = i18n.baseText('agents.builder.skills.validation.nameMaxLength');
} else if (
(props.data.existingSkillNames ?? []).some(
(existingName) => existingName.trim().toLowerCase() === name.toLowerCase(),
)
) {
errors.name = i18n.baseText('agents.builder.skills.validation.nameDuplicate');
} }
if (!description) { if (!description) {
@@ -104,6 +121,9 @@ const validationErrors = computed<Partial<Record<keyof AgentSkill, string>>>(()
const visibleErrors = computed(() => const visibleErrors = computed(() =>
submitted.value || openedWithMissingContent.value ? validationErrors.value : {}, submitted.value || openedWithMissingContent.value ? validationErrors.value : {},
); );
const visibleNameError = computed(() =>
submitted.value ? (validationErrors.value.name ?? '') : '',
);
const canSave = computed(() => formIsValid.value); const canSave = computed(() => formIsValid.value);
function onSkillUpdate(updates: Partial<AgentSkill>) { function onSkillUpdate(updates: Partial<AgentSkill>) {
@@ -177,19 +197,7 @@ function closeModal() {
function onSave() { function onSave() {
submitted.value = true; submitted.value = true;
if (!canSave.value) { if (!canSave.value) return;
const message =
validationErrors.value.name ??
validationErrors.value.description ??
validationErrors.value.instructions ??
validationErrors.value.references;
showMessage({
title: i18n.baseText('agents.builder.skills.saveError'),
...(message ? { message } : {}),
type: 'error',
});
return;
}
const payload = normalizeSkill({ const payload = normalizeSkill({
name: skill.value.name.trim(), name: skill.value.name.trim(),
@@ -211,98 +219,82 @@ function onRemove() {
</script> </script>
<template> <template>
<Modal <AgentModal
:name="props.modalName" :open="modalOpen"
width="1100px" :title="skill.name"
:custom-class="$style.modal" :title-placeholder="i18n.baseText('agents.builder.skills.name.placeholder')"
:title-error="visibleNameError"
:title-max-length="128"
size="fit"
body-flush
editable-title
data-testid="agent-skill-modal" data-testid="agent-skill-modal"
@update:open="!$event && closeModal()"
@update:title="onSkillUpdate({ name: $event })"
> >
<template #header> <N8nCallout
<N8nHeading tag="h2" size="large"> v-if="openedWithMissingContent"
{{ i18n.baseText('agents.builder.skills.create.title') }} theme="warning"
</N8nHeading> :class="$style.missingContentCallout"
</template> data-testid="agent-skill-missing-content-callout"
>
{{ i18n.baseText('agents.builder.skills.missingContent.callout' as BaseTextKey) }}
</N8nCallout>
<div :class="$style.content">
<AgentSkillFileNav
:skill="skill"
:selected-path="selectedPath"
:add-reference-disabled="!canAddReference"
@add-reference="onAddReference"
@remove-reference="onRemoveReference"
@select="selectedPath = $event"
/>
<AgentSkillViewer
:skill="skill"
:available-tools="props.data.availableTools ?? []"
:existing-skill-names="props.data.existingSkillNames ?? []"
:selected-path="selectedPath"
:errors="visibleErrors"
:scrollable="false"
:show-name-field="false"
:show-validation-warnings="submitted || openedWithMissingContent"
@import:skill="onImportSkill"
@select:path="selectedPath = $event"
@update:skill="onSkillUpdate"
@update:valid="onValidUpdate"
/>
</div>
<template #content> <template v-if="isEditing && data.onRemove" #footerLeft>
<N8nCallout <N8nButton variant="ghost" data-testid="agent-skill-remove" @click="onRemove">
v-if="openedWithMissingContent" <template #icon><N8nIcon icon="trash-2" :size="16" /></template>
theme="warning" {{ i18n.baseText('agents.builder.skills.remove') }}
data-testid="agent-skill-missing-content-callout" </N8nButton>
>
{{ i18n.baseText('agents.builder.skills.missingContent.callout' as BaseTextKey) }}
</N8nCallout>
<div :class="$style.content">
<AgentSkillFileNav
:skill="skill"
:selected-path="selectedPath"
:add-reference-disabled="!canAddReference"
@add-reference="onAddReference"
@remove-reference="onRemoveReference"
@select="selectedPath = $event"
/>
<AgentSkillViewer
:skill="skill"
:available-tools="props.data.availableTools ?? []"
:existing-skill-names="props.data.existingSkillNames ?? []"
:selected-path="selectedPath"
:errors="visibleErrors"
:scrollable="false"
:show-validation-warnings="submitted || openedWithMissingContent"
@import:skill="onImportSkill"
@select:path="selectedPath = $event"
@update:skill="onSkillUpdate"
@update:valid="onValidUpdate"
/>
</div>
</template> </template>
<template #footerActions>
<template #footer> <N8nButton variant="solid" data-testid="agent-skill-create-save" @click="onSave">
<div :class="$style.footer"> {{ i18n.baseText('generic.save') }}
<N8nButton </N8nButton>
v-if="isEditing && data.onRemove"
variant="subtle"
data-testid="agent-skill-remove"
@click="onRemove"
>
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
{{ i18n.baseText('agents.builder.skills.remove') }}
</N8nButton>
<div :class="$style.footerActions">
<N8nButton variant="subtle" @click="closeModal">
{{ i18n.baseText('agents.builder.skills.create.cancel') }}
</N8nButton>
<N8nButton variant="solid" data-testid="agent-skill-create-save" @click="onSave">
{{ i18n.baseText('agents.builder.skills.create.save') }}
</N8nButton>
</div>
</div>
</template> </template>
</Modal> </AgentModal>
</template> </template>
<style module> <style module>
.content { .content {
height: 620px; /* The Design System has no width preset between 2xlarge and full. */
width: min(52rem, calc(100dvw - var(--spacing--lg) * 3));
height: min(calc(70dvh - var(--spacing--lg)), calc(var(--height--5xl) * 6 - var(--spacing--lg)));
min-height: 0; min-height: 0;
margin: 0 calc(-1 * var(--spacing--lg)) calc(-1 * var(--spacing--lg));
display: flex; display: flex;
} }
.modal { .missingContentCallout {
:global(.modal-content) { margin: var(--spacing--md) var(--spacing--lg) 0;
overflow: hidden; }
@media (max-width: 480px) {
.content {
flex-direction: column;
} }
} }
.footer {
display: flex;
justify-content: space-between;
gap: var(--spacing--2xs);
}
.footerActions {
display: flex;
gap: var(--spacing--2xs);
margin-left: auto;
}
</style> </style>
@@ -8,9 +8,6 @@ import {
} from '@n8n/api-types'; } from '@n8n/api-types';
import { import {
N8nButton, N8nButton,
N8nDialog,
N8nDialogHeader,
N8nDialogTitle,
N8nFormInput, N8nFormInput,
N8nIcon, N8nIcon,
N8nInputLabel, N8nInputLabel,
@@ -27,6 +24,7 @@ import { AgentSkillImportError, useAgentSkillImport } from '../composables/useAg
import type { AgentSkill, AgentSkillReference } from '../types'; import type { AgentSkill, AgentSkillReference } from '../types';
import { formatToolNameForDisplay } from '../utils/toolDisplayName'; import { formatToolNameForDisplay } from '../utils/toolDisplayName';
import AgentChipButton from './AgentChipButton.vue'; import AgentChipButton from './AgentChipButton.vue';
import AgentModal from './modals/AgentModal.vue';
const SKILL_FILE = 'SKILL.md'; const SKILL_FILE = 'SKILL.md';
@@ -51,6 +49,7 @@ const props = withDefaults(
selectedPath?: string; selectedPath?: string;
scrollable?: boolean; scrollable?: boolean;
showValidationWarnings?: boolean; showValidationWarnings?: boolean;
showNameField?: boolean;
}>(), }>(),
{ {
availableTools: () => [], availableTools: () => [],
@@ -59,6 +58,7 @@ const props = withDefaults(
selectedPath: SKILL_FILE, selectedPath: SKILL_FILE,
scrollable: true, scrollable: true,
showValidationWarnings: false, showValidationWarnings: false,
showNameField: true,
}, },
); );
@@ -86,7 +86,6 @@ const referenceFileName = ref('');
const fileError = ref(''); const fileError = ref('');
const addToolDialogOpen = ref(false); const addToolDialogOpen = ref(false);
const formValidation = reactive({ const formValidation = reactive({
name: false,
description: false, description: false,
referenceName: true, referenceName: true,
}); });
@@ -98,6 +97,14 @@ const nameValidationRules: Array<Rule | RuleGroup> = [
const normalizedExistingSkillNames = computed( const normalizedExistingSkillNames = computed(
() => new Set(props.existingSkillNames.map((name) => name.trim().toLowerCase())), () => new Set(props.existingSkillNames.map((name) => name.trim().toLowerCase())),
); );
const nameIsValid = computed(() => {
const value = name.value.trim();
return (
value.length > 0 &&
value.length <= 128 &&
!normalizedExistingSkillNames.value.has(value.toLowerCase())
);
});
const nameValidators: Record<string, IValidator> = { const nameValidators: Record<string, IValidator> = {
uniqueSkillName: { uniqueSkillName: {
validate: (value: Validatable) => validate: (value: Validatable) =>
@@ -167,7 +174,7 @@ const referencesValid = computed(
); );
const formIsValid = computed( const formIsValid = computed(
() => () =>
formValidation.name && nameIsValid.value &&
formValidation.description && formValidation.description &&
(!selectedReference.value || formValidation.referenceName) && (!selectedReference.value || formValidation.referenceName) &&
instructionsValid.value && instructionsValid.value &&
@@ -245,8 +252,8 @@ function onDescriptionInput(value: string | number | boolean | null | undefined)
emit('update:skill', { description: next }); emit('update:skill', { description: next });
} }
function onFieldValidate(field: 'name' | 'description', valid: boolean) { function onDescriptionValidate(valid: boolean) {
formValidation[field] = valid; formValidation.description = valid;
} }
function onReferenceNameValidate(valid: boolean) { function onReferenceNameValidate(valid: boolean) {
@@ -448,6 +455,7 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
ref="skillFileInput" ref="skillFileInput"
type="file" type="file"
accept=".md,text/markdown" accept=".md,text/markdown"
tabindex="-1"
:disabled="props.disabled" :disabled="props.disabled"
:class="$style.fileInput" :class="$style.fileInput"
data-testid="agent-skill-skill-md-file-input" data-testid="agent-skill-skill-md-file-input"
@@ -458,6 +466,7 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
type="file" type="file"
webkitdirectory webkitdirectory
multiple multiple
tabindex="-1"
:disabled="props.disabled" :disabled="props.disabled"
:class="$style.fileInput" :class="$style.fileInput"
data-testid="agent-skill-folder-file-input" data-testid="agent-skill-folder-file-input"
@@ -470,7 +479,7 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
props.errors.references props.errors.references
}}</N8nText> }}</N8nText>
<div :class="$style.field"> <div v-if="props.showNameField" :class="$style.field">
<N8nFormInput <N8nFormInput
:model-value="name" :model-value="name"
:label="i18n.baseText('agents.builder.skills.name.label')" :label="i18n.baseText('agents.builder.skills.name.label')"
@@ -484,7 +493,6 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
:validators="nameValidators" :validators="nameValidators"
data-testid="agent-skill-name-input" data-testid="agent-skill-name-input"
@update:model-value="onNameInput" @update:model-value="onNameInput"
@validate="onFieldValidate('name', $event)"
/> />
</div> </div>
@@ -494,6 +502,7 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
:label="i18n.baseText('agents.builder.skills.description.label')" :label="i18n.baseText('agents.builder.skills.description.label')"
name="skill-description" name="skill-description"
required required
:focus-initially="!props.showNameField"
label-size="small" label-size="small"
:placeholder="i18n.baseText('agents.builder.skills.description.placeholder')" :placeholder="i18n.baseText('agents.builder.skills.description.placeholder')"
:disabled="props.disabled" :disabled="props.disabled"
@@ -501,7 +510,7 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
:validation-rules="descriptionValidationRules" :validation-rules="descriptionValidationRules"
data-testid="agent-skill-description-input" data-testid="agent-skill-description-input"
@update:model-value="onDescriptionInput" @update:model-value="onDescriptionInput"
@validate="onFieldValidate('description', $event)" @validate="onDescriptionValidate"
/> />
</div> </div>
@@ -638,12 +647,14 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
</N8nInputLabel> </N8nInputLabel>
</div> </div>
<N8nDialog :open="addToolDialogOpen" size="small" @update:open="addToolDialogOpen = $event"> <AgentModal
<N8nDialogHeader> :open="addToolDialogOpen"
<N8nDialogTitle> :title="i18n.baseText('agents.builder.skills.allowedTools.addModal.title' as BaseTextKey)"
{{ i18n.baseText('agents.builder.skills.allowedTools.addModal.title' as BaseTextKey) }} size="small"
</N8nDialogTitle> stacked
</N8nDialogHeader> :show-footer="false"
@update:open="addToolDialogOpen = $event"
>
<div :class="$style.allowedToolOptions"> <div :class="$style.allowedToolOptions">
<N8nText v-if="props.availableTools.length === 0" size="small" color="text-light"> <N8nText v-if="props.availableTools.length === 0" size="small" color="text-light">
{{ i18n.baseText('agents.builder.skills.allowedTools.addModal.empty' as BaseTextKey) }} {{ i18n.baseText('agents.builder.skills.allowedTools.addModal.empty' as BaseTextKey) }}
@@ -664,7 +675,7 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
{{ tool.label }} {{ tool.label }}
</AgentChipButton> </AgentChipButton>
</div> </div>
</N8nDialog> </AgentModal>
</div> </div>
</template> </template>
@@ -749,7 +760,6 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--spacing--2xs); gap: var(--spacing--2xs);
margin-top: var(--spacing--sm);
} }
.allowedToolOption { .allowedToolOption {
@@ -1,26 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { import {
N8nEmptyState,
N8nButton, N8nButton,
N8nCallout, N8nCallout,
N8nIcon, N8nIcon,
N8nIconButton, N8nIconButton,
N8nHeading,
N8nInput,
N8nMarkdownEditor, N8nMarkdownEditor,
N8nScrollArea,
N8nText, N8nText,
} from '@n8n/design-system'; } from '@n8n/design-system';
import { SUB_AGENT_USE_WHEN_MAX_LENGTH } from '@n8n/api-types'; import { SUB_AGENT_USE_WHEN_MAX_LENGTH } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n'; import { useI18n, type BaseTextKey } from '@n8n/i18n';
import Modal from '@/app/components/Modal.vue';
import { useUIStore } from '@/app/stores/ui.store'; import { useUIStore } from '@/app/stores/ui.store';
import ToolsConnectionModal from '@/features/shared/toolsConnection/ToolsConnectionModal.vue';
import type {
AgentConnectionItem,
ToolConnectionItem,
} from '@/features/shared/toolsConnection/types';
import AgentModalMultiStep from './modals/AgentModalMultiStep.vue';
export type AgentSubAgentOption = { export type AgentSubAgentOption = {
id: string; id: string;
name: string; name: string;
added?: boolean;
useWhen?: string;
invalidReasons?: string[];
agentHref?: string;
}; };
type AgentSubAgentsModalConfirmPayload = { agentId: string; useWhen?: string }; type AgentSubAgentsModalConfirmPayload = { agentId: string; useWhen?: string };
@@ -28,8 +33,9 @@ type AgentSubAgentsModalConfirmPayload = { agentId: string; useWhen?: string };
type AddSubAgentModalData = { type AddSubAgentModalData = {
agents: AgentSubAgentOption[]; agents: AgentSubAgentOption[];
selectedAgent?: never; selectedAgent?: never;
onCreateAgent?: () => void;
onConfirm: (payload: AgentSubAgentsModalConfirmPayload) => void; onConfirm: (payload: AgentSubAgentsModalConfirmPayload) => void;
onRemove?: never; onRemove?: (agentId: string) => void;
}; };
type EditSubAgentModalData = { type EditSubAgentModalData = {
@@ -51,24 +57,30 @@ const props = defineProps<{
const i18n = useI18n(); const i18n = useI18n();
const uiStore = useUIStore(); const uiStore = useUIStore();
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
const availableAgents = computed(() => ('agents' in props.data ? props.data.agents : [])); const availableAgents = computed(() => ('agents' in props.data ? props.data.agents : []));
const hasAgents = computed(() => availableAgents.value.length > 0); const pickerItems = computed<AgentConnectionItem[]>(() =>
const searchQuery = ref(''); availableAgents.value.map((agent) => ({
const normalizedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase()); id: `agent:${agent.id}`,
const filteredAgents = computed(() => kind: 'agent',
normalizedSearchQuery.value agentId: agent.id,
? availableAgents.value.filter((agent) => title: agent.name,
agent.name.toLowerCase().includes(normalizedSearchQuery.value), status: agent.added ? 'connected' : 'none',
) category: 'agents',
: availableAgents.value, })),
); );
const hasMatchingAgents = computed(() => filteredAgents.value.length > 0);
const isEditing = computed(() => Boolean(props.data.selectedAgent)); const isEditing = computed(() => Boolean(props.data.selectedAgent));
const invalidReasons = computed(() =>
'invalidReasons' in props.data ? (props.data.invalidReasons ?? []) : [],
);
const selectedAgent = ref<AgentSubAgentOption | null>(props.data.selectedAgent ?? null); const selectedAgent = ref<AgentSubAgentOption | null>(props.data.selectedAgent ?? null);
const selectedAgentIsAdded = computed(() => isEditing.value || Boolean(selectedAgent.value?.added));
const invalidReasons = computed(() => {
if ('invalidReasons' in props.data) return props.data.invalidReasons ?? [];
return selectedAgent.value?.invalidReasons ?? [];
});
const selectedAgentHref = computed(() => {
if ('agentHref' in props.data) return props.data.agentHref;
return selectedAgent.value?.agentHref;
});
const useWhen = ref(('useWhen' in props.data ? props.data.useWhen : '') ?? ''); const useWhen = ref(('useWhen' in props.data ? props.data.useWhen : '') ?? '');
const useWhenTrimmed = computed(() => useWhen.value.trim()); const useWhenTrimmed = computed(() => useWhen.value.trim());
const useWhenError = computed(() => { const useWhenError = computed(() => {
@@ -78,6 +90,10 @@ const useWhenError = computed(() => {
}); });
}); });
const canConfirm = computed(() => !useWhenError.value); const canConfirm = computed(() => !useWhenError.value);
const currentStep = computed(() => (selectedAgent.value ? 'configure' : 'select'));
const title = computed(
() => selectedAgent.value?.name ?? i18n.baseText('agents.builder.subAgents.modal.title'),
);
function closeModal() { function closeModal() {
uiStore.closeModal(props.modalName); uiStore.closeModal(props.modalName);
@@ -85,7 +101,19 @@ function closeModal() {
function onSelectAgent(agent: AgentSubAgentOption) { function onSelectAgent(agent: AgentSubAgentOption) {
selectedAgent.value = agent; selectedAgent.value = agent;
useWhen.value = ''; useWhen.value = agent.useWhen ?? '';
}
function onPickerItemActivate(item: ToolConnectionItem) {
if (item.kind !== 'agent') return;
const agent = availableAgents.value.find((candidate) => candidate.id === item.agentId);
if (agent) onSelectAgent(agent);
}
function onCreateAgent() {
if (!('onCreateAgent' in props.data) || !props.data.onCreateAgent) return;
closeModal();
props.data.onCreateAgent();
} }
function onBack() { function onBack() {
@@ -112,258 +140,133 @@ function onConfirm() {
</script> </script>
<template> <template>
<Modal <AgentModalMultiStep
:name="props.modalName" :open="modalOpen"
width="640px" :step="currentStep"
:custom-class="$style.modal" :title="title"
:show-back="Boolean(selectedAgent) && !isEditing"
:show-footer="Boolean(selectedAgent)"
data-testid="agent-sub-agents-modal" data-testid="agent-sub-agents-modal"
@update:open="!$event && closeModal()"
@back="onBack"
> >
<template #header> <template #headerActions>
<div :class="$style.header"> <N8nIconButton
<N8nHeading tag="h2" size="large"> v-if="selectedAgent && selectedAgentHref"
{{ icon="external-link"
selectedAgent variant="ghost"
? selectedAgent.name size="small"
: i18n.baseText('agents.builder.subAgents.modal.title') :href="selectedAgentHref"
}} target="_blank"
</N8nHeading> rel="noopener noreferrer"
<N8nIconButton :title="i18n.baseText('agents.builder.subAgents.open')"
v-if="selectedAgent && 'agentHref' in data && data.agentHref" :aria-label="i18n.baseText('agents.builder.subAgents.open')"
icon="external-link" data-testid="agent-sub-agents-modal-open"
variant="ghost" />
size="small"
:href="data.agentHref"
target="_blank"
rel="noopener noreferrer"
:title="i18n.baseText('agents.builder.subAgents.open')"
:aria-label="i18n.baseText('agents.builder.subAgents.open')"
data-test-id="agent-sub-agents-modal-open"
/>
</div>
</template> </template>
<template #content> <ToolsConnectionModal
<div v-if="!selectedAgent" :class="$style.content"> v-show="!selectedAgent"
:open="modalOpen"
:items="pickerItems"
:categories="['agents']"
:detail-item="null"
:search-placeholder="i18n.baseText('agents.builder.subAgents.modal.search.placeholder')"
:empty-message="i18n.baseText('agents.builder.subAgents.modal.empty.title')"
:no-results-message="i18n.baseText('agents.builder.subAgents.modal.noResults.title')"
:create-action="
'onCreateAgent' in data && data.onCreateAgent
? {
category: 'agents',
label: i18n.baseText('projects.header.create.agent'),
description: i18n.baseText('projectRoles.agent:create.tooltip'),
testId: 'agent-sub-agents-modal-create',
}
: undefined
"
:connect-label="() => i18n.baseText('agents.builder.subAgents.modal.add')"
:connect-aria-label="
(item) =>
i18n.baseText('agents.builder.subAgents.modal.addAriaLabel' as BaseTextKey, {
interpolate: { name: item.title },
})
"
:connected-label="() => i18n.baseText('agents.builder.subAgents.modal.added' as BaseTextKey)"
embedded
show-connect-actions
persistent-scrollbar
@connect="onPickerItemActivate"
@open-detail="onPickerItemActivate"
@create="onCreateAgent"
/>
<div v-if="selectedAgent" :class="[$style.content, $style.configureContent]">
<N8nCallout
v-if="invalidReasons.length > 0"
theme="danger"
data-testid="agent-sub-agents-modal-invalid-callout"
>
<div v-for="reason in invalidReasons" :key="reason">{{ reason }}</div>
</N8nCallout>
<div :class="$style.field">
<label :class="$style.label">
<N8nText size="small" :bold="true">
{{ i18n.baseText('agents.builder.subAgents.useWhen.label') }}
</N8nText>
</label>
<N8nText size="small" color="text-light"> <N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.subAgents.modal.description') }} {{ i18n.baseText('agents.builder.subAgents.useWhen.hint') }}
</N8nText> </N8nText>
<N8nMarkdownEditor
<N8nInput :class="$style.useWhenEditor"
v-if="hasAgents" :model-value="useWhen"
v-model="searchQuery" :placeholder="i18n.baseText('agents.builder.subAgents.useWhen.placeholder')"
:placeholder="i18n.baseText('agents.builder.subAgents.modal.search.placeholder')" show-toolbar="floating"
clearable max-height="100%"
data-testid="agent-sub-agents-modal-search" data-testid="agent-sub-agents-modal-use-when"
> @update:model-value="useWhen = $event"
<template #prefix>
<N8nIcon icon="search" :size="16" />
</template>
</N8nInput>
<N8nScrollArea v-if="hasAgents && hasMatchingAgents" max-height="420px" type="auto">
<div :class="$style.rows">
<div
v-for="agent in filteredAgents"
:key="agent.id"
:class="$style.row"
data-testid="agent-sub-agents-modal-row"
>
<div :class="$style.iconWrapper">
<N8nIcon icon="bot" :size="24" :class="$style.itemIcon" />
</div>
<div :class="$style.rowBody">
<N8nText size="small" color="text-dark" :class="$style.name">
{{ agent.name }}
</N8nText>
</div>
<div :class="$style.actions">
<N8nButton
variant="subtle"
size="small"
data-testid="agent-sub-agents-modal-add"
@click="onSelectAgent(agent)"
>
{{ i18n.baseText('agents.builder.subAgents.modal.add') }}
</N8nButton>
</div>
</div>
</div>
</N8nScrollArea>
<N8nEmptyState
v-else-if="hasAgents && !hasMatchingAgents"
:icon="{ type: 'icon', value: 'bot' }"
:heading="i18n.baseText('agents.builder.subAgents.modal.noResults.title')"
:description="i18n.baseText('agents.builder.subAgents.modal.noResults.description')"
data-testid="agent-sub-agents-modal-no-results"
/>
<N8nEmptyState
v-else
:icon="{ type: 'icon', value: 'bot' }"
:heading="i18n.baseText('agents.builder.subAgents.modal.empty.title')"
:description="i18n.baseText('agents.builder.subAgents.modal.empty.description')"
data-testid="agent-sub-agents-modal-empty"
/> />
<N8nText v-if="useWhenError" size="small" color="danger">
{{ useWhenError }}
</N8nText>
<N8nText size="xsmall" color="text-light">
{{
i18n.baseText('agents.builder.subAgents.useWhen.characterCount', {
interpolate: {
count: String(useWhen.length),
max: String(SUB_AGENT_USE_WHEN_MAX_LENGTH),
},
})
}}
</N8nText>
</div> </div>
</div>
<div v-else :class="[$style.content, $style.configureContent]"> <template v-if="selectedAgent && selectedAgentIsAdded && data.onRemove" #footerLeft>
<N8nCallout <N8nButton variant="ghost" data-testid="agent-sub-agents-modal-remove" @click="onRemove">
v-if="invalidReasons.length > 0" <template #icon><N8nIcon icon="trash-2" :size="16" /></template>
theme="danger" {{ i18n.baseText('agents.builder.subAgents.modal.remove') }}
data-testid="agent-sub-agents-modal-invalid-callout" </N8nButton>
>
<div v-for="reason in invalidReasons" :key="reason">{{ reason }}</div>
</N8nCallout>
<div :class="$style.field">
<label :class="$style.label">
<N8nText size="small" :bold="true">
{{ i18n.baseText('agents.builder.subAgents.useWhen.label') }}
</N8nText>
</label>
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.subAgents.useWhen.hint') }}
</N8nText>
<N8nMarkdownEditor
:class="$style.useWhenEditor"
:model-value="useWhen"
:placeholder="i18n.baseText('agents.builder.subAgents.useWhen.placeholder')"
show-toolbar="floating"
max-height="100%"
data-testid="agent-sub-agents-modal-use-when"
@update:model-value="useWhen = $event"
/>
<N8nText v-if="useWhenError" size="small" color="danger">
{{ useWhenError }}
</N8nText>
<N8nText size="xsmall" color="text-light">
{{
i18n.baseText('agents.builder.subAgents.useWhen.characterCount', {
interpolate: {
count: String(useWhen.length),
max: String(SUB_AGENT_USE_WHEN_MAX_LENGTH),
},
})
}}
</N8nText>
</div>
</div>
</template> </template>
<template v-if="selectedAgent" #footerActions>
<template v-if="selectedAgent" #footer> <N8nButton variant="solid" data-testid="agent-sub-agents-modal-confirm" @click="onConfirm">
<div :class="$style.footer"> {{ i18n.baseText('generic.save') }}
<N8nButton </N8nButton>
v-if="isEditing && data.onRemove"
variant="subtle"
data-testid="agent-sub-agents-modal-remove"
@click="onRemove"
>
{{ i18n.baseText('agents.builder.subAgents.modal.remove') }}
</N8nButton>
<N8nButton
v-else
variant="subtle"
data-testid="agent-sub-agents-modal-back"
@click="onBack"
>
{{ i18n.baseText('generic.back') }}
</N8nButton>
<div :class="$style.footerActions">
<N8nButton variant="subtle" @click="closeModal">
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<N8nButton
variant="solid"
:disabled="!canConfirm"
data-testid="agent-sub-agents-modal-confirm"
@click="onConfirm"
>
{{ i18n.baseText(isEditing ? 'generic.save' : 'agents.builder.subAgents.modal.add') }}
</N8nButton>
</div>
</div>
</template> </template>
</Modal> </AgentModalMultiStep>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.modal {
:global(.modal-content) {
overflow: hidden;
}
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.content { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing--md); gap: var(--spacing--md);
margin: calc(-1 * var(--spacing--lg));
padding: var(--spacing--lg);
} }
.configureContent { .configureContent {
min-height: 460px;
gap: var(--spacing--lg); gap: var(--spacing--lg);
} }
.rows {
display: flex;
flex-direction: column;
padding-right: var(--spacing--lg);
}
.row {
display: flex;
align-items: center;
gap: var(--spacing--sm);
flex-shrink: 0;
padding-block: var(--spacing--sm);
}
.iconWrapper {
flex-shrink: 0;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.rowBody {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
min-width: 0;
}
.name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: var(--line-height--md);
max-width: 100%;
}
.itemIcon {
color: var(--text-color--subtle);
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
flex-shrink: 0;
}
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -377,17 +280,6 @@ function onConfirm() {
} }
.useWhenEditor { .useWhenEditor {
height: 300px; height: min(40dvh, calc(var(--height--5xl) * 3));
}
.footer {
display: flex;
justify-content: space-between;
gap: var(--spacing--2xs);
}
.footerActions {
display: flex;
gap: var(--spacing--2xs);
} }
</style> </style>
@@ -9,7 +9,6 @@ import {
import { import {
N8nButton, N8nButton,
N8nFormInput, N8nFormInput,
N8nHeading,
N8nIcon, N8nIcon,
N8nInput, N8nInput,
N8nMarkdownEditor, N8nMarkdownEditor,
@@ -25,11 +24,8 @@ import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@n8n/stores/settings.store'; import { useSettingsStore } from '@n8n/stores/settings.store';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import Modal from '@/app/components/Modal.vue';
import { MODAL_CONFIRM } from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store'; import { useUIStore } from '@/app/stores/ui.store';
import { createAgentTask, deleteAgentTask, updateAgentTask } from '../composables/useAgentApi'; import { createAgentTask, deleteAgentTask, updateAgentTask } from '../composables/useAgentApi';
import { useAgentConfirmationModal } from '../composables/useAgentConfirmationModal';
import { import {
buildCron, buildCron,
DEFAULT_SCHEDULE_PARTS, DEFAULT_SCHEDULE_PARTS,
@@ -42,6 +38,7 @@ import {
weekdayLabel, weekdayLabel,
} from '../utils/scheduleBuilder'; } from '../utils/scheduleBuilder';
import AgentPreviewButton from './AgentPreviewButton.vue'; import AgentPreviewButton from './AgentPreviewButton.vue';
import AgentModal from './modals/AgentModal.vue';
export type AgentTaskModalData = { export type AgentTaskModalData = {
projectId: string; projectId: string;
@@ -70,7 +67,7 @@ const i18n = useI18n();
const rootStore = useRootStore(); const rootStore = useRootStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const { openAgentConfirmationModal } = useAgentConfirmationModal(); const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
const task = computed(() => props.data.task ?? null); const task = computed(() => props.data.task ?? null);
const isEditing = computed(() => Boolean(task.value)); const isEditing = computed(() => Boolean(task.value));
@@ -101,7 +98,6 @@ const errorMessage = ref('');
// Save is always clickable; clicking with invalid data reveals every field's // Save is always clickable; clicking with invalid data reveals every field's
// error instead of silently no-op'ing behind a disabled button. // error instead of silently no-op'ing behind a disabled button.
const saveAttempted = ref(false); const saveAttempted = ref(false);
const nameValid = ref(false);
const objectiveTouched = ref(false); const objectiveTouched = ref(false);
// Non-custom frequencies always build a well-formed cron (see `cronExpression` // Non-custom frequencies always build a well-formed cron (see `cronExpression`
// below), so only the custom field's own validator can make this false. // below), so only the custom field's own validator can make this false.
@@ -132,7 +128,7 @@ const cronExpression = computed(() => {
function applyTask() { function applyTask() {
const current = task.value; const current = task.value;
name.value = current?.name ?? ''; name.value = current?.name ?? i18n.baseText('agents.builder.tasks.defaultName' as BaseTextKey);
objective.value = current?.objective ?? ''; objective.value = current?.objective ?? '';
// A task saved before schedules carried their own timezone runs in the // A task saved before schedules carried their own timezone runs in the
// instance timezone, so keep showing that instead of the viewer's zone. // instance timezone, so keep showing that instead of the viewer's zone.
@@ -159,7 +155,6 @@ applyTask();
// Snapshot of whether each field was already invalid when an existing task // Snapshot of whether each field was already invalid when an existing task
// opened, so its error shows immediately instead of only after a touch/save. // opened, so its error shows immediately instead of only after a touch/save.
// A pristine new-task field stays quiet until the user interacts with it. // A pristine new-task field stays quiet until the user interacts with it.
const initialNameInvalid = isEditing.value && !name.value.trim();
const initialObjectiveInvalid = isEditing.value && !objective.value.trim(); const initialObjectiveInvalid = isEditing.value && !objective.value.trim();
const initialCronInvalid = const initialCronInvalid =
isEditing.value && !getNextScheduleOccurrence(cronExpression.value, timezone.value); isEditing.value && !getNextScheduleOccurrence(cronExpression.value, timezone.value);
@@ -310,8 +305,21 @@ const visibleObjectiveError = computed(() =>
: '', : '',
); );
const nameError = computed(() => {
if (!name.value.trim()) {
return i18n.baseText('agents.builder.tasks.validation.nameRequired');
}
if (name.value.trim().length > AGENT_TASK_NAME_MAX_LENGTH) {
return i18n.baseText('agents.builder.tasks.validation.nameMaxLength' as BaseTextKey, {
interpolate: { max: String(AGENT_TASK_NAME_MAX_LENGTH) },
});
}
return '';
});
const visibleNameError = computed(() => (saveAttempted.value ? nameError.value : ''));
const canSave = computed( const canSave = computed(
() => nameValid.value && !objectiveError.value && cronValid.value && !saving.value, () => !nameError.value && !objectiveError.value && cronValid.value && !saving.value,
); );
function onObjectiveInput(value: string) { function onObjectiveInput(value: string) {
@@ -319,10 +327,6 @@ function onObjectiveInput(value: string) {
objectiveTouched.value = true; objectiveTouched.value = true;
} }
function onNameInput(value: Validatable) {
name.value = typeof value === 'string' ? value : '';
}
function onCronInput(value: Validatable) { function onCronInput(value: Validatable) {
customCron.value = typeof value === 'string' ? value : ''; customCron.value = typeof value === 'string' ? value : '';
scheduleTouched.value = true; scheduleTouched.value = true;
@@ -360,13 +364,6 @@ function onPreview() {
async function onDelete() { async function onDelete() {
const current = task.value; const current = task.value;
if (!current || deleting.value) return; if (!current || deleting.value) return;
const confirmed = await openAgentConfirmationModal({
title: i18n.baseText('agents.builder.tasks.deleteConfirm.title'),
description: i18n.baseText('agents.builder.tasks.deleteConfirm.description'),
confirmButtonText: i18n.baseText('agents.builder.tasks.deleteConfirm.confirm'),
cancelButtonText: i18n.baseText('agents.builder.tasks.cancel'),
});
if (confirmed !== MODAL_CONFIRM) return;
deleting.value = true; deleting.value = true;
errorMessage.value = ''; errorMessage.value = '';
@@ -383,7 +380,7 @@ async function onDelete() {
errorMessage.value = errorMessage.value =
error instanceof Error && error.message error instanceof Error && error.message
? error.message ? error.message
: i18n.baseText('agents.builder.tasks.deleteError'); : i18n.baseText('agents.builder.tasks.removeError' as BaseTextKey);
} finally { } finally {
deleting.value = false; deleting.value = false;
} }
@@ -413,7 +410,7 @@ async function onSave() {
base, base,
); );
} else { } else {
/** Save the agent only on submit, so canceling does not create an empty agent. */ /** Save the agent only on submit, so closing does not create an empty agent. */
await props.data.ensureAgentPersisted?.(); await props.data.ensureAgentPersisted?.();
/** New tasks start running once the agent is published. */ /** New tasks start running once the agent is published. */
await createAgentTask(rootStore.restApiContext, props.data.projectId, props.data.agentId, { await createAgentTask(rootStore.restApiContext, props.data.projectId, props.data.agentId, {
@@ -435,265 +432,236 @@ async function onSave() {
</script> </script>
<template> <template>
<Modal :name="modalName" width="860px" data-testid="agent-task-modal"> <AgentModal
<template #header> :open="modalOpen"
<div :class="$style.header"> :title="name"
<N8nHeading tag="h2" size="large"> :title-placeholder="i18n.baseText('agents.builder.tasks.name.placeholder')"
{{ :title-max-length="AGENT_TASK_NAME_MAX_LENGTH"
i18n.baseText( :title-error="visibleNameError"
isEditing ? 'agents.builder.tasks.edit.title' : 'agents.builder.tasks.create.title', :busy="saving || deleting"
) editable-title
}} data-testid="agent-task-modal"
</N8nHeading> @update:open="!$event && closeModal()"
</div> @update:title="name = $event"
</template> >
<div :class="$style.content">
<template #content> <div :class="$style.field">
<div :class="$style.content"> <N8nText size="small" bold>
<div :class="$style.field"> {{ i18n.baseText('agents.builder.tasks.objective.label') }}
<N8nFormInput <N8nText color="primary" bold size="small">*</N8nText>
:model-value="name" </N8nText>
:label="i18n.baseText('agents.builder.tasks.name.label')" <N8nMarkdownEditor
name="task-name" :class="$style.objectiveEditor"
required :model-value="objective"
label-size="small" :placeholder="i18n.baseText('agents.builder.tasks.objective.placeholder')"
:placeholder="i18n.baseText('agents.builder.tasks.name.placeholder')" show-toolbar="floating"
:validation-rules="[ max-height="100%"
{ name: 'MAX_LENGTH', config: { maximum: AGENT_TASK_NAME_MAX_LENGTH } }, data-testid="agent-task-objective-input"
]" @update:model-value="onObjectiveInput"
:show-validation-warnings="saveAttempted || initialNameInvalid" />
data-testid="agent-task-name-input" <N8nText v-if="visibleObjectiveError" :class="$style.error" size="small">
@update:model-value="onNameInput" {{ visibleObjectiveError }}
@validate="nameValid = $event"
/>
</div>
<div :class="$style.field">
<N8nText size="small" bold>
{{ i18n.baseText('agents.builder.tasks.objective.label') }}
<N8nText color="primary" bold size="small">*</N8nText>
</N8nText>
<N8nMarkdownEditor
:class="$style.objectiveEditor"
:model-value="objective"
:placeholder="i18n.baseText('agents.builder.tasks.objective.placeholder')"
show-toolbar="floating"
max-height="100%"
data-testid="agent-task-objective-input"
@update:model-value="onObjectiveInput"
/>
<N8nText v-if="visibleObjectiveError" :class="$style.error" size="small">
{{ visibleObjectiveError }}
</N8nText>
</div>
<div :class="$style.field">
<N8nText size="small" bold>
{{ i18n.baseText('agents.builder.tasks.schedule.label') }}
<N8nText color="primary" bold size="small">*</N8nText>
</N8nText>
<div :class="$style.scheduleRow">
<N8nSelect
:model-value="frequency"
:class="$style.frequencySelect"
data-testid="agent-task-frequency"
@update:model-value="onFrequencyChange"
>
<N8nOption
v-for="option in frequencyOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</N8nSelect>
<template v-if="frequency === 'weekly'">
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.tasks.schedule.on') }}
</N8nText>
<N8nSelect
:model-value="dayOfWeek"
:class="$style.daySelect"
data-testid="agent-task-day-of-week"
@update:model-value="onDayOfWeekChange"
>
<N8nOption
v-for="day in dayOfWeekOptions"
:key="day.value"
:value="day.value"
:label="day.label"
/>
</N8nSelect>
</template>
<template v-if="frequency === 'monthly'">
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.tasks.schedule.onDay') }}
</N8nText>
<N8nSelect
:model-value="dayOfMonth"
:class="$style.daySelect"
data-testid="agent-task-day-of-month"
@update:model-value="onDayOfMonthChange"
>
<N8nOption
v-for="day in dayOfMonthOptions"
:key="day.value"
:value="day.value"
:label="day.label"
/>
</N8nSelect>
</template>
<template v-if="showTime">
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.tasks.schedule.at') }}
</N8nText>
<N8nSelect
:model-value="selectedTime"
:class="$style.timeSelect"
data-testid="agent-task-time"
@update:model-value="selectedTime = Number($event)"
>
<N8nOption
v-for="option in timeOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</N8nSelect>
</template>
<template v-if="frequency === 'hourly'">
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.tasks.schedule.minuteLabel') }}
</N8nText>
<N8nInput
type="number"
:model-value="String(minute)"
:class="$style.minuteInput"
data-testid="agent-task-minute"
@update:model-value="onMinuteInput"
/>
</template>
<N8nFormInput
v-if="frequency === 'custom'"
:model-value="customCron"
label=""
name="task-cron"
required
:placeholder="i18n.baseText('agents.builder.tasks.schedule.cron.placeholder')"
:validators="{ VALID_CRON: cronValidator }"
:validation-rules="[{ name: 'VALID_CRON' }]"
:show-validation-warnings="saveAttempted || initialCronInvalid"
:class="$style.cronInput"
data-testid="agent-task-schedule-cron"
@update:model-value="onCronInput"
@validate="cronValid = $event"
/>
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.tasks.schedule.in') }}
</N8nText>
<N8nSelect
:model-value="timezone"
:class="$style.timezoneSelect"
:placeholder="i18n.baseText('agents.builder.tasks.schedule.timezone.placeholder')"
filterable
:limit-popper-width="true"
data-testid="agent-task-timezone"
@update:model-value="onTimezoneChange"
>
<N8nOption
v-for="option in timezoneSelectOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</N8nSelect>
</div>
<div v-if="scheduleSummary" :class="$style.scheduleSummary">
<N8nText :class="$style.help" size="small">
{{ scheduleSummary }}
</N8nText>
<N8nTooltip
v-if="showRepublishHint"
:content="i18n.baseText('agents.builder.tasks.republishHint')"
placement="top"
>
<span
:class="$style.infoIcon"
:aria-label="i18n.baseText('agents.builder.tasks.republishHint')"
tabindex="0"
>
<N8nIcon icon="info" size="small" />
</span>
</N8nTooltip>
</div>
</div>
<div v-if="isEditing" :class="$style.pauseControl" data-testid="agent-task-pause-control">
<N8nText size="small" bold>
{{ i18n.baseText('agents.builder.tasks.pause') }}
</N8nText>
<N8nSwitch2
:model-value="!enabled"
:aria-label="i18n.baseText('agents.builder.tasks.pause')"
data-testid="agent-task-toggle"
@update:model-value="(paused) => onPauseToggle(Boolean(paused))"
/>
</div>
<N8nText v-if="errorMessage" :class="$style.error" size="small">
{{ errorMessage }}
</N8nText> </N8nText>
</div> </div>
</template>
<template #footer> <div :class="$style.field">
<div :class="$style.footer"> <N8nText size="small" bold>
<N8nButton {{ i18n.baseText('agents.builder.tasks.schedule.label') }}
v-if="isEditing" <N8nText color="primary" bold size="small">*</N8nText>
variant="subtle" </N8nText>
:loading="deleting" <div :class="$style.scheduleRow">
data-testid="agent-task-delete" <N8nSelect
@click="onDelete" :model-value="frequency"
> :class="$style.frequencySelect"
<template #icon><N8nIcon icon="trash-2" :size="16" /></template> data-testid="agent-task-frequency"
{{ i18n.baseText('generic.delete') }} @update:model-value="onFrequencyChange"
</N8nButton>
<div :class="$style.footerActions">
<AgentPreviewButton
:is-runnable="props.data.isRunnable === true"
:validation-issues="props.data.validationIssues ?? []"
test-id="agent-task-preview"
@open-preview="onPreview"
/>
<N8nButton
variant="solid"
:disabled="saving"
:loading="saving"
data-testid="agent-task-save"
@click="onSave"
> >
{{ i18n.baseText('generic.save') }} <N8nOption
</N8nButton> v-for="option in frequencyOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</N8nSelect>
<template v-if="frequency === 'weekly'">
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.tasks.schedule.on') }}
</N8nText>
<N8nSelect
:model-value="dayOfWeek"
:class="$style.daySelect"
data-testid="agent-task-day-of-week"
@update:model-value="onDayOfWeekChange"
>
<N8nOption
v-for="day in dayOfWeekOptions"
:key="day.value"
:value="day.value"
:label="day.label"
/>
</N8nSelect>
</template>
<template v-if="frequency === 'monthly'">
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.tasks.schedule.onDay') }}
</N8nText>
<N8nSelect
:model-value="dayOfMonth"
:class="$style.daySelect"
data-testid="agent-task-day-of-month"
@update:model-value="onDayOfMonthChange"
>
<N8nOption
v-for="day in dayOfMonthOptions"
:key="day.value"
:value="day.value"
:label="day.label"
/>
</N8nSelect>
</template>
<template v-if="showTime">
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.tasks.schedule.at') }}
</N8nText>
<N8nSelect
:model-value="selectedTime"
:class="$style.timeSelect"
data-testid="agent-task-time"
@update:model-value="selectedTime = Number($event)"
>
<N8nOption
v-for="option in timeOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</N8nSelect>
</template>
<template v-if="frequency === 'hourly'">
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.tasks.schedule.minuteLabel') }}
</N8nText>
<N8nInput
type="number"
:model-value="String(minute)"
:class="$style.minuteInput"
data-testid="agent-task-minute"
@update:model-value="onMinuteInput"
/>
</template>
<N8nFormInput
v-if="frequency === 'custom'"
:model-value="customCron"
label=""
name="task-cron"
required
:placeholder="i18n.baseText('agents.builder.tasks.schedule.cron.placeholder')"
:validators="{ VALID_CRON: cronValidator }"
:validation-rules="[{ name: 'VALID_CRON' }]"
:show-validation-warnings="saveAttempted || initialCronInvalid"
:class="$style.cronInput"
data-testid="agent-task-schedule-cron"
@update:model-value="onCronInput"
@validate="cronValid = $event"
/>
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.tasks.schedule.in') }}
</N8nText>
<N8nSelect
:model-value="timezone"
:class="$style.timezoneSelect"
:placeholder="i18n.baseText('agents.builder.tasks.schedule.timezone.placeholder')"
filterable
:limit-popper-width="true"
data-testid="agent-task-timezone"
@update:model-value="onTimezoneChange"
>
<N8nOption
v-for="option in timezoneSelectOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</N8nSelect>
</div>
<div v-if="scheduleSummary" :class="$style.scheduleSummary">
<N8nText :class="$style.help" size="small">
{{ scheduleSummary }}
</N8nText>
<N8nTooltip
v-if="showRepublishHint"
:content="i18n.baseText('agents.builder.tasks.republishHint')"
placement="top"
>
<span
:class="$style.infoIcon"
:aria-label="i18n.baseText('agents.builder.tasks.republishHint')"
tabindex="0"
>
<N8nIcon icon="info" size="small" />
</span>
</N8nTooltip>
</div> </div>
</div> </div>
<div v-if="isEditing" :class="$style.pauseControl" data-testid="agent-task-pause-control">
<N8nText size="small" bold>
{{ i18n.baseText('agents.builder.tasks.pause') }}
</N8nText>
<N8nSwitch2
:model-value="!enabled"
:aria-label="i18n.baseText('agents.builder.tasks.pause')"
data-testid="agent-task-toggle"
@update:model-value="(paused) => onPauseToggle(Boolean(paused))"
/>
</div>
<N8nText v-if="errorMessage" :class="$style.error" size="small">
{{ errorMessage }}
</N8nText>
</div>
<template v-if="isEditing" #footerLeft>
<N8nButton
variant="ghost"
:loading="deleting"
data-testid="agent-task-delete"
@click="onDelete"
>
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
{{ i18n.baseText('agents.builder.tasks.delete') }}
</N8nButton>
</template> </template>
</Modal> <template #footerBeforeCancel>
<AgentPreviewButton
:is-runnable="props.data.isRunnable === true"
:validation-issues="props.data.validationIssues ?? []"
variant="ghost"
test-id="agent-task-preview"
@open-preview="onPreview"
/>
</template>
<template #footerActions>
<N8nButton
variant="solid"
:disabled="saving"
:loading="saving"
data-testid="agent-task-save"
@click="onSave"
>
{{ i18n.baseText('generic.save') }}
</N8nButton>
</template>
</AgentModal>
</template> </template>
<style module> <style module>
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
padding-right: var(--spacing--xl);
}
.content { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -706,9 +674,8 @@ async function onSave() {
gap: var(--spacing--2xs); gap: var(--spacing--2xs);
} }
/* Matches the height the skills instructions editor fills inside its modal. */
.objectiveEditor { .objectiveEditor {
height: 400px; height: min(36dvh, calc(var(--height--5xl) * 3));
} }
.scheduleRow { .scheduleRow {
@@ -768,17 +735,4 @@ async function onSave() {
align-items: center; align-items: center;
gap: var(--spacing--2xs); gap: var(--spacing--2xs);
} }
.footer {
display: flex;
justify-content: space-between;
gap: var(--spacing--2xs);
}
.footerActions {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
margin-left: auto;
}
</style> </style>
@@ -0,0 +1,400 @@
<script setup lang="ts">
import type { AgentConfigValidationIssue } from '@n8n/api-types';
import { N8nText } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { extractFromAICalls, type INode } from 'n8n-workflow';
import { computed, ref, watch } from 'vue';
import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
import { useUIStore } from '@/app/stores/ui.store';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
import {
toolRefToNode,
updateToolRefFromNode,
updateWorkflowToolRef,
} from '../composables/useAgentToolRefAdapter';
import { nodeToMcpServer } from '../composables/useMcpServerAdapter';
import type {
AgentJsonMcpServerConfig,
AgentJsonToolRef,
CustomToolEntry,
WorkflowToolRef,
} from '../types';
import AgentToolConfigApprovalSetting from './AgentToolConfigApprovalSetting.vue';
import AgentToolConfigCustomContent from './AgentToolConfigCustomContent.vue';
import AgentToolConfigMcpApprovalSetting from './AgentToolConfigMcpApprovalSetting.vue';
import AgentToolConfigNodeContent from './AgentToolConfigNodeContent.vue';
import AgentToolConfigWorkflowContent from './AgentToolConfigWorkflowContent.vue';
interface ToolModalData {
toolRef: AgentJsonToolRef;
customTool?: CustomToolEntry;
existingToolNames?: string[];
projectId?: string;
agentId?: string;
validationIssues?: AgentConfigValidationIssue[];
/** Inline agents pass false because approval needs suspend and resume. */
supportsToolApproval?: boolean;
onConfirm: (updatedRef: AgentJsonToolRef) => void;
onRemove?: () => void;
kind?: 'tool';
}
interface McpServerModalData {
kind: 'mcpServer';
mcpServer: AgentJsonMcpServerConfig;
initialNode: INode;
existingToolNames?: string[];
projectId?: string;
agentId?: string;
/** Inline agents pass false because approval needs suspend and resume. */
supportsToolApproval?: boolean;
onConfirm: (updatedServer: AgentJsonMcpServerConfig) => void;
onRemove?: () => void;
}
export type AgentToolConfigModalData = ToolModalData | McpServerModalData;
const props = defineProps<{
data: AgentToolConfigModalData;
}>();
const emit = defineEmits<{
'update:title': [title: string];
'update:credentialModalOpen': [open: boolean];
}>();
const i18n = useI18n();
const uiStore = useUIStore();
const httpRequestUrlErrorKey =
'agents.builder.validation.issue.httpRequestUrlFromAi' as BaseTextKey;
const credentialModalOpen = computed(
() => uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY]?.open === true,
);
watch(credentialModalOpen, (open) => emit('update:credentialModalOpen', open), { immediate: true });
function isMcpServerModalData(data: AgentToolConfigModalData): data is McpServerModalData {
return data.kind === 'mcpServer';
}
function containsFromAiCall(value: unknown): boolean {
if (typeof value !== 'string') return false;
try {
return extractFromAICalls(value).length > 0;
} catch {
return false;
}
}
const isMcpTool = computed(() => isMcpServerModalData(props.data));
const mcpModalData = computed(() => (isMcpServerModalData(props.data) ? props.data : null));
const toolModalData = computed(() => (isMcpServerModalData(props.data) ? null : props.data));
const isWorkflowTool = computed(() => toolModalData.value?.toolRef.type === 'workflow');
const isCustomTool = computed(() => toolModalData.value?.toolRef.type === 'custom');
const nodeContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | null>(null);
const mcpContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | null>(null);
const workflowContentRef = ref<InstanceType<typeof AgentToolConfigWorkflowContent> | null>(null);
const isValid = ref(false);
const submitted = ref(false);
const approvalRequired = ref(false);
const mcpApproval = ref<AgentJsonMcpServerConfig['approval']>();
const mcpApprovalValid = ref(true);
const draftNode = ref<INode | null>(null);
const initialNode = computed<INode | null>(() =>
isMcpTool.value
? (mcpModalData.value?.initialNode ?? null)
: isWorkflowTool.value || isCustomTool.value
? null
: toolModalData.value
? toolRefToNode(toolModalData.value.toolRef)
: null,
);
const workflowInitialRef = computed<WorkflowToolRef | null>(() =>
isWorkflowTool.value && toolModalData.value?.toolRef.type === 'workflow'
? toolModalData.value.toolRef
: null,
);
const initialName = computed(() => {
if (isMcpTool.value) return mcpModalData.value?.mcpServer.name ?? '';
const toolName =
toolModalData.value?.toolRef.type === 'node' ? toolModalData.value.toolRef.name : undefined;
return toolName ?? initialNode.value?.name ?? '';
});
const nodeName = ref(initialName.value);
const customToolCode = computed(() =>
!isMcpTool.value ? (toolModalData.value?.customTool?.code ?? '') : '',
);
const customToolTitle = computed(() => {
const toolRef = toolModalData.value?.toolRef;
const fallbackName =
toolRef?.type === 'custom'
? toolRef.id
: toolRef?.type === 'workflow' || toolRef?.type === 'node'
? toolRef.name
: undefined;
return (
toolModalData.value?.customTool?.descriptor.name ??
fallbackName ??
i18n.baseText('agents.builder.tree.customBadge')
);
});
const title = computed(() => (isCustomTool.value ? customToolTitle.value : nodeName.value));
const supportsApproval = computed(() => props.data.supportsToolApproval !== false);
const showApprovalSetting = computed(
() => supportsApproval.value && !isMcpTool.value && toolModalData.value !== null,
);
watch(
() => toolModalData.value?.toolRef,
(toolRef) => {
approvalRequired.value = Boolean(toolRef?.requireApproval);
},
{ immediate: true },
);
watch(
() => mcpModalData.value?.mcpServer.approval,
(approval) => {
mcpApproval.value = approval;
},
{ immediate: true },
);
watch(
initialNode,
(node) => {
draftNode.value = node;
},
{ immediate: true },
);
watch(title, (value) => emit('update:title', value), { immediate: true });
const currentNode = computed(() => draftNode.value ?? initialNode.value);
const hasHttpRequestUrlIssue = computed(() => {
const data = toolModalData.value;
if (data?.toolRef.type !== 'node') return false;
if (
!data.validationIssues?.some(
(issue) => issue.code === 'invalid_value' && issue.path.endsWith('.node.nodeParameters.url'),
)
) {
return false;
}
return containsFromAiCall(currentNode.value?.parameters.url);
});
const canSave = computed(() => {
if (isCustomTool.value) return true;
if (isMcpTool.value) return isValid.value && mcpApprovalValid.value;
return isValid.value && !hasHttpRequestUrlIssue.value;
});
const fromAiDisabledParameters = computed(() => {
const toolRef = toolModalData.value?.toolRef;
if (
toolRef?.type === 'node' &&
(toolRef.node.nodeType === HTTP_REQUEST_NODE_TYPE ||
toolRef.node.nodeType === HTTP_REQUEST_TOOL_NODE_TYPE)
) {
return ['url'];
}
return [];
});
const nodeParameterIssues = computed<Record<string, string[]>>(() => {
const issues: Record<string, string[]> = {};
if (hasHttpRequestUrlIssue.value) {
issues.url = [i18n.baseText(httpRequestUrlErrorKey)];
}
return issues;
});
function withApprovalRequirement(ref: AgentJsonToolRef): AgentJsonToolRef {
if (!supportsApproval.value) {
const { requireApproval: _requireApproval, ...rest } = ref;
return rest;
}
const updatedRef = { ...ref };
if (approvalRequired.value) updatedRef.requireApproval = true;
else delete updatedRef.requireApproval;
return updatedRef;
}
function withMcpApproval(server: AgentJsonMcpServerConfig): AgentJsonMcpServerConfig {
const updatedServer = { ...server };
if (supportsApproval.value && mcpApproval.value) updatedServer.approval = mcpApproval.value;
else delete updatedServer.approval;
return updatedServer;
}
function confirm(): boolean {
submitted.value = true;
if (!canSave.value) return false;
if (isCustomTool.value) {
const toolData = toolModalData.value;
if (!toolData) return false;
toolData.onConfirm(withApprovalRequirement(toolData.toolRef));
return true;
}
if (isMcpTool.value) {
const currentMcpNode = mcpContentRef.value?.getNode();
const mcpData = mcpModalData.value;
if (!currentMcpNode || !mcpData) return false;
const updatedServer = nodeToMcpServer(currentMcpNode, mcpData.mcpServer);
mcpData.onConfirm(withMcpApproval(updatedServer));
return true;
}
if (isWorkflowTool.value) {
const content = workflowContentRef.value;
const toolData = toolModalData.value;
if (!toolData || !content) return false;
const workflowId = content.getWorkflowId();
const updatedRef = updateWorkflowToolRef(toolData.toolRef, {
name: content.getName(),
description: content.getDescription(),
allOutputs: content.getAllOutputs(),
workflow: content.getWorkflow(),
...(workflowId !== undefined ? { workflowId } : {}),
inputs: content.getInputs(),
});
toolData.onConfirm(withApprovalRequirement(updatedRef));
return true;
}
const currentToolNode = nodeContentRef.value?.getNode();
const toolData = toolModalData.value;
if (!currentToolNode || !toolData) return false;
const updatedRef = updateToolRefFromNode(toolData.toolRef, currentToolNode);
toolData.onConfirm(withApprovalRequirement(updatedRef));
return true;
}
function remove() {
props.data.onRemove?.();
}
function changeTitle(name: string) {
if (isCustomTool.value) return;
if (isMcpTool.value) mcpContentRef.value?.handleChangeName(name);
else if (isWorkflowTool.value) workflowContentRef.value?.handleChangeName(name);
else nodeContentRef.value?.handleChangeName(name);
}
function handleNodeNameUpdate(name: string) {
nodeName.value = name;
}
defineExpose({ canSave, confirm, remove, changeTitle, credentialModalOpen, title });
</script>
<template>
<div :class="[$style.contentWrapper, isCustomTool && $style.codeContentWrapper]">
<N8nText
v-if="submitted && !canSave"
size="small"
color="danger"
data-testid="agent-tool-config-validation-error"
>
{{ i18n.baseText('agents.toolConfig.validation.fixFields' as BaseTextKey) }}
</N8nText>
<AgentToolConfigCustomContent
v-if="isCustomTool"
:code="customToolCode"
:class="$style.customToolViewer"
/>
<AgentToolConfigApprovalSetting
v-if="isCustomTool && showApprovalSetting"
v-model="approvalRequired"
/>
<div v-else :class="$style.configureTab">
<AgentToolConfigWorkflowContent
v-if="workflowInitialRef"
ref="workflowContentRef"
:initial-ref="workflowInitialRef"
:project-id="data.projectId"
:show-approval-setting="showApprovalSetting"
:approval-required="approvalRequired"
@update:valid="isValid = $event"
@update:node-name="handleNodeNameUpdate"
@update:approval-required="approvalRequired = $event"
/>
<AgentToolConfigNodeContent
v-else-if="isMcpTool && initialNode"
ref="mcpContentRef"
:initial-node="initialNode"
:existing-tool-names="data.existingToolNames"
:project-id="data.projectId"
content-test-id="agent-tool-config-mcp-content"
@update:valid="isValid = $event"
@update:node-name="handleNodeNameUpdate"
@update:node="draftNode = $event"
/>
<AgentToolConfigNodeContent
v-else-if="initialNode"
ref="nodeContentRef"
:initial-node="initialNode"
:existing-tool-names="data.existingToolNames"
:project-id="data.projectId"
:from-ai-disabled-parameters="fromAiDisabledParameters"
:parameter-issues="nodeParameterIssues"
content-test-id="node-tool-settings-content"
@update:valid="isValid = $event"
@update:node-name="handleNodeNameUpdate"
@update:node="draftNode = $event"
/>
<AgentToolConfigApprovalSetting
v-if="!isMcpTool && initialNode && showApprovalSetting"
v-model="approvalRequired"
/>
<AgentToolConfigMcpApprovalSetting
v-if="isMcpTool && currentNode && supportsApproval"
v-model="mcpApproval"
:node="currentNode"
:project-id="data.projectId"
@update:valid="mcpApprovalValid = $event"
/>
</div>
</div>
</template>
<style lang="scss" module>
.contentWrapper {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
overflow-x: hidden;
:global(.ndv-connection-hint-notice) {
display: none;
}
}
.codeContentWrapper {
height: min(calc(var(--spacing--5xl) * 2), calc(70dvh - var(--spacing--5xl)));
overflow: hidden;
}
.configureTab {
display: flex;
min-height: 0;
flex-direction: column;
gap: var(--spacing--sm);
}
.customToolViewer {
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
</style>
@@ -1,74 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
/** import { N8nButton, N8nIcon } from '@n8n/design-system';
* Configure one agent tool entry (node/workflow/custom) or one MCP server.
*/
import { computed, ref, watch } from 'vue';
import { useUIStore } from '@/app/stores/ui.store';
import {
N8nButton,
N8nDialog,
N8nDialogFooter,
N8nDialogHeader,
N8nIcon,
} from '@n8n/design-system';
import type { AgentConfigValidationIssue } from '@n8n/api-types';
import { useI18n, type BaseTextKey } from '@n8n/i18n'; import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { extractFromAICalls, type INode } from 'n8n-workflow'; import { computed, ref } from 'vue';
import { FocusScope } from 'reka-ui';
import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes'; import { useUIStore } from '@/app/stores/ui.store';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants'; import { toolRefToNode } from '../composables/useAgentToolRefAdapter';
import type { import AgentModal from './modals/AgentModal.vue';
AgentJsonMcpServerConfig, import AgentToolConfigForm, { type AgentToolConfigModalData } from './AgentToolConfigForm.vue';
AgentJsonToolRef,
CustomToolEntry,
WorkflowToolRef,
} from '../types';
import {
toolRefToNode,
updateToolRefFromNode,
updateWorkflowToolRef,
} from '../composables/useAgentToolRefAdapter';
import { nodeToMcpServer } from '../composables/useMcpServerAdapter';
import AgentToolConfigApprovalSetting from './AgentToolConfigApprovalSetting.vue';
import AgentToolConfigCustomContent from './AgentToolConfigCustomContent.vue';
import AgentToolConfigMcpApprovalSetting from './AgentToolConfigMcpApprovalSetting.vue';
import AgentToolConfigModalHeader from './AgentToolConfigModalHeader.vue';
import AgentToolConfigNodeContent from './AgentToolConfigNodeContent.vue';
import AgentToolConfigWorkflowContent from './AgentToolConfigWorkflowContent.vue';
interface ToolModalData {
toolRef: AgentJsonToolRef;
customTool?: CustomToolEntry;
existingToolNames?: string[];
projectId?: string;
agentId?: string;
validationIssues?: AgentConfigValidationIssue[];
/** Inline agents pass false: approval needs suspend/resume, which workflow executions don't support. */
supportsToolApproval?: boolean;
onConfirm: (updatedRef: AgentJsonToolRef) => void;
onRemove?: () => void;
kind?: 'tool';
}
interface McpServerModalData {
kind: 'mcpServer';
mcpServer: AgentJsonMcpServerConfig;
initialNode: INode;
existingToolNames?: string[];
projectId?: string;
agentId?: string;
/** Inline agents pass false: approval needs suspend/resume, which workflow executions don't support. */
supportsToolApproval?: boolean;
onConfirm: (updatedServer: AgentJsonMcpServerConfig) => void;
onRemove?: () => void;
}
type AgentToolConfigModalData = ToolModalData | McpServerModalData;
// DynamicModalLoader passes `open`/`active`/`mode`/`activeId` alongside the
// props we declare. Without this they fall through onto N8nDialog, and the
// inherited `open` (always true while mounted) wins over our own binding.
defineOptions({ inheritAttrs: false }); defineOptions({ inheritAttrs: false });
const props = defineProps<{ const props = defineProps<{
@@ -77,473 +16,95 @@ const props = defineProps<{
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
const httpRequestUrlErrorKey =
'agents.builder.validation.issue.httpRequestUrlFromAi' as BaseTextKey;
const uiStore = useUIStore(); const uiStore = useUIStore();
const isCredentialModalOpen = computed( const form = ref<InstanceType<typeof AgentToolConfigForm> | null>(null);
() => uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY]?.open === true, const credentialModalOpen = ref(false);
); const title = ref(initialTitle());
const isOpen = computed({ const isOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
get: () => uiStore.modalsById[props.modalName]?.open === true, const isCustomTool = computed(
set: (value: boolean) => { () => props.data.kind !== 'mcpServer' && props.data.toolRef.type === 'custom',
if (!value) uiStore.closeModal(props.modalName); );
}, const canRender = computed(() => {
if (props.data.kind === 'mcpServer') return Boolean(props.data.initialNode);
if (props.data.toolRef.type === 'custom' || props.data.toolRef.type === 'workflow') return true;
return toolRefToNode(props.data.toolRef) !== null;
});
const removeLabel = computed(() => {
if (props.data.kind === 'mcpServer') {
return i18n.baseText('agents.builder.tools.mcp.remove' as BaseTextKey);
}
if (props.data.toolRef.type === 'workflow') {
return i18n.baseText('agents.builder.tools.workflow.remove' as BaseTextKey);
}
return i18n.baseText('agents.builder.tools.remove');
}); });
function isMcpServerModalData(data: AgentToolConfigModalData): data is McpServerModalData { function initialTitle(): string {
return data.kind === 'mcpServer'; if (props.data.kind === 'mcpServer') return props.data.mcpServer.name;
if (props.data.toolRef.type === 'custom') {
return props.data.customTool?.descriptor.name ?? props.data.toolRef.id;
}
return props.data.toolRef.name ?? '';
} }
function containsFromAiCall(value: unknown): boolean {
if (typeof value !== 'string') return false;
try {
return extractFromAICalls(value).length > 0;
} catch {
return false;
}
}
const isMcpTool = computed(() => isMcpServerModalData(props.data));
const mcpModalData = computed(() => (isMcpServerModalData(props.data) ? props.data : null));
const toolModalData = computed(() => (isMcpServerModalData(props.data) ? null : props.data));
const isWorkflowTool = computed(() => toolModalData.value?.toolRef.type === 'workflow');
const isCustomTool = computed(() => toolModalData.value?.toolRef.type === 'custom');
const nodeContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | null>(null);
const mcpContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | null>(null);
const workflowContentRef = ref<InstanceType<typeof AgentToolConfigWorkflowContent> | null>(null);
const isValid = ref(false);
const approvalRequired = ref(false);
const mcpApproval = ref<AgentJsonMcpServerConfig['approval']>();
const mcpApprovalValid = ref(true);
const draftNode = ref<INode | null>(null);
const initialNode = computed<INode | null>(() =>
isMcpTool.value
? (mcpModalData.value?.initialNode ?? null)
: isWorkflowTool.value || isCustomTool.value
? null
: toolModalData.value
? toolRefToNode(toolModalData.value.toolRef)
: null,
);
const workflowInitialRef = computed<WorkflowToolRef | null>(() =>
isWorkflowTool.value && toolModalData.value?.toolRef.type === 'workflow'
? toolModalData.value.toolRef
: null,
);
const initialName = computed(() => {
if (isMcpTool.value) return mcpModalData.value?.mcpServer.name ?? '';
const toolName =
toolModalData.value?.toolRef.type === 'node' ? toolModalData.value.toolRef.name : undefined;
return toolName ?? initialNode.value?.name ?? '';
});
const nodeName = ref(initialName.value);
const customToolCode = computed(() =>
!isMcpTool.value ? (toolModalData.value?.customTool?.code ?? '') : '',
);
const customToolTitle = computed(() => {
const toolRef = toolModalData.value?.toolRef;
const fallbackName =
toolRef?.type === 'custom'
? toolRef.id
: toolRef?.type === 'workflow' || toolRef?.type === 'node'
? toolRef.name
: undefined;
return (
toolModalData.value?.customTool?.descriptor.name ??
fallbackName ??
i18n.baseText('agents.builder.tree.customBadge')
);
});
const canRender = computed(
() => isCustomTool.value || isWorkflowTool.value || initialNode.value !== null,
);
const supportsApproval = computed(() => props.data.supportsToolApproval !== false);
const showApprovalSetting = computed(
() => supportsApproval.value && !isMcpTool.value && toolModalData.value !== null,
);
watch(
() => toolModalData.value?.toolRef,
(toolRef) => {
approvalRequired.value = Boolean(toolRef?.requireApproval);
},
{ immediate: true },
);
watch(
() => mcpModalData.value?.mcpServer.approval,
(approval) => {
mcpApproval.value = approval;
},
{ immediate: true },
);
watch(
initialNode,
(node) => {
draftNode.value = node;
},
{ immediate: true },
);
const currentNode = computed(() => draftNode.value ?? initialNode.value);
const hasHttpRequestUrlIssue = computed(() => {
const data = toolModalData.value;
if (data?.toolRef.type !== 'node') return false;
if (
!data.validationIssues?.some(
(issue) => issue.code === 'invalid_value' && issue.path.endsWith('.node.nodeParameters.url'),
)
) {
return false;
}
const url = currentNode.value?.parameters.url;
return containsFromAiCall(url);
});
const canSave = computed(() => {
if (isCustomTool.value) return true;
if (isMcpTool.value) return isValid.value && mcpApprovalValid.value;
return isValid.value && !hasHttpRequestUrlIssue.value;
});
const fromAiDisabledParameters = computed(() => {
const toolRef = toolModalData.value?.toolRef;
if (
toolRef?.type === 'node' &&
(toolRef.node.nodeType === HTTP_REQUEST_NODE_TYPE ||
toolRef.node.nodeType === HTTP_REQUEST_TOOL_NODE_TYPE)
) {
return ['url'];
}
return [];
});
const nodeParameterIssues = computed<Record<string, string[]>>(() => {
const issues: Record<string, string[]> = {};
if (hasHttpRequestUrlIssue.value) {
issues.url = [i18n.baseText(httpRequestUrlErrorKey)];
}
return issues;
});
const headerKind = computed<'node' | 'workflow' | 'custom' | 'mcp'>(() => {
if (isCustomTool.value) return 'custom';
if (isWorkflowTool.value) return 'workflow';
if (isMcpTool.value) return 'mcp';
return 'node';
});
const headerNodeTypeDescription = computed(() => {
if (isMcpTool.value) {
return mcpContentRef.value?.getNodeTypeDescription() ?? null;
}
if (isWorkflowTool.value || isCustomTool.value) {
return null;
}
return nodeContentRef.value?.getNodeTypeDescription() ?? null;
});
function closeDialog() { function closeDialog() {
uiStore.closeModal(props.modalName); uiStore.closeModal(props.modalName);
} }
function onOpenChange(open: boolean) {
if (!open) closeDialog();
}
function handleInteractOutside(event: Event) { function handleInteractOutside(event: Event) {
if (isCredentialModalOpen.value) event.preventDefault(); if (credentialModalOpen.value) event.preventDefault();
} }
function withApprovalRequirement(ref: AgentJsonToolRef): AgentJsonToolRef { function updateTitle(value: string) {
if (!supportsApproval.value) { title.value = value;
const { requireApproval: _requireApproval, ...rest } = ref; form.value?.changeTitle(value);
return rest as AgentJsonToolRef;
}
const updatedRef = { ...ref };
if (approvalRequired.value) {
updatedRef.requireApproval = true;
} else {
delete updatedRef.requireApproval;
}
return updatedRef;
}
function withMcpApproval(server: AgentJsonMcpServerConfig): AgentJsonMcpServerConfig {
const updatedServer = { ...server };
if (supportsApproval.value && mcpApproval.value) {
updatedServer.approval = mcpApproval.value;
} else {
delete updatedServer.approval;
}
return updatedServer;
} }
function handleConfirm() { function handleConfirm() {
if (isCustomTool.value) { if (form.value?.confirm()) closeDialog();
const toolData = toolModalData.value;
if (!toolData) return;
toolData.onConfirm(withApprovalRequirement(toolData.toolRef));
closeDialog();
return;
}
if (isMcpTool.value) {
const currentNode = mcpContentRef.value?.getNode();
const mcpData = mcpModalData.value;
if (!currentNode) return;
if (!mcpData) return;
const updatedServer = nodeToMcpServer(currentNode, mcpData.mcpServer);
mcpData.onConfirm(withMcpApproval(updatedServer));
closeDialog();
return;
}
if (isWorkflowTool.value) {
const wc = workflowContentRef.value;
const toolData = toolModalData.value;
if (!toolData) return;
if (!wc) return;
const workflowId = wc.getWorkflowId();
const updatedRef = updateWorkflowToolRef(toolData.toolRef, {
name: wc.getName(),
description: wc.getDescription(),
allOutputs: wc.getAllOutputs(),
workflow: wc.getWorkflow(),
...(workflowId !== undefined ? { workflowId } : {}),
inputs: wc.getInputs(),
});
toolData.onConfirm(withApprovalRequirement(updatedRef));
closeDialog();
return;
}
const currentNode = nodeContentRef.value?.getNode();
const toolData = toolModalData.value;
if (!currentNode) return;
if (!toolData) return;
const updatedRef = updateToolRefFromNode(toolData.toolRef, currentNode);
toolData.onConfirm(withApprovalRequirement(updatedRef));
closeDialog();
}
function handleCancel() {
closeDialog();
} }
function handleRemove() { function handleRemove() {
props.data.onRemove?.(); form.value?.remove();
closeDialog(); closeDialog();
} }
function handleChangeName(name: string) {
if (isCustomTool.value) return;
if (isMcpTool.value) {
mcpContentRef.value?.handleChangeName(name);
} else if (isWorkflowTool.value) {
workflowContentRef.value?.handleChangeName(name);
} else {
nodeContentRef.value?.handleChangeName(name);
}
}
function handleValidUpdate(valid: boolean) {
isValid.value = valid;
}
function handleNodeNameUpdate(name: string) {
nodeName.value = name;
}
function handleNodeUpdate(node: INode) {
draftNode.value = node;
}
</script> </script>
<template> <template>
<N8nDialog <AgentModal
v-if="canRender" v-if="canRender"
v-model:open="isOpen" :open="isOpen"
size="2xlarge" :title="title"
:trap-focus="!isCredentialModalOpen" :editable-title="!isCustomTool"
:disable-outside-pointer-events="!isCredentialModalOpen" :trap-focus="!credentialModalOpen"
:show-close-button="false" :disable-outside-pointer-events="!credentialModalOpen"
data-test-id="agent-tool-config-modal" data-testid="agent-tool-config-modal"
@interact-outside="handleInteractOutside" @interact-outside="handleInteractOutside"
@update:open="onOpenChange"
@update:title="updateTitle"
> >
<FocusScope <AgentToolConfigForm
v-if="isCredentialModalOpen" ref="form"
as-child :data="data"
@mount-auto-focus.prevent @update:title="title = $event"
@unmount-auto-focus.prevent @update:credential-modal-open="credentialModalOpen = $event"
> />
<span hidden aria-hidden="true" />
</FocusScope>
<N8nDialogHeader> <template v-if="data.onRemove" #footerLeft>
<AgentToolConfigModalHeader <N8nButton variant="ghost" data-testid="agent-tool-config-remove" @click="handleRemove">
:kind="headerKind" <template #icon><N8nIcon icon="trash-2" :size="16" /></template>
:title="isCustomTool ? customToolTitle : nodeName" {{ removeLabel }}
:node-type-description="headerNodeTypeDescription" </N8nButton>
@update:title="handleChangeName" </template>
/> <template #footerActions>
</N8nDialogHeader> <N8nButton variant="solid" data-testid="agent-tool-config-save" @click="handleConfirm">
<div :class="$style.body"> {{ i18n.baseText('generic.save') }}
<div :class="[$style.contentWrapper, isCustomTool && $style.codeContentWrapper]"> </N8nButton>
<AgentToolConfigCustomContent </template>
v-if="isCustomTool" </AgentModal>
:code="customToolCode"
:class="$style.customToolViewer"
/>
<AgentToolConfigApprovalSetting
v-if="isCustomTool && showApprovalSetting"
v-model="approvalRequired"
/>
<template v-else>
<div :class="$style.configureTab">
<AgentToolConfigWorkflowContent
v-if="workflowInitialRef"
ref="workflowContentRef"
:initial-ref="workflowInitialRef"
:project-id="data.projectId"
:show-approval-setting="showApprovalSetting"
:approval-required="approvalRequired"
@update:valid="handleValidUpdate"
@update:node-name="handleNodeNameUpdate"
@update:approval-required="approvalRequired = $event"
/>
<AgentToolConfigNodeContent
v-else-if="isMcpTool && initialNode"
ref="mcpContentRef"
:initial-node="initialNode"
:existing-tool-names="data.existingToolNames"
:project-id="data.projectId"
content-test-id="agent-tool-config-mcp-content"
@update:valid="handleValidUpdate"
@update:node-name="handleNodeNameUpdate"
@update:node="handleNodeUpdate"
/>
<AgentToolConfigNodeContent
v-else-if="initialNode"
ref="nodeContentRef"
:initial-node="initialNode"
:existing-tool-names="data.existingToolNames"
:project-id="data.projectId"
:from-ai-disabled-parameters="fromAiDisabledParameters"
:parameter-issues="nodeParameterIssues"
content-test-id="node-tool-settings-content"
@update:valid="handleValidUpdate"
@update:node-name="handleNodeNameUpdate"
@update:node="handleNodeUpdate"
/>
<AgentToolConfigApprovalSetting
v-if="!isMcpTool && initialNode && showApprovalSetting"
v-model="approvalRequired"
/>
<AgentToolConfigMcpApprovalSetting
v-if="isMcpTool && currentNode && supportsApproval"
v-model="mcpApproval"
:node="currentNode"
:project-id="data.projectId"
@update:valid="mcpApprovalValid = $event"
/>
</div>
</template>
</div>
</div>
<N8nDialogFooter>
<div :class="$style.footer">
<N8nButton
v-if="data.onRemove"
variant="subtle"
data-test-id="agent-tool-config-remove"
@click="handleRemove"
>
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
{{ i18n.baseText('agents.builder.tools.remove') }}
</N8nButton>
<div :class="$style.footerActions">
<N8nButton variant="subtle" @click="handleCancel">
{{ i18n.baseText('agents.toolConfig.cancel') }}
</N8nButton>
<N8nButton
variant="solid"
:disabled="!canSave"
data-test-id="agent-tool-config-save"
@click="handleConfirm"
>
{{ i18n.baseText('agents.toolConfig.save') }}
</N8nButton>
</div>
</div>
</N8nDialogFooter>
</N8nDialog>
</template> </template>
<style lang="scss" module>
// Replaces Modal's max-height prop, which N8nDialog has no equivalent for.
.body {
display: flex;
flex-direction: column;
min-height: 0;
max-height: 70vh;
overflow: hidden;
}
.footer {
display: flex;
justify-content: space-between;
gap: var(--spacing--2xs);
}
.footerActions {
display: flex;
gap: var(--spacing--2xs);
margin-left: auto;
}
.contentWrapper {
--agent-tool-config-content-max-height: min(
calc(var(--spacing--5xl) * 2),
calc(var(--dialog--max-height) - var(--spacing--5xl))
);
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
max-height: var(--agent-tool-config-content-max-height);
overflow-x: hidden;
overflow-y: auto;
margin-right: 0;
padding: var(--spacing--md) 0;
:global(.ndv-connection-hint-notice) {
display: none;
}
}
.codeContentWrapper {
height: var(--agent-tool-config-content-max-height);
margin-right: 0;
padding-bottom: 0;
overflow: hidden;
}
.configureTab {
display: flex;
min-height: 0;
flex-direction: column;
gap: var(--spacing--sm);
}
.customToolViewer {
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
</style>
@@ -1,93 +0,0 @@
<script setup lang="ts">
import NodeIcon from '@/app/components/NodeIcon.vue';
import { N8nIcon, N8nInlineTextEdit, N8nText } from '@n8n/design-system';
import type { INodeTypeDescription } from 'n8n-workflow';
type HeaderKind = 'node' | 'workflow' | 'custom' | 'mcp';
withDefaults(
defineProps<{
kind: HeaderKind;
title: string;
nodeTypeDescription?: INodeTypeDescription | null;
editable?: boolean;
}>(),
{
nodeTypeDescription: null,
editable: true,
},
);
const emit = defineEmits<{
'update:title': [name: string];
}>();
</script>
<template>
<div :class="$style.header">
<NodeIcon
v-if="(kind === 'node' || kind === 'mcp') && nodeTypeDescription"
:node-type="nodeTypeDescription"
:size="24"
:circle="true"
:class="$style.icon"
/>
<N8nIcon
v-else-if="kind === 'workflow'"
icon="workflow"
:size="20"
:class="$style.workflowHeaderIcon"
/>
<N8nIcon
v-else-if="kind === 'custom'"
icon="code"
:size="20"
:class="$style.customHeaderIcon"
/>
<N8nInlineTextEdit
v-if="editable && kind !== 'custom'"
:model-value="title"
:max-width="400"
:class="$style.title"
@update:model-value="emit('update:title', $event)"
/>
<N8nText v-else :class="$style.title">
{{ title }}
</N8nText>
</div>
</template>
<style lang="scss" module>
.header {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
min-width: 0;
}
.icon {
flex-shrink: 0;
flex-grow: 0;
}
.workflowHeaderIcon {
flex-shrink: 0;
flex-grow: 0;
color: var(--color--primary);
}
.customHeaderIcon {
flex-shrink: 0;
flex-grow: 0;
color: var(--color--text--tint-1);
}
.title {
font-size: var(--font-size--md);
font-weight: var(--font-weight--regular);
line-height: var(--line-height--lg);
color: var(--color--text--shade-1);
flex: 1;
min-width: 0;
}
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue'; import { computed, onMounted, provide, ref, shallowRef, watch } from 'vue';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { useI18n } from '@n8n/i18n'; import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { N8nButton, N8nIcon } from '@n8n/design-system';
import { getResourcePermissions } from '@n8n/permissions'; import { getResourcePermissions } from '@n8n/permissions';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
import { INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES } from '@n8n/api-types'; import { INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES } from '@n8n/api-types';
@@ -10,7 +11,7 @@ import {
isCommunityPackageName, isCommunityPackageName,
resolveSupportedCredentialActivation, resolveSupportedCredentialActivation,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { INode, INodeProperties, INodeTypeDescription } from 'n8n-workflow'; import type { INode, INodeTypeDescription } from 'n8n-workflow';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { getWorkflow } from '@/app/api/workflows'; import { getWorkflow } from '@/app/api/workflows';
@@ -50,7 +51,6 @@ import {
type WorkflowConnectionItem, type WorkflowConnectionItem,
} from '@/features/shared/toolsConnection/types'; } from '@/features/shared/toolsConnection/types';
import { AGENT_TOOL_CONFIG_MODAL_KEY } from '../constants';
import { import {
getExistingToolNames, getExistingToolNames,
nodeTypeToNewToolRef, nodeTypeToNewToolRef,
@@ -68,11 +68,13 @@ import {
mcpServerToNode, mcpServerToNode,
nodeTypeToNewMcpServer, nodeTypeToNewMcpServer,
} from '../composables/useMcpServerAdapter'; } from '../composables/useMcpServerAdapter';
import type { AgentJsonMcpServerConfig, AgentJsonToolRef, WorkflowToolRef } from '../types'; import type { AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types';
import type { ToolPickerMode } from './AgentCapabilitiesSection.types'; import type { ToolPickerMode } from './AgentCapabilitiesSection.types';
import type { WorkflowToolIncompatibilityReason } from '@n8n/api-types'; import type { WorkflowToolIncompatibilityReason } from '@n8n/api-types';
import { toToolIconSource } from '../utils/toolIconSource'; import { toToolIconSource } from '../utils/toolIconSource';
import { workflowToolTriggerLabel } from '../utils/workflowToolTriggers'; import { workflowToolTriggerLabel } from '../utils/workflowToolTriggers';
import AgentToolConfigForm, { type AgentToolConfigModalData } from './AgentToolConfigForm.vue';
import AgentModalMultiStep from './modals/AgentModalMultiStep.vue';
const BASE_CATEGORIES: ToolCategoryKey[] = ['all', 'mcp', 'app-action', 'workflows']; const BASE_CATEGORIES: ToolCategoryKey[] = ['all', 'mcp', 'app-action', 'workflows'];
/** Prefix for the synthetic ids of gateway-backed rows in the n8n Connect section. */ /** Prefix for the synthetic ids of gateway-backed rows in the n8n Connect section. */
@@ -201,26 +203,79 @@ watch(
const workingTools = computed(() => workingToolEntries.value.map(({ ref }) => ref)); const workingTools = computed(() => workingToolEntries.value.map(({ ref }) => ref));
const workingMcpServers = computed(() => workingMcpServerEntries.value.map(({ server }) => server)); const workingMcpServers = computed(() => workingMcpServerEntries.value.map(({ server }) => server));
const configData = shallowRef<AgentToolConfigModalData | null>(null);
const configForm = ref<InstanceType<typeof AgentToolConfigForm> | null>(null);
const configTitle = ref('');
const configSession = ref(0);
const isCredentialModalOpen = ref(false);
const isConfigModalOpen = computed(
() => uiStore.modalsById[AGENT_TOOL_CONFIG_MODAL_KEY]?.open === true,
);
/**
* The two dialogs are sequential rather than stacked: connecting a tool hands
* over to the config modal, and this one steps aside. It stays open in the
* store rather than closing, so cancelling the config brings the list back with
* its search and scroll position intact.
*/
const isOpen = computed({ const isOpen = computed({
get: () => uiStore.modalsById[props.modalName]?.open === true && !isConfigModalOpen.value, get: () => uiStore.modalsById[props.modalName]?.open === true,
set: (value: boolean) => { set: (value: boolean) => {
if (!value) uiStore.closeModal(props.modalName); if (!value) uiStore.closeModal(props.modalName);
}, },
}); });
function openConfigModal(data: Record<string, unknown>) { const currentStep = computed(() => (configData.value ? 'configure' : 'select'));
uiStore.openModalWithData({ name: AGENT_TOOL_CONFIG_MODAL_KEY, data }); const pickerTitle = computed(() =>
isWorkflow.value ? i18n.baseText('workflows.add') : i18n.baseText('agents.builder.tools.add'),
);
const modalTitle = computed(() => (configData.value ? configTitle.value : pickerTitle.value));
const configIsCustom = computed(
() => configData.value?.kind !== 'mcpServer' && configData.value?.toolRef.type === 'custom',
);
const removeLabel = computed(() => {
const data = configData.value;
if (data?.kind === 'mcpServer') {
return i18n.baseText('agents.builder.tools.mcp.remove' as BaseTextKey);
}
if (data?.toolRef.type === 'workflow') {
return i18n.baseText('agents.builder.tools.workflow.remove' as BaseTextKey);
}
return i18n.baseText('agents.builder.tools.remove');
});
function initialConfigTitle(data: AgentToolConfigModalData): string {
if (data.kind === 'mcpServer') return data.mcpServer.name;
if (data.toolRef.type === 'custom') {
return data.customTool?.descriptor.name ?? data.toolRef.id;
}
return data.toolRef.name ?? '';
}
function openConfigModal(data: AgentToolConfigModalData) {
configData.value = data;
configTitle.value = initialConfigTitle(data);
configSession.value += 1;
}
function closeModal() {
uiStore.closeModal(props.modalName);
configData.value = null;
}
function backToPicker() {
configData.value = null;
isCredentialModalOpen.value = false;
configTitle.value = '';
}
function updateConfigTitle(value: string) {
configTitle.value = value;
configForm.value?.changeTitle(value);
}
function saveConfig() {
if (configForm.value?.confirm()) closeModal();
}
function removeConfig() {
configForm.value?.remove();
closeModal();
}
function handleInteractOutside(event: Event) {
if (isCredentialModalOpen.value) event.preventDefault();
} }
onMounted(() => { onMounted(() => {
@@ -237,20 +292,6 @@ onMounted(() => {
} }
}); });
function hasRequiredCredentials(nodeType: INodeTypeDescription): boolean {
return (nodeType.credentials ?? []).some((credential) => credential.required !== false);
}
function isConfigurableParameter(parameter: INodeProperties): boolean {
return parameter.type !== 'notice' && parameter.type !== 'hidden';
}
function needsSetup(nodeType: INodeTypeDescription): boolean {
return (
hasRequiredCredentials(nodeType) || (nodeType.properties ?? []).some(isConfigurableParameter)
);
}
function makeUniqueName( function makeUniqueName(
baseName: string, baseName: string,
existingNames: string[], existingNames: string[],
@@ -291,11 +332,6 @@ function commit() {
function addToolRef(savedRef: AgentJsonToolRef) { function addToolRef(savedRef: AgentJsonToolRef) {
workingToolEntries.value = [...workingToolEntries.value, { localId: uuidv4(), ref: savedRef }]; workingToolEntries.value = [...workingToolEntries.value, { localId: uuidv4(), ref: savedRef }];
commit(); commit();
uiStore.closeModal(props.modalName);
toast.showMessage({
title: i18n.baseText('agents.tools.added'),
type: 'success',
});
} }
function addMcpServer(savedServer: AgentJsonMcpServerConfig) { function addMcpServer(savedServer: AgentJsonMcpServerConfig) {
@@ -304,11 +340,6 @@ function addMcpServer(savedServer: AgentJsonMcpServerConfig) {
{ localId: uuidv4(), server: savedServer }, { localId: uuidv4(), server: savedServer },
]; ];
commit(); commit();
uiStore.closeModal(props.modalName);
toast.showMessage({
title: i18n.baseText('agents.tools.mcp.added'),
type: 'success',
});
} }
function openConfigForNewRef(newRef: AgentJsonToolRef) { function openConfigForNewRef(newRef: AgentJsonToolRef) {
@@ -416,14 +447,8 @@ async function handleAddTool(nodeType: INodeTypeDescription) {
function addNodeTool(nodeType: INodeTypeDescription) { function addNodeTool(nodeType: INodeTypeDescription) {
toolTelemetry.trackAddStarted('node'); toolTelemetry.trackAddStarted('node');
const newRef = nodeTypeToNewToolRef(nodeType); const newRef = nodeTypeToNewToolRef(nodeType);
if (needsSetup(nodeType)) {
openConfigForNewRef(newRef);
return;
}
if (newRef.type === 'node') { if (newRef.type === 'node') {
addToolRef({ openConfigForNewRef({
...newRef, ...newRef,
name: makeUniqueName( name: makeUniqueName(
newRef.name ?? nodeType.displayName, newRef.name ?? nodeType.displayName,
@@ -431,7 +456,7 @@ function addNodeTool(nodeType: INodeTypeDescription) {
), ),
}); });
} else { } else {
addToolRef({ openConfigForNewRef({
...newRef, ...newRef,
}); });
} }
@@ -551,7 +576,6 @@ function openConfigForToolEntry(entry: WorkingToolEntry) {
); );
toolTelemetry.trackEdited(updatedRef); toolTelemetry.trackEdited(updatedRef);
commit(); commit();
uiStore.closeModal(props.modalName);
}, },
onRemove: () => { onRemove: () => {
workingToolEntries.value = workingToolEntries.value.filter( workingToolEntries.value = workingToolEntries.value.filter(
@@ -579,7 +603,6 @@ function openConfigForMcpEntry(entry: WorkingMcpServerEntry) {
e.localId === entry.localId ? { ...e, server: updatedServer } : e, e.localId === entry.localId ? { ...e, server: updatedServer } : e,
); );
commit(); commit();
uiStore.closeModal(props.modalName);
}, },
onRemove: () => { onRemove: () => {
workingMcpServerEntries.value = workingMcpServerEntries.value.filter( workingMcpServerEntries.value = workingMcpServerEntries.value.filter(
@@ -604,21 +627,6 @@ function credentialSubtitle(node: INode): string | undefined {
function connectedToolItem(entry: WorkingToolEntry): ToolConnectionItem | null { function connectedToolItem(entry: WorkingToolEntry): ToolConnectionItem | null {
const { localId, ref } = entry; const { localId, ref } = entry;
if (ref.type === 'workflow') {
const workflowRef = ref as WorkflowToolRef;
const item: WorkflowConnectionItem = {
id: `tool:${localId}`,
kind: 'workflow',
category: 'workflows',
workflowId: workflowRef.workflowId ?? workflowRef.workflow,
title: workflowRef.name ?? workflowRef.workflow,
description: workflowRef.description,
status: 'connected',
credentials: [],
};
return item;
}
if (ref.type !== 'node') return null; if (ref.type !== 'node') return null;
const node = toolRefToNode(ref); const node = toolRefToNode(ref);
@@ -820,6 +828,14 @@ const items = computed<ToolConnectionItem[]>(() => {
return out; return out;
}); });
function addActionLabel(item: ToolConnectionItem): string {
if (item.category === 'mcp') {
return i18n.baseText('agents.builder.tools.mcp.add' as BaseTextKey);
}
if (item.kind === 'workflow') return i18n.baseText('workflows.add');
return i18n.baseText('node.addNode');
}
function handleRowActivate(item: ToolConnectionItem) { function handleRowActivate(item: ToolConnectionItem) {
// Disabled rows (e.g. incompatible workflows) are visible-but-not-selectable; // Disabled rows (e.g. incompatible workflows) are visible-but-not-selectable;
// the row's own tooltip already explains why, so activating does nothing. // the row's own tooltip already explains why, so activating does nothing.
@@ -889,28 +905,88 @@ function handleRowActivate(item: ToolConnectionItem) {
</script> </script>
<template> <template>
<ToolsConnectionModal <AgentModalMultiStep
v-model:open="isOpen" :open="isOpen"
:items="items" :step="currentStep"
:categories="categories" :title="modalTitle"
:title="isWorkflow ? i18n.baseText('generic.workflows') : undefined" :editable-title="Boolean(configData) && !configIsCustom"
:searchPlaceholder=" :show-back="Boolean(configData)"
isWorkflow ? i18n.baseText('agents.tools.workflow.search.placeholder') : undefined :show-footer="Boolean(configData)"
" :busy="isCredentialModalOpen || isCreatingWorkflow"
size="2xlarge" :trap-focus="!isCredentialModalOpen"
:detail-item="null" :disable-outside-pointer-events="!isCredentialModalOpen"
:allowWorkflowCreation="isWorkflow && canCreateWorkflow" data-testid="agent-tools-connection-modal"
:workflowCreationLoading="isCreatingWorkflow" @interact-outside="handleInteractOutside"
@update:search-query="searchQuery = $event" @update:open="isOpen = $event"
@connect="handleRowActivate" @update:title="updateConfigTitle"
@open-detail="handleRowActivate" @back="backToPicker"
@create-workflow="handleCreateWorkflow"
> >
<template #suggestion-footer> <ToolsConnectionModal
<McpRegistrySuggestionFooter v-show="!configData"
:prompt="i18n.baseText('agents.tools.suggestion.prompt')" :open="isOpen"
:action="i18n.baseText('agents.tools.suggestion.action')" :items="items"
/> :categories="categories"
:title="pickerTitle"
:search-placeholder="
isWorkflow ? i18n.baseText('agents.tools.workflow.search.placeholder') : undefined
"
:detail-item="null"
:create-action="
isWorkflow && canCreateWorkflow
? {
category: 'workflows',
label: i18n.baseText('generic.create.workflow'),
description: i18n.baseText('projectRoles.workflow:create.tooltip'),
testId: 'tools-connection-create-workflow',
}
: undefined
"
:create-action-loading="isCreatingWorkflow"
:empty-message="isWorkflow ? i18n.baseText('agents.tools.workflow.empty.title') : undefined"
:no-results-message="
isWorkflow ? i18n.baseText('agents.tools.workflow.empty.noResults') : undefined
"
:connect-label="addActionLabel"
embedded
show-connect-actions
persistent-scrollbar
@update:search-query="searchQuery = $event"
@connect="handleRowActivate"
@open-detail="handleRowActivate"
@create="handleCreateWorkflow"
>
<template #suggestion-footer>
<McpRegistrySuggestionFooter
:prompt="i18n.baseText('agents.tools.suggestion.prompt')"
:action="i18n.baseText('agents.tools.suggestion.action')"
/>
</template>
</ToolsConnectionModal>
<AgentToolConfigForm
v-if="configData"
:key="configSession"
ref="configForm"
:data="configData"
@update:title="configTitle = $event"
@update:credential-modal-open="isCredentialModalOpen = $event"
/>
<template v-if="configData?.onRemove" #footerLeft>
<N8nButton variant="ghost" data-testid="agent-tool-config-remove" @click="removeConfig">
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
{{ removeLabel }}
</N8nButton>
</template> </template>
</ToolsConnectionModal> <template v-if="configData" #footerActions>
<N8nButton
variant="solid"
:disabled="isCredentialModalOpen"
data-testid="agent-tool-config-save"
@click="saveConfig"
>
{{ i18n.baseText('generic.save') }}
</N8nButton>
</template>
</AgentModalMultiStep>
</template> </template>
@@ -2,13 +2,12 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import type { AgentJsonVectorStoreConfig, AgentVectorStoreProvider } from '@n8n/api-types'; import type { AgentJsonVectorStoreConfig, AgentVectorStoreProvider } from '@n8n/api-types';
import { VECTOR_STORE_NAME_REGEX, VECTOR_STORE_USE_WHEN_MAX_LENGTH } from '@n8n/api-types'; import { VECTOR_STORE_NAME_REGEX, VECTOR_STORE_USE_WHEN_MAX_LENGTH } from '@n8n/api-types';
import { N8nButton, N8nFormInput, N8nHeading, N8nInputLabel, N8nText } from '@n8n/design-system'; import { N8nButton, N8nFormInput, N8nIcon, N8nInputLabel, N8nText } from '@n8n/design-system';
import type { IValidator, Rule, RuleGroup, Validatable } from '@n8n/design-system'; import type { Rule, RuleGroup } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n'; import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions'; import { getResourcePermissions } from '@n8n/permissions';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
import Modal from '@/app/components/Modal.vue';
import { useToast } from '@n8n/composables/useToast'; import { useToast } from '@n8n/composables/useToast';
import { useUIStore } from '@/app/stores/ui.store'; import { useUIStore } from '@/app/stores/ui.store';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue'; import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
@@ -26,6 +25,7 @@ import {
} from '../vector-stores'; } from '../vector-stores';
import AgentCredentialSelect, { type AgentCredentialOption } from './AgentCredentialSelect.vue'; import AgentCredentialSelect, { type AgentCredentialOption } from './AgentCredentialSelect.vue';
import EmbeddingModelSelector from './EmbeddingModelSelector.vue'; import EmbeddingModelSelector from './EmbeddingModelSelector.vue';
import AgentModalMultiStep from './modals/AgentModalMultiStep.vue';
type AddVectorStoreModalData = { type AddVectorStoreModalData = {
projectId: string; projectId: string;
@@ -58,6 +58,7 @@ const { showMessage, showError } = useToast();
const rootStore = useRootStore(); const rootStore = useRootStore();
const credentialsStore = useCredentialsStore(); const credentialsStore = useCredentialsStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
const providerOrder: AgentVectorStoreProvider[] = ['pinecone', 'supabase', 'qdrant', 'postgres']; const providerOrder: AgentVectorStoreProvider[] = ['pinecone', 'supabase', 'qdrant', 'postgres'];
@@ -68,9 +69,15 @@ const selectedProvider = ref<AgentVectorStoreProvider | null>(
const providerDefinition = computed(() => const providerDefinition = computed(() =>
selectedProvider.value ? AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[selectedProvider.value] : null, selectedProvider.value ? AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[selectedProvider.value] : null,
); );
const currentStep = computed(() => (selectedProvider.value ? 'configure' : 'select'));
const modalTitle = computed(() =>
selectedProvider.value ? name.value : i18n.baseText('agents.builder.vectorStores.modal.title'),
);
const existing = props.data.vectorStore; const existing = props.data.vectorStore;
const name = ref(existing?.name ?? ''); const name = ref(
existing?.name ?? i18n.baseText('agents.builder.vectorStores.modal.defaultName' as BaseTextKey),
);
const nameTouched = ref(isEditing.value); const nameTouched = ref(isEditing.value);
const credential = ref(existing?.credential ?? ''); const credential = ref(existing?.credential ?? '');
const indexName = ref(existing && existing.provider === 'pinecone' ? existing.indexName : ''); const indexName = ref(existing && existing.provider === 'pinecone' ? existing.indexName : '');
@@ -93,6 +100,7 @@ const embeddingCredential = ref(existing?.embedding.credential ?? '');
const useWhen = ref(existing?.useWhen ?? ''); const useWhen = ref(existing?.useWhen ?? '');
const testing = ref(false); const testing = ref(false);
const submitted = ref(false);
const credentialsByType = ref<Record<string, AgentCredentialOption[]>>({}); const credentialsByType = ref<Record<string, AgentCredentialOption[]>>({});
const credentialsLoading = ref(false); const credentialsLoading = ref(false);
@@ -104,7 +112,6 @@ const pendingCredentialType = ref<string | null>(null);
// already handles "don't show the error until the field is touched" internally // already handles "don't show the error until the field is touched" internally
// (validateOnBlur) — this only drives the Connect/Save button's disabled state. // (validateOnBlur) — this only drives the Connect/Save button's disabled state.
const formValidation = reactive({ const formValidation = reactive({
name: false,
locator: false, locator: false,
useWhen: false, useWhen: false,
}); });
@@ -113,21 +120,32 @@ function closeModal() {
uiStore.closeModal(props.modalName); uiStore.closeModal(props.modalName);
} }
function handleInteractOutside(event: Event) {
if (credentialModalOpen.value) event.preventDefault();
}
function selectProvider(provider: AgentVectorStoreProvider) { function selectProvider(provider: AgentVectorStoreProvider) {
submitted.value = false;
selectedProvider.value = provider; selectedProvider.value = provider;
} }
function goBack() { function goBack() {
if (isEditing.value) return; if (isEditing.value) return;
submitted.value = false;
selectedProvider.value = null; selectedProvider.value = null;
nameTouched.value = false; nameTouched.value = false;
name.value = ''; name.value = i18n.baseText('agents.builder.vectorStores.modal.defaultName' as BaseTextKey);
credential.value = ''; credential.value = '';
indexName.value = ''; indexName.value = '';
namespace.value = ''; namespace.value = '';
collectionName.value = ''; collectionName.value = '';
tableName.value = ''; tableName.value = '';
queryName.value = ''; queryName.value = '';
embeddingModel.value = '';
embeddingCredential.value = '';
useWhen.value = '';
formValidation.locator = false;
formValidation.useWhen = false;
} }
const locatorValue = computed(() => { const locatorValue = computed(() => {
@@ -148,47 +166,35 @@ const locatorValue = computed(() => {
// user edits it directly, so add mode doesn't start with an empty required field. // user edits it directly, so add mode doesn't start with an empty required field.
watch(locatorValue, (value) => { watch(locatorValue, (value) => {
if (isEditing.value || nameTouched.value) return; if (isEditing.value || nameTouched.value) return;
name.value = value const locator = value.trim();
.trim() if (!locator) return;
.replace(/[^a-zA-Z0-9_-]+/g, '_') name.value = locator.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 64);
.slice(0, 64);
}); });
function onNameInput(value: Validatable) { function onNameInput(value: string) {
nameTouched.value = true; nameTouched.value = true;
name.value = typeof value === 'string' ? value : ''; name.value = value;
} }
const nameValidators: Record<string, IValidator> = { const nameError = computed(() => {
NAME_UNIQUE: { const trimmed = name.value.trim();
validate: (value: Validatable) => { if (!trimmed) {
const trimmed = typeof value === 'string' ? value.trim() : ''; return i18n.baseText(
const otherNames = props.data.existingNames.filter( 'agents.builder.vectorStores.modal.name.validation.required' as BaseTextKey,
(otherName) => otherName !== existing?.name, );
); }
// '-' is sanitized to '_' in the derived search_<name> tool name, so if (!VECTOR_STORE_NAME_REGEX.test(trimmed)) {
// compare sanitized forms to catch collisions like 'docs-a' vs 'docs_a'. return i18n.baseText('agents.builder.vectorStores.modal.name.validation.pattern');
const sanitize = (value: string) => value.replace(/-/g, '_'); }
if (trimmed && otherNames.some((otherName) => sanitize(otherName) === sanitize(trimmed))) { const otherNames = props.data.existingNames.filter((otherName) => otherName !== existing?.name);
return { // '-' becomes '_' in the generated tool name. Compare the generated forms.
message: i18n.baseText('agents.builder.vectorStores.modal.name.validation.duplicate'), const sanitize = (value: string) => value.replace(/-/g, '_');
}; if (otherNames.some((otherName) => sanitize(otherName) === sanitize(trimmed))) {
} return i18n.baseText('agents.builder.vectorStores.modal.name.validation.duplicate');
return false; }
}, return '';
}, });
}; const visibleNameError = computed(() => (submitted.value ? nameError.value : ''));
const nameValidationRules: Array<Rule | RuleGroup> = [
{
name: 'MATCH_REGEX',
config: {
regex: VECTOR_STORE_NAME_REGEX,
message: i18n.baseText('agents.builder.vectorStores.modal.name.validation.pattern'),
},
},
{ name: 'NAME_UNIQUE' },
];
const useWhenValidationRules: Array<Rule | RuleGroup> = [ const useWhenValidationRules: Array<Rule | RuleGroup> = [
{ name: 'MAX_LENGTH', config: { maximum: VECTOR_STORE_USE_WHEN_MAX_LENGTH } }, { name: 'MAX_LENGTH', config: { maximum: VECTOR_STORE_USE_WHEN_MAX_LENGTH } },
@@ -197,7 +203,7 @@ const useWhenValidationRules: Array<Rule | RuleGroup> = [
const canTest = computed( const canTest = computed(
() => () =>
Boolean(selectedProvider.value) && Boolean(selectedProvider.value) &&
formValidation.name && !nameError.value &&
Boolean(credential.value) && Boolean(credential.value) &&
formValidation.locator && formValidation.locator &&
Boolean(embeddingModel.value) && Boolean(embeddingModel.value) &&
@@ -364,6 +370,7 @@ function buildVectorStoreConfig(): AgentJsonVectorStoreConfig {
} }
async function onTestAndConnect() { async function onTestAndConnect() {
submitted.value = true;
if (!canTest.value || testing.value) return; if (!canTest.value || testing.value) return;
testing.value = true; testing.value = true;
try { try {
@@ -383,13 +390,15 @@ async function onTestAndConnect() {
}); });
return; return;
} }
const successTitle = i18n.baseText('agents.builder.vectorStores.modal.test.successTitle', {
interpolate: { name: vectorStore.name },
});
if (result.warning) { if (result.warning) {
showMessage({ title: successTitle, message: result.warning, type: 'warning', duration: 0 }); showMessage({
} else { title: i18n.baseText('agents.builder.vectorStores.modal.test.successTitle', {
showMessage({ title: successTitle, type: 'success' }); interpolate: { name: vectorStore.name },
}),
message: result.warning,
type: 'warning',
duration: 0,
});
} }
props.data.onConfirm(vectorStore); props.data.onConfirm(vectorStore);
closeModal(); closeModal();
@@ -412,231 +421,212 @@ onMounted(() => {
</script> </script>
<template> <template>
<Modal <AgentModalMultiStep
:name="props.modalName" :open="modalOpen"
width="640px" :step="currentStep"
:custom-class="$style.modal" :title="modalTitle"
:editable-title="Boolean(selectedProvider)"
:title-placeholder="i18n.baseText('agents.builder.vectorStores.modal.name.placeholder')"
:title-error="visibleNameError"
:title-max-length="64"
:show-back="Boolean(selectedProvider) && !isEditing"
:show-footer="Boolean(selectedProvider)"
:busy="testing"
:trap-focus="!credentialModalOpen"
:disable-outside-pointer-events="!credentialModalOpen"
data-testid="agent-vector-stores-modal" data-testid="agent-vector-stores-modal"
@interact-outside="handleInteractOutside"
@update:open="!$event && closeModal()"
@update:title="onNameInput"
@back="goBack"
> >
<template #header> <div v-show="!selectedProvider" :class="$style.content">
<N8nHeading tag="h2" size="large"> <N8nText size="small" color="text-light">
{{ {{ i18n.baseText('agents.builder.vectorStores.modal.description') }}
selectedProvider </N8nText>
? providerDefinition?.displayName
: i18n.baseText('agents.builder.vectorStores.modal.title')
}}
</N8nHeading>
</template>
<template #content> <div :class="$style.rows">
<div v-if="!selectedProvider" :class="$style.content"> <div
<N8nText size="small" color="text-light"> v-for="provider in providerOrder"
{{ i18n.baseText('agents.builder.vectorStores.modal.description') }} :key="provider"
</N8nText> :class="$style.row"
role="group"
<div :class="$style.rows"> :aria-label="AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[provider].displayName"
<div data-testid="agent-vector-stores-modal-row"
v-for="provider in providerOrder" >
:key="provider" <div :class="$style.iconWrapper">
:class="$style.row" <CredentialIcon
data-testid="agent-vector-stores-modal-row" :credential-type-name="
> AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[provider].credentialType
<div :class="$style.iconWrapper"> "
<CredentialIcon :size="24"
:credential-type-name=" />
AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[provider].credentialType </div>
" <div :class="$style.rowBody">
:size="24" <N8nText size="small" color="text-dark" :class="$style.name">
/> {{ AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[provider].displayName }}
</div> </N8nText>
<div :class="$style.rowBody"> </div>
<N8nText size="small" color="text-dark" :class="$style.name"> <div :class="$style.actions">
{{ AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[provider].displayName }} <N8nButton
</N8nText> variant="subtle"
</div> size="small"
<div :class="$style.actions"> data-testid="agent-vector-stores-modal-connect"
<N8nButton @click="selectProvider(provider)"
variant="subtle" >
size="small" {{ i18n.baseText('agents.builder.vectorStores.modal.connect') }}
data-testid="agent-vector-stores-modal-connect" </N8nButton>
@click="selectProvider(provider)"
>
{{ i18n.baseText('agents.builder.vectorStores.modal.connect') }}
</N8nButton>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div v-else :class="[$style.content, $style.configureContent]"> <div v-if="selectedProvider" :class="[$style.content, $style.configureContent]">
<N8nFormInput <N8nInputLabel
:model-value="name" :label="i18n.baseText('agents.builder.vectorStores.modal.credential.label')"
name="vectorStoreName" required
:label="i18n.baseText('agents.builder.vectorStores.modal.name.label')" >
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.name.placeholder')" <AgentCredentialSelect
required v-model="credential"
:maxlength="64" :credentials="storeCredentialOptions"
:validation-rules="nameValidationRules" :placeholder="i18n.baseText('agents.builder.vectorStores.modal.credential.placeholder')"
:validators="nameValidators" :credential-permissions="credentialPermissions"
data-testid="agent-vector-stores-modal-name" :loading="credentialsLoading"
@update:model-value="onNameInput" data-test-id="agent-vector-stores-modal-credential"
@validate="(valid: boolean) => (formValidation.name = valid)" @create="onCreateStoreCredential"
/> />
<N8nText
<N8nInputLabel v-if="submitted && !credential"
:label="i18n.baseText('agents.builder.vectorStores.modal.credential.label')" size="small"
required color="danger"
data-testid="agent-vector-stores-modal-credential-required"
> >
<AgentCredentialSelect {{
v-model="credential" i18n.baseText('agents.builder.vectorStores.modal.credential.required' as BaseTextKey)
:credentials="storeCredentialOptions" }}
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.credential.placeholder')" </N8nText>
:credential-permissions="credentialPermissions" </N8nInputLabel>
:loading="credentialsLoading"
data-test-id="agent-vector-stores-modal-credential"
@create="onCreateStoreCredential"
/>
</N8nInputLabel>
<template v-if="selectedProvider === 'pinecone'">
<N8nFormInput
v-model="indexName"
name="indexName"
:label="i18n.baseText('agents.builder.vectorStores.modal.indexName.label')"
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.indexName.placeholder')"
required
data-testid="agent-vector-stores-modal-index-name"
@validate="(valid: boolean) => (formValidation.locator = valid)"
/>
<N8nFormInput
v-model="namespace"
name="namespace"
:label="i18n.baseText('agents.builder.vectorStores.modal.namespace.label')"
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.namespace.placeholder')"
data-testid="agent-vector-stores-modal-namespace"
/>
</template>
<template v-if="selectedProvider === 'pinecone'">
<N8nFormInput <N8nFormInput
v-else-if="selectedProvider === 'qdrant'" v-model="indexName"
v-model="collectionName" name="indexName"
name="collectionName" :label="i18n.baseText('agents.builder.vectorStores.modal.indexName.label')"
:label="i18n.baseText('agents.builder.vectorStores.modal.collectionName.label')" :placeholder="i18n.baseText('agents.builder.vectorStores.modal.indexName.placeholder')"
:placeholder="
i18n.baseText('agents.builder.vectorStores.modal.collectionName.placeholder')
"
required required
data-testid="agent-vector-stores-modal-collection-name" :show-validation-warnings="submitted"
data-testid="agent-vector-stores-modal-index-name"
@validate="(valid: boolean) => (formValidation.locator = valid)" @validate="(valid: boolean) => (formValidation.locator = valid)"
/> />
<template v-else-if="selectedProvider === 'supabase' || selectedProvider === 'postgres'">
<N8nFormInput
v-model="tableName"
name="tableName"
:label="i18n.baseText('agents.builder.vectorStores.modal.tableName.label')"
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.tableName.placeholder')"
required
data-testid="agent-vector-stores-modal-table-name"
@validate="(valid: boolean) => (formValidation.locator = valid)"
/>
<N8nFormInput
v-if="selectedProvider === 'supabase'"
v-model="queryName"
name="queryName"
:label="i18n.baseText('agents.builder.vectorStores.modal.queryName.label')"
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.queryName.placeholder')"
data-testid="agent-vector-stores-modal-query-name"
/>
</template>
<N8nInputLabel
:label="i18n.baseText('agents.builder.vectorStores.modal.embeddingModel.label')"
:tooltip-text="i18n.baseText('agents.builder.vectorStores.modal.embeddingModel.hint')"
required
>
<EmbeddingModelSelector
:selected-model="embeddingModel"
:selected-credential-id="embeddingCredential || null"
:credentials-by-type="credentialsByType"
:can-create-credentials="credentialPermissions.create"
@update:selected-model="onEmbeddingModelUpdate"
@update:selected-credential-id="embeddingCredential = $event"
@create-credential="onCreateEmbeddingCredential"
/>
</N8nInputLabel>
<N8nFormInput <N8nFormInput
v-model="useWhen" v-model="namespace"
name="useWhen" name="namespace"
:label="i18n.baseText('agents.builder.vectorStores.useWhen.label')" :label="i18n.baseText('agents.builder.vectorStores.modal.namespace.label')"
:tooltip-text="i18n.baseText('agents.builder.vectorStores.useWhen.hint')" :placeholder="i18n.baseText('agents.builder.vectorStores.modal.namespace.placeholder')"
:placeholder="i18n.baseText('agents.builder.vectorStores.useWhen.placeholder')" data-testid="agent-vector-stores-modal-namespace"
required
:maxlength="VECTOR_STORE_USE_WHEN_MAX_LENGTH"
:validation-rules="useWhenValidationRules"
data-testid="agent-vector-stores-modal-use-when"
@validate="(valid: boolean) => (formValidation.useWhen = valid)"
/> />
</div> </template>
</template>
<template v-if="selectedProvider" #footer> <N8nFormInput
<div :class="$style.footer"> v-else-if="selectedProvider === 'qdrant'"
<N8nButton v-model="collectionName"
v-if="isEditing && data.onRemove" name="collectionName"
variant="subtle" :label="i18n.baseText('agents.builder.vectorStores.modal.collectionName.label')"
data-testid="agent-vector-stores-modal-remove" :placeholder="i18n.baseText('agents.builder.vectorStores.modal.collectionName.placeholder')"
@click="onRemove" required
:show-validation-warnings="submitted"
data-testid="agent-vector-stores-modal-collection-name"
@validate="(valid: boolean) => (formValidation.locator = valid)"
/>
<template v-else-if="selectedProvider === 'supabase' || selectedProvider === 'postgres'">
<N8nFormInput
v-model="tableName"
name="tableName"
:label="i18n.baseText('agents.builder.vectorStores.modal.tableName.label')"
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.tableName.placeholder')"
required
:show-validation-warnings="submitted"
data-testid="agent-vector-stores-modal-table-name"
@validate="(valid: boolean) => (formValidation.locator = valid)"
/>
<N8nFormInput
v-if="selectedProvider === 'supabase'"
v-model="queryName"
name="queryName"
:label="i18n.baseText('agents.builder.vectorStores.modal.queryName.label')"
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.queryName.placeholder')"
data-testid="agent-vector-stores-modal-query-name"
/>
</template>
<N8nInputLabel
:label="i18n.baseText('agents.builder.vectorStores.modal.embeddingModel.label')"
:tooltip-text="i18n.baseText('agents.builder.vectorStores.modal.embeddingModel.hint')"
required
>
<EmbeddingModelSelector
:selected-model="embeddingModel"
:selected-credential-id="embeddingCredential || null"
:credentials-by-type="credentialsByType"
:can-create-credentials="credentialPermissions.create"
@update:selected-model="onEmbeddingModelUpdate"
@update:selected-credential-id="embeddingCredential = $event"
@create-credential="onCreateEmbeddingCredential"
/>
<N8nText
v-if="submitted && (!embeddingModel || !embeddingCredential)"
size="small"
color="danger"
> >
{{ i18n.baseText('agents.builder.vectorStores.modal.remove') }} {{
</N8nButton> i18n.baseText(
<N8nButton 'agents.builder.vectorStores.modal.embeddingModel.required' as BaseTextKey,
v-else )
variant="subtle" }}
data-testid="agent-vector-stores-modal-back" </N8nText>
@click="goBack" </N8nInputLabel>
>
{{ i18n.baseText('agents.builder.vectorStores.modal.back') }} <N8nFormInput
</N8nButton> v-model="useWhen"
<div :class="$style.footerActions"> name="useWhen"
<N8nButton variant="subtle" @click="closeModal"> :label="i18n.baseText('agents.builder.vectorStores.useWhen.label')"
{{ i18n.baseText('generic.cancel') }} :tooltip-text="i18n.baseText('agents.builder.vectorStores.useWhen.hint')"
</N8nButton> :placeholder="i18n.baseText('agents.builder.vectorStores.useWhen.placeholder')"
<N8nButton required
variant="solid" :maxlength="VECTOR_STORE_USE_WHEN_MAX_LENGTH"
:disabled="!canTest" :validation-rules="useWhenValidationRules"
:loading="testing" :show-validation-warnings="submitted"
data-testid="agent-vector-stores-modal-confirm" data-testid="agent-vector-stores-modal-use-when"
@click="onTestAndConnect" @validate="(valid: boolean) => (formValidation.useWhen = valid)"
> />
{{ </div>
i18n.baseText(
isEditing ? 'generic.save' : 'agents.builder.vectorStores.modal.connect', <template v-if="selectedProvider && isEditing && data.onRemove" #footerLeft>
) <N8nButton variant="ghost" data-testid="agent-vector-stores-modal-remove" @click="onRemove">
}} <template #icon><N8nIcon icon="trash-2" :size="16" /></template>
</N8nButton> {{ i18n.baseText('agents.builder.vectorStores.modal.remove') }}
</div> </N8nButton>
</div>
</template> </template>
</Modal> <template v-if="selectedProvider" #footerActions>
<N8nButton
variant="solid"
:loading="testing"
:disabled="testing"
data-testid="agent-vector-stores-modal-confirm"
@click="onTestAndConnect"
>
{{ i18n.baseText('generic.save') }}
</N8nButton>
</template>
</AgentModalMultiStep>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.modal {
:global(.modal-content) {
overflow: hidden;
}
}
.content { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing--md); gap: var(--spacing--md);
margin: calc(-1 * var(--spacing--lg));
padding: var(--spacing--lg);
max-height: 640px;
overflow-y: auto;
} }
.configureContent { .configureContent {
@@ -659,7 +649,7 @@ onMounted(() => {
.iconWrapper { .iconWrapper {
flex-shrink: 0; flex-shrink: 0;
width: 32px; width: var(--spacing--xl);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -682,25 +672,10 @@ onMounted(() => {
max-width: 100%; max-width: 100%;
} }
.itemIcon {
color: var(--text-color--subtle);
}
.actions { .actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing--2xs); gap: var(--spacing--2xs);
flex-shrink: 0; flex-shrink: 0;
} }
.footer {
display: flex;
justify-content: space-between;
gap: var(--spacing--2xs);
}
.footerActions {
display: flex;
gap: var(--spacing--2xs);
}
</style> </style>
@@ -587,7 +587,6 @@ defineExpose({
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing--md); gap: var(--spacing--md);
padding: var(--spacing--sm) 0;
} }
.field { .field {
@@ -0,0 +1,375 @@
<script setup lang="ts">
import {
N8nDialog,
N8nButton,
N8nDialogFooter,
N8nDialogHeader,
N8nDialogTitle,
N8nIcon,
N8nIconButton,
N8nInlineTextEdit,
N8nText,
type DialogSize,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { FocusScope } from 'reka-ui';
import { computed, nextTick, useSlots, useTemplateRef } from 'vue';
const props = withDefaults(
defineProps<{
open: boolean;
title: string;
editableTitle?: boolean;
titlePlaceholder?: string;
titleMaxLength?: number;
titleError?: string;
showBack?: boolean;
showFooter?: boolean;
showCancel?: boolean;
bodyScrollable?: boolean;
bodyFlush?: boolean;
busy?: boolean;
size?: DialogSize;
stacked?: boolean;
trapFocus?: boolean;
disableOutsidePointerEvents?: boolean;
}>(),
{
editableTitle: false,
titlePlaceholder: '',
titleMaxLength: 128,
titleError: '',
showBack: false,
showFooter: undefined,
showCancel: true,
bodyScrollable: true,
bodyFlush: false,
busy: false,
size: '2xlarge',
stacked: false,
trapFocus: true,
disableOutsidePointerEvents: true,
},
);
const emit = defineEmits<{
'update:open': [value: boolean];
'update:title': [value: string];
back: [];
interactOutside: [event: Event];
}>();
const slots = useSlots();
const i18n = useI18n();
const body = useTemplateRef<HTMLElement>('body');
const hasFooter = computed(
() =>
props.showFooter ??
Boolean(slots.footer || slots.footerLeft || slots.footerBeforeCancel || slots.footerActions),
);
const dismissalBlocked = computed(() => props.busy || !props.trapFocus);
function onOpenChange(open: boolean) {
if (!open && dismissalBlocked.value) return;
emit('update:open', open);
}
function close() {
if (!dismissalBlocked.value) emit('update:open', false);
}
function onBack() {
if (!dismissalBlocked.value) emit('back');
}
function onEscapeKeyDown(event: KeyboardEvent) {
if (dismissalBlocked.value) event.preventDefault();
}
function onInteractOutside(event: Event) {
if (dismissalBlocked.value) event.preventDefault();
emit('interactOutside', event);
}
function onOpenAutoFocus(event: Event) {
const autofocusTarget =
body.value?.querySelector<HTMLElement>(
'[data-agent-modal-autofocus], input:not([type="hidden"]):not([disabled]):not([tabindex="-1"]), textarea:not([disabled]), select:not([disabled]), [contenteditable="true"]',
) ?? body.value?.querySelector<HTMLElement>('button:not([disabled])');
if (!autofocusTarget) return;
event.preventDefault();
void nextTick(() => autofocusTarget.focus());
}
</script>
<template>
<N8nDialog
:open="props.open"
:size="props.size"
:stacked="props.stacked"
:trap-focus="props.trapFocus"
:disable-outside-pointer-events="props.disableOutsidePointerEvents"
:show-close-button="false"
@escape-key-down="onEscapeKeyDown"
@interact-outside="onInteractOutside"
@open-auto-focus="onOpenAutoFocus"
@update:open="onOpenChange"
>
<N8nDialogHeader :class="$style.header">
<div :class="$style.headerContent">
<N8nIconButton
v-if="props.showBack"
icon="arrow-left"
variant="ghost"
size="small"
:disabled="dismissalBlocked"
:class="$style.backButton"
:aria-label="i18n.baseText('generic.back')"
data-testid="agent-modal-back"
@click="onBack"
/>
<N8nDialogTitle as-child>
<div :class="$style.titleGroup">
<N8nInlineTextEdit
v-if="props.editableTitle"
:model-value="props.title"
:max-length="props.titleMaxLength"
:max-width="'100%'"
:placeholder="props.titlePlaceholder"
:disabled="props.busy"
:class="$style.editableTitle"
@update:model-value="emit('update:title', $event)"
/>
<span v-else :class="$style.title">{{ props.title }}</span>
<N8nIcon
v-if="props.editableTitle"
icon="pencil"
size="small"
:class="$style.editIcon"
aria-hidden="true"
/>
</div>
</N8nDialogTitle>
<div v-if="$slots.headerActions" :class="$style.headerActions">
<slot name="headerActions" />
</div>
<N8nIconButton
icon="x"
variant="ghost"
size="small"
:disabled="dismissalBlocked"
:aria-label="i18n.baseText('generic.close')"
data-testid="dialog-close-button"
:class="$style.closeButton"
@click="close"
/>
</div>
<N8nText
v-if="props.titleError"
size="small"
color="danger"
:class="[$style.titleError, props.showBack && $style.titleErrorWithBack]"
>
{{ props.titleError }}
</N8nText>
</N8nDialogHeader>
<FocusScope
v-if="!props.trapFocus"
as-child
@mount-auto-focus.prevent
@unmount-auto-focus.prevent
>
<span hidden aria-hidden="true" />
</FocusScope>
<div
ref="body"
:class="[
$style.body,
!props.bodyScrollable && $style.bodyNotScrollable,
props.bodyFlush && $style.bodyFlush,
]"
data-testid="agent-modal-body"
>
<slot />
</div>
<N8nDialogFooter v-if="hasFooter" :class="$style.footer">
<slot name="footer">
<div :class="$style.footerLayout">
<div :class="$style.footerLeft">
<slot name="footerLeft" />
</div>
<div :class="$style.footerActions" data-testid="agent-modal-footer-actions">
<slot name="footerBeforeCancel" />
<N8nButton
v-if="props.showCancel"
variant="outline"
:disabled="dismissalBlocked"
data-testid="agent-modal-cancel"
@click="close"
>
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<slot name="footerActions" />
</div>
</div>
</slot>
</N8nDialogFooter>
</N8nDialog>
</template>
<style module lang="scss">
@use '@n8n/design-system/css/mixins/mixins' as scrollbar-mixins;
.header {
display: flex;
flex-direction: column;
margin: calc(var(--spacing--lg) * -1) calc(var(--spacing--lg) * -1) 0;
padding: var(--spacing--md) var(--spacing--lg);
border-bottom: var(--border);
}
.titleError {
min-width: 0;
}
.titleErrorWithBack {
padding-left: calc(var(--spacing--xl) + var(--spacing--2xs));
}
.headerContent {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
min-width: 0;
}
.backButton,
.closeButton {
flex-shrink: 0;
}
.titleGroup {
display: flex;
align-items: center;
gap: var(--spacing--xs);
min-width: 0;
flex: 1;
font-size: var(--font-size--lg);
font-weight: var(--font-weight--medium);
line-height: var(--line-height--lg);
color: light-dark(var(--color--neutral-900), var(--color--neutral-100));
&:hover .editIcon,
&:focus-within .editIcon {
opacity: 1;
}
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editableTitle {
min-width: 0;
max-width: 100%;
font: inherit;
color: inherit;
}
.editIcon {
flex-shrink: 0;
opacity: 0;
color: var(--color--text--tint-1);
transition: opacity var(--duration--snappy) ease;
}
.headerActions {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
margin-left: auto;
}
.headerActions + .closeButton,
.titleGroup + .closeButton {
margin-left: auto;
}
.body {
box-sizing: border-box;
min-height: 0;
max-height: min(70dvh, calc(var(--height--5xl) * 6));
overflow-y: auto;
margin-inline: calc(var(--spacing--5xs) * -1);
padding: var(--spacing--md) var(--spacing--5xs) var(--spacing--5xs);
@include scrollbar-mixins.scroll-bar;
}
.body :global(.n8n-markdown) {
@include scrollbar-mixins.scroll-bar;
}
.bodyNotScrollable {
overflow-y: hidden;
}
.bodyFlush {
margin-inline: calc(var(--spacing--lg) * -1);
padding: 0;
}
.footerLayout {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
width: 100%;
}
.footerLeft,
.footerActions {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.footerActions {
margin-left: auto;
}
@media (max-width: 480px) {
.header {
padding-inline: var(--spacing--md);
}
.footerLayout {
align-items: stretch;
flex-direction: column-reverse;
}
.footerLeft,
.footerActions {
width: 100%;
:global(.button) {
flex: 1;
}
}
}
@media (hover: none) {
.editIcon {
opacity: 1;
}
}
</style>
@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { DialogSize } from '@n8n/design-system';
import { computed, nextTick, ref, watch } from 'vue';
import AgentModal from './AgentModal.vue';
const props = withDefaults(
defineProps<{
open: boolean;
step: string;
title: string;
editableTitle?: boolean;
titlePlaceholder?: string;
titleMaxLength?: number;
titleError?: string;
showBack?: boolean;
showFooter?: boolean;
showCancel?: boolean;
busy?: boolean;
size?: DialogSize;
stacked?: boolean;
trapFocus?: boolean;
disableOutsidePointerEvents?: boolean;
}>(),
{
editableTitle: false,
titlePlaceholder: '',
titleMaxLength: 128,
titleError: '',
showBack: false,
showFooter: undefined,
showCancel: true,
busy: false,
size: '2xlarge',
stacked: false,
trapFocus: true,
disableOutsidePointerEvents: true,
},
);
const emit = defineEmits<{
'update:open': [value: boolean];
'update:title': [value: string];
back: [];
interactOutside: [event: Event];
}>();
const isPickerStep = computed(() => props.step === 'select' || props.step === 'list');
const stepChanged = ref(false);
watch(
() => props.step,
async () => {
stepChanged.value = false;
await nextTick();
stepChanged.value = true;
},
);
</script>
<template>
<AgentModal
:open="open"
:title="title"
:editable-title="editableTitle"
:title-placeholder="titlePlaceholder"
:title-max-length="titleMaxLength"
:title-error="titleError"
:show-back="showBack"
:show-footer="showFooter"
:show-cancel="showCancel"
:body-scrollable="!isPickerStep"
:busy="busy"
:size="size"
:stacked="stacked"
:trap-focus="trapFocus"
:disable-outside-pointer-events="disableOutsidePointerEvents"
@interact-outside="emit('interactOutside', $event)"
@update:open="emit('update:open', $event)"
@update:title="emit('update:title', $event)"
@back="emit('back')"
>
<template #headerActions>
<slot name="headerActions" />
</template>
<div :class="[$style.step, stepChanged && $style.stepChanged]" :data-step="step">
<slot />
</div>
<template v-if="$slots.footerLeft" #footerLeft>
<slot name="footerLeft" />
</template>
<template v-if="$slots.footerBeforeCancel" #footerBeforeCancel>
<slot name="footerBeforeCancel" />
</template>
<template v-if="$slots.footerActions" #footerActions>
<slot name="footerActions" />
</template>
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</AgentModal>
</template>
<style module lang="scss">
@use '@n8n/design-system/css/mixins/motion';
.step {
min-height: 0;
&[data-step='select'],
&[data-step='list'] {
min-height: min(60dvh, calc(var(--height--5xl) * 5));
}
}
.stepChanged {
--animation--fade-in--duration: var(--duration--snappy);
--animation--fade-in--translate: var(--spacing--2xs);
@include motion.fade-in;
}
</style>
@@ -85,6 +85,18 @@ export interface UseAgentCapabilitiesActionsDeps {
* Hosts whose agent always exists omit it. * Hosts whose agent always exists omit it.
*/ */
ensureAgentPersisted?: () => Promise<void>; ensureAgentPersisted?: () => Promise<void>;
/**
* Flushes pending agent edits before an API mutation that also writes the
* agent config. This prevents the mutation from advancing the config hash
* ahead of a queued config save.
*/
beforeAgentMutation?: () => Promise<void>;
/**
* Reloads agent-owned state after an API mutation that writes both a sidecar
* resource and the agent config. The reload updates the config hash without
* scheduling a duplicate config write.
*/
refreshAgentAfterMutation?: (projectId: string, agentId: string) => Promise<boolean>;
validationIssues?: Ref<AgentConfigValidationIssue[]> | ComputedRef<AgentConfigValidationIssue[]>; validationIssues?: Ref<AgentConfigValidationIssue[]> | ComputedRef<AgentConfigValidationIssue[]>;
telemetry?: AgentCapabilitiesTelemetry; telemetry?: AgentCapabilitiesTelemetry;
} }
@@ -107,6 +119,8 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
localSkills, localSkills,
supportsToolApproval, supportsToolApproval,
ensureAgentPersisted, ensureAgentPersisted,
beforeAgentMutation,
refreshAgentAfterMutation,
validationIssues, validationIssues,
telemetry, telemetry,
} = deps; } = deps;
@@ -414,6 +428,8 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
// still the current one. // still the current one.
const targetProjectId = projectId.value; const targetProjectId = projectId.value;
const targetAgentId = agentId.value; const targetAgentId = agentId.value;
const isCurrentTarget = () =>
projectId.value === targetProjectId && agentId.value === targetAgentId;
uiStore.openModalWithData({ uiStore.openModalWithData({
name: AGENT_SKILL_MODAL_KEY, name: AGENT_SKILL_MODAL_KEY,
@@ -424,7 +440,7 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
existingSkillNames: appliedSkillNames(), existingSkillNames: appliedSkillNames(),
onConfirm: ({ skill }: { id?: string; skill: AgentSkill }) => { onConfirm: ({ skill }: { id?: string; skill: AgentSkill }) => {
if (localSkills) { if (localSkills) {
if (agentId.value !== targetAgentId) return; if (!isCurrentTarget()) return;
const sanitizedSkill = filterSkillAllowedTools(skill); const sanitizedSkill = filterSkillAllowedTools(skill);
if (hasDuplicateSkillName(sanitizedSkill.name)) { if (hasDuplicateSkillName(sanitizedSkill.name)) {
showDuplicateSkillNameError(sanitizedSkill.name); showDuplicateSkillNameError(sanitizedSkill.name);
@@ -433,10 +449,6 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
// The host mints the skill id and writes body + ref together. // The host mints the skill id and writes body + ref together.
localSkills.createSkill(sanitizedSkill); localSkills.createSkill(sanitizedSkill);
showMessage({
title: locale.baseText('agents.builder.skills.added'),
type: 'success',
});
return; return;
} }
@@ -446,8 +458,17 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
let skillHash: string; let skillHash: string;
let versionId: string | null; let versionId: string | null;
let skillId: string; let skillId: string;
try {
await beforeAgentMutation?.();
} catch {
// The host owns the autosave error message. Do not also report
// this as a skill-creation failure.
return;
}
if (!isCurrentTarget()) return;
try { try {
await ensureAgentPersisted?.(); await ensureAgentPersisted?.();
if (!isCurrentTarget()) return;
const result = await createAgentSkill( const result = await createAgentSkill(
rootStore.restApiContext, rootStore.restApiContext,
targetProjectId, targetProjectId,
@@ -462,7 +483,7 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
showError(error, locale.baseText('agents.builder.skills.create.error')); showError(error, locale.baseText('agents.builder.skills.create.error'));
return; return;
} }
if (agent.value?.id !== targetAgentId) return; if (!isCurrentTarget() || agent.value?.id !== targetAgentId) return;
agent.value = { agent.value = {
...agent.value, ...agent.value,
versionId, versionId,
@@ -475,13 +496,23 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
[skillId]: created, [skillId]: created,
}, },
}; };
scheduleConfigUpdate({ let refreshed = true;
skills: [...(localConfig.value?.skills ?? []), { type: 'skill', id: skillId }], try {
}); refreshed =
showMessage({ (await refreshAgentAfterMutation?.(targetProjectId, targetAgentId)) ?? true;
title: locale.baseText('agents.builder.skills.added'), } catch (error) {
type: 'success', showError(error, locale.baseText('agents.builder.loadError'));
}); return;
}
if (refreshed && isCurrentTarget() && localConfig.value) {
localConfig.value = {
...localConfig.value,
skills: [
...(localConfig.value.skills ?? []).filter((ref) => ref.id !== skillId),
{ type: 'skill', id: skillId },
],
};
}
})(); })();
}, },
}, },
@@ -1,6 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount, useTemplateRef } from 'vue'; import { ref, computed, watch, nextTick, onBeforeUnmount, useTemplateRef } from 'vue';
import { StorageSerializers, useEventListener, useLocalStorage, useStorage } from '@vueuse/core'; import {
StorageSerializers,
useElementSize,
useEventListener,
useLocalStorage,
useStorage,
} from '@vueuse/core';
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'; import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router';
import { import {
N8nAssistantIcon, N8nAssistantIcon,
@@ -180,6 +186,11 @@ const mcpStore = useMCPStore();
const mcp = useMcp(); const mcp = useMcp();
const { isCtrlKeyPressed } = useDeviceSupport(); const { isCtrlKeyPressed } = useDeviceSupport();
// No design tokens cover these layout widths. Keep the editor usable while the
// two resizable side panels adapt to the available viewport.
const AGENT_BUILDER_EDITOR_MIN_WIDTH = 480;
const AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH = 320;
// Gates the Knowledge Base files table (upload, list, sandbox fetch/warmup) on // Gates the Knowledge Base files table (upload, list, sandbox fetch/warmup) on
// the backend: Daytona sandbox env vars (N8N_AGENTS_AI_SANDBOX_ENABLED + // the backend: Daytona sandbox env vars (N8N_AGENTS_AI_SANDBOX_ENABLED +
// PROVIDER=daytona) OR AI Assistant proxy availability. The Knowledge tab and // PROVIDER=daytona) OR AI Assistant proxy availability. The Knowledge tab and
@@ -223,6 +234,10 @@ const previewOpenStorageKey = computed(function getPreviewOpenStorageKey() {
const persistedPreviewOpen = useStorage(previewOpenStorageKey, false); const persistedPreviewOpen = useStorage(previewOpenStorageKey, false);
const previewDockWidth = ref(480); const previewDockWidth = ref(480);
const isPreviewDockResizing = ref(false); const isPreviewDockResizing = ref(false);
const builderContainer = useTemplateRef<HTMLElement>('builderContainer');
const { width: builderContainerWidth } = useElementSize(builderContainer, undefined, {
box: 'border-box',
});
const isPreviewDockOpen = computed(function isPreviewDockOpen() { const isPreviewDockOpen = computed(function isPreviewDockOpen() {
return !isStandalonePreview.value && persistedPreviewOpen.value; return !isStandalonePreview.value && persistedPreviewOpen.value;
}); });
@@ -300,7 +315,94 @@ watch(aiPanelRef, (panel) => {
}); });
const isEditingLocked = computed(() => props.artifactEditingLocked || embeddedAiBuilding.value); const isEditingLocked = computed(() => props.artifactEditingLocked || embeddedAiBuilding.value);
const aiPanelWidth = useStorage('N8N_AGENT_AI_PANEL_WIDTH', 400); const aiPanelWidth = useStorage('N8N_AGENT_AI_PANEL_WIDTH', 400);
type SidePanel = 'assistant' | 'preview';
const preferredSidePanel = ref<SidePanel>('assistant');
function bothSidePanelsFit() {
if (builderContainerWidth.value === 0) return true;
return (
builderContainerWidth.value >=
AGENT_BUILDER_EDITOR_MIN_WIDTH + AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH * 2
);
}
const renderedSidePanelWidths = computed(function getRenderedSidePanelWidths() {
const desiredAiWidth = Math.max(aiPanelWidth.value, AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH);
const desiredPreviewWidth = Math.max(previewDockWidth.value, AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH);
const containerWidth = builderContainerWidth.value;
if (containerWidth === 0) {
return { ai: desiredAiWidth, preview: desiredPreviewWidth };
}
const availableSidePanelWidth = Math.max(
AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH,
containerWidth - AGENT_BUILDER_EDITOR_MIN_WIDTH,
);
if (showAiPanel.value && !isPreviewDockOpen.value) {
return {
ai: Math.min(desiredAiWidth, availableSidePanelWidth),
preview: desiredPreviewWidth,
};
}
if (!showAiPanel.value && isPreviewDockOpen.value) {
return {
ai: desiredAiWidth,
preview: Math.min(desiredPreviewWidth, availableSidePanelWidth),
};
}
if (!showAiPanel.value || !isPreviewDockOpen.value || !bothSidePanelsFit()) {
return { ai: desiredAiWidth, preview: desiredPreviewWidth };
}
if (desiredAiWidth + desiredPreviewWidth <= availableSidePanelWidth) {
return { ai: desiredAiWidth, preview: desiredPreviewWidth };
}
const availableExtraWidth = availableSidePanelWidth - AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH * 2;
const desiredAiExtraWidth = desiredAiWidth - AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH;
const desiredPreviewExtraWidth = desiredPreviewWidth - AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH;
const desiredExtraWidth = desiredAiExtraWidth + desiredPreviewExtraWidth;
const renderedAiWidth =
AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH +
availableExtraWidth * (desiredAiExtraWidth / desiredExtraWidth);
return {
ai: renderedAiWidth,
preview: availableSidePanelWidth - renderedAiWidth,
};
});
function keepPreferredSidePanel() {
if (!showAiPanel.value || !isPreviewDockOpen.value || bothSidePanelsFit()) return;
if (preferredSidePanel.value === 'preview') {
isAiPanelOpen.value = false;
return;
}
closePreviewDock();
}
watch(
showAiPanel,
(open, wasOpen) => {
if (open && !wasOpen) preferredSidePanel.value = 'assistant';
},
{ flush: 'sync' },
);
watch(
isPreviewDockOpen,
(open, wasOpen) => {
if (open && !wasOpen) preferredSidePanel.value = 'preview';
},
{ flush: 'sync' },
);
watch(
[builderContainerWidth, showAiPanel, isPreviewDockOpen, aiPanelWidth, previewDockWidth],
keepPreferredSidePanel,
{ flush: 'post' },
);
function onAiPanelResize({ width }: { width: number }) { function onAiPanelResize({ width }: { width: number }) {
preferredSidePanel.value = 'assistant';
aiPanelWidth.value = width; aiPanelWidth.value = width;
} }
function toggleAiPanel() { function toggleAiPanel() {
@@ -794,6 +896,7 @@ function sessionIdForPreview(): string | undefined {
} }
async function openPreview(preferredSessionId?: string) { async function openPreview(preferredSessionId?: string) {
preferredSidePanel.value = 'preview';
const sessionId = preferredSessionId ?? sessionIdForPreview(); const sessionId = preferredSessionId ?? sessionIdForPreview();
activeChatSessionId.value = sessionId ?? null; activeChatSessionId.value = sessionId ?? null;
persistedPreviewOpen.value = true; persistedPreviewOpen.value = true;
@@ -890,6 +993,7 @@ function closePreviewDock() {
} }
function onPreviewDockResize({ width }: ResizeData) { function onPreviewDockResize({ width }: ResizeData) {
preferredSidePanel.value = 'preview';
previewDockWidth.value = width; previewDockWidth.value = width;
} }
@@ -1460,6 +1564,8 @@ const caps = useAgentCapabilitiesActions({
agentId, agentId,
connectedTriggers, connectedTriggers,
ensureAgentPersisted, ensureAgentPersisted,
beforeAgentMutation: flushAutosave,
refreshAgentAfterMutation: onConfigUpdated,
validationIssues: computed(() => configValidation.value?.issues ?? []), validationIssues: computed(() => configValidation.value?.issues ?? []),
scheduleConfigUpdate: onConfigFieldUpdate, scheduleConfigUpdate: onConfigFieldUpdate,
scheduleSkillSave: ({ skillId, skill }) => { scheduleSkillSave: ({ skillId, skill }) => {
@@ -1509,22 +1615,29 @@ function persistMissingPersonalisationGradient() {
replaceConfigAndScheduleSave(nextConfig); replaceConfigAndScheduleSave(nextConfig);
} }
async function onConfigUpdated() { async function onConfigUpdated(
targetProjectId: string = projectId.value,
targetAgentId: string = agentId.value,
): Promise<boolean> {
// Modal flows (e.g. skill creation) write through their own API calls, not // Modal flows (e.g. skill creation) write through their own API calls, not
// `saveConfig` — notify other surfaces (canvas agent cards) here too. // `saveConfig` — notify other surfaces (canvas agent cards) here too.
agentsEventBus.emit('agentUpdated', { agentId: agentId.value, source: 'agent-builder' }); agentsEventBus.emit('agentUpdated', { agentId: targetAgentId, source: 'agent-builder' });
await Promise.all([ await Promise.all([
fetchAgent(), fetchAgent(targetProjectId, targetAgentId),
fetchConfig(projectId.value, agentId.value), fetchConfig(targetProjectId, targetAgentId),
refreshConfigValidation(projectId.value, agentId.value), refreshConfigValidation(targetProjectId, targetAgentId),
]); ]);
if (isStaleAgentTarget(targetProjectId, targetAgentId)) return false;
// Refresh the connected-trigger list so chips reflect builder writes // Refresh the connected-trigger list so chips reflect builder writes
// without waiting for a tab switch. Mirrors the initial baseline fetch. // without waiting for a tab switch. Mirrors the initial baseline fetch.
const integrations = await ensureIntegrationsCatalog(projectId.value).catch(() => []); const integrations = await ensureIntegrationsCatalog(targetProjectId).catch(() => []);
if (isStaleAgentTarget(targetProjectId, targetAgentId)) return false;
const triggerTypes = integrations.map((i) => i.type); const triggerTypes = integrations.map((i) => i.type);
const connected = await builderTelemetry.fetchInitialTriggersBaseline(triggerTypes); const connected = await builderTelemetry.fetchInitialTriggersBaseline(triggerTypes);
if (isStaleAgentTarget(targetProjectId, targetAgentId)) return false;
if (connected) connectedTriggers.value = connected; if (connected) connectedTriggers.value = connected;
tasksReloadKey.value += 1; tasksReloadKey.value += 1;
return true;
} }
async function refreshArtifactShell() { async function refreshArtifactShell() {
@@ -2271,21 +2384,7 @@ function onOpenEditVectorStoreModal(vectorStore: AgentJsonVectorStoreConfig) {
}); });
} }
async function onRemoveVectorStore(vectorStore: AgentJsonVectorStoreConfig) { function onRemoveVectorStore(vectorStore: AgentJsonVectorStoreConfig) {
const confirmed = await openAgentConfirmationModal({
title: locale.baseText('agents.builder.vectorStores.panel.removeModal.title', {
interpolate: { name: vectorStore.name },
}),
description: locale.baseText('agents.builder.vectorStores.panel.removeModal.description', {
interpolate: { name: vectorStore.name },
}),
confirmButtonText: locale.baseText(
'agents.builder.vectorStores.panel.removeModal.button.remove',
),
cancelButtonText: locale.baseText('generic.cancel'),
});
if (confirmed !== MODAL_CONFIRM) return;
onConfigFieldUpdate({ onConfigFieldUpdate({
vectorStores: (localConfig.value?.vectorStores ?? []).filter( vectorStores: (localConfig.value?.vectorStores ?? []).filter(
(existing) => existing.name !== vectorStore.name, (existing) => existing.name !== vectorStore.name,
@@ -2402,6 +2501,7 @@ function onSwitchAgent(nextAgentId: string) {
</div> </div>
<div <div
ref="builderContainer" ref="builderContainer"
data-testid="agent-builder-container"
:class="[ :class="[
$style.builder, $style.builder,
{ {
@@ -2411,14 +2511,15 @@ function onSwitchAgent(nextAgentId: string) {
}, },
]" ]"
:style="{ :style="{
'--agent-ai-panel-width': `${aiPanelWidth}px`, '--agent-ai-panel-width': `${renderedSidePanelWidths.ai}px`,
'--agent-preview-chat-column-width': `${previewDockWidth}px`, '--agent-preview-chat-column-width': `${renderedSidePanelWidths.preview}px`,
'--agent-builder-editor-min-width': `${AGENT_BUILDER_EDITOR_MIN_WIDTH}px`,
}" }"
> >
<aside v-if="showAiPanel" :class="$style.aiDock" data-testid="agent-ai-dock"> <aside v-if="showAiPanel" :class="$style.aiDock" data-testid="agent-ai-dock">
<N8nResizeWrapper <N8nResizeWrapper
:width="aiPanelWidth" :width="renderedSidePanelWidths.ai"
:min-width="320" :min-width="AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH"
:max-width="720" :max-width="720"
:supported-directions="['right']" :supported-directions="['right']"
@resize="onAiPanelResize" @resize="onAiPanelResize"
@@ -2530,8 +2631,8 @@ function onSwitchAgent(nextAgentId: string) {
<N8nResizeWrapper <N8nResizeWrapper
v-if="!isStandalonePreview" v-if="!isStandalonePreview"
:class="[$style.previewResizeWrapper, { [$style.previewResizeOpen]: isPreviewDockOpen }]" :class="[$style.previewResizeWrapper, { [$style.previewResizeOpen]: isPreviewDockOpen }]"
:width="previewDockWidth" :width="renderedSidePanelWidths.preview"
:min-width="320" :min-width="AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH"
:supported-directions="['left']" :supported-directions="['left']"
:grid-size="8" :grid-size="8"
@resizestart="isPreviewDockResizing = true" @resizestart="isPreviewDockResizing = true"
@@ -37,11 +37,17 @@ type Props = {
isSharedGlobally?: boolean; isSharedGlobally?: boolean;
allUsersLabel?: string; allUsersLabel?: string;
disabledTooltip?: string; disabledTooltip?: string;
teleported?: boolean;
// Show the dropdown chevron even in remote+filterable mode (element-plus hides it by default) // Show the dropdown chevron even in remote+filterable mode (element-plus hides it by default)
showSuffix?: boolean; showSuffix?: boolean;
}; };
const props = defineProps<Props>(); const props = withDefaults(defineProps<Props>(), {
teleported: true,
});
// Keep an in-place popper outside a scroll container's clipping area.
const inPlacePopperOptions: { strategy: 'fixed' } = { strategy: 'fixed' };
const GLOBAL_GROUP: ProjectListItem = { const GLOBAL_GROUP: ProjectListItem = {
id: 'all_users', id: 'all_users',
@@ -231,6 +237,8 @@ watch(
:size="size ?? 'medium'" :size="size ?? 'medium'"
:disabled="props.readonly || !!props.disabledTooltip" :disabled="props.readonly || !!props.disabledTooltip"
:clearable :clearable
:teleported="props.teleported"
:popper-options="props.teleported ? undefined : inPlacePopperOptions"
:popper-class="$style.popper" :popper-class="$style.popper"
@update:model-value="onProjectSelected" @update:model-value="onProjectSelected"
@clear="emit('clear')" @clear="emit('clear')"
@@ -297,17 +297,6 @@ const renderComponent = createComponentRenderer(CredentialEdit, {
}), }),
}); });
const modalLoadingStub = {
props: ['loading'],
template: `
<div data-test-id="credential-edit-modal-stub" :data-loading="String(loading)">
<slot v-if="!loading" name="header" />
<slot v-if="!loading" name="content" />
<slot v-if="!loading" name="footer" />
</div>
`,
};
let broadcastMessageListener: ((event: MessageEvent) => void) | undefined; let broadcastMessageListener: ((event: MessageEvent) => void) | undefined;
class BroadcastChannelMock { class BroadcastChannelMock {
@@ -349,6 +338,18 @@ describe('CredentialEdit', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
test('closes through the dialog shell', async () => {
const uiStore = mockedStore(useUIStore);
const { getByTestId } = renderComponent({
props: { modalName: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' },
});
await retry(() => expect(getByTestId('editCredential-modal')).toBeInTheDocument());
await userEvent.click(getByTestId('dialog-close-button'));
await waitFor(() => expect(uiStore.closeModal).toHaveBeenCalledWith(CREDENTIAL_EDIT_MODAL_KEY));
});
test('shows the save button when credentialId is null', async () => { test('shows the save button when credentialId is null', async () => {
const pinia = createTestingPinia({ const pinia = createTestingPinia({
initialState: { initialState: {
@@ -562,15 +563,9 @@ describe('CredentialEdit', () => {
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent({
props: { modalName: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' }, props: { modalName: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' },
global: {
stubs: {
Modal: modalLoadingStub,
},
},
}); });
await waitFor(() => { await waitFor(() => {
expect(getByTestId('credential-edit-modal-stub')).toHaveAttribute('data-loading', 'false');
expect(getByTestId('credential-edit-dialog')).toBeInTheDocument(); expect(getByTestId('credential-edit-dialog')).toBeInTheDocument();
}); });
}); });
@@ -587,20 +582,12 @@ describe('CredentialEdit', () => {
modalName: CREDENTIAL_EDIT_MODAL_KEY, modalName: CREDENTIAL_EDIT_MODAL_KEY,
mode: 'edit', mode: 'edit',
}, },
global: {
stubs: {
Modal: modalLoadingStub,
},
},
}); });
try { try {
await waitFor(() => { await waitFor(() => {
expect(credentialsStore.getCredentialData).toHaveBeenCalled(); expect(credentialsStore.getCredentialData).toHaveBeenCalled();
expect(getByTestId('credential-edit-modal-stub')).toHaveAttribute( expect(getByTestId('credential-edit-dialog')).toBeInTheDocument();
'data-loading',
'false',
);
}); });
} finally { } finally {
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore();
@@ -876,20 +863,12 @@ describe('CredentialEdit', () => {
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent({
props: { modalName: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' }, props: { modalName: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' },
global: {
stubs: {
Modal: modalLoadingStub,
},
},
}); });
try { try {
// Wait for the modal to appear and loading to finish // Wait for the modal to appear and loading to finish
await waitFor(() => { await waitFor(() => {
expect(getByTestId('credential-edit-modal-stub')).toHaveAttribute( expect(getByTestId('editCredential-modal')).toBeInTheDocument();
'data-loading',
'false',
);
expect(getByTestId('credential-edit-dialog')).toBeInTheDocument(); expect(getByTestId('credential-edit-dialog')).toBeInTheDocument();
}); });
@@ -23,7 +23,6 @@ import CredentialIcon from '../CredentialIcon.vue';
import CredentialConfig from './CredentialConfig.vue'; import CredentialConfig from './CredentialConfig.vue';
import CredentialInfo from './CredentialInfo.vue'; import CredentialInfo from './CredentialInfo.vue';
import CredentialSharing from './CredentialSharing.ee.vue'; import CredentialSharing from './CredentialSharing.ee.vue';
import Modal from '@/app/components/Modal.vue';
import SaveButton from '@/app/components/SaveButton.vue'; import SaveButton from '@/app/components/SaveButton.vue';
import { useMessage } from '@/app/composables/useMessage'; import { useMessage } from '@/app/composables/useMessage';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers'; import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
@@ -62,9 +61,13 @@ import { useElementSize } from '@vueuse/core';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { import {
N8nDialog,
N8nDialogHeader,
N8nDialogTitle,
N8nIconButton, N8nIconButton,
N8nInlineTextEdit, N8nInlineTextEdit,
N8nMenuItem, N8nMenuItem,
N8nSpinner,
N8nText, N8nText,
type IMenuItem, type IMenuItem,
} from '@n8n/design-system'; } from '@n8n/design-system';
@@ -93,6 +96,7 @@ const settingsStore = useSettingsStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const externalSecretsStore = useExternalSecretsStore(); const externalSecretsStore = useExternalSecretsStore();
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
const externalHooks = useExternalHooks(); const externalHooks = useExternalHooks();
@@ -166,6 +170,7 @@ const { getQuickConnectOption, connect: quickConnect } = useQuickConnect();
const isQuickConnectMode = ref(false); const isQuickConnectMode = ref(false);
const activeTab = ref('connection'); const activeTab = ref('connection');
const modalBus = ref(createEventBus()); const modalBus = ref(createEventBus());
const closing = ref(false);
const isDeleting = ref(false); const isDeleting = ref(false);
const hasUnsavedChanges = ref(false); const hasUnsavedChanges = ref(false);
const credentialDescription = ref(''); const credentialDescription = ref('');
@@ -656,8 +661,20 @@ function onDataChange(update: IUpdateInformation) {
if (form.onDataChange(update)) hasUnsavedChanges.value = true; if (form.onDataChange(update)) hasUnsavedChanges.value = true;
} }
function closeDialog() { async function closeDialog() {
modalBus.value.emit('close'); if (closing.value) return;
closing.value = true;
try {
if ((await beforeClose()) === false) return;
uiStore.closeModal(props.modalName);
modalBus.value.emit('closed');
} finally {
closing.value = false;
}
}
function onDialogOpenUpdate(open: boolean) {
if (!open) void closeDialog();
} }
function onNameEdit(text: string) { function onNameEdit(text: string) {
@@ -1382,172 +1399,177 @@ const { width } = useElementSize(credNameRef);
<template> <template>
<div> <div>
<Modal <N8nDialog
:name="modalName" :open="modalOpen"
:custom-class="$style.credentialModal" size="fit"
:event-bus="modalBus" :stacked="appendToBody"
:loading="loading" :aria-label="loading ? i18n.baseText('credentials.heading') : undefined"
:before-close="beforeClose" @update:open="onDialogOpenUpdate"
width="70%"
height="80%"
:append-to-body="appendToBody"
> >
<template #header> <div :class="$style.credentialDialog" data-test-id="editCredential-modal">
<div :class="$style.header"> <template v-if="!loading">
<div :class="$style.credInfo"> <N8nDialogHeader :class="$style.header">
<div :class="$style.credIcon"> <N8nDialogTitle as-child>
<CredentialIcon :credential-type-name="defaultCredentialTypeName" /> <div :class="$style.credInfo">
</div> <div :class="$style.credIcon">
<div ref="credNameRef" :class="$style.credName"> <CredentialIcon :credential-type-name="defaultCredentialTypeName" />
<div :class="$style.credNameRow"> </div>
<N8nInlineTextEdit <div ref="credNameRef" :class="$style.credName">
v-if="credentialName" <div :class="$style.credNameRow">
data-test-id="credential-name" <N8nInlineTextEdit
:model-value="credentialName" v-if="credentialName"
:max-width="width - 10" data-test-id="credential-name"
:readonly=" :model-value="credentialName"
!( :max-width="width - 10"
(credentialPermissions.create && props.mode === 'new') || :readonly="
credentialPermissions.update !(
) || (credentialPermissions.create && props.mode === 'new') ||
!credentialType || credentialPermissions.update
isEditingManagedCredential ) ||
" !credentialType ||
@update:model-value="onNameEdit" isEditingManagedCredential
/> "
<span @update:model-value="onNameEdit"
v-if="isResolvable" />
:class="$style.dynamicTag" <span
data-test-id="credential-dynamic-tag" v-if="isResolvable"
> :class="$style.dynamicTag"
<PrivateCredentialIcon data-test-id="credential-dynamic-tag"
:tooltip-title="i18n.baseText('credentials.private.tooltipTitle')" >
:tooltip-text="i18n.baseText('credentials.private.tooltip')" <PrivateCredentialIcon
/> :tooltip-title="i18n.baseText('credentials.private.tooltipTitle')"
</span> :tooltip-text="i18n.baseText('credentials.private.tooltip')"
/>
</span>
</div>
<N8nText v-if="credentialType" size="small" tag="p" color="text-light">{{
credentialType.displayName
}}</N8nText>
</div>
</div> </div>
<N8nText v-if="credentialType" size="small" tag="p" color="text-light">{{ </N8nDialogTitle>
credentialType.displayName <div :class="$style.credActions">
}}</N8nText> <SaveButton
v-if="showHeaderSaveButton"
:class="$style.saveButton"
:disabled="
(!isNewCredential && !hasUnsavedChanges && !isTesting) ||
!requiredPropertiesFilled
"
:variant="hasUnsavedChanges || isTesting ? 'solid' : 'subtle'"
:is-saving="isSaving || isTesting"
:saved="!isNewCredential && isSaved && !hasUnsavedChanges && !isTesting"
:saving-label="
isTesting
? i18n.baseText('credentialEdit.credentialEdit.testing')
: i18n.baseText('credentialEdit.credentialEdit.saving')
"
data-test-id="credential-save-button"
@click="saveCredential"
/>
<N8nIconButton
variant="subtle"
v-if="
currentCredential &&
credentialPermissions.delete &&
(!isResolvable || credentialPermissions.createEndUser)
"
:title="i18n.baseText('credentialEdit.credentialEdit.delete')"
icon="trash-2"
:disabled="isSaving"
:loading="isDeleting"
data-test-id="credential-delete-button"
@click="deleteCredential"
/>
</div>
</N8nDialogHeader>
<div :class="$style.container" data-test-id="credential-edit-dialog">
<div
v-if="credentialDescriptionsEnabled || !isEditingManagedCredential"
:class="$style.sidebar"
>
<N8nMenuItem
v-for="item in sidebarItems"
:key="item.id"
:item="item"
:active="activeTab === item.id"
@click="() => onTabSelect(item.id)"
/>
</div>
<div
v-if="activeTab === 'connection' && credentialType"
ref="contentRef"
:class="$style.mainContent"
>
<CredentialConfig
:credential-type="credentialType"
:credential-properties="credentialProperties"
:credential-data="credentialData"
:credential-id="credentialId"
:is-managed="isEditingManagedCredential"
:show-validation-warning="showValidationWarning"
:auth-error="authError"
:tested-successfully="testedSuccessfully"
:is-o-auth-type="isOAuthType"
:is-o-auth-connected="isOAuthConnected"
:is-retesting="isRetesting"
:parent-types="parentTypes"
:required-properties-filled="requiredPropertiesFilled"
:credential-permissions="credentialPermissions"
:mode="mode"
:selected-credential="selectedCredential"
:is-private-credentials-enabled="
isPrivateCredentialsEnabled && !isInstanceCredential
"
:is-resolvable="isResolvable"
:connected-by-me="connectedByMe"
:connected-account-identifier="connectedAccountIdentifier"
:is-new-credential="isNewCredential"
:new-credential-project-type="homeProject?.type"
:managed-oauth-available="managedOAuthAvailable"
:use-custom-oauth="useCustomOAuth"
:is-quick-connect-mode="isQuickConnectMode"
:context-node="contextNode"
:hide-ask-assistant="hideAskAssistant"
:instance-ai-credential-help="instanceAiCredentialHelp"
@update="onDataChange"
@oauth="oAuthCredentialAuthorize"
@disconnect="onDisconnectMyConnection"
@quick-connect="onQuickConnect"
@retest="retestCredential"
@scroll-to-top="scrollToTop"
@auth-type-changed="onAuthTypeChanged"
@claimed="closeDialog"
@update:is-resolvable="onResolvableChange"
/>
</div>
<div v-else-if="showSharingContent" :class="$style.mainContent">
<CredentialSharing
:credential="currentCredential"
:credential-data="credentialData"
:credential-id="credentialId"
:credential-permissions="credentialPermissions"
:is-shared-globally="isSharedGlobally"
:modal-bus="modalBus"
@update:model-value="onChangeSharedWith"
@update:share-with-all-users="onShareWithAllUsersUpdate"
/>
</div>
<div v-else-if="activeTab === 'details' && credentialType" :class="$style.mainContent">
<CredentialInfo
:current-credential="currentCredential"
:description="credentialDescription"
:readonly="!canEditDescription"
@update:description="onDescriptionEdit"
/>
</div> </div>
</div> </div>
<div :class="$style.credActions"> </template>
<SaveButton <div v-else :class="$style.loader">
v-if="showHeaderSaveButton" <N8nSpinner />
:class="$style.saveButton"
:disabled="
(!isNewCredential && !hasUnsavedChanges && !isTesting) || !requiredPropertiesFilled
"
:variant="hasUnsavedChanges || isTesting ? 'solid' : 'subtle'"
:is-saving="isSaving || isTesting"
:saved="!isNewCredential && isSaved && !hasUnsavedChanges && !isTesting"
:saving-label="
isTesting
? i18n.baseText('credentialEdit.credentialEdit.testing')
: i18n.baseText('credentialEdit.credentialEdit.saving')
"
data-test-id="credential-save-button"
@click="saveCredential"
/>
<N8nIconButton
variant="subtle"
v-if="
currentCredential &&
credentialPermissions.delete &&
(!isResolvable || credentialPermissions.createEndUser)
"
:title="i18n.baseText('credentialEdit.credentialEdit.delete')"
icon="trash-2"
:disabled="isSaving"
:loading="isDeleting"
data-test-id="credential-delete-button"
@click="deleteCredential"
/>
</div>
</div> </div>
</template> </div>
<template #content> </N8nDialog>
<div :class="$style.container" data-test-id="credential-edit-dialog">
<div
v-if="credentialDescriptionsEnabled || !isEditingManagedCredential"
:class="$style.sidebar"
>
<N8nMenuItem
v-for="item in sidebarItems"
:key="item.id"
:item="item"
:active="activeTab === item.id"
@click="() => onTabSelect(item.id)"
/>
</div>
<div
v-if="activeTab === 'connection' && credentialType"
ref="contentRef"
:class="$style.mainContent"
>
<CredentialConfig
:credential-type="credentialType"
:credential-properties="credentialProperties"
:credential-data="credentialData"
:credential-id="credentialId"
:is-managed="isEditingManagedCredential"
:show-validation-warning="showValidationWarning"
:auth-error="authError"
:tested-successfully="testedSuccessfully"
:is-o-auth-type="isOAuthType"
:is-o-auth-connected="isOAuthConnected"
:is-retesting="isRetesting"
:parent-types="parentTypes"
:required-properties-filled="requiredPropertiesFilled"
:credential-permissions="credentialPermissions"
:mode="mode"
:selected-credential="selectedCredential"
:is-private-credentials-enabled="isPrivateCredentialsEnabled && !isInstanceCredential"
:is-resolvable="isResolvable"
:connected-by-me="connectedByMe"
:connected-account-identifier="connectedAccountIdentifier"
:is-new-credential="isNewCredential"
:new-credential-project-type="homeProject?.type"
:managed-oauth-available="managedOAuthAvailable"
:use-custom-oauth="useCustomOAuth"
:is-quick-connect-mode="isQuickConnectMode"
:context-node="contextNode"
:hide-ask-assistant="hideAskAssistant"
:instance-ai-credential-help="instanceAiCredentialHelp"
@update="onDataChange"
@oauth="oAuthCredentialAuthorize"
@disconnect="onDisconnectMyConnection"
@quick-connect="onQuickConnect"
@retest="retestCredential"
@scroll-to-top="scrollToTop"
@auth-type-changed="onAuthTypeChanged"
@claimed="closeDialog"
@update:is-resolvable="onResolvableChange"
/>
</div>
<div v-else-if="showSharingContent" :class="$style.mainContent">
<CredentialSharing
:credential="currentCredential"
:credential-data="credentialData"
:credential-id="credentialId"
:credential-permissions="credentialPermissions"
:is-shared-globally="isSharedGlobally"
:modal-bus="modalBus"
@update:model-value="onChangeSharedWith"
@update:share-with-all-users="onShareWithAllUsersUpdate"
/>
</div>
<div v-else-if="activeTab === 'details' && credentialType" :class="$style.mainContent">
<CredentialInfo
:current-credential="currentCredential"
:description="credentialDescription"
:readonly="!canEditDescription"
@update:description="onDescriptionEdit"
/>
</div>
</div>
</template>
</Modal>
<TypeToConfirmDialog <TypeToConfirmDialog
v-if="typeToConfirmDialog" v-if="typeToConfirmDialog"
:open="typeToConfirmDialog.open" :open="typeToConfirmDialog.open"
@@ -1563,20 +1585,13 @@ const { width } = useElementSize(credNameRef);
</template> </template>
<style module lang="scss"> <style module lang="scss">
.credentialModal { .credentialDialog {
--dialog--max-width: 1200px; width: min(70dvw, calc(var(--spacing--5xl) * 4 + var(--spacing--4xl) + var(--spacing--2xl)));
--dialog--close--spacing--top: 36px; height: 80dvh;
--dialog--max-height: 750px; max-height: calc(var(--height--5xl) * 8);
min-height: 0;
:global(.el-dialog__header) { display: flex;
padding-bottom: 0; flex-direction: column;
border-bottom: var(--border);
}
:global(.el-dialog__body) {
padding-top: var(--spacing--lg);
position: relative;
}
} }
.mainContent { .mainContent {
@@ -1618,11 +1633,19 @@ const { width } = useElementSize(credNameRef);
.header { .header {
display: flex; display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 0;
margin: calc(var(--spacing--lg) * -1) calc(var(--spacing--lg) * -1) 0;
padding: var(--spacing--md) var(--spacing--lg);
border-bottom: var(--border);
} }
.container { .container {
display: flex; display: flex;
height: 100%; flex: 1;
min-height: 0;
padding-top: var(--spacing--lg);
} }
.credInfo { .credInfo {
@@ -1630,7 +1653,6 @@ const { width } = useElementSize(credNameRef);
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
flex-grow: 1; flex-grow: 1;
margin-bottom: var(--spacing--lg);
} }
.credActions { .credActions {
@@ -1639,10 +1661,17 @@ const { width } = useElementSize(credNameRef);
align-items: center; align-items: center;
gap: var(--spacing--2xs); gap: var(--spacing--2xs);
margin-right: var(--spacing--xl); margin-right: var(--spacing--xl);
margin-bottom: var(--spacing--lg);
flex-shrink: 0; flex-shrink: 0;
} }
.loader {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--color--primary--tint-1);
}
.credIcon { .credIcon {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1653,4 +1682,16 @@ const { width } = useElementSize(credNameRef);
flex-shrink: 0; flex-shrink: 0;
min-width: 57px; min-width: 57px;
} }
@media (max-width: 640px) {
.credentialDialog {
width: calc(100dvw - var(--spacing--3xl));
}
.sidebar {
min-width: calc(var(--spacing--5xl) + var(--spacing--2xl));
max-width: calc(var(--spacing--5xl) + var(--spacing--2xl));
margin-right: var(--spacing--sm);
}
}
</style> </style>
@@ -189,6 +189,7 @@ function goToUpgrade() {
:placeholder="sharingSelectPlaceholder" :placeholder="sharingSelectPlaceholder"
:can-share-globally="canShareGlobally" :can-share-globally="canShareGlobally"
:is-shared-globally="isSharedGlobally" :is-shared-globally="isSharedGlobally"
:teleported="false"
@update:share-with-all-users="emit('update:shareWithAllUsers', $event)" @update:share-with-all-users="emit('update:shareWithAllUsers', $event)"
/> />
</div> </div>
@@ -58,6 +58,7 @@ function close(): void {
:open="open" :open="open"
:header="title" :header="title"
size="small" size="small"
stacked
data-test-id="credential-type-to-confirm-dialog" data-test-id="credential-type-to-confirm-dialog"
@update:open="emit('update:open', $event)" @update:open="emit('update:open', $event)"
> >
@@ -14,6 +14,10 @@ import { resolveToolItemIcon } from './toolItemIcon';
const props = defineProps<{ const props = defineProps<{
item: ToolConnectionItem; item: ToolConnectionItem;
showConnectAction?: boolean;
connectLabel?: string;
connectAriaLabel?: string;
connectedLabel?: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -78,15 +82,13 @@ const placeholderIcon = computed(() => {
const resolvedIcon = computed(() => resolveToolItemIcon(props.item)); const resolvedIcon = computed(() => resolveToolItemIcon(props.item));
const actionLabel = computed(() => const actionLabel = computed(() => {
props.item.communityPreview if (props.item.communityPreview) return i18n.baseText('communityNodeDetails.install');
? i18n.baseText('communityNodeDetails.install') if (props.item.status === 'disconnected') {
: i18n.baseText( return i18n.baseText('tools.connection.action.reconnect');
props.item.status === 'disconnected' }
? 'tools.connection.action.reconnect' return props.connectLabel ?? i18n.baseText('tools.connection.action.connect');
: 'tools.connection.action.connect', });
),
);
const installBlocked = computed( const installBlocked = computed(
() => Boolean(props.item.communityPreview) && Boolean(props.item.installDisabled), () => Boolean(props.item.communityPreview) && Boolean(props.item.installDisabled),
@@ -101,7 +103,10 @@ const isDisabled = computed(() => Boolean(props.item.disabled));
* its detail view. * its detail view.
*/ */
const hasDirectAction = computed( const hasDirectAction = computed(
() => Boolean(props.item.communityPreview) || props.item.kind === 'mcp-server', () =>
props.showConnectAction ||
Boolean(props.item.communityPreview) ||
props.item.kind === 'mcp-server',
); );
function handleRowClick() { function handleRowClick() {
@@ -224,7 +229,7 @@ function handleConnect() {
data-test-id="tools-connection-row-connected" data-test-id="tools-connection-row-connected"
> >
<N8nIcon icon="check" :size="14" :class="$style.statusIconConnected" aria-hidden="true" /> <N8nIcon icon="check" :size="14" :class="$style.statusIconConnected" aria-hidden="true" />
{{ i18n.baseText('tools.connection.action.connected') }} {{ connectedLabel ?? i18n.baseText('tools.connection.action.connected') }}
</span> </span>
<span <span
v-else-if="item.status === 'connecting'" v-else-if="item.status === 'connecting'"
@@ -255,6 +260,7 @@ function handleConnect() {
variant="outline" variant="outline"
size="small" size="small"
:loading="item.installing" :loading="item.installing"
:aria-label="connectAriaLabel"
:data-test-id=" :data-test-id="
item.communityPreview ? 'tools-connection-row-install' : 'tools-connection-row-connect' item.communityPreview ? 'tools-connection-row-install' : 'tools-connection-row-connect'
" "
@@ -26,6 +26,13 @@ import {
type ToolConnectionSettings, type ToolConnectionSettings,
} from './types'; } from './types';
type PickerCreateAction = {
category: ToolCategoryKey;
label: string;
description?: string;
testId?: string;
};
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
open?: boolean; open?: boolean;
@@ -39,16 +46,31 @@ const props = withDefaults(
hideBackButton?: boolean; hideBackButton?: boolean;
/** Dialog width. Consumers with more tabs (e.g. the n8n Connect section) can widen it. */ /** Dialog width. Consumers with more tabs (e.g. the n8n Connect section) can widen it. */
size?: DialogSize; size?: DialogSize;
allowWorkflowCreation?: boolean; createAction?: PickerCreateAction;
workflowCreationLoading?: boolean; createActionLoading?: boolean;
emptyMessage?: string;
noResultsMessage?: string;
/** Render only the modal body when an owning feature supplies the dialog shell. */
embedded?: boolean;
showConnectActions?: boolean;
/** Keep the list scrollbar visible instead of revealing it on hover. */
persistentScrollbar?: boolean;
connectLabel?: (item: ToolConnectionItem) => string;
connectAriaLabel?: (item: ToolConnectionItem) => string;
connectedLabel?: (item: ToolConnectionItem) => string;
}>(), }>(),
{ {
open: false, open: false,
detailItem: null, detailItem: null,
detailMode: 'detail', detailMode: 'detail',
size: 'xlarge', size: 'xlarge',
allowWorkflowCreation: false, createAction: undefined,
workflowCreationLoading: false, createActionLoading: false,
emptyMessage: undefined,
noResultsMessage: undefined,
embedded: false,
showConnectActions: false,
persistentScrollbar: false,
}, },
); );
@@ -64,11 +86,23 @@ const emit = defineEmits<{
'new-credential-connect': [item: ToolConnectionItem]; 'new-credential-connect': [item: ToolConnectionItem];
'open-detail': [item: ToolConnectionItem]; 'open-detail': [item: ToolConnectionItem];
connect: [item: ToolConnectionItem]; connect: [item: ToolConnectionItem];
'create-workflow': []; create: [];
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
const modalTitle = computed(() => props.title ?? i18n.baseText('tools.connection.title')); const modalTitle = computed(() => props.title ?? i18n.baseText('tools.connection.title'));
const containerComponent = computed(() => (props.embedded ? 'div' : N8nDialog));
const containerProps = computed(() =>
props.embedded
? {}
: {
open: props.open,
size: props.size,
header: props.detailItem ? '' : modalTitle.value,
showCloseButton: !props.detailItem,
'aria-label': modalTitle.value,
},
);
const searchPlaceholder = computed( const searchPlaceholder = computed(
() => props.searchPlaceholder ?? i18n.baseText('tools.connection.search.placeholder'), () => props.searchPlaceholder ?? i18n.baseText('tools.connection.search.placeholder'),
); );
@@ -124,11 +158,12 @@ onMounted(() => {
} }
}); });
const hasActiveSearch = computed(() => debouncedSearchQuery.value.length > 0); const normalizedSearchQuery = computed(() => debouncedSearchQuery.value.trim());
const hasActiveSearch = computed(() => normalizedSearchQuery.value.length > 0);
function matchesQuery(item: ToolConnectionItem): boolean { function matchesQuery(item: ToolConnectionItem): boolean {
if (!debouncedSearchQuery.value) return true; if (!normalizedSearchQuery.value) return true;
const query = debouncedSearchQuery.value.toLowerCase(); const query = normalizedSearchQuery.value.toLowerCase();
return ( return (
item.title.toLowerCase().includes(query) || item.title.toLowerCase().includes(query) ||
(item.description ?? '').toLowerCase().includes(query) (item.description ?? '').toLowerCase().includes(query)
@@ -260,14 +295,16 @@ watch(visibleCategories, (categories) => {
}); });
const isListEmpty = computed(() => toolRows.value.length === 0); const isListEmpty = computed(() => toolRows.value.length === 0);
const emptyMessage = computed(() => { const resolvedEmptyMessage = computed(() => {
if (hasActiveSearch.value) { if (hasActiveSearch.value) {
if (props.noResultsMessage) return props.noResultsMessage;
return i18n.baseText('tools.connection.empty.noResults', { return i18n.baseText('tools.connection.empty.noResults', {
interpolate: { query: debouncedSearchQuery.value }, interpolate: { query: normalizedSearchQuery.value },
}); });
} }
return i18n.baseText('tools.connection.empty.title'); return props.emptyMessage ?? i18n.baseText('tools.connection.empty.title');
}); });
const showCreateAction = computed(() => props.createAction?.category === activeCategory.value);
function openDetail(item: ToolConnectionItem) { function openDetail(item: ToolConnectionItem) {
emit('open-detail', item); emit('open-detail', item);
@@ -287,12 +324,10 @@ function handleOpenChange(value: boolean) {
</script> </script>
<template> <template>
<N8nDialog <component
:open="open" :is="containerComponent"
:size="size" v-bind="containerProps"
:header="detailItem ? '' : modalTitle" :class="props.embedded && $style.embedded"
:show-close-button="!detailItem"
:aria-label="modalTitle"
data-test-id="tools-connection-modal" data-test-id="tools-connection-modal"
@update:open="handleOpenChange" @update:open="handleOpenChange"
> >
@@ -361,27 +396,27 @@ function handleOpenChange(value: boolean) {
/> />
<button <button
v-if="activeCategory === 'workflows' && allowWorkflowCreation" v-if="showCreateAction && createAction"
type="button" type="button"
:class="$style.createWorkflowRow" :class="$style.createRow"
:disabled="workflowCreationLoading" :disabled="createActionLoading"
:aria-busy="workflowCreationLoading" :aria-busy="createActionLoading"
data-test-id="tools-connection-create-workflow" :data-test-id="createAction.testId ?? 'tools-connection-create'"
@click="emit('create-workflow')" @click="emit('create')"
> >
<span :class="$style.createWorkflowIcon" aria-hidden="true"> <span :class="$style.createIcon" aria-hidden="true">
<N8nIcon <N8nIcon
:icon="workflowCreationLoading ? 'loader-circle' : 'plus'" :icon="createActionLoading ? 'loader-circle' : 'plus'"
:size="20" :size="20"
:spin="workflowCreationLoading" :spin="createActionLoading"
/> />
</span> </span>
<span :class="$style.createWorkflowText"> <span :class="$style.createText">
<N8nText tag="span" bold> <N8nText tag="span" bold>
{{ i18n.baseText('generic.create.workflow') }} {{ createAction.label }}
</N8nText> </N8nText>
<N8nText tag="span" size="small" color="text-light"> <N8nText v-if="createAction.description" tag="span" size="small" color="text-light">
{{ i18n.baseText('projectRoles.workflow:create.tooltip') }} {{ createAction.description }}
</N8nText> </N8nText>
</span> </span>
</button> </button>
@@ -389,7 +424,7 @@ function handleOpenChange(value: boolean) {
<div :class="$style.listWrapper"> <div :class="$style.listWrapper">
<template v-if="isListEmpty"> <template v-if="isListEmpty">
<div :class="$style.empty" data-test-id="tools-connection-empty"> <div :class="$style.empty" data-test-id="tools-connection-empty">
<N8nText color="text-light">{{ emptyMessage }}</N8nText> <N8nText color="text-light">{{ resolvedEmptyMessage }}</N8nText>
</div> </div>
<div v-if="isMcpCategory" :class="$style.suggestionRow"> <div v-if="isMcpCategory" :class="$style.suggestionRow">
<slot name="suggestion-footer" /> <slot name="suggestion-footer" />
@@ -401,12 +436,16 @@ function handleOpenChange(value: boolean) {
:items="flattenedRows" :items="flattenedRows"
:item-size="ITEM_HEIGHT" :item-size="ITEM_HEIGHT"
item-key="key" item-key="key"
:class="$style.scroller" :class="[$style.scroller, persistentScrollbar && $style.persistentScrollbar]"
> >
<template #default="{ item: row }"> <template #default="{ item: row }">
<ToolRow <ToolRow
v-if="'item' in row" v-if="'item' in row"
:item="row.item" :item="row.item"
:show-connect-action="props.showConnectActions"
:connect-label="props.connectLabel?.(row.item)"
:connect-aria-label="props.connectAriaLabel?.(row.item)"
:connected-label="props.connectedLabel?.(row.item)"
@open-detail="openDetail($event)" @open-detail="openDetail($event)"
@connect="emit('connect', $event)" @connect="emit('connect', $event)"
@select-credential=" @select-credential="
@@ -425,18 +464,35 @@ function handleOpenChange(value: boolean) {
</div> </div>
</template> </template>
</div> </div>
</N8nDialog> </component>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
@use '@n8n/design-system/css/mixins/mixins' as scrollbar-mixins;
.body { .body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 70vh; height: 70vh;
max-height: 640px; max-height: calc(var(--height--5xl) * 6);
min-height: 0; min-height: 0;
} }
.embedded {
height: 100%;
min-height: 0;
.body {
height: min(60dvh, calc(var(--height--5xl) * 5));
max-height: 100%;
}
.searchInput {
margin-top: 0;
margin-bottom: var(--spacing--lg);
}
}
.searchInput { .searchInput {
width: 100%; width: 100%;
flex-shrink: 0; flex-shrink: 0;
@@ -451,7 +507,7 @@ function handleOpenChange(value: boolean) {
flex-shrink: 0; flex-shrink: 0;
} }
.createWorkflowRow { .createRow {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing--xs); gap: var(--spacing--xs);
@@ -480,7 +536,7 @@ function handleOpenChange(value: boolean) {
} }
} }
.createWorkflowIcon { .createIcon {
flex-shrink: 0; flex-shrink: 0;
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -490,7 +546,7 @@ function handleOpenChange(value: boolean) {
color: var(--color--primary); color: var(--color--primary);
} }
.createWorkflowText { .createText {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing--5xs); gap: var(--spacing--5xs);
@@ -511,6 +567,10 @@ function handleOpenChange(value: boolean) {
overflow-y: auto; overflow-y: auto;
} }
.persistentScrollbar {
@include scrollbar-mixins.scroll-bar;
}
.empty { .empty {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -91,7 +91,11 @@ function renderWith(
categories: ToolCategoryKey[]; categories: ToolCategoryKey[];
detailItem: ToolConnectionItem | null; detailItem: ToolConnectionItem | null;
detailMode: 'detail' | 'settings'; detailMode: 'detail' | 'settings';
allowWorkflowCreation: boolean; createAction: {
category: ToolCategoryKey;
label: string;
testId?: string;
};
}>, }>,
) { ) {
return renderModal({ return renderModal({
@@ -101,7 +105,7 @@ function renderWith(
categories: props.categories ?? ALL_CATEGORIES, categories: props.categories ?? ALL_CATEGORIES,
detailItem: props.detailItem ?? null, detailItem: props.detailItem ?? null,
detailMode: props.detailMode, detailMode: props.detailMode,
allowWorkflowCreation: props.allowWorkflowCreation, createAction: props.createAction,
}, },
slots: { slots: {
'suggestion-footer': '<div data-test-id="suggest-tool-footer">Suggest a tool</div>', 'suggestion-footer': '<div data-test-id="suggest-tool-footer">Suggest a tool</div>',
@@ -258,7 +262,11 @@ describe('ToolsConnectionModal', () => {
const { emitted, getByTestId, queryByTestId } = renderWith({ const { emitted, getByTestId, queryByTestId } = renderWith({
items: [], items: [],
categories: ['mcp', 'workflows'], categories: ['mcp', 'workflows'],
allowWorkflowCreation: true, createAction: {
category: 'workflows',
label: 'Create workflow',
testId: 'tools-connection-create-workflow',
},
}); });
expect(queryByTestId('tools-connection-create-workflow')).toBeNull(); expect(queryByTestId('tools-connection-create-workflow')).toBeNull();
@@ -267,7 +275,7 @@ describe('ToolsConnectionModal', () => {
expect(getByTestId('tools-connection-empty')).toBeTruthy(); expect(getByTestId('tools-connection-empty')).toBeTruthy();
await fireEvent.click(getByTestId('tools-connection-create-workflow')); await fireEvent.click(getByTestId('tools-connection-create-workflow'));
expect(emitted()['create-workflow']).toEqual([[]]); expect(emitted().create).toEqual([[]]);
}); });
it('renders the detail view when a detailItem is set', () => { it('renders the detail view when a detailItem is set', () => {
@@ -503,6 +511,17 @@ describe('ToolsConnectionModal', () => {
}); });
}); });
it('ignores surrounding whitespace in a search query', async () => {
const { getByPlaceholderText, queryByText } = renderWith({ categories: ['mcp'] });
await fireEvent.update(getByPlaceholderText('Search all tools...'), ' github ');
await waitFor(() => {
expect(queryByText('GitHub')).toBeTruthy();
expect(queryByText('Gmail')).toBeNull();
});
});
it('feeds every flattened row through to the scroller', async () => { it('feeds every flattened row through to the scroller', async () => {
const items = makeLargeMcpList(300); const items = makeLargeMcpList(300);
const { getAllByTestId } = renderWith({ items, categories: ['mcp'] }); const { getAllByTestId } = renderWith({ items, categories: ['mcp'] });
@@ -127,9 +127,8 @@ export type ToolConnectionItem =
| ServiceConnectionItem; | ServiceConnectionItem;
/** /**
* One tab in the modal. Consumers declare the subset they support; `agents` and * One tab in the modal. Consumers declare the subset they support. `data` has
* `data` have no supplier yet and are reserved for folding the sub-agent and * no supplier yet and is reserved for folding the vector-store picker in later.
* vector-store pickers in later.
*/ */
export type ToolCategoryKey = export type ToolCategoryKey =
| 'all' | 'all'
@@ -19,7 +19,8 @@ export class CredentialModal extends BaseModal {
} }
static fromPage(page: Page): CredentialModal { static fromPage(page: Page): CredentialModal {
return new CredentialModal(page.getByTestId('editCredential-modal')); const content = page.getByTestId('editCredential-modal');
return new CredentialModal(page.getByRole('dialog').filter({ has: content }));
} }
/** /**
@@ -33,7 +34,13 @@ export class CredentialModal extends BaseModal {
} }
getModal(): Locator { getModal(): Locator {
return this.root; return this.root.getByTestId('editCredential-modal');
}
async close(): Promise<void> {
const closeButton = this.getCloseButton();
if (!(await closeButton.isVisible())) return;
await closeButton.click();
} }
getCredentialName(): Locator { getCredentialName(): Locator {