mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(editor): Add a Chat with agent picker for n8n Chat variant B (no-changelog) (#40337)
This commit is contained in:
parent
cf54433c14
commit
d767edfacd
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user