feat(editor): Add a Chat with agent picker for n8n Chat variant B (no-changelog) (#40337)

This commit is contained in:
Benjamin Schroth 2026-10-06 11:02:56 +00:00 committed by GitHub
parent cf54433c14
commit d767edfacd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1791 additions and 79 deletions

View File

@ -665,10 +665,10 @@ export const AGENTS_TELEMETRY = defineTelemetryEvents({
USER_SELECTED_N8N_CHAT_AGENT: {
name: 'User selected n8n chat agent',
description:
'The user picked a published agent to chat with over n8n Chat, from a card or the agents library.',
'The user picked a published agent to chat with over n8n Chat, from a card, the agents library, or the chat-page agent picker dropdown.',
properties: z.object({
agent_id: z.string(),
source: z.enum(['card', 'library']),
source: z.enum(['card', 'library', 'dropdown']),
variant: n8nChatVariant,
session_id: sessionId,
}),

View File

@ -8178,6 +8178,11 @@
"agents.n8nChatPage.library.empty.noResults.title": "No agents match your search",
"agents.n8nChatPage.chooseAgent": "Talk to your agents",
"agents.n8nChatPage.viewAllAgents": "View all agents",
"agents.n8nChatPage.picker.chatWith": "Chat with {agent}",
"agents.n8nChatPage.picker.assistantDescription": "Turns plain language into working workflows and agents.",
"agents.n8nChatPage.picker.createNewAgent": "Create new agent",
"agents.n8nChatPage.picker.placeholder": "Ask {name}…",
"agents.n8nChatPage.picker.tryAskingAssistant": "Try asking n8n Assistant",
"agents.backToWorkflow": "Back to workflow",
"agents.heading": "Agents",
"agents.list.published": "Published",

View File

@ -1,4 +1,8 @@
import { convertFileToBinaryData, getBinaryDataFileName } from '@/app/utils/fileUtils';
import {
base64ToFile,
convertFileToBinaryData,
getBinaryDataFileName,
} from '@/app/utils/fileUtils';
describe('getBinaryDataFileName', () => {
it('keeps a name that already carries the extension', () => {
@ -75,3 +79,16 @@ describe('convertFileToBinaryData', () => {
);
});
});
describe('base64ToFile', () => {
it('round-trips the bytes, name, and type of a file', async () => {
const original = new File(['héllo, world'], 'note.txt', { type: 'text/plain' });
const encoded = await convertFileToBinaryData(original);
const decoded = base64ToFile(encoded.data, 'note.txt', 'text/plain');
expect(decoded.name).toBe('note.txt');
expect(decoded.type).toBe('text/plain');
expect((await convertFileToBinaryData(decoded)).data).toBe(encoded.data);
});
});

View File

@ -22,6 +22,12 @@ export function resolveFileMimeType(fileName: string, mimeType: string): string
return '';
}
/** Inverse of `convertFileToBinaryData`: decodes base64 file data back into a `File`. */
export function base64ToFile(data: string, fileName: string, mimeType: string): File {
const bytes = Uint8Array.from(atob(data), (char) => char.charCodeAt(0));
return new File([bytes], fileName, { type: mimeType });
}
export async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
const reader = new FileReader();
return await new Promise((resolve, reject) => {

View File

@ -55,6 +55,17 @@ describe('WorkflowPreviewSuggestions', () => {
});
});
it('omits the assistant label by default and shows it when requested', async () => {
const { queryByTestId, rerender } = renderComponent();
const labelTestId = 'instance-ai-workflow-preview-suggestions-assistant-label';
expect(queryByTestId(labelTestId)).not.toBeInTheDocument();
await rerender({ suggestions, disabled: false, label: 'Try asking n8n Assistant' });
expect(queryByTestId(labelTestId)).toHaveTextContent('Try asking n8n Assistant');
});
it('links "see all" to the templates website URL with instance parameters', () => {
const { container } = renderComponent();

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { N8nIcon } from '@n8n/design-system';
import { N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { onUnmounted, ref } from 'vue';
import { useTelemetry } from '@n8n/composables/useTelemetry';
@ -9,10 +9,15 @@ import type { InstanceAiPrefillDeclaration } from '@/features/ai/instanceAi/pref
const PREVIEW_HOVER_DELAY_MS = 30;
const props = defineProps<{
suggestions: readonly WorkflowPreviewSuggestion[];
disabled: boolean;
}>();
const props = withDefaults(
defineProps<{
suggestions: readonly WorkflowPreviewSuggestion[];
disabled: boolean;
/** Optional label shown above the row. */
label?: string;
}>(),
{ label: undefined },
);
interface InsertSuggestionPayload extends InstanceAiPrefillDeclaration {
promptKey: BaseTextKey;
@ -109,6 +114,15 @@ onUnmounted(clearPreview);
<template>
<div :class="$style.suggestions" data-test-id="instance-ai-workflow-preview-suggestions">
<N8nText
v-if="props.label"
size="small"
color="text-light"
:class="$style.assistantLabel"
data-test-id="instance-ai-workflow-preview-suggestions-assistant-label"
>
{{ props.label }}
</N8nText>
<div :class="$style.suggestionRow">
<button
v-for="(suggestion, index) in props.suggestions"
@ -155,6 +169,12 @@ onUnmounted(clearPreview);
width: 100%;
}
.assistantLabel {
display: block;
text-align: center;
margin-bottom: var(--spacing--2xs);
}
.suggestionRow {
display: flex;
align-items: center;

View File

@ -18,6 +18,8 @@ import type { AgentJsonConfig } from '../types';
import AgentChatPlan from '../components/AgentChatPlan.vue';
import { planMessage, planView } from './fixtures/agent-plan';
type PanelVm = { sendMessageFromOutside: (message: string, files?: File[]) => void };
const sendMessageMock = vi.fn();
const stopGeneratingMock = vi.fn();
const detachStreamMock = vi.fn();
@ -229,7 +231,7 @@ vi.mock('../components/AgentChatMessageList.vue', () => ({
default: {
name: 'AgentChatMessageList',
template: '<div data-testid="message-list-stub" />',
props: ['messages', 'canIncreaseBudget', 'budgetIncreasePending'],
props: ['messages', 'messagingState', 'canIncreaseBudget', 'budgetIncreasePending'],
emits: ['send-to-assistant', 'increase-budget'],
},
}));
@ -389,6 +391,240 @@ describe('AgentChatPanel', () => {
expect(isCentered(mountPanel({ centerEmptyState: true, newSession: true }))).toBe(false);
});
describe('first message preview', () => {
const messageList = (wrapper: ReturnType<typeof mountPanel>) =>
wrapper.findComponent({ name: 'AgentChatMessageList' });
it('shows the first message right away, with the waiting indicator, until the run starts', async () => {
sendMessageMock.mockReturnValue(new Promise(() => {}));
const wrapper = mountPanel({ centerEmptyState: true, newSession: true });
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('hello agent');
await flushPromises();
expect(isCentered(wrapper)).toBe(false);
expect(wrapper.find('[data-testid="empty-state-stub"]').exists()).toBe(false);
expect(messageList(wrapper).props('messages')).toEqual([
expect.objectContaining({ role: 'user', content: 'hello agent' }),
]);
expect(messageList(wrapper).props('messagingState')).toBe('waitingFirstChunk');
wrapper.unmount();
});
it('shows it already while history loads, for a handed-off message', async () => {
isLoadingHistoryMock.value = true;
const wrapper = mountPanel({ centerEmptyState: true, newSession: true });
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('hello agent');
await flushPromises();
expect(sendMessageMock).not.toHaveBeenCalled();
expect(messageList(wrapper).props('messages')).toEqual([
expect.objectContaining({ content: 'hello agent' }),
]);
wrapper.unmount();
});
it('does not repeat the previewed message as a pending composer row', async () => {
sendMessageMock.mockReturnValue(new Promise(() => {}));
const wrapper = mountPanel({ centerEmptyState: true, newSession: true });
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('hello agent');
await flushPromises();
// The server queues the previewed message as item '1'.
sendMessageMock.mock.lastCall?.[2]?.('1');
queuedMessagesMock.value = [
{
id: '1',
steeringExecutionId: null,
message: 'hello agent',
createdAt: new Date().toISOString(),
},
];
await flushPromises();
expect(wrapper.text()).not.toContain('hello agent');
wrapper.unmount();
});
it('hides only the queued copy of the previewed message, keeping a second queued message visible', async () => {
sendMessageMock.mockReturnValue(new Promise(() => {}));
const wrapper = mountPanel({ centerEmptyState: true, newSession: true });
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('hello agent');
await flushPromises();
// The server queues the previewed message as item '1'.
sendMessageMock.mock.lastCall?.[2]?.('1');
queuedMessagesMock.value = [
{
id: '1',
steeringExecutionId: null,
message: 'hello agent',
createdAt: new Date().toISOString(),
},
{
id: '2',
steeringExecutionId: null,
message: 'second message',
createdAt: new Date().toISOString(),
},
];
await flushPromises();
const rows = wrapper.findAll('[data-testid="agent-queued-message"]');
expect(rows).toHaveLength(1);
expect(rows[0].text()).toContain('second message');
wrapper.unmount();
});
it('keeps the first message as the preview bubble when a second send arrives before the first run starts', async () => {
const firstSend = createDeferredPromise<'sent' | 'busy'>();
sendMessageMock.mockReturnValueOnce(firstSend.promise);
sendMessageMock.mockReturnValueOnce(new Promise(() => {}));
const wrapper = mountPanel({ centerEmptyState: true, newSession: true });
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('message one');
await flushPromises();
expect(messageList(wrapper).props('messages')).toEqual([
expect.objectContaining({ content: 'message one' }),
]);
// A second send lands in the gap before the first run has started.
const input = wrapper.findComponent({ name: 'ChatInputBase' });
await input.vm.$emit('update:modelValue', 'message two');
await input.vm.$emit('submit');
await flushPromises();
expect(messageList(wrapper).props('messages')).toEqual([
expect.objectContaining({ content: 'message one' }),
]);
wrapper.unmount();
});
it('does not clear the first message preview when a second, unaccepted send comes back busy', async () => {
const firstSend = createDeferredPromise<'sent' | 'busy'>();
const secondSend = createDeferredPromise<'sent' | 'busy'>();
sendMessageMock.mockReturnValueOnce(firstSend.promise);
sendMessageMock.mockReturnValueOnce(secondSend.promise);
const wrapper = mountPanel({ centerEmptyState: true, newSession: true });
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('message one');
await flushPromises();
const input = wrapper.findComponent({ name: 'ChatInputBase' });
await input.vm.$emit('update:modelValue', 'message two');
await input.vm.$emit('submit');
await flushPromises();
secondSend.resolve('busy');
await flushPromises();
expect(messageList(wrapper).props('messages')).toEqual([
expect.objectContaining({ content: 'message one' }),
]);
wrapper.unmount();
});
it('gives way to the real messages once they arrive', async () => {
sendMessageMock.mockReturnValue(new Promise(() => {}));
const wrapper = mountPanel({ centerEmptyState: true, newSession: true });
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('hello agent');
await flushPromises();
const real = [{ id: 'm1', role: 'user', content: 'hello agent' } as ChatMessage];
messagesMock.value = real;
await flushPromises();
messagesMock.value = [];
await flushPromises();
expect(messageList(wrapper).exists()).toBe(false);
wrapper.unmount();
});
it('drops the preview when the send comes back busy', async () => {
sendMessageMock.mockResolvedValue('busy');
const wrapper = mountPanel({ centerEmptyState: true, newSession: true });
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('hello agent');
await flushPromises();
expect(messageList(wrapper).exists()).toBe(false);
wrapper.unmount();
});
it('drops the preview when the send ends without being accepted', async () => {
sendMessageMock.mockResolvedValue('sent');
const wrapper = mountPanel({ centerEmptyState: true, newSession: true });
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('hello agent');
await flushPromises();
expect(messageList(wrapper).exists()).toBe(false);
wrapper.unmount();
});
it('keeps the preview once the send is accepted, until the run adds the messages', async () => {
sendMessageMock.mockImplementation(
async (_text: string, _files: File[] | undefined, onAccepted: () => void) => {
onAccepted();
return 'sent';
},
);
const wrapper = mountPanel({ centerEmptyState: true, newSession: true });
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('hello agent');
await flushPromises();
expect(messageList(wrapper).props('messages')).toEqual([
expect.objectContaining({ content: 'hello agent' }),
]);
wrapper.unmount();
});
it('does not preview outside the n8n Chat entry page', async () => {
sendMessageMock.mockReturnValue(new Promise(() => {}));
const wrapper = mountPanel();
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('hello agent');
await flushPromises();
expect(messageList(wrapper).exists()).toBe(false);
wrapper.unmount();
});
it('ignores a stale, unaccepted send after the target has moved on to a newer hand-off', async () => {
const firstSend = createDeferredPromise<'sent' | 'busy'>();
sendMessageMock.mockReturnValueOnce(firstSend.promise);
const wrapper = mountPanel({
centerEmptyState: true,
newSession: true,
continueSessionId: 's1',
});
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('hello agent');
await flushPromises();
expect(messageList(wrapper).props('messages')).toEqual([
expect.objectContaining({ content: 'hello agent' }),
]);
// Reused for a different session before the stale first send settles.
await wrapper.setProps({ continueSessionId: 's2' });
sendMessageMock.mockReturnValueOnce(new Promise(() => {}));
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('hello again');
await flushPromises();
expect(messageList(wrapper).props('messages')).toEqual([
expect.objectContaining({ content: 'hello again' }),
]);
// The first send's response arrives late, never accepted.
firstSend.resolve('sent');
await flushPromises();
expect(messageList(wrapper).props('messages')).toEqual([
expect.objectContaining({ content: 'hello again' }),
]);
wrapper.unmount();
});
});
it('does not center a continued thread or without the prop', () => {
expect(isCentered(mountPanel({ centerEmptyState: true, continueSessionId: 's1' }))).toBe(
false,
@ -397,6 +633,30 @@ describe('AgentChatPanel', () => {
});
});
it('drops only a blocked hand-off own files when its target is abandoned, keeping user picks', async () => {
isLoadingHistoryMock.value = true;
const wrapper = mountPanel({ continueSessionId: 's1' });
const handOffFile = new File(['a'], 'hand-off.txt', { type: 'text/plain' });
(wrapper.vm as unknown as PanelVm).sendMessageFromOutside('hello agent', [handOffFile]);
await flushPromises();
expect(sendMessageMock).not.toHaveBeenCalled();
// Reused for a different session — the blocked hand-off is abandoned.
await wrapper.setProps({ continueSessionId: 's2' });
isLoadingHistoryMock.value = false;
const input = wrapper.findComponent({ name: 'ChatInputBase' });
const userFile = new File(['b'], 'user-picked.txt', { type: 'text/plain' });
input.vm.$emit('files-selected', [userFile]);
input.vm.$emit('update:modelValue', 'new message');
input.vm.$emit('submit');
await flushPromises();
expect(sendMessageMock).toHaveBeenCalledWith('new message', [userFile], expect.any(Function));
wrapper.unmount();
});
it('keeps two pending messages in the composer below background tasks and removes them without adding conversation bubbles', async () => {
queuedMessagesMock.value = [
{
@ -1516,6 +1776,44 @@ describe('AgentChatPanel', () => {
wrapper.unmount();
});
it('sends files queued with an outside message, surviving the blocked-on-history-load retry', async () => {
isLoadingHistoryMock.value = true;
const file = new File(['content'], 'notes.txt', { type: 'text/plain' });
const wrapper = mountPanel();
(
wrapper.vm as unknown as {
sendMessageFromOutside: (message: string, files?: File[]) => void;
}
).sendMessageFromOutside('Test this task', [file]);
await flushPromises();
expect(sendMessageMock).not.toHaveBeenCalled();
isLoadingHistoryMock.value = false;
await flushPromises();
expect(sendMessageMock).toHaveBeenCalledExactlyOnceWith(
'Test this task',
[file],
expect.any(Function),
);
wrapper.unmount();
});
it('sends an outside message that carries only files', async () => {
const file = new File(['content'], 'notes.txt', { type: 'text/plain' });
const wrapper = mountPanel();
(
wrapper.vm as unknown as {
sendMessageFromOutside: (message: string, files?: File[]) => void;
}
).sendMessageFromOutside('', [file]);
await flushPromises();
expect(sendMessageMock).toHaveBeenCalledExactlyOnceWith('', [file], expect.any(Function));
wrapper.unmount();
});
it('submits an outside message while the current stream runs', async () => {
const response = Promise.withResolvers<'sent'>();
sendMessageMock.mockReturnValueOnce(response.promise);

View File

@ -3837,6 +3837,22 @@ describe('useAgentChatStream — queued submissions', () => {
});
afterEach(() => vi.unstubAllGlobals());
it('passes the queue item id to onAccepted when the message is queued', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
makeSseResponse([{ type: 'message-queued', queueId: 'q-7', sessionId: 'thread-1' }], false),
),
);
const accepted = vi.fn();
const hook = buildHook('thread-1');
await hook.sendMessage('Hello', undefined, accepted);
await flushPromises();
expect(accepted).toHaveBeenCalledWith('q-7');
});
it('keeps the original request UUID and stream when another delivery completes as a duplicate', async () => {
const clientId = 'c4b02d7b-2088-41ce-9c6b-faf8c7b83d8a';
vi.spyOn(crypto, 'randomUUID').mockReturnValueOnce(clientId);

View File

@ -48,6 +48,8 @@ import {
parseApprovalInput,
} from '@/features/ai/shared/agentsChat/messageMappers';
import AgentChatEmptyState from './AgentChatEmptyState.vue';
import type { ChatMessage } from '@/features/ai/shared/agentsChat/types';
import { resolveFileMimeType } from '@/app/utils/fileUtils';
import AgentChatMessageList from './AgentChatMessageList.vue';
import AgentChatPlan from './AgentChatPlan.vue';
import { selectLatestAgentPlan } from '../utils/agent-plan';
@ -89,7 +91,10 @@ const props = withDefaults(
backgroundJobsActive?: boolean;
/** `'chat'` (default) talks to the builder's draft/test chat; `'n8n-chat'` talks to the published n8n Chat channel. */
channel?: AgentChatChannel;
/** Centers the empty state and the composer together until a new chat gets its first message. */
/**
* The n8n Chat entry page: centers the empty state and the composer together until a
* new chat gets its first message, and shows that message right away when sent.
*/
centerEmptyState?: boolean;
budgetCards?: boolean;
/**
@ -196,12 +201,23 @@ const queueEdit = ref<{
unavailable: boolean;
saving: boolean;
}>();
// The stream adds a sent message only once its run starts. On the entry page the first
// message shows right away instead, so the page doesn't sit in its empty state meanwhile.
const firstMessagePreview = ref<ChatMessage>();
// Queue item of the previewed message, once the server has queued it.
const previewQueueId = ref<string>();
const isPreviewingFirstMessage = computed(
() => !!firstMessagePreview.value && messages.value.length === 0,
);
const queueRows = computed(() => {
const edit = queueEdit.value;
if (edit && !queuedMessages.value.some((item) => item.id === edit.item.id)) {
return [...queuedMessages.value, edit.item];
}
return queuedMessages.value;
const rows =
edit && !queuedMessages.value.some((item) => item.id === edit.item.id)
? [...queuedMessages.value, edit.item]
: queuedMessages.value;
// The previewed message is already on screen as a bubble, so hide only its queue row.
if (!isPreviewingFirstMessage.value) return rows;
return rows.filter((item) => item.id !== previewQueueId.value);
});
const queueElement = useTemplateRef<HTMLDivElement>('messageQueue');
const queueListId = useId();
@ -681,6 +697,11 @@ const isPreparingToSend = ref(false);
let disposed = false;
let queuedExternalMessage: string | undefined;
let submittingQueuedExternalMessage = false;
// The bubble a hand-off installed before it was sent. Its own `onSubmit` takes it.
let handoffPreview: ChatMessage | undefined;
// Files a hand-off staged into `attachedFiles`, tracked so an abandoned hand-off
// can drop only its own files and leave the user's own picks alone.
let externalAttachedFiles: File[] = [];
type SubmitResult = 'sent' | 'busy' | 'rejected';
@ -782,9 +803,48 @@ const chatPlaceholder = computed(() => {
: locale.baseText('agents.chat.input.placeholder');
});
/**
* Installs the first-message bubble and returns it. Returns undefined when a bubble
* already shows: a second send before the first run starts must not replace it.
*/
function previewFirstMessage(text: string, files: File[] = []): ChatMessage | undefined {
if (!props.centerEmptyState || messages.value.length > 0 || firstMessagePreview.value) {
return undefined;
}
firstMessagePreview.value = {
id: 'first-message-preview',
role: 'user',
content: text,
status: 'success',
createdAt: Date.now(),
attachments: files.map((file) => ({
fileName: file.name,
mimeType: resolveFileMimeType(file.name, file.type) || 'application/octet-stream',
sizeBytes: file.size,
file,
})),
};
previewQueueId.value = undefined;
return firstMessagePreview.value;
}
watch([() => messages.value.length, fatalError], ([count, error]) => {
if (count > 0 || error) firstMessagePreview.value = undefined;
});
const displayedMessages = computed(() =>
isPreviewingFirstMessage.value && firstMessagePreview.value
? [firstMessagePreview.value]
: messages.value,
);
const displayedMessagingState = computed(() =>
isPreviewingFirstMessage.value ? 'waitingFirstChunk' : messagingState.value,
);
const isCenteredEmpty = computed(
() =>
props.centerEmptyState && props.newSession && messages.value.length === 0 && !isStreaming.value,
props.centerEmptyState &&
props.newSession &&
displayedMessages.value.length === 0 &&
!isStreaming.value,
);
watch(isStreaming, (v) => emit('update:streaming', v));
@ -807,9 +867,19 @@ watch(
() => [props.projectId, props.agentId, props.continueSessionId],
() => {
queuedExternalMessage = undefined;
handoffPreview = undefined;
queueEdit.value = undefined;
queueExpanded.value = false;
queueOrder.value = undefined;
firstMessagePreview.value = undefined;
// A blocked hand-off's own files must not ride out with the next message to
// the new target; a file the user picked themselves stays.
if (externalAttachedFiles.length) {
attachedFiles.value = attachedFiles.value.filter(
(file) => !externalAttachedFiles.includes(file),
);
externalAttachedFiles = [];
}
},
);
@ -838,6 +908,9 @@ async function onSubmit(): Promise<SubmitResult> {
const files = [...attachedFiles.value];
if (!text && files.length === 0) return 'rejected';
if (isSubmissionBlocked.value) return 'busy';
// Taken before any await, so a user send made while this hand-off runs cannot claim it.
const ownedHandoffPreview = submittingQueuedExternalMessage ? handoffPreview : undefined;
if (ownedHandoffPreview) handoffPreview = undefined;
const hadNoMessagesBeforeSend = messages.value.length === 0;
const target = {
projectId: props.projectId,
@ -878,8 +951,14 @@ async function onSubmit(): Promise<SubmitResult> {
: undefined;
if (!isCurrentTarget()) return 'rejected';
const sending = sendMessage(text, files.length > 0 ? files : undefined, () => {
const installedPreview = previewFirstMessage(text, files) ?? ownedHandoffPreview;
let accepted = false;
const sending = sendMessage(text, files.length > 0 ? files : undefined, (queueId) => {
accepted = true;
if (!isCurrentTarget()) return;
if (installedPreview && firstMessagePreview.value === installedPreview) {
previewQueueId.value = queueId;
}
if (fingerprint) {
agentTelemetry.trackSubmittedMessage({
agentId: props.agentId,
@ -889,11 +968,19 @@ async function onSubmit(): Promise<SubmitResult> {
}
if (inputText.value.trim() === text) inputText.value = '';
attachedFiles.value = attachedFiles.value.filter((file) => !files.includes(file));
externalAttachedFiles = [];
consumeQueuedExternalMessage(text);
trackSentToN8nChat(hadNoMessagesBeforeSend);
});
isPreparingToSend.value = false;
const result = await sending;
// A send the server never accepted (busy, failed, lost) won't bring the real
// messages that replace the preview, so drop it here -- but only while this
// send's own preview is still the one showing. A newer hand-off's own preview
// must survive this one settling late.
if (!accepted && firstMessagePreview.value === installedPreview) {
firstMessagePreview.value = undefined;
}
if (result === 'busy') return 'busy';
return 'sent';
} finally {
@ -934,15 +1021,24 @@ async function onIncreaseBudget(payload: { field: BudgetAmountField; amount: num
}
}
function sendMessageFromOutside(message: string) {
function sendMessageFromOutside(message: string, files?: File[]) {
queuedExternalMessage = message;
inputText.value = message;
externalAttachedFiles = files ?? [];
// Staged as the composer's own attachments, with the same count and size checks
// as a picked file: `onSubmit` reads `attachedFiles`, so they ride along with
// every retry `submitQueuedExternalMessage` makes while blocked.
if (files?.length) handleFilesSelected(files);
handoffPreview = previewFirstMessage(message, attachedFiles.value);
// A previewed message already shows as a bubble; `submitQueuedExternalMessage` fills
// the composer itself right before it submits.
if (!firstMessagePreview.value) inputText.value = message;
void submitQueuedExternalMessage();
}
async function submitQueuedExternalMessage() {
const message = queuedExternalMessage;
if (!message || submittingQueuedExternalMessage || isSubmissionBlocked.value) return;
// An empty text is still a send when files are staged with it.
if (message === undefined || submittingQueuedExternalMessage || isSubmissionBlocked.value) return;
submittingQueuedExternalMessage = true;
let result: SubmitResult = 'rejected';
@ -955,8 +1051,15 @@ async function submitQueuedExternalMessage() {
if (result === 'rejected' && queuedExternalMessage === message) {
queuedExternalMessage = undefined;
firstMessagePreview.value = undefined;
// The files stay in the composer as the user's draft now.
externalAttachedFiles = [];
}
if (queuedExternalMessage && queuedExternalMessage !== message && !isSubmissionBlocked.value) {
if (
queuedExternalMessage !== undefined &&
queuedExternalMessage !== message &&
!isSubmissionBlocked.value
) {
await nextTick();
void submitQueuedExternalMessage();
}
@ -1050,15 +1153,15 @@ onBeforeUnmount(() => {
</N8nCallout>
</div>
<template v-if="messages.length === 0 && !isStreaming">
<template v-if="displayedMessages.length === 0 && !isStreaming">
<slot name="empty-state">
<AgentChatEmptyState :agent-config="agentConfig" />
</slot>
</template>
<AgentChatMessageList
v-else
:messages="messages"
:messaging-state="messagingState"
:messages="displayedMessages"
:messaging-state="displayedMessagingState"
:project-id="projectId"
:agent-id="agentId"
:session-id="continueSessionId"

View File

@ -593,7 +593,8 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
executionId?: string;
needsStartValidation?: boolean;
busy?: boolean;
onAccepted?: () => void;
/** Gets the queue item id when the message was queued rather than started. */
onAccepted?: (queueId?: string) => void;
/**
* Set when the stream emitted an `error` event. Callers (notably
* `resume`) inspect this so they can roll back optimistic UI state
@ -947,7 +948,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
session.queueId = event.queueId;
acceptedSessionId.value = event.sessionId;
detachExcessWaitingStreams();
session.onAccepted?.();
session.onAccepted?.(event.queueId);
session.onAccepted = undefined;
if (consumedQueueIds.has(event.queueId)) {
session.controller.abort();
@ -1371,7 +1372,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
async function postAndConsume(
url: string,
body: Record<string, unknown>,
onAccepted?: () => void,
onAccepted?: (queueId?: string) => void,
userMessage?: ChatMessage,
): Promise<{ outcome: StreamOutcome }> {
const controller = new AbortController();
@ -1472,7 +1473,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
async function streamChat(
message: string,
files?: File[],
onAccepted?: () => void,
onAccepted?: (queueId?: string) => void,
userMessage?: ChatMessage,
) {
const target = targetKey();
@ -1660,7 +1661,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
async function sendMessage(
text: string,
files?: File[],
onAccepted?: () => void,
onAccepted?: (queueId?: string) => void,
): Promise<'sent' | 'busy'> {
const trimmed = text.trim();
if ((!trimmed && !files?.length) || isSubmitting.value || isLoadingHistory.value) return 'busy';
@ -1691,8 +1692,8 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
void streamChat(
trimmed,
files,
() => {
onAccepted?.();
(queueId) => {
onAccepted?.(queueId);
release();
},
userMessage,

View File

@ -8,7 +8,7 @@ import { useAgentsN8nChatFlag } from './useAgentsN8nChatFlag';
import type { AgentConfigFingerprint, AgentTelemetryStatus } from './agentTelemetry.utils';
export type AgentCreateSource = 'button' | 'dropdown' | 'card';
export type N8nChatAgentSource = 'card' | 'library';
export type N8nChatAgentSource = 'card' | 'library' | 'dropdown';
export function useAgentTelemetry() {
const telemetry = useTelemetry();

View File

@ -17,6 +17,13 @@ export const AGENT_N8N_CHAT_VIEW = 'AgentN8nChatView';
/** The "browse agents available over n8n Chat" library screen. */
export const AGENT_N8N_CHAT_LIBRARY_VIEW = 'AgentN8nChatLibraryView';
/**
* n8n Chat agent search cap, shared by the library view and the "Chat with"
* picker. Matches the backend filter (`agentListFilterSchema.query` `.max(128)`,
* `@n8n/api-types`), so a longer search term never reaches the API.
*/
export const AGENT_N8N_CHAT_SEARCH_MAX_LENGTH = 128;
export const AGENT_TOOLS_MODAL_KEY = 'agentToolsModal';
export const AGENT_TOOL_CONFIG_MODAL_KEY = 'agentToolConfigModal';
export const AGENT_SKILL_MODAL_KEY = 'agentSkillModal';

View File

@ -22,6 +22,7 @@ import AgentPersonalisationIcon from '../components/AgentPersonalisationIcon.vue
import N8nChatPageLayout from './components/N8nChatPageLayout.vue';
import N8nChatThreadHistory from './components/N8nChatThreadHistory.vue';
import { useAgentN8nChatThreadsStore } from './n8nChatThreads.store';
import { consumePendingN8nChatMessage, type PendingN8nChatMessage } from './pendingN8nChatMessage';
import ProjectIcon from '@/features/collaboration/projects/components/ProjectIcon.vue';
import { firstMessageTitle } from '@/features/ai/instanceAi/instanceAi.threadRuntime';
@ -82,10 +83,14 @@ const isNewSession = computed(() => !props.agentThreadId);
const panel = useTemplateRef<InstanceType<typeof AgentChatPanel>>('panel');
// Hand-off from the Assistant picker. Consumed per `agentId` because `RouterView`
// reuses this view across agents.
let pendingMessage: PendingN8nChatMessage | undefined;
watch(
() => props.agentId,
async (id) => {
if (!id) return;
pendingMessage = consumePendingN8nChatMessage(id);
await loadAgent(id);
},
{ immediate: true },
@ -103,6 +108,12 @@ watch(
{ immediate: true },
);
watch(panel, (current) => {
if (!current || !pendingMessage || props.agentThreadId) return;
current.sendMessageFromOutside(pendingMessage.text, pendingMessage.files);
pendingMessage = undefined;
});
// The same recent-threads list the sidebar shows, so both name the open chat the same way.
// A thread outside the newest page (e.g. opened directly by URL) is missing here until
// `loadThread` below adds it.

View File

@ -6,13 +6,12 @@ import { useI18n } from '@n8n/i18n';
import { getDebounceTime } from '@n8n/composables/useDebounce';
import { DEBOUNCE_TIME, DEFAULT_WORKFLOW_PAGE_SIZE } from '@/app/constants';
import { AGENT_N8N_CHAT_SEARCH_MAX_LENGTH } from '../constants';
import { useAgentsN8nChatVariant } from '../composables/useAgentsN8nChatFlag';
import { useN8nChatAgents } from './composables/useN8nChatAgents';
import N8nChatAgentGrid from './components/N8nChatAgentGrid.vue';
import N8nChatPageLayout from './components/N8nChatPageLayout.vue';
// Backend filter cap (`agentListFilterSchema.query`, @n8n/api-types).
const SEARCH_MAX_LENGTH = 128;
const i18n = useI18n();
const searchInput = ref('');
@ -37,9 +36,16 @@ function onSearchInput(value: string): void {
}
const hasSearch = computed(() => query.value.length > 0);
const showEmptyState = computed(() => !isLoading.value && !loadFailed.value && count.value === 0);
const showPagination = computed(() => count.value > 0);
// Variant B lists the n8n Assistant as a chat option too, at the top of the unfiltered list.
const { isVariantB } = useAgentsN8nChatVariant();
const includeAssistant = computed(() => isVariantB.value && !hasSearch.value && page.value === 1);
// With the Assistant card there is always something to pick, so no empty state.
const showEmptyState = computed(
() => !includeAssistant.value && !isLoading.value && !loadFailed.value && count.value === 0,
);
function onPageSizeChange(size: number): void {
pageSize.value = size;
page.value = 1;
@ -55,7 +61,7 @@ function onPageSizeChange(size: number): void {
<N8nInput
:model-value="searchInput"
:placeholder="i18n.baseText('agents.n8nChatPage.library.search.placeholder')"
:maxlength="SEARCH_MAX_LENGTH"
:maxlength="AGENT_N8N_CHAT_SEARCH_MAX_LENGTH"
size="medium"
clearable
data-testid="n8n-chat-library-search"
@ -67,15 +73,7 @@ function onPageSizeChange(size: number): void {
</N8nInput>
<N8nEmptyState
v-if="loadFailed"
data-testid="n8n-chat-library-error"
:icon="{ type: 'icon', value: 'circle-alert' }"
:heading="i18n.baseText('agents.n8nChatPage.library.loadFailed.title')"
:button-text="i18n.baseText('generic.retry')"
@click:button="retry"
/>
<N8nEmptyState
v-else-if="showEmptyState"
v-if="showEmptyState"
data-testid="n8n-chat-library-empty"
:icon="{ type: 'icon', value: 'bot' }"
:heading="
@ -84,7 +82,25 @@ function onPageSizeChange(size: number): void {
: i18n.baseText('agents.n8nChatPage.library.empty.noAgents.title')
"
/>
<N8nChatAgentGrid v-else :agents="agents" :loading="isLoading" source="library" />
<template v-else>
<!-- A failed agent load must not hide the Assistant card: it's always a
valid pick, independent of whether the agent list loaded. -->
<N8nChatAgentGrid
v-if="!loadFailed || includeAssistant"
:agents="loadFailed ? [] : agents"
:loading="!loadFailed && isLoading"
:include-assistant="includeAssistant"
source="library"
/>
<N8nEmptyState
v-if="loadFailed"
data-testid="n8n-chat-library-error"
:icon="{ type: 'icon', value: 'circle-alert' }"
:heading="i18n.baseText('agents.n8nChatPage.library.loadFailed.title')"
:button-text="i18n.baseText('generic.retry')"
@click:button="retry"
/>
</template>
<N8nPagination
v-if="showPagination"

View File

@ -17,6 +17,7 @@ import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
import { AGENT_N8N_CHAT_VIEW } from '../../constants';
import { useAgentN8nChatThreadsStore } from '../n8nChatThreads.store';
import { consumePendingN8nChatMessage, stashPendingN8nChatMessage } from '../pendingN8nChatMessage';
import AgentN8nChatView from '../AgentN8nChatView.vue';
const getN8nChatAgentMock = vi.fn();
@ -411,6 +412,78 @@ describe('AgentN8nChatView', () => {
expect(backMock).not.toHaveBeenCalled();
});
describe('a pending message handed off from the n8n Assistant picker', () => {
it('sends it once the panel loads, with its text and files', async () => {
const file = new File(['a'], 'a.txt');
stashPendingN8nChatMessage({ agentId: 'agent-1', text: 'hello agent', files: [file] });
renderView();
await flushPromises();
expect(sendMessageFromOutsideMock).toHaveBeenCalledExactlyOnceWith('hello agent', [file]);
});
it('sends nothing when there is no pending message', async () => {
renderView();
await flushPromises();
expect(sendMessageFromOutsideMock).not.toHaveBeenCalled();
});
it('sends nothing when the URL already has a thread id', async () => {
stashPendingN8nChatMessage({ agentId: 'agent-1', text: 'hello agent', files: [] });
renderView({ agentThreadId: 'thread-99' });
await flushPromises();
expect(sendMessageFromOutsideMock).not.toHaveBeenCalled();
expect(consumePendingN8nChatMessage('agent-1')).toBeUndefined();
});
it('drops the pending message when the agent cannot load', async () => {
getN8nChatAgentMock.mockRejectedValue({ httpStatusCode: 404 });
stashPendingN8nChatMessage({ agentId: 'agent-1', text: 'hello agent', files: [] });
renderView();
await flushPromises();
expect(sendMessageFromOutsideMock).not.toHaveBeenCalled();
expect(consumePendingN8nChatMessage('agent-1')).toBeUndefined();
});
it('ignores a pending message stored for a different agent', async () => {
stashPendingN8nChatMessage({ agentId: 'agent-2', text: 'hello agent', files: [] });
renderView({ agentId: 'agent-1' });
await flushPromises();
expect(sendMessageFromOutsideMock).not.toHaveBeenCalled();
});
it('discards a hand-off for an agent abandoned before its panel ever mounts', async () => {
// `RouterView` isn't keyed on `agentId`: this same view instance is reused
// for agent-2 while agent-1 is still loading, so agent-1's panel never mounts.
const agentOneLoad = createDeferredPromise<AgentChatListItem>();
const agentTwoLoad = createDeferredPromise<AgentChatListItem>();
getN8nChatAgentMock.mockReturnValueOnce(agentOneLoad.promise);
getN8nChatAgentMock.mockReturnValueOnce(agentTwoLoad.promise);
stashPendingN8nChatMessage({ agentId: 'agent-1', text: 'hello agent one', files: [] });
const wrapper = renderView({ agentId: 'agent-1' });
await flushPromises();
await wrapper.setProps({ agentId: 'agent-2' });
await flushPromises();
agentTwoLoad.resolve({ ...agentItem, id: 'agent-2', name: 'Other Agent' });
await flushPromises();
agentOneLoad.resolve(agentItem);
await flushPromises();
expect(sendMessageFromOutsideMock).not.toHaveBeenCalled();
});
});
describe('refreshing the n8n Chat threads store', () => {
it('does not refresh when a session is created, only when a turn finishes streaming', async () => {
const wrapper = renderView();

View File

@ -13,6 +13,17 @@ type Options = { query: Ref<string>; page: Ref<number>; pageSize: Ref<number> };
const useN8nChatAgentsMock = vi.fn();
const retryMock = vi.fn();
const isVariantB = { value: false };
vi.mock('../../composables/useAgentsN8nChatFlag', async () => {
const { computed } = await import('vue');
return {
useAgentsN8nChatVariant: () => ({
isVariantA: computed(() => false),
isVariantB: computed(() => isVariantB.value),
}),
};
});
vi.mock('../composables/useN8nChatAgents', () => ({
useN8nChatAgents: (options: Options) => useN8nChatAgentsMock(options),
}));
@ -24,7 +35,7 @@ vi.mock('../components/N8nChatAgentCard.vue', () => ({
default: {
name: 'N8nChatAgentCard',
props: ['agent', 'source'],
template: '<div data-testid="stub-agent-card">{{ agent.name }}</div>',
template: '<div data-testid="stub-agent-card">{{ agent?.name ?? \'n8n Assistant\' }}</div>',
},
}));
@ -82,6 +93,38 @@ describe('N8nChatAgentLibraryView', () => {
beforeEach(() => {
vi.clearAllMocks();
historyBack.value = undefined;
isVariantB.value = false;
});
it('shows the n8n Assistant card instead of the empty state for variant B with no agents', () => {
isVariantB.value = true;
const wrapper = setup({ agents: [], count: 0, isLoading: false });
expect(wrapper.find('[data-testid="n8n-chat-library-empty"]').exists()).toBe(false);
expect(wrapper.findComponent(N8nChatAgentGrid).props('includeAssistant')).toBe(true);
});
it('includes the n8n Assistant card only for variant B, on the first page without a search', async () => {
expect(
setup({ agents: [agentA], count: 1, isLoading: false })
.findComponent(N8nChatAgentGrid)
.props('includeAssistant'),
).toBe(false);
isVariantB.value = true;
const wrapper = setup({ agents: [agentA], count: 1, isLoading: false });
const grid = () => wrapper.findComponent(N8nChatAgentGrid);
expect(grid().props('includeAssistant')).toBe(true);
const options = useN8nChatAgentsMock.mock.lastCall?.[0] as Options;
options.page.value = 2;
await wrapper.vm.$nextTick();
expect(grid().props('includeAssistant')).toBe(false);
options.page.value = 1;
options.query.value = 'support';
await wrapper.vm.$nextTick();
expect(grid().props('includeAssistant')).toBe(false);
});
it('renders the title and the agent grid', () => {
@ -138,6 +181,17 @@ describe('N8nChatAgentLibraryView', () => {
expect(retryMock).toHaveBeenCalledOnce();
});
it('keeps the n8n Assistant card next to the error state for variant B on a failed fetch', () => {
isVariantB.value = true;
const wrapper = setup({ agents: [], count: 0, isLoading: false, loadFailed: true });
expect(wrapper.find('[data-testid="n8n-chat-library-error"]').exists()).toBe(true);
const grid = wrapper.findComponent(N8nChatAgentGrid);
expect(grid.exists()).toBe(true);
expect(grid.props('includeAssistant')).toBe(true);
expect(grid.props('agents')).toEqual([]);
});
it('resets to page 1 once the search query actually changes', async () => {
const wrapper = setup({ agents: [], count: 0, isLoading: false });
const options = useN8nChatAgentsMock.mock.calls[0][0] as Options;

View File

@ -0,0 +1,33 @@
import { stashPendingN8nChatMessage, consumePendingN8nChatMessage } from '../pendingN8nChatMessage';
describe('pendingN8nChatMessage', () => {
it('returns the stored message for a matching agent id', () => {
const file = new File(['a'], 'a.txt');
stashPendingN8nChatMessage({ agentId: 'agent-1', text: 'hello', files: [file] });
expect(consumePendingN8nChatMessage('agent-1')).toEqual({
agentId: 'agent-1',
text: 'hello',
files: [file],
});
});
it('returns undefined and clears the entry for a different agent id', () => {
stashPendingN8nChatMessage({ agentId: 'agent-1', text: 'hello', files: [] });
expect(consumePendingN8nChatMessage('agent-2')).toBeUndefined();
// The mismatched read already cleared it, so even the original agent gets nothing.
expect(consumePendingN8nChatMessage('agent-1')).toBeUndefined();
});
it('is one-shot: a second take for the same agent returns undefined', () => {
stashPendingN8nChatMessage({ agentId: 'agent-1', text: 'hello', files: [] });
expect(consumePendingN8nChatMessage('agent-1')).toBeDefined();
expect(consumePendingN8nChatMessage('agent-1')).toBeUndefined();
});
it('returns undefined when nothing was ever stored', () => {
expect(consumePendingN8nChatMessage('agent-1')).toBeUndefined();
});
});

View File

@ -1,45 +1,59 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import type { AgentChatListItem } from '@n8n/api-types';
import { N8nText } from '@n8n/design-system';
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
import { AGENT_N8N_CHAT_VIEW } from '../../constants';
import { useAgentTelemetry, type N8nChatAgentSource } from '../../composables/useAgentTelemetry';
import AgentPersonalisationIcon from '../../components/AgentPersonalisationIcon.vue';
import { useN8nAssistantIdentity } from '../composables/useN8nAssistantIdentity';
const props = defineProps<{
agent: AgentChatListItem;
/** `null` renders the n8n Assistant, which links back to the Assistant page. */
agent: AgentChatListItem | null;
/** Where the card was shown, for the selection telemetry event. */
source: N8nChatAgentSource;
}>();
const assistant = useN8nAssistantIdentity();
const agentTelemetry = useAgentTelemetry();
const to = computed(() =>
props.agent
? { name: AGENT_N8N_CHAT_VIEW, params: { agentId: props.agent.id } }
: { name: INSTANCE_AI_VIEW },
);
const name = computed(() => props.agent?.name ?? assistant.name.value);
const description = computed(() =>
props.agent ? props.agent.description : assistant.description.value,
);
function onClick(): void {
if (!props.agent) return;
agentTelemetry.trackSelectedN8nChatAgent({ agentId: props.agent.id, source: props.source });
}
</script>
<template>
<RouterLink
:to="{ name: AGENT_N8N_CHAT_VIEW, params: { agentId: agent.id } }"
:class="$style.card"
data-test-id="n8n-chat-agent-card"
@click="onClick"
>
<AgentPersonalisationIcon :personalisation="agent.personalisation" :size="40" />
<RouterLink :to="to" :class="$style.card" data-test-id="n8n-chat-agent-card" @click="onClick">
<AgentPersonalisationIcon
:personalisation="agent ? agent.personalisation : assistant.personalisation"
:size="40"
/>
<div :class="$style.content">
<N8nText bold :class="$style.title" data-test-id="n8n-chat-agent-card-name">
{{ agent.name }}
{{ name }}
</N8nText>
<N8nText
v-if="agent.description"
v-if="description"
size="small"
color="text-light"
:class="$style.description"
data-test-id="n8n-chat-agent-card-description"
>
{{ agent.description }}
{{ description }}
</N8nText>
</div>
</RouterLink>

View File

@ -10,8 +10,10 @@ withDefaults(
agents: AgentChatListItem[];
loading?: boolean;
source: N8nChatAgentSource;
/** Puts an n8n Assistant card before the agents. */
includeAssistant?: boolean;
}>(),
{ loading: false },
{ loading: false, includeAssistant: false },
);
const SKELETON_CARD_COUNT = 6;
@ -28,6 +30,7 @@ const SKELETON_CARD_COUNT = 6;
/>
</template>
<template v-else>
<N8nChatAgentCard v-if="includeAssistant" :agent="null" :source="source" />
<N8nChatAgentCard v-for="agent in agents" :key="agent.id" :agent="agent" :source="source" />
</template>
</div>

View File

@ -0,0 +1,313 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { AgentChatListItem } from '@n8n/api-types';
import {
N8nAssistantIcon,
N8nButton,
N8nDropdownMenu,
N8nIcon,
N8nText,
type DropdownMenuItemProps,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { I18nT } from 'vue-i18n';
import { getDebounceTime } from '@n8n/composables/useDebounce';
import { DEBOUNCE_TIME } from '@/app/constants';
import { AGENT_N8N_CHAT_LIBRARY_VIEW } from '../../constants';
import { useN8nChatAgents } from '../composables/useN8nChatAgents';
import { useN8nAssistantIdentity } from '../composables/useN8nAssistantIdentity';
import { useAgentTelemetry } from '../../composables/useAgentTelemetry';
import { useAgentPermissions } from '../../composables/useAgentPermissions';
import { useCreateAgent } from '../../composables/useCreateAgent';
import AgentPersonalisationIcon from '../../components/AgentPersonalisationIcon.vue';
/** Non-agent id for the "n8n Assistant" row, which has no agent id of its own. */
const ASSISTANT_ITEM_ID = '__n8n_assistant__';
const PAGE_SIZE = 10;
type PickerItemData = { kind: 'assistant' } | { kind: 'agent'; agent: AgentChatListItem };
const props = defineProps<{
/** `null` selects n8n Assistant. */
modelValue: AgentChatListItem | null;
projectId?: string;
/** Blocks picking a different recipient, e.g. while a send is being prepared. */
disabled?: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [agent: AgentChatListItem | null];
}>();
const i18n = useI18n();
const router = useRouter();
const agentTelemetry = useAgentTelemetry();
const { canCreate } = useAgentPermissions(() => props.projectId);
const { createAgent } = useCreateAgent();
const searchQuery = ref('');
const { agents, count, isLoading } = useN8nChatAgents({
query: searchQuery,
page: 1,
pageSize: PAGE_SIZE,
});
const { name: assistantName, description: assistantDescription } = useN8nAssistantIdentity();
// The dropdown's internal search mode manages the search text and keyboard
// navigation but renders no filtering of its own (DropdownMenuSearchableContent
// only emits `search`) — the Assistant row's visibility is filtered here.
const showAssistantItem = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
return query.length === 0 || assistantName.value.toLowerCase().includes(query);
});
const items = computed<Array<DropdownMenuItemProps<string, PickerItemData>>>(() => {
const agentItems = agents.value.map((agent) => ({
id: agent.id,
testId: `n8n-chat-agent-picker-item-${agent.id}`,
label: agent.name,
data: { kind: 'agent' as const, agent },
}));
if (!showAssistantItem.value) return agentItems;
return [
{
id: ASSISTANT_ITEM_ID,
testId: 'n8n-chat-agent-picker-item-assistant',
label: assistantName.value,
data: { kind: 'assistant' as const },
},
...agentItems,
];
});
function itemDescription(data?: PickerItemData): string | undefined {
if (!data) return undefined;
return data.kind === 'assistant' ? assistantDescription.value : data.agent.description;
}
const showViewAll = computed(() => count.value > PAGE_SIZE);
const canCreateInProject = computed(() => Boolean(props.projectId) && canCreate.value);
const showFooter = computed(() => showViewAll.value || canCreateInProject.value);
const selectedName = computed(() => props.modelValue?.name ?? assistantName.value);
const selectedDescription = computed(() =>
props.modelValue ? props.modelValue.description : assistantDescription.value,
);
function handleSearch(term: string) {
searchQuery.value = term;
}
function handleSelect(id: string) {
if (id === ASSISTANT_ITEM_ID) {
emit('update:modelValue', null);
return;
}
const agent = agents.value.find((candidate) => candidate.id === id);
if (!agent) return;
emit('update:modelValue', agent);
agentTelemetry.trackSelectedN8nChatAgent({ agentId: agent.id, source: 'dropdown' });
}
function goToLibrary() {
void router.push({ name: AGENT_N8N_CHAT_LIBRARY_VIEW });
}
function handleCreateAgent() {
if (!props.projectId) return;
createAgent('dropdown', props.projectId);
}
</script>
<template>
<div :class="$style.container" data-test-id="n8n-chat-agent-picker">
<N8nText
tag="h1"
size="xlarge"
bold
:class="$style.heading"
data-test-id="n8n-chat-agent-picker-heading"
>
<I18nT keypath="agents.n8nChatPage.picker.chatWith" scope="global">
<template #agent>
<N8nDropdownMenu
:items="items"
:loading="isLoading"
:disabled="disabled"
searchable
:search-placeholder="i18n.baseText('agents.n8nChatPage.library.search.placeholder')"
:search-debounce="getDebounceTime(DEBOUNCE_TIME.INPUT.SEARCH)"
:empty-text="i18n.baseText('agents.n8nChatPage.library.empty.noResults.title')"
placement="bottom-start"
max-height="50vh"
content-test-id="n8n-chat-agent-picker-menu"
@search="handleSearch"
@select="handleSelect"
>
<template #trigger>
<button
type="button"
:class="$style.trigger"
:disabled="disabled"
data-test-id="n8n-chat-agent-picker-trigger"
>
<N8nAssistantIcon v-if="!modelValue" size="large" />
<AgentPersonalisationIcon
v-else
:personalisation="modelValue.personalisation"
:size="24"
/>
<span :class="$style.triggerName">{{ selectedName }}</span>
<N8nIcon icon="chevron-down" size="small" color="text-light" />
</button>
</template>
<template #item-leading="{ item, ui }">
<span :class="[ui.class, $style.itemAvatar]">
<N8nAssistantIcon v-if="item.data?.kind === 'assistant'" size="large" />
<AgentPersonalisationIcon
v-else
:personalisation="
item.data?.kind === 'agent' ? item.data.agent.personalisation : null
"
:size="24"
/>
</span>
</template>
<template #item-label="{ item }">
<div :class="$style.itemLabel">
<N8nText bold>{{ item.label }}</N8nText>
<N8nText
v-if="itemDescription(item.data)"
size="small"
color="text-light"
:class="$style.itemDescription"
>
{{ itemDescription(item.data) }}
</N8nText>
</div>
</template>
<template v-if="showFooter" #footer>
<div :class="$style.footer">
<N8nButton
v-if="showViewAll"
variant="ghost"
:class="$style.footerButton"
data-test-id="n8n-chat-agent-picker-view-all"
@click="goToLibrary"
>
{{ i18n.baseText('agents.n8nChatPage.viewAllAgents') }}
<N8nIcon icon="chevron-right" size="small" />
</N8nButton>
<N8nButton
v-if="canCreateInProject"
variant="ghost"
:class="$style.footerButton"
icon="plus"
data-test-id="n8n-chat-agent-picker-create"
@click="handleCreateAgent"
>
{{ i18n.baseText('agents.n8nChatPage.picker.createNewAgent') }}
<!-- Opens the agent builder, a new page. -->
<N8nIcon icon="external-link" size="small" />
</N8nButton>
</div>
</template>
</N8nDropdownMenu>
</template>
</I18nT>
</N8nText>
<N8nText
v-if="selectedDescription"
tag="p"
color="text-light"
:class="$style.subtitle"
data-test-id="n8n-chat-agent-picker-subtitle"
>
{{ selectedDescription }}
</N8nText>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing--2xs);
text-align: center;
}
.heading {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: var(--spacing--2xs);
}
.trigger {
display: inline-flex;
align-items: center;
gap: var(--spacing--3xs);
padding: 0;
font: inherit;
font-weight: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
}
.triggerName {
text-decoration: underline dotted;
text-underline-offset: var(--spacing--5xs);
}
.subtitle {
max-width: 480px;
}
// Top-aligned next to a two-line label, not centered.
.itemAvatar {
align-self: flex-start;
display: inline-flex;
}
.itemLabel {
display: flex;
flex-direction: column;
min-width: 0;
line-height: var(--line-height--sm);
}
.itemDescription {
line-height: var(--line-height--sm);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
// Same footer as the "Chat history" dropdown: a divider, then full-width actions that
// start at the rows' left edge.
.footer {
display: flex;
flex-direction: column;
border-top: var(--border);
padding: var(--spacing--4xs);
}
.footerButton {
width: 100%;
justify-content: flex-start;
}
</style>

View File

@ -4,6 +4,7 @@ import { createMemoryHistory, createRouter } from 'vue-router';
import { i18nInstance } from '@n8n/i18n';
import type { AgentChatListItem } from '@n8n/api-types';
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
import { AGENT_N8N_CHAT_VIEW } from '../../../constants';
import N8nChatAgentCard from '../N8nChatAgentCard.vue';
@ -21,6 +22,7 @@ const router = createRouter({
name: AGENT_N8N_CHAT_VIEW,
component: stub,
},
{ path: '/assistant', name: INSTANCE_AI_VIEW, component: stub },
],
});
@ -43,6 +45,20 @@ describe('N8nChatAgentCard', () => {
trackSelectedN8nChatAgentMock.mockClear();
});
it('renders n8n Assistant for a null agent, linking to the Assistant page without telemetry', async () => {
const wrapper = renderCard({ agent: null });
await router.isReady();
expect(wrapper.get('a').attributes('href')).toBe('/assistant');
expect(wrapper.get('[data-test-id="n8n-chat-agent-card-name"]').text()).toBe('n8n Assistant');
expect(wrapper.get('[data-test-id="n8n-chat-agent-card-description"]').text()).toBe(
'Turns plain language into working workflows and agents.',
);
await wrapper.get('a').trigger('click');
expect(trackSelectedN8nChatAgentMock).not.toHaveBeenCalled();
});
it('links to the agent n8n Chat view', async () => {
const wrapper = renderCard();
await router.isReady();

View File

@ -4,6 +4,7 @@ import { createMemoryHistory, createRouter } from 'vue-router';
import { i18nInstance } from '@n8n/i18n';
import type { AgentChatListItem } from '@n8n/api-types';
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
import { AGENT_N8N_CHAT_VIEW } from '../../../constants';
import SkeletonAgentCard from '@/features/ai/chatHub/components/SkeletonAgentCard.vue';
import N8nChatAgentCard from '../N8nChatAgentCard.vue';
@ -22,6 +23,7 @@ const router = createRouter({
name: AGENT_N8N_CHAT_VIEW,
component: stub,
},
{ path: '/assistant', name: INSTANCE_AI_VIEW, component: stub },
],
});
@ -30,7 +32,9 @@ const agents: AgentChatListItem[] = [
{ id: 'agent-2', name: 'Two', project: { id: 'p', name: 'P' } },
];
function renderGrid(props: { agents?: AgentChatListItem[]; loading?: boolean } = {}) {
function renderGrid(
props: { agents?: AgentChatListItem[]; loading?: boolean; includeAssistant?: boolean } = {},
) {
return mount(N8nChatAgentGrid, {
props: { agents, source: 'library', ...props },
global: { plugins: [router, i18nInstance] },
@ -38,6 +42,13 @@ function renderGrid(props: { agents?: AgentChatListItem[]; loading?: boolean } =
}
describe('N8nChatAgentGrid', () => {
it('puts an n8n Assistant card first with includeAssistant', () => {
const cards = renderGrid({ includeAssistant: true }).findAllComponents(N8nChatAgentCard);
expect(cards).toHaveLength(3);
expect(cards[0].props('agent')).toBeNull();
});
it('renders one card per agent', () => {
const wrapper = renderGrid();

View File

@ -0,0 +1,225 @@
import { computed, ref, type Ref } from 'vue';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import type { AgentChatListItem } from '@n8n/api-types';
import { createComponentRenderer } from '@/__tests__/render';
import N8nChatAgentPicker from '../N8nChatAgentPicker.vue';
type Options = { query: Ref<string>; page: Ref<number>; pageSize: Ref<number> };
const useN8nChatAgentsMock = vi.fn();
vi.mock('../../composables/useN8nChatAgents', () => ({
useN8nChatAgents: (options: Options) => useN8nChatAgentsMock(options),
}));
const trackSelectedN8nChatAgentMock = vi.fn();
vi.mock('../../../composables/useAgentTelemetry', () => ({
useAgentTelemetry: () => ({ trackSelectedN8nChatAgent: trackSelectedN8nChatAgentMock }),
}));
const canCreateState = { value: true };
vi.mock('../../../composables/useAgentPermissions', () => ({
useAgentPermissions: () => ({ canCreate: computed(() => canCreateState.value) }),
}));
const createAgentMock = vi.fn();
vi.mock('../../../composables/useCreateAgent', () => ({
useCreateAgent: () => ({ createAgent: createAgentMock }),
}));
const pushMock = vi.fn();
vi.mock('vue-router', () => ({
useRouter: () => ({ push: pushMock }),
}));
function agent(id: string, name: string, description?: string): AgentChatListItem {
return { id, name, description, project: { id: 'project-1', name: 'Project' } };
}
function setup(
result: { agents: AgentChatListItem[]; count: number; isLoading?: boolean },
props: {
modelValue: AgentChatListItem | null;
projectId?: string;
disabled?: boolean;
} = { modelValue: null },
) {
useN8nChatAgentsMock.mockReturnValue({
agents: ref(result.agents),
count: ref(result.count),
isLoading: ref(result.isLoading ?? false),
loadFailed: ref(false),
retry: vi.fn(),
});
const renderComponent = createComponentRenderer(N8nChatAgentPicker);
return renderComponent({ props });
}
describe('N8nChatAgentPicker', () => {
beforeEach(() => {
vi.clearAllMocks();
canCreateState.value = true;
});
it('renders the heading as one translated phrase with the picker in its agent slot', () => {
const { getByTestId } = setup({ agents: [], count: 0 });
const heading = getByTestId('n8n-chat-agent-picker-heading');
expect(heading).toHaveTextContent(/^Chat with\s*n8n Assistant$/);
expect(heading).toContainElement(getByTestId('n8n-chat-agent-picker-trigger'));
});
it('shows n8n Assistant as the selection by default', () => {
const { getByTestId } = setup({ agents: [], count: 0 });
expect(getByTestId('n8n-chat-agent-picker-trigger')).toHaveTextContent('n8n Assistant');
expect(getByTestId('n8n-chat-agent-picker-subtitle')).toHaveTextContent(
'Turns plain language into working workflows and agents.',
);
});
it('shows no subtitle for a selected agent without a description', () => {
const selected = agent('a1', 'Support Agent');
const { queryByTestId } = setup({ agents: [selected], count: 1 }, { modelValue: selected });
expect(queryByTestId('n8n-chat-agent-picker-subtitle')).not.toBeInTheDocument();
});
it('shows the selected agent instead of the Assistant', () => {
const selected = agent('a1', 'Support Agent', 'Answers billing questions');
const { getByTestId } = setup({ agents: [selected], count: 1 }, { modelValue: selected });
expect(getByTestId('n8n-chat-agent-picker-trigger')).toHaveTextContent('Support Agent');
expect(getByTestId('n8n-chat-agent-picker-subtitle')).toHaveTextContent(
'Answers billing questions',
);
});
it('lists agents from the library in the dropdown', async () => {
const { getByTestId, findByTestId } = setup({
agents: [agent('a1', 'Support Agent', 'Answers billing questions')],
count: 1,
});
await userEvent.click(getByTestId('n8n-chat-agent-picker-trigger'));
const item = await findByTestId('n8n-chat-agent-picker-item-a1');
expect(item).toHaveTextContent('Support Agent');
expect(item).toHaveTextContent('Answers billing questions');
});
it('hides the Assistant row once the search no longer matches its name', async () => {
const { getByTestId, findByTestId, queryByTestId, getByPlaceholderText } = setup({
agents: [agent('a1', 'Support Agent')],
count: 1,
});
await userEvent.click(getByTestId('n8n-chat-agent-picker-trigger'));
expect(await findByTestId('n8n-chat-agent-picker-item-assistant')).toBeInTheDocument();
await userEvent.type(getByPlaceholderText('Search agents...'), 'support');
await waitFor(() => {
expect(queryByTestId('n8n-chat-agent-picker-item-assistant')).not.toBeInTheDocument();
});
expect(getByTestId('n8n-chat-agent-picker-item-a1')).toBeInTheDocument();
});
it('emits the selected agent and tracks the selection on click', async () => {
const testAgent = agent('a1', 'Support Agent');
const { getByTestId, findByTestId, emitted } = setup({ agents: [testAgent], count: 1 });
await userEvent.click(getByTestId('n8n-chat-agent-picker-trigger'));
await userEvent.click(await findByTestId('n8n-chat-agent-picker-item-a1'));
expect(emitted()['update:modelValue']?.[0]).toEqual([testAgent]);
expect(trackSelectedN8nChatAgentMock).toHaveBeenCalledWith({
agentId: 'a1',
source: 'dropdown',
});
});
it('emits null and tracks nothing when the Assistant is selected', async () => {
const selected = agent('a1', 'Support Agent');
const { getByTestId, findByTestId, emitted } = setup(
{ agents: [selected], count: 1 },
{ modelValue: selected },
);
await userEvent.click(getByTestId('n8n-chat-agent-picker-trigger'));
await userEvent.click(await findByTestId('n8n-chat-agent-picker-item-assistant'));
expect(emitted()['update:modelValue']?.[0]).toEqual([null]);
expect(trackSelectedN8nChatAgentMock).not.toHaveBeenCalled();
});
it('hides "View all agents" with 10 or fewer agents', async () => {
canCreateState.value = false;
const { getByTestId, findByTestId, queryByTestId } = setup({ agents: [], count: 10 });
await userEvent.click(getByTestId('n8n-chat-agent-picker-trigger'));
await findByTestId('n8n-chat-agent-picker-menu');
expect(queryByTestId('n8n-chat-agent-picker-view-all')).not.toBeInTheDocument();
});
it('shows "View all agents" past 10 agents and navigates to the library', async () => {
canCreateState.value = false;
const { getByTestId, findByTestId } = setup({ agents: [], count: 11 });
await userEvent.click(getByTestId('n8n-chat-agent-picker-trigger'));
const viewAll = await findByTestId('n8n-chat-agent-picker-view-all');
expect(viewAll.querySelector('[data-icon="chevron-right"]')).not.toBeNull();
await userEvent.click(viewAll);
expect(pushMock).toHaveBeenCalledWith({ name: 'AgentN8nChatLibraryView' });
});
it.each([
['without create permission', false, 'p1'],
['without a selected project', true, undefined],
])('hides "Create new agent" %s', async (_case, canCreate, projectId) => {
canCreateState.value = canCreate;
const { getByTestId, findByTestId, queryByTestId } = setup(
{ agents: [], count: 0 },
{ modelValue: null, projectId },
);
await userEvent.click(getByTestId('n8n-chat-agent-picker-trigger'));
await findByTestId('n8n-chat-agent-picker-menu');
expect(queryByTestId('n8n-chat-agent-picker-create')).not.toBeInTheDocument();
});
it('shows "Create new agent" with permission and a project, and creates it there', async () => {
canCreateState.value = true;
const { getByTestId, findByTestId } = setup(
{ agents: [], count: 0 },
{ modelValue: null, projectId: 'p1' },
);
await userEvent.click(getByTestId('n8n-chat-agent-picker-trigger'));
const createButton = await findByTestId('n8n-chat-agent-picker-create');
// It opens the agent builder, a new page, so it carries the new-page icon.
expect(createButton.querySelector('[data-icon="external-link"]')).not.toBeNull();
await userEvent.click(createButton);
expect(createAgentMock).toHaveBeenCalledWith('dropdown', 'p1');
});
it('disables the trigger and blocks opening the menu when disabled', async () => {
const { getByTestId, queryByTestId } = setup(
{ agents: [agent('a1', 'Support Agent')], count: 1 },
{ modelValue: null, disabled: true },
);
const trigger = getByTestId('n8n-chat-agent-picker-trigger');
expect(trigger).toBeDisabled();
await userEvent.click(trigger);
expect(queryByTestId('n8n-chat-agent-picker-menu')).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,26 @@
import { computed } from 'vue';
import type { AgentJsonConfig } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
// The Assistant has no personalisation of its own: a sparkle on its brand gradient tokens,
// so the tile follows the theme like the rest of the Assistant UI.
const N8N_ASSISTANT_PERSONALISATION: AgentJsonConfig['personalisation'] = {
icon: 'sparkles',
gradient: {
from: 'var(--assistant--color--highlight-1)',
to: 'var(--assistant--color--highlight-3)',
angle: 135,
fromStop: 0,
toStop: 100,
},
};
/** How n8n Assistant shows up next to agents in the n8n Chat picker and library. */
export function useN8nAssistantIdentity() {
const i18n = useI18n();
return {
name: computed(() => i18n.baseText('instanceAi.view.title')),
description: computed(() => i18n.baseText('agents.n8nChatPage.picker.assistantDescription')),
personalisation: N8N_ASSISTANT_PERSONALISATION,
};
}

View File

@ -4,6 +4,7 @@ import { flushPromises } from '@vue/test-utils';
import type { AgentChatListResponse } from '@n8n/api-types';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { AGENT_N8N_CHAT_SEARCH_MAX_LENGTH } from '../../constants';
import { useN8nChatAgents } from './useN8nChatAgents';
const listN8nChatAgentsMock = vi.fn();
@ -102,6 +103,23 @@ describe('useN8nChatAgents', () => {
});
});
it('caps the query before it reaches the API', async () => {
listN8nChatAgentsMock.mockResolvedValue({ count: 0, data: [] });
const query = ref('a'.repeat(200));
const page = ref(1);
const pageSize = 50;
useN8nChatAgents({ query, page, pageSize });
await flushPromises();
expect(listN8nChatAgentsMock).toHaveBeenCalledWith(expect.anything(), {
query: 'a'.repeat(AGENT_N8N_CHAT_SEARCH_MAX_LENGTH),
skip: 0,
take: 50,
sortBy: 'usage:desc',
});
});
it('drops a stale response that resolves after a newer request', async () => {
const first = createDeferredPromise<AgentChatListResponse>();
const second = createDeferredPromise<AgentChatListResponse>();

View File

@ -4,6 +4,7 @@ import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { useRootStore } from '@n8n/stores/useRootStore';
import { AGENT_N8N_CHAT_SEARCH_MAX_LENGTH } from '../../constants';
import { listN8nChatAgents } from '../../composables/useAgentApi';
export interface UseN8nChatAgentsOptions {
@ -36,7 +37,8 @@ export function useN8nChatAgents(options: UseN8nChatAgentsOptions) {
const version = ++requestVersion;
isLoading.value = true;
loadFailed.value = false;
const query = toValue(options.query);
// Caps the query before it reaches the API, which rejects a longer one (backend filter cap).
const query = toValue(options.query).slice(0, AGENT_N8N_CHAT_SEARCH_MAX_LENGTH);
const page = toValue(options.page);
const pageSize = toValue(options.pageSize);
try {

View File

@ -0,0 +1,40 @@
/**
* Hand-off for the first message of a new n8n Chat agent conversation. The n8n
* Assistant page's "Chat with" picker stores it here before navigating to the
* agent's page, which consumes it once its chat panel is ready to send.
*
* Module-level state, not a store: it only ever bridges one navigation, never
* persists, and never has more than one reader.
*/
export interface PendingN8nChatMessage {
agentId: string;
text: string;
files: File[];
}
let pending: PendingN8nChatMessage | undefined;
export function stashPendingN8nChatMessage(message: PendingN8nChatMessage): void {
pending = message;
}
/**
* One-shot read: always clears the stored hand-off, even for a mismatched
* `agentId`, so a stale message never reaches a later visit to a different agent.
*/
export function consumePendingN8nChatMessage(agentId: string): PendingN8nChatMessage | undefined {
const message = pending;
pending = undefined;
return message?.agentId === agentId ? message : undefined;
}
/**
* Clears the slot only if it still holds this exact message. Use this to back
* out of a stash after a failed hand-off: a later stash may already have
* replaced it, and that later one must survive.
*/
export function discardPendingN8nChatMessage(message: PendingN8nChatMessage): boolean {
if (pending !== message) return false;
pending = undefined;
return true;
}

View File

@ -4,7 +4,12 @@ import { storeToRefs } from 'pinia';
import { useRoute, useRouter } from 'vue-router';
import { useResizeObserver } from '@vueuse/core';
import { v4 as uuidv4 } from 'uuid';
import type { InstanceAiAttachment, InstanceAiThreadSource } from '@n8n/api-types';
import type {
AgentChatListItem,
InstanceAiAttachment,
InstanceAiFileAttachment,
InstanceAiThreadSource,
} from '@n8n/api-types';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { useChatInputAutoFocus } from '@n8n/design-system';
import { useRootStore } from '@n8n/stores/useRootStore';
@ -87,7 +92,14 @@ import {
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { InstanceAiFreeNudge } from '@/experiments/instanceAiFreeNudge';
import { useAgentsN8nChatVariant } from '@/features/agents/composables/useAgentsN8nChatFlag';
import { AGENT_N8N_CHAT_VIEW } from '@/features/agents/constants';
import N8nChatAgentSection from '@/features/agents/n8nChatPage/components/N8nChatAgentSection.vue';
import N8nChatAgentPicker from '@/features/agents/n8nChatPage/components/N8nChatAgentPicker.vue';
import {
discardPendingN8nChatMessage,
stashPendingN8nChatMessage,
} from '@/features/agents/n8nChatPage/pendingN8nChatMessage';
import { base64ToFile } from '@/app/utils/fileUtils';
// Experiment cleanup: remove with instanceAiPromptSuggestionsV2.
const INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY: BaseTextKey =
@ -156,9 +168,16 @@ const { isFeatureEnabled: isProactiveAgentExperimentEnabled } =
const { isFeatureEnabled: isPromptSuggestionsV2ExperimentEnabled } =
useInstanceAiPromptSuggestionsV2Experiment();
const { isVariantEnabled: isSplitVariantEnabled } = useInstanceAiSplitEmptyStateExperiment();
// AGENT-957: variant A replaces the empty state with an agent picker, so it must
// force the default layout and win over every other empty-state experiment.
const { isVariantA: isAgentsN8nChatVariantA } = useAgentsN8nChatVariant();
// Variant A replaces the empty state with an agent picker, variant B with a
// "Chat with [agent]" picker — both force the default layout and win over
// every other empty-state experiment.
const { isVariantA: isAgentsN8nChatVariantA, isVariantB: isAgentsN8nChatVariantB } =
useAgentsN8nChatVariant();
const forcesDefaultEmptyLayout = computed(
() => isAgentsN8nChatVariantA.value || isAgentsN8nChatVariantB.value,
);
// Variant B's picker selection: null is n8n Assistant, matching its own v-model contract.
const selectedChatAgent = ref<AgentChatListItem | null>(null);
// Experiment cleanup: remove with instanceAiSplitEmptyState.
const splitPreviewPromptKey = ref<BaseTextKey | null>(null);
const composerHasContent = ref(false);
@ -172,21 +191,21 @@ const {
isTreatmentVariant: isInspirationFromTaxonomyTreatmentVariant,
} = useInstanceAiInspirationFromTaxonomyExperiment();
const showProactiveStarter = computed(
() => isProactiveAgentExperimentEnabled.value && !isAgentsN8nChatVariantA.value,
() => isProactiveAgentExperimentEnabled.value && !forcesDefaultEmptyLayout.value,
);
// Experiment cleanup: remove with instanceAiSplitEmptyState. The split layout
// hosts the view header inside its chat column; the proactive starter (082)
// keeps precedence.
const isSplitLayoutActive = computed(
() =>
isSplitVariantEnabled.value && !showProactiveStarter.value && !isAgentsN8nChatVariantA.value,
isSplitVariantEnabled.value && !showProactiveStarter.value && !forcesDefaultEmptyLayout.value,
);
const shouldTrackPersonalizedPromptSuggestionsExposure = computed(
() =>
typeof personalizedPromptSuggestionsVariant.value === 'string' &&
!showProactiveStarter.value &&
!isSplitLayoutActive.value &&
!isAgentsN8nChatVariantA.value &&
!forcesDefaultEmptyLayout.value &&
settingsStore.isWorkflowBuilderAvailable,
);
const personalizedPromptSuggestionResolution = ref<
@ -203,7 +222,7 @@ const isTaxonomySegmentResolved = computed(
'taxonomy',
);
const shouldTrackInspirationFromTaxonomyExposure = computed(() => {
if (showProactiveStarter.value || isSplitLayoutActive.value || isAgentsN8nChatVariantA.value) {
if (showProactiveStarter.value || isSplitLayoutActive.value || forcesDefaultEmptyLayout.value) {
return false;
}
@ -396,11 +415,25 @@ const shouldShowPersonalizedPromptSuggestions = computed(() =>
// Experiment cleanup: remove with instanceAiPromptSuggestionsV2.
const emptyStatePromptSuggestionProps = computed(() => {
// AGENT-957: variant A shows the agent picker, not suggestion chips.
// Variant A shows the agent picker, not suggestion chips.
if (isAgentsN8nChatVariantA.value) {
return { placeholderKey: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_PLACEHOLDER_KEY };
}
// Variant B shows its own "Chat with" picker above; the composer only gets
// suggestions while n8n Assistant (not an agent) is the current selection.
if (isAgentsN8nChatVariantB.value) {
if (selectedChatAgent.value) return {};
return {
suggestions: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
suggestionsComponent: WorkflowPreviewSuggestions,
suggestionsComponentProps: {
label: i18n.baseText('agents.n8nChatPage.picker.tryAskingAssistant'),
},
suggestionCatalogVersion: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
};
}
if (showProactiveStarter.value) {
return {};
}
@ -496,12 +529,30 @@ const emptyStateTitleKey = computed<BaseTextKey>(() => {
return INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_TITLE_KEY;
});
// Variant B always overrides the placeholder with who is selected, agent or Assistant.
const n8nChatPickerPlaceholder = computed(() =>
i18n.baseText('agents.n8nChatPage.picker.placeholder', {
interpolate: { name: selectedChatAgent.value?.name ?? i18n.baseText('instanceAi.view.title') },
}),
);
// An agent chat does not need the workflow builder, so a selected agent keeps the composer usable.
const isComposerAvailable = computed(
() => settingsStore.isWorkflowBuilderAvailable || !!selectedChatAgent.value,
);
// Mentions only make sense against the Assistant, so they're off while an agent is selected.
const inputMentionsEnabled = computed(() => mentionsEnabled.value && !selectedChatAgent.value);
const chatInputRef = ref<InstanceType<typeof InstanceAiInput> | null>(null);
// Layout changes mount a new, empty composer.
watch(chatInputRef, () => {
composerHasContent.value = false;
});
const isStartingThread = ref(false);
// Blocks switching the recipient while a send targeting the current one is being
// prepared (e.g. a file still encoding), so it cannot land with the wrong agent.
const isChatAgentPickerDisabled = computed(
() => isStartingThread.value || !!chatInputRef.value?.isPreparingSubmission,
);
watch(
() => route.query[INSTANCE_AI_PROJECT_ID_QUERY],
@ -582,6 +633,35 @@ async function handleSubmit(
mentionCounts: AssistantMentionCounts = EMPTY_ASSISTANT_MENTION_COUNTS,
mentionedWorkflowIds: readonly string[] = [],
) {
if (selectedChatAgent.value) {
const agentId = selectedChatAgent.value.id;
// Resource attachments (workflow/agent/nodes references) don't apply to an
// agent's own n8n Chat page, only binary files do.
const fileAttachments = (attachments ?? []).filter(
(attachment): attachment is InstanceAiFileAttachment => attachment.type === 'file',
);
const files = fileAttachments.map((attachment) =>
base64ToFile(attachment.data, attachment.fileName, attachment.mimeType),
);
// Nothing the agent can take (e.g. only resource references): keep the draft.
if (!message.trim() && files.length === 0) {
restoreDraftAfterFailedSubmit(restoreDraft);
return;
}
const pendingMessage = { agentId, text: message, files };
stashPendingN8nChatMessage(pendingMessage);
acceptDraft();
const failure = await router.push({ name: AGENT_N8N_CHAT_VIEW, params: { agentId } });
// A blocked or cancelled navigation must not leave the hand-off for a later
// visit. Only discard and restore the draft if the slot still holds this
// submission: a second submit made while this push was pending already
// replaced it, and that one must not lose its stash or its draft.
if (failure && discardPendingN8nChatMessage(pendingMessage)) {
restoreDraftAfterFailedSubmit(restoreDraft);
}
return;
}
if (!settingsStore.isWorkflowBuilderAvailable) {
return;
}
@ -764,7 +844,13 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
</template>
</InstanceAiSplitEmptyState>
<div v-else ref="emptyLayout" :class="$style.emptyLayout">
<InstanceAiEmptyState :title-key="emptyStateTitleKey" :show-title-icon="true" />
<N8nChatAgentPicker
v-if="isAgentsN8nChatVariantB"
v-model="selectedChatAgent"
:project-id="selectedProject"
:disabled="isChatAgentPickerDisabled"
/>
<InstanceAiEmptyState v-else :title-key="emptyStateTitleKey" :show-title-icon="true" />
<div ref="centeredInput" :class="$style.centeredInput">
<InstanceAiFreeNudge
:eligible="
@ -781,14 +867,15 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
@upgrade-click="goToUpgrade('instance-ai', 'upgrade-instance-ai')"
@dismiss="creditBanner.dismiss()"
/>
<WorkflowBuilderUnavailableNotice v-if="!settingsStore.isWorkflowBuilderAvailable" />
<WorkflowBuilderUnavailableNotice v-if="!isComposerAvailable" />
<LimitedModeNotice />
<InstanceAiInput
ref="chatInputRef"
:is-submitting="isStartingThread"
:is-workflow-builder-available="settingsStore.isWorkflowBuilderAvailable"
:mentions-enabled="mentionsEnabled"
:is-workflow-builder-available="isComposerAvailable"
:mentions-enabled="inputMentionsEnabled"
:mention-project-id="selectedProject"
:placeholder="isAgentsN8nChatVariantB ? n8nChatPickerPlaceholder : undefined"
v-bind="emptyStatePromptSuggestionProps"
@submit="handleSubmit"
@workflow-preview="handleWorkflowPreview"
@ -801,9 +888,10 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
</template>
</InstanceAiInput>
</div>
<!-- AGENT-957: variant A shows the agent picker here instead of the workflow preview. -->
<!-- Variant A shows the agent picker here instead of the workflow preview;
variant B drops the preview canvas entirely (hover preview stays off). -->
<N8nChatAgentSection v-if="isAgentsN8nChatVariantA" />
<Transition v-else name="workflow-preview-fade">
<Transition v-else-if="!isAgentsN8nChatVariantB" name="workflow-preview-fade">
<div
v-if="activeWorkflowPreview && hasSpaceForPreview"
:class="$style.workflowPreviewWrapper"

View File

@ -1,11 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { computed, defineComponent, h, nextTick, reactive, ref } from 'vue';
import type { InstanceAiAttachment } from '@n8n/api-types';
import { USER_TYPED_MESSAGE, type InstanceAiPrefillType } from '../prefills';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { fireEvent } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import InstanceAiEmptyView from '../InstanceAiEmptyView.vue';
@ -14,6 +16,7 @@ import { useSettingsStore } from '@n8n/stores/settings.store';
import { INSTANCE_AI_THREAD_VIEW } from '../constants';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import type { Project, ProjectListItem } from '@/features/collaboration/projects/projects.types';
import { consumePendingN8nChatMessage } from '@/features/agents/n8nChatPage/pendingN8nChatMessage';
import { defaultModuleSettings } from './createThreadComponentRenderer';
const PERSONAL_PROJECT_ID = 'personal-project-id';
@ -33,6 +36,7 @@ const {
appSettingsStoreMock,
agentsN8nChatVariant,
replaceMock,
pushMock,
showErrorMock,
telemetryTrack,
} = vi.hoisted(() => ({
@ -101,6 +105,7 @@ const {
workflowPreviewSuggestionsComponent: { name: 'WorkflowPreviewSuggestionsStub' },
personalizedPromptSuggestionsComponent: { name: 'InstanceAiPersonalizedPromptSuggestionsStub' },
replaceMock: vi.fn(),
pushMock: vi.fn(),
showErrorMock: vi.fn(),
telemetryTrack: vi.fn(),
}));
@ -279,6 +284,46 @@ vi.mock('@/features/agents/n8nChatPage/components/N8nChatAgentSection.vue', () =
},
}));
vi.mock('@/features/agents/n8nChatPage/components/N8nChatAgentPicker.vue', () => ({
default: {
name: 'N8nChatAgentPickerStub',
props: {
modelValue: { required: false, default: null },
projectId: { required: false },
disabled: { type: Boolean, required: false, default: false },
},
emits: ['update:modelValue'],
data: () => ({
agent: {
id: 'agent-1',
name: 'Support Agent',
project: { id: 'project-1', name: 'Project' },
},
}),
template: `
<div data-test-id="n8n-chat-agent-picker-stub" :data-disabled="String(disabled)">
<button
data-test-id="n8n-chat-agent-picker-stub-select-agent"
:disabled="disabled"
@click="$emit('update:modelValue', agent)"
>select agent</button>
<button
data-test-id="n8n-chat-agent-picker-stub-select-assistant"
:disabled="disabled"
@click="$emit('update:modelValue', null)"
>select assistant</button>
</div>
`,
},
}));
// Not otherwise exercised by this view's tests; defaults on so the mentions-gating
// tests below can prove the agent-selection gate rather than a flag that's already off.
const mentionsFlagEnabled = { value: true };
vi.mock('@/features/ai/assistant-at-mentions/composables/useIsAssistantAtMentionsEnabled', () => ({
useIsAssistantAtMentionsEnabled: () => computed(() => mentionsFlagEnabled.value),
}));
vi.mock('uuid', () => ({
v4: () => 'thread-placeholder',
}));
@ -286,9 +331,14 @@ vi.mock('uuid', () => ({
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal()),
useRoute: () => ({ query: routeQuery, params: {} }),
useRouter: () => ({ push: vi.fn(), replace: replaceMock }),
useRouter: () => ({ push: pushMock, replace: replaceMock }),
}));
// Shared across all stub submits (not just the agent hand-off tests below) so
// the n8n Chat variant B test can assert it without widening the stub's emit
// contract for every other caller.
const acceptDraftMock = vi.fn();
const InstanceAiInputStub = defineComponent({
name: 'InstanceAiInputStub',
props: {
@ -302,6 +352,9 @@ const InstanceAiInputStub = defineComponent({
isSubmitting: { type: Boolean, required: false },
isWorkflowBuilderAvailable: { type: Boolean, required: false },
fixedRows: { type: Number, required: false },
contextualSuggestion: { type: String, required: false, default: null },
placeholder: { type: String, required: false },
mentionsEnabled: { type: Boolean, required: false },
},
emits: ['submit'],
setup(props, { emit, expose, slots }) {
@ -313,6 +366,12 @@ const InstanceAiInputStub = defineComponent({
prefillType: InstanceAiPrefillType;
prefillId?: string;
} | null>(null);
// Staged by the "attach" test button below; mirrors the real composer's
// encoded file attachments.
const stagedFileAttachments = ref<InstanceAiAttachment[]>([]);
// Toggled by a test button below; mirrors the real composer's own flag while
// it awaits file encoding before a submit.
const isPreparingSubmission = ref(false);
const submit = (message: string) => {
const prefill = activePrefill.value;
// Mirrors the real composer: the restore callback is always provided, and
@ -326,7 +385,7 @@ const InstanceAiInputStub = defineComponent({
emit(
'submit',
message,
undefined,
stagedFileAttachments.value.length ? [...stagedFileAttachments.value] : undefined,
restoreDraft,
prefill
? {
@ -336,9 +395,12 @@ const InstanceAiInputStub = defineComponent({
promptModified: message !== prefill.text.trim(),
}
: USER_TYPED_MESSAGE,
undefined,
acceptDraftMock,
);
currentText.value = '';
activePrefill.value = null;
stagedFileAttachments.value = [];
};
expose({
focus: vi.fn(),
@ -352,6 +414,7 @@ const InstanceAiInputStub = defineComponent({
currentText.value = prefill.text;
activePrefill.value = { ...prefill };
},
isPreparingSubmission,
// Mirror the real submitSuggestion: resolve the prompt + emit submit.
submitSuggestion: (payload: {
promptKey: BaseTextKey;
@ -415,6 +478,16 @@ const InstanceAiInputStub = defineComponent({
{ 'data-test-id': 'instance-ai-input-availability' },
props.isWorkflowBuilderAvailable === false ? 'unavailable' : 'available',
),
h(
'span',
{ 'data-test-id': 'instance-ai-input-placeholder' },
props.placeholder ?? 'unset',
),
h(
'span',
{ 'data-test-id': 'instance-ai-input-mentions-enabled' },
String(props.mentionsEnabled),
),
h(
'button',
{
@ -423,6 +496,30 @@ const InstanceAiInputStub = defineComponent({
},
'submit',
),
h(
'button',
{
'data-test-id': 'instance-ai-input-stub-attach-file',
onClick: () =>
stagedFileAttachments.value.push({
type: 'file',
data: btoa('file content'),
mimeType: 'text/plain',
fileName: 'context.txt',
}),
},
'attach',
),
h(
'button',
{
'data-test-id': 'instance-ai-input-stub-toggle-preparing',
onClick: () => {
isPreparingSubmission.value = !isPreparingSubmission.value;
},
},
'toggle preparing',
),
...(slots.footer?.() ?? []),
]);
},
@ -503,6 +600,7 @@ describe('InstanceAiEmptyView', () => {
cloudPlanStoreMock.state.initialized = false;
cloudPlanStoreMock.currentUserCloudInfo = null;
appSettingsStoreMock.isCloudDeployment = false;
mentionsFlagEnabled.value = true;
});
afterEach(() => {
@ -1373,4 +1471,174 @@ describe('InstanceAiEmptyView', () => {
},
);
});
describe('n8n Chat variant B', () => {
beforeEach(() => {
agentsN8nChatVariant.value = 'variant-b';
});
it('renders the picker instead of the empty-state title', () => {
const { getByTestId, queryByTestId } = renderView();
expect(getByTestId('n8n-chat-agent-picker-stub')).toBeInTheDocument();
expect(queryByTestId('instance-ai-empty-state')).not.toBeInTheDocument();
});
it('disables the picker while the input is preparing a submission, and re-enables it after', async () => {
const { getByTestId } = renderView();
expect(getByTestId('n8n-chat-agent-picker-stub')).toHaveAttribute('data-disabled', 'false');
await fireEvent.click(getByTestId('instance-ai-input-stub-toggle-preparing'));
await nextTick();
expect(getByTestId('n8n-chat-agent-picker-stub')).toHaveAttribute('data-disabled', 'true');
await fireEvent.click(getByTestId('instance-ai-input-stub-toggle-preparing'));
await nextTick();
expect(getByTestId('n8n-chat-agent-picker-stub')).toHaveAttribute('data-disabled', 'false');
});
it('forces the default layout, even with the proactive starter and split-layout experiments on', () => {
experimentMocks.proactiveAgentEnabled.value = true;
experimentMocks.splitBelowInputVariant.value = true;
const { getByTestId, queryByTestId } = renderView();
expect(queryByTestId('instance-ai-proactive-starter')).not.toBeInTheDocument();
expect(queryByTestId('instance-ai-split-empty-state')).not.toBeInTheDocument();
expect(getByTestId('n8n-chat-agent-picker-stub')).toBeInTheDocument();
});
it('shows the control suggestions with the Assistant label, and the Assistant placeholder, by default', () => {
const { getByTestId } = renderView();
expect(getByTestId('instance-ai-input-suggestions')).toHaveTextContent('4');
expect(getByTestId('instance-ai-input-suggestions-component')).toHaveTextContent('set');
expect(getByTestId('instance-ai-input-suggestions-component-props')).toHaveTextContent(
'"label":"Try asking n8n Assistant"',
);
expect(getByTestId('instance-ai-input-placeholder')).toHaveTextContent('Ask n8n Assistant…');
});
it('hides suggestions and shows the agent name in the placeholder once an agent is selected', async () => {
const { getByTestId } = renderView();
await fireEvent.click(getByTestId('n8n-chat-agent-picker-stub-select-agent'));
await nextTick();
expect(getByTestId('instance-ai-input-suggestions')).toHaveTextContent('unset');
expect(getByTestId('instance-ai-input-suggestions-component')).toHaveTextContent('unset');
expect(getByTestId('instance-ai-input-placeholder')).toHaveTextContent('Ask Support Agent…');
});
it('keeps the composer usable for a selected agent when the workflow builder is unavailable', async () => {
useSettingsStore().moduleSettings = {
'instance-ai': {
...defaultModuleSettings,
sandboxEnabled: false,
workflowBuilderAvailable: false,
},
};
const { getByTestId, queryByTestId } = renderView();
expect(getByTestId('instance-ai-input-availability')).toHaveTextContent('unavailable');
await fireEvent.click(getByTestId('n8n-chat-agent-picker-stub-select-agent'));
await nextTick();
expect(getByTestId('instance-ai-input-availability')).toHaveTextContent('available');
expect(queryByTestId('instance-ai-workflow-builder-unavailable')).not.toBeInTheDocument();
});
it('disables mentions once an agent is selected, and re-enables them back on the Assistant', async () => {
const { getByTestId } = renderView();
expect(getByTestId('instance-ai-input-mentions-enabled')).toHaveTextContent('true');
await fireEvent.click(getByTestId('n8n-chat-agent-picker-stub-select-agent'));
await nextTick();
expect(getByTestId('instance-ai-input-mentions-enabled')).toHaveTextContent('false');
await fireEvent.click(getByTestId('n8n-chat-agent-picker-stub-select-assistant'));
await nextTick();
expect(getByTestId('instance-ai-input-mentions-enabled')).toHaveTextContent('true');
});
it('navigates to the agent chat route on submit, without starting an Assistant thread', async () => {
const { getByTestId } = renderView();
await fireEvent.click(getByTestId('n8n-chat-agent-picker-stub-select-agent'));
await nextTick();
await fireEvent.click(getByTestId('instance-ai-input-stub-submit'));
await flushPromises();
expect(pushMock).toHaveBeenCalledWith({
name: 'AgentN8nChatView',
params: { agentId: 'agent-1' },
});
expect(store.syncThread).not.toHaveBeenCalled();
expect(thread.sendMessage).not.toHaveBeenCalled();
});
it('stores the hand-off with the typed text and converted file attachments, calls acceptDraft, and does not start an Assistant thread', async () => {
const { getByTestId } = renderView();
await fireEvent.click(getByTestId('n8n-chat-agent-picker-stub-select-agent'));
await nextTick();
await fireEvent.click(getByTestId('instance-ai-input-stub-attach-file'));
await fireEvent.click(getByTestId('instance-ai-input-stub-submit'));
await flushPromises();
expect(pushMock).toHaveBeenCalledWith({
name: 'AgentN8nChatView',
params: { agentId: 'agent-1' },
});
expect(store.syncThread).not.toHaveBeenCalled();
expect(thread.sendMessage).not.toHaveBeenCalled();
expect(acceptDraftMock).toHaveBeenCalledOnce();
const pendingMessage = consumePendingN8nChatMessage('agent-1');
expect(pendingMessage?.text).toBe('hello');
expect(pendingMessage?.files).toHaveLength(1);
expect(pendingMessage?.files[0].name).toBe('context.txt');
expect(pendingMessage?.files[0].type).toBe('text/plain');
expect(pendingMessage?.files[0].size).toBe('file content'.length);
});
it('drops the hand-off when the navigation to the agent fails', async () => {
pushMock.mockResolvedValueOnce(new Error('navigation aborted'));
const { getByTestId } = renderView();
await fireEvent.click(getByTestId('n8n-chat-agent-picker-stub-select-agent'));
await nextTick();
await fireEvent.click(getByTestId('instance-ai-input-stub-submit'));
await flushPromises();
expect(consumePendingN8nChatMessage('agent-1')).toBeUndefined();
});
it('keeps a second hand-off when the first submission fails navigation after the second was stashed', async () => {
const firstPush = createDeferredPromise<unknown>();
pushMock.mockImplementationOnce(async () => await firstPush.promise);
const { getByTestId } = renderView();
await fireEvent.click(getByTestId('n8n-chat-agent-picker-stub-select-agent'));
await nextTick();
// First submission: push 1 stays pending, no files.
await fireEvent.click(getByTestId('instance-ai-input-stub-submit'));
// Second submission lands while push 1 is still pending — the composer
// stays live, so nothing blocks it. A file tells the two apart.
await fireEvent.click(getByTestId('instance-ai-input-stub-attach-file'));
await fireEvent.click(getByTestId('instance-ai-input-stub-submit'));
await flushPromises();
// Push 1 fails after the second stash is in place.
firstPush.resolve(new Error('navigation aborted'));
await flushPromises();
const pendingMessage = consumePendingN8nChatMessage('agent-1');
expect(pendingMessage?.files).toHaveLength(1);
expect(pendingMessage?.files[0]?.name).toBe('context.txt');
});
});
});

View File

@ -32,6 +32,7 @@ type InputTestProps = {
suggestionCatalogVersion?: string;
suggestionTelemetryPayload?: ITelemetryTrackProperties;
placeholderKey?: BaseTextKey;
placeholder?: string;
contextChip?: ContextChip | null;
};
@ -314,6 +315,17 @@ describe('InstanceAiInput', () => {
);
});
it('prefers a caller-provided placeholder text over the placeholder key', () => {
const { getByRole } = renderComponent({
props: {
placeholder: 'Ask Support Agent…',
placeholderKey: 'experiments.instanceAiPromptSuggestionsV2.input.placeholder',
},
});
expect(getByRole('textbox')).toHaveAttribute('placeholder', 'Ask Support Agent…');
});
it('uses the new agent placeholder when the caller passes its key', () => {
const { getByRole } = renderComponent({
props: {

View File

@ -98,6 +98,8 @@ const props = withDefaults(
suggestionCatalogVersion?: string;
suggestionTelemetryPayload?: ITelemetryTrackProperties;
placeholderKey?: BaseTextKey;
/** Already-translated base placeholder; wins over `placeholderKey`. Never used as a prefill. */
placeholder?: string;
// Experiment cleanup: remove with instanceAiSplitEmptyState.
previewPromptKey?: BaseTextKey | null;
// Experiment cleanup: remove with instanceAiSplitEmptyState.
@ -269,6 +271,9 @@ defineExpose({
// Experiment cleanup: remove with instanceAiSplitEmptyState.
insertSuggestion: handleSuggestionInsert,
submitSuggestion,
// Lets a host disable other controls (e.g. an agent picker) while a file
// attachment is still encoding, so they cannot change who a send targets.
isPreparingSubmission,
});
// A run suspended on a plan review is parked, not working: the user is meant to
@ -451,7 +456,7 @@ const placeholder = computed(() => {
if (props.contextualSuggestion) {
return props.contextualSuggestion;
}
return i18n.baseText(props.placeholderKey ?? 'instanceAi.input.placeholder');
return props.placeholder ?? i18n.baseText(props.placeholderKey ?? 'instanceAi.input.placeholder');
});
watch(