refactor(editor): Add 'Build an Agent' to empty state (#30078)

This commit is contained in:
Rob Hough 2026-05-18 16:31:38 +01:00 committed by GitHub
parent 0d262fe638
commit 341abecbfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 262 additions and 40 deletions

View File

@ -136,6 +136,27 @@ describe('N8nNavigationDropdown', () => {
expect(getByText('first')).toBeVisible();
});
it('should render agent immediately after workflow', async () => {
const { getByTestId, getAllByTestId } = render(NavigationDropdown, {
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
props: {
menu: [
{ id: 'workflow', title: 'New workflow' },
{ id: 'credential', title: 'New credential' },
{ id: 'agent', title: 'New agent' },
],
},
});
await userEvent.click(getByTestId('test-trigger'));
expect(getAllByTestId('navigation-menu-item').map((item) => item.textContent?.trim())).toEqual([
'New workflow',
'New agent',
'New credential',
]);
});
it('should toggle nested level on mouseenter / mouseleave', async () => {
const { getByTestId, getByText } = render(NavigationDropdown, {
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ElMenu, ElSubMenu, ElMenuItem, type MenuItemRegistered } from 'element-plus';
import { defineComponent, ref, useSlots } from 'vue';
import { computed, defineComponent, ref, useSlots } from 'vue';
import type { RouteLocationRaw } from 'vue-router';
import type { IconSize } from '@n8n/design-system/types';
@ -31,7 +31,7 @@ defineOptions({
name: 'N8nNavigationDropdown',
});
defineProps<{
const props = defineProps<{
menu: Array<Item | Divider>;
disabled?: boolean;
teleport?: boolean;
@ -55,6 +55,22 @@ const emit = defineEmits<{
const slots = useSlots();
const hasAppendSlot = (id: string) => Boolean(slots[`item.append.${id}`]);
const orderedMenu = computed(() => {
const workflowIndex = props.menu.findIndex((item) => !item.isDivider && item.id === 'workflow');
const agentIndex = props.menu.findIndex((item) => !item.isDivider && item.id === 'agent');
if (workflowIndex === -1 || agentIndex === -1 || agentIndex === workflowIndex + 1) {
return props.menu;
}
const ordered = [...props.menu];
const [agentItem] = ordered.splice(agentIndex, 1);
const nextWorkflowIndex = ordered.findIndex((item) => !item.isDivider && item.id === 'workflow');
ordered.splice(nextWorkflowIndex + 1, 0, agentItem);
return ordered;
});
defineSlots<{
default?: () => unknown;
'item-icon'?: (props: { item: BaseItem }) => unknown;
@ -111,7 +127,7 @@ defineExpose({
<slot />
</template>
<template v-for="item in menu" :key="item.id">
<template v-for="item in orderedMenu" :key="item.id">
<hr v-if="item.isDivider" />
<template v-else-if="item.submenu">
<ElSubMenu

View File

@ -4090,11 +4090,11 @@
"workflows.noResults.withSearch.switchToShared.link": "hidden",
"workflows.empty.heading": "Welcome, {name}!",
"workflows.empty.heading.userNotSetup": "Welcome!",
"workflows.empty.headingWithIcon": "👋 Welcome, {name}!",
"workflows.empty.headingWithIcon.userNotSetup": "👋 Welcome!",
"workflows.empty.headingWithIcon": "What do you want to build, {name}",
"workflows.empty.headingWithIcon.userNotSetup": "What do you want to build?",
"workflows.empty.heading.builder": "Hi {name}, what do you want to automate?",
"workflows.empty.heading.builder.userNotSetup": "Hi, what do you want to automate?",
"workflows.empty.description": "Create your first workflow",
"workflows.empty.description": "What do you want to build?",
"workflows.empty.description.readOnlyEnv": "No workflows here yet",
"workflows.empty.description.noPermission": "There are currently no workflows to view",
"workflows.empty.startFromScratch": "Start from scratch",
@ -4102,6 +4102,8 @@
"workflows.empty.browseTemplates": "Explore workflow templates",
"workflows.empty.learnN8n": "Learn n8n",
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
"workflows.empty.buildAgent": "Build an agent",
"workflows.empty.buildWorkflow": "Build a workflow",
"workflows.empty.easyAI": "Test a simple AI Agent example",
"workflows.empty.tryAiWorkflow": "Run live demo",
"workflows.empty.shared-with-me": "No {resource} has been shared with you",

View File

@ -8,6 +8,7 @@ import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/
import { useRecommendedTemplatesStore } from '@/features/workflows/templates/recommendations/recommendedTemplates.store';
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
import { useBannersStore } from '@/features/shared/banners/banners.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import userEvent from '@testing-library/user-event';
import type { IUser } from '@n8n/rest-api-client/api/users';
@ -15,6 +16,7 @@ const surfaceMcpEmptyState = vi.hoisted(() => ({
showTile: false,
showReminder: false,
}));
const trackClickedNewAgent = vi.hoisted(() => vi.fn());
vi.mock('vue-router', () => ({
useRouter: () => ({
@ -39,6 +41,12 @@ vi.mock('@/experiments/surfaceMcpToNewCloudUsers/composables/useSurfaceMcpEmptyS
};
});
vi.mock('@/features/agents/composables/useAgentTelemetry', () => ({
useAgentTelemetry: () => ({
trackClickedNewAgent,
}),
}));
const renderComponent = createComponentRenderer(EmptyStateLayout, {
pinia: createTestingPinia(),
global: {
@ -82,6 +90,7 @@ describe('EmptyStateLayout', () => {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
globalScopes: ['agent:create'],
isDefaultUser: false,
isPendingUser: false,
mfaEnabled: false,
@ -101,6 +110,9 @@ describe('EmptyStateLayout', () => {
bannersStore.bannersHeight = 0;
readyToRunStore.userCanClaimOpenAiCredits = false;
vi.spyOn(useSettingsStore(), 'isModuleActive').mockImplementation((moduleName) => {
return moduleName === 'agents';
});
surfaceMcpEmptyState.showTile = false;
surfaceMcpEmptyState.showReminder = false;
@ -229,6 +241,14 @@ describe('EmptyStateLayout', () => {
expect(emitted('click:add')).toHaveLength(1);
});
it('should track New Agent card clicks', async () => {
const { getByTestId } = renderComponent();
await userEvent.click(getByTestId('build-agent-card'));
expect(trackClickedNewAgent).toHaveBeenCalledWith('card');
});
});
describe('when in read-only environment', () => {

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { N8nButton, N8nCard, N8nHeading, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useBannersStore } from '@/features/shared/banners/banners.store';
@ -15,6 +15,10 @@ import RecommendedTemplatesSection from '@/features/workflows/templates/recommen
import ReadyToRunButton from '@/features/workflows/readyToRun/components/ReadyToRunButton.vue';
import EmptyStateBuilderPrompt from '@/experiments/emptyStateBuilderPrompt/components/EmptyStateBuilderPrompt.vue';
import AppSelectionPage from '@/experiments/credentialsAppSelection/components/AppSelectionPage.vue';
import { useSettingsStore } from '@/app/stores/settings.store';
import { NEW_AGENT_VIEW } from '@/features/agents/constants';
import { useAgentTelemetry } from '@/features/agents/composables/useAgentTelemetry';
import { useAgentPermissions } from '@/features/agents/composables/useAgentPermissions';
import SurfaceMcpEmptyStateReminder from '@/experiments/surfaceMcpToNewCloudUsers/components/SurfaceMcpEmptyStateReminder.vue';
import SurfaceMcpEmptyStateTile from '@/experiments/surfaceMcpToNewCloudUsers/components/SurfaceMcpEmptyStateTile.vue';
@ -24,12 +28,15 @@ const emit = defineEmits<{
const i18n = useI18n();
const route = useRoute();
const router = useRouter();
const bannersStore = useBannersStore();
const projectsStore = useProjectsStore();
const projectPages = useProjectPages();
const emptyStateBuilderPromptStore = useEmptyStateBuilderPromptStore();
const credentialsAppSelectionStore = useCredentialsAppSelectionStore();
const readyToRunStore = useReadyToRunStore();
const settingsStore = useSettingsStore();
const agentTelemetry = useAgentTelemetry();
const {
showAppSelection,
@ -57,6 +64,17 @@ const showReadyToRunCard = computed(() => {
return readyToRunStore.userCanClaimOpenAiCredits && canCreateWorkflow.value && !showMcpTile.value;
});
const builderProjectId = computed(() =>
projectPages.isOverviewSubPage
? projectsStore.personalProject?.id
: (route.params.projectId as string),
);
const { canCreate } = useAgentPermissions(builderProjectId);
const showBuildAgentCard = computed(() => {
return settingsStore.isModuleActive('agents') && canCreate.value;
});
const handleReadyToRunClick = async () => {
try {
await readyToRunStore.claimCreditsAndOpenWorkflow(
@ -69,16 +87,20 @@ const handleReadyToRunClick = async () => {
}
};
const handleBuildAgentClick = () => {
agentTelemetry.trackClickedNewAgent('card');
void router.push({
name: NEW_AGENT_VIEW,
query: {
projectId: builderProjectId.value,
},
});
};
const containerStyle = computed(() => ({
minHeight: `calc(100vh - ${bannersStore.bannersHeight}px)`,
}));
const builderProjectId = computed(() =>
projectPages.isOverviewSubPage
? projectsStore.personalProject?.id
: (route.params.projectId as string),
);
const builderParentFolderId = computed(() => route.params.folderId as string | undefined);
const handleBuilderPromptSubmit = async (prompt: string) => {
@ -123,8 +145,10 @@ const handleAppSelectionContinue = () => {
data-test-id="empty-state-builder-prompt"
:project-id="builderProjectId"
:parent-folder-id="builderParentFolderId"
:show-build-agent-button="showBuildAgentCard"
@submit="handleBuilderPromptSubmit"
@start-from-scratch="addWorkflow"
@build-agent="handleBuildAgentClick"
/>
</template>
@ -146,13 +170,23 @@ const handleAppSelectionContinue = () => {
<div :class="$style.actionButtons">
<ReadyToRunButton type="secondary" size="large" />
<N8nButton
v-if="showBuildAgentCard"
variant="subtle"
icon="file"
icon="robot"
size="large"
data-test-id="build-agent-button"
@click="handleBuildAgentClick"
>
{{ i18n.baseText('workflows.empty.buildAgent') }}
</N8nButton>
<N8nButton
variant="subtle"
icon="workflow"
size="large"
data-test-id="start-from-scratch-button"
@click="addWorkflow"
>
{{ i18n.baseText('workflows.empty.startFromScratch') }}
{{ i18n.baseText('workflows.empty.buildWorkflow') }}
</N8nButton>
</div>
</div>
@ -160,22 +194,33 @@ const handleAppSelectionContinue = () => {
<!-- State 3: Fallback (Baseline) -->
<template v-else>
<N8nHeading tag="h1" size="2xlarge" bold :class="$style.welcomeTitle">
<N8nHeading
tag="h1"
size="2xlarge"
bold
:class="[$style.welcomeTitle, $style.fallbackHeading]"
>
{{ emptyStateHeading }}
</N8nHeading>
<div :class="$style.fallbackContent">
<N8nText tag="p" size="large" color="text-base">
<N8nText
v-if="!canCreateWorkflow"
tag="p"
size="large"
color="text-base"
:class="$style.fallbackDescription"
>
{{ emptyStateDescription }}
</N8nText>
<SurfaceMcpEmptyStateReminder v-if="showMcpReminder" />
<!-- Two cards or single card depending on ready-to-run availability -->
<!-- Cards vary based on enabled modules and ready-to-run availability -->
<div
v-if="canCreateWorkflow"
:class="[
$style.actionCardsContainer,
{
[$style.singleCard]: !showReadyToRunCard && !showMcpTile,
[$style.singleCard]: !showReadyToRunCard && !showMcpTile && !showBuildAgentCard,
},
]"
>
@ -202,7 +247,28 @@ const handleAppSelectionContinue = () => {
</div>
</N8nCard>
<!-- Card 2: Start from scratch (always shown) -->
<!-- Card 2: Build an agent (conditional) -->
<N8nCard
v-if="showBuildAgentCard"
:class="$style.actionCard"
hoverable
data-test-id="build-agent-card"
@click="handleBuildAgentClick"
>
<div :class="$style.cardContent">
<N8nIcon
:class="$style.cardIcon"
icon="robot"
color="foreground-dark"
:stroke-width="1.5"
/>
<N8nText size="large" class="mt-xs">
{{ i18n.baseText('workflows.empty.buildAgent') }}
</N8nText>
</div>
</N8nCard>
<!-- Card 3: Start from scratch (always shown) -->
<N8nCard
:class="$style.actionCard"
hoverable
@ -212,12 +278,12 @@ const handleAppSelectionContinue = () => {
<div :class="$style.cardContent">
<N8nIcon
:class="$style.cardIcon"
icon="file"
icon="workflow"
color="foreground-dark"
:stroke-width="1.5"
/>
<N8nText size="large" class="mt-xs">
{{ i18n.baseText('workflows.empty.startFromScratch') }}
{{ i18n.baseText('workflows.empty.buildWorkflow') }}
</N8nText>
</div>
</N8nCard>
@ -234,8 +300,12 @@ const handleAppSelectionContinue = () => {
.emptyStateLayout {
display: flex;
flex-direction: column;
justify-content: center;
align-content: center;
height: 100%;
padding: var(--spacing--4xl) var(--spacing--2xl) 0;
max-width: var(--content-container--width);
width: 100%;
@media (max-width: vars.$breakpoint-lg) {
padding: var(--spacing--xl) var(--spacing--xs) 0;
@ -271,7 +341,7 @@ const handleAppSelectionContinue = () => {
}
.welcomeTitle {
margin-bottom: var(--spacing--2xl);
margin-bottom: var(--spacing--sm);
}
.templatesSection {
@ -283,37 +353,46 @@ const handleAppSelectionContinue = () => {
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
}
.fallbackDescription {
animation: contentDropIn var(--duration--base) var(--easing--ease-out)
calc(var(--duration--base) / 4) both;
}
.fallbackHeading {
animation: headingLift var(--duration--base) var(--easing--ease-out) both;
}
.actionCardsContainer {
display: grid;
grid-template-columns: repeat(2, 192px);
display: flex;
flex-wrap: wrap;
gap: var(--spacing--lg);
margin-top: var(--spacing--2xl);
justify-content: center;
width: 100%;
&.singleCard {
grid-template-columns: 192px;
max-width: 192px;
}
@media (max-width: vars.$breakpoint-xs) {
grid-template-columns: 1fr;
gap: var(--spacing--md);
margin-top: var(--spacing--lg);
}
}
.actionCard {
position: relative;
width: 192px;
height: 230px;
--action-card-index: 0;
max-width: 192px;
aspect-ratio: 4/3;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
animation: actionCardIn var(--duration--base) var(--easing--ease-out)
calc(160ms + var(--action-card-index) * 80ms) both;
&:hover {
transform: translateY(-2px);
@ -323,6 +402,62 @@ const handleAppSelectionContinue = () => {
color: var(--color--primary);
}
}
&:nth-child(2) {
--action-card-index: 1;
}
&:nth-child(3) {
--action-card-index: 2;
}
}
@keyframes headingLift {
from {
opacity: 0;
filter: blur(3px);
transform: translateY(var(--spacing--xs));
}
to {
opacity: 1;
filter: blur(0);
transform: translateY(calc(-1 * var(--spacing--xs)));
}
}
@keyframes contentDropIn {
from {
opacity: 0;
filter: blur(3px);
transform: translateY(calc(-1 * var(--spacing--xs)));
}
to {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
@keyframes actionCardIn {
from {
opacity: 0;
transform: translateY(var(--spacing--2xs));
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.fallbackHeading,
.fallbackDescription,
.actionCard {
animation: none;
}
}
.cardContent {
@ -330,7 +465,7 @@ const handleAppSelectionContinue = () => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing--md);
padding: var(--spacing--sm);
}
.cardIcon {

View File

@ -125,7 +125,7 @@ describe('WorkflowsView', () => {
it('for non setup user', async () => {
const { getByText } = renderComponent({ pinia });
await waitAllPromises();
expect(getByText('👋 Welcome!')).toBeVisible();
expect(getByText('What do you want to build?')).toBeVisible();
});
it('for currentUser user', async () => {
@ -135,7 +135,7 @@ describe('WorkflowsView', () => {
const { getByText } = renderComponent({ pinia });
await waitAllPromises();
expect(getByText('👋 Welcome, John!')).toBeVisible();
expect(getByText('What do you want to build, John')).toBeVisible();
});
describe('when onboardingExperiment -> False', () => {
@ -160,9 +160,9 @@ describe('WorkflowsView', () => {
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const { getByText } = renderComponent({ pinia });
const { getByRole } = renderComponent({ pinia });
await waitAllPromises();
expect(getByText('Create your first workflow')).toBeInTheDocument();
expect(getByRole('heading', { name: 'What do you want to build?' })).toBeInTheDocument();
});
});
});

View File

@ -15,6 +15,7 @@ import { useEmptyStateBuilderPromptStore } from '../stores/emptyStateBuilderProm
const props = defineProps<{
projectId?: string;
parentFolderId?: string;
showBuildAgentButton?: boolean;
}>();
const router = useRouter();
@ -26,6 +27,7 @@ const emptyStateBuilderPromptStore = useEmptyStateBuilderPromptStore();
const emit = defineEmits<{
submit: [prompt: string];
startFromScratch: [];
buildAgent: [];
}>();
const textInputValue = ref<string>('');
@ -59,6 +61,10 @@ function onImportFromFile() {
importFileRef.value?.click();
}
function onBuildAgent() {
emit('buildAgent');
}
function handleFileImport() {
const input = importFileRef.value;
if (!input?.files?.length) return;
@ -136,6 +142,16 @@ function handleFileImport() {
{{ i18n.baseText('emptyStateBuilderPrompt.fromScratch') }}
</N8nButton>
</N8nTooltip>
<N8nButton
v-if="props.showBuildAgentButton"
variant="subtle"
size="small"
icon="robot"
data-test-id="build-agent-button"
@click="onBuildAgent"
>
{{ i18n.baseText('workflows.empty.buildAgent') }}
</N8nButton>
<N8nTooltip :content="i18n.baseText('emptyStateBuilderPrompt.templateTooltip')">
<N8nButton variant="subtle" size="small" icon="layout-template" @click="onTemplate">
{{ i18n.baseText('emptyStateBuilderPrompt.template') }}

View File

@ -24,6 +24,14 @@ describe('useAgentTelemetry', () => {
});
});
it('trackClickedNewAgent tracks card source', () => {
useAgentTelemetry().trackClickedNewAgent('card');
expect(trackMock).toHaveBeenCalledWith('User clicked new agent', {
source: 'card',
session_id: 'session-xyz',
});
});
it('trackSubmittedMessage includes mode, status, agent_config (no raw message)', () => {
const fingerprint = {
instructions: 'hello',

View File

@ -4,6 +4,7 @@ import { useRootStore } from '@n8n/stores/useRootStore';
import type { AgentConfigFingerprint, AgentTelemetryStatus } from './agentTelemetry.utils';
export type AgentChatMode = 'build' | 'test';
export type AgentCreateSource = 'button' | 'dropdown' | 'card';
export type AgentConfigPart =
| 'instructions'
| 'model'
@ -31,7 +32,7 @@ export function useAgentTelemetry() {
}
}
function trackClickedNewAgent(source: 'button' | 'dropdown') {
function trackClickedNewAgent(source: AgentCreateSource) {
safeTrack('User clicked new agent', { source, ...common() });
}

View File

@ -13,6 +13,7 @@ import { useProjectPages } from '@/features/collaboration/projects/composables/u
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { listAgents } from '../composables/useAgentApi';
import { useAgentPermissions } from '../composables/useAgentPermissions';
import { useAgentTelemetry } from '../composables/useAgentTelemetry';
import type { AgentResource } from '../types';
import { AGENT_BUILDER_VIEW, NEW_AGENT_VIEW } from '../constants';
import AgentCard from '../components/AgentCard.vue';
@ -26,6 +27,7 @@ const rootStore = useRootStore();
const projectsStore = useProjectsStore();
const insightsStore = useInsightsStore();
const projectPages = useProjectPages();
const agentTelemetry = useAgentTelemetry();
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
@ -76,6 +78,7 @@ function onAgentDeleted(agentId: string) {
}
function onCreateAgentClick() {
agentTelemetry.trackClickedNewAgent('button');
void router.push({ name: NEW_AGENT_VIEW, query: { projectId: projectId.value } });
}