feat(editor): Add agent setup checklist (#39919)

This commit is contained in:
Rob Hough 2026-10-06 16:30:46 +00:00 committed by GitHub
parent b36c2b506b
commit 883ade4b9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1212 additions and 3 deletions

View File

@ -54,6 +54,8 @@
"generic.openResource": "Open {resource}",
"generic.add": "Add",
"generic.close": "Close",
"generic.expand": "Expand",
"generic.collapse": "Collapse",
"generic.clear": "Clear",
"generic.confirm": "Confirm",
"generic.create": "Create",
@ -8353,6 +8355,19 @@
"agents.builder.sections.triggers": "Triggers",
"agents.builder.agent.title": "Model & instructions",
"agents.builder.agent.description": "Choose a model and give the agent instructions",
"agents.builder.setupTasks.title": "Finish setup",
"agents.builder.setupTasks.done": "Done!",
"agents.builder.setupTasks.remaining": "{count} remaining",
"agents.builder.setupTasks.tasksDone": "{count} tasks done",
"agents.builder.setupTasks.maximize": "Maximize",
"agents.builder.setupTasks.minimize": "Minimize",
"agents.builder.setupTasks.chooseModel": "Choose a model",
"agents.builder.setupTasks.addTool": "Add tools",
"agents.builder.setupTasks.addInstructions": "Add instructions",
"agents.builder.setupTasks.testAgent": "Test agent",
"agents.builder.setupTasks.addChannel": "Add channels",
"agents.builder.setupTasks.addChannel.description": "Add a channel to let this agent respond to events from a chat platform.",
"agents.builder.setupTasks.publishAgent": "Publish agent",
"agents.builder.agent.name.label": "Name",
"agents.builder.agent.name.placeholder": "My agent",
"agents.builder.agent.personalisation.change": "Change agent icon",

View File

@ -139,7 +139,7 @@ const globalStubs = {
'configValidationStatus',
'beforePublish',
],
emits: ['published', 'unpublished', 'reverted'],
emits: ['publish-ready', 'published', 'unpublished', 'reverted'],
},
};
@ -315,6 +315,16 @@ describe('AgentBuilderHeader', () => {
expect(wrapper.emitted('reverted')).toBeTruthy();
});
it('forwards publish readiness changes', () => {
const wrapper = mountHeader();
const publish = wrapper.findComponent({ name: 'AgentPublishButton' });
publish.vm.$emit('publish-ready', false);
publish.vm.$emit('publish-ready', true);
expect(wrapper.emitted('publish-ready')).toEqual([[false], [true]]);
});
it('forwards header-action from the action menu', () => {
const wrapper = mountHeader({ headerActions: [{ id: 'delete', label: 'Delete' }] });
const action = getDropdown(wrapper, 'agent-header-actions');

View File

@ -606,11 +606,13 @@ const commonStubs = {
'configValidationStatus',
'saveStatus',
'beforePublish',
'tasks',
],
emits: [
'header-action',
'open-preview',
'close-preview',
'publish-ready',
'published',
'unpublished',
'reverted',
@ -2807,6 +2809,73 @@ describe('AgentBuilderView — configuration validation', () => {
expect(header.attributes('data-config-validation-status')).toBe('invalid');
});
it.each(['tool', 'MCP server', 'both'])(
'completes the add-tool setup task when adding %s',
async (kind) => {
const wrapper = await renderView();
const header = wrapper.findComponent({ name: 'AgentBuilderHeader' });
const vm = wrapper.vm as unknown as {
onConfigFieldUpdate: (updates: Partial<AgentJsonConfig>) => void;
};
expect(header.props('tasks')).toEqual(
expect.arrayContaining([expect.objectContaining({ id: 'add-tool', state: 'todo' })]),
);
vm.onConfigFieldUpdate({
tools: kind === 'MCP server' ? [] : [{ type: 'custom', id: 'custom_tool' }],
mcpServers:
kind === 'tool'
? []
: [
{
name: 'Example MCP',
url: 'https://mcp.example.com',
authentication: 'none',
transport: 'streamableHttp',
},
],
});
await nextTick();
expect(header.props('tasks')).toEqual(
expect.arrayContaining([expect.objectContaining({ id: 'add-tool', state: 'complete' })]),
);
vm.onConfigFieldUpdate({ tools: [], mcpServers: [] });
await nextTick();
expect(header.props('tasks')).toEqual(
expect.arrayContaining([expect.objectContaining({ id: 'add-tool', state: 'todo' })]),
);
},
);
it('shows the publish setup task for a tested agent only when the publish button is ready', async () => {
fetchedSessionThreads.push({ id: 'thread-tested', updatedAt: '2026-01-02T00:00:00Z' });
const wrapper = await renderView();
const header = wrapper.findComponent({ name: 'AgentBuilderHeader' });
expect(header.props('tasks')).toEqual(
expect.arrayContaining([expect.objectContaining({ id: 'publish-agent', visible: false })]),
);
header.vm.$emit('publish-ready', true);
await nextTick();
expect(header.props('tasks')).toEqual(
expect.arrayContaining([expect.objectContaining({ id: 'publish-agent', visible: true })]),
);
header.vm.$emit('publish-ready', false);
await nextTick();
expect(header.props('tasks')).toEqual(
expect.arrayContaining([expect.objectContaining({ id: 'publish-agent', visible: false })]),
);
});
it('flushes a pending config edit when the builder unmounts', async () => {
const wrapper = await renderView();
updateConfigMock.mockClear();

View File

@ -105,6 +105,7 @@ interface RenderProps {
agent?: AgentResource | null;
projectId?: string;
agentId?: string;
isSaving?: boolean;
beforeRevertToPublished?: () => Promise<void> | void;
configValidationStatus?: 'valid' | 'invalid' | null;
configValidationIssues?: AgentConfigValidationIssue[];
@ -462,6 +463,57 @@ describe('AgentPublishButton', () => {
).toBe('true');
});
it('reports readiness when tool credentials and validation change', async () => {
const wrapper = await renderComponent({
configValidationStatus: 'invalid',
configValidationIssues: [
{
code: 'missing_credential',
path: 'tools.0.credential',
capability: { kind: 'tool', toolType: 'node', id: 'Lookup', index: 0 },
},
],
});
expect(wrapper.emitted('publish-ready')).toEqual([[false]]);
await wrapper.setProps({ configValidationStatus: 'valid', configValidationIssues: [] });
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]);
await wrapper.setProps({ configValidationStatus: null });
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([false]);
await wrapper.setProps({
configValidationStatus: 'invalid',
configValidationIssues: [notPublishedIssue],
});
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]);
});
it('reports readiness when saving, permissions, and the published version change', async () => {
const wrapper = await renderComponent({ isSaving: true });
expect(wrapper.emitted('publish-ready')).toEqual([[false]]);
await wrapper.setProps({ isSaving: false });
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]);
agentPermissionsMock.canPublish.value = false;
await flushPromises();
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([false]);
agentPermissionsMock.canPublish.value = true;
await flushPromises();
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]);
await wrapper.setProps({ agent: createAgent({ activeVersionId: 'v1', activeVersion }) });
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([false]);
await wrapper.setProps({
agent: createAgent({ versionId: 'v2', activeVersionId: 'v1', activeVersion }),
});
expect(wrapper.emitted('publish-ready')?.at(-1)).toEqual([true]);
});
it('aborts the publish request when beforePublish resolves false', async () => {
const { publishAgent } = await import('../composables/useAgentApi');
const beforePublish = vi.fn().mockResolvedValue(false);

View File

@ -0,0 +1,184 @@
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import type {
SetupTask,
SetupTaskId,
} from '../components/AgentSetupTasks/agentSetupTasks.registry';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
vi.mock('@n8n/design-system', () => ({
N8nIcon: {
template: '<i :data-icon="icon" />',
props: ['icon'],
},
N8nPopover: {
template:
'<div><div @click="$emit(\'update:open\', !open)"><slot name="trigger" /></div><div v-if="open"><slot name="content" /></div></div>',
props: ['open'],
emits: ['update:open'],
},
N8nText: {
template: '<component :is="tag ?? \'span\'"><slot /></component>',
props: ['tag'],
},
}));
import AgentSetupTasks from '../components/AgentSetupTasks/AgentSetupTasks.vue';
const tasks: Array<SetupTask<SetupTaskId>> = [
{
id: 'choose-model',
titleKey: 'agents.builder.setupTasks.chooseModel',
required: true,
state: 'complete',
visible: true,
action: {
labelKey: 'agents.builder.setupTasks.chooseModel',
target: { kind: 'agent' },
path: 'model',
},
},
{
id: 'add-tool',
titleKey: 'agents.builder.tools.add',
required: false,
state: 'todo',
visible: false,
action: {
labelKey: 'agents.builder.tools.add',
target: { kind: 'tool' },
},
},
{
id: 'add-instructions',
titleKey: 'agents.builder.setupTasks.addInstructions',
required: true,
state: 'todo',
visible: true,
action: {
labelKey: 'agents.builder.setupTasks.addInstructions',
target: { kind: 'agent' },
path: 'instructions',
},
},
];
describe('AgentSetupTasks', () => {
it('changes selection with the arrow keys without moving focus and activates the selected task', async () => {
const localTasks: Array<SetupTask<SetupTaskId>> = [{ ...tasks[0], state: 'todo' }, tasks[2]];
const wrapper = mount(AgentSetupTasks, {
props: { tasks: localTasks },
global: { stubs: { TransitionGroup: false } },
attachTo: document.body,
});
try {
await wrapper.get('button').trigger('click');
await nextTick();
const list = wrapper.get('ul');
const taskItems = wrapper.findAll('[role="menuitem"]');
(list.element as HTMLElement).focus();
expect(document.activeElement).toBe(list.element);
for (const taskItem of taskItems) {
expect(taskItem.attributes('tabindex')).toBeUndefined();
expect(taskItem.attributes('data-selected')).toBe('false');
}
await list.trigger('keydown', { key: 'ArrowDown' });
expect(taskItems[0].attributes('data-selected')).toBe('true');
expect(document.activeElement).toBe(list.element);
await list.trigger('keydown', { key: 'ArrowDown' });
expect(taskItems[0].attributes('data-selected')).toBe('false');
expect(taskItems[1].attributes('data-selected')).toBe('true');
expect(document.activeElement).toBe(list.element);
await list.trigger('keydown', { key: 'Enter' });
expect(wrapper.emitted('action')?.[0]).toEqual([localTasks[1]]);
await list.trigger('keydown', { key: 'ArrowUp' });
expect(taskItems[0].attributes('data-selected')).toBe('true');
expect(taskItems[1].attributes('data-selected')).toBe('false');
expect(document.activeElement).toBe(list.element);
await list.trigger('keydown', { key: ' ' });
expect(wrapper.emitted('action')?.[1]).toEqual([localTasks[0]]);
} finally {
wrapper.unmount();
}
});
it('exposes the active task through the named menu without selection attributes', async () => {
const localTasks: Array<SetupTask<SetupTaskId>> = [{ ...tasks[0], state: 'todo' }, tasks[2]];
const wrapper = mount(AgentSetupTasks, { props: { tasks: localTasks } });
try {
await wrapper.get('button').trigger('click');
const menu = wrapper.get('[role="menu"]');
const items = wrapper.findAll('[role="menuitem"]');
expect(menu.attributes('aria-label')).toBe('agents.builder.setupTasks.title');
expect(menu.attributes('tabindex')).toBe('-1');
expect(menu.attributes('aria-activedescendant')).toBeUndefined();
const ids = items.map((item) => item.attributes('id'));
expect(ids.every((id) => Boolean(id))).toBe(true);
expect(new Set(ids).size).toBe(items.length);
for (const item of items) {
expect(item.attributes('aria-selected')).toBeUndefined();
expect(item.attributes('aria-current')).toBeUndefined();
}
await menu.trigger('keydown', { key: 'ArrowDown' });
expect(menu.attributes('aria-activedescendant')).toBe(ids[0]);
await menu.trigger('keydown', { key: 'ArrowDown' });
expect(menu.attributes('aria-activedescendant')).toBe(ids[1]);
await menu.trigger('keydown', { key: 'ArrowUp' });
expect(menu.attributes('aria-activedescendant')).toBe(ids[0]);
await items[1].trigger('mouseenter');
expect(menu.attributes('aria-activedescendant')).toBe(ids[1]);
await wrapper.setProps({ tasks: [{ ...localTasks[0], state: 'complete' }] });
expect(menu.attributes('aria-activedescendant')).toBeUndefined();
} finally {
wrapper.unmount();
}
});
it.each(['Escape', 'Tab', 'a'])('does not prevent the %s key', async (key) => {
const wrapper = mount(AgentSetupTasks, { props: { tasks } });
await wrapper.get('button').trigger('click');
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
wrapper.get('[role="menuitem"]').element.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
it('only shows visible tasks and puts completed tasks last', async () => {
const wrapper = mount(AgentSetupTasks, { props: { tasks } });
await wrapper.get('button').trigger('click');
const taskItems = wrapper.findAll('li');
expect(taskItems).toHaveLength(2);
expect(taskItems[0].text()).toContain('agents.builder.setupTasks.addInstructions');
expect(taskItems[1].text()).toContain('agents.builder.setupTasks.chooseModel');
expect(taskItems[1].attributes('tabindex')).toBeUndefined();
expect(taskItems[1].attributes('role')).toBeUndefined();
expect(taskItems[1].attributes('data-selected')).toBe('false');
expect(wrapper.text()).not.toContain('agents.builder.tools.add');
});
it('opens and closes the setup popover', async () => {
const wrapper = mount(AgentSetupTasks, { props: { tasks } });
const trigger = wrapper.get('button');
expect(trigger.attributes('type')).toBe('button');
expect(wrapper.find('[data-testid="agent-setup-tasks"]').exists()).toBe(false);
await trigger.trigger('click');
expect(wrapper.find('[data-testid="agent-setup-tasks"]').exists()).toBe(true);
await trigger.trigger('click');
expect(wrapper.find('[data-testid="agent-setup-tasks"]').exists()).toBe(false);
});
});

View File

@ -5,6 +5,7 @@ import { useI18n } from '@n8n/i18n';
import type { AgentConfigValidationIssue, AgentFileDto } from '@n8n/api-types';
import type { AgentBuilderMainTab } from '../composables/useAgentBuilderMainTabs';
import type { SetupTask } from './AgentSetupTasks/agentSetupTasks.registry';
import type {
AgentJsonConfig,
AgentJsonVectorStoreConfig,
@ -99,9 +100,42 @@ const emit = defineEmits<{
'agent-changed': [];
'generate-eval-cases': [];
'open-preview': [];
'publish-agent': [];
'setup-task-action': [task: SetupTask];
}>();
const i18n = useI18n();
const agentInfoPanel = ref<InstanceType<typeof AgentInfoPanel>>();
const agentTriggersSection = ref<InstanceType<typeof AgentTriggersSection>>();
function onSetupTaskAction(task: SetupTask) {
if (task.id === 'publish-agent') {
emit('publish-agent');
return;
}
if (task.action.target.kind === 'tool') {
emit('add-tool', 'tools');
return;
}
if (task.action.target.kind === 'channel') {
agentTriggersSection.value?.openChannelModal();
return;
}
if (task.action.path === 'instructions') {
agentInfoPanel.value?.focusInstructions();
return;
}
if (task.action.path === 'model') {
agentInfoPanel.value?.focusModel();
}
}
defineExpose({ onSetupTaskAction });
</script>
<template>
@ -133,6 +167,7 @@ const i18n = useI18n();
<div :class="$style.panelAreaContainer">
<AgentBuilderTabPanel v-if="activeMainTab === 'agent'" data-testid="agent-tab-content">
<AgentInfoPanel
ref="agentInfoPanel"
:config="localConfig"
:disabled="childrenDisabled"
:project-id="projectId"
@ -172,6 +207,7 @@ const i18n = useI18n();
/>
</template>
<AgentTriggersSection
ref="agentTriggersSection"
:key="`${projectId}:${agentId}`"
:connected-triggers="connectedTriggers"
:disabled="childrenDisabled"
@ -487,4 +523,12 @@ const i18n = useI18n();
padding-left: 0;
}
}
.setupTasks {
padding-inline: var(--agent-builder-content-padding-inline);
padding-block-end: var(--spacing--lg);
max-width: var(--agent-builder-content-max-width);
margin: 0 auto;
width: 100%;
}
</style>

View File

@ -6,7 +6,7 @@
* Navigation intents are emitted as events, except for the project breadcrumb
* which links back to the owning project/personal page.
*/
import { computed, onMounted, useCssModule } from 'vue';
import { computed, onMounted, ref, useCssModule } from 'vue';
import { useRouter, type RouteLocationRaw } from 'vue-router';
import type { AgentConfigValidationIssue } from '@n8n/api-types';
import {
@ -25,6 +25,8 @@ import ProjectIcon from '@/features/collaboration/projects/components/ProjectIco
import AgentPublishButton from './AgentPublishButton.vue';
import AgentPreviewButton from './AgentPreviewButton.vue';
import AgentSetupTasks from './AgentSetupTasks/AgentSetupTasks.vue';
import type { SetupTask, SetupTaskId } from './AgentSetupTasks/agentSetupTasks.registry';
import { useCreateAgent } from '../composables/useCreateAgent';
import { useProjectAgentsList } from '../composables/useProjectAgentsList';
import type { AgentResource } from '../types';
@ -45,22 +47,26 @@ const props = defineProps<{
configValidationStatus?: 'valid' | 'invalid' | null;
configValidationIssues?: AgentConfigValidationIssue[];
beforePublish?: () => Promise<boolean>;
tasks?: Array<SetupTask<SetupTaskId>>;
}>();
const emit = defineEmits<{
'header-action': [item: string];
'open-preview': [];
'close-preview': [];
'publish-ready': [ready: boolean];
published: [agent: AgentResource];
unpublished: [agent: AgentResource];
reverted: [agent: AgentResource];
'switch-agent': [agentId: string];
'toggle-version-history': [];
'setup-task-action': [task: SetupTask<SetupTaskId>];
}>();
const i18n = useI18n();
const router = useRouter();
const $style = useCssModule();
const publishButton = ref<InstanceType<typeof AgentPublishButton>>();
const { createAgent } = useCreateAgent();
const { list: agentsList, ensureLoaded } = useProjectAgentsList(computed(() => props.projectId));
@ -85,6 +91,12 @@ const breadcrumbItems = computed<PathItem[]>(() => [
const agentDisplayName = computed(() => props.agent?.name ?? '…');
function publishAgent() {
return publishButton.value?.publish();
}
defineExpose({ publishAgent });
const switcherOptions = computed<Array<DropdownMenuItemProps<string>>>(() => {
const list = agentsList.value ?? [];
const others = list.filter((a) => a.id !== props.agentId);
@ -224,6 +236,11 @@ function onMenuSelect(id: string) {
: i18n.baseText('agents.builder.header.saved')
}}
</span>
<AgentSetupTasks
v-if="tasks && tasks.length > 0"
:tasks="tasks"
@action="emit('setup-task-action', $event)"
/>
<AgentPreviewButton
:is-runnable="props.agent?.isRunnable === true"
:is-preview-open="props.isPreviewOpen"
@ -233,6 +250,7 @@ function onMenuSelect(id: string) {
@close-preview="emit('close-preview')"
/>
<AgentPublishButton
ref="publishButton"
:agent="agent"
:project-id="projectId"
:agent-id="agentId"
@ -241,6 +259,7 @@ function onMenuSelect(id: string) {
:config-validation-status="configValidationStatus"
:config-validation-issues="props.configValidationIssues ?? []"
:before-publish="beforePublish"
@publish-ready="emit('publish-ready', $event)"
@published="(a: AgentResource) => emit('published', a)"
@unpublished="(a: AgentResource) => emit('unpublished', a)"
@reverted="(a: AgentResource) => emit('reverted', a)"

View File

@ -354,6 +354,8 @@ function onSelectCredential(provider: AgentModelProvider, credentialId: string |
}
const instructions = ref(props.config?.instructions ?? '');
const instructionsEditor = ref<InstanceType<typeof N8nMarkdownEditor>>();
const modelSelector = ref<InstanceType<typeof AgentModelSelector>>();
// Keep the local editor stable while external config updates arrive.
watch(
@ -367,6 +369,14 @@ const emitInstructionsDebounced = useDebounceFn(() => {
emit('update:config', { instructions: instructions.value });
}, getDebounceTime(DEBOUNCE_TIME.API.HEAVY_OPERATION));
function focusInstructions() {
instructionsEditor.value?.focus();
}
function focusModel() {
modelSelector.value?.open();
}
function onInstructionsInput(value: string) {
instructions.value = value;
emit('draft:config');
@ -376,6 +386,7 @@ function onInstructionsInput(value: string) {
}
void emitInstructionsDebounced();
}
defineExpose({ focusInstructions, focusModel });
</script>
<template>
@ -397,6 +408,7 @@ function onInstructionsInput(value: string) {
</N8nText>
</div>
<AgentModelSelector
ref="modelSelector"
:disabled="props.disabled"
:selected-model="selectedAgent"
:credentials="effectiveCredentials"
@ -479,6 +491,7 @@ function onInstructionsInput(value: string) {
</N8nText>
</div>
<N8nMarkdownEditor
ref="instructionsEditor"
:id="instructionsEditorId"
:class="$style.instructionsDocument"
:model-value="instructions"

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AgentConfigValidationIssue } from '@n8n/api-types';
import { computed } from 'vue';
import { computed, watch } from 'vue';
import { N8nActionDropdown, N8nButton, N8nIconButton } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
@ -39,6 +39,7 @@ const props = withDefaults(
const { canUpdate, canPublish, canUnpublish } = useAgentPermissions(() => props.projectId);
const emit = defineEmits<{
'publish-ready': [ready: boolean];
published: [agent: AgentResource];
unpublished: [agent: AgentResource];
reverted: [agent: AgentResource];
@ -101,6 +102,8 @@ const isPublishDisabled = computed(
isConfigInvalid.value,
);
watch(isPublishDisabled, (disabled) => emit('publish-ready', !disabled), { immediate: true });
const dropdownActions = computed(() => {
const actions: Array<ActionDropdownItem<string>> = [
{
@ -148,6 +151,8 @@ useKeybindings({
},
});
defineExpose({ publish: onPublishClick });
async function onDropdownSelect(action: string) {
if (action === 'publish') {
await onPublishClick();

View File

@ -0,0 +1,465 @@
<script setup lang="ts">
import { computed, nextTick, ref, useId, useTemplateRef, watch } from 'vue';
import type { ComponentPublicInstance } from 'vue';
import { useI18n } from '@n8n/i18n';
import type { AgentJsonConfig } from '@n8n/api-types';
import { N8nIcon, N8nPopover, N8nText } from '@n8n/design-system';
import type { SetupTask, SetupTaskId } from './agentSetupTasks.registry';
const props = defineProps<{
tasks: Array<SetupTask<SetupTaskId>>;
personalisation?: AgentJsonConfig['personalisation'] | null;
}>();
const emit = defineEmits<{
action: [task: SetupTask<SetupTaskId>];
}>();
const i18n = useI18n();
const taskIdPrefix = useId();
const isTriggerShimmering = ref(false);
const activeTaskIndex = ref(-1);
const isPopoverOpen = ref(false);
const areTasksResolved = computed(() => props.tasks.every((task) => task.state !== 'unknown'));
const listRef = useTemplateRef<ComponentPublicInstance>('list');
watch([isPopoverOpen, areTasksResolved, activeTaskIndex], async ([isOpen, resolved]) => {
if (!isOpen || !resolved) return;
await nextTick();
const listElement = listRef.value?.$el;
if (!(listElement instanceof HTMLElement)) return;
const activeTask = listElement.querySelector<HTMLElement>('[tabindex="0"]');
(activeTask ?? listElement).focus();
});
const sortedList = computed(() => {
return props.tasks
.filter((task) => task.visible)
.toSorted((a, b) => Number(a.state === 'complete') - Number(b.state === 'complete'));
});
const completedTaskCount = computed(() => {
return sortedList.value.filter((task) => task.state === 'complete').length;
});
const completionPercentage = computed(() =>
sortedList.value.length > 0 ? (completedTaskCount.value / sortedList.value.length) * 100 : 0,
);
const completedTasksAreGrouped = computed(() => completedTaskCount.value > 3);
const incompleteTasks = computed(() =>
sortedList.value.filter((task) => task.state !== 'complete'),
);
const triggerLabel = computed(() =>
i18n.baseText(
incompleteTasks.value.length > 0
? 'agents.builder.setupTasks.title'
: 'agents.builder.setupTasks.done',
),
);
function getActiveTaskItem() {
const task = incompleteTasks.value[activeTaskIndex.value];
return task ? `${taskIdPrefix}-${task.id}` : undefined;
}
function resetActiveTask() {
/** Reset this to -1 so active state doesnt appear until keydown */
activeTaskIndex.value = -1;
}
function setActiveTask(task: SetupTask<SetupTaskId>) {
const index = incompleteTasks.value.findIndex((incompleteTask) => incompleteTask.id === task.id);
if (index >= 0) activeTaskIndex.value = index;
}
function handleTaskKeydown(event: KeyboardEvent): void {
if (!isPopoverOpen.value) return;
if (!['ArrowUp', 'ArrowDown', 'Enter', ' '].includes(event.key)) return;
event.preventDefault();
switch (event.key) {
case 'ArrowUp':
if (activeTaskIndex.value > 0) activeTaskIndex.value -= 1;
return;
case 'ArrowDown':
if (activeTaskIndex.value < incompleteTasks.value.length - 1) activeTaskIndex.value += 1;
return;
case 'Enter':
case ' ': {
const task = incompleteTasks.value[activeTaskIndex.value];
if (task) emit('action', task);
return;
}
}
}
</script>
<template>
<N8nPopover
v-model:open="isPopoverOpen"
:suppress-auto-focus="true"
side="bottom"
align="end"
width="280px"
:content-class="$style.popoverContent"
@before-enter="resetActiveTask"
>
<template #trigger>
<button
type="button"
:class="[
$style.trigger,
{ [$style.triggerShimmer]: isTriggerShimmering && !isPopoverOpen },
]"
@animationend.self="isTriggerShimmering = true"
>
<svg
v-if="incompleteTasks.length > 0"
:class="$style.progressWheel"
viewBox="0 0 20 20"
fill="none"
stroke-width="2"
role="progressbar"
:aria-label="i18n.baseText('agents.builder.setupTasks.title')"
:aria-valuenow="completionPercentage"
:aria-valuemin="0"
:aria-valuemax="100"
>
<circle :class="$style.progressTrack" cx="10" cy="10" r="8" />
<circle
:class="$style.progressValue"
cx="10"
cy="10"
r="8"
pathLength="100"
stroke-dasharray="100"
:stroke-dashoffset="100 - completionPercentage"
transform="rotate(-90 10 10)"
/>
</svg>
<N8nIcon v-else icon="check" :size="20" />
<N8nText :class="$style.triggerLabel" step="sm" bold>
<Transition
:enter-active-class="$style.labelEnter"
:leave-active-class="$style.labelLeave"
>
<span :key="triggerLabel">{{ triggerLabel }}</span>
</Transition>
</N8nText>
</button>
</template>
<template #content>
<div :class="$style.container" data-testid="agent-setup-tasks">
<TransitionGroup
v-if="areTasksResolved"
ref="list"
tag="ul"
role="menu"
:aria-label="i18n.baseText('agents.builder.setupTasks.title')"
tabindex="-1"
:aria-activedescendant="getActiveTaskItem()"
:class="$style.list"
:move-class="$style.listMove"
@keydown="handleTaskKeydown"
>
<li
v-for="(task, index) in completedTasksAreGrouped ? incompleteTasks : sortedList"
:id="`${taskIdPrefix}-${task.id}`"
:key="task.id"
:role="task.state !== 'complete' ? 'menuitem' : undefined"
:data-selected="task.state !== 'complete' && activeTaskIndex === index"
:class="[$style.listItem, { [$style.isComplete]: task.state === 'complete' }]"
@focus="setActiveTask(task)"
@mouseenter="setActiveTask(task)"
@click="emit('action', task)"
>
<div :class="[$style.taskIcon, { [$style.isComplete]: task.state === 'complete' }]">
<N8nIcon
v-if="task.state === 'complete'"
icon="check"
size="small"
:stroke-width="2.5"
/>
</div>
<N8nText bold :class="$style.taskTitle">{{ i18n.baseText(task.titleKey) }}</N8nText>
<N8nIcon icon="arrow-right" color="text-light" :class="$style.taskActionIcon" />
</li>
<li
v-if="completedTasksAreGrouped"
:key="'completed-tasks'"
role="presentation"
:class="[$style.listItem, $style.isComplete]"
>
<div :class="[$style.taskIcon, $style.isComplete]">
<N8nIcon icon="check" size="small" :stroke-width="2.5" />
</div>
<N8nText bold :class="$style.taskTitle">
{{
i18n.baseText('agents.builder.setupTasks.tasksDone', {
interpolate: { count: completedTaskCount.toString() },
})
}}
</N8nText>
</li>
</TransitionGroup>
</div>
</template>
</N8nPopover>
</template>
<style module lang="scss">
@use '@n8n/design-system/css/mixins/mixins' as mixins;
@use '@n8n/design-system/css/mixins/utils' as utils;
@use '@n8n/design-system/css/mixins/_focus' as focus;
@use '@n8n/design-system/css/mixins/motion' as motion;
.popoverContent {
padding: 0;
}
.trigger {
--animation--fade-in--translate: 0;
--animation--fade-in--blur: 0;
position: relative;
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing--2xs);
width: fit-content;
border: 1px solid transparent;
padding-inline: var(--spacing--3xs);
height: var(--height--md);
border-radius: var(--radius--full);
background-color: var(--color--purple-100);
color: var(--color--purple-900);
@include motion.fade-in;
animation-delay: calc(var(--duration--slow) / 2);
animation-fill-mode: both;
&:hover,
&[data-state='open'] {
background-color: var(--color--purple-200);
}
&:focus-visible,
&[data-state='open'] {
background-color: var(--color--purple-200);
@include focus.focus-ring-with-border;
}
> :global(.n8n-text) {
padding-inline-end: var(--spacing--2xs);
}
}
.triggerLabel {
--animation--blur-swap--blur: var(--spacing--5xs);
display: inline-grid;
> span {
grid-area: 1 / 1;
}
}
.labelEnter {
@include motion.blur-swap-in;
}
.labelLeave {
@include motion.blur-swap-out;
}
.triggerShimmer::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
110deg,
transparent 20%,
var(--color--pink-300) 38%,
var(--color--blue-300) 50%,
var(--color--orange-300) 62%,
transparent 80%
);
mix-blend-mode: overlay;
background-size: 200% 100%;
background-repeat: repeat-x;
opacity: 0;
pointer-events: none;
/** Use 1s for the sweep and hide the shimmer for 4s. */
animation: triggerShimmer calc(var(--duration--slow) * 5) cubic-bezier(0.15, 0.5, 0.85, 0.5)
infinite;
@include motion.reduced-motion;
}
.triggerShimmer:hover::after {
animation-play-state: paused;
}
@keyframes triggerShimmer {
0% {
background-position: 100% 0;
opacity: 0;
}
2%,
18% {
opacity: 1;
}
20%,
100% {
background-position: -100% 0;
opacity: 0;
}
}
.container {
--n8n-agent-setup-task-list--padding: var(--spacing--xs);
display: flex;
flex-direction: column;
max-height: 240px;
overflow: hidden;
}
.header {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--n8n-agent-setup-task-list--padding);
padding-block-end: 0;
user-select: none;
}
.headerContent {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
}
.progressWheel {
flex-shrink: 0;
width: var(--height--2xs);
height: var(--height--2xs);
}
.progressTrack {
stroke: var(--border-color);
}
.progressValue {
stroke: var(--color--secondary);
animation: progressFill var(--duration--snappy) var(--easing--ease-out) var(--duration--slow)
backwards;
@include motion.reduced-motion;
}
@keyframes progressFill {
from {
stroke-dashoffset: 100;
}
}
.list {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
padding-block: calc(var(--n8n-agent-setup-task-list--padding) / 2);
padding-inline: var(--n8n-agent-setup-task-list--padding);
@include mixins.hoverable-scroll-bar;
}
.listMove {
transition: transform var(--duration--base) var(--easing--ease-out-quart);
@include motion.reduced-motion;
/** TransitionGroup needs to add custom transition to children. But svg icon needs to stay hidden unless we focus/hover **/
> *:not(:global(.n8n-icon)) {
animation: taskMoveBlur var(--duration--base) var(--easing--ease-out-quart);
@include motion.reduced-motion;
}
}
.listItem {
display: flex;
align-items: center;
gap: calc(var(--n8n-agent-setup-task-list--padding) / 1.5);
padding-inline: calc(var(--n8n-agent-setup-task-list--padding) / 2);
height: var(--height--md);
border-radius: var(--radius);
margin-inline: calc(calc(var(--n8n-agent-setup-task-list--padding) / 2) * -1);
background-color: transparent;
user-select: none;
cursor: pointer;
border: 1px solid transparent;
.taskActionIcon {
margin-inline-start: auto;
opacity: 0;
flex-shrink: 0;
min-width: 0;
}
&:not(.isComplete) {
&:focus-visible,
&[data-selected='true'] {
background-color: var(--background--hover);
.taskActionIcon {
opacity: 1;
}
}
}
&.isComplete {
opacity: 0.6;
pointer-events: none;
> :global(.n8n-text) {
text-decoration: line-through;
}
}
}
.taskTitle {
@include utils.utils-ellipsis;
}
.taskIcon {
width: var(--height--2xs);
height: var(--height--2xs);
display: grid;
place-items: center;
border-radius: var(--radius--full);
border: var(--border);
box-shadow: inset 0 0 0 1px var(--border-color);
flex-shrink: 0;
min-width: 0;
&.isComplete {
background-color: var(--color--neutral-200);
border-color: var(--color--neutral-200);
box-shadow: none;
}
}
@keyframes taskMoveBlur {
0%,
100% {
filter: blur(0);
opacity: 1;
}
50% {
filter: blur(var(--spacing--5xs));
opacity: 0.2;
}
}
</style>

View File

@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { setupTaskDefinitions, type SetupContext } from './agentSetupTasks.registry';
function makeContext(): SetupContext {
return {
config: { loaded: true, model: 'openai:gpt-4.1', instructions: 'Help the user', toolCount: 0 },
channels: { loaded: true, ids: [] },
publication: { loaded: true, canPublish: true, activeVersionId: null },
sessions: { loaded: true, count: 0 },
};
}
describe('add-channel setup task', () => {
it('does not require a channel to publish an agent', () => {
expect(setupTaskDefinitions['add-channel'].required).toBe(false);
});
it('stays hidden when no channel is configured', () => {
const context = makeContext();
expect(setupTaskDefinitions['add-channel'].getVisible()).toBe(false);
expect(setupTaskDefinitions['add-channel'].getState(context)).toBe('todo');
});
it('keeps the channel completion state', () => {
const context = makeContext();
context.channels.ids = ['n8n_chat'];
expect(setupTaskDefinitions['add-channel'].getState(context)).toBe('complete');
});
it('keeps the state unknown until channels load', () => {
const context = makeContext();
context.channels.loaded = false;
expect(setupTaskDefinitions['add-channel'].getState(context)).toBe('unknown');
});
});

View File

@ -0,0 +1,165 @@
import type { AgentConfigValidationCapabilityRef } from '@n8n/api-types';
import type { BaseTextKey } from '@n8n/i18n';
export type SetupTaskState = 'unknown' | 'todo' | 'complete';
export interface SetupContext {
config: {
loaded: boolean;
model: string;
instructions: string;
toolCount: number;
};
channels: {
loaded: boolean;
ids: readonly string[];
};
publication: {
loaded: boolean;
canPublish: boolean;
activeVersionId: string | null;
};
sessions: {
loaded: boolean;
count: number;
};
}
export interface SetupTask<TId extends string = string> {
/** Stable across task order and page reloads. */
id: TId;
/** Name of the task, for example, Add channel. */
titleKey: BaseTextKey;
/** Description that gives more context about the task. */
descriptionKey?: BaseTextKey;
/** Whether this task is required to publish the agent. */
required: boolean;
/** Derived from configuration and validation. */
state: SetupTaskState;
/** Whether to show the task. */
visible?: boolean;
/** The action to do when a user selects the task. */
action: {
labelKey: BaseTextKey;
target: AgentConfigValidationCapabilityRef;
path?: string;
};
}
export type SetupTaskDefinition = Omit<SetupTask, 'id' | 'state' | 'visible'> & {
getState: (context: SetupContext) => SetupTaskState;
getVisible?: (context: SetupContext) => boolean;
};
/** Returns the task state after the agent configuration is available. */
function getConfigState(context: SetupContext, complete: boolean): SetupTaskState {
if (!context.config.loaded) return 'unknown';
return complete ? 'complete' : 'todo';
}
function getAddChannelState(context: SetupContext): SetupTaskState {
if (!context.channels.loaded) return 'unknown';
return context.channels.ids.length > 0 ? 'complete' : 'todo';
}
function getPublishAgentState(context: SetupContext): SetupTaskState {
if (!context.publication.loaded) return 'unknown';
return context.publication.activeVersionId ? 'complete' : 'todo';
}
function canTestAgent(context: SetupContext): boolean {
return (
context.config.model.trim().length > 0 &&
context.config.instructions.trim().length > 0 &&
context.config.toolCount > 0
);
}
function isTestAgentComplete(context: SetupContext): boolean {
return context.sessions.loaded && context.sessions.count > 0;
}
function getTestAgentState(context: SetupContext): SetupTaskState {
if (!context.sessions.loaded) return 'unknown';
return isTestAgentComplete(context) ? 'complete' : 'todo';
}
export const setupTaskDefinitions = {
'choose-model': {
titleKey: 'agents.builder.setupTasks.chooseModel',
required: true,
action: {
labelKey: 'agents.builder.setupTasks.chooseModel',
target: { kind: 'agent' },
path: 'model',
},
getState: (context) => getConfigState(context, context.config.model.trim().length > 0),
},
'add-tool': {
titleKey: 'agents.builder.setupTasks.addTool',
required: false,
action: {
labelKey: 'agents.builder.setupTasks.addTool',
target: { kind: 'tool' },
},
getState: (context) => getConfigState(context, context.config.toolCount > 0),
},
'add-instructions': {
titleKey: 'agents.builder.setupTasks.addInstructions',
required: true,
action: {
labelKey: 'agents.builder.setupTasks.addInstructions',
target: { kind: 'agent' },
path: 'instructions',
},
getState: (context) => getConfigState(context, context.config.instructions.trim().length > 0),
},
'test-agent': {
titleKey: 'agents.builder.setupTasks.testAgent',
required: false,
action: {
labelKey: 'agents.builder.setupTasks.testAgent',
target: { kind: 'agent' },
path: 'preview',
},
getVisible: canTestAgent,
getState: getTestAgentState,
},
'add-channel': {
titleKey: 'agents.builder.setupTasks.addChannel',
descriptionKey: 'agents.builder.setupTasks.addChannel.description',
required: false,
/** TODO: Decide if we want to conditionally render this */
getVisible: () => false,
action: {
labelKey: 'agents.builder.setupTasks.addChannel',
target: { kind: 'channel' },
},
getState: getAddChannelState,
},
'publish-agent': {
titleKey: 'agents.builder.setupTasks.publishAgent',
getVisible: (context) => context.publication.canPublish && isTestAgentComplete(context),
required: false,
action: {
labelKey: 'agents.builder.setupTasks.publishAgent',
target: { kind: 'agent' },
},
getState: getPublishAgentState,
},
} satisfies Record<string, SetupTaskDefinition>;
export type SetupTaskId = keyof typeof setupTaskDefinitions;
export const setupTaskOrder = [
'choose-model',
'add-tool',
'add-instructions',
'test-agent',
'add-channel',
'publish-agent',
] as const satisfies readonly SetupTaskId[];

View File

@ -0,0 +1,59 @@
import { computed, ref, watch, type ComputedRef } from 'vue';
import { TIME } from '@/app/constants/durations';
import {
setupTaskDefinitions,
setupTaskOrder,
type SetupContext,
type SetupTask,
type SetupTaskDefinition,
type SetupTaskId,
type SetupTaskState,
} from './agentSetupTasks.registry';
export function useAgentSetupTasks(context: ComputedRef<SetupContext>) {
const tasks = computed<Array<SetupTask<SetupTaskId>>>(() => {
return setupTaskOrder.map((id) => {
const definition: SetupTaskDefinition = setupTaskDefinitions[id];
const { getState, getVisible, ...task } = definition;
return {
...task,
id,
visible: getVisible?.(context.value) ?? true,
state: getState(context.value),
};
});
});
const isVisible = ref(false);
const checklistState = computed<SetupTaskState>(() => {
if (tasks.value.some((task) => task.state === 'unknown')) return 'unknown';
return tasks.value.some((task) => task.visible && task.state !== 'complete')
? 'todo'
: 'complete';
});
watch(
checklistState,
(state, _, onCleanup) => {
if (state !== 'complete') {
isVisible.value = state === 'todo';
return;
}
if (!isVisible.value) return;
const timeout = setTimeout(() => {
isVisible.value = false;
}, 5 * TIME.SECOND);
onCleanup(() => clearTimeout(timeout));
},
{ immediate: true },
);
return { tasks, isVisible };
}

View File

@ -252,6 +252,8 @@ function handleChannelConnected(channelType: string) {
void loadChannelDetails();
}
defineExpose({ openChannelModal });
function handleChannelDisconnected(channelType: string) {
emit(
'update:connected-triggers',

View File

@ -87,6 +87,7 @@ import {
} from '../composables/useAgentConfigAutosave';
import { useAgentBuilderMainTabs } from '../composables/useAgentBuilderMainTabs';
import { useAgentCapabilitiesActions } from '../composables/useAgentCapabilitiesActions';
import { useAgentSetupTasks } from '../components/AgentSetupTasks/useAgentSetupTasks';
import {
removeProjectAgentFromListCache,
upsertProjectAgentsListCache,
@ -123,6 +124,7 @@ import AgentBuilderHeader from '../components/AgentBuilderHeader.vue';
import AgentCollaborationBanner from '../components/AgentCollaborationBanner.vue';
import AgentBuilderEditorColumn from '../components/AgentBuilderEditorColumn.vue';
import AgentBuilderIntro from '../components/AgentBuilderIntro.vue';
import type { SetupTask } from '../components/AgentSetupTasks/agentSetupTasks.registry';
import AgentPreviewHeader from '../components/AgentPreviewHeader.vue';
import AgentPreviewChatPage from '../components/AgentPreviewChatPage.vue';
import AgentPreviewDock from '../components/AgentPreviewDock.vue';
@ -670,6 +672,7 @@ async function onSendPreviewToAssistant(event?: AgentSendToAssistantEvent) {
* - render the preview chat before the route/config/session state has settled.
*/
const initialized = ref(false);
const previewSessionsLoaded = ref(false);
let disposed = false;
let latestSessionsFetchRequestId = 0;
/**
@ -755,6 +758,63 @@ function markConfigDraftEdited() {
configEditRevision += 1;
}
const connectedTriggers = ref<string[]>([]);
const isPublishReady = ref(false);
const setupChecklistContext = computed(() => {
return {
config: {
loaded: initialized.value && localConfig.value !== null,
model: localConfig.value?.model ?? '',
instructions: localConfig.value?.instructions ?? '',
toolCount:
(localConfig.value?.tools?.length ?? 0) +
(localConfig.value?.mcpServers?.length ?? 0) +
(localConfig.value?.subAgents?.agents?.length ?? 0),
},
channels: {
loaded: initialized.value,
ids: connectedTriggers.value,
},
publication: {
loaded: initialized.value,
canPublish:
initialized.value &&
!isUnsaved.value &&
effectiveCanEditAgent.value &&
isPublishReady.value,
activeVersionId: agent.value?.activeVersionId ?? null,
},
sessions: {
loaded: initialized.value && previewSessionsLoaded.value,
count: sessionsStore.previewThreads.length,
},
};
});
const { tasks: setupTasks, isVisible: areSetupTasksVisible } =
useAgentSetupTasks(setupChecklistContext);
const builderHeader = useTemplateRef<{ publishAgent: () => Promise<void> | undefined }>(
'builderHeader',
);
const editorColumn = useTemplateRef<{ onSetupTaskAction: (task: SetupTask) => void }>(
'editorColumn',
);
async function onSetupTaskAction(task: SetupTask) {
if (task.action.path === 'preview') {
await onOpenPreview();
return;
}
if (activeMainTab.value !== 'agent') {
activeMainTab.value = 'agent';
await nextTick();
}
editorColumn.value?.onSetupTaskAction(task);
}
function onSetupTaskPublishAgent() {
void builderHeader.value?.publishAgent();
}
/** Bumped when the config changes outside the local editor (modal flows, version revert) so the Tasks panel reloads. */
const tasksReloadKey = ref(0);
const versionHistoryPanel = useTemplateRef<{ refresh: () => Promise<void> }>('versionHistoryPanel');
@ -2561,6 +2621,7 @@ async function initialize({ preserveState = false }: { preserveState?: boolean }
// Stop any in-flight auto-refresh from the previous agent before kicking
// off a new fetch — keeps the store tied to the current project/agent.
sessionsStore.stopAutoRefresh();
previewSessionsLoaded.value = false;
if (!isUnsaved.value) {
void sessionsStore
.fetchThreads(targetProjectId, targetAgentId)
@ -2570,6 +2631,7 @@ async function initialize({ preserveState = false }: { preserveState?: boolean }
})
.finally(() => {
if (!isCurrentInitialization()) return;
previewSessionsLoaded.value = true;
sessionsStore.startAutoRefresh();
});
}
@ -2895,6 +2957,7 @@ useKeybindings({
/>
<AgentBuilderHeader
v-else
ref="builderHeader"
:agent="agent"
:project-id="projectId"
:agent-id="agentId"
@ -2909,13 +2972,16 @@ useKeybindings({
:config-validation-issues="configValidation?.issues ?? []"
:before-publish="refreshValidationBeforePublish"
:is-preview-open="isPreviewDockOpen"
:tasks="areSetupTasksVisible ? setupTasks : undefined"
@header-action="onHeaderAction"
@open-preview="onOpenPreview"
@close-preview="closePreviewDock"
@publish-ready="isPublishReady = $event"
@published="onPublished"
@unpublished="onUnpublished"
@reverted="onReverted"
@switch-agent="onSwitchAgent"
@setup-task-action="onSetupTaskAction"
/>
<AgentCollaborationBanner v-if="!isArtifactMode" />
<div
@ -3054,6 +3120,7 @@ useKeybindings({
/>
<AgentBuilderEditorColumn
ref="editorColumn"
v-else
v-model:active-main-tab="activeMainTab"
:class="$style.editorColumn"
@ -3106,6 +3173,7 @@ useKeybindings({
@agent-changed="refreshAgentAfterIntegrationChange"
@generate-eval-cases="onGenerateEvalCases"
@open-preview="onOpenPreview"
@publish-agent="onSetupTaskPublishAgent"
/>
<AgentVersionHistoryPanel