From fe524fd52001de1257de95b7413cb1469b478b58 Mon Sep 17 00:00:00 2001
From: Bob Hough <15361982+heymynameisrob@users.noreply.github.com>
Date: Thu, 15 Oct 2026 09:40:56 +0000
Subject: [PATCH] feat(editor): Add search to the Agent Builder switcher
Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
---
.../frontend/@n8n/i18n/src/locales/en.json | 2 +
.../__tests__/AgentBuilderHeader.test.ts | 71 +++++++++++++++++--
.../agents/components/AgentBuilderHeader.vue | 10 ++-
3 files changed, 77 insertions(+), 6 deletions(-)
diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json
index 704b610a44a..4b0bf4b3b03 100644
--- a/packages/frontend/@n8n/i18n/src/locales/en.json
+++ b/packages/frontend/@n8n/i18n/src/locales/en.json
@@ -8985,6 +8985,8 @@
"agents.builder.header.tab.raw": "Raw",
"agents.builder.header.workspaceCrumb": "Workspace",
"agents.builder.header.projectFallback": "Project",
+ "agents.builder.header.switcher.searchPlaceholder": "Search agents",
+ "agents.builder.header.switcher.noMatch": "No matching agents",
"agents.builder.header.switcher.empty": "No other agents",
"agents.builder.header.switcher.newAgent": "New agent",
"agents.builder.header.switcher.ariaLabel": "Switch agent",
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts
index 7a7f8e5801c..8b175a8de9d 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts
@@ -38,7 +38,9 @@ vi.mock('vue-router', () => ({
RouterLink: { template: '' },
}));
-vi.mock('@n8n/design-system', () => ({
+vi.mock('@n8n/design-system', async (importOriginal) => ({
+ useDropdownSearch: (await importOriginal())
+ .useDropdownSearch,
N8nAssistantIcon: {
name: 'N8nAssistantIcon',
template: '',
@@ -80,13 +82,27 @@ vi.mock('@n8n/design-system', () => ({
N8nDropdownMenu: {
name: 'N8nDropdownMenu',
template: '
',
- props: ['items', 'placement', 'extraPopperClass'],
- emits: ['select'],
+ props: {
+ items: Array,
+ placement: String,
+ extraPopperClass: String,
+ searchable: Boolean,
+ searchPlaceholder: String,
+ emptyText: String,
+ },
+ emits: ['select', 'search'],
},
'n8n-dropdown-menu': {
name: 'N8nDropdownMenu',
template: '
',
- props: ['items', 'placement', 'extraPopperClass'],
+ props: {
+ items: Array,
+ placement: String,
+ extraPopperClass: String,
+ searchable: Boolean,
+ searchPlaceholder: String,
+ emptyText: String,
+ },
emits: ['select'],
},
N8nActionDropdown: {
@@ -97,13 +113,17 @@ vi.mock('@n8n/design-system', () => ({
},
}));
+vi.mock('../components/AgentPublishButton.vue', () => ({
+ default: { name: 'AgentPublishButton', template: '' },
+}));
+
import AgentBuilderHeader from '../components/AgentBuilderHeader.vue';
type DropdownStubWrapper = VueWrapper<{
items: Array<{ id: string; label?: string; disabled?: boolean }>;
extraPopperClass?: string;
$options: unknown;
- $emit: (event: 'select', value: string) => void;
+ $emit: (event: 'select' | 'search', value: string) => void;
}>;
function getDropdown(wrapper: ReturnType, testId: string) {
@@ -293,6 +313,47 @@ describe('AgentBuilderHeader', () => {
expect(options.map((option) => option.id)).toEqual(['a2']);
});
+ it('filters the switcher by agent name and restores the list when search clears', async () => {
+ agentsListRef.value = [
+ baseAgent,
+ { ...baseAgent, id: 'a2', name: 'Researcher' },
+ { ...baseAgent, id: 'a3', name: 'News brief' },
+ ];
+ const wrapper = mountHeader();
+ const switcher = getDropdown(wrapper, 'agent-header-switcher');
+
+ expect(switcher.props()).toMatchObject({
+ searchable: true,
+ searchPlaceholder: 'agents.builder.header.switcher.searchPlaceholder',
+ emptyText: 'agents.builder.header.switcher.noMatch',
+ });
+ switcher.vm.$emit('search', ' RESEARCH ');
+ await flushPromises();
+ expect(getSwitcherOptions(wrapper).map((item) => item.id)).toEqual(['a2']);
+
+ switcher.vm.$emit('select', 'a2');
+ expect(wrapper.emitted('switch-agent')).toEqual([['a2']]);
+
+ switcher.vm.$emit('search', 'Darwin');
+ await flushPromises();
+ expect(getSwitcherOptions(wrapper)).toEqual([]);
+ expect(wrapper.find('[data-testid="agent-header-new-agent"]').exists()).toBe(true);
+
+ switcher.vm.$emit('search', '');
+ await flushPromises();
+ expect(getSwitcherOptions(wrapper).map((item) => item.id)).toEqual(['a2', 'a3']);
+ });
+
+ it('excludes the empty placeholder from search results', async () => {
+ agentsListRef.value = [baseAgent];
+ const wrapper = mountHeader();
+
+ getDropdown(wrapper, 'agent-header-switcher').vm.$emit('search', 'switcher.empty');
+ await flushPromises();
+
+ expect(getSwitcherOptions(wrapper)).toEqual([]);
+ });
+
it('shows a disabled "No other agents" entry when the project has only this agent', async () => {
agentsListRef.value = [baseAgent];
ensureLoadedMock.mockResolvedValue(agentsListRef.value);
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue
index c694a3ef4ee..403b0d8c5ef 100644
--- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue
+++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue
@@ -15,6 +15,7 @@ import {
N8nDropdownMenu,
N8nDropdownMenuItem,
N8nIcon,
+ useDropdownSearch,
} from '@n8n/design-system';
import type { IconOrEmoji, PathItem } from '@n8n/design-system';
import type { DropdownMenuItemProps } from '@n8n/design-system';
@@ -115,6 +116,9 @@ const switcherOptions = computed>>(() => {
}));
});
+const { filteredItems: filteredSwitcherOptions, handleSearch: handleSwitcherSearch } =
+ useDropdownSearch(switcherOptions, { isSearchable: (item) => !item.disabled });
+
function onSwitcherSelect(id: string) {
if (id === '__empty__') return;
emit('switch-agent', id);
@@ -173,9 +177,13 @@ function onMenuSelect(id: string) {
/