+
+
+
+
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) => {