diff --git a/packages/cli/src/modules/agents/builder/__tests__/agents-builder-model-recommendations.test.ts b/packages/cli/src/modules/agents/builder/__tests__/agents-builder-model-recommendations.test.ts index e481e41c30e..eb744c9be44 100644 --- a/packages/cli/src/modules/agents/builder/__tests__/agents-builder-model-recommendations.test.ts +++ b/packages/cli/src/modules/agents/builder/__tests__/agents-builder-model-recommendations.test.ts @@ -88,6 +88,7 @@ function buildPrompt(modelRecommendationsSection: string | null) { configHash: null, configUpdatedAt: null, toolList: '(none)', + agentPreviewPath: '/projects/project-1/agents/agent-1/preview', modelRecommendationsSection, }); } diff --git a/packages/cli/src/modules/agents/builder/agent-builder-preview-path.ts b/packages/cli/src/modules/agents/builder/agent-builder-preview-path.ts new file mode 100644 index 00000000000..812b87a9bc2 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/agent-builder-preview-path.ts @@ -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`; +} diff --git a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts index 3418b24e9ad..aeb0933e975 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts @@ -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, diff --git a/packages/cli/src/modules/agents/builder/agents-builder.service.ts b/packages/cli/src/modules/agents/builder/agents-builder.service.ts index b8128795611..d8b90702915 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder.service.ts @@ -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, }); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 212776ea8d3..30005c1dfef 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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.", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts index 4a581f3ff9c..58ea0df573f 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts @@ -32,7 +32,18 @@ vi.mock('vue-router', () => ({ vi.mock('@n8n/design-system', () => ({ N8nIcon: { template: '', props: ['icon', 'size'] }, - N8nButton: { template: '', props: ['variant', 'size'] }, + N8nButton: { + template: + '', + props: ['variant', 'size', 'icon', 'iconOnly', 'disabled'], + emits: ['click'], + }, + N8nTooltip: { + name: 'N8nTooltip', + template: + '', + props: ['disabled', 'content'], + }, N8nBreadcrumbs: { name: 'N8nBreadcrumbs', template: '
', @@ -41,19 +52,19 @@ vi.mock('@n8n/design-system', () => ({ }, N8nDropdownMenu: { name: 'N8nDropdownMenu', - template: '
', + template: '
', props: ['items'], emits: ['select'], }, 'n8n-dropdown-menu': { name: 'N8nDropdownMenu', - template: '
', + template: '
', props: ['items'], emits: ['select'], }, N8nActionDropdown: { name: 'ActionDropdown', - template: '
', + template: '
', 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); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index 2a48a3c5de2..eac67a6352a 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -8,6 +8,7 @@ import type { AgentJsonSkillRef, AgentJsonToolRef, CustomToolEntry } from '../ty const routerPush = vi.fn(); const routerReplace = vi.fn(); const routeQuery: Record = {}; +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: '' }, @@ -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: '
', + props: [ + 'initialized', + 'projectId', + 'agentId', + 'agent', + 'localConfig', + 'connectedTriggers', + 'effectiveSessionId', + 'initialPrompt', + ], + emits: ['config-updated', 'continue-loaded', 'open-build'], + }, AgentConfigTree: { name: 'AgentConfigTree', template: '
', @@ -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: '
' }, }; -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"]') diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/constants.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/constants.test.ts index 4c2b40ba6da..9b8141c77f0 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/constants.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/constants.test.ts @@ -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'); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue index 4619155e7b3..f177741e570 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue @@ -1,14 +1,11 @@ @@ -77,120 +54,30 @@ const sharedInputDraft = ref(''); :aria-label="i18n.baseText('agents.builder.chatColumn.ariaLabel')" data-testid="agent-builder-chat-column" > -
- - - -
- + + - - - - - - - - - -
-
- - - - - + + + +
- - -
- - +
@@ -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 { diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatModeToggle.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatModeToggle.vue deleted file mode 100644 index 499822f435c..00000000000 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatModeToggle.vue +++ /dev/null @@ -1,76 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue index f2ea10cb22a..fe8d1f71757 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue @@ -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>; saveStatus?: 'idle' | 'saving' | 'saved'; + mode?: 'edit' | 'preview'; + currentSessionTitle?: string; + sessionOptions?: Array>; beforeRevertToPublished?: () => Promise | 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(() => [ ]); 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>>(() => { + 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>>(() => { 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'); +} +
- - {{ - saveStatus === 'saving' - ? i18n.baseText('agents.builder.header.saving') - : i18n.baseText('agents.builder.header.saved') - }} - - - + +
@@ -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; +} diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewChatPage.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewChatPage.vue new file mode 100644 index 00000000000..72c6dc02f4a --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewChatPage.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderSession.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderSession.ts index 2d539203a8b..59f632375f4 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderSession.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderSession.ts @@ -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. diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatMode.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatMode.ts deleted file mode 100644 index 36b82cd6983..00000000000 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatMode.ts +++ /dev/null @@ -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('test'); - const chatModeOpened = ref>({ test: false, build: false }); - const isBuildChatStreaming = ref(false); - const initialPrompt = ref(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, - }; -} diff --git a/packages/frontend/editor-ui/src/features/agents/constants.ts b/packages/frontend/editor-ui/src/features/agents/constants.ts index a0a211de1af..a7a2048775b 100644 --- a/packages/frontend/editor-ui/src/features/agents/constants.ts +++ b/packages/frontend/editor-ui/src/features/agents/constants.ts @@ -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'; diff --git a/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts b/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts index 70bab7c3bb3..6c788e8791b 100644 --- a/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts @@ -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', diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue index 928c94b57fb..5f00dc66dca 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue @@ -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(); + +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, }); } @@ -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, }" > + { + 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); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatHubMarkdownOptions.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatHubMarkdownOptions.ts index 6e353da743b..cd1a7e50892 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatHubMarkdownOptions.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatHubMarkdownOptions.ts @@ -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) => {