feat(core): Move agents test chat into a preview tab (no-changelog) (#30527)

This commit is contained in:
Michael Drury 2026-05-18 09:58:58 +01:00 committed by GitHub
parent cec8238b64
commit a0c427fbc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 650 additions and 611 deletions

View File

@ -88,6 +88,7 @@ function buildPrompt(modelRecommendationsSection: string | null) {
configHash: null,
configUpdatedAt: null,
toolList: '(none)',
agentPreviewPath: '/projects/project-1/agents/agent-1/preview',
modelRecommendationsSection,
});
}

View File

@ -0,0 +1,6 @@
export function buildAgentPreviewPath(projectId: string, agentId: string): string {
const encodedProjectId = encodeURIComponent(projectId);
const encodedAgentId = encodeURIComponent(agentId);
return `/projects/${encodedProjectId}/agents/${encodedAgentId}/preview`;
}

View File

@ -284,7 +284,8 @@ OpenAI image generation:
{ "providerTools": { "openai.image_generation": {} } }
\`\`\``;
export const CONVERSATION_MODE_SECTION = `\
export function getConversationModeSection(agentPreviewPath: string): string {
return `\
## When to build vs when to converse
Not every user message is a build request. Before calling \`write_config\`,
@ -297,12 +298,15 @@ want the agent to do, what systems it needs to touch, what triggers it. Only
start building once you have a real goal.
If the user tries to test, run, chat with, or interact with the newly built
agent in this Build chat, reply exactly: "Please click the Test toggle next to
Build below to chat with your new agent."
agent in this Build chat, do not call tools. Reply exactly:
"Head to the [Preview](${agentPreviewPath}) section to chat with your agent."
Do not say anything else. Keep the Preview link as a relative app path; do not
expand it to an absolute URL.
Never call \`write_config\` with empty, placeholder, or guessed \`instructions\`.
An agent without real instructions is broken and can't chat. If you don't have
enough detail to write meaningful instructions, ask the user first.`;
}
export const RESEARCH_SECTION = `\
## Research
@ -607,17 +611,25 @@ export interface BuilderPromptContext {
configHash: string | null;
configUpdatedAt: string | null;
toolList: string;
agentPreviewPath: string;
modelRecommendationsSection: string | null;
}
export function buildBuilderPrompt(ctx: BuilderPromptContext): string {
const { configJson, configHash, configUpdatedAt, toolList, modelRecommendationsSection } = ctx;
const {
configJson,
configHash,
configUpdatedAt,
toolList,
agentPreviewPath,
modelRecommendationsSection,
} = ctx;
const sections = [
'You are an expert agent builder. You help users create and configure AI agents by writing raw JSON configuration and building custom tools.',
getAgentStateSection(configJson, configHash, configUpdatedAt, toolList),
READ_CONFIG_SECTION,
CONVERSATION_MODE_SECTION,
getConversationModeSection(agentPreviewPath),
TOOL_TYPES_SECTION,
LLM_RESOLUTION_SECTION,
modelRecommendationsSection,

View File

@ -10,16 +10,18 @@ import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { jsonParse, UserError } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AgentsService } from '../agents.service';
import { composeJsonConfig } from '../json-config/agent-config-composition';
import { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
import { N8nMemory } from '../integrations/n8n-memory';
import type { AgentJsonConfig } from '@n8n/api-types';
import { AgentCheckpointRepository } from '../repositories/agent-checkpoint.repository';
import { buildAgentPreviewPath } from './agent-builder-preview-path';
import { buildBuilderPrompt } from './agents-builder-prompts';
import { AgentsBuilderToolsService, getAgentConfigHash } from './agents-builder-tools.service';
import { AGENT_THREAD_PREFIX } from './builder-tool-names';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AgentsBuilderSettingsService } from './agents-builder-settings.service';
import { buildBuilderTelemetry } from '../tracing/builder-telemetry';
import { getModelRecommendationsSection } from './agents-builder-model-recommendations';
@ -166,6 +168,7 @@ export class AgentsBuilderService {
configHash: getAgentConfigHash(currentConfig),
configUpdatedAt: agent.updatedAt.toISOString(),
toolList,
agentPreviewPath: buildAgentPreviewPath(projectId, agentId),
modelRecommendationsSection,
});

View File

@ -6093,6 +6093,9 @@
"agents.builder.header.switcher.ariaLabel": "Switch agent",
"agents.builder.header.saving": "Saving…",
"agents.builder.header.saved": "Saved",
"agents.builder.preview.button": "Preview",
"agents.builder.preview.disabledTooltip": "Add instructions, a model, and credentials before previewing this agent.",
"agents.builder.preview.close.ariaLabel": "Close preview",
"agents.builder.executions.count": "{count} session | {count} sessions",
"agents.builder.raw.description": "Agent JSON configuration",
"agents.builder.evaluations.comingSoon": "Evaluations functionality is coming soon.",

View File

@ -32,7 +32,18 @@ vi.mock('vue-router', () => ({
vi.mock('@n8n/design-system', () => ({
N8nIcon: { template: '<i v-bind="$attrs"></i>', props: ['icon', 'size'] },
N8nButton: { template: '<button><slot /></button>', props: ['variant', 'size'] },
N8nButton: {
template:
'<button v-bind="$attrs" :data-variant="variant" :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'size', 'icon', 'iconOnly', 'disabled'],
emits: ['click'],
},
N8nTooltip: {
name: 'N8nTooltip',
template:
'<span data-testid="stub-tooltip" :data-disabled="disabled" :data-content="content"><slot /></span>',
props: ['disabled', 'content'],
},
N8nBreadcrumbs: {
name: 'N8nBreadcrumbs',
template: '<div data-testid="stub-breadcrumbs"><slot name="append" /></div>',
@ -41,19 +52,19 @@ vi.mock('@n8n/design-system', () => ({
},
N8nDropdownMenu: {
name: 'N8nDropdownMenu',
template: '<div data-testid="agent-header-switcher"><slot name="trigger" /></div>',
template: '<div v-bind="$attrs"><slot name="trigger" /></div>',
props: ['items'],
emits: ['select'],
},
'n8n-dropdown-menu': {
name: 'N8nDropdownMenu',
template: '<div data-testid="agent-header-switcher"><slot name="trigger" /></div>',
template: '<div v-bind="$attrs"><slot name="trigger" /></div>',
props: ['items'],
emits: ['select'],
},
N8nActionDropdown: {
name: 'ActionDropdown',
template: '<div data-testid="stub-action-dropdown" />',
template: '<div v-bind="$attrs" />',
props: ['items', 'activatorIcon'],
emits: ['select'],
},
@ -78,6 +89,7 @@ const baseAgent = {
id: 'a1',
name: 'Darwin',
icon: { type: 'icon', value: 'robot' },
isRunnable: true,
} as unknown as AgentResource;
const globalStubs = {
@ -94,6 +106,9 @@ function mountHeader(
agent: AgentResource | null;
projectName: string | null;
headerActions: unknown[];
mode: 'edit' | 'preview';
currentSessionTitle: string;
sessionOptions: Array<{ id: string; label: string }>;
}> = {},
) {
return mount(AgentBuilderHeader, {
@ -103,6 +118,9 @@ function mountHeader(
agentId: 'a1',
projectName: 'projectName' in overrides ? (overrides.projectName ?? null) : 'My project',
headerActions: (overrides.headerActions ?? []) as Array<{ id: string; label: string }>,
mode: overrides.mode,
currentSessionTitle: overrides.currentSessionTitle,
sessionOptions: overrides.sessionOptions,
},
global: { stubs: globalStubs },
});
@ -119,6 +137,7 @@ describe('AgentBuilderHeader', () => {
it('renders breadcrumbs, publish and action dropdown', () => {
const wrapper = mountHeader({ headerActions: [{ id: 'delete', label: 'Delete' }] });
expect(wrapper.find('[data-testid="stub-breadcrumbs"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-header-preview-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="stub-publish"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-header-actions"]').exists()).toBe(true);
});
@ -216,6 +235,65 @@ describe('AgentBuilderHeader', () => {
expect(wrapper.emitted('header-action')).toEqual([['delete']]);
});
it('emits open-preview from the preview button', async () => {
const wrapper = mountHeader();
await wrapper.find('[data-testid="agent-header-preview-btn"]').trigger('click');
expect(wrapper.emitted('open-preview')).toEqual([[]]);
});
it('disables preview with a tooltip when the agent is not runnable', async () => {
const wrapper = mountHeader({
agent: { ...baseAgent, isRunnable: false } as AgentResource,
});
const previewButton = wrapper.find('[data-testid="agent-header-preview-btn"]');
expect(previewButton.attributes('disabled')).toBeDefined();
expect(wrapper.find('[data-testid="stub-tooltip"]').attributes('data-disabled')).toBe('false');
expect(wrapper.find('[data-testid="stub-tooltip"]').attributes('data-content')).toBe(
'agents.builder.preview.disabledTooltip',
);
await previewButton.trigger('click');
expect(wrapper.emitted('open-preview')).toBeUndefined();
});
it('renders preview actions and hides publish actions in preview mode', () => {
const wrapper = mountHeader({
mode: 'preview',
currentSessionTitle: 'Support session',
sessionOptions: [{ id: 'thread-1', label: 'Support session' }],
headerActions: [{ id: 'delete', label: 'Delete' }],
});
expect(wrapper.find('[data-testid="agent-preview-session-picker"]').exists()).toBe(true);
expect(
wrapper.find('[data-testid="agent-preview-new-chat-btn"]').attributes('data-variant'),
).toBe('outline');
expect(wrapper.find('[data-testid="agent-preview-close-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="stub-publish"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-header-actions"]').exists()).toBe(false);
});
it('forwards preview session and header action events', async () => {
const wrapper = mountHeader({
mode: 'preview',
currentSessionTitle: 'Support session',
sessionOptions: [{ id: 'thread-1', label: 'Support session' }],
});
const sessionPicker = wrapper.findComponent(
'[data-testid="agent-preview-session-picker"]',
) as DropdownStubWrapper;
sessionPicker.vm.$emit('select', 'thread-1');
await wrapper.find('[data-testid="agent-preview-new-chat-btn"]').trigger('click');
await wrapper.find('[data-testid="agent-preview-close-btn"]').trigger('click');
expect(wrapper.emitted('session-select')).toEqual([['thread-1']]);
expect(wrapper.emitted('new-chat')).toEqual([[]]);
expect(wrapper.emitted('close-preview')).toEqual([[]]);
});
it('emits switch-agent when a switcher item is selected', async () => {
agentsListRef.value = [baseAgent, { id: 'a2', name: 'Other' } as unknown as AgentResource];
ensureLoadedMock.mockResolvedValue(agentsListRef.value);

View File

@ -8,6 +8,7 @@ import type { AgentJsonSkillRef, AgentJsonToolRef, CustomToolEntry } from '../ty
const routerPush = vi.fn();
const routerReplace = vi.fn();
const routeQuery: Record<string, string | undefined> = {};
let routeName = 'AgentBuilderView';
const openModalWithDataMock = vi.fn();
const closeModalMock = vi.fn();
const showMessageMock = vi.fn();
@ -24,7 +25,11 @@ const {
}));
vi.mock('vue-router', () => ({
useRouter: () => ({ push: routerPush, replace: routerReplace }),
useRoute: () => ({ params: { projectId: 'p1', agentId: 'a1' }, query: routeQuery }),
useRoute: () => ({
name: routeName,
params: { projectId: 'p1', agentId: 'a1' },
query: routeQuery,
}),
onBeforeRouteLeave: vi.fn(),
onBeforeRouteUpdate: vi.fn(),
RouterLink: { template: '<a><slot/></a>' },
@ -205,6 +210,8 @@ const baseTextFn = (key: string) => {
'agents.builder.chatMode.ariaLabel': 'Switch chat mode',
'agents.builder.chat.fullWidth.expand.ariaLabel': 'Expand',
'agents.builder.chat.fullWidth.collapse.ariaLabel': 'Collapse',
'agents.builder.preview.button': 'Preview',
'agents.builder.preview.close.ariaLabel': 'Close preview',
'projects.menu.personal': 'Personal',
};
return map[key] ?? key;
@ -254,8 +261,24 @@ const commonStubs = {
'agentConfig',
'agentStatus',
'connectedTriggers',
'continueSessionId',
],
},
AgentPreviewChatPage: {
name: 'AgentPreviewChatPage',
template: '<div data-testid="stub-agent-preview-chat-page" />',
props: [
'initialized',
'projectId',
'agentId',
'agent',
'localConfig',
'connectedTriggers',
'effectiveSessionId',
'initialPrompt',
],
emits: ['config-updated', 'continue-loaded', 'open-build'],
},
AgentConfigTree: {
name: 'AgentConfigTree',
template: '<div data-testid="stub-agent-config-tree" />',
@ -284,9 +307,22 @@ const commonStubs = {
'agentId',
'projectName',
'headerActions',
'mode',
'currentSessionTitle',
'sessionOptions',
'beforeRevertToPublished',
],
emits: ['header-action', 'published', 'unpublished', 'reverted', 'switch-agent'],
emits: [
'header-action',
'open-preview',
'new-chat',
'close-preview',
'session-select',
'published',
'unpublished',
'reverted',
'switch-agent',
],
},
// Stub each panel that the editor column dispatches to. These panels pull
// in stores / composables (users, chatHub, credentials, sessions list)
@ -348,7 +384,7 @@ const commonStubs = {
Transition: { template: '<div><slot/></div>' },
};
describe('AgentBuilderView — chat mode toggle', () => {
describe('AgentBuilderView — preview routing', () => {
// First Vite transform of this SFC + design-system deps can exceed the default
// 5s test timeout; warm the module once so each case measures mount behavior.
beforeAll(async () => {
@ -361,6 +397,7 @@ describe('AgentBuilderView — chat mode toggle', () => {
routerReplace.mockReset();
openModalWithDataMock.mockReset();
closeModalMock.mockReset();
routeName = 'AgentBuilderView';
for (const key of Object.keys(routeQuery)) delete routeQuery[key];
sessionThreads.length = 0;
sessionStorage.removeItem('N8N_DEBOUNCE_MULTIPLIER');
@ -376,15 +413,15 @@ describe('AgentBuilderView — chat mode toggle', () => {
getIntegrationStatusMock.mockResolvedValue({ status: 'ok', integrations: [] });
});
it('renders the chat mode toggle with Build selected by default', async () => {
// Built agents default to Build unless the URL pins a session id; see
// AgentBuilderView.initialize() for the canonical decision.
it('renders the build chat in the editing experience without the old mode toggle', async () => {
const wrapper = await renderView();
const toggle = wrapper.find('[data-testid="agent-chat-mode-toggle"]');
expect(toggle.exists()).toBe(true);
const vm = wrapper.vm as unknown as { chatMode: string };
expect(vm.chatMode).toBe('build');
expect(wrapper.find('[data-testid="agent-builder-chat-column"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="chat-panel-stub"][data-endpoint="build"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-testid="agent-chat-mode-toggle"]').exists()).toBe(false);
});
it('loads credentials through the workflow-scoped credentials endpoint for the agent project', async () => {
@ -395,154 +432,67 @@ describe('AgentBuilderView — chat mode toggle', () => {
expect(fetchAllCredentialsMock).not.toHaveBeenCalled();
});
it('lazy-mounts each chat panel on first activation and toggles visibility via v-show afterwards', async () => {
// Default mount is Build (see prior test). Switching to Test mounts the
// test panel for the first time; flipping back to Build keeps both
// mounted so neither panel re-runs loadHistory() on toggle.
it('renders only the full-page preview chat on the preview route', async () => {
routeName = 'AgentPreviewView';
routeQuery.continueSessionId = 'thread-1';
const wrapper = await renderView();
const vm = wrapper.vm as unknown as { activeChatSessionId: string | null };
const preview = wrapper.findComponent({ name: 'AgentPreviewChatPage' });
const header = wrapper.findComponent({ name: 'AgentBuilderHeader' });
const buildPanel = wrapper.find('[data-testid="chat-panel-stub"][data-endpoint="build"]');
expect(buildPanel.exists()).toBe(true);
expect(wrapper.find('[data-testid="chat-panel-stub"][data-endpoint="chat"]').exists()).toBe(
false,
);
expect((buildPanel.element as HTMLElement).style.display).not.toBe('none');
// Test requires an active session (a real user reaches this by sending
// a message from the home input). Seed it so the chat panel binds.
vm.activeChatSessionId = 'test-session-1';
(wrapper.vm as unknown as { setChatMode: (m: string) => void }).setChatMode('test');
await nextTick();
const testPanel = wrapper.find('[data-testid="chat-panel-stub"][data-endpoint="chat"]');
expect(testPanel.exists()).toBe(true);
expect((buildPanel.element as HTMLElement).style.display).toBe('none');
expect((testPanel.element as HTMLElement).style.display).not.toBe('none');
// Switching back to Build should not unmount Test — both panels stay
// mounted once opened.
(wrapper.vm as unknown as { setChatMode: (m: string) => void }).setChatMode('build');
await nextTick();
expect(wrapper.find('[data-testid="chat-panel-stub"][data-endpoint="chat"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-testid="chat-panel-stub"][data-endpoint="build"]').exists()).toBe(
true,
);
expect((buildPanel.element as HTMLElement).style.display).not.toBe('none');
expect((testPanel.element as HTMLElement).style.display).toBe('none');
expect(preview.exists()).toBe(true);
expect(preview.props('effectiveSessionId')).toBe('thread-1');
expect(header.props('mode')).toBe('preview');
expect(wrapper.find('[data-testid="agent-builder-chat-column"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(false);
});
it('drops unbuilt agents straight into the build chat on load', async () => {
// Unbuilt agents go to the build chat unconditionally so the build
// panel mounts, triggers loadHistory, and any prior conversation with
// the builder is visible instead of being stranded behind the home
// screen (where the Test tab is locked and clicking Build is a no-op).
// the builder is visible.
intendedConfig = { name: 'Agent One', instructions: '' };
mockConfig.value = withDefaultLlm(intendedConfig);
getAgentMock.mockResolvedValue(makeAgentResponse({ isRunnable: false }));
const wrapper = await renderView();
const vm = wrapper.vm as unknown as { chatMode: string };
// The view no longer has a `mode: 'home' | 'chat'` field — the three-column
// shell is always visible. We verify only the chat tab selection.
expect(vm.chatMode).toBe('build');
expect(wrapper.find('[data-testid="chat-panel-stub"][data-endpoint="build"]').exists()).toBe(
true,
);
});
it('initialises built agents with the build tab selected', async () => {
it('opens the preview route from the header preview action', async () => {
const wrapper = await renderView();
const vm = wrapper.vm as unknown as { chatMode: string };
const header = wrapper.findComponent({ name: 'AgentBuilderHeader' });
// The view no longer has a `mode: 'home' | 'chat'` field — the three-column
// shell is always visible. Built agents default to Build unless the URL
// pins a session id (see initialize() in AgentBuilderView).
expect(vm.chatMode).toBe('build');
header.vm.$emit('open-preview');
await flushPromises();
expect(routerPush).toHaveBeenCalledWith(
expect.objectContaining({
name: 'AgentPreviewView',
params: { projectId: 'p1', agentId: 'a1' },
query: expect.objectContaining({ continueSessionId: expect.any(String) }),
}),
);
});
it('locks the Test tab when the agent has no instructions', async () => {
intendedConfig = { name: 'Agent One', instructions: '' };
mockConfig.value = withDefaultLlm(intendedConfig);
it('does not open preview when the agent is not runnable', async () => {
getAgentMock.mockResolvedValue(makeAgentResponse({ isRunnable: false }));
const wrapper = await renderView();
const vm = wrapper.vm as unknown as { chatMode: string };
const header = wrapper.findComponent({ name: 'AgentBuilderHeader' });
// Get into Build mode first (it's clickable on any agent state).
(wrapper.vm as unknown as { setChatMode: (m: string) => void }).setChatMode('build');
await nextTick();
expect(vm.chatMode).toBe('build');
expect(header.props('agent')).toEqual(expect.objectContaining({ isRunnable: false }));
// Clicking Test on an unbuilt agent must be a no-op — the RadioButton
// option is disabled and the click handler returns early.
(wrapper.vm as unknown as { setChatMode: (m: string) => void }).setChatMode('test');
await nextTick();
expect(vm.chatMode).toBe('build');
});
header.vm.$emit('open-preview');
await flushPromises();
it('locks the Test tab when the agent has instructions but no LLM credential', async () => {
intendedConfig = {
name: 'Agent One',
model: '',
credential: undefined,
instructions: 'You are a helpful assistant.',
};
mockConfig.value = withDefaultLlm(intendedConfig);
getAgentMock.mockResolvedValue(makeAgentResponse({ isRunnable: false }));
const wrapper = await renderView();
const vm = wrapper.vm as unknown as { chatMode: string; isBuilt: boolean };
expect(vm.isBuilt).toBe(false);
(wrapper.vm as unknown as { setChatMode: (m: string) => void }).setChatMode('test');
await nextTick();
expect(vm.chatMode).toBe('build');
});
it('locks the Test tab when the agent has an invalid LLM model string', async () => {
intendedConfig = {
name: 'Agent One',
model: 'openai/',
credential: 'cred-openai',
instructions: 'You are a helpful assistant.',
};
mockConfig.value = withDefaultLlm(intendedConfig);
getAgentMock.mockResolvedValue(makeAgentResponse({ isRunnable: false }));
const wrapper = await renderView();
const vm = wrapper.vm as unknown as { chatMode: string; isBuilt: boolean };
expect(vm.isBuilt).toBe(false);
(wrapper.vm as unknown as { setChatMode: (m: string) => void }).setChatMode('test');
await nextTick();
expect(vm.chatMode).toBe('build');
});
it('transitions to test chat when a toggle segment is clicked', async () => {
// The view defaults to Build for built agents; clicking Test must
// switch chatMode and mount the test panel.
const wrapper = await renderView();
const vm = wrapper.vm as unknown as {
chatMode: string;
activeChatSessionId: string | null;
};
expect(vm.chatMode).toBe('build');
// Test mode requires an active session for the panel to bind.
vm.activeChatSessionId = 'test-session-1';
(wrapper.vm as unknown as { setChatMode: (m: string) => void }).setChatMode('test');
await nextTick();
expect(vm.chatMode).toBe('test');
const testPanel = wrapper.find('[data-testid="chat-panel-stub"][data-endpoint="chat"]');
expect(testPanel.exists()).toBe(true);
expect((testPanel.element as HTMLElement).style.display).not.toBe('none');
expect(routerPush).not.toHaveBeenCalled();
});
it('keeps a known continued session selected even when it has no persisted messages', async () => {
routeName = 'AgentPreviewView';
routeQuery.continueSessionId = 'faulty-thread';
sessionThreads.push({ id: 'faulty-thread', updatedAt: '2026-01-01T00:00:00Z' });
@ -554,7 +504,9 @@ describe('AgentBuilderView — chat mode toggle', () => {
await flushPromises();
expect(routerReplace).not.toHaveBeenCalled();
expect((wrapper.vm as unknown as { chatMode: string }).chatMode).toBe('test');
expect(
wrapper.findComponent({ name: 'AgentPreviewChatPage' }).props('effectiveSessionId'),
).toBe('faulty-thread');
});
it('navigates directly to build chat on startChat for an unbuilt agent', async () => {
@ -564,7 +516,6 @@ describe('AgentBuilderView — chat mode toggle', () => {
const wrapper = await renderView();
const vm = wrapper.vm as unknown as {
chatMode: string;
startChat: (msg: string) => void;
isBuilt: boolean;
};
@ -575,10 +526,6 @@ describe('AgentBuilderView — chat mode toggle', () => {
vm.startChat('Build me a Slack triage agent');
await nextTick();
// The three-column shell is always visible (no separate `mode`
// state machine); startChat just selects the build chat tab.
expect(vm.chatMode).toBe('build');
// No progress screen rendered
expect(wrapper.find('[data-testid="progress-stub"]').exists()).toBe(false);
@ -630,6 +577,7 @@ describe('AgentBuilderView — three-column shell', () => {
routerReplace.mockReset();
openModalWithDataMock.mockReset();
closeModalMock.mockReset();
routeName = 'AgentBuilderView';
for (const key of Object.keys(routeQuery)) delete routeQuery[key];
sessionThreads.length = 0;
sessionStorage.removeItem('N8N_DEBOUNCE_MULTIPLIER');
@ -675,14 +623,14 @@ describe('AgentBuilderView — three-column shell', () => {
expect(wrapper.find('[data-testid="agent-build-chat-full-width-toggle"]').exists()).toBe(true);
});
it('renders the Build/Test toggle inside the chat input footer', async () => {
it('does not render the old Build/Test toggle inside the chat input footer', async () => {
const wrapper = await renderView();
const chatPanel = wrapper.find('[data-testid="chat-panel-stub"][data-endpoint="build"]');
expect(
chatPanel
.find('[data-testid="stub-footer-start"] [data-testid="agent-chat-mode-toggle"]')
.exists(),
).toBe(true);
).toBe(false);
expect(
chatPanel
.find('[data-testid="stub-above-input"] [data-testid="agent-chat-mode-toggle"]')

View File

@ -1,10 +1,16 @@
import { describe, it, expect } from 'vitest';
import { AGENTS_LIST_VIEW, AGENT_BUILDER_VIEW, PROJECT_AGENTS } from '../constants';
import {
AGENTS_LIST_VIEW,
AGENT_BUILDER_VIEW,
AGENT_PREVIEW_VIEW,
PROJECT_AGENTS,
} from '../constants';
describe('Agent constants', () => {
it('exports all required route names', () => {
expect(AGENTS_LIST_VIEW).toBe('AgentsListView');
expect(AGENT_BUILDER_VIEW).toBe('AgentBuilderView');
expect(AGENT_PREVIEW_VIEW).toBe('AgentPreviewView');
expect(PROJECT_AGENTS).toBe('ProjectAgents');
});
});

View File

@ -1,14 +1,11 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { N8nButton, N8nDropdownMenu, N8nIcon, N8nTooltip } from '@n8n/design-system';
import type { DropdownMenuItemProps } from '@n8n/design-system';
import { N8nButton, N8nIcon, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { deriveAgentStatus } from '../composables/agentTelemetry.utils';
import type { ChatMode } from '../composables/useAgentChatMode';
import type { AgentJsonConfig, AgentJsonToolRef, AgentResource } from '../types';
import AgentBuilderUnconfiguredEmptyState from './AgentBuilderUnconfiguredEmptyState.vue';
import AgentBuilderChatModeToggle from './AgentBuilderChatModeToggle.vue';
import AgentChatPanel from './AgentChatPanel.vue';
import AgentChatQuickActions from './AgentChatQuickActions.vue';
@ -20,17 +17,8 @@ const props = defineProps<{
agent: AgentResource | null;
localConfig: AgentJsonConfig | null;
connectedTriggers: string[];
chatMode: ChatMode;
chatModeOpened: Record<ChatMode, boolean>;
chatModeOptions: Array<{ label: string; value: ChatMode; disabled?: boolean }>;
effectiveSessionId?: string;
currentSessionTitle: string;
currentSessionHasMessages: boolean;
sessionOptions: Array<DropdownMenuItemProps<string>>;
initialPrompt?: string;
isBuilt: boolean;
isBuilderConfigured: boolean;
isBuildChatStreaming: boolean;
isPublished: boolean;
isFullWidth: boolean;
canEditAgent: boolean;
@ -38,12 +26,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
'session-select': [sessionId: string];
'new-chat': [];
'config-updated': [];
'continue-loaded': [count: number];
'open-build': [];
'chat-mode-change': [mode: ChatMode];
'update:streaming': [streaming: boolean];
'update:tools': [tools: AgentJsonToolRef[]];
'update:connected-triggers': [triggers: string[]];
@ -53,10 +36,6 @@ const emit = defineEmits<{
}>();
const i18n = useI18n();
const sessionMenuMaxHeight = 'calc((var(--spacing--xl) * 5) + var(--spacing--xs))';
// `sessionOptions` already match `DropdownMenuItemProps`; alias for template clarity.
const sessionMenuItems = computed<Array<DropdownMenuItemProps<string>>>(() => props.sessionOptions);
const fullWidthToggleLabel = computed(() =>
i18n.baseText(
@ -66,8 +45,6 @@ const fullWidthToggleLabel = computed(() =>
),
);
// Shared draft text across Build and Test inputs so switching modes preserves
// what the user typed. The two AgentChatPanel instances bind to the same ref.
const sharedInputDraft = ref('');
</script>
@ -77,120 +54,30 @@ const sharedInputDraft = ref('');
:aria-label="i18n.baseText('agents.builder.chatColumn.ariaLabel')"
data-testid="agent-builder-chat-column"
>
<div
v-if="initialized && chatMode === 'test' && effectiveSessionId"
:class="$style.sessionHeader"
data-testid="agent-chat-session-header"
>
<N8nDropdownMenu
:items="sessionMenuItems"
:max-height="sessionMenuMaxHeight"
:extra-popper-class="$style.sessionMenu"
placement="bottom-start"
data-testid="agent-chat-session-picker"
@select="emit('session-select', $event)"
>
<template #trigger>
<N8nButton
variant="ghost"
size="small"
:class="$style.sessionTitleBtn"
:aria-label="i18n.baseText('agents.builder.chat.sessionPicker.ariaLabel')"
>
{{ currentSessionTitle }}
<N8nIcon icon="chevron-down" color="text-light" :size="12" />
</N8nButton>
</template>
</N8nDropdownMenu>
<div :class="$style.sessionActions">
<N8nTooltip
placement="left"
:content="i18n.baseText('agents.builder.chat.newChat.ariaLabel')"
<span v-if="initialized" :class="$style.floatingFullWidthToggle">
<N8nTooltip placement="left" :content="fullWidthToggleLabel">
<N8nButton
variant="ghost"
icon-only
size="small"
:class="$style.headerIconBtn"
:aria-label="fullWidthToggleLabel"
data-testid="agent-build-chat-full-width-toggle"
@click="emit('update:full-width', !isFullWidth)"
>
<N8nButton
v-if="currentSessionHasMessages"
variant="ghost"
icon-only
size="small"
:class="$style.headerIconBtn"
:aria-label="i18n.baseText('agents.builder.chat.newChat.ariaLabel')"
data-testid="agent-chat-new-chat-btn"
@click="emit('new-chat')"
>
<N8nIcon icon="plus" :size="14" />
</N8nButton>
</N8nTooltip>
<N8nTooltip placement="left" :content="fullWidthToggleLabel">
<N8nButton
variant="ghost"
icon-only
size="small"
:class="$style.headerIconBtn"
:aria-label="fullWidthToggleLabel"
data-testid="agent-chat-full-width-toggle"
@click="emit('update:full-width', !isFullWidth)"
>
<N8nIcon :icon="isFullWidth ? 'minimize-2' : 'maximize-2'" :size="14" />
</N8nButton>
</N8nTooltip>
</div>
</div>
<N8nTooltip
v-if="initialized && chatMode === 'build'"
placement="left"
:content="fullWidthToggleLabel"
>
<N8nButton
variant="ghost"
icon-only
size="small"
:class="[$style.headerIconBtn, $style.floatingFullWidthToggle]"
:aria-label="fullWidthToggleLabel"
data-testid="agent-build-chat-full-width-toggle"
@click="emit('update:full-width', !isFullWidth)"
>
<N8nIcon :icon="isFullWidth ? 'minimize-2' : 'maximize-2'" :size="14" />
</N8nButton>
</N8nTooltip>
<N8nIcon :icon="isFullWidth ? 'minimize-2' : 'maximize-2'" :size="14" />
</N8nButton>
</N8nTooltip>
</span>
<div :class="$style.chatBody">
<AgentChatPanel
v-if="initialized && chatModeOpened.test && effectiveSessionId"
v-show="chatMode === 'test'"
:key="`test-${effectiveSessionId}`"
v-model:input-draft="sharedInputDraft"
:project-id="projectId"
:agent-id="agentId"
mode="inline"
endpoint="chat"
:initial-message="initialPrompt"
:continue-session-id="effectiveSessionId"
:agent-config="localConfig"
:agent-status="deriveAgentStatus(agent)"
:connected-triggers="connectedTriggers"
@config-updated="emit('config-updated')"
@continue-loaded="emit('continue-loaded', $event)"
@open-build="emit('open-build')"
>
<template #footer-start>
<AgentBuilderChatModeToggle
v-if="initialized"
:model-value="chatMode"
:options="chatModeOptions"
:is-built="isBuilt"
:is-build-chat-streaming="isBuildChatStreaming"
@update:model-value="emit('chat-mode-change', $event)"
/>
</template>
</AgentChatPanel>
<AgentChatPanel
v-if="initialized && chatModeOpened.build"
v-show="chatMode === 'build' && isBuilderConfigured"
v-if="initialized && isBuilderConfigured"
v-model:input-draft="sharedInputDraft"
:project-id="projectId"
:agent-id="agentId"
mode="inline"
endpoint="build"
:initial-message="chatMode === 'build' ? initialPrompt : undefined"
:initial-message="initialPrompt"
:agent-config="localConfig"
:agent-status="deriveAgentStatus(agent)"
:connected-triggers="connectedTriggers"
@ -215,18 +102,8 @@ const sharedInputDraft = ref('');
/>
</div>
</template>
<template #footer-start>
<AgentBuilderChatModeToggle
v-if="initialized"
:model-value="chatMode"
:options="chatModeOptions"
:is-built="isBuilt"
:is-build-chat-streaming="isBuildChatStreaming"
@update:model-value="emit('chat-mode-change', $event)"
/>
</template>
</AgentChatPanel>
<AgentBuilderUnconfiguredEmptyState v-if="chatModeOpened.build && !isBuilderConfigured" />
<AgentBuilderUnconfiguredEmptyState v-if="initialized && !isBuilderConfigured" />
</div>
</aside>
</template>
@ -252,30 +129,6 @@ const sharedInputDraft = ref('');
width: 100%;
}
.sessionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
padding: var(--spacing--3xs) var(--spacing--sm);
height: var(--height--2xl);
border-bottom: var(--border);
min-height: 36px;
}
.sessionTitleBtn {
gap: var(--spacing--4xs);
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--bold);
margin-left: calc(var(--spacing--5xs) * -1);
}
.sessionActions {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
}
.headerIconBtn {
color: var(--text-color--subtle);
@ -290,13 +143,7 @@ const sharedInputDraft = ref('');
top: var(--spacing--2xs);
right: var(--spacing--sm);
z-index: 2;
}
.sessionMenu {
width: min(
calc(var(--spacing--5xl) + var(--spacing--3xl)),
calc(100vw - var(--spacing--xl))
) !important;
display: flex;
}
.chatBody {

View File

@ -1,76 +0,0 @@
<script setup lang="ts">
import { N8nIcon, N8nRadioButtons, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { ChatMode } from '../composables/useAgentChatMode';
defineProps<{
modelValue: ChatMode;
options: Array<{ label: string; value: ChatMode; disabled?: boolean }>;
isBuilt: boolean;
isBuildChatStreaming: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [value: ChatMode];
}>();
const i18n = useI18n();
</script>
<template>
<N8nTooltip
:class="$style.chatModeToggle"
:disabled="isBuilt"
:content="i18n.baseText('agents.builder.chatMode.test.lockedTooltip')"
:show-after="100"
placement="top"
>
<N8nRadioButtons
:model-value="modelValue"
:options="options"
:aria-label="i18n.baseText('agents.builder.chatMode.ariaLabel')"
data-testid="agent-chat-mode-toggle"
@update:model-value="emit('update:modelValue', $event)"
>
<template #option="option">
<span :class="$style.chatModeOption">
<N8nIcon
v-if="option.value === 'build' && isBuildChatStreaming"
icon="loader-circle"
:size="14"
:spin="true"
/>
<N8nIcon
v-else-if="option.value === 'test' && !isBuilt"
icon="triangle-alert"
:size="14"
:class="$style.chatModeLockedIcon"
/>
<N8nIcon
v-else
:icon="option.value === 'build' ? 'wand-sparkles' : 'message-square'"
:size="14"
/>
<span>{{ option.label }}</span>
</span>
</template>
</N8nRadioButtons>
</N8nTooltip>
</template>
<style lang="scss" module>
.chatModeToggle {
flex-shrink: 0;
}
.chatModeOption {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
}
.chatModeLockedIcon {
color: var(--text-color--warning);
}
</style>

View File

@ -14,11 +14,12 @@ import {
N8nButton,
N8nDropdownMenu,
N8nIcon,
N8nTooltip,
} from '@n8n/design-system';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import type { DropdownMenuItemProps } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types/action-dropdown';
import { useI18n } from '@n8n/i18n';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { VIEWS } from '@/app/constants';
import AgentPublishButton from './AgentPublishButton.vue';
@ -32,11 +33,18 @@ const props = defineProps<{
projectName: string | null;
headerActions: Array<ActionDropdownItem<string>>;
saveStatus?: 'idle' | 'saving' | 'saved';
mode?: 'edit' | 'preview';
currentSessionTitle?: string;
sessionOptions?: Array<DropdownMenuItemProps<string>>;
beforeRevertToPublished?: () => Promise<void> | void;
}>();
const emit = defineEmits<{
'header-action': [item: string];
'open-preview': [];
'new-chat': [];
'close-preview': [];
'session-select': [sessionId: string];
published: [agent: AgentResource];
unpublished: [agent: AgentResource];
reverted: [agent: AgentResource];
@ -47,6 +55,7 @@ const i18n = useI18n();
const router = useRouter();
const { list: agentsList, ensureLoaded } = useProjectAgentsList(computed(() => props.projectId));
const sessionMenuMaxHeight = 'calc((var(--spacing--xl) * 5) + var(--spacing--xs))';
onMounted(() => {
void ensureLoaded();
@ -67,6 +76,24 @@ const breadcrumbItems = computed<PathItem[]>(() => [
]);
const agentDisplayName = computed(() => props.agent?.name ?? '…');
const isPreview = computed(() => props.mode === 'preview');
const isPreviewDisabled = computed(() => props.agent?.isRunnable !== true);
const previewDisabledTooltip = computed(() =>
i18n.baseText('agents.builder.preview.disabledTooltip' as BaseTextKey),
);
const sessionTitle = computed(
() => props.currentSessionTitle ?? i18n.baseText('agents.builder.chat.newChat.label'),
);
const sessionOptions = computed<Array<DropdownMenuItemProps<string>>>(() => {
if (props.sessionOptions && props.sessionOptions.length > 0) return props.sessionOptions;
return [
{
id: '__empty__',
label: i18n.baseText('agents.builder.chat.sessionPicker.empty'),
disabled: true,
},
];
});
const switcherOptions = computed<Array<DropdownMenuItemProps<string>>>(() => {
const list = agentsList.value ?? [];
@ -95,6 +122,11 @@ function onBreadcrumbSelect(item: PathItem) {
if (item.id !== props.projectId) return;
void router.push(projectRoute.value);
}
function onOpenPreview() {
if (isPreviewDisabled.value) return;
emit('open-preview');
}
</script>
<template>
@ -115,44 +147,106 @@ function onBreadcrumbSelect(item: PathItem) {
:class="$style.switcherButton"
:aria-label="i18n.baseText('agents.builder.header.switcher.ariaLabel')"
>
<span :class="$style.switcherLabel">{{ agentDisplayName }}</span>
<span :class="[$style.switcherLabel, $style.agentSwitcherLabel]">
{{ agentDisplayName }}
</span>
<N8nIcon icon="chevron-down" :size="12" />
</N8nButton>
</template>
</N8nDropdownMenu>
<template v-if="isPreview">
<span :class="$style.crumbSeparator" aria-hidden="true">/</span>
<N8nDropdownMenu
:items="sessionOptions"
:max-height="sessionMenuMaxHeight"
:extra-popper-class="$style.sessionMenu"
placement="bottom-start"
data-testid="agent-preview-session-picker"
@select="emit('session-select', $event)"
>
<template #trigger>
<N8nButton
variant="ghost"
size="small"
:class="$style.switcherButton"
:aria-label="i18n.baseText('agents.builder.chat.sessionPicker.ariaLabel')"
>
<span :class="[$style.switcherLabel, $style.previewSessionLabel]">
{{ sessionTitle }}
</span>
<N8nIcon icon="chevron-down" :size="12" />
</N8nButton>
</template>
</N8nDropdownMenu>
</template>
</template>
</N8nBreadcrumbs>
</div>
<div :class="$style.right">
<span
v-if="saveStatus === 'saving' || saveStatus === 'saved'"
:class="$style.saveStatus"
data-testid="agent-header-save-status"
>
{{
saveStatus === 'saving'
? i18n.baseText('agents.builder.header.saving')
: i18n.baseText('agents.builder.header.saved')
}}
</span>
<AgentPublishButton
:agent="agent"
:project-id="projectId"
:agent-id="agentId"
:is-saving="saveStatus === 'saving'"
:before-revert-to-published="beforeRevertToPublished"
@published="(a: AgentResource) => emit('published', a)"
@unpublished="(a: AgentResource) => emit('unpublished', a)"
@reverted="(a: AgentResource) => emit('reverted', a)"
/>
<N8nActionDropdown
v-if="headerActions.length > 0"
:items="headerActions"
activator-icon="ellipsis"
activator-size="medium"
data-testid="agent-header-actions"
@select="(item: string) => emit('header-action', item)"
/>
<template v-if="isPreview">
<N8nButton
variant="outline"
size="medium"
icon="plus"
data-testid="agent-preview-new-chat-btn"
@click="emit('new-chat')"
>
{{ i18n.baseText('agents.builder.chat.newChat.label') }}
</N8nButton>
<N8nButton
variant="ghost"
icon-only
size="medium"
:aria-label="i18n.baseText('agents.builder.preview.close.ariaLabel' as BaseTextKey)"
data-testid="agent-preview-close-btn"
@click="emit('close-preview')"
>
<N8nIcon icon="x" :size="16" />
</N8nButton>
</template>
<template v-else>
<span
v-if="saveStatus === 'saving' || saveStatus === 'saved'"
:class="$style.saveStatus"
data-testid="agent-header-save-status"
>
{{
saveStatus === 'saving'
? i18n.baseText('agents.builder.header.saving')
: i18n.baseText('agents.builder.header.saved')
}}
</span>
<N8nTooltip :disabled="!isPreviewDisabled" :content="previewDisabledTooltip">
<N8nButton
variant="ghost"
size="medium"
icon="play"
:disabled="isPreviewDisabled"
data-testid="agent-header-preview-btn"
@click="onOpenPreview"
>
{{ i18n.baseText('agents.builder.preview.button' as BaseTextKey) }}
</N8nButton>
</N8nTooltip>
<AgentPublishButton
:agent="agent"
:project-id="projectId"
:agent-id="agentId"
:is-saving="saveStatus === 'saving'"
:before-revert-to-published="beforeRevertToPublished"
@published="(a: AgentResource) => emit('published', a)"
@unpublished="(a: AgentResource) => emit('unpublished', a)"
@reverted="(a: AgentResource) => emit('reverted', a)"
/>
<N8nActionDropdown
v-if="headerActions.length > 0"
:items="headerActions"
activator-icon="ellipsis"
activator-size="medium"
data-testid="agent-header-actions"
@select="(item: string) => emit('header-action', item)"
/>
</template>
</div>
</header>
</template>
@ -172,6 +266,16 @@ function onBreadcrumbSelect(item: PathItem) {
.left {
display: flex;
align-items: center;
flex: 1 1 auto;
min-width: 0;
}
.left :global(.n8n-breadcrumbs) {
min-width: 0;
}
.left :global(.n8n-breadcrumbs [data-test-id='breadcrumbs-item'] *) {
line-height: var(--line-height--lg);
}
.crumbSeparator {
@ -183,14 +287,23 @@ function onBreadcrumbSelect(item: PathItem) {
.switcherButton {
font-size: var(--font-size--sm);
gap: var(--spacing--4xs);
margin-top: var(--spacing--5xs);
line-height: var(--line-height--lg);
}
.switcherLabel {
max-width: 200px;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: var(--line-height--lg);
}
.agentSwitcherLabel {
max-width: 240px;
}
.previewSessionLabel {
max-width: clamp(320px, 42vw, 640px);
}
.right {
@ -198,6 +311,7 @@ function onBreadcrumbSelect(item: PathItem) {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
flex-shrink: 0;
}
.saveStatus {
@ -205,4 +319,11 @@ function onBreadcrumbSelect(item: PathItem) {
color: var(--text-color--subtle);
user-select: none;
}
.sessionMenu {
width: min(
calc(var(--spacing--5xl) + var(--spacing--3xl)),
calc(100vw - var(--spacing--xl))
) !important;
}
</style>

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref } from 'vue';
import { deriveAgentStatus } from '../composables/agentTelemetry.utils';
import type { AgentJsonConfig, AgentResource } from '../types';
import AgentChatPanel from './AgentChatPanel.vue';
defineProps<{
initialized: boolean;
projectId: string;
agentId: string;
agent: AgentResource | null;
localConfig: AgentJsonConfig | null;
connectedTriggers: string[];
effectiveSessionId?: string;
initialPrompt?: string;
}>();
const emit = defineEmits<{
'config-updated': [];
'continue-loaded': [count: number];
'open-build': [];
}>();
const inputDraft = ref('');
</script>
<template>
<main :class="$style.previewPage" data-testid="agent-preview-chat-page">
<div :class="$style.chatFrame">
<AgentChatPanel
v-if="initialized && effectiveSessionId"
:key="`preview-${effectiveSessionId}`"
v-model:input-draft="inputDraft"
:project-id="projectId"
:agent-id="agentId"
mode="inline"
endpoint="chat"
:initial-message="initialPrompt"
:continue-session-id="effectiveSessionId"
:agent-config="localConfig"
:agent-status="deriveAgentStatus(agent)"
:connected-triggers="connectedTriggers"
@config-updated="emit('config-updated')"
@continue-loaded="emit('continue-loaded', $event)"
@open-build="emit('open-build')"
/>
</div>
</main>
</template>
<style lang="scss" module>
.previewPage {
flex: 1;
min-height: 0;
display: flex;
justify-content: center;
background-color: var(--background--surface);
overflow: hidden;
}
.chatFrame {
width: 100%;
max-width: 45rem;
min-height: 0;
display: flex;
}
</style>

View File

@ -10,11 +10,10 @@ import { useThreadTitle } from '../utils/thread-title';
import { useRelativeTimestamp } from '../utils/relative-time';
/**
* Max chars for session-name display in the chat-header dropdown trigger and
* its menu rows. Long titles otherwise wrap and push the "new chat" button
* onto a second line.
* Max chars for session-name display in the preview breadcrumb dropdown trigger
* and its menu rows. Long titles otherwise crowd the header actions.
*/
const SESSION_TITLE_MAX_CHARS = 20;
const SESSION_TITLE_MAX_CHARS = 64;
interface SessionMenuItem {
id: string;
@ -32,7 +31,7 @@ interface SessionMenuItem {
}
/**
* Owns the chat-session state that's split across two surfaces in the builder:
* Owns the preview chat-session state:
*
* - `continueSessionId` set via the URL query string for shareable deep-links
* into a specific session. Takes precedence when present.

View File

@ -1,40 +0,0 @@
import { ref } from 'vue';
export type ChatMode = 'build' | 'test';
/**
* Per-agent chat-mode UI state. Owns:
* - the active mode (Build/Test)
* - lazy-mount tracking for the two chat panels (we don't mount Test until
* the user clicks into it, so the unused panel doesn't fire loadHistory)
* - streaming flag for the builder chat (used to disable config inputs)
* - the seed prompt for whichever panel is about to mount
*
* `setChatMode` lives in the view because it bridges this state with session
* + URL + telemetry concerns; this composable only owns the storage.
*/
export function useAgentChatMode() {
const chatMode = ref<ChatMode>('test');
const chatModeOpened = ref<Record<ChatMode, boolean>>({ test: false, build: false });
const isBuildChatStreaming = ref(false);
const initialPrompt = ref<string | undefined>(undefined);
function onBuildChatStreamingChange(streaming: boolean) {
isBuildChatStreaming.value = streaming;
}
function resetForAgentSwitch() {
chatModeOpened.value = { test: false, build: false };
isBuildChatStreaming.value = false;
initialPrompt.value = undefined;
}
return {
chatMode,
chatModeOpened,
isBuildChatStreaming,
initialPrompt,
onBuildChatStreamingChange,
resetForAgentSwitch,
};
}

View File

@ -1,5 +1,6 @@
export const AGENTS_LIST_VIEW = 'AgentsListView';
export const AGENT_BUILDER_VIEW = 'AgentBuilderView';
export const AGENT_PREVIEW_VIEW = 'AgentPreviewView';
export const NEW_AGENT_VIEW = 'NewAgentView';
export const AGENT_VIEW = 'AgentView';
export const AGENT_SESSIONS_LIST_VIEW = 'AgentSessionsListView';

View File

@ -6,6 +6,7 @@ import {
AGENTS_LIST_VIEW,
AGENT_BUILDER_SETTINGS_VIEW,
AGENT_BUILDER_VIEW,
AGENT_PREVIEW_VIEW,
AGENT_TOOLS_MODAL_KEY,
AGENT_TOOL_CONFIG_MODAL_KEY,
AGENT_SKILL_MODAL_KEY,
@ -129,6 +130,12 @@ export const AgentsModule: FrontendModuleDescription = {
props: true,
component: AgentBuilderView,
},
{
name: AGENT_PREVIEW_VIEW,
path: 'preview',
props: true,
component: AgentBuilderView,
},
{
name: AGENT_SESSIONS_LIST_VIEW,
path: 'sessions',

View File

@ -29,11 +29,11 @@ import { useAgentBuilderStatus } from '../composables/useAgentBuilderStatus';
import { useAgentPermissions } from '../composables/useAgentPermissions';
import { useAgentSessionsStore } from '../agentSessions.store';
import { useAgentBuilderSession } from '../composables/useAgentBuilderSession';
import { useAgentChatMode, type ChatMode } from '../composables/useAgentChatMode';
import { useAgentConfigAutosave } from '../composables/useAgentConfigAutosave';
import { useAgentBuilderMainTabs } from '../composables/useAgentBuilderMainTabs';
import {
AGENT_BUILDER_VIEW,
AGENT_PREVIEW_VIEW,
AGENT_TOOLS_MODAL_KEY,
AGENT_TOOL_CONFIG_MODAL_KEY,
AGENT_SKILL_MODAL_KEY,
@ -44,6 +44,7 @@ import { agentsEventBus } from '../agents.eventBus';
import AgentBuilderHeader from '../components/AgentBuilderHeader.vue';
import AgentBuilderChatColumn from '../components/AgentBuilderChatColumn.vue';
import AgentBuilderEditorColumn from '../components/AgentBuilderEditorColumn.vue';
import AgentPreviewChatPage from '../components/AgentPreviewChatPage.vue';
const AGENT_CHAT_PANEL_MIN_WIDTH = 320;
const AGENT_CHAT_PANEL_DEFAULT_WIDTH = 460;
@ -64,6 +65,7 @@ const { showError, showMessage } = useToast();
const { isBuilderConfigured, fetchStatus: fetchBuilderStatus } = useAgentBuilderStatus();
const { openAgentConfirmationModal } = useAgentConfirmationModal();
const isPreviewMode = computed(() => route.name === AGENT_PREVIEW_VIEW);
const projectId = computed(
() => (route.params.projectId as string) ?? projectsStore.personalProject?.id ?? '',
);
@ -72,21 +74,19 @@ const agentId = computed(() => route.params.agentId as string);
const { canUpdate: canEditAgent, canDelete: canDeleteAgent } = useAgentPermissions(projectId);
// UI state
const {
chatMode,
chatModeOpened,
isBuildChatStreaming,
initialPrompt,
onBuildChatStreamingChange,
resetForAgentSwitch: resetChatModeForAgentSwitch,
} = useAgentChatMode();
const isBuildChatStreaming = ref(false);
const initialPrompt = ref<string | undefined>();
function onBuildChatStreamingChange(streaming: boolean) {
isBuildChatStreaming.value = streaming;
}
/**
* Gate for the main body render. Stays false while `initialize()` is running so
* we don't:
* - flash the home screen for users who arrive with a `?prompt=…` query that
* will immediately transition them to the build chat, and
* - render the Test tab first on unbuilt agents only to flip it to Build
* once the config fetch resolves.
* - render the preview chat before the route/config/session state has settled.
*/
const initialized = ref(false);
const agentName = ref('');
@ -99,7 +99,6 @@ const {
activeChatSessionId,
continueSessionId,
effectiveSessionId,
currentSessionHasMessages,
currentSessionTitle,
sessionMenu,
setSessionInUrl,
@ -140,9 +139,8 @@ const builderTelemetry = useAgentBuilderTelemetry({
});
/**
* The backend owns runnable validation so the Test tab matches the chat endpoint.
* In that state the home screen + send flow routes to the chat endpoint
* instead of the builder.
* The backend owns runnable validation so the chat entry point either opens
* Preview or stays in the builder.
*/
const isBuilt = computed(() => agent.value?.isRunnable === true);
@ -205,38 +203,75 @@ async function fetchAgent(
agentName.value = data.name;
}
function sessionIdForPreview(): string {
return effectiveSessionId.value ?? sessionsStore.threads?.[0]?.id ?? crypto.randomUUID();
}
async function openPreview(seedMessage?: string, preferredSessionId?: string) {
const sessionId = preferredSessionId ?? sessionIdForPreview();
activeChatSessionId.value = sessionId;
if (seedMessage) initialPrompt.value = seedMessage;
await router.push({
name: AGENT_PREVIEW_VIEW,
params: { projectId: projectId.value, agentId: agentId.value },
query: {
...route.query,
prompt: undefined,
[CONTINUE_SESSION_ID_PARAM]: sessionId,
},
});
if (seedMessage) {
void nextTick(() => {
initialPrompt.value = undefined;
});
}
}
async function onOpenPreview() {
if (!isBuilt.value) return;
try {
await flushAutosave();
} catch {
return;
}
await openPreview();
telemetry.track('User opened agent preview', { agent_id: agentId.value });
}
function closePreview() {
const { [CONTINUE_SESSION_ID_PARAM]: _sessionId, prompt: _prompt, ...rest } = route.query;
void router.push({
name: AGENT_BUILDER_VIEW,
params: { projectId: projectId.value, agentId: agentId.value },
query: rest,
});
}
function startChat(msg: string) {
// Starting a fresh chat must never inherit a stale continue-session from a
// previous URL otherwise the new conversation would keep appending to the
// old thread.
if (continueSessionId.value) clearContinueSessionParam();
if (isBuilt.value) {
// Mint a fresh thread id and push it to the URL so the current chat is
// persisted across reloads. Test and Build remain visually linked via
// `chatModeOpened` (v-show) Build doesn't share the thread, it uses
// its own per-agent builder history.
setSessionInUrl(crypto.randomUUID());
initialPrompt.value = msg;
chatMode.value = 'test';
const sessionId = crypto.randomUUID();
activeChatSessionId.value = sessionId;
void openPreview(msg, sessionId);
telemetry.track('User started agent chat', { agent_id: agentId.value });
} else {
// Fresh agent route through the same build chat panel the Build tab
// uses so the first-build experience matches the ongoing Build UX.
// Fresh agent route through the same build chat panel used for ongoing
// Build conversations.
initialPrompt.value = msg;
chatMode.value = 'build';
telemetry.track('User started agent build', { agent_id: agentId.value });
}
// Drop the seed prompt after the re-render that mounts the target panel.
// Vue runs this child's setup during the render kicked off by the state
// changes above, so `props.initialMessage` is captured synchronously in
// the panel's setup before this callback fires. Leaving the prompt in
// place would bleed the same message into whichever panel the user
// opens next (e.g. clicking Build after starting a Test chat would
// re-send the Test message to the builder and skip loadHistory).
void nextTick(() => {
initialPrompt.value = undefined;
});
// Drop the seed prompt after the build panel captures it during the
// render kicked off by the state change above.
void nextTick(() => {
initialPrompt.value = undefined;
});
}
}
function onPublished(updated: AgentResource) {
@ -256,11 +291,11 @@ async function onReverted(updated: AgentResource) {
}
/**
* Pick the session the Test tab should bind to when no explicit one has been
* Pick the session the preview chat should bind to when no explicit one has been
* chosen yet. Prefer the most recent thread users land back where they left
* off and only mint a fresh ephemeral session when there is no history.
*/
function bindTestSession() {
function bindPreviewSession() {
if (continueSessionId.value || activeChatSessionId.value) return;
const latest = sessionsStore.threads?.[0];
if (latest) {
@ -274,57 +309,8 @@ function bindTestSession() {
setSessionInUrl(crypto.randomUUID());
}
function setChatMode(next: ChatMode) {
if (chatMode.value === next) return;
// Test is locked until the backend says the agent is runnable. No-op on
// the click so the user doesn't get bounced into a half-configured chat.
if (next === 'test' && !isBuilt.value) return;
chatMode.value = next;
if (next === 'test') {
// Restore the currently-bound Test session to the URL so refresh and
// shared links point at the same chat.
if (activeChatSessionId.value && !continueSessionId.value) {
void router.replace({
query: { ...route.query, [CONTINUE_SESSION_ID_PARAM]: activeChatSessionId.value },
});
} else {
bindTestSession();
}
} else {
// Build mode doesn't use continueSessionId drop it from the URL so a
// refresh while on Build doesn't bounce back to Test.
if (continueSessionId.value) clearContinueSessionParam();
}
telemetry.track('User switched agent chat mode', {
agent_id: agentId.value,
mode: next,
});
}
/**
* Test is locked until the persisted config passes backend runnable validation.
* Locking the tab (rather than silently redirecting homeBuild) keeps the UX
* honest: users see WHY they can't chat yet instead of getting bounced to a
* different surface after sending a message.
*
* This also closes the first-build cancellation hole: a mid-stream first
* build is always `!isBuilt`, so the Test tab stays locked while the build
* is in flight, preventing the tab-switch-unmounts-the-stream regression.
*/
const chatModeOptions = computed(() => [
{ label: locale.baseText('agents.builder.chatMode.build'), value: 'build' as const },
{
label: locale.baseText('agents.builder.chatMode.test'),
value: 'test' as const,
disabled: !isBuilt.value,
},
]);
function onOpenBuildFromChat() {
// Triggered by the misconfigured-agent banner on the Test panel. Flip to
// the Build tab so the user can finish setup without leaving the session.
chatMode.value = 'build';
closePreview();
}
interface ConfigAutosaveSnapshot {
@ -539,7 +525,6 @@ async function initialize() {
builderTelemetry.resetForAgentSwitch();
agent.value = null;
resetChatModeForAgentSwitch();
activeChatSessionId.value = null;
localConfig.value = null;
connectedTriggers.value = [];
@ -576,15 +561,7 @@ async function initialize() {
if (connected) connectedTriggers.value = connected;
})();
// Default landing is Build. If the URL pins a specific chat session
// (e.g. refresh, shared link, deep link from elsewhere) we honor it and
// open Test so the user sees the chat they linked to.
chatMode.value = continueSessionId.value && isBuilt.value ? 'test' : 'build';
// Explicitly open the target mode. The `chatMode` watcher only fires on a
// value change, but on agent-switch we just reset `chatModeOpened` above
// if both agents share the same default mode the watcher doesn't fire and
// the chat panel's v-if gate stays false, leaving the chat pane blank.
chatModeOpened.value[chatMode.value] = true;
if (isPreviewMode.value) bindPreviewSession();
// If the user arrived via NewAgentView with a seed prompt, jump straight
// into the build chat.
@ -603,15 +580,7 @@ onBeforeUnmount(() => {
sessionsStore.stopAutoRefresh();
});
watch(
chatMode,
(cm) => {
chatModeOpened.value[cm] = true;
},
{ immediate: true },
);
// If the user is on Test before the sessions list finishes loading, latch onto
// If the user is on Preview before the sessions list finishes loading, latch onto
// the most recent thread as soon as it arrives. Also fires when loading
// finishes with no threads so we can mint a fresh ephemeral session instead
// of leaving the chat panel empty.
@ -619,12 +588,18 @@ watch(
() => sessionsStore.loading,
(isLoading, wasLoading) => {
if (!wasLoading || isLoading) return;
if (chatMode.value !== 'test') return;
if (!isPreviewMode.value) return;
if (continueSessionId.value || activeChatSessionId.value) return;
bindTestSession();
bindPreviewSession();
},
);
watch(isPreviewMode, (preview) => {
if (preview) {
bindPreviewSession();
}
});
function exitContinueMode() {
clearContinueSessionParam();
}
@ -827,7 +802,7 @@ function onContinueLoaded(count: number) {
// update lands, latch onto an existing thread (or mint a fresh
// ephemeral one) so the test pane has something to render.
void nextTick(() => {
if (chatMode.value === 'test') bindTestSession();
if (isPreviewMode.value) bindPreviewSession();
});
}
}
@ -835,8 +810,9 @@ function onContinueLoaded(count: number) {
function onSwitchAgent(nextAgentId: string) {
if (!nextAgentId || nextAgentId === agentId.value) return;
void router.push({
name: AGENT_BUILDER_VIEW,
name: isPreviewMode.value ? AGENT_PREVIEW_VIEW : AGENT_BUILDER_VIEW,
params: { projectId: projectId.value, agentId: nextAgentId },
query: isPreviewMode.value ? {} : route.query,
});
}
</script>
@ -850,8 +826,15 @@ function onSwitchAgent(nextAgentId: string) {
:project-name="projectName"
:header-actions="headerActions"
:save-status="saveStatus"
:mode="isPreviewMode ? 'preview' : 'edit'"
:current-session-title="currentSessionTitle"
:session-options="sessionOptions"
:before-revert-to-published="settleAutosave"
@header-action="onHeaderAction"
@open-preview="onOpenPreview"
@new-chat="onNewChat"
@close-preview="closePreview"
@session-select="onSessionPick"
@published="onPublished"
@unpublished="onUnpublished"
@reverted="onReverted"
@ -862,9 +845,25 @@ function onSwitchAgent(nextAgentId: string) {
:class="{
[$style.builder]: true,
[$style.isResizingChat]: chatPanelResizer.isResizing.value,
[$style.previewBuilder]: isPreviewMode,
}"
>
<AgentPreviewChatPage
v-if="isPreviewMode"
:initialized="initialized"
:project-id="projectId"
:agent-id="agentId"
:agent="agent"
:local-config="localConfig"
:connected-triggers="connectedTriggers"
:effective-session-id="effectiveSessionId"
:initial-prompt="initialPrompt"
@config-updated="onConfigUpdated"
@continue-loaded="onContinueLoaded"
@open-build="onOpenBuildFromChat"
/>
<N8nResizeWrapper
v-else
:class="{
[$style.chatResizer]: true,
[$style.chatResizerFullWidth]: isChatFullWidth,
@ -889,27 +888,13 @@ function onSwitchAgent(nextAgentId: string) {
:agent="agent"
:local-config="localConfig"
:connected-triggers="connectedTriggers"
:chat-mode="chatMode"
:chat-mode-opened="chatModeOpened"
:chat-mode-options="chatModeOptions"
:effective-session-id="effectiveSessionId"
:current-session-title="currentSessionTitle"
:current-session-has-messages="currentSessionHasMessages"
:session-options="sessionOptions"
:initial-prompt="initialPrompt"
:is-built="isBuilt"
:is-builder-configured="isBuilderConfigured"
:is-build-chat-streaming="isBuildChatStreaming"
:is-published="Boolean(agent?.publishedVersion)"
:is-full-width="isChatFullWidth"
:can-edit-agent="canEditAgent"
:before-build-send="flushAutosave"
@session-select="onSessionPick"
@new-chat="onNewChat"
@config-updated="onConfigUpdated"
@continue-loaded="onContinueLoaded"
@open-build="onOpenBuildFromChat"
@chat-mode-change="setChatMode"
@update:streaming="onBuildChatStreamingChange"
@update:tools="onQuickActionAddTool"
@update:connected-triggers="onConnectedTriggersUpdate"
@ -920,7 +905,7 @@ function onSwitchAgent(nextAgentId: string) {
</N8nResizeWrapper>
<AgentBuilderEditorColumn
v-if="!isChatFullWidth"
v-if="!isPreviewMode && !isChatFullWidth"
:class="$style.editorColumn"
v-model:active-main-tab="activeMainTab"
:local-config="localConfig"
@ -964,6 +949,10 @@ function onSwitchAgent(nextAgentId: string) {
overflow: hidden;
}
.previewBuilder {
background-color: var(--background--surface);
}
.chatResizer {
flex-shrink: 0;

View File

@ -7,6 +7,7 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
import { useAgentSessionsStore } from '@/features/agents/agentSessions.store';
import {
AGENT_BUILDER_VIEW,
AGENT_PREVIEW_VIEW,
CONTINUE_SESSION_ID_PARAM,
AGENT_SESSION_DETAIL_VIEW,
} from '@/features/agents/constants';
@ -312,7 +313,7 @@ function formatDate(fullDate: string): string {
function continueChat() {
void router.push({
name: AGENT_BUILDER_VIEW,
name: AGENT_PREVIEW_VIEW,
params: { projectId: projectId.value, agentId: agentId.value },
query: { [CONTINUE_SESSION_ID_PARAM]: threadId.value },
});

View File

@ -0,0 +1,47 @@
import MarkdownIt from 'markdown-it';
import { describe, expect, it } from 'vitest';
import {
shouldOpenChatMarkdownLinkInNewTab,
useChatHubMarkdownOptions,
} from './useChatHubMarkdownOptions';
function renderMarkdown(content: string): string {
const markdown = useChatHubMarkdownOptions('code-actions', 'table-container', null);
const renderer = new MarkdownIt();
for (const plugin of markdown.plugins.value) {
renderer.use(plugin);
}
return renderer.render(content);
}
describe('useChatHubMarkdownOptions', () => {
it('renders app-relative links without a new-tab target', () => {
const html = renderMarkdown('[Preview](/projects/project-1/agents/agent-1/preview)');
expect(html).toContain('href="/projects/project-1/agents/agent-1/preview"');
expect(html).not.toContain('target="_blank"');
});
it('renders external links with a new-tab target', () => {
const html = renderMarkdown('[Docs](https://docs.n8n.io)');
expect(html).toContain('href="https://docs.n8n.io"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener"');
});
it('classifies relative app links as same-tab links', () => {
expect(shouldOpenChatMarkdownLinkInNewTab('/projects/project-1/agents/agent-1/preview')).toBe(
false,
);
expect(shouldOpenChatMarkdownLinkInNewTab('projects/project-1/agents/agent-1/preview')).toBe(
false,
);
expect(shouldOpenChatMarkdownLinkInNewTab('#section')).toBe(false);
expect(shouldOpenChatMarkdownLinkInNewTab('https://docs.n8n.io')).toBe(true);
expect(shouldOpenChatMarkdownLinkInNewTab('//docs.n8n.io')).toBe(true);
});
});

View File

@ -2,7 +2,6 @@
import { type HLJSApi } from 'highlight.js';
import { computed, ref } from 'vue';
import type MarkdownIt from 'markdown-it';
import markdownLink from 'markdown-it-link-attributes';
import markdownItKatex from '@vscode/markdown-it-katex';
import markdownItFootnote from 'markdown-it-footnote';
import { truncateBeforeLast } from '@n8n/utils/string/truncate';
@ -23,6 +22,17 @@ type FootnoteEnv = {
footnotes?: { list?: Array<{ label?: string; count?: number; content?: string }> };
};
export function shouldOpenChatMarkdownLinkInNewTab(href: string): boolean {
const normalizedHref = href.trim().toLowerCase();
if (!normalizedHref) return false;
if (normalizedHref.startsWith('#')) return false;
if (normalizedHref.startsWith('/')) return normalizedHref.startsWith('//');
if (normalizedHref.startsWith('./') || normalizedHref.startsWith('../')) return false;
return /^[a-z][a-z0-9+.-]*:/.test(normalizedHref);
}
/**
* To render streamed content cleanly, strip orphaned [^label] references that have no matching definition.
* markdown-it-footnote only creates footnote_ref tokens for resolved references; unresolved ones remain as literal text.
@ -127,12 +137,20 @@ export function useChatHubMarkdownOptions(
const plugins = computed(() => {
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
vueMarkdownItInstance.use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
const defaultLinkOpenRenderer =
vueMarkdownItInstance.renderer.rules.link_open ??
((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options));
vueMarkdownItInstance.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const href = tokens[idx].attrGet('href');
if (href && shouldOpenChatMarkdownLinkInNewTab(href)) {
tokens[idx].attrSet('target', '_blank');
tokens[idx].attrSet('rel', 'noopener');
}
return defaultLinkOpenRenderer(tokens, idx, options, env, self);
};
};
const codeBlockPlugin = (vueMarkdownItInstance: MarkdownIt) => {