mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 00:37:10 +02:00
feat(core): Move agents test chat into a preview tab (no-changelog) (#30527)
This commit is contained in:
parent
cec8238b64
commit
a0c427fbc1
|
|
@ -88,6 +88,7 @@ function buildPrompt(modelRecommendationsSection: string | null) {
|
|||
configHash: null,
|
||||
configUpdatedAt: null,
|
||||
toolList: '(none)',
|
||||
agentPreviewPath: '/projects/project-1/agents/agent-1/preview',
|
||||
modelRecommendationsSection,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"]')
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 home→Build) 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user