diff --git a/packages/@n8n/telemetry/src/events/agents.ts b/packages/@n8n/telemetry/src/events/agents.ts index 6e1d663e040..04527efbb44 100644 --- a/packages/@n8n/telemetry/src/events/agents.ts +++ b/packages/@n8n/telemetry/src/events/agents.ts @@ -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, }), diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 724f40fe8d9..c85f4d5c54a 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/app/utils/fileUtils.test.ts b/packages/frontend/editor-ui/src/app/utils/fileUtils.test.ts index 0a0be6f7f55..7f2a96887dd 100644 --- a/packages/frontend/editor-ui/src/app/utils/fileUtils.test.ts +++ b/packages/frontend/editor-ui/src/app/utils/fileUtils.test.ts @@ -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); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/utils/fileUtils.ts b/packages/frontend/editor-ui/src/app/utils/fileUtils.ts index fbda2f88fbf..b161eb43c8e 100644 --- a/packages/frontend/editor-ui/src/app/utils/fileUtils.ts +++ b/packages/frontend/editor-ui/src/app/utils/fileUtils.ts @@ -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 { const reader = new FileReader(); return await new Promise((resolve, reject) => { diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.test.ts b/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.test.ts index c5b1ff156c5..e5c7b09c084 100644 --- a/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.test.ts +++ b/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.vue b/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.vue index ea660eacecf..beb5c78962d 100644 --- a/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.vue +++ b/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.vue @@ -1,5 +1,5 @@ diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatAgentPicker.vue b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatAgentPicker.vue new file mode 100644 index 00000000000..6132485f4e8 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatAgentPicker.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentCard.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentCard.test.ts index 3fd272b50ec..1594599326b 100644 --- a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentCard.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentCard.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentGrid.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentGrid.test.ts index f1f4d3f0bdf..3925cc31a04 100644 --- a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentGrid.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentGrid.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentPicker.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentPicker.test.ts new file mode 100644 index 00000000000..f3fd6d822a6 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentPicker.test.ts @@ -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; page: Ref; pageSize: Ref }; + +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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nAssistantIdentity.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nAssistantIdentity.ts new file mode 100644 index 00000000000..08c40422d10 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nAssistantIdentity.ts @@ -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, + }; +} diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.test.ts index bb14caff542..de84b56e80f 100644 --- a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.test.ts @@ -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(); const second = createDeferredPromise(); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.ts index 2df00b263e5..7f5935d2ade 100644 --- a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.ts +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.ts @@ -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 { diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/pendingN8nChatMessage.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/pendingN8nChatMessage.ts new file mode 100644 index 00000000000..2cbf25d9885 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/pendingN8nChatMessage.ts @@ -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; +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue index 833856b46cd..1395879d421 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue @@ -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(null); // Experiment cleanup: remove with instanceAiSplitEmptyState. const splitPreviewPromptKey = ref(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(() => { 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 | 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) {
- + +
- +
- + - +
({ @@ -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: ` +
+ + +
+ `, + }, +})); + +// 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([]); + // 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(); + 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'); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiInput.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiInput.test.ts index d95911269e4..fb9bd1086f1 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiInput.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiInput.test.ts @@ -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: { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiInput.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiInput.vue index 24ff3098943..cc8b23b0668 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiInput.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiInput.vue @@ -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(