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) {