From 1d60318c280a88a2808ffece5f64dea49d7fc0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Tue, 19 May 2026 14:58:38 +0200 Subject: [PATCH] feat(editor): Add new AI chat to universal create dropdown (#30719) --- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../useGlobalEntityCreation.test.ts | 121 ++++++++++++++++++ .../composables/useGlobalEntityCreation.ts | 24 ++++ .../components/InstanceAiThreadList.vue | 26 ++-- 4 files changed, 158 insertions(+), 14 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 76435520cda..9af7ae592dc 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -4758,6 +4758,7 @@ "projects.menu.create.credential": "New credential", "projects.menu.create.agent": "New agent", "projects.menu.create.project": "New project", + "projects.menu.create.instanceAiThread": "New AI chat", "projects.settings": "Project settings", "projects.settings.info": "Project info", "projects.settings.newProjectName": "My project", diff --git a/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.test.ts b/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.test.ts index 3e743685b1d..72ec9e0eaef 100644 --- a/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.test.ts @@ -13,10 +13,16 @@ import type { CloudPlanState } from '@/Interface'; import { VIEWS } from '@/app/constants'; import { NEW_AGENT_VIEW, AGENTS_MODULE_NAME } from '@/features/agents/constants'; +import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants'; +import { hasPermission } from '@/app/utils/rbac/permissions'; import type { Project, ProjectListItem } from '@/features/collaboration/projects/projects.types'; import { useGlobalEntityCreation } from './useGlobalEntityCreation'; +vi.mock('@/app/utils/rbac/permissions', () => ({ + hasPermission: vi.fn().mockReturnValue(false), +})); + vi.mock('@/app/composables/usePageRedirectionHelper', () => { const goToUpgrade = vi.fn(); return { @@ -54,6 +60,7 @@ vi.mock('vue-router', async (importOriginal) => { beforeEach(() => { setActivePinia(createTestingPinia()); routerPushMock.mockReset(); + vi.mocked(hasPermission).mockReturnValue(false); }); describe('useGlobalEntityCreation', () => { @@ -402,4 +409,118 @@ describe('useGlobalEntityCreation', () => { expect(agentEntry?.submenu).toBeUndefined(); }); }); + + describe('instance-ai module', () => { + const INSTANCE_AI_SETTINGS = { + enabled: true, + localGatewayDisabled: false, + proxyEnabled: false, + cloudManaged: false, + }; + + const enableInstanceAi = () => { + const settingsStore = mockedStore(useSettingsStore); + settingsStore.isModuleActive.mockImplementation((name: string) => name === 'instance-ai'); + settingsStore.moduleSettings = { 'instance-ai': { ...INSTANCE_AI_SETTINGS } }; + vi.mocked(hasPermission).mockReturnValue(true); + return settingsStore; + }; + + it('omits the instance-ai entry when the module is inactive', () => { + const projectsStore = mockedStore(useProjectsStore); + projectsStore.isTeamProjectFeatureEnabled = false; + projectsStore.personalProject = { id: 'personal-project' } as Project; + + const { menu } = useGlobalEntityCreation(); + + expect(menu.value.find((item) => item.id === 'instance-ai-thread')).toBeUndefined(); + }); + + it('omits the instance-ai entry when the module is active but disabled in settings', () => { + const settingsStore = mockedStore(useSettingsStore); + settingsStore.isModuleActive.mockImplementation((name: string) => name === 'instance-ai'); + settingsStore.moduleSettings = { + 'instance-ai': { ...INSTANCE_AI_SETTINGS, enabled: false }, + }; + vi.mocked(hasPermission).mockReturnValue(true); + + const projectsStore = mockedStore(useProjectsStore); + projectsStore.isTeamProjectFeatureEnabled = false; + projectsStore.personalProject = { id: 'personal-project' } as Project; + + const { menu } = useGlobalEntityCreation(); + + expect(menu.value.find((item) => item.id === 'instance-ai-thread')).toBeUndefined(); + }); + + it('omits the instance-ai entry when the user lacks the instanceAi:message scope', () => { + const settingsStore = mockedStore(useSettingsStore); + settingsStore.isModuleActive.mockImplementation((name: string) => name === 'instance-ai'); + settingsStore.moduleSettings = { 'instance-ai': { ...INSTANCE_AI_SETTINGS } }; + vi.mocked(hasPermission).mockReturnValue(false); + + const projectsStore = mockedStore(useProjectsStore); + projectsStore.isTeamProjectFeatureEnabled = false; + projectsStore.personalProject = { id: 'personal-project' } as Project; + + const { menu } = useGlobalEntityCreation(); + + expect(menu.value.find((item) => item.id === 'instance-ai-thread')).toBeUndefined(); + }); + + it('appends the instance-ai entry as the last item in the community shape', () => { + enableInstanceAi(); + + const projectsStore = mockedStore(useProjectsStore); + projectsStore.isTeamProjectFeatureEnabled = false; + projectsStore.personalProject = { id: 'personal-project' } as Project; + + const { menu } = useGlobalEntityCreation(); + + expect(menu.value.at(-1)).toStrictEqual( + expect.objectContaining({ + id: 'instance-ai-thread', + route: { name: INSTANCE_AI_VIEW }, + }), + ); + }); + + it('appends the instance-ai entry as the last item when team feature is enabled but no team projects exist', () => { + enableInstanceAi(); + + const projectsStore = mockedStore(useProjectsStore); + projectsStore.teamProjectsLimit = -1; + projectsStore.isTeamProjectFeatureEnabled = true; + projectsStore.personalProject = { id: 'personal-project' } as Project; + projectsStore.myProjects = []; + + const { menu } = useGlobalEntityCreation(); + + expect(menu.value.at(-1)).toStrictEqual( + expect.objectContaining({ + id: 'instance-ai-thread', + route: { name: INSTANCE_AI_VIEW }, + }), + ); + }); + + it('appends the instance-ai entry as the last item in the global shape', () => { + enableInstanceAi(); + + const projectsStore = mockedStore(useProjectsStore); + projectsStore.teamProjectsLimit = -1; + projectsStore.isTeamProjectFeatureEnabled = true; + projectsStore.personalProject = { id: 'personal-project' } as Project; + projectsStore.myProjects = [{ id: '1', name: '1', type: 'team' }] as ProjectListItem[]; + + const { menu } = useGlobalEntityCreation(); + + expect(menu.value.at(-1)).toStrictEqual( + expect.objectContaining({ + id: 'instance-ai-thread', + route: { name: INSTANCE_AI_VIEW }, + }), + ); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.ts b/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.ts index 0aa1105d00b..c450da234b9 100644 --- a/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.ts +++ b/packages/frontend/editor-ui/src/app/composables/useGlobalEntityCreation.ts @@ -1,6 +1,7 @@ import { computed, ref } from 'vue'; import { VIEWS } from '@/app/constants'; import { AGENTS_MODULE_NAME, NEW_AGENT_VIEW } from '@/features/agents/constants'; +import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants'; import { useRouter } from 'vue-router'; import { useI18n } from '@n8n/i18n'; import { sortByProperty } from '@n8n/utils/sort/sortByProperty'; @@ -11,6 +12,7 @@ import { useCloudPlanStore } from '@/app/stores/cloudPlan.store'; import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store'; import { getResourcePermissions } from '@n8n/permissions'; import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper'; +import { hasPermission } from '@/app/utils/rbac/permissions'; import type { Scope } from '@n8n/permissions'; import type { RouteLocationRaw } from 'vue-router'; import { updatedIconSet, type IconName } from '@n8n/design-system/components/N8nIcon/icons'; @@ -46,6 +48,7 @@ export const useGlobalEntityCreation = () => { const WORKFLOWS_MENU_ID = 'workflow'; const CREDENTIALS_MENU_ID = 'credential'; const AGENTS_MENU_ID = 'agent'; + const INSTANCE_AI_THREAD_MENU_ID = 'instance-ai-thread'; const DEFAULT_ICON: IconName = 'layers'; const settingsStore = useSettingsStore(); @@ -79,11 +82,29 @@ export const useGlobalEntityCreation = () => { const isAgentsModuleActive = computed(() => settingsStore.isModuleActive(AGENTS_MODULE_NAME)); + const isInstanceAiAvailable = computed( + () => + settingsStore.isModuleActive('instance-ai') && + settingsStore.moduleSettings['instance-ai']?.enabled !== false && + hasPermission(['rbac'], { rbac: { scope: 'instanceAi:message' } }), + ); + + const instanceAiThreadItem = computed(() => + isInstanceAiAvailable.value + ? { + id: INSTANCE_AI_THREAD_MENU_ID, + title: i18n.baseText('projects.menu.create.instanceAiThread'), + route: { name: INSTANCE_AI_VIEW }, + } + : null, + ); + const menu = computed(() => { const workflowTitle = i18n.baseText('projects.menu.create.workflow'); const credentialTitle = i18n.baseText('projects.menu.create.credential'); const agentTitle = i18n.baseText('projects.menu.create.agent'); const projectTitle = i18n.baseText('projects.menu.create.project'); + const instanceAiTrailing = instanceAiThreadItem.value ? [instanceAiThreadItem.value] : []; // Community if (!projectsStore.isTeamProjectFeatureEnabled) { @@ -126,6 +147,7 @@ export const useGlobalEntityCreation = () => { title: projectTitle, disabled: true, }, + ...instanceAiTrailing, ]; } @@ -173,6 +195,7 @@ export const useGlobalEntityCreation = () => { disabled: !projectsStore.canCreateProjects || !projectsStore.hasPermissionToCreateProjects, }, + ...instanceAiTrailing, ] satisfies Item[]; } @@ -290,6 +313,7 @@ export const useGlobalEntityCreation = () => { title: projectTitle, disabled: !projectsStore.canCreateProjects || !projectsStore.hasPermissionToCreateProjects, }, + ...instanceAiTrailing, ] satisfies Item[]; }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiThreadList.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiThreadList.vue index 587e8d18f16..cbdf1b218ed 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiThreadList.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiThreadList.vue @@ -72,11 +72,6 @@ const groupedThreads = computed(() => { }); }); -function handleNewThread() { - if (!activeThreadId.value) return; - void router.push({ name: INSTANCE_AI_VIEW }); -} - async function handleDeleteThread(threadId: string) { const wasActive = threadId === activeThreadId.value; const deleted = await store.deleteThread(threadId); @@ -154,15 +149,18 @@ function handleThreadAction(action: string, threadId: string) { placement="bottom" :show-after="TOOLTIP_DELAY_MS" > - + + +