mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
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
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:
parent
133a5aa0ad
commit
7fdd98aa72
|
|
@ -1189,6 +1189,8 @@
|
|||
"duplicateWorkflowDialog.errors.forbidden.message": "This action is forbidden. Do you have the correct permissions?",
|
||||
"duplicateWorkflowDialog.errors.generic.title": "Duplicate workflow failed",
|
||||
"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.couldntFind": "Need something different?",
|
||||
"experiments.personalizedTemplatesV3.exploreTemplates": "Get started with HubSpot workflows:",
|
||||
|
|
|
|||
|
|
@ -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 INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT = createExperiment(
|
||||
'082_instance_ai_proactive_agent',
|
||||
);
|
||||
export const AA_EXPERIMENT_CHECK = createExperiment('078_experiment_check_aa');
|
||||
|
||||
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,
|
||||
MERGE_ASK_BUILD_EXPERIMENT.name,
|
||||
AI_BUILDER_SETUP_WIZARD_EXPERIMENT.name,
|
||||
INSTANCE_AI_PROACTIVE_AGENT_EXPERIMENT.name,
|
||||
AA_EXPERIMENT_CHECK.name,
|
||||
CHAT_HUB_SEMANTIC_SEARCH_EXPERIMENT.name,
|
||||
FLOATING_CHAT_HUB_PANEL_EXPERIMENT.name,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { useInstanceAiProactiveAgentExperiment } from './useInstanceAiProactiveAgentExperiment';
|
||||
export { default as InstanceAiProactiveStarterMessage } from './components/InstanceAiProactiveStarterMessage.vue';
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useRouter } from 'vue-router';
|
||||
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_EMPTY_STATE_SUGGESTIONS } from './emptyStateSuggestions';
|
||||
import { useCreditWarningBanner } from './composables/useCreditWarningBanner';
|
||||
import {
|
||||
InstanceAiProactiveStarterMessage,
|
||||
useInstanceAiProactiveAgentExperiment,
|
||||
} from '@/experiments/instanceAiProactiveAgent';
|
||||
import InstanceAiInput from './components/InstanceAiInput.vue';
|
||||
import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue';
|
||||
import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue';
|
||||
|
|
@ -22,6 +26,9 @@ const router = useRouter();
|
|||
const toast = useToast();
|
||||
const { goToUpgrade } = usePageRedirectionHelper();
|
||||
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
|
||||
// highlighting the previous thread alongside the empty main view, and SSE
|
||||
|
|
@ -65,7 +72,34 @@ function handleStop() {
|
|||
<InstanceAiViewHeader />
|
||||
|
||||
<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 />
|
||||
<div :class="$style.centeredInput">
|
||||
<CreditWarningBanner
|
||||
|
|
@ -127,4 +161,29 @@ function handleStop() {
|
|||
width: 100%;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -11,11 +11,24 @@ import { useInstanceAiStore } from '../instanceAi.store';
|
|||
import { SidebarStateKey } from '../instanceAiLayout';
|
||||
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(),
|
||||
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', () => ({
|
||||
usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }),
|
||||
}));
|
||||
|
|
@ -80,6 +93,7 @@ describe('InstanceAiEmptyView', () => {
|
|||
|
||||
store = mockedStore(useInstanceAiStore);
|
||||
store.currentThreadId = 'thread-placeholder';
|
||||
experimentMocks.proactiveAgentEnabled.value = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -93,6 +107,16 @@ describe('InstanceAiEmptyView', () => {
|
|||
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)', () => {
|
||||
// Without this, currentThreadId keeps pointing at the last visited thread
|
||||
// and the sidebar would highlight it alongside the empty main view.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user