feat(editor): Add proactive starter experiment (no-changelog) (#30252)
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.14.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Sync API Docs / sync-public-api (push) Waiting to run

This commit is contained in:
Romeo Balta 2026-05-11 22:32:14 +01:00 committed by GitHub
parent 133a5aa0ad
commit 7fdd98aa72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 355 additions and 3 deletions

View File

@ -1189,6 +1189,8 @@
"duplicateWorkflowDialog.errors.forbidden.message": "This action is forbidden. Do you have the correct permissions?", "duplicateWorkflowDialog.errors.forbidden.message": "This action is forbidden. Do you have the correct permissions?",
"duplicateWorkflowDialog.errors.generic.title": "Duplicate workflow failed", "duplicateWorkflowDialog.errors.generic.title": "Duplicate workflow failed",
"editor.mainHeader.githubButton.label": "Star n8n-io/n8n on GitHub", "editor.mainHeader.githubButton.label": "Star n8n-io/n8n on GitHub",
"experiments.instanceAiProactiveAgent.message": "Hey, I can build your first workflow in a few minutes. Do you know what you want to automate, or do you want help with ideas?",
"experiments.instanceAiProactiveAgent.typingLabel": "AI Assistant is typing",
"experiments.personalizedTemplatesV3.browseAllTemplates": "Browse our template library", "experiments.personalizedTemplatesV3.browseAllTemplates": "Browse our template library",
"experiments.personalizedTemplatesV3.couldntFind": "Need something different?", "experiments.personalizedTemplatesV3.couldntFind": "Need something different?",
"experiments.personalizedTemplatesV3.exploreTemplates": "Get started with HubSpot workflows:", "experiments.personalizedTemplatesV3.exploreTemplates": "Get started with HubSpot workflows:",

View File

@ -94,6 +94,9 @@ export const CODE_WORKFLOW_BUILDER_EXPERIMENT = createExperiment('071_coding_wor
}); });
export const AI_BUILDER_SETUP_WIZARD_EXPERIMENT = createExperiment('079_ai_builder_setup_wizard'); export const AI_BUILDER_SETUP_WIZARD_EXPERIMENT = createExperiment('079_ai_builder_setup_wizard');
export const INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT = createExperiment(
'082_instance_ai_proactive_agent',
);
export const AA_EXPERIMENT_CHECK = createExperiment('078_experiment_check_aa'); export const AA_EXPERIMENT_CHECK = createExperiment('078_experiment_check_aa');
export const CHAT_HUB_SEMANTIC_SEARCH_EXPERIMENT = createExperiment('077_chat_hub_semantic_search'); export const CHAT_HUB_SEMANTIC_SEARCH_EXPERIMENT = createExperiment('077_chat_hub_semantic_search');
@ -122,6 +125,7 @@ export const EXPERIMENTS_TO_TRACK = [
AI_BUILDER_REVIEW_CHANGES_EXPERIMENT.name, AI_BUILDER_REVIEW_CHANGES_EXPERIMENT.name,
MERGE_ASK_BUILD_EXPERIMENT.name, MERGE_ASK_BUILD_EXPERIMENT.name,
AI_BUILDER_SETUP_WIZARD_EXPERIMENT.name, AI_BUILDER_SETUP_WIZARD_EXPERIMENT.name,
INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT.name,
AA_EXPERIMENT_CHECK.name, AA_EXPERIMENT_CHECK.name,
CHAT_HUB_SEMANTIC_SEARCH_EXPERIMENT.name, CHAT_HUB_SEMANTIC_SEARCH_EXPERIMENT.name,
FLOATING_CHAT_HUB_PANEL_EXPERIMENT.name, FLOATING_CHAT_HUB_PANEL_EXPERIMENT.name,

View File

@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
import InstanceAiProactiveStarterMessage from './InstanceAiProactiveStarterMessage.vue';
const renderComponent = createComponentRenderer(InstanceAiProactiveStarterMessage);
const PROACTIVE_MESSAGE =
'Hey, I can build your first workflow in a few minutes. Do you know what you want to automate, or do you want help with ideas?';
const OLD_PROACTIVE_MESSAGE =
'I can help with workflow ideas and build the workflow for you. What would you like to automate?';
describe('InstanceAiProactiveStarterMessage', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('delays, shows typing, then reveals the polished proactive assistant message', async () => {
const { getByTestId, queryByTestId, queryByText } = renderComponent();
expect(queryByTestId('instance-ai-proactive-starter')).not.toBeInTheDocument();
await vi.advanceTimersByTimeAsync(800);
expect(getByTestId('instance-ai-proactive-starter')).toBeVisible();
expect(getByTestId('instance-ai-proactive-typing')).toBeVisible();
expect(queryByText(PROACTIVE_MESSAGE)).not.toBeInTheDocument();
await vi.advanceTimersByTimeAsync(600);
expect(queryByTestId('instance-ai-proactive-typing')).not.toBeInTheDocument();
expect(getByTestId('instance-ai-proactive-message')).toHaveTextContent(PROACTIVE_MESSAGE);
expect(queryByText(OLD_PROACTIVE_MESSAGE)).not.toBeInTheDocument();
expect(queryByTestId('instance-ai-suggestion-build-workflow')).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,175 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { N8nAssistantIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
const STARTER_DELAY_MS = 800;
const TYPING_DURATION_MS = 600;
type StarterStage = 'waiting' | 'typing' | 'message';
const i18n = useI18n();
const stage = ref<StarterStage>('waiting');
const timers: Array<ReturnType<typeof setTimeout>> = [];
onMounted(() => {
timers.push(
setTimeout(() => {
stage.value = 'typing';
}, STARTER_DELAY_MS),
);
timers.push(
setTimeout(() => {
stage.value = 'message';
}, STARTER_DELAY_MS + TYPING_DURATION_MS),
);
});
onBeforeUnmount(() => {
for (const timer of timers) {
clearTimeout(timer);
}
});
</script>
<template>
<article
v-if="stage !== 'waiting'"
:class="$style.container"
data-test-id="instance-ai-proactive-starter"
>
<div :class="$style.avatar" aria-hidden="true">
<N8nAssistantIcon size="large" theme="blank" />
</div>
<section :class="$style.bubble" aria-live="polite">
<div
v-if="stage === 'typing'"
:class="$style.typing"
:aria-label="
i18n.baseText('experiments.instanceAiProactiveAgent.typingLabel' as BaseTextKey)
"
role="status"
data-test-id="instance-ai-proactive-typing"
>
<span :class="$style.typingDot" />
<span :class="$style.typingDot" />
<span :class="$style.typingDot" />
</div>
<N8nText
v-else
tag="p"
size="large"
:class="$style.message"
data-test-id="instance-ai-proactive-message"
>
{{ i18n.baseText('experiments.instanceAiProactiveAgent.message' as BaseTextKey) }}
</N8nText>
</section>
</article>
</template>
<style module lang="scss">
@use '@n8n/design-system/css/mixins/motion';
.container {
display: grid;
grid-template-columns: var(--spacing--xl) minmax(0, 1fr);
column-gap: var(--spacing--sm);
align-items: start;
width: 100%;
max-width: 720px;
padding-top: var(--spacing--md);
@include motion.fade-in-up;
animation-duration: var(--duration--base);
animation-fill-mode: both;
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--spacing--xl);
height: var(--spacing--xl);
border-radius: var(--radius--full);
background: var(--assistant--color--highlight-gradient);
box-shadow: var(--shadow--xs);
}
.bubble {
display: inline-flex;
align-items: center;
min-height: var(--spacing--xl);
width: fit-content;
max-width: min(560px, 100%);
padding: var(--spacing--sm) var(--spacing--md);
background: var(--color--background--light-3);
border-radius: var(--radius--3xs) var(--radius--xl) var(--radius--xl) var(--radius--xl);
}
.message {
--animation--fade-in-up--duration: var(--duration--base);
--animation--fade-in-up--translate: var(--spacing--5xs);
margin: 0;
color: var(--color--text);
line-height: var(--line-height--xl);
white-space: pre-wrap;
@include motion.fade-in-up;
animation-fill-mode: both;
}
.typing {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
padding: 0 var(--spacing--4xs);
}
.typingDot {
width: var(--spacing--4xs);
height: var(--spacing--4xs);
border-radius: var(--radius--full);
background: var(--color--text--tint-1);
animation: typing-dot var(--duration--slow) ease-in-out infinite;
&:nth-child(2) {
animation-delay: 120ms;
}
&:nth-child(3) {
animation-delay: 240ms;
}
}
@keyframes typing-dot {
0%,
60%,
100% {
opacity: 0.35;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(calc(var(--spacing--5xs) * -1));
}
}
@media (prefers-reduced-motion: reduce) {
.container,
.message,
.typingDot {
animation: none;
}
.typingDot {
opacity: 0.7;
transform: none;
}
}
</style>

View File

@ -0,0 +1,2 @@
export { useInstanceAiProactiveAgentExperiment } from './useInstanceAiProactiveAgentExperiment';
export { default as InstanceAiProactiveStarterMessage } from './components/InstanceAiProactiveStarterMessage.vue';

View File

@ -0,0 +1,30 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT } from '@/app/constants/experiments';
import { useInstanceAiProactiveAgentExperiment } from './useInstanceAiProactiveAgentExperiment';
const getVariant = vi.fn();
vi.mock('@/app/stores/posthog.store', () => ({
usePostHog: vi.fn(() => ({
getVariant,
})),
}));
describe('useInstanceAiProactiveAgentExperiment', () => {
beforeEach(() => {
getVariant.mockReset();
});
it.each([
{ variant: INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT.variant, enabled: true },
{ variant: INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT.control, enabled: false },
{ variant: undefined, enabled: false },
])('returns $enabled when PostHog variant is $variant', ({ variant, enabled }) => {
getVariant.mockReturnValue(variant);
const { isFeatureEnabled } = useInstanceAiProactiveAgentExperiment();
expect(isFeatureEnabled.value).toBe(enabled);
expect(getVariant).toHaveBeenCalledWith(INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT.name);
});
});

View File

@ -0,0 +1,16 @@
import { computed } from 'vue';
import { INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT } from '@/app/constants/experiments';
import { usePostHog } from '@/app/stores/posthog.store';
export function useInstanceAiProactiveAgentExperiment() {
const posthogStore = usePostHog();
const isFeatureEnabled = computed(
() =>
posthogStore.getVariant(INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT.name) ===
INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT.variant,
);
return { isFeatureEnabled };
}

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { nextTick, onMounted, ref } from 'vue'; import { computed, nextTick, onMounted, ref } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import type { InstanceAiAttachment } from '@n8n/api-types'; import type { InstanceAiAttachment } from '@n8n/api-types';
@ -10,6 +10,10 @@ import { useInstanceAiStore } from './instanceAi.store';
import { INSTANCE_AI_THREAD_VIEW } from './constants'; import { INSTANCE_AI_THREAD_VIEW } from './constants';
import { INSTANCE_AI_EMPTY_STATE_SUGGESTIONS } from './emptyStateSuggestions'; import { INSTANCE_AI_EMPTY_STATE_SUGGESTIONS } from './emptyStateSuggestions';
import { useCreditWarningBanner } from './composables/useCreditWarningBanner'; import { useCreditWarningBanner } from './composables/useCreditWarningBanner';
import {
InstanceAiProactiveStarterMessage,
useInstanceAiProactiveAgentExperiment,
} from '@/experiments/instanceAiProactiveAgent';
import InstanceAiInput from './components/InstanceAiInput.vue'; import InstanceAiInput from './components/InstanceAiInput.vue';
import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue'; import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue';
import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue'; import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue';
@ -22,6 +26,9 @@ const router = useRouter();
const toast = useToast(); const toast = useToast();
const { goToUpgrade } = usePageRedirectionHelper(); const { goToUpgrade } = usePageRedirectionHelper();
const creditBanner = useCreditWarningBanner(isLowCredits); const creditBanner = useCreditWarningBanner(isLowCredits);
const { isFeatureEnabled: isProactiveAgentExperimentEnabled } =
useInstanceAiProactiveAgentExperiment();
const showProactiveStarter = computed(() => isProactiveAgentExperimentEnabled.value);
// Reset to a blank "no active thread" state so the sidebar doesn't keep // Reset to a blank "no active thread" state so the sidebar doesn't keep
// highlighting the previous thread alongside the empty main view, and SSE // highlighting the previous thread alongside the empty main view, and SSE
@ -65,7 +72,34 @@ function handleStop() {
<InstanceAiViewHeader /> <InstanceAiViewHeader />
<div :class="$style.contentArea"> <div :class="$style.contentArea">
<div :class="$style.emptyLayout"> <div v-if="showProactiveStarter" :class="$style.proactiveLayout">
<div :class="$style.proactiveMessageList">
<InstanceAiProactiveStarterMessage />
</div>
<div :class="$style.proactiveInput">
<CreditWarningBanner
v-if="creditBanner.visible.value"
:credits-remaining="store.creditsRemaining"
:credits-quota="store.creditsQuota"
@upgrade-click="goToUpgrade('instance-ai', 'upgrade-instance-ai')"
@dismiss="creditBanner.dismiss()"
/>
<InstanceAiInput
ref="chatInputRef"
:is-streaming="store.isStreaming"
:is-sending-message="store.isSendingMessage"
:is-awaiting-confirmation="store.isAwaitingConfirmation"
:current-thread-id="store.currentThreadId"
:amend-context="store.amendContext"
:contextual-suggestion="store.contextualSuggestion"
:research-mode="store.researchMode"
@submit="handleSubmit"
@stop="handleStop"
@toggle-research-mode="store.toggleResearchMode()"
/>
</div>
</div>
<div v-else :class="$style.emptyLayout">
<InstanceAiEmptyState /> <InstanceAiEmptyState />
<div :class="$style.centeredInput"> <div :class="$style.centeredInput">
<CreditWarningBanner <CreditWarningBanner
@ -127,4 +161,29 @@ function handleStop() {
width: 100%; width: 100%;
max-width: 680px; max-width: 680px;
} }
.proactiveLayout {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.proactiveMessageList {
flex: 1;
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: var(--spacing--lg);
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.proactiveInput {
width: 100%;
max-width: 750px;
margin: 0 auto;
padding: 0 var(--spacing--lg) var(--spacing--sm);
}
</style> </style>

View File

@ -11,11 +11,24 @@ import { useInstanceAiStore } from '../instanceAi.store';
import { SidebarStateKey } from '../instanceAiLayout'; import { SidebarStateKey } from '../instanceAiLayout';
import { INSTANCE_AI_THREAD_VIEW } from '../constants'; import { INSTANCE_AI_THREAD_VIEW } from '../constants';
const { replaceMock, showErrorMock } = vi.hoisted(() => ({ const { experimentMocks, replaceMock, showErrorMock } = vi.hoisted(() => ({
experimentMocks: {
proactiveAgentEnabled: { value: false },
},
replaceMock: vi.fn(), replaceMock: vi.fn(),
showErrorMock: vi.fn(), showErrorMock: vi.fn(),
})); }));
vi.mock('@/experiments/instanceAiProactiveAgent', () => ({
useInstanceAiProactiveAgentExperiment: () => ({
isFeatureEnabled: experimentMocks.proactiveAgentEnabled,
}),
InstanceAiProactiveStarterMessage: {
name: 'InstanceAiProactiveStarterMessageStub',
template: '<div data-test-id="instance-ai-proactive-starter">starter</div>',
},
}));
vi.mock('@/app/composables/usePageRedirectionHelper', () => ({ vi.mock('@/app/composables/usePageRedirectionHelper', () => ({
usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }), usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }),
})); }));
@ -80,6 +93,7 @@ describe('InstanceAiEmptyView', () => {
store = mockedStore(useInstanceAiStore); store = mockedStore(useInstanceAiStore);
store.currentThreadId = 'thread-placeholder'; store.currentThreadId = 'thread-placeholder';
experimentMocks.proactiveAgentEnabled.value = false;
}); });
afterEach(() => { afterEach(() => {
@ -93,6 +107,16 @@ describe('InstanceAiEmptyView', () => {
expect(getByTestId('instance-ai-input-stub')).toHaveTextContent('4'); expect(getByTestId('instance-ai-input-stub')).toHaveTextContent('4');
}); });
it('renders the proactive starter and moves suggestions out of the composer when enabled', () => {
experimentMocks.proactiveAgentEnabled.value = true;
const { getByTestId, queryByTestId } = renderView();
expect(getByTestId('instance-ai-proactive-starter')).toHaveTextContent('starter');
expect(queryByTestId('instance-ai-empty-state')).not.toBeInTheDocument();
expect(getByTestId('instance-ai-input-stub')).toHaveTextContent('unset');
});
it('clears the current thread on mount (AI-2408)', () => { it('clears the current thread on mount (AI-2408)', () => {
// Without this, currentThreadId keeps pointing at the last visited thread // Without this, currentThreadId keeps pointing at the last visited thread
// and the sidebar would highlight it alongside the empty main view. // and the sidebar would highlight it alongside the empty main view.