feat(editor): Roll out Assistant mentions (no-changelog) (#39370)

This commit is contained in:
Milorad FIlipović 2026-09-25 13:27:43 +00:00 committed by GitHub
parent c53a5e4dd8
commit ebbf129719
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 2243 additions and 414 deletions

View File

@ -423,6 +423,7 @@ export {
CONFIG_EVALUATIONS_FLAG,
CONFIG_EVALUATIONS_ENABLED_VARIANT,
CANVAS_NODE_CONTEXT_FLAG,
AI_ASSISTANT_AT_MENTIONS_FLAG,
INSTANCE_AI_CONVERSATION_HISTORY_FLAG,
INSTANCE_AI_CONVERSATION_HISTORY_ENABLED_VARIANT,
INSTANCE_AI_PROGRESSIVE_BUILDING_FLAG,

View File

@ -2716,6 +2716,9 @@ export const CONFIG_EVALUATIONS_ENABLED_VARIANT = 'variant';
/** Enables adding selected canvas nodes as chat context in the n8n Assistant */
export const CANVAS_NODE_CONTEXT_FLAG = '104_canvas_aia_node_context';
/** Enables workflow, node, and canvas group mentions in the n8n Assistant */
export const AI_ASSISTANT_AT_MENTIONS_FLAG = '116_at_mentions_enabled';
/** Enables the conversation-history tool and the past-conversations first-turn hint */
export const INSTANCE_AI_CONVERSATION_HISTORY_FLAG = '109_instance_ai_conversation_history';

View File

@ -44,6 +44,7 @@ const setupSnapshotProps = {
const freeNudgeVariant = z.enum(['control', 'variant-1', 'variant-2']);
const freeNudgeTreatmentVariant = z.enum(['variant-1', 'variant-2']);
const assistantMentionKind = z.enum(['workflow', 'node', 'group']);
// Experiment cleanup: remove with openWorkflowInAssistant.
const openWorkflowInAssistantVariant = z.enum(['control', 'variant']);
@ -466,6 +467,39 @@ export const INSTANCE_AI_TELEMETRY = defineTelemetryEvents({
node_count: z.number().describe('Total nodes attached across the sent message'),
}),
},
USER_OPENED_AI_ASSISTANT_MENTION_PICKER: {
name: 'User opened AI Assistant mention picker',
description:
'The user opened the n8n Assistant mention picker by typing an at sign or selecting the composer button.',
properties: z.object({
source: z.enum(['typed', 'button']),
}),
},
USER_SELECTED_AI_ASSISTANT_MENTION: {
name: 'User selected AI Assistant mention',
description:
'The user selected a workflow, node, or canvas group from the n8n Assistant mention picker. The event contains interaction metadata but no resource names or IDs.',
properties: z.object({
kind: assistantMentionKind,
mode: z.enum(['browse', 'search']),
source: z.enum(['artifacts', 'workflows']),
result_position: z
.number()
.int()
.positive()
.describe('One-based position in the current search list, browse section, or submenu'),
query_length: z.number().int().nonnegative(),
already_artifact: z.boolean(),
}),
},
USER_REMOVED_AI_ASSISTANT_MENTION: {
name: 'User removed AI Assistant mention',
description:
'The user removed workflow, node, or canvas group context that they added through the n8n Assistant mention picker.',
properties: z.object({
kind: assistantMentionKind,
}),
},
USER_SENT_BUILDER_MESSAGE: {
name: 'User sent builder message',
description:
@ -500,6 +534,11 @@ export const INSTANCE_AI_TELEMETRY = defineTelemetryEvents({
.describe(
'Whether the user edited the pre-filled text before sending. Always false for pre-fills that send without being shown. Null when the user typed the message.',
),
mention_count: z.number().int().nonnegative(),
workflow_mention_count: z.number().int().nonnegative(),
node_mention_count: z.number().int().nonnegative(),
group_mention_count: z.number().int().nonnegative(),
attachment_count: z.number().int().nonnegative(),
}),
},
BUILDER_LISTED_WORKFLOWS: {

View File

@ -1,43 +0,0 @@
import { CANVAS_NODE_CONTEXT_FLAG } from '@n8n/api-types';
import type { User } from '@n8n/db';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { PostHogClient } from '@/posthog';
import { CanvasNodeContextFlagGate } from '../canvas-node-context-flag-gate';
describe('CanvasNodeContextFlagGate', () => {
let postHogClient: Mocked<PostHogClient>;
let gate: CanvasNodeContextFlagGate;
const user = mock<User>({ id: 'user-1' });
beforeEach(() => {
postHogClient = mock<PostHogClient>();
gate = new CanvasNodeContextFlagGate(postHogClient);
});
it('resolves true when the flag is on for the user', async () => {
postHogClient.getFeatureFlags.mockResolvedValue({ [CANVAS_NODE_CONTEXT_FLAG]: true });
expect(await gate.isEnabled(user)).toBe(true);
});
it('resolves false when the flag is absent', async () => {
postHogClient.getFeatureFlags.mockResolvedValue({});
expect(await gate.isEnabled(user)).toBe(false);
});
it('resolves false when the flag is explicitly off', async () => {
postHogClient.getFeatureFlags.mockResolvedValue({ [CANVAS_NODE_CONTEXT_FLAG]: false });
expect(await gate.isEnabled(user)).toBe(false);
});
it('resolves false (fail-closed) when getFeatureFlags rejects', async () => {
postHogClient.getFeatureFlags.mockRejectedValue(new Error('PostHog unreachable'));
await expect(gate.isEnabled(user)).resolves.toBe(false);
});
});

View File

@ -48,6 +48,7 @@ function createService() {
progressiveBuildingEnabled: false,
setupPanelEnabled: false,
nodeUsageEnabled: false,
nodeContextEnabled: false,
folderExplorationEnabled: false,
aiPreferencesEnabled: false,
instanceContextEnabled: false,

View File

@ -67,6 +67,8 @@ import type {
} from 'n8n-workflow';
import {
AI_GATEWAY_MANAGED_TAG,
AI_ASSISTANT_AT_MENTIONS_FLAG,
CANVAS_NODE_CONTEXT_FLAG,
CONFIG_EVALUATIONS_FLAG,
INSTANCE_AI_CONVERSATION_HISTORY_FLAG,
INSTANCE_AI_NODE_USAGE_FLAG,
@ -6987,6 +6989,7 @@ describe('resolveExperimentGates', () => {
[INSTANCE_AI_PROGRESSIVE_BUILDING_FLAG]: INSTANCE_AI_PROGRESSIVE_BUILDING_ENABLED_VARIANT,
[INSTANCE_AI_SETUP_PANEL_FLAG]: INSTANCE_AI_SETUP_PANEL_ENABLED_VARIANT,
[INSTANCE_AI_NODE_USAGE_FLAG]: true,
[AI_ASSISTANT_AT_MENTIONS_FLAG]: true,
[INSTANCE_AI_FOLDER_EXPLORATION_FLAG]: INSTANCE_AI_FOLDER_EXPLORATION_ENABLED_VARIANT,
[CONTEXT_PREFERENCES_FLAG]: CONTEXT_PREFERENCES_ENABLED_VARIANT,
};
@ -7002,6 +7005,7 @@ describe('resolveExperimentGates', () => {
setupPanelEnabled: true,
setupPanelVariant: 'variant',
nodeUsageEnabled: true,
nodeContextEnabled: true,
folderExplorationEnabled: true,
aiPreferencesEnabled: true,
instanceContextEnabled: false,
@ -7058,9 +7062,21 @@ describe('resolveExperimentGates', () => {
await expect(createAdapter().resolveExperimentGates(user)).resolves.toMatchObject({
instanceContextEnabled: true,
nodeUsageEnabled: false,
nodeContextEnabled: false,
});
});
it.each([CANVAS_NODE_CONTEXT_FLAG, AI_ASSISTANT_AT_MENTIONS_FLAG])(
'enables node context with %s',
async (flag) => {
stubContainer({ [flag]: true });
await expect(createAdapter().resolveExperimentGates(user)).resolves.toMatchObject({
nodeContextEnabled: true,
});
},
);
it('disables experiment gates for control variants', async () => {
stubContainer({
[CONFIG_EVALUATIONS_FLAG]: 'control',
@ -7068,6 +7084,8 @@ describe('resolveExperimentGates', () => {
[INSTANCE_AI_PROGRESSIVE_BUILDING_FLAG]: 'control',
[INSTANCE_AI_SETUP_PANEL_FLAG]: 'control',
[INSTANCE_AI_NODE_USAGE_FLAG]: false,
[CANVAS_NODE_CONTEXT_FLAG]: false,
[AI_ASSISTANT_AT_MENTIONS_FLAG]: false,
[INSTANCE_AI_FOLDER_EXPLORATION_FLAG]: 'control',
[CONTEXT_PREFERENCES_FLAG]: CONTEXT_PREFERENCES_CONTROL_VARIANT,
});
@ -7080,6 +7098,7 @@ describe('resolveExperimentGates', () => {
setupPanelEnabled: false,
setupPanelVariant: 'control',
nodeUsageEnabled: false,
nodeContextEnabled: false,
folderExplorationEnabled: false,
aiPreferencesEnabled: false,
instanceContextEnabled: false,
@ -7129,6 +7148,7 @@ describe('resolveExperimentGates', () => {
progressiveBuildingEnabled: false,
setupPanelEnabled: false,
nodeUsageEnabled: false,
nodeContextEnabled: false,
folderExplorationEnabled: false,
aiPreferencesEnabled: false,
instanceContextEnabled: false,
@ -7146,6 +7166,7 @@ describe('resolveExperimentGates', () => {
progressiveBuildingEnabled: false,
setupPanelEnabled: false,
nodeUsageEnabled: false,
nodeContextEnabled: false,
folderExplorationEnabled: false,
aiPreferencesEnabled: false,
instanceContextEnabled: false,

View File

@ -1,10 +1,9 @@
import type {
InstanceAiAgentAttachment,
InstanceAiNodesAttachment,
InstanceAiResourceAttachment,
InstanceAiWorkflowAttachment,
} from '@n8n/api-types';
import type { User } from '@n8n/db';
import type { Mock } from 'vitest';
import { InstanceAiService } from '../instance-ai.service';
@ -21,56 +20,51 @@ function nodesAttachment(
describe('InstanceAiService — resolveContextAttachments gating', () => {
type GatedService = {
canvasNodeContextFlagGate: { isEnabled: Mock };
resolveContextAttachments: (
attachments: InstanceAiResourceAttachment[] | undefined,
user: User,
) => Promise<InstanceAiResourceAttachment[]>;
nodeContextEnabled: boolean,
) => InstanceAiResourceAttachment[];
};
function createService(isEnabled: Mock): GatedService {
const service = Object.create(InstanceAiService.prototype) as GatedService;
service.canvasNodeContextFlagGate = { isEnabled };
return service;
function createService(): GatedService {
return Object.create(InstanceAiService.prototype) as GatedService;
}
const user = { id: 'user-1' } as User;
it('includes the nodes attachment when the flag is on', () => {
const service = createService();
it('includes the nodes attachment when the flag is on', async () => {
const service = createService(vi.fn().mockResolvedValue(true));
const result = await service.resolveContextAttachments([nodesAttachment()], user);
const result = service.resolveContextAttachments([nodesAttachment()], true);
expect(result).toEqual([nodesAttachment()]);
});
it('drops the nodes attachment when the flag is off, without throwing', async () => {
const service = createService(vi.fn().mockResolvedValue(false));
it('drops the nodes attachment when the flag is off, without throwing', () => {
const service = createService();
const result = await service.resolveContextAttachments([nodesAttachment()], user);
const result = service.resolveContextAttachments([nodesAttachment()], false);
expect(result).toEqual([]);
});
it('never asks the gate when there are no nodes attachments', async () => {
const isEnabled = vi.fn().mockResolvedValue(true);
const service = createService(isEnabled);
it('keeps workflow and agent attachments when node context is off', () => {
const service = createService();
const workflowAttachment: InstanceAiWorkflowAttachment = { type: 'workflow', id: 'wf-1' };
const agentAttachment: InstanceAiAgentAttachment = {
type: 'agent',
id: 'agent-1',
projectId: 'project-1',
};
const result = await service.resolveContextAttachments([workflowAttachment], user);
const result = service.resolveContextAttachments([workflowAttachment, agentAttachment], false);
expect(result).toEqual([workflowAttachment]);
expect(isEnabled).not.toHaveBeenCalled();
expect(result).toEqual([workflowAttachment, agentAttachment]);
});
it('keeps a workflow attachment alongside an enabled nodes attachment', async () => {
const service = createService(vi.fn().mockResolvedValue(true));
it('keeps a workflow attachment alongside an enabled nodes attachment', () => {
const service = createService();
const workflowAttachment: InstanceAiWorkflowAttachment = { type: 'workflow', id: 'wf-1' };
const result = await service.resolveContextAttachments(
[workflowAttachment, nodesAttachment()],
user,
);
const result = service.resolveContextAttachments([workflowAttachment, nodesAttachment()], true);
expect(result).toEqual([workflowAttachment, nodesAttachment()]);
});

View File

@ -734,7 +734,10 @@ function stubInitialRunSurface(
instanceContextEnabled = false,
): void {
Object.assign(service, {
resolveContextAttachments: vi.fn(async () => []),
adapterService: {
resolveExperimentGates: vi.fn(async () => ({ nodeContextEnabled: false })),
},
resolveContextAttachments: vi.fn(() => []),
createProxyRunConfig: vi.fn(async () => ({})),
browserSessionService: { getExtensionTraceContext: vi.fn() },
readThreadProvenance: vi.fn(async () => ({})),
@ -934,7 +937,9 @@ describe('InstanceAiService — runtime workspace setup', () => {
setupPanelVariant: snapshotMode === 'off' ? 'control' : 'variant',
configEvalsEnabled: true,
conversationHistoryEnabled: false,
progressiveBuildingEnabled: false,
nodeUsageEnabled: !instanceContextEnabled,
nodeContextEnabled: false,
folderExplorationEnabled: false,
aiPreferencesEnabled: false,
instanceContextEnabled,
@ -1286,6 +1291,7 @@ describe('InstanceAiService — runtime workspace setup', () => {
conversationHistoryEnabled: false,
progressiveBuildingEnabled: enabled,
nodeUsageEnabled: false,
nodeContextEnabled: false,
folderExplorationEnabled: true,
aiPreferencesEnabled: false,
}),
@ -5055,7 +5061,10 @@ describe('InstanceAiService run input gates', () => {
orchestrationContext: {},
};
const service = Object.assign(Object.create(InstanceAiService.prototype), {
resolveContextAttachments: vi.fn(async () => []),
adapterService: {
resolveExperimentGates: vi.fn(async () => ({ nodeContextEnabled: false })),
},
resolveContextAttachments: vi.fn(() => []),
instanceAiErrorReporter: { beginRun: vi.fn(), endRun: vi.fn() },
createProxyRunConfig: vi.fn(async () => ({})),
browserSessionService: { getExtensionTraceContext: vi.fn() },
@ -5076,6 +5085,7 @@ describe('InstanceAiService run input gates', () => {
buildOrchestratorAgentStreamOptions: vi.fn(() => ({})),
shouldPreserveHitlOnShutdown: vi.fn(() => true),
runState: { clearActiveRun: vi.fn(), hasSuspendedRun: vi.fn(() => true) },
telemetry: { track: vi.fn() },
domainAccessTrackersByThread: new Map(),
updateInternalFollowUpFailureStreak: vi.fn(),
}) as {
@ -5137,6 +5147,7 @@ describe('InstanceAiService — user message persistence on cancel', () => {
schedulePlannedTasks: Mock;
taskProjector: { syncFromWorkflowLoop: Mock };
browserSessionService: { getExtensionTraceContext: Mock };
adapterService: { resolveExperimentGates: Mock };
};
function createCancelPersistenceService(): ExecuteRunInternals {
@ -5158,6 +5169,9 @@ describe('InstanceAiService — user message persistence on cancel', () => {
service.browserSessionService = {
getExtensionTraceContext: vi.fn(() => ({ connectionState: 'disconnected' })),
};
service.adapterService = {
resolveExperimentGates: vi.fn(async () => ({ nodeContextEnabled: false })),
};
return service;
}

View File

@ -1,27 +0,0 @@
import { CANVAS_NODE_CONTEXT_FLAG } from '@n8n/api-types';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { PostHogClient } from '@/posthog';
/**
* Per-user rollout gate for the canvas-node chat-context feature.
*
* PostHog owns cohort rollout; `N8N_INSTANCE_AI_NODE_CONTEXT_ENABLED` still
* force-enables because {@link PostHogClient} layers that override on top of
* the resolved flags. Fails closed on any PostHog error so an outage never
* turns the feature on by accident.
*/
@Service()
export class CanvasNodeContextFlagGate {
constructor(private readonly postHogClient: PostHogClient) {}
async isEnabled(user: User): Promise<boolean> {
try {
const flags = await this.postHogClient.getFeatureFlags(user);
return flags?.[CANVAS_NODE_CONTEXT_FLAG] === true;
} catch {
return false;
}
}
}

View File

@ -1,6 +1,8 @@
import { braveSearch, searxngSearch, type WebSearchResponse } from '@n8n/ai-utilities';
import {
AI_GATEWAY_MANAGED_TAG,
AI_ASSISTANT_AT_MENTIONS_FLAG,
CANVAS_NODE_CONTEXT_FLAG,
CREDENTIAL_DESCRIPTIONS_FLAG,
CONFIG_EVALUATIONS_FLAG,
CONFIG_EVALUATIONS_ENABLED_VARIANT,
@ -650,6 +652,8 @@ export class InstanceAiAdapterService {
setupPanelVariant?: 'control' | 'variant';
/** Node-usage context surface: the `node-usage` action and the `nodeTypes` filter on `list`. */
nodeUsageEnabled: boolean;
/** Node attachments from either the canvas or Assistant mentions rollout. */
nodeContextEnabled: boolean;
/** Per-user folder-exploration gate, passed into `createContext`. Fails
* closed with every other gate: `getFeatureFlags` never throws, it
* returns `{}` on a PostHog outage. */
@ -688,6 +692,8 @@ export class InstanceAiAdapterService {
? { setupPanelVariant }
: {}),
nodeUsageEnabled: flags[INSTANCE_AI_NODE_USAGE_FLAG] === true,
nodeContextEnabled:
flags[CANVAS_NODE_CONTEXT_FLAG] === true || flags[AI_ASSISTANT_AT_MENTIONS_FLAG] === true,
folderExplorationEnabled:
flags[INSTANCE_AI_FOLDER_EXPLORATION_FLAG] ===
INSTANCE_AI_FOLDER_EXPLORATION_ENABLED_VARIANT,

View File

@ -182,7 +182,6 @@ import {
} from './instance-context.service';
import { composeLocalMcpServers } from './browser/composite-local-mcp-server';
import { InstanceAiBrowserSessionService } from './browser/instance-ai-browser-session.service';
import { CanvasNodeContextFlagGate } from './canvas-node-context-flag-gate';
import { enabledToolCategories, resolveComputerUseState } from './computer-use-availability';
import { dropRejectedAttachmentsFromHistory } from './drop-rejected-attachments';
import { EvalThreadCredentialAllowlistService } from './eval/thread-credential-allowlist.service';
@ -807,7 +806,6 @@ export class InstanceAiService {
private readonly creditService: InstanceAiCreditService,
private readonly publisher: Publisher,
private readonly instanceAiErrorReporter: InstanceAiErrorReporterService,
private readonly canvasNodeContextFlagGate: CanvasNodeContextFlagGate,
private readonly push: Push,
private readonly conversationHistoryService: InstanceAiConversationHistoryService,
private readonly instanceContext: InstanceContextService,
@ -2468,6 +2466,7 @@ export class InstanceAiService {
pushRef?: string,
proxyRunConfig?: Awaited<ReturnType<InstanceAiService['createProxyRunConfig']>>,
instanceContextGates?: InstanceContextGates,
experimentGates?: Awaited<ReturnType<InstanceAiAdapterService['resolveExperimentGates']>>,
) {
const memory = this.agentMemory;
const boundProjectId = await memory.getThreadProjectId(threadId);
@ -2498,7 +2497,7 @@ export class InstanceAiService {
? await this.modelService.resolveProxyModel(user, proxyBaseUrl, tokenManager, proxyContext)
: await this.modelService.resolveAgentModelConfig(user, proxyContext);
const gates = await this.adapterService.resolveExperimentGates(user);
const gates = experimentGates ?? (await this.adapterService.resolveExperimentGates(user));
const {
configEvalsEnabled,
conversationHistoryEnabled,
@ -3658,14 +3657,13 @@ export class InstanceAiService {
/**
* Splits a message's attachments into the resource references that feed the
* context block, gating canvas node-selection attachments behind
* CANVAS_NODE_CONTEXT_FLAG per user. Workflow and agent references always pass
* through — only `nodes` attachments are conditional.
* context block. The canvas node-context and Assistant mentions flags both
* accept `nodes` attachments. Workflow and agent references always pass.
*/
private async resolveContextAttachments(
private resolveContextAttachments(
attachments: InstanceAiAttachment[] | undefined,
user: User,
): Promise<InstanceAiResourceAttachment[]> {
nodeContextEnabled: boolean,
): InstanceAiResourceAttachment[] {
const attachmentsOrEmpty = attachments ?? [];
const workflowAttachments = attachmentsOrEmpty.filter(
@ -3680,13 +3678,10 @@ export class InstanceAiService {
(attachment): attachment is InstanceAiNodesAttachment => attachment.type === 'nodes',
);
const canvasNodeContextEnabled =
nodeAttachments.length > 0 && (await this.canvasNodeContextFlagGate.isEnabled(user));
return [
...workflowAttachments,
...agentAttachments,
...(canvasNodeContextEnabled ? nodeAttachments : []),
...(nodeContextEnabled ? nodeAttachments : []),
];
}
@ -3738,7 +3733,11 @@ export class InstanceAiService {
(attachment): attachment is InstanceAiFileAttachment => attachment.type === 'file',
);
const contextAttachments = await this.resolveContextAttachments(attachments, user);
const experimentGates = await this.adapterService.resolveExperimentGates(user);
const contextAttachments = this.resolveContextAttachments(
attachments,
experimentGates.nodeContextEnabled,
);
const signal = abortController.signal;
let tracing: InstanceAiTraceContext | undefined;
@ -3901,6 +3900,8 @@ export class InstanceAiService {
messageGroupId,
executionPushRef,
proxyRunConfig,
undefined,
experimentGates,
);
const {
context,

View File

@ -16,4 +16,6 @@ export interface ActionDropdownProps<T extends string = string> {
maxHeight?: string | number;
width?: string;
modal?: boolean;
/** Prevents the menu from returning focus to its trigger when it closes. */
suppressCloseAutoFocus?: boolean;
}

View File

@ -25,6 +25,7 @@ const props = withDefaults(defineProps<ActionDropdownProps<T>>(), {
disabled: false,
maxHeight: '',
modal: true,
suppressCloseAutoFocus: false,
});
const $style = useCssModule();
@ -110,6 +111,7 @@ const getItemClasses = (item: ActionDropdownItem<T>): Record<string, boolean> =>
:disabled="disabled"
:teleported="teleported"
:modal="modal"
:suppress-close-auto-focus="suppressCloseAutoFocus"
:extra-popper-class="`${extraPopperClass ?? ''}`"
:max-height="maxHeight"
:width="width"

View File

@ -1,13 +1,15 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`components > N8nActionDropdown > should render custom styling correctly 1`] = `
"<div class="action-dropdown-container actionDropdownContainer"><span id="reka-dropdown-menu-trigger-v-0" type="button" aria-haspopup="menu" aria-expanded="false" disabled="false" data-state="closed" class="trigger"><n8n-icon-button-stub variant="ghost" icon="ellipsis" size="medium" loading="false" icononly="true" disabled="false" class="activator" aria-label="Actions" aria-controls="n8n-action-dropdown-21"></n8n-icon-button-stub></span>
"<div class="action-dropdown-container actionDropdownContainer">
<n8n-icon-button-stub variant="ghost" icon="ellipsis" size="medium" loading="false" icononly="true" disabled="false" class="trigger activator" id="reka-dropdown-menu-trigger-v-1" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="n8n-action-dropdown-21" data-state="closed" aria-label="Actions"></n8n-icon-button-stub>
<!--v-if-->
</div>"
`;
exports[`components > N8nActionDropdown > should render default styling correctly 1`] = `
"<div class="action-dropdown-container actionDropdownContainer"><span id="reka-dropdown-menu-trigger-v-0" type="button" aria-haspopup="menu" aria-expanded="false" disabled="false" data-state="closed" class="trigger"><n8n-icon-button-stub variant="ghost" icon="ellipsis" size="medium" loading="false" icononly="true" disabled="false" class="activator" aria-label="Actions" aria-controls="n8n-action-dropdown-1"></n8n-icon-button-stub></span>
"<div class="action-dropdown-container actionDropdownContainer">
<n8n-icon-button-stub variant="ghost" icon="ellipsis" size="medium" loading="false" icononly="true" disabled="false" class="trigger activator" id="reka-dropdown-menu-trigger-v-1" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="n8n-action-dropdown-1" data-state="closed" aria-label="Actions"></n8n-icon-button-stub>
<!--v-if-->
</div>"
`;

View File

@ -1,6 +1,7 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from '../../composables/useI18n';
import N8nActionToggle from '../N8nActionToggle';
import type { DropdownMenuItemProps } from '../N8nDropdownMenu/DropdownMenu.types';
import N8nLink from '../N8nLink';
@ -50,6 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
hiddenItemsTrigger: 'click',
dragActive: false,
});
const { t } = useI18n();
const loadedHiddenItems = ref<PathItem[]>([]);
const isLoadingHiddenItems = ref(false);
@ -208,7 +210,15 @@ const handleTooltipClose = () => {
@action="emitItemSelected"
@item-mouseup="onHiddenItemMouseUp"
>
<N8nText :bold="true" :class="$style.dots">...</N8nText>
<N8nText
tag="button"
type="button"
:aria-label="t('breadcrumbs.showMoreItems')"
:bold="true"
:class="$style.dots"
>
...
</N8nText>
</N8nActionToggle>
</div>
<!-- Just a tooltip for smaller versions -->
@ -395,7 +405,12 @@ const handleTooltipClose = () => {
}
.dots {
appearance: none;
padding: 0 var(--spacing--4xs);
border: none;
background: none;
font: inherit;
cursor: pointer;
color: var(--color--text--tint-1);
border-radius: var(--radius);

View File

@ -558,7 +558,7 @@ const ExternalDropdownTemplate: StoryFn = (args) => ({
@select="handleSelect"
>
<template #trigger>
<N8nTooltip content="Open context menu" placement="top">
<N8nTooltip as-child content="Open context menu" placement="top">
<N8nIconButton
icon="at-sign"
title="Open context menu"

View File

@ -77,7 +77,7 @@ describe('N8nDropdownMenu', () => {
expect(trigger).toMatchSnapshot();
});
it('should render custom trigger via slot', () => {
it('should render and open from a custom trigger via slot', async () => {
const wrapper = render(DropdownMenu, {
props: {
items: createItems(3),
@ -87,7 +87,11 @@ describe('N8nDropdownMenu', () => {
},
});
expect(wrapper.getByTestId('custom-trigger')).toBeInTheDocument();
const trigger = wrapper.getByTestId('custom-trigger');
expect(trigger).toHaveAttribute('aria-haspopup', 'menu');
await userEvent.click(trigger);
expect((await getDropdownContent()).dropdown).toBeVisible();
});
it('should render with emoji activator', () => {
@ -892,11 +896,50 @@ describe('N8nDropdownMenu', () => {
});
describe('external search mode', () => {
it('should manage and restore the external target ARIA attributes', async () => {
const wrapper = renderExternalDropdown();
const textarea = wrapper.container.querySelector('textarea')!;
const { dropdown } = await getDropdownContent();
await waitFor(() => {
expect(textarea).toHaveAttribute('role', 'combobox');
expect(textarea).toHaveAttribute('aria-expanded', 'true');
expect(textarea).toHaveAttribute('aria-haspopup', 'menu');
expect(textarea).toHaveAttribute('aria-controls', dropdown.id);
});
wrapper.isOpen.value = false;
await waitFor(() => {
expect(textarea).not.toHaveAttribute('role');
expect(textarea).not.toHaveAttribute('aria-expanded');
expect(textarea).not.toHaveAttribute('aria-haspopup');
expect(textarea).not.toHaveAttribute('aria-controls');
});
});
it('should focus the real control inside a custom trigger wrapper', async () => {
const dropdownRef = ref<DropdownMenuExposed | null>(null);
const wrapper = render({
components: { DropdownMenu },
setup: () => ({ dropdownRef, items: createItems(1) }),
template: `
<DropdownMenu ref="dropdownRef" :items="items">
<template #trigger><button data-test-id="focus-target">Open</button></template>
</DropdownMenu>
`,
});
dropdownRef.value?.focusTrigger();
expect(document.activeElement).toBe(wrapper.getByTestId('focus-target'));
});
it('should keep focus in the external textarea without rendering an internal search input', async () => {
const wrapper = renderExternalDropdown();
const textarea = wrapper.getByRole('textbox');
const textarea = wrapper.container.querySelector('textarea')!;
await getDropdownContent();
await waitFor(() => expect(textarea).toHaveAttribute('role', 'combobox'));
await waitFor(() => expect(document.activeElement).toBe(textarea));
expect(document.querySelector('input[type="text"]')).not.toBeInTheDocument();
@ -910,13 +953,13 @@ describe('N8nDropdownMenu', () => {
it('should navigate and select from the external textarea', async () => {
const wrapper = renderExternalDropdown();
const textarea = wrapper.getByRole('textbox');
const textarea = wrapper.container.querySelector('textarea')!;
await waitFor(() => expect(document.activeElement).toBe(textarea));
await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowUp}');
const firstItem = document.querySelectorAll('[role="menuitem"]')[0];
expect(firstItem).toHaveAttribute('aria-selected', 'true');
expect(firstItem).toHaveAttribute('data-virtual-highlighted');
expect(textarea).toHaveAttribute('aria-activedescendant', firstItem.id);
expect(document.activeElement).toBe(textarea);
@ -991,7 +1034,7 @@ describe('N8nDropdownMenu', () => {
children: [{ id: 'child', label: 'Child' }],
},
]);
const textarea = wrapper.getByRole('textbox');
const textarea = wrapper.container.querySelector('textarea')!;
await waitFor(() => expect(document.activeElement).toBe(textarea));
await userEvent.keyboard('{ArrowDown}{ArrowRight}');
@ -999,14 +1042,13 @@ describe('N8nDropdownMenu', () => {
await userEvent.keyboard('{ArrowDown}');
const child = wrapper.getByText('Child').closest('[role="menuitem"]');
expect(child).toHaveAttribute('aria-selected', 'true');
expect(child).toHaveAttribute('data-virtual-highlighted');
expect(document.activeElement).toBe(textarea);
await userEvent.keyboard('{ArrowLeft}');
await waitFor(() => expect(document.querySelectorAll('[role="menu"]')).toHaveLength(1));
expect(wrapper.getByText('Parent').closest('[role="menuitem"]')).toHaveAttribute(
'aria-selected',
'true',
'data-virtual-highlighted',
);
await userEvent.keyboard('{Enter}');
@ -1015,7 +1057,7 @@ describe('N8nDropdownMenu', () => {
it('should close on Escape and restore textarea focus', async () => {
const wrapper = renderExternalDropdown();
const textarea = wrapper.getByRole('textbox');
const textarea = wrapper.container.querySelector('textarea')!;
await waitFor(() => expect(document.activeElement).toBe(textarea));
await userEvent.keyboard('{Escape}');
@ -1028,7 +1070,7 @@ describe('N8nDropdownMenu', () => {
it('should close on Tab without keeping focus in the textarea', async () => {
const wrapper = renderExternalDropdown();
const textarea = wrapper.getByRole('textbox');
const textarea = wrapper.container.querySelector('textarea')!;
await waitFor(() => expect(document.activeElement).toBe(textarea));
await userEvent.tab();
@ -1039,7 +1081,7 @@ describe('N8nDropdownMenu', () => {
it('should not restore textarea focus from a pending frame after Tab', async () => {
const wrapper = renderExternalDropdown();
const textarea = wrapper.getByRole('textbox');
const textarea = wrapper.container.querySelector('textarea')!;
await waitFor(() => expect(document.activeElement).toBe(textarea));
const pendingFrames: FrameRequestCallback[] = [];
@ -1071,7 +1113,7 @@ describe('N8nDropdownMenu', () => {
it('should ignore keys during IME composition', async () => {
const wrapper = renderExternalDropdown();
const textarea = wrapper.getByRole('textbox');
const textarea = wrapper.container.querySelector('textarea')!;
await waitFor(() => expect(document.activeElement).toBe(textarea));
textarea.dispatchEvent(

View File

@ -76,6 +76,8 @@ export interface DropdownMenuExposed {
highlightFirstItem: () => void;
/** Handles menu navigation from an external text control. */
handleExternalKeydown: (event: KeyboardEvent) => boolean;
/** Moves focus to the rendered trigger element. */
focusTrigger: () => void;
}
export type DropdownMenuItemProps<T = string, D = never> = {
@ -95,6 +97,8 @@ export type DropdownMenuItemProps<T = string, D = never> = {
checked?: boolean;
/** Keep the menu open after this item is selected (e.g. toggle rows). */
keepOpen?: boolean;
/** Prevent the menu from returning focus to its trigger after this item closes it. */
suppressCloseAutoFocus?: boolean;
/** Render as a non-interactive section header label instead of a selectable item. */
header?: boolean;
/** Whether to expose the item as a menu item checkbox */
@ -134,6 +138,8 @@ export interface DropdownMenuProps<T = string, D = never> {
reference?: DropdownMenuContentProps['reference'];
/** When true (default), blocks interaction with the rest of the page while open (reka-ui sets pointer-events:none on body and locks scroll). */
modal?: boolean;
/** Prevents the menu from returning focus to its trigger when it closes. */
suppressCloseAutoFocus?: boolean;
/** Array of menu items to display */
items: Array<DropdownMenuItemProps<T, D>>;
/** The controlled open state of the dropdown. Can be bind as `v-model` */
@ -194,7 +200,7 @@ export interface DropdownMenuEmits<T = string, D = never> {
type SlotUiProps = { class: string };
export interface DropdownMenuSlots<T = string, D = never> {
/** Custom trigger element (replaces default button) */
/** Replaces the default button with one focusable root that forwards attributes and listeners. */
trigger?: () => void;
/** Complete custom dropdown content (replaces item list) */
content?: () => void;

View File

@ -12,6 +12,7 @@ import {
isInputCursorAtEnd,
isInputCursorAtStart,
isNavigableItem,
scrollHighlightedItemIntoView,
} from './DropdownMenu.utils';
import DropdownMenuSearchableContent from './DropdownMenuSearchableContent.vue';
@ -104,6 +105,28 @@ describe('DropdownMenu utils', () => {
expect(getItemDomId('abc', 2)).toBe('dropdown-menu-searchable-abc-item-2');
});
it('scrolls the virtually highlighted item into view', () => {
const container = document.createElement('div');
const items = document.createElement('div');
const highlightedItem = document.createElement('button');
items.dataset.menuItems = '';
highlightedItem.dataset.virtualHighlighted = '';
items.appendChild(highlightedItem);
container.appendChild(items);
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
top: 0,
bottom: 100,
} as DOMRect);
vi.spyOn(highlightedItem, 'getBoundingClientRect').mockReturnValue({
top: 100,
bottom: 120,
} as DOMRect);
scrollHighlightedItemIntoView(container);
expect(container.scrollTop).toBe(20);
});
describe('input cursor helpers', () => {
it('should detect whether the input cursor is at the start or end', () => {
const input = document.createElement('input');
@ -162,7 +185,7 @@ const renderSearchableContent = (
'button',
{
id: getItemDomId(index),
'aria-selected': highlightedIndex === index ? 'true' : undefined,
'data-virtual-highlighted': highlightedIndex === index ? '' : undefined,
onPointermove: (event: PointerEvent) => {
(event.currentTarget as HTMLButtonElement).focus();
onItemHover(index);
@ -209,7 +232,7 @@ describe('DropdownMenuSearchableContent keyboard navigation', () => {
await userEvent.keyboard('{ArrowDown}');
await waitFor(() => {
expect(wrapper.getByText('Item 1')).toHaveAttribute('aria-selected', 'true');
expect(wrapper.getByText('Item 1')).toHaveAttribute('data-virtual-highlighted');
});
});
@ -220,12 +243,12 @@ describe('DropdownMenuSearchableContent keyboard navigation', () => {
await userEvent.keyboard('{ArrowDown}');
await waitFor(() => {
expect(wrapper.getByText('Item 0')).toHaveAttribute('aria-selected', 'true');
expect(wrapper.getByText('Item 0')).toHaveAttribute('data-virtual-highlighted');
});
await userEvent.keyboard('{ArrowUp}');
expect(wrapper.getByText('Item 0')).toHaveAttribute('aria-selected', 'true');
expect(wrapper.getByText('Item 0')).toHaveAttribute('data-virtual-highlighted');
});
it('should select the highlighted item and close when pressing Enter', async () => {
@ -269,7 +292,7 @@ describe('DropdownMenuSearchableContent keyboard navigation', () => {
await userEvent.click(input);
await fireEvent.pointerMove(wrapper.getByText('Parent'));
expect(wrapper.getByText('Parent')).toHaveAttribute('aria-selected', 'true');
expect(wrapper.getByText('Parent')).toHaveAttribute('data-virtual-highlighted');
await waitFor(() => expect(document.activeElement).toBe(input));
});
@ -287,7 +310,7 @@ describe('DropdownMenuSearchableContent keyboard navigation', () => {
'data-open-submenu-index',
'0',
);
expect(wrapper.getByText('Item 1')).toHaveAttribute('aria-selected', 'true');
expect(wrapper.getByText('Item 1')).toHaveAttribute('data-virtual-highlighted');
});
it('should close a pointer-opened submenu when navigating with ArrowDown', async () => {
@ -314,7 +337,7 @@ describe('DropdownMenuSearchableContent keyboard navigation', () => {
'data-open-submenu-index',
'-1',
);
expect(wrapper.getByText('Parent')).toHaveAttribute('aria-selected', 'true');
expect(wrapper.getByText('Parent')).toHaveAttribute('data-virtual-highlighted');
});
it('should close when pressing Escape or Tab', async () => {
@ -348,8 +371,7 @@ describe('DropdownMenuSearchableContent keyboard navigation', () => {
expect(activeDescendant).toBeTruthy();
expect(document.getElementById(activeDescendant ?? '')).toHaveTextContent('Item 0');
expect(document.getElementById(activeDescendant ?? '')).toHaveAttribute(
'aria-selected',
'true',
'data-virtual-highlighted',
);
});
});
@ -455,13 +477,13 @@ describe('DropdownMenuSearchableContent keyboard navigation', () => {
await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}');
await waitFor(() => {
expect(wrapper.getByText('Item 2')).toHaveAttribute('aria-selected', 'true');
expect(wrapper.getByText('Item 2')).toHaveAttribute('data-virtual-highlighted');
});
await wrapper.rerender({ open: true, items: createItems(4), searchDebounce: 0 });
await userEvent.keyboard('{ArrowDown}');
expect(wrapper.getByText('Item 3')).toHaveAttribute('aria-selected', 'true');
expect(wrapper.getByText('Item 3')).toHaveAttribute('data-virtual-highlighted');
});
it('should reset the highlighted item when items change and the highlighted item no longer exists', async () => {
@ -472,7 +494,7 @@ describe('DropdownMenuSearchableContent keyboard navigation', () => {
await userEvent.keyboard('{ArrowDown}');
await waitFor(() => {
expect(wrapper.getByText('Item 0')).toHaveAttribute('aria-selected', 'true');
expect(wrapper.getByText('Item 0')).toHaveAttribute('data-virtual-highlighted');
});
await wrapper.rerender({
@ -481,6 +503,6 @@ describe('DropdownMenuSearchableContent keyboard navigation', () => {
searchDebounce: 0,
});
expect(wrapper.getByText('Item 1')).not.toHaveAttribute('aria-selected');
expect(wrapper.getByText('Item 1')).not.toHaveAttribute('data-virtual-highlighted');
});
});

View File

@ -37,7 +37,7 @@ export function getItemDomId(instanceId: string, index: number) {
}
export function scrollHighlightedItemIntoView(container: HTMLElement | null) {
const highlightedItem = container?.querySelector('[data-menu-items] [aria-selected="true"]');
const highlightedItem = container?.querySelector('[data-menu-items] [data-virtual-highlighted]');
if (!(container && highlightedItem instanceof HTMLElement)) return;

View File

@ -7,7 +7,7 @@ import {
type FocusOutsideEvent,
type PointerDownOutsideEvent,
} from 'reka-ui';
import { computed, nextTick, onBeforeUnmount, provide, ref, useCssModule, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, provide, ref, useCssModule, useId, watch } from 'vue';
import { isAlign, isSide } from './DropdownMenu.typeguards';
import {
@ -32,6 +32,7 @@ const props = withDefaults(defineProps<DropdownMenuProps<T, D>>(), {
trigger: 'click',
activatorIcon: () => ({ type: 'icon', value: 'ellipsis' }),
modal: true,
suppressCloseAutoFocus: false,
disabled: false,
teleported: true,
loading: false,
@ -80,9 +81,13 @@ provide(
const internalOpen = ref(props.defaultOpen ?? false);
const contentRef = ref<InstanceType<typeof DropdownMenuContent> | null>(null);
const triggerRef = ref<InstanceType<typeof DropdownMenuTrigger> | null>(null);
const searchableContentRef = ref<{ highlightFirstItem: () => void } | null>(null);
const generatedContentId = `n8n-dropdown-menu-${useId()}`;
const contentId = computed(() => props.id ?? generatedContentId);
const externalNavigationControllers: DropdownMenuExternalNavigationController[] = [];
let hoverCloseTimer: ReturnType<typeof setTimeout> | undefined;
let suppressNextCloseAutoFocus = false;
const isExternalSearchMode = computed(() => props.searchable && props.searchMode === 'external');
const effectiveModal = computed(() => (isExternalSearchMode.value ? false : props.modal));
@ -126,6 +131,15 @@ const focusExternalTarget = (allowClosed = false) => {
props.externalFocusTarget.focus({ preventScroll: true });
};
const focusTrigger = () => {
const trigger = triggerRef.value?.$el as HTMLElement | undefined;
if (!trigger) return;
const focusTarget =
trigger.querySelector<HTMLElement>('button, [href], input, select, textarea, [tabindex]') ??
(trigger.matches('button, [href], input, select, textarea, [tabindex]') ? trigger : undefined);
focusTarget?.focus();
};
const syncExternalActiveDescendant = () => {
const target = props.externalFocusTarget;
if (!isExternalSearchMode.value || !internalOpen.value || !target) return;
@ -174,17 +188,16 @@ const handleContentOpenAutoFocus = (event: Event) => {
};
const handleContentCloseAutoFocus = (event: Event) => {
if (isExternalSearchMode.value) event.preventDefault();
if (isExternalSearchMode.value || props.suppressCloseAutoFocus || suppressNextCloseAutoFocus) {
event.preventDefault();
}
suppressNextCloseAutoFocus = false;
};
const externalContentEventHandlers = computed(() =>
isExternalSearchMode.value
? {
onOpenAutoFocus: handleContentOpenAutoFocus,
onCloseAutoFocus: handleContentCloseAutoFocus,
}
: {},
);
const contentFocusEventHandlers = computed(() => ({
...(isExternalSearchMode.value ? { onOpenAutoFocus: handleContentOpenAutoFocus } : {}),
onCloseAutoFocus: handleContentCloseAutoFocus,
}));
const handleContentInteractOutside = (event: FocusOutsideEvent | PointerDownOutsideEvent) => {
if (
@ -241,9 +254,13 @@ function findItemById(
}
const handleItemSelect = (value: T) => {
const item = findItemById(props.items, value);
emit('select', value);
// Toggle-style rows (e.g. credential selection) keep the menu open.
if (!findItemById(props.items, value)?.keepOpen) close();
if (!item?.keepOpen) {
suppressNextCloseAutoFocus = item?.suppressCloseAutoFocus ?? false;
close();
}
};
const handleItemSearch = (term: string, itemId: T) => {
@ -344,11 +361,17 @@ watch(
);
watch(
[internalOpen, isExternalSearchMode, () => props.externalFocusTarget],
[internalOpen, isExternalSearchMode, () => props.externalFocusTarget, contentId],
async ([isOpen, externalMode, target], _oldValues, onCleanup) => {
if (!isOpen || !externalMode || !target) return;
const attributes = ['aria-activedescendant', 'aria-controls', 'aria-expanded', 'aria-haspopup'];
const attributes = [
'aria-activedescendant',
'aria-controls',
'aria-expanded',
'aria-haspopup',
'role',
];
const previousAttributes = new Map(
attributes.map((attribute) => [attribute, target.getAttribute(attribute)]),
);
@ -360,13 +383,13 @@ watch(
}
});
await nextTick();
if (!internalOpen.value || props.externalFocusTarget !== target) return;
target.setAttribute('role', 'combobox');
target.setAttribute('aria-expanded', 'true');
target.setAttribute('aria-haspopup', 'menu');
const contentId = (contentRef.value?.$el as HTMLElement | undefined)?.id;
if (contentId) target.setAttribute('aria-controls', contentId);
target.setAttribute('aria-controls', contentId.value);
await nextTick();
if (!internalOpen.value || props.externalFocusTarget !== target) return;
focusExternalTarget();
syncExternalActiveDescendant();
},
@ -411,33 +434,28 @@ watch(internalOpen, (isOpen, _oldValue, onCleanup) => {
});
});
defineExpose({ open, close, highlightFirstItem, handleExternalKeydown });
defineExpose({ open, close, highlightFirstItem, handleExternalKeydown, focusTrigger });
</script>
<!-- TODO DS-580: Let consumers bind trigger props/listeners directly in the slot so their
element can be the actual trigger. For now this wrapper owns hover events and test ids. -->
<template>
<DropdownMenuRoot :modal="effectiveModal" :open="internalOpen" @update:open="handleOpenChange">
<DropdownMenuTrigger as-child :disabled="disabled">
<span
v-if="slots.trigger"
:class="$style.trigger"
:data-test-id="dataTestId"
@pointerenter="triggerHoverEnter"
@pointerleave="triggerHoverLeave"
>
<slot name="trigger" />
</span>
<DropdownMenuTrigger
ref="triggerRef"
as-child
:disabled="disabled"
:class="$style.trigger"
:data-test-id="dataTestId"
@pointerenter="triggerHoverEnter"
@pointerleave="triggerHoverLeave"
>
<slot v-if="slots.trigger" name="trigger" />
<N8nButton
v-else
:icon="activatorIcon?.type === 'icon' ? (activatorIcon.value as IconName) : undefined"
:data-test-id="dataTestId"
:disabled="disabled"
:icon-only="true"
variant="ghost"
size="xsmall"
@pointerenter="triggerHoverEnter"
@pointerleave="triggerHoverLeave"
>
<template v-if="activatorIcon?.type === 'emoji'" #icon>
{{ activatorIcon.value }}
@ -453,8 +471,8 @@ defineExpose({ open, close, highlightFirstItem, handleExternalKeydown });
ref="contentRef"
v-bind="{
...fixedContentProps,
...(id ? { id } : {}),
...externalContentEventHandlers,
id: contentId,
...contentFocusEventHandlers,
}"
data-menu-content
:data-test-id="contentTestId"

View File

@ -132,7 +132,7 @@ describe('N8nDropdownMenuItem', () => {
highlighted: true,
});
expect(await wrapper.findByRole('menuitem')).toHaveAttribute('aria-selected', 'true');
expect(await wrapper.findByRole('menuitem')).toHaveAttribute('data-virtual-highlighted');
});
it('should show title attribute when label is 20+ characters', async () => {
@ -515,5 +515,20 @@ describe('N8nDropdownMenuItem', () => {
expect(customTrailing).toBeInTheDocument();
});
});
it('should render custom item-trailing slot on a sub-menu parent', async () => {
renderMenuItem(
{ id: 'parent', label: 'Parent', children: [{ id: 'child', label: 'Child' }] },
{
slots: {
'item-trailing': '<span data-test-id="submenu-trailing">2</span>',
},
},
);
await waitFor(() => {
expect(document.querySelector('[data-test-id="submenu-trailing"]')).toHaveTextContent('2');
});
});
});
});

View File

@ -44,7 +44,7 @@ const props = withDefaults(
searchMode: 'internal',
},
);
defineSlots<DropdownMenuItemSlots<T, D>>();
const slots = defineSlots<DropdownMenuItemSlots<T, D>>();
const emit = defineEmits<{
select: [value: T];
@ -260,7 +260,7 @@ onBeforeUnmount(() => {
>
<DropdownMenuSubTrigger
:id="htmlId"
:aria-selected="highlighted || undefined"
:data-virtual-highlighted="highlighted ? '' : undefined"
:disabled="disabled"
:data-test-id="testId"
:class="[
@ -318,6 +318,21 @@ onBeforeUnmount(() => {
data-sub-menu-action="open"
@click.stop="handleSubMenuIndicatorClick"
>
<slot
v-if="slots['item-trailing']"
name="item-trailing"
:item="props"
:ui="trailingProps"
/>
<Icon
icon="chevron-right"
:class="$style['sub-indicator']"
:color="disabled ? 'text-xlight' : 'text-light'"
size="large"
/>
</span>
<span v-else-if="slots['item-trailing']" :class="$style['sub-indicator-action']">
<slot name="item-trailing" :item="props" :ui="trailingProps" />
<Icon
icon="chevron-right"
:class="$style['sub-indicator']"
@ -453,7 +468,7 @@ onBeforeUnmount(() => {
v-else-if="checkbox"
:id="htmlId"
:model-value="checked"
:aria-selected="highlighted || undefined"
:data-virtual-highlighted="highlighted ? '' : undefined"
:disabled="disabled"
:data-test-id="testId"
:class="[
@ -493,7 +508,7 @@ onBeforeUnmount(() => {
<DropdownMenuItem
v-else
:id="htmlId"
:aria-selected="highlighted || undefined"
:data-virtual-highlighted="highlighted ? '' : undefined"
:disabled="disabled"
:data-test-id="testId"
:class="[
@ -575,7 +590,7 @@ onBeforeUnmount(() => {
&:not([data-disabled]) {
&:hover,
&[data-highlighted],
&[aria-selected='true'] {
&[data-virtual-highlighted] {
background-color: var(--background--hover);
cursor: pointer;
}
@ -589,7 +604,7 @@ onBeforeUnmount(() => {
&.destructive.destructive:not([data-disabled]) {
&:hover,
&[data-highlighted],
&[aria-selected='true'] {
&[data-virtual-highlighted] {
.item-label.item-label {
color: var(--text-color--danger);
}
@ -602,7 +617,7 @@ onBeforeUnmount(() => {
}
}
:global([data-menu-items]:has([aria-selected='true'])) &:not([aria-selected='true']) {
:global([data-menu-items]:has([data-virtual-highlighted])) &:not([data-virtual-highlighted]) {
&:hover,
&[data-highlighted] {
background-color: transparent;
@ -612,7 +627,7 @@ onBeforeUnmount(() => {
.sub-trigger {
&:not([data-disabled]) {
&[aria-selected='true'],
&[data-virtual-highlighted],
&[data-state='open'] {
background-color: var(--background--hover);
cursor: pointer;
@ -634,10 +649,13 @@ onBeforeUnmount(() => {
.sub-indicator-action {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
flex-shrink: 0;
margin-left: auto;
}
.sub-indicator-action .item-trailing,
.sub-indicator-action .sub-indicator {
margin-left: 0;
}

View File

@ -6,12 +6,12 @@ exports[`N8nDropdownMenu > disabled state > should render trigger with disabled
aria-expanded="false"
aria-haspopup="menu"
aria-live="polite"
class="button button ghost xsmall iconOnly disabled is-disabled"
class="button button ghost xsmall iconOnly disabled is-disabled trigger"
data-disabled=""
data-icon-only="true"
data-state="closed"
disabled=""
id="reka-dropdown-menu-trigger-v-0"
id="reka-dropdown-menu-trigger-v-1"
type="button"
>
<transition-stub
@ -72,10 +72,10 @@ exports[`N8nDropdownMenu > rendering > should render default trigger button 1`]
aria-expanded="false"
aria-haspopup="menu"
aria-live="polite"
class="button button ghost xsmall iconOnly"
class="button button ghost xsmall iconOnly trigger"
data-icon-only="true"
data-state="closed"
id="reka-dropdown-menu-trigger-v-0"
id="reka-dropdown-menu-trigger-v-1"
type="button"
>
<transition-stub
@ -136,10 +136,10 @@ exports[`N8nDropdownMenu > rendering > should render with emoji activator 1`] =
aria-expanded="false"
aria-haspopup="menu"
aria-live="polite"
class="button button ghost xsmall iconOnly"
class="button button ghost xsmall iconOnly trigger"
data-icon-only="true"
data-state="closed"
id="reka-dropdown-menu-trigger-v-0"
id="reka-dropdown-menu-trigger-v-1"
type="button"
>
<transition-stub

View File

@ -39,7 +39,7 @@ const emit = defineEmits<{
</script>
<template>
<N8nTooltip :disabled="!tooltip" :show-after="TOOLTIP_DELAY_MS">
<N8nTooltip as-child :disabled="!tooltip" :show-after="TOOLTIP_DELAY_MS">
<template #content>{{ tooltip || label }}</template>
<N8nIconButton
variant="ghost"

View File

@ -208,6 +208,7 @@ export default {
'iconPicker.emojiSection.symbols': 'Symbols',
'iconPicker.emojiSection.flags': 'Flags',
'actionDropdown.activator': 'Actions',
'breadcrumbs.showMoreItems': 'Show more items',
'askAssistantChat.close': 'Close',
'sendStopButton.stop': 'Stop',
'sendStopButton.send': 'Send',

View File

@ -7888,7 +7888,11 @@
"instanceAi.mentions.buttonLabel": "Mention a workflow, node, or group",
"instanceAi.mentions.artifactsSection": "Artifacts",
"instanceAi.mentions.workflowsSection": "Workflows",
"instanceAi.mentions.searchPlaceholder": "Search workflows, nodes, and groups",
"instanceAi.mentions.loadingWorkflowContents": "Loading workflow contents…",
"instanceAi.mentions.noRecentWorkflows": "No recent workflows",
"instanceAi.mentions.noResults": "No results",
"instanceAi.mentions.loadError": "Workflows couldn't load. Try again.",
"instanceAi.mentions.attachmentLimitTitle": "Attachment limit reached",
"instanceAi.mentions.attachmentLimitMessage": "Context can't be added. Remove an attachment and try again.",
"instanceAi.mentions.removeWorkflow": "Remove workflow context",

View File

@ -321,6 +321,7 @@ const workflowMenuItems = computed<WorkflowMenuItem[]>(() => {
id: WORKFLOW_MENU_ACTIONS.PRODUCTION_CHECKLIST,
label: locale.baseText('menuActions.productionChecklist'),
icon: { type: 'icon', value: 'list-checks' },
suppressCloseAutoFocus: true,
});
}
@ -431,10 +432,8 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS | string): Pro
break;
}
case WORKFLOW_MENU_ACTIONS.PRODUCTION_CHECKLIST: {
// Defer until the dropdown has closed and restored focus to its trigger;
// opening in the same tick lets that focus restore land "outside" the
// popover, which would immediately dismiss it.
setTimeout(() => productionChecklistRef.value?.open(), 0);
// Open after the dropdown unmounts so the floating layers do not overlap.
requestAnimationFrame(() => productionChecklistRef.value?.open());
break;
}
case WORKFLOW_MENU_ACTIONS.VERSION_HISTORY: {

View File

@ -1,4 +1,5 @@
import {
AI_ASSISTANT_AT_MENTIONS_FLAG,
CREDENTIAL_DESCRIPTIONS_FLAG,
INSTANCE_AI_PROGRESSIVE_BUILDING_FLAG,
INSTANCE_AI_SETUP_PANEL_FLAG,
@ -149,6 +150,7 @@ export const INSTANCE_AI_PROGRESSIVE_BUILDING_EXPERIMENT = createExperiment(
);
export const INSTANCE_AI_SETUP_PANEL_EXPERIMENT = createExperiment(INSTANCE_AI_SETUP_PANEL_FLAG);
export const AI_ASSISTANT_AT_MENTIONS_EXPERIMENT = createExperiment(AI_ASSISTANT_AT_MENTIONS_FLAG);
/**
* Multivariate: the enabled arm is the variant string `variant`, not a boolean,
@ -161,6 +163,7 @@ export const CREDENTIAL_DESCRIPTIONS_EXPERIMENT = createExperiment(CREDENTIAL_DE
export const EXPERIMENTS_TO_TRACK = [
INSTANCE_AI_SETUP_PANEL_EXPERIMENT.name,
AI_ASSISTANT_AT_MENTIONS_EXPERIMENT.name,
INSTANCE_AI_PROGRESSIVE_BUILDING_EXPERIMENT.name,
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
TEMPLATE_ONBOARDING_EXPERIMENT.name,

View File

@ -747,14 +747,12 @@ describe('AgentCapabilitiesSection', () => {
},
]);
// Reka's DropdownMenuTrigger — not the read-only chip inside it — is what
// actually gates opening the menu, so assert its own disabled state.
const trigger = wrapper.find('[aria-haspopup="menu"]');
expect(trigger.attributes('disabled')).toBe('false');
expect(trigger.element).toBeEnabled();
await wrapper.setProps({ disabled: true });
expect(wrapper.find('[aria-haspopup="menu"]').attributes('disabled')).toBe('true');
expect(wrapper.find('[aria-haspopup="menu"]').element).toBeDisabled();
});
describe('validation issues', () => {

View File

@ -99,17 +99,21 @@ function handleSelect(key: string): void {
<template>
<N8nDropdownMenu :items="menuItems" placement="bottom-end" @select="handleSelect">
<template #trigger>
<N8nTooltip :content="i18n.baseText('agentSessions.timeline.events')" placement="top">
<span :class="$style.trigger">
<N8nButton
variant="outline"
icon="funnel"
icon-only
:aria-label="i18n.baseText('agentSessions.timeline.events')"
data-test-id="filter-trigger"
/>
<N8nTooltip
as-child
:content="i18n.baseText('agentSessions.timeline.events')"
placement="top"
>
<N8nButton
variant="outline"
icon="funnel"
icon-only
:aria-label="i18n.baseText('agentSessions.timeline.events')"
:class="$style.trigger"
data-test-id="filter-trigger"
>
<span v-if="props.selected.size > 0" :class="$style.activeIndicator" aria-hidden="true" />
</span>
</N8nButton>
</N8nTooltip>
</template>

View File

@ -1,18 +1,39 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest';
import { waitFor } from '@testing-library/vue';
import type { IWorkflowDb } from '@/Interface';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import AssistantAtMentionPicker from './AssistantAtMentionPicker.vue';
import type { AssistantMentionSelection } from './assistantAtMentions.types';
const getWorkflow = vi.hoisted(() => vi.fn());
vi.mock('@/app/api/workflows', async (importOriginal) => ({
...(await importOriginal<typeof import('@/app/api/workflows')>()),
getWorkflow,
}));
const renderComponent = createComponentRenderer(AssistantAtMentionPicker);
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolver) => {
resolve = resolver;
});
return { promise, resolve };
}
describe('AssistantAtMentionPicker', () => {
beforeEach(() => {
getWorkflow.mockReset();
setActivePinia(createTestingPinia({ stubActions: false }));
vi.mocked(useNodeTypesStore().loadNodeTypesIfNotLoaded).mockResolvedValue();
});
it('renders artifact browse roots and emits a workflow selection', async () => {
@ -29,8 +50,9 @@ describe('AssistantAtMentionPicker', () => {
reference,
},
});
await userEvent.click(await findByText('Orders'));
const workflowItem = await findByText('Orders');
expect(document.querySelector('[data-icon="workflow"]')).toBeVisible();
await userEvent.click(workflowItem);
const selection = (emitted().select as unknown[][] | undefined)?.[0]?.[0] as
| AssistantMentionSelection
@ -38,9 +60,43 @@ describe('AssistantAtMentionPicker', () => {
expect(selection).toMatchObject({
item: { kind: 'workflow', workflowId: 'w1', label: 'Orders' },
attachment: { type: 'workflow', id: 'w1', name: 'Orders' },
telemetry: { mode: 'browse', resultPosition: 1, queryLength: 0 },
});
});
it('reports privacy-safe search interaction metadata', async () => {
const input = document.createElement('textarea');
const reference = document.createElement('div');
document.body.append(input, reference);
const pinia = createTestingPinia({ stubActions: true });
setActivePinia(pinia);
const { useWorkflowsListStore } = await import('@/app/stores/workflowsList.store');
vi.mocked(useWorkflowsListStore().searchWorkflows).mockResolvedValue([
{ id: 'w1', name: 'Orders' },
] as never);
const { findByText, emitted } = renderComponent({
props: {
modelValue: true,
query: 'ord',
projectId: 'project-1',
inputElement: input,
reference,
},
});
await userEvent.click(await findByText('Orders'));
const selection = (emitted().select as unknown[][] | undefined)?.[0]?.[0] as
| AssistantMentionSelection
| undefined;
expect(selection?.telemetry).toEqual({
mode: 'search',
resultPosition: 1,
queryLength: 3,
});
expect(selection?.item).not.toHaveProperty('query');
});
it('emits open state from the mention button', async () => {
const { getByTestId, emitted } = renderComponent({
props: { modelValue: false, query: '' },
@ -49,4 +105,194 @@ describe('AssistantAtMentionPicker', () => {
await userEvent.click(getByTestId('instance-ai-mention-button'));
expect(emitted()['update:modelValue']?.[0]).toEqual([true]);
});
it('disables the real mention button when the picker is disabled', () => {
const { getByTestId } = renderComponent({
props: { modelValue: false, query: '', disabled: true },
});
expect(getByTestId('instance-ai-mention-button')).toBeDisabled();
});
it('shows the empty recent-workflow state after browse completes', async () => {
const { findByText } = renderComponent({
props: { modelValue: true, query: '', projectId: undefined },
});
expect(await findByText('No recent workflows')).toBeVisible();
});
it('renders ten skeleton rows while workflows load', async () => {
setActivePinia(createTestingPinia());
const { useRecentWorkflowsStore } = await import('@/app/stores/recentWorkflows.store');
const { useWorkflowsListStore } = await import('@/app/stores/workflowsList.store');
const response = deferred<IWorkflowDb[]>();
vi.mocked(useRecentWorkflowsStore().resolveRecentWorkflows).mockReturnValue(response.promise);
vi.mocked(useWorkflowsListStore().searchWorkflows).mockResolvedValue([]);
renderComponent({
props: { modelValue: true, query: '', projectId: 'project-1' },
});
await waitFor(() => expect(document.querySelectorAll('.n8n-loading')).toHaveLength(10));
expect(document.body).not.toHaveTextContent('Loading workflows');
response.resolve([]);
});
it('renders group and resolved node icons in artifact search results', async () => {
const input = document.createElement('textarea');
const reference = document.createElement('div');
document.body.append(input, reference);
const nodeTypesStore = mockedStore(useNodeTypesStore);
const getNodeType = vi.fn().mockReturnValue({
displayName: 'If',
name: 'n8n-nodes-base.if',
icon: 'fa:map-signs',
} as never);
nodeTypesStore.getNodeType = getNodeType;
getWorkflow.mockResolvedValue({
id: 'w1',
name: 'Orders',
versionId: 'version-1',
nodes: [
{
id: 'if-node',
name: 'If',
type: 'n8n-nodes-base.if',
typeVersion: 2.2,
position: [0, 0],
parameters: {},
},
],
connections: {},
nodeGroups: [{ id: 'group-1', name: 'If checks', nodeIds: ['if-node'] }],
} as never);
const { findByRole } = renderComponent({
props: {
modelValue: true,
query: 'if',
artifacts: [{ id: 'w1', name: 'Orders' }],
inputElement: input,
reference,
},
});
const groupResult = await findByRole('menuitem', { name: 'Orders > If checks' });
const nodeResult = await findByRole('menuitem', { name: 'Orders > If checks > If' });
expect(groupResult.querySelector('[data-icon="layers"]')).toBeVisible();
expect(nodeResult.querySelector('.n8n-node-icon')).toBeVisible();
expect(
[...nodeResult.querySelectorAll('[class*="breadcrumbAncestor"]')].map((element) =>
element.textContent?.trim(),
),
).toEqual(['Orders', '>', 'If checks', '>']);
expect(getNodeType).toHaveBeenCalledWith('n8n-nodes-base.if', 2.2);
expect(nodeTypesStore.loadNodeTypesIfNotLoaded).toHaveBeenCalled();
});
it('shows a retry footer when one search provider fails with partial results', async () => {
getWorkflow.mockResolvedValue({
id: 'w1',
name: 'Orders',
versionId: 'version-1',
nodes: [],
connections: {},
} as never);
const { useWorkflowsListStore } = await import('@/app/stores/workflowsList.store');
vi.mocked(useWorkflowsListStore().searchWorkflows).mockRejectedValue(
new Error('Workflow search unavailable'),
);
const { findByText } = renderComponent({
props: {
modelValue: true,
query: 'ord',
projectId: 'project-1',
artifacts: [{ id: 'w1', name: 'Orders' }],
},
});
expect(await findByText('Orders')).toBeVisible();
expect(await findByText("Workflows couldn't load. Try again.")).toBeVisible();
expect(await findByText('Retry')).toBeVisible();
});
it('shows node counts next to expandable workflow and group chevrons', async () => {
const input = document.createElement('textarea');
const reference = document.createElement('div');
document.body.append(input, reference);
getWorkflow.mockResolvedValue({
id: 'w1',
name: 'Orders',
versionId: 'version-1',
nodes: [
{
id: 'node-1',
name: 'First',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'node-2',
name: 'Second',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'node-3',
name: 'Third',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
nodeGroups: [{ id: 'group-1', name: 'Review group', nodeIds: ['node-1', 'node-2'] }],
} as never);
const { findByText } = renderComponent({
props: {
modelValue: true,
query: '',
artifacts: [{ id: 'w1', name: 'Orders' }],
inputElement: input,
reference,
},
});
const workflowRow = (await findByText('Orders')).closest('[role="menuitem"]');
const workflowOpenAction = workflowRow?.querySelector('[data-sub-menu-action="open"]');
expect(workflowOpenAction).not.toBeNull();
await userEvent.click(workflowOpenAction as HTMLElement);
const groupRow = (await findByText('Review group')).closest('[role="menuitem"]');
expect(workflowOpenAction).toHaveTextContent('3');
expect(groupRow?.querySelector('[data-sub-menu-action="open"]')).toHaveTextContent('2');
});
it('shows a retry action when workflow browse fails', async () => {
setActivePinia(createTestingPinia());
const { useRecentWorkflowsStore } = await import('@/app/stores/recentWorkflows.store');
const recentWorkflowsStore = useRecentWorkflowsStore();
vi.mocked(recentWorkflowsStore.resolveRecentWorkflows).mockRejectedValue(
new Error('Request failed'),
);
const { findByText, getByRole } = renderComponent({
props: {
modelValue: true,
query: '',
projectId: 'project-1',
artifacts: [{ id: 'w1', name: 'Orders' }],
},
});
expect(await findByText('Orders')).toBeVisible();
expect(await findByText("Workflows couldn't load. Try again.")).toBeVisible();
await userEvent.click(getByRole('menuitem', { name: 'Retry' }));
expect(recentWorkflowsStore.resolveRecentWorkflows).toHaveBeenCalledTimes(2);
});
});

View File

@ -1,16 +1,22 @@
<script setup lang="ts">
import {
N8nDropdownMenu,
N8nButton,
N8nIcon,
N8nIconButton,
N8nText,
N8nTooltip,
type DropdownMenuExposed,
type DropdownMenuItemProps,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useDebounceFn } from '@vueuse/core';
import { useDebounceFn, useElementSize } from '@vueuse/core';
import type { INodeTypeDescription } from 'n8n-workflow';
import { computed, nextTick, ref, watch } from 'vue';
import { DEBOUNCE_TIME } from '@/app/constants';
import NodeIcon from '@/app/components/NodeIcon.vue';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { getDebounceTime } from '@n8n/composables/useDebounce';
import type {
@ -28,8 +34,14 @@ import { buildMentionAttachment } from './utils/buildMentionAttachment';
interface MentionMenuData {
item: AssistantMentionItem;
nodeType?: INodeTypeDescription | null;
}
const RETRY_ITEM_PREFIX = 'retry:';
const RETRY_SOURCES_ITEM_ID = 'retry-sources';
const ERROR_ITEM_PREFIX = 'error:';
const LOADING_ITEM_PREFIX = 'loading:';
type MentionMenuItem = DropdownMenuItemProps<string, MentionMenuData>;
const props = withDefaults(
@ -40,6 +52,7 @@ const props = withDefaults(
artifacts?: readonly WorkflowArtifactReference[];
activeWorkflowId?: string;
excludedKeys?: readonly string[];
excludedWorkflowIds?: readonly string[];
inputElement?: HTMLTextAreaElement | null;
reference?: HTMLElement | null;
disabled?: boolean;
@ -49,6 +62,7 @@ const props = withDefaults(
artifacts: () => [],
activeWorkflowId: undefined,
excludedKeys: () => [],
excludedWorkflowIds: () => [],
inputElement: null,
reference: null,
disabled: false,
@ -61,7 +75,14 @@ const emit = defineEmits<{
}>();
const i18n = useI18n();
const nodeTypesStore = useNodeTypesStore();
const dropdownRef = ref<DropdownMenuExposed>();
const referenceElement = computed(() => props.reference);
const { width: referenceWidth } = useElementSize(referenceElement);
const menuWidth = computed(() =>
referenceWidth.value > 0 ? `${referenceWidth.value}px` : undefined,
);
const excludedKeys = computed(() => new Set(props.excludedKeys));
const artifactIndex = useArtifactMentionIndex({
artifacts: () => props.artifacts,
activeWorkflowId: () => props.activeWorkflowId,
@ -69,15 +90,16 @@ const artifactIndex = useArtifactMentionIndex({
const artifactProvider = createArtifactMentionSourceProvider({
artifacts: () => props.artifacts,
artifactIndex,
excludedKeys,
});
const workflowProvider = createWorkflowMentionSourceProvider({
projectId: () => props.projectId,
artifactWorkflowIds: () => props.artifacts.map(({ id }) => id),
excludedWorkflowIds: () => props.excludedWorkflowIds,
});
const sources = useAssistantMentionSources([artifactProvider, workflowProvider]);
const sources = useAssistantMentionSources([artifactProvider, workflowProvider], { excludedKeys });
const searchPending = ref(false);
let highlightedForCurrentOpen = false;
const excludedKeys = computed(() => new Set(props.excludedKeys));
function isMenuItem(item: MentionMenuItem | undefined): item is MentionMenuItem {
return item !== undefined;
@ -86,19 +108,45 @@ function isMenuItem(item: MentionMenuItem | undefined): item is MentionMenuItem
function toMenuItem(item: AssistantMentionItem, searchMode: boolean): MentionMenuItem | undefined {
const indexEntry = artifactIndex.getEntry(item.workflowId);
const isExcluded = excludedKeys.value.has(item.key);
const children = item.children?.map((child) => toMenuItem(child, false)).filter(isMenuItem);
let children = item.children?.map((child) => toMenuItem(child, false)).filter(isMenuItem);
if (isExcluded && children?.length === 0) return undefined;
if (isExcluded && !item.hasChildren) return undefined;
if (item.hasChildren && !children) {
children =
indexEntry?.status === 'error'
? [
{
id: `${ERROR_ITEM_PREFIX}${item.workflowId}`,
label: i18n.baseText('instanceAi.mentions.loadError'),
disabled: true,
},
{
id: `${RETRY_ITEM_PREFIX}${item.workflowId}`,
label: i18n.baseText('generic.retry'),
keepOpen: true,
},
]
: [
{
id: `${LOADING_ITEM_PREFIX}${item.workflowId}`,
label: i18n.baseText('instanceAi.mentions.loadingWorkflowContents'),
disabled: true,
},
];
}
return {
id: item.key,
label: searchMode ? item.breadcrumbs.join(' > ') : item.label,
data: { item },
data: {
item,
...(item.kind === 'node' && item.nodeTypeName
? {
nodeType: nodeTypesStore.getNodeType(item.nodeTypeName, item.nodeTypeVersion),
}
: {}),
},
selectable: item.hasChildren && !isExcluded ? true : undefined,
children,
loading:
item.hasChildren === true && item.children === undefined && indexEntry?.status !== 'error',
loadingItemCount: 3,
};
}
@ -106,12 +154,51 @@ const menuItems = computed<MentionMenuItem[]>(() => {
if (props.query.trim()) {
return sources.searchResults.value.map((item) => toMenuItem(item, true)).filter(isMenuItem);
}
const sections = sources.browseSections.value.map((section) => ({
...section,
hadItems: section.items.length > 0,
items: section.items.map((item) => toMenuItem(item, false)).filter(isMenuItem),
}));
const hasItems = sections.some((section) => section.items.length > 0);
if (!hasItems && sources.providerErrors.value.size > 0) return [];
return sections.flatMap((section) => {
if (section.id === 'workflows' && section.items.length === 0) {
if (section.hadItems) return [];
if (sources.isBrowsing.value) return [];
if (sources.providerErrors.value.has('workflows')) {
return [
{
id: 'section:workflows',
label: i18n.baseText('instanceAi.mentions.workflowsSection'),
header: true,
},
{
id: 'state:workflows-error',
label: i18n.baseText('instanceAi.mentions.loadError'),
disabled: true,
},
{
id: RETRY_SOURCES_ITEM_ID,
label: i18n.baseText('generic.retry'),
keepOpen: true,
},
];
}
return [
{
id: 'section:workflows',
label: i18n.baseText('instanceAi.mentions.workflowsSection'),
header: true,
},
{
id: 'state:no-recent-workflows',
label: i18n.baseText('instanceAi.mentions.noRecentWorkflows'),
disabled: true,
},
];
}
if (section.items.length === 0) return [];
return [
{
@ -128,6 +215,33 @@ const menuItems = computed<MentionMenuItem[]>(() => {
});
});
const isLoading = computed(
() =>
(props.query.trim()
? sources.searchResults.value.length === 0
: !sources.browseSections.value.some((section) => section.items.length > 0)) &&
(searchPending.value || sources.isBrowsing.value || sources.isSearching.value),
);
function getResultPosition(itemId: string): number | undefined {
function find(items: MentionMenuItem[]): number | undefined {
let position = 0;
for (const item of items) {
if (item.header) {
position = 0;
continue;
}
if (item.data) position++;
if (item.id === itemId && item.data) return position;
const childPosition = item.children ? find(item.children) : undefined;
if (childPosition !== undefined) return childPosition;
}
return undefined;
}
return find(menuItems.value);
}
const itemsById = computed(() => {
const items = new Map<string, AssistantMentionItem>();
const visit = (menuItemsToVisit: MentionMenuItem[]) => {
@ -149,6 +263,16 @@ const runSearch = useDebounceFn(async (query: string) => {
}
}, getDebounceTime(DEBOUNCE_TIME.INPUT.SEARCH));
watch(
() => props.modelValue,
(open) => {
if (open) {
void Promise.resolve(nodeTypesStore.loadNodeTypesIfNotLoaded()).catch(() => undefined);
}
},
{ immediate: true },
);
watch(
[() => props.modelValue, () => props.query],
([open, query]) => {
@ -187,10 +311,40 @@ watch(menuItems, (items) => {
});
function handleSelect(itemId: string): void {
if (itemId === RETRY_SOURCES_ITEM_ID) {
retrySources();
return;
}
if (itemId.startsWith(RETRY_ITEM_PREFIX)) {
void artifactIndex.retry(itemId.slice(RETRY_ITEM_PREFIX.length));
return;
}
const item = itemsById.value.get(itemId);
if (!item) return;
const selection = buildMentionAttachment(item, artifactIndex.getIndex(item.workflowId));
if (selection) emit('select', selection);
if (selection) {
const resultPosition = getResultPosition(itemId);
emit('select', {
...selection,
...(resultPosition !== undefined
? {
telemetry: {
mode: props.query.trim() ? ('search' as const) : ('browse' as const),
resultPosition,
queryLength: props.query.trim().length,
},
}
: {}),
});
}
}
function retrySources(): void {
if (props.query.trim()) {
void sources.search(props.query);
} else {
void sources.browse();
}
}
function handleSubmenuToggle(itemId: string, open: boolean): void {
@ -218,28 +372,134 @@ defineExpose({ handleExternalKeydown });
:items="menuItems"
:external-focus-target="inputElement"
:reference="reference ?? undefined"
:width="menuWidth"
:extra-popper-class="$style.menuContent"
:disabled="disabled"
:loading="searchPending"
:loading="isLoading"
:loading-item-count="10"
:empty-text="i18n.baseText('instanceAi.mentions.noResults')"
:search-placeholder="i18n.baseText('instanceAi.mentions.searchPlaceholder')"
placement="top-start"
searchable
search-mode="external"
data-test-id="instance-ai-mention-menu"
content-test-id="instance-ai-mention-menu-content"
@update:model-value="emit('update:modelValue', $event)"
@select="handleSelect"
@submenu:toggle="handleSubmenuToggle"
>
<template v-if="sources.providerErrors.value.size > 0" #empty>
<div :class="$style.errorState">
<N8nText size="small">{{ i18n.baseText('instanceAi.mentions.loadError') }}</N8nText>
<N8nButton size="small" variant="outline" @click="retrySources">
{{ i18n.baseText('generic.retry') }}
</N8nButton>
</div>
</template>
<template #trigger>
<N8nTooltip :content="i18n.baseText('instanceAi.mentions.buttonLabel')" placement="top">
<N8nTooltip
as-child
:content="i18n.baseText('instanceAi.mentions.buttonLabel')"
placement="top"
>
<N8nIconButton
icon="at-sign"
variant="ghost"
size="medium"
:disabled="disabled"
:title="i18n.baseText('instanceAi.mentions.buttonLabel')"
:aria-label="i18n.baseText('instanceAi.mentions.buttonLabel')"
data-test-id="instance-ai-mention-button"
/>
</N8nTooltip>
</template>
<template #item-leading="{ item, ui }">
<N8nIcon
v-if="item.data?.item.kind === 'workflow'"
icon="workflow"
size="large"
:class="ui.class"
/>
<N8nIcon
v-else-if="item.data?.item.kind === 'group'"
icon="layers"
size="large"
:class="ui.class"
/>
<NodeIcon
v-else-if="item.data?.item.kind === 'node'"
:node-type="item.data.nodeType"
:size="16"
:class="ui.class"
/>
</template>
<template #item-label="{ item, ui }">
<N8nText
:class="ui.class"
:title="item.label"
size="medium"
:color="item.disabled ? 'text-xlight' : 'text-dark'"
>
<template v-if="query.trim() && item.data?.item">
<template
v-for="(breadcrumb, index) in item.data.item.breadcrumbs"
:key="`${item.id}:${index}`"
>
<span
:class="{
[$style.breadcrumbAncestor]: index < item.data.item.breadcrumbs.length - 1,
}"
>
{{ breadcrumb }}
</span>
<span
v-if="index < item.data.item.breadcrumbs.length - 1"
:class="$style.breadcrumbAncestor"
>
&gt;
</span>
</template>
</template>
<template v-else>{{ item.label }}</template>
</N8nText>
</template>
<template #item-trailing="{ item, ui }">
<N8nText
v-if="item.data?.item.hasChildren && item.data.item.nodeCount !== undefined"
:class="ui.class"
size="small"
color="text-light"
>
{{ item.data.item.nodeCount }}
</N8nText>
</template>
<template
v-if="query.trim() && menuItems.length > 0 && sources.providerErrors.value.size > 0"
#footer
>
<div :class="$style.errorState">
<N8nText size="small">{{ i18n.baseText('instanceAi.mentions.loadError') }}</N8nText>
<N8nButton size="small" variant="outline" @click="retrySources">
{{ i18n.baseText('generic.retry') }}
</N8nButton>
</div>
</template>
</N8nDropdownMenu>
</template>
<style module lang="scss">
.menuContent {
width: var(--n8n--dropdown-menu-width);
}
.breadcrumbAncestor {
color: var(--color--text--tint-1);
}
.errorState {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--xs);
}
</style>

View File

@ -0,0 +1,39 @@
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import type {
AssistantMentionKind,
AssistantMentionSelection,
AssistantMentionTriggerSource,
} from './assistantAtMentions.types';
export function useAssistantAtMentionsTelemetry() {
const telemetry = useTelemetry();
function trackPickerOpened(source: AssistantMentionTriggerSource): void {
telemetry.track(TELEMETRY_EVENT.INSTANCE_AI.USER_OPENED_AI_ASSISTANT_MENTION_PICKER, {
source,
});
}
function trackMentionSelected(
selection: AssistantMentionSelection,
alreadyArtifact: boolean,
): void {
if (!selection.telemetry) return;
telemetry.track(TELEMETRY_EVENT.INSTANCE_AI.USER_SELECTED_AI_ASSISTANT_MENTION, {
kind: selection.item.kind,
mode: selection.telemetry.mode,
source: selection.item.source,
result_position: selection.telemetry.resultPosition,
query_length: selection.telemetry.queryLength,
already_artifact: alreadyArtifact,
});
}
function trackMentionRemoved(kind: AssistantMentionKind): void {
telemetry.track(TELEMETRY_EVENT.INSTANCE_AI.USER_REMOVED_AI_ASSISTANT_MENTION, { kind });
}
return { trackPickerOpened, trackMentionSelected, trackMentionRemoved };
}

View File

@ -5,6 +5,8 @@ export type AssistantMentionKind = 'workflow' | 'node' | 'group';
export type AssistantMentionSourceId = 'artifacts' | 'workflows';
export type AssistantMentionTriggerSource = 'typed' | 'button';
export interface AssistantMentionItem {
key: string;
kind: AssistantMentionKind;
@ -16,6 +18,9 @@ export interface AssistantMentionItem {
workflowName: string;
groupId?: string;
groupName?: string;
nodeTypeName?: string;
nodeTypeVersion?: number;
nodeCount?: number;
description?: string;
hasChildren?: boolean;
children?: AssistantMentionItem[];
@ -75,8 +80,27 @@ export interface AssistantMentionSelection {
item: AssistantMentionItem;
attachment: InstanceAiResourceAttachment;
truncated: boolean;
telemetry?: {
mode: 'browse' | 'search';
resultPosition: number;
queryLength: number;
};
}
export interface AssistantMentionCounts {
mentionCount: number;
workflowMentionCount: number;
nodeMentionCount: number;
groupMentionCount: number;
}
export const EMPTY_ASSISTANT_MENTION_COUNTS: AssistantMentionCounts = {
mentionCount: 0,
workflowMentionCount: 0,
nodeMentionCount: 0,
groupMentionCount: 0,
};
export interface AssistantMentionArtifactReference {
referenceId: string;
workflowId: string;

View File

@ -1,5 +1,5 @@
import { nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { useAssistantAtMentions } from './useAssistantAtMentions';
@ -7,14 +7,16 @@ function setup(initialText = '') {
const text = ref(initialText);
const enabled = ref(true);
const input = document.createElement('textarea');
const onOpened = vi.fn();
input.value = initialText;
document.body.appendChild(input);
const mentions = useAssistantAtMentions({
text,
enabled,
getInputElement: () => input,
onOpened,
});
return { text, enabled, input, mentions };
return { text, enabled, input, mentions, onOpened };
}
describe('useAssistantAtMentions', () => {
@ -43,6 +45,20 @@ describe('useAssistantAtMentions', () => {
expect(text.value).toBe('Compare "Orders"');
});
it('reports typed and button opens once per open state', async () => {
const { input, mentions, onOpened } = setup();
input.value = '@';
input.setSelectionRange(1, 1);
await mentions.handleTextChange('@');
await mentions.handleTextChange('@');
expect(onOpened).toHaveBeenCalledExactlyOnceWith('typed');
mentions.close();
mentions.openFromButton();
mentions.openFromButton();
expect(onOpened).toHaveBeenNthCalledWith(2, 'button');
});
it('does not open for an email-like value', async () => {
const { input, mentions } = setup();
input.value = 'user@';
@ -52,6 +68,70 @@ describe('useAssistantAtMentions', () => {
expect(mentions.menuOpen.value).toBe(false);
});
it('opens when the trigger and query arrive in one input event', async () => {
const { input, mentions } = setup();
input.value = 'Compare @ord';
input.setSelectionRange(input.value.length, input.value.length);
await mentions.handleTextChange(input.value);
expect(mentions.menuOpen.value).toBe(true);
expect(mentions.query.value).toBe('ord');
});
it('does not reopen a dismissed trigger when its query changes', async () => {
const { input, mentions } = setup();
input.value = '@ord';
input.setSelectionRange(input.value.length, input.value.length);
await mentions.handleTextChange(input.value);
mentions.handleMenuOpenChange(false);
input.value = '@orde';
input.setSelectionRange(input.value.length, input.value.length);
await mentions.handleTextChange(input.value);
expect(mentions.menuOpen.value).toBe(false);
mentions.openFromButton();
mentions.handleMenuOpenChange(false);
input.value = '@order';
input.setSelectionRange(input.value.length, input.value.length);
await mentions.handleTextChange(input.value);
expect(mentions.menuOpen.value).toBe(false);
mentions.openFromButton();
input.value = '@orders';
input.setSelectionRange(input.value.length, input.value.length);
await mentions.handleTextChange(input.value);
expect(mentions.menuOpen.value).toBe(true);
expect(mentions.query.value).toBe('orders');
mentions.close();
input.value = '';
input.setSelectionRange(0, 0);
await mentions.handleTextChange('');
input.value = '@';
input.setSelectionRange(1, 1);
await mentions.handleTextChange('@');
expect(mentions.menuOpen.value).toBe(true);
});
it('clears dismissal when the draft is replaced externally', async () => {
const { text, input, mentions } = setup();
input.value = '@old';
input.setSelectionRange(input.value.length, input.value.length);
await mentions.handleTextChange(input.value);
mentions.handleMenuOpenChange(false);
text.value = '@new';
input.value = '@new';
input.setSelectionRange(input.value.length, input.value.length);
await mentions.handleTextChange(input.value);
expect(mentions.menuOpen.value).toBe(true);
expect(mentions.query.value).toBe('new');
});
it('starts a new range when another whitespace-delimited trigger is typed', async () => {
const { text, input, mentions } = setup();
input.value = '@';

View File

@ -1,5 +1,7 @@
import { nextTick, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue';
import type { AssistantMentionTriggerSource } from '../assistantAtMentions.types';
interface MentionRange {
origin: 'typed' | 'button';
start: number;
@ -17,17 +19,36 @@ export function isMentionTrigger(character: string | undefined): boolean {
return character !== undefined && MENTION_TRIGGER_CHARACTERS.has(character);
}
function findLastMentionTriggerIndex(text: string): number {
for (let index = text.length - 1; index >= 0; index--) {
if (isMentionTrigger(text[index])) return index;
}
return -1;
}
export function useAssistantAtMentions(options: {
text: Ref<string>;
enabled: MaybeRefOrGetter<boolean>;
getInputElement: () => HTMLTextAreaElement | undefined;
onOpened?: (source: AssistantMentionTriggerSource) => void;
}) {
const menuOpen = ref(false);
const query = ref('');
const activeRange = ref<MentionRange>();
const savedSelection = ref({ start: 0, end: 0 });
const dismissedTypedTriggerIndex = ref<number>();
let updatingTextInternally = false;
function close(): void {
function updateText(value: string): void {
updatingTextInternally = true;
options.text.value = value;
updatingTextInternally = false;
}
function close(rememberTypedTrigger = false): void {
if (rememberTypedTrigger && activeRange.value?.origin === 'typed') {
dismissedTypedTriggerIndex.value = activeRange.value.start;
}
menuOpen.value = false;
query.value = '';
activeRange.value = undefined;
@ -42,19 +63,23 @@ export function useAssistantAtMentions(options: {
};
}
function openTypedRange(triggerIndex: number, caret: number): void {
function openTypedRange(triggerIndex: number, caret: number, initialQuery = ''): void {
const wasOpen = menuOpen.value;
activeRange.value = {
origin: 'typed',
start: triggerIndex,
queryStart: triggerIndex + 1,
end: caret,
};
query.value = '';
dismissedTypedTriggerIndex.value = undefined;
query.value = initialQuery;
menuOpen.value = true;
if (!wasOpen) options.onOpened?.('typed');
}
function openFromButton(): void {
if (!toValue(options.enabled)) return;
const wasOpen = menuOpen.value;
saveSelection();
activeRange.value = {
origin: 'button',
@ -64,10 +89,17 @@ export function useAssistantAtMentions(options: {
};
query.value = '';
menuOpen.value = true;
if (!wasOpen) options.onOpened?.('button');
}
async function handleTextChange(value: string, caretOverride?: number): Promise<void> {
options.text.value = value;
updateText(value);
if (
dismissedTypedTriggerIndex.value !== undefined &&
!isMentionTrigger(value[dismissedTypedTriggerIndex.value])
) {
dismissedTypedTriggerIndex.value = undefined;
}
if (!toValue(options.enabled)) {
close();
return;
@ -77,14 +109,19 @@ export function useAssistantAtMentions(options: {
const input = options.getInputElement();
const caret = caretOverride ?? input?.selectionEnd ?? value.length;
savedSelection.value = { start: caret, end: caret };
const triggerIndex = caret - 1;
const followsWhitespace = triggerIndex === 0 || /\s/.test(value[triggerIndex - 1] ?? '');
if (isMentionTrigger(value[triggerIndex]) && followsWhitespace) {
openTypedRange(triggerIndex, caret);
const valueBeforeCaret = value.slice(0, caret);
const possibleTriggerIndex = findLastMentionTriggerIndex(valueBeforeCaret);
const triggerIndex =
possibleTriggerIndex === 0 || /\s/.test(valueBeforeCaret[possibleTriggerIndex - 1] ?? '')
? possibleTriggerIndex
: -1;
const range = activeRange.value;
if (triggerIndex >= 0 && (!range || range.origin !== 'typed' || range.start !== triggerIndex)) {
if (dismissedTypedTriggerIndex.value === triggerIndex && !range) return;
openTypedRange(triggerIndex, caret, value.slice(triggerIndex + 1, caret));
return;
}
const range = activeRange.value;
if (range) {
const triggerExists = range.origin === 'button' || isMentionTrigger(value[range.start]);
const beforeRange = range.origin === 'typed' ? caret <= range.start : caret < range.start;
@ -116,8 +153,7 @@ export function useAssistantAtMentions(options: {
const start = range?.start ?? savedSelection.value.start;
const end = range?.end ?? savedSelection.value.end;
const insertedText = `"${label}"`;
options.text.value =
options.text.value.slice(0, start) + insertedText + options.text.value.slice(end);
updateText(options.text.value.slice(0, start) + insertedText + options.text.value.slice(end));
close();
await nextTick();
@ -130,7 +166,7 @@ export function useAssistantAtMentions(options: {
function handleMenuOpenChange(open: boolean): void {
if (!open) {
close();
close(true);
return;
}
if (!activeRange.value) openFromButton();
@ -142,6 +178,13 @@ export function useAssistantAtMentions(options: {
if (!enabled) close();
},
);
watch(
options.text,
() => {
if (!updatingTextInternally) dismissedTypedTriggerIndex.value = undefined;
},
{ flush: 'sync' },
);
return {
menuOpen,

View File

@ -51,6 +51,7 @@ function setup(options: { reservedAttachmentCount?: number } = {}) {
const projectId = ref('project-1');
const onReferenceAdded = vi.fn();
const onReferenceRemoved = vi.fn();
const onMentionRemoved = vi.fn();
const onCleared = vi.fn();
let mentions!: ReturnType<typeof useAssistantMentionAttachments>;
scope.run(() => {
@ -61,6 +62,7 @@ function setup(options: { reservedAttachmentCount?: number } = {}) {
reservedAttachmentCount: options.reservedAttachmentCount ?? 0,
onReferenceAdded,
onReferenceRemoved,
onMentionRemoved,
onCleared,
});
});
@ -71,6 +73,7 @@ function setup(options: { reservedAttachmentCount?: number } = {}) {
projectId,
onReferenceAdded,
onReferenceRemoved,
onMentionRemoved,
onCleared,
mentions,
};
@ -100,7 +103,7 @@ describe('useAssistantMentionAttachments', () => {
});
it('keeps child context when the workflow attachment is removed', () => {
const { mentions, resources, onReferenceRemoved, scope } = setup();
const { mentions, resources, onReferenceRemoved, onMentionRemoved, scope } = setup();
mentions.select(workflowSelection());
mentions.select(nodeSelection());
@ -108,6 +111,21 @@ describe('useAssistantMentionAttachments', () => {
expect(resources.value).toEqual([nodeSelection().attachment]);
expect(onReferenceRemoved).toHaveBeenCalledTimes(1);
expect(onMentionRemoved).toHaveBeenCalledExactlyOnceWith('workflow');
scope.stop();
});
it('snapshots counts before merged attachments are submitted', () => {
const { mentions, scope } = setup();
mentions.select(workflowSelection());
mentions.select(nodeSelection());
expect(mentions.snapshotCounts()).toEqual({
mentionCount: 2,
workflowMentionCount: 1,
nodeMentionCount: 1,
groupMentionCount: 0,
});
scope.stop();
});
@ -139,7 +157,15 @@ describe('useAssistantMentionAttachments', () => {
});
it('clears mention context when the project changes', async () => {
const { mentions, resources, projectId, onReferenceRemoved, onCleared, scope } = setup();
const {
mentions,
resources,
projectId,
onReferenceRemoved,
onMentionRemoved,
onCleared,
scope,
} = setup();
mentions.select(workflowSelection());
projectId.value = 'project-2';
await nextTick();
@ -147,6 +173,7 @@ describe('useAssistantMentionAttachments', () => {
expect(resources.value).toEqual([]);
expect(onReferenceRemoved).toHaveBeenCalledOnce();
expect(onCleared).toHaveBeenCalledOnce();
expect(onMentionRemoved).not.toHaveBeenCalled();
scope.stop();
});

View File

@ -6,6 +6,7 @@ import { onScopeDispose, toValue, watch, type MaybeRefOrGetter, type Ref } from
import type {
AssistantMentionArtifactReference,
AssistantMentionCounts,
AssistantMentionItem,
AssistantMentionSelection,
} from '../assistantAtMentions.types';
@ -37,6 +38,7 @@ export function useAssistantMentionAttachments(options: {
reservedAttachmentCount: MaybeRefOrGetter<number>;
onReferenceAdded: (reference: AssistantMentionArtifactReference) => void;
onReferenceRemoved: (referenceId: string) => void;
onMentionRemoved?: (kind: AssistantMentionItem['kind']) => void;
onCleared?: () => void;
}) {
let referenceSequence = 0;
@ -89,6 +91,7 @@ export function useAssistantMentionAttachments(options: {
for (const [mentionKey, record] of selectedRecords) {
if (attachmentContainsMention(record.item)) continue;
selectedRecords.delete(mentionKey);
options.onMentionRemoved?.(record.item.kind);
releaseReference(record);
}
}
@ -210,6 +213,23 @@ export function useAssistantMentionAttachments(options: {
return [...selectedRecords.values()].map(({ referenceId }) => referenceId);
}
function snapshotCounts(): AssistantMentionCounts {
let workflowMentionCount = 0;
let nodeMentionCount = 0;
let groupMentionCount = 0;
for (const { item } of selectedRecords.values()) {
if (item.kind === 'workflow') workflowMentionCount++;
if (item.kind === 'node') nodeMentionCount++;
if (item.kind === 'group') groupMentionCount++;
}
return {
mentionCount: workflowMentionCount + nodeMentionCount + groupMentionCount,
workflowMentionCount,
nodeMentionCount,
groupMentionCount,
};
}
function detachSubmission(
referenceIds: AssistantMentionAttachmentSubmissionSnapshot = snapshotSubmission(),
): AssistantMentionAttachmentSubmission {
@ -266,6 +286,7 @@ export function useAssistantMentionAttachments(options: {
removeResource,
clearForProjectChange,
snapshotSubmission,
snapshotCounts,
detachSubmission,
};
}

View File

@ -0,0 +1,88 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { effectScope, ref } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import type { WorkflowArtifactReference } from '../assistantAtMentions.types';
import { useAssistantMentionAvailability } from './useAssistantMentionAvailability';
describe('useAssistantMentionAvailability', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
});
function setup(
options: {
enabled?: boolean;
projectId?: string;
artifacts?: WorkflowArtifactReference[];
} = {},
) {
const enabled = ref(options.enabled ?? true);
const projectId = ref(options.projectId);
const artifacts = ref(options.artifacts ?? []);
const scope = effectScope();
let availability!: ReturnType<typeof useAssistantMentionAvailability>;
scope.run(() => {
availability = useAssistantMentionAvailability({ enabled, projectId, artifacts });
});
return { availability, artifacts, enabled, projectId, scope };
}
it('uses one bounded workflow row to resolve project availability', async () => {
const store = useWorkflowsListStore();
vi.mocked(store.searchWorkflows).mockResolvedValue([{ id: 'workflow-1' }] as never);
const { availability, scope } = setup({ projectId: 'project-1' });
await vi.waitFor(() => expect(availability.isAvailable.value).toBe(true));
expect(store.searchWorkflows).toHaveBeenCalledExactlyOnceWith({
projectId: 'project-1',
isArchived: false,
select: ['id', 'updatedAt'],
options: { take: 1, skip: 0, sortBy: 'updatedAt:asc', includeScopes: false },
});
scope.stop();
});
it('uses an existing workflow artifact without a list request', () => {
const store = useWorkflowsListStore();
const { availability, scope } = setup({
projectId: 'project-1',
artifacts: [{ id: 'workflow-1', name: 'Orders' }],
});
expect(availability.isAvailable.value).toBe(true);
expect(store.searchWorkflows).not.toHaveBeenCalled();
scope.stop();
});
it('does not query without the rollout flag or a project', () => {
const store = useWorkflowsListStore();
const disabled = setup({ enabled: false, projectId: 'project-1' });
const withoutProject = setup();
expect(disabled.availability.isAvailable.value).toBe(false);
expect(withoutProject.availability.isAvailable.value).toBe(false);
expect(store.searchWorkflows).not.toHaveBeenCalled();
disabled.scope.stop();
withoutProject.scope.stop();
});
it('can refresh after a transient availability failure', async () => {
const store = useWorkflowsListStore();
vi.mocked(store.searchWorkflows)
.mockRejectedValueOnce(new Error('Request failed'))
.mockResolvedValue([{ id: 'workflow-1' }] as never);
const { availability, scope } = setup({ projectId: 'project-1' });
await vi.waitFor(() => expect(availability.isLoading.value).toBe(false));
expect(availability.isAvailable.value).toBe(false);
await availability.refresh();
expect(availability.isAvailable.value).toBe(true);
scope.stop();
});
});

View File

@ -0,0 +1,63 @@
import { useEventListener } from '@vueuse/core';
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import type { WorkflowArtifactReference } from '../assistantAtMentions.types';
export function useAssistantMentionAvailability(options: {
enabled: MaybeRefOrGetter<boolean>;
projectId: MaybeRefOrGetter<string | undefined>;
artifacts: MaybeRefOrGetter<readonly WorkflowArtifactReference[]>;
}) {
const workflowsListStore = useWorkflowsListStore();
const hasSavedWorkflow = ref(false);
const isLoading = ref(false);
let requestGeneration = 0;
const hasArtifacts = computed(() => toValue(options.artifacts).length > 0);
const isAvailable = computed(
() => toValue(options.enabled) && (hasArtifacts.value || hasSavedWorkflow.value),
);
async function refresh({ reset = false }: { reset?: boolean } = {}): Promise<void> {
const generation = ++requestGeneration;
const enabled = toValue(options.enabled);
const projectId = toValue(options.projectId)?.trim();
if (!enabled || !projectId) {
hasSavedWorkflow.value = false;
isLoading.value = false;
return;
}
if (hasArtifacts.value) {
hasSavedWorkflow.value = false;
isLoading.value = false;
return;
}
if (reset) hasSavedWorkflow.value = false;
isLoading.value = true;
try {
const workflows = await workflowsListStore.searchWorkflows({
projectId,
isArchived: false,
select: ['id', 'updatedAt'],
options: { take: 1, skip: 0, sortBy: 'updatedAt:asc', includeScopes: false },
});
if (generation === requestGeneration) hasSavedWorkflow.value = workflows.length > 0;
} catch {
if (reset && generation === requestGeneration) hasSavedWorkflow.value = false;
} finally {
if (generation === requestGeneration) isLoading.value = false;
}
}
watch(
[() => toValue(options.enabled), () => toValue(options.projectId), hasArtifacts],
async () => await refresh({ reset: true }),
{ immediate: true },
);
useEventListener(window, 'focus', async () => await refresh());
return { isAvailable, isLoading, refresh };
}

View File

@ -205,6 +205,28 @@ describe('createWorkflowMentionSourceProvider', () => {
expect(results.map(({ workflowId }) => workflowId)).toEqual(['artifact', 'result']);
});
it('overfetches enough workflow matches to replace excluded results', async () => {
const workflowsListStore = useWorkflowsListStore();
vi.spyOn(workflowsListStore, 'searchWorkflows').mockResolvedValue(
Array.from({ length: 11 }, (_, index) =>
makeWorkflow(`workflow-${index}`, `Order workflow ${index}`),
),
);
const source = createWorkflowMentionSourceProvider({
projectId: 'project-1',
artifactWorkflowIds: [],
excludedWorkflowIds: ['workflow-0'],
});
const results = await source.search('order');
expect(workflowsListStore.searchWorkflows).toHaveBeenCalledWith(
expect.objectContaining({ options: expect.objectContaining({ take: 11 }) }),
);
expect(results).toHaveLength(10);
expect(results.map(({ workflowId }) => workflowId)).not.toContain('workflow-0');
});
it('does not issue unscoped browse or search requests', async () => {
const recentWorkflowsStore = useRecentWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
@ -264,6 +286,23 @@ describe('useAssistantMentionSources', () => {
scope.stop();
});
it('filters excluded results before applying the combined search limit', async () => {
const items = Array.from({ length: 15 }, (_, index) =>
buildWorkflowMentionItem(makeWorkflow(`${index}`, `Orders ${index}`), 'workflows'),
);
const excludedKeys = ref(new Set(items.slice(0, 10).map(({ key }) => key)));
const scope = effectScope();
let sources!: ReturnType<typeof useAssistantMentionSources>;
scope.run(() => {
sources = useAssistantMentionSources([provider('workflows', items)], { excludedKeys });
});
await sources.search('orders');
expect(sources.searchResults.value).toEqual(items.slice(10));
scope.stop();
});
it('prioritizes a matching node from a loaded artifact over workflow matches', async () => {
const artifacts = [{ id: 'workflow-1', name: 'Qualify B2B leads' }];
const scope = effectScope();

View File

@ -24,17 +24,26 @@ const MAX_WORKFLOW_BROWSE_CANDIDATES = 50;
export function createArtifactMentionSourceProvider(options: {
artifacts: MaybeRefOrGetter<readonly WorkflowArtifactReference[]>;
artifactIndex: ArtifactMentionIndex;
excludedKeys?: MaybeRefOrGetter<ReadonlySet<string>>;
}): MentionSourceProvider {
return {
id: 'artifacts',
revision: options.artifactIndex.revision,
async browse() {
return buildArtifactBrowseItems(toValue(options.artifacts), options.artifactIndex.getIndex);
return buildArtifactBrowseItems(
toValue(options.artifacts),
options.artifactIndex.getIndex,
options.excludedKeys ? toValue(options.excludedKeys) : undefined,
);
},
async search(query) {
await options.artifactIndex.loadAll();
return searchMentionItems(
buildArtifactSearchItems(toValue(options.artifacts), options.artifactIndex.getIndex),
buildArtifactSearchItems(
toValue(options.artifacts),
options.artifactIndex.getIndex,
options.excludedKeys ? toValue(options.excludedKeys) : undefined,
),
query,
MAX_MENTION_RESULTS,
);
@ -45,6 +54,7 @@ export function createArtifactMentionSourceProvider(options: {
export function createWorkflowMentionSourceProvider(options: {
projectId: MaybeRefOrGetter<string | undefined>;
artifactWorkflowIds: MaybeRefOrGetter<readonly string[]>;
excludedWorkflowIds?: MaybeRefOrGetter<readonly string[]>;
}): MentionSourceProvider {
const recentWorkflowsStore = useRecentWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
@ -97,10 +107,20 @@ export function createWorkflowMentionSourceProvider(options: {
if (!projectId) return [];
const artifactWorkflowIds = toValue(options.artifactWorkflowIds);
const recentWorkflows = await resolveRecentWorkflows(projectId, artifactWorkflowIds);
const excludedWorkflowIds = options.excludedWorkflowIds
? toValue(options.excludedWorkflowIds)
: [];
const recentWorkflows = await resolveRecentWorkflows(projectId, [
...artifactWorkflowIds,
...excludedWorkflowIds,
]);
if (recentWorkflows.length >= MAX_MENTION_RESULTS) return toMentionItems(recentWorkflows);
const excludedIds = new Set([...artifactWorkflowIds, ...recentWorkflows.map(({ id }) => id)]);
const excludedIds = new Set([
...artifactWorkflowIds,
...excludedWorkflowIds,
...recentWorkflows.map(({ id }) => id),
]);
try {
const backfillWorkflows = await backfillProjectWorkflows(projectId, excludedIds);
return toMentionItems([...recentWorkflows, ...backfillWorkflows]);
@ -115,6 +135,9 @@ export function createWorkflowMentionSourceProvider(options: {
const projectId = toValue(options.projectId)?.trim();
const normalizedQuery = query.trim();
if (!projectId || !normalizedQuery) return [];
const excludedWorkflowIds = new Set(
options.excludedWorkflowIds ? toValue(options.excludedWorkflowIds) : [],
);
const workflows = await workflowsListStore.searchWorkflows({
projectId,
@ -122,18 +145,24 @@ export function createWorkflowMentionSourceProvider(options: {
isArchived: false,
select: ['id', 'name', 'description', 'updatedAt'],
options: {
take: MAX_MENTION_RESULTS,
take: Math.min(
MAX_MENTION_RESULTS + excludedWorkflowIds.size,
MAX_WORKFLOW_BROWSE_CANDIDATES,
),
skip: 0,
sortBy: 'updatedAt:desc',
includeScopes: false,
},
});
return toMentionItems(workflows);
return toMentionItems(workflows.filter(({ id }) => !excludedWorkflowIds.has(id)));
},
};
}
export function useAssistantMentionSources(providers: readonly MentionSourceProvider[]) {
export function useAssistantMentionSources(
providers: readonly MentionSourceProvider[],
options: { excludedKeys?: MaybeRefOrGetter<ReadonlySet<string>> } = {},
) {
const browseSections = shallowRef<AssistantMentionBrowseSection[]>([]);
const searchResults = shallowRef<AssistantMentionItem[]>([]);
const providerErrors = shallowRef(new Map<MentionSourceProvider['id'], unknown>());
@ -148,15 +177,21 @@ export function useAssistantMentionSources(providers: readonly MentionSourceProv
let disposed = false;
function updateBrowseSections(): void {
const excludedKeys = options.excludedKeys ? toValue(options.excludedKeys) : undefined;
browseSections.value = providers.map((provider) => ({
id: provider.id,
items: (browseItemsByProvider.get(provider.id) ?? []).slice(0, MAX_MENTION_RESULTS),
items: (browseItemsByProvider.get(provider.id) ?? [])
.filter((item) => !excludedKeys?.has(item.key) || item.hasChildren)
.slice(0, MAX_MENTION_RESULTS),
}));
}
function updateSearchResults(query: string): void {
const excludedKeys = options.excludedKeys ? toValue(options.excludedKeys) : undefined;
searchResults.value = searchMentionItems(
providers.flatMap((provider) => searchItemsByProvider.get(provider.id) ?? []),
providers
.flatMap((provider) => searchItemsByProvider.get(provider.id) ?? [])
.filter((item) => !excludedKeys?.has(item.key)),
query,
MAX_MENTION_RESULTS,
);

View File

@ -0,0 +1,33 @@
import { AI_ASSISTANT_AT_MENTIONS_FLAG } from '@n8n/api-types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AI_ASSISTANT_AT_MENTIONS_EXPERIMENT,
EXPERIMENTS_TO_TRACK,
} from '@/app/constants/experiments';
import { useIsAssistantAtMentionsEnabled } from './useIsAssistantAtMentionsEnabled';
const isFeatureEnabled = vi.hoisted(() => vi.fn());
vi.mock('@/app/stores/posthog.store', () => ({
usePostHog: () => ({ isFeatureEnabled }),
}));
describe('useIsAssistantAtMentionsEnabled', () => {
beforeEach(() => {
isFeatureEnabled.mockReset();
});
it.each([true, false])('reflects the shared rollout flag when it is %s', (enabled) => {
isFeatureEnabled.mockReturnValue(enabled);
expect(useIsAssistantAtMentionsEnabled().value).toBe(enabled);
expect(isFeatureEnabled).toHaveBeenCalledExactlyOnceWith(AI_ASSISTANT_AT_MENTIONS_FLAG);
});
it('registers the flag for experiment exposure tracking', () => {
expect(AI_ASSISTANT_AT_MENTIONS_EXPERIMENT.name).toBe(AI_ASSISTANT_AT_MENTIONS_FLAG);
expect(EXPERIMENTS_TO_TRACK).toContain(AI_ASSISTANT_AT_MENTIONS_FLAG);
});
});

View File

@ -0,0 +1,10 @@
import { AI_ASSISTANT_AT_MENTIONS_FLAG } from '@n8n/api-types';
import { computed } from 'vue';
import { usePostHog } from '@/app/stores/posthog.store';
export function useIsAssistantAtMentionsEnabled() {
const posthog = usePostHog();
return computed(() => posthog.isFeatureEnabled(AI_ASSISTANT_AT_MENTIONS_FLAG));
}

View File

@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { projectWorkflowArtifact } from '../composables/useArtifactMentionIndex';
import {
@ -44,6 +44,7 @@ describe('buildMentionItems', () => {
);
expect(workflow.label).toBe('Order processing');
expect(workflow.nodeCount).toBe(3);
expect(workflow.children?.map(({ kind, label }) => [kind, label])).toEqual([
['group', 'Fulfilment'],
['node', 'Node 1'],
@ -52,6 +53,7 @@ describe('buildMentionItems', () => {
'Node 2',
'Node 3',
]);
expect(workflow.children?.[0].nodeCount).toBe(2);
});
it('builds flat items with workflow and group breadcrumbs', () => {
@ -70,6 +72,10 @@ describe('buildMentionItems', () => {
'Fulfilment',
'Node 2',
]);
expect(items.find(({ entityId }) => entityId === 'node-2')).toMatchObject({
nodeTypeName: 'n8n-nodes-base.noOp',
nodeTypeVersion: 1,
});
expect(items.filter(({ entityId }) => entityId === 'node-2')).toHaveLength(1);
expect(items.every(({ children, hasChildren }) => !children && !hasChildren)).toBe(true);
});
@ -80,16 +86,35 @@ describe('buildMentionItems', () => {
id: `workflow-${itemIndex + 1}`,
name: `Workflow ${itemIndex + 1}`,
}));
const items = buildArtifactBrowseItems(artifacts, (workflowId) =>
const getIndex = vi.fn((workflowId: string) =>
workflowId === 'workflow-1' ? index : undefined,
);
const items = buildArtifactBrowseItems(artifacts, getIndex);
expect(items).toHaveLength(10);
expect(getIndex).toHaveBeenCalledTimes(10);
expect(items[0].children).toHaveLength(10);
expect(items[0].children?.[0]).toMatchObject({ kind: 'group', label: 'Fulfilment' });
expect(items[0].children?.[0].children).toHaveLength(10);
});
it('removes the group expand affordance when all visible children are excluded', () => {
const index = makeIndex();
const excludedKeys = new Set([
buildMentionKey('node', 'workflow-1', 'node-2'),
buildMentionKey('node', 'workflow-1', 'node-3'),
]);
const [workflow] = buildArtifactBrowseItems(
[{ id: 'workflow-1', name: 'Order processing' }],
() => index,
excludedKeys,
);
const group = workflow.children?.find(({ kind }) => kind === 'group');
expect(group).toMatchObject({ hasChildren: false, children: [] });
});
it('keeps artifact roots browseable before their compact index loads', () => {
const [workflow] = buildArtifactBrowseItems(
[{ id: 'workflow-1', name: 'Order processing' }],

View File

@ -54,6 +54,8 @@ function buildNodeMentionItem(
entityId: node.id,
workflowName: index.workflowName,
...(group ? { groupId: group.id, groupName: group.name } : {}),
nodeTypeName: node.type,
nodeTypeVersion: node.typeVersion,
};
}
@ -61,6 +63,7 @@ function buildGroupMentionItem(
index: WorkflowArtifactIndex,
groupId: string,
includeChildren: boolean,
excludedKeys?: ReadonlySet<string>,
): AssistantMentionItem | undefined {
const group = index.groupsById.get(groupId);
if (!group) return undefined;
@ -68,7 +71,10 @@ function buildGroupMentionItem(
const children = includeChildren
? group.nodeIds
.map((nodeId) => buildNodeMentionItem(index, nodeId, group.id))
.filter((item): item is AssistantMentionItem => item !== undefined)
.filter(
(item): item is AssistantMentionItem =>
item !== undefined && !excludedKeys?.has(item.key),
)
.slice(0, MAX_MENTION_RESULTS)
: undefined;
@ -83,8 +89,9 @@ function buildGroupMentionItem(
workflowName: index.workflowName,
groupId: group.id,
groupName: group.name,
nodeCount: group.nodeIds.length,
...(includeChildren
? { hasChildren: group.nodeIds.length > 0, ...(children ? { children } : {}) }
? { hasChildren: Boolean(children?.length), ...(children ? { children } : {}) }
: {}),
};
}
@ -93,17 +100,24 @@ function buildArtifactWorkflowItem(
artifact: WorkflowArtifactReference,
index?: WorkflowArtifactIndex,
includeChildren = true,
excludedKeys?: ReadonlySet<string>,
): AssistantMentionItem {
const workflowName = index?.workflowName ?? artifact.name;
const children =
includeChildren && index
? [
...index.groups.map((group) => buildGroupMentionItem(index, group.id, true)),
...index.groups.map((group) =>
buildGroupMentionItem(index, group.id, true, excludedKeys),
),
...index.nodes
.filter((node) => !index.nodeIdToGroupId.has(node.id))
.map((node) => buildNodeMentionItem(index, node.id)),
]
.filter((item): item is AssistantMentionItem => item !== undefined)
.filter(
(item): item is AssistantMentionItem =>
item !== undefined &&
(!excludedKeys?.has(item.key) || Boolean(item.hasChildren && item.children?.length)),
)
.slice(0, MAX_MENTION_RESULTS)
: undefined;
@ -116,6 +130,7 @@ function buildArtifactWorkflowItem(
workflowId: artifact.id,
entityId: artifact.id,
workflowName,
...(index ? { nodeCount: index.nodes.length } : {}),
...(includeChildren
? { hasChildren: index ? Boolean(children?.length) : true, ...(children ? { children } : {}) }
: {}),
@ -125,31 +140,39 @@ function buildArtifactWorkflowItem(
export function buildArtifactBrowseItems(
artifacts: readonly WorkflowArtifactReference[],
getIndex: (workflowId: string) => WorkflowArtifactIndex | undefined,
excludedKeys?: ReadonlySet<string>,
): AssistantMentionItem[] {
return artifacts
.slice(0, MAX_MENTION_RESULTS)
.map((artifact) => buildArtifactWorkflowItem(artifact, getIndex(artifact.id)));
const items: AssistantMentionItem[] = [];
for (const artifact of artifacts) {
const item = buildArtifactWorkflowItem(artifact, getIndex(artifact.id), true, excludedKeys);
if (excludedKeys?.has(item.key) && !item.hasChildren) continue;
items.push(item);
if (items.length === MAX_MENTION_RESULTS) break;
}
return items;
}
export function buildArtifactSearchItems(
artifacts: readonly WorkflowArtifactReference[],
getIndex: (workflowId: string) => WorkflowArtifactIndex | undefined,
excludedKeys?: ReadonlySet<string>,
): AssistantMentionItem[] {
const items: AssistantMentionItem[] = [];
for (const artifact of artifacts) {
const index = getIndex(artifact.id);
items.push(buildArtifactWorkflowItem(artifact, index, false));
const workflowItem = buildArtifactWorkflowItem(artifact, index, false);
if (!excludedKeys?.has(workflowItem.key)) items.push(workflowItem);
if (!index) continue;
for (const group of index.groups) {
const item = buildGroupMentionItem(index, group.id, false);
if (item) items.push(item);
if (item && !excludedKeys?.has(item.key)) items.push(item);
}
for (const node of index.nodes) {
const item = buildNodeMentionItem(index, node.id, index.nodeIdToGroupId.get(node.id));
if (item) items.push(item);
if (item && !excludedKeys?.has(item.key)) items.push(item);
}
}

View File

@ -78,10 +78,11 @@ import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue';
import WorkflowBuilderUnavailableNotice from './components/WorkflowBuilderUnavailableNotice.vue';
import CreditWarningBanner from '@/features/ai/assistant/components/Agent/CreditWarningBanner.vue';
import ProjectSelect from './components/ProjectSelect.vue';
const props = withDefaults(defineProps<{ mentionsEnabled?: boolean }>(), {
mentionsEnabled: false,
});
import { useIsAssistantAtMentionsEnabled } from '@/features/ai/assistant-at-mentions/composables/useIsAssistantAtMentionsEnabled';
import {
EMPTY_ASSISTANT_MENTION_COUNTS,
type AssistantMentionCounts,
} from '@/features/ai/assistant-at-mentions/assistantAtMentions.types';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { InstanceAiFreeNudge } from '@/experiments/instanceAiFreeNudge';
@ -141,6 +142,7 @@ const rootStore = useRootStore();
const toast = useToast();
const telemetry = useTelemetry();
const i18n = useI18n();
const mentionsEnabled = useIsAssistantAtMentionsEnabled();
// Opening a new conversation drops the tab title of the thread we came from —
// this view mounts on every entry to the empty route, the parent layout doesn't.
useDocumentTitle().set(i18n.baseText('instanceAi.view.title'));
@ -558,6 +560,7 @@ async function handleSubmit(
authorship: InstanceAiMessageAuthorship,
responseStartedAtEpochMs?: number,
acceptDraft: () => void = () => {},
mentionCounts: AssistantMentionCounts = EMPTY_ASSISTANT_MENTION_COUNTS,
) {
if (!settingsStore.isWorkflowBuilderAvailable) {
return;
@ -597,7 +600,8 @@ async function handleSubmit(
authorship,
attachments,
pushRef: rootStore.pushRef,
responseStartedAtEpochMs,
...(responseStartedAtEpochMs !== undefined ? { responseStartedAtEpochMs } : {}),
...(mentionCounts.mentionCount > 0 ? { mentionCounts } : {}),
});
if (!sent) {
isStartingThread.value = false;
@ -676,7 +680,7 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
ref="chatInputRef"
:is-submitting="isStartingThread"
:is-workflow-builder-available="settingsStore.isWorkflowBuilderAvailable"
:mentions-enabled="props.mentionsEnabled"
:mentions-enabled="mentionsEnabled"
:mention-project-id="selectedProject"
@submit="handleSubmit"
@content-change="composerHasContent = $event"
@ -716,7 +720,7 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
ref="chatInputRef"
:is-submitting="isStartingThread"
:is-workflow-builder-available="settingsStore.isWorkflowBuilderAvailable"
:mentions-enabled="props.mentionsEnabled"
:mentions-enabled="mentionsEnabled"
:mention-project-id="selectedProject"
:placeholder-key="INSTANCE_AI_SPLIT_EMPTY_STATE_PLACEHOLDER_KEY"
:preview-prompt-key="composerHasContent ? null : splitPreviewPromptKey"
@ -759,7 +763,7 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
ref="chatInputRef"
:is-submitting="isStartingThread"
:is-workflow-builder-available="settingsStore.isWorkflowBuilderAvailable"
:mentions-enabled="props.mentionsEnabled"
:mentions-enabled="mentionsEnabled"
:mention-project-id="selectedProject"
v-bind="emptyStatePromptSuggestionProps"
@submit="handleSubmit"

View File

@ -67,11 +67,9 @@ import { useAgentEvalsStore } from '@/features/agents/agentEvals.store';
import { useIsAgentWorking } from './composables/useIsAgentWorking';
import { useAgentReturnContextStore } from '@/features/agents/agentReturnContext.store';
import { useRecentWorkflowsStore } from '@/app/stores/recentWorkflows.store';
import { useIsAssistantAtMentionsEnabled } from '@/features/ai/assistant-at-mentions/composables/useIsAssistantAtMentionsEnabled';
const props = defineProps<{
threadId: string;
mentionsEnabled?: boolean;
}>();
const props = defineProps<{ threadId: string }>();
const store = useInstanceAiStore();
const settingsStore = useInstanceAiSettingsStore();
@ -83,6 +81,7 @@ const { width: windowWidth } = useWindowSize();
const { isCollapsed: isMainSidebarCollapsed, sidebarWidth: mainSidebarWidth } = useSidebarLayout();
const toast = useToast();
const recentWorkflowsStore = useRecentWorkflowsStore();
const mentionsEnabled = useIsAssistantAtMentionsEnabled();
const conversationRef = useTemplateRef<InstanceType<typeof InstanceAiConversation>>('conversation');
@ -859,7 +858,7 @@ function handleNewThreadClick() {
<InstanceAiConversation
ref="conversation"
:above-input-overlap-height="setupPanelWorkflowId ? setupOverlapHeight : undefined"
:mentions-enabled="props.mentionsEnabled"
:mentions-enabled="mentionsEnabled"
@thread-missing="onThreadMissing"
@agent-attachment-restored="onAgentAttachmentRestored"
>

View File

@ -6,6 +6,7 @@ import { defineComponent, h, ref, type Component, type PropType } from 'vue';
import type { BaseTextKey } from '@n8n/i18n';
import type { ITelemetryTrackProperties } from 'n8n-workflow';
import { createComponentRenderer } from '@/__tests__/render';
import { EMPTY_ASSISTANT_MENTION_COUNTS } from '@/features/ai/assistant-at-mentions/assistantAtMentions.types';
import InstanceAiInput from '../components/InstanceAiInput.vue';
import type { ContextChip } from '../instanceAi.contextChip';
import {
@ -525,6 +526,7 @@ describe('InstanceAiInput', () => {
},
expect.any(Number),
expect.any(Function),
EMPTY_ASSISTANT_MENTION_COUNTS,
]);
expect(textbox).toHaveValue('');
});
@ -838,6 +840,7 @@ describe('InstanceAiInput', () => {
{ kind: 'user_typed' },
expect.any(Number),
expect.any(Function),
EMPTY_ASSISTANT_MENTION_COUNTS,
],
]);
expect(textbox).toHaveValue('');
@ -1035,6 +1038,7 @@ describe('InstanceAiInput', () => {
{ kind: 'user_typed' },
expect.any(Number),
expect.any(Function),
EMPTY_ASSISTANT_MENTION_COUNTS,
],
]);
});
@ -1078,6 +1082,7 @@ describe('InstanceAiInput', () => {
{ kind: 'user_typed' },
expect.any(Number),
expect.any(Function),
EMPTY_ASSISTANT_MENTION_COUNTS,
],
]);
});

View File

@ -1362,6 +1362,11 @@ describe('createThreadRuntime - SSE and hydration', () => {
prefill_type: null,
prefill_id: null,
prompt_modified: null,
mention_count: 0,
workflow_mention_count: 0,
node_mention_count: 0,
group_mention_count: 0,
attachment_count: 0,
},
);
expect(mockTelemetryTrack).toHaveBeenNthCalledWith(
@ -1375,6 +1380,11 @@ describe('createThreadRuntime - SSE and hydration', () => {
prefill_type: null,
prefill_id: null,
prompt_modified: null,
mention_count: 0,
workflow_mention_count: 0,
node_mention_count: 0,
group_mention_count: 0,
attachment_count: 0,
},
);
expect(warnSpy).toHaveBeenCalledWith(
@ -1452,6 +1462,11 @@ describe('createThreadRuntime - SSE and hydration', () => {
prefill_type: 'handoff_setup_panel_execute',
prefill_id: null,
prompt_modified: false,
mention_count: 0,
workflow_mention_count: 0,
node_mention_count: 0,
group_mention_count: 0,
attachment_count: 0,
},
);
});
@ -1481,6 +1496,41 @@ describe('createThreadRuntime - SSE and hydration', () => {
});
});
test('sendMessage reports mention and outbound attachment counts', async () => {
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await activeRuntime(registry).sendMessage('Compare the workflow and node', {
authorship: USER_TYPED_MESSAGE,
attachments: [
{ type: 'workflow', id: 'workflow-1', name: 'Orders' },
{
type: 'nodes',
workflowId: 'workflow-1',
sets: [{ nodes: [{ id: 'node-1', name: 'Validate' }] }],
},
],
mentionCounts: {
mentionCount: 3,
workflowMentionCount: 1,
nodeMentionCount: 1,
groupMentionCount: 1,
},
});
expect(mockTelemetryTrack).toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_SENT_BUILDER_MESSAGE,
expect.objectContaining({
mention_count: 3,
workflow_mention_count: 1,
node_mention_count: 1,
group_mention_count: 1,
attachment_count: 2,
}),
);
warnSpy.mockRestore();
});
test('sendMessage includes action_source from thread metadata', async () => {
const hooks = {
onTitleUpdated: vi.fn(),
@ -1503,6 +1553,11 @@ describe('createThreadRuntime - SSE and hydration', () => {
prefill_type: null,
prefill_id: null,
prompt_modified: null,
mention_count: 0,
workflow_mention_count: 0,
node_mention_count: 0,
group_mention_count: 0,
attachment_count: 0,
},
);
expect(warnSpy).not.toHaveBeenCalled();
@ -1531,6 +1586,11 @@ describe('createThreadRuntime - SSE and hydration', () => {
prefill_type: null,
prefill_id: null,
prompt_modified: null,
mention_count: 0,
workflow_mention_count: 0,
node_mention_count: 0,
group_mention_count: 0,
attachment_count: 0,
},
);
expect(warnSpy).toHaveBeenCalledWith(
@ -1564,6 +1624,11 @@ describe('createThreadRuntime - SSE and hydration', () => {
prefill_type: 'suggestion_catalog',
prefill_id: 'v4-engineering-data-management-1',
prompt_modified: true,
mention_count: 0,
workflow_mention_count: 0,
node_mention_count: 0,
group_mention_count: 0,
attachment_count: 0,
},
);
warnSpy.mockRestore();

View File

@ -68,6 +68,11 @@ describe('the prefill_type property on the message event', () => {
action_source: 'assistant_page',
prefill_id: null,
prompt_modified: false,
mention_count: 0,
workflow_mention_count: 0,
node_mention_count: 0,
group_mention_count: 0,
attachment_count: 0,
};
it.each([...INSTANCE_AI_PREFILL_TYPES, INSTANCE_AI_PREFILL_TYPE_FALLBACK].map((t) => [t]))(

View File

@ -57,8 +57,10 @@ import {
import type { InstanceAiMessageAuthorship } from '../prefills';
import type {
AssistantMentionArtifactReference,
AssistantMentionCounts,
WorkflowArtifactReference,
} from '@/features/ai/assistant-at-mentions/assistantAtMentions.types';
import { EMPTY_ASSISTANT_MENTION_COUNTS } from '@/features/ai/assistant-at-mentions/assistantAtMentions.types';
import { INSTANCE_AI_AGENT_PREVIEW_VIEW_METADATA_KEY } from '../constants';
import {
agentPreviewContextIcon,
@ -546,6 +548,7 @@ async function handleSubmit(
authorship: InstanceAiMessageAuthorship,
responseStartedAtEpochMs?: number,
acceptDraft: () => void = () => {},
mentionCounts: AssistantMentionCounts = EMPTY_ASSISTANT_MENTION_COUNTS,
) {
if (!settingsStore.isWorkflowBuilderAvailable) {
return;
@ -630,7 +633,8 @@ async function handleSubmit(
attachments: submittedAttachments,
pushRef: rootStore.pushRef,
handoffContext,
responseStartedAtEpochMs,
...(responseStartedAtEpochMs !== undefined ? { responseStartedAtEpochMs } : {}),
...(mentionCounts.mentionCount > 0 ? { mentionCounts } : {}),
})
.then((sent) => {
if (!sent) {

View File

@ -17,11 +17,15 @@ import { useToast } from '@n8n/composables/useToast';
import AssistantAtMentionPicker from '@/features/ai/assistant-at-mentions/AssistantAtMentionPicker.vue';
import { useAssistantAtMentions } from '@/features/ai/assistant-at-mentions/composables/useAssistantAtMentions';
import { useAssistantMentionAttachments } from '@/features/ai/assistant-at-mentions/composables/useAssistantMentionAttachments';
import { useAssistantMentionAvailability } from '@/features/ai/assistant-at-mentions/composables/useAssistantMentionAvailability';
import { useAssistantAtMentionsTelemetry } from '@/features/ai/assistant-at-mentions/assistantAtMentions.telemetry';
import type {
AssistantMentionArtifactReference,
AssistantMentionCounts,
AssistantMentionSelection,
WorkflowArtifactReference,
} from '@/features/ai/assistant-at-mentions/assistantAtMentions.types';
import { EMPTY_ASSISTANT_MENTION_COUNTS } from '@/features/ai/assistant-at-mentions/assistantAtMentions.types';
import { buildMentionKey } from '@/features/ai/assistant-at-mentions/utils/buildMentionItems';
import { INSTANCE_AI_EMPTY_STATE_SUGGESTIONS_VERSION } from '../emptyStateSuggestions';
import { useInstanceAiPromptSuggestionsTelemetry } from '../instanceAiPromptSuggestions.telemetry';
@ -142,6 +146,7 @@ const emit = defineEmits<{
authorship: InstanceAiMessageAuthorship,
responseStartedAtEpochMs: number,
acceptDraft: () => void,
mentionCounts: AssistantMentionCounts,
];
stop: [];
'dismiss-context-chip': [];
@ -159,6 +164,7 @@ const emit = defineEmits<{
const i18n = useI18n();
const toast = useToast();
const promptSuggestionsTelemetry = useInstanceAiPromptSuggestionsTelemetry();
const mentionTelemetry = useAssistantAtMentionsTelemetry();
const instanceAiStore = useInstanceAiStore();
const inputText = ref('');
const attachedFiles = ref<File[]>([]);
@ -274,17 +280,20 @@ const isInputVisuallyEmpty = computed(() => inputText.value.length === 0);
const hasAttachments = computed(
() => attachedFiles.value.length > 0 || attachedResources.value.length > 0,
);
const excludedMentionKeys = computed(() => {
const excludedMentionContext = computed(() => {
const keys = new Set<string>();
const workflowIds = new Set<string>();
if (props.contextChip?.type === 'workflow-artifact') {
keys.add(
buildMentionKey('workflow', props.contextChip.workflowId, props.contextChip.workflowId),
);
workflowIds.add(props.contextChip.workflowId);
}
for (const attachment of attachedResources.value) {
if (attachment.type === 'workflow') {
keys.add(buildMentionKey('workflow', attachment.id, attachment.id));
workflowIds.add(attachment.id);
continue;
}
if (attachment.type !== 'nodes') continue;
@ -300,7 +309,7 @@ const excludedMentionKeys = computed(() => {
}
}
return [...keys];
return { keys: [...keys], workflowIds: [...workflowIds] };
});
// Fed to the composer so its size guard can account for what is already staged.
// Summed per file after encoding — base64 pads each file individually, so encoding
@ -317,17 +326,30 @@ const isGatedBySetup = computed(
const shouldShowMentions = computed(
() => props.mentionsEnabled && Boolean(props.mentionProjectId) && !props.isAwaitingPlanReview,
);
const mentionAvailability = useAssistantMentionAvailability({
enabled: shouldShowMentions,
projectId: () => props.mentionProjectId,
artifacts: () => props.mentionArtifacts,
});
const canUseMentions = computed(
() => shouldShowMentions.value && !isBusy.value && !isGatedBySetup.value,
() =>
shouldShowMentions.value &&
mentionAvailability.isAvailable.value &&
!isBusy.value &&
!isGatedBySetup.value,
);
const inputElement = computed(() => chatInputRef.value?.getInputElement() ?? null);
const mentions = useAssistantAtMentions({
text: inputText,
enabled: canUseMentions,
getInputElement: () => inputElement.value ?? undefined,
onOpened: mentionTelemetry.trackPickerOpened,
});
const mentionMenuOpen = mentions.menuOpen;
const mentionQuery = mentions.query;
watch(canUseMentions, (enabled, wasEnabled) => {
if (enabled && !wasEnabled) void mentions.handleTextChange(inputText.value);
});
const mentionAttachments = useAssistantMentionAttachments({
files: attachedFiles,
@ -336,10 +358,14 @@ const mentionAttachments = useAssistantMentionAttachments({
reservedAttachmentCount: () => props.reservedAttachmentCount,
onReferenceAdded: (reference) => emit('mention-reference-added', reference),
onReferenceRemoved: (referenceId) => emit('mention-reference-removed', referenceId),
onMentionRemoved: mentionTelemetry.trackMentionRemoved,
onCleared: mentions.close,
});
async function handleMentionSelection(selection: AssistantMentionSelection): Promise<void> {
const alreadyArtifact = props.mentionArtifacts.some(
(artifact) => artifact.id === selection.item.workflowId,
);
const result = mentionAttachments.select(selection);
if (result.status === 'limit') {
toast.showError(
@ -348,6 +374,7 @@ async function handleMentionSelection(selection: AssistantMentionSelection): Pro
);
return;
}
if (result.status === 'added') mentionTelemetry.trackMentionSelected(selection, alreadyArtifact);
if (result.truncated) {
toast.showError(
@ -445,6 +472,7 @@ function emitSubmittedMessage(
authorship: InstanceAiMessageAuthorship,
responseStartedAtEpochMs: number,
acceptDraft: () => void,
mentionCounts: AssistantMentionCounts,
) {
previewPrompt.value = null;
emit(
@ -455,6 +483,7 @@ function emitSubmittedMessage(
authorship,
responseStartedAtEpochMs,
acceptDraft,
mentionCounts,
);
}
@ -547,6 +576,7 @@ function submitComposerMessage(
files: File[];
resources: InstanceAiResourceAttachment[];
mentionReferenceIds: readonly string[];
mentionCounts: AssistantMentionCounts;
},
) {
if (!canSubmitMessage(message, attachments?.length ?? 0)) {
@ -568,6 +598,7 @@ function submitComposerMessage(
USER_TYPED_MESSAGE,
responseStartedAtEpochMs,
() => {},
EMPTY_ASSISTANT_MENTION_COUNTS,
);
resetDraftComposer({ keepAttachments: true });
return;
@ -577,6 +608,7 @@ function submitComposerMessage(
const submittedFiles = draftSnapshot?.files ?? [...attachedFiles.value];
const submittedResources = draftSnapshot?.resources ?? [...attachedResources.value];
const mentionCounts = draftSnapshot?.mentionCounts ?? mentionAttachments.snapshotCounts();
const mentionSubmission = mentionAttachments.detachSubmission(draftSnapshot?.mentionReferenceIds);
emitSubmittedMessage(
message,
@ -589,6 +621,7 @@ function submitComposerMessage(
resolveAuthorship(message, prefill),
responseStartedAtEpochMs,
mentionSubmission.accept,
mentionCounts,
);
resetDraftComposer();
}
@ -629,6 +662,7 @@ async function handleSubmit() {
const submittedFiles = [...attachedFiles.value];
const submittedResources = [...attachedResources.value];
const mentionReferenceIds = mentionAttachments.snapshotSubmission();
const mentionCounts = mentionAttachments.snapshotCounts();
isPreparingSubmission.value = true;
let fileAttachments: InstanceAiAttachment[];
try {
@ -650,7 +684,7 @@ async function handleSubmit() {
attachments.length ? attachments : undefined,
prefill,
responseStartedAtEpochMs,
{ files: submittedFiles, resources: submittedResources, mentionReferenceIds },
{ files: submittedFiles, resources: submittedResources, mentionReferenceIds, mentionCounts },
);
}
@ -827,6 +861,7 @@ const resizable = computed(() => {
<div
ref="composerRef"
:class="$style.composer"
data-test-id="instance-ai-composer"
@keydown.capture="handleComposerKeydown"
@pointerdown.capture="mentions.saveSelection"
@click.capture="mentions.handleCaretMove"
@ -901,10 +936,11 @@ const resizable = computed(() => {
:project-id="props.mentionProjectId"
:artifacts="props.mentionArtifacts"
:active-workflow-id="props.mentionActiveWorkflowId"
:excluded-keys="excludedMentionKeys"
:excluded-keys="excludedMentionContext.keys"
:excluded-workflow-ids="excludedMentionContext.workflowIds"
:input-element="inputElement"
:reference="composerRef"
:disabled="isBusy || isGatedBySetup"
:disabled="!canUseMentions"
@update:model-value="mentions.handleMenuOpenChange"
@select="handleMentionSelection"
/>

View File

@ -73,97 +73,96 @@ function handleUpdateDropdownModelValue(open: boolean) {
</script>
<template>
<N8nTooltip :content="tooltip" :content-class="$style.triggerTooltip" placement="top">
<N8nDropdownMenu
:items="menuItems"
placement="top-start"
:disabled="props.disabled"
data-test-id="instance-ai-input-menu"
@select="handleSelect"
@update:model-value="handleUpdateDropdownModelValue"
>
<template #trigger>
<span :class="$style.trigger">
<N8nIconButton
icon="plus"
variant="ghost"
size="medium"
icon-size="large"
:disabled="props.disabled"
:aria-label="tooltip"
<N8nDropdownMenu
:items="menuItems"
placement="top-start"
:disabled="props.disabled"
data-test-id="instance-ai-input-menu"
@select="handleSelect"
@update:model-value="handleUpdateDropdownModelValue"
>
<template #trigger>
<N8nTooltip
as-child
:content="tooltip"
:content-class="$style.triggerTooltip"
placement="top"
>
<N8nIconButton
icon="plus"
variant="ghost"
size="medium"
icon-size="large"
:disabled="props.disabled"
:aria-label="tooltip"
:class="[$style.trigger, { [$style.triggerWithStatus]: disconnectedConnectionCount > 0 }]"
/>
</N8nTooltip>
</template>
<template #item-leading="{ item, ui }">
<N8nNodeIcon
v-if="item.data?.toolIcon"
:type="item.data.toolIcon.type"
:src="item.data.toolIcon.type === 'file' ? item.data.toolIcon.src : undefined"
:name="item.data.toolIcon.type === 'icon' ? item.data.toolIcon.name : undefined"
:size="16"
:class="ui.class"
/>
<N8nIcon
v-else-if="item.icon?.type === 'icon'"
:icon="item.icon.value"
size="large"
:class="ui.class"
/>
</template>
<template #item-label="{ item, ui }">
<N8nText
size="medium"
:color="
item.disabled || (item.data?.preference && item.data.preference !== 'applied')
? 'text-xlight'
: 'text-dark'
"
:class="[
ui.class,
$style.itemLabel,
!item.children?.length && $style.itemLabelLeaf,
item.data?.preference && $style.preferenceItem,
]"
>
<span :class="item.data?.preference && $style.preferenceText">{{ item.label }}</span>
<span
v-if="
item.data?.status &&
item.data.status !== 'none' &&
!(item.id === 'tools' && item.data.status === 'connected')
"
:class="$style.statusIndicator"
:aria-label="i18n.baseText(STATUS_LABEL_KEYS[item.data.status])"
>
<N8nSpinner v-if="item.data.status === 'connecting'" size="small" />
<N8nIcon
v-else-if="item.data.status === 'connected'"
icon="check"
size="small"
:class="[$style.statusIcon, $style.connected]"
/>
<span
v-if="disconnectedConnectionCount > 0"
:class="$style.triggerStatusDot"
aria-hidden="true"
v-else-if="item.id === 'tools'"
:class="[$style.statusDot, $style.disconnectedDot]"
/>
<N8nIcon
v-else
icon="circle-x"
size="small"
:class="[$style.statusIcon, $style.disconnected]"
/>
</span>
</template>
<template #item-leading="{ item, ui }">
<N8nNodeIcon
v-if="item.data?.toolIcon"
:type="item.data.toolIcon.type"
:src="item.data.toolIcon.type === 'file' ? item.data.toolIcon.src : undefined"
:name="item.data.toolIcon.type === 'icon' ? item.data.toolIcon.name : undefined"
:size="16"
:class="ui.class"
/>
<N8nIcon
v-else-if="item.icon?.type === 'icon'"
:icon="item.icon.value"
size="large"
:class="ui.class"
/>
</template>
<template #item-label="{ item, ui }">
<N8nText
size="medium"
:color="
item.disabled || (item.data?.preference && item.data.preference !== 'applied')
? 'text-xlight'
: 'text-dark'
"
:class="[
ui.class,
$style.itemLabel,
!item.children?.length && $style.itemLabelLeaf,
item.data?.preference && $style.preferenceItem,
]"
>
<span :class="item.data?.preference && $style.preferenceText">{{ item.label }}</span>
<span
v-if="
item.data?.status &&
item.data.status !== 'none' &&
!(item.id === 'tools' && item.data.status === 'connected')
"
:class="$style.statusIndicator"
:aria-label="i18n.baseText(STATUS_LABEL_KEYS[item.data.status])"
>
<N8nSpinner v-if="item.data.status === 'connecting'" size="small" />
<N8nIcon
v-else-if="item.data.status === 'connected'"
icon="check"
size="small"
:class="[$style.statusIcon, $style.connected]"
/>
<span
v-else-if="item.id === 'tools'"
:class="[$style.statusDot, $style.disconnectedDot]"
/>
<N8nIcon
v-else
icon="circle-x"
size="small"
:class="[$style.statusIcon, $style.disconnected]"
/>
</span>
</N8nText>
</template>
</N8nDropdownMenu>
</N8nTooltip>
</N8nText>
</template>
</N8nDropdownMenu>
</template>
<style lang="scss" module>
@ -177,10 +176,11 @@ function handleUpdateDropdownModelValue(open: boolean) {
display: inline-flex;
}
.triggerStatusDot {
.triggerWithStatus::after {
content: '';
position: absolute;
top: 2px;
right: 2px;
top: var(--spacing--5xs);
right: var(--spacing--5xs);
width: var(--spacing--2xs);
height: var(--spacing--2xs);
border-radius: 50%;

View File

@ -16,6 +16,10 @@ const emit = defineEmits<{ remove: [] }>();
const rootRef = useTemplateRef<HTMLElement>('root');
defineExpose({ focus: () => rootRef.value?.focus() });
function handleRemoveKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape') event.stopPropagation();
}
</script>
<template>
@ -37,7 +41,7 @@ defineExpose({ focus: () => rootRef.value?.focus() });
:title="props.removeLabel"
:aria-label="props.removeLabel"
:data-test-id="props.removeTestId"
@keydown.stop
@keydown="handleRemoveKeydown"
@click.stop="emit('remove')"
/>
</template>

View File

@ -53,8 +53,10 @@ const { history, search, sentinelRef, loadMore } = useInstanceAiThreadHistory();
const menuOpen = ref(false);
const menuContentId = useId();
const historyDropdownRef = ref<{ highlightFirstItem: () => void } | null>(null);
const triggerRef = ref<HTMLElement | null>(null);
const historyDropdownRef = ref<{
highlightFirstItem: () => void;
focusTrigger: () => void;
} | null>(null);
const editingThreadId = ref<string | null>(null);
const editingTitle = ref('');
const renameInput = ref<HTMLInputElement | null>(null);
@ -115,7 +117,7 @@ function handleMenuOpenChange(open: boolean) {
if (!restoreTriggerFocus) return;
restoreTriggerFocus = false;
void nextTick(() => triggerRef.value?.querySelector('button')?.focus());
void nextTick(() => historyDropdownRef.value?.focusTrigger());
}
function closeMenu() {
@ -225,7 +227,8 @@ function handleThreadAction(action: string, threadId: string) {
if (action === 'delete') {
void handleDeleteThread(threadId);
} else if (action === 'rename') {
startRename(threadId);
// Wait for the action menu to unmount before the rename input takes focus.
requestAnimationFrame(() => startRename(threadId));
}
}
</script>
@ -251,7 +254,7 @@ function handleThreadAction(action: string, threadId: string) {
@update:model-value="handleMenuOpenChange"
>
<template v-if="$slots.trigger" #trigger>
<span ref="triggerRef"><slot name="trigger" /></span>
<slot name="trigger" />
</template>
<template #loading>

View File

@ -396,6 +396,7 @@ const totalNodeCount = computed(() =>
.chipAnchor {
display: inline-flex;
max-width: min(var(--spacing--5xl), 100%);
}
.panel {

View File

@ -155,8 +155,25 @@ describe('InstanceAiConversation', () => {
USER_TYPED_MESSAGE,
Date.now(),
acceptDraft,
{
mentionCount: 1,
workflowMentionCount: 1,
nodeMentionCount: 0,
groupMentionCount: 0,
},
);
await vi.waitFor(() => expect(thread.sendMessage).toHaveBeenCalled());
expect(thread.sendMessage).toHaveBeenCalledWith(
'Compare orders',
expect.objectContaining({
mentionCounts: {
mentionCount: 1,
workflowMentionCount: 1,
nodeMentionCount: 0,
groupMentionCount: 0,
},
}),
);
expect(acceptDraft).not.toHaveBeenCalled();
admit(true);

View File

@ -8,7 +8,11 @@ import { createComponentRenderer } from '@/__tests__/render';
import InstanceAiInput from '../InstanceAiInput.vue';
import AttachmentPreview from '../AttachmentPreview.vue';
import { useInstanceAiStore } from '../../instanceAi.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import type { AssistantMentionSelection } from '@/features/ai/assistant-at-mentions/assistantAtMentions.types';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
const telemetryTrack = vi.hoisted(() => vi.fn());
// The plus menu asks for a router; keep the rest of vue-router real.
vi.mock('vue-router', async (importOriginal) => ({
@ -17,7 +21,7 @@ vi.mock('vue-router', async (importOriginal) => ({
}));
vi.mock('@n8n/composables/useTelemetry', () => ({
useTelemetry: vi.fn(() => ({ track: vi.fn() })),
useTelemetry: vi.fn(() => ({ track: telemetryTrack })),
}));
vi.mock('@/app/stores/pushConnection.store', () => ({
@ -43,6 +47,13 @@ const renderComponent = createComponentRenderer(InstanceAiInput, {
describe('InstanceAiInput — staged node attachments', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
telemetryTrack.mockClear();
});
it('keeps the existing input menu enabled outside the mentions rollout', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('instance-ai-input-menu')).toBeEnabled();
});
it('consumes staged attachments into the draft without touching already-typed text', async () => {
@ -188,6 +199,7 @@ const workflowMentionSelection: AssistantMentionSelection = {
},
attachment: { type: 'workflow', id: 'w1', name: 'Orders' },
truncated: false,
telemetry: { mode: 'browse', resultPosition: 1, queryLength: 0 },
};
const nodeMentionSelection: AssistantMentionSelection = {
@ -208,14 +220,19 @@ const nodeMentionSelection: AssistantMentionSelection = {
sets: [{ nodes: [{ id: 'n1', name: 'Validate' }] }],
},
truncated: false,
telemetry: { mode: 'search', resultPosition: 2, queryLength: 3 },
};
const MentionPickerStub = defineComponent({
name: 'AssistantAtMentionPicker',
props: {
modelValue: { type: Boolean, default: false },
query: { type: String, default: '' },
},
emits: ['select'],
setup(_props, { emit }) {
setup(props, { emit }) {
return () =>
h('div', [
h('div', { 'data-test-id': 'mention-picker-stub', 'data-query': props.query }, [
h(
'button',
{
@ -241,6 +258,7 @@ const renderMentionsInput = createComponentRenderer(InstanceAiInput, {
...defaultProps(),
mentionsEnabled: true,
mentionProjectId: 'project-1',
mentionArtifacts: [{ id: 'w1', name: 'Orders' }],
},
global: { stubs: { AssistantAtMentionPicker: MentionPickerStub } },
});
@ -248,6 +266,45 @@ const renderMentionsInput = createComponentRenderer(InstanceAiInput, {
describe('InstanceAiInput — mention attachments', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
telemetryTrack.mockClear();
});
it('tracks a typed picker open without sending draft text', async () => {
const { getByRole } = renderMentionsInput();
await userEvent.type(getByRole('textbox'), '@');
expect(telemetryTrack).toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_OPENED_AI_ASSISTANT_MENTION_PICKER,
{ source: 'typed' },
);
});
it('re-parses a typed mention after project availability resolves', async () => {
let resolveWorkflows!: (workflows: Array<{ id: string }>) => void;
const workflows = new Promise<Array<{ id: string }>>((resolve) => {
resolveWorkflows = resolve;
});
vi.spyOn(useWorkflowsListStore(), 'searchWorkflows').mockReturnValue(workflows as never);
const { getByRole, getByTestId } = renderMentionsInput({
props: { mentionArtifacts: [] },
});
await userEvent.type(getByRole('textbox'), '@ord');
expect(telemetryTrack).not.toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_OPENED_AI_ASSISTANT_MENTION_PICKER,
expect.anything(),
);
resolveWorkflows([{ id: 'w2' }]);
await waitFor(() =>
expect(getByTestId('mention-picker-stub')).toHaveAttribute('data-query', 'ord'),
);
expect(telemetryTrack).toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_OPENED_AI_ASSISTANT_MENTION_PICKER,
{ source: 'typed' },
);
});
it('inserts quoted text, attaches the workflow, and registers a transient reference', async () => {
@ -260,6 +317,30 @@ describe('InstanceAiInput — mention attachments', () => {
expect(getByTestId('attachment-preview-resource')).toHaveTextContent('Orders');
expect(emitted()['mention-reference-added']).toHaveLength(1);
expect(emitted()['mention-workflow-open']?.[0]).toEqual(['w1']);
expect(telemetryTrack).toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_SELECTED_AI_ASSISTANT_MENTION,
{
kind: 'workflow',
mode: 'browse',
source: 'workflows',
result_position: 1,
query_length: 0,
already_artifact: true,
},
);
});
it('does not track a duplicate selection as newly staged context', async () => {
const { getByTestId } = renderMentionsInput();
await userEvent.click(getByTestId('mention-picker-select'));
await userEvent.click(getByTestId('mention-picker-select'));
expect(
telemetryTrack.mock.calls.filter(
([event]) => event === TELEMETRY_EVENT.INSTANCE_AI.USER_SELECTED_AI_ASSISTANT_MENTION,
),
).toHaveLength(1);
});
it('keeps mention context on failed send and releases it after an accepted send', async () => {
@ -274,8 +355,15 @@ describe('InstanceAiInput — mention attachments', () => {
unknown,
number,
() => void,
unknown,
];
expect(firstSubmit[1]).toEqual([{ type: 'workflow', id: 'w1', name: 'Orders' }]);
expect(firstSubmit[6]).toEqual({
mentionCount: 1,
workflowMentionCount: 1,
nodeMentionCount: 0,
groupMentionCount: 0,
});
expect(firstSubmit[2]()).toBe(true);
await findByTestId('attachment-preview-resource');
expect(emitted()['mention-reference-removed']).toBeUndefined();
@ -291,10 +379,16 @@ describe('InstanceAiInput — mention attachments', () => {
];
secondSubmit[5]();
expect(emitted()['mention-reference-removed']).toHaveLength(1);
expect(telemetryTrack).not.toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_REMOVED_AI_ASSISTANT_MENTION,
{ kind: 'workflow' },
);
});
it('adds node context with parent workflow metadata', async () => {
const { getByTestId, getByRole, emitted } = renderMentionsInput();
const { getByTestId, getByRole, emitted } = renderMentionsInput({
props: { mentionArtifacts: [{ id: 'artifact', name: 'Other workflow' }] },
});
await userEvent.click(getByTestId('mention-picker-select-node'));
expect(getByRole('textbox')).toHaveValue('"Validate"');
@ -308,6 +402,17 @@ describe('InstanceAiInput — mention attachments', () => {
sets: [{ nodes: [{ id: 'n1', name: 'Validate' }] }],
},
]);
expect(telemetryTrack).toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_SELECTED_AI_ASSISTANT_MENTION,
{
kind: 'node',
mode: 'search',
source: 'artifacts',
result_position: 2,
query_length: 3,
already_artifact: false,
},
);
});
it('removes the transient reference with the workflow chip', async () => {
@ -316,5 +421,9 @@ describe('InstanceAiInput — mention attachments', () => {
await userEvent.click(getByTestId('attachment-preview-remove-resource'));
expect(emitted()['mention-reference-removed']).toHaveLength(1);
expect(telemetryTrack).toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_REMOVED_AI_ASSISTANT_MENTION,
{ kind: 'workflow' },
);
});
});

View File

@ -288,4 +288,16 @@ describe('NodesAttachmentChips', () => {
await fireEvent.keyDown(chip, { key: 'Escape' });
expect(queryByTestId('nodes-chip-panel')).toBeNull();
});
it('Escape on the remove button closes an already-open panel', async () => {
const { getByTestId, queryByTestId } = renderComponent(NodesAttachmentChips, {
props: { attachment: att([{ nodes: nodeRefs('A', 'B', 'C', 'D') }]), isRemovable: true },
});
await fireEvent.click(getByTestId('nodes-chip-bundle'));
expect(getByTestId('nodes-chip-panel')).toBeTruthy();
await fireEvent.keyDown(getByTestId('nodes-chip-remove'), { key: 'Escape' });
expect(queryByTestId('nodes-chip-panel')).toBeNull();
});
});

View File

@ -0,0 +1,42 @@
import { AI_ASSISTANT_AT_MENTIONS_FLAG, CANVAS_NODE_CONTEXT_FLAG } from '@n8n/api-types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useIsNodeContextEnabled } from './useIsNodeContextEnabled';
const flagValues = vi.hoisted(() => new Map<string, boolean>());
const editorContext = vi.hoisted(() => ({ instanceAi: { value: true } }));
vi.mock('@/app/stores/posthog.store', () => ({
usePostHog: () => ({ isFeatureEnabled: (flag: string) => flagValues.get(flag) === true }),
}));
vi.mock('@/app/composables/useEditorContext', () => ({
useEditorContext: () => editorContext,
}));
describe('useIsNodeContextEnabled', () => {
beforeEach(() => {
flagValues.clear();
editorContext.instanceAi.value = true;
});
it('keeps canvas controls disabled when only Assistant mentions are enabled', () => {
flagValues.set(AI_ASSISTANT_AT_MENTIONS_FLAG, true);
flagValues.set(CANVAS_NODE_CONTEXT_FLAG, false);
expect(useIsNodeContextEnabled().value).toBe(false);
});
it('enables canvas controls with the canvas node-context flag', () => {
flagValues.set(CANVAS_NODE_CONTEXT_FLAG, true);
expect(useIsNodeContextEnabled().value).toBe(true);
});
it('keeps canvas controls disabled outside the Instance AI editor context', () => {
flagValues.set(CANVAS_NODE_CONTEXT_FLAG, true);
editorContext.instanceAi.value = false;
expect(useIsNodeContextEnabled().value).toBe(false);
});
});

View File

@ -58,6 +58,10 @@ import {
fetchThreadStatus as fetchThreadStatusApi,
} from './instanceAi.memory.api';
import type { InstanceAiMessageAuthorship } from './prefills';
import {
EMPTY_ASSISTANT_MENTION_COUNTS,
type AssistantMentionCounts,
} from '@/features/ai/assistant-at-mentions/assistantAtMentions.types';
import { handleEvent as reduceEvent, createRunStateFromTree } from './instanceAi.reducer';
import { getLatestBuildResult, type RememberedManualExecution } from './canvasPreview.utils';
import {
@ -1516,6 +1520,8 @@ export function createThreadRuntime(
isFirstMessage: boolean,
authorship: InstanceAiMessageAuthorship,
actionSource: InstanceAiThreadSourcePersisted,
mentionCounts: AssistantMentionCounts,
attachmentCount: number,
): void {
const isPrefill = authorship.kind === 'prefill';
const setupContext =
@ -1533,6 +1539,11 @@ export function createThreadRuntime(
prefill_type: isPrefill ? authorship.prefillType : null,
prefill_id: isPrefill ? (authorship.prefillId ?? null) : null,
prompt_modified: isPrefill ? (authorship.promptModified ?? false) : null,
mention_count: mentionCounts.mentionCount,
workflow_mention_count: mentionCounts.workflowMentionCount,
node_mention_count: mentionCounts.nodeMentionCount,
group_mention_count: mentionCounts.groupMentionCount,
attachment_count: attachmentCount,
});
}
@ -1601,6 +1612,7 @@ export function createThreadRuntime(
pushRef?: string;
handoffContext?: InstanceAiHandoffContext;
responseStartedAtEpochMs?: number;
mentionCounts?: AssistantMentionCounts;
},
): Promise<boolean> {
const {
@ -1609,6 +1621,7 @@ export function createThreadRuntime(
pushRef,
handoffContext,
responseStartedAtEpochMs = instanceAiResponseNow(),
mentionCounts = EMPTY_ASSISTANT_MENTION_COUNTS,
} = opts;
const metricGeneration = responseMetricGeneration;
amendContext.value = null;
@ -1618,7 +1631,13 @@ export function createThreadRuntime(
const isFirstMessage = !messages.value.some((m) => m.role === 'user');
const actionSource = resolveActionSource();
const optimistic = pushOptimisticUserMessage(message, attachments, handoffContext);
trackUserMessageSent(isFirstMessage, authorship, actionSource);
trackUserMessageSent(
isFirstMessage,
authorship,
actionSource,
mentionCounts,
attachments?.length ?? 0,
);
const runId = await dispatchUserMessage(message, attachments, handoffContext, pushRef);
if (!runId) {

View File

@ -64,7 +64,11 @@ const slots = defineSlots<{
const i18n = useI18n();
const generatedContentId = useId();
const contentId = computed(() => props.contentId ?? generatedContentId);
const dropdownRef = ref<{ close: () => void; highlightFirstItem: () => void } | null>(null);
const dropdownRef = ref<{
close: () => void;
highlightFirstItem: () => void;
focusTrigger: () => void;
} | null>(null);
let pendingItemClick: ReturnType<typeof setTimeout> | undefined;
const groupOrder = ['Today', 'Yesterday', 'This week', 'Older'] as const;
const groupLabels = {
@ -160,7 +164,11 @@ const highlightFirstItem = () => {
dropdownRef.value?.highlightFirstItem();
};
defineExpose({ highlightFirstItem });
const focusTrigger = () => {
dropdownRef.value?.focusTrigger();
};
defineExpose({ highlightFirstItem, focusTrigger });
</script>
<template>
@ -226,6 +234,7 @@ defineExpose({ highlightFirstItem });
:class="$style.actionDropdown"
placement="bottom-start"
:disabled="props.actionsDisabled || item.disabled"
suppress-close-auto-focus
@select="emit('action', $event, item.id)"
>
<template #activator>

View File

@ -296,6 +296,7 @@ defineExpose({
:disabled="disabled || isStreaming"
icon="paperclip"
icon-size="large"
:aria-label="i18n.baseText('chatInputBase.button.attach')"
data-test-id="chat-input-attach-button"
@click.stop="handleAttach"
/>
@ -311,6 +312,7 @@ defineExpose({
:icon="speechInput.isListening.value ? 'square' : 'mic'"
:class="{ [$style.recording]: speechInput.isListening.value }"
icon-size="large"
:aria-label="i18n.baseText('chatInputBase.button.dictate')"
data-test-id="chat-input-voice-button"
@click.stop="handleMic"
/>

View File

@ -149,9 +149,9 @@ describe('WorkflowExecutionsCard', () => {
expect(retryButton).toBeVisible();
if (disabled) {
expect(retryButton?.querySelector('.is-disabled')).toBeVisible();
expect(retryButton).toBeDisabled();
} else {
expect(retryButton?.querySelector('.is-disabled')).toBe(null);
expect(retryButton).not.toBeDisabled();
}
} else {
expect(retryButton).toBe(null);

View File

@ -80,17 +80,13 @@ const onSelect = (type: string): void => {
padding-top: var(--parameter-input-options--height, 22px);
}
.wrapper > :global(span) {
width: 100%;
height: var(--height--md);
}
.trigger {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
width: 100%;
height: 100%;
min-height: var(--height--md);
padding: 0 var(--spacing--2xs);
border: var(--border-width) var(--border-style) var(--input--border-color, var(--border-color));
border-top-left-radius: var(--input--radius--top-left, var(--input--radius, 0));

View File

@ -104,11 +104,6 @@ const onSelect = (operatorId: string): void => {
.wrapper {
width: 100%;
height: 100%;
/* TODO DS-580: Remove when child consumers of DropdownMenu trigger aren't wrapped in containing span */
> [aria-haspopup='menu'] {
width: 100%;
}
}
.trigger {

View File

@ -94,15 +94,16 @@ const overflowItems = computed<Array<ActionDropdownItem<string>>>(() =>
data-test-id="canvas-node-agent-chips-overflow"
>
<template #activator>
<span :class="[$style.chipWrapper, { [$style.running]: isOverflowActive }]">
<AgentChipButton :aria-busy="isOverflowActive">
{{
i18n.baseText('agentNode.card.moreChips', {
interpolate: { count: overflowChips.length },
})
}}
</AgentChipButton>
</span>
<AgentChipButton
:aria-busy="isOverflowActive"
:class="[$style.chipWrapper, { [$style.running]: isOverflowActive }]"
>
{{
i18n.baseText('agentNode.card.moreChips', {
interpolate: { count: overflowChips.length },
})
}}
</AgentChipButton>
</template>
</N8nActionDropdown>
</div>
@ -141,10 +142,26 @@ const overflowItems = computed<Array<ActionDropdownItem<string>>>(() =>
@include styles.status-animated-after;
@include styles.status-running-animation;
// This matches the mixin's 3px inset and keeps the gradient outside the button face.
padding: 3px;
border-radius: inherit;
z-index: 0;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.running > * {
position: relative;
z-index: 1;
}
@include styles.status-animation-definitions;
/* stylelint-enable */
</style>

View File

@ -82,6 +82,16 @@ export class TestEntryComposer {
await this.n8n.canvas.getCanvasNodes().first().waitFor({ state: 'visible' });
}
async fromInstanceAi() {
await this.n8n.navigate.toInstanceAi();
await this.n8n.instanceAi.getChatInput().waitFor({ state: 'visible', timeout: 30_000 });
}
async fromInstanceAiThread(threadId: string) {
await this.n8n.instanceAi.gotoThread(threadId);
await this.n8n.instanceAi.getChatInput().waitFor({ state: 'visible', timeout: 30_000 });
}
/**
* Start UI test on a new page created by an action
* @param action - The action that will create a new page

View File

@ -22,6 +22,8 @@ export const A11Y_BUCKETS = {
'node-creator': '[data-test-id="node-creator"]',
sidebar: '#side-menu',
modal: '[role="dialog"]',
'instance-ai':
':is([data-test-id="instance-ai-container"], [data-test-id="instance-ai-mention-menu-content"])',
} as const satisfies Record<string, string | undefined>;
export type A11yBucket = keyof typeof A11Y_BUCKETS;

View File

@ -138,7 +138,11 @@ export class InstanceAiPage extends BasePage {
// ── Messages ──────────────────────────────────────────────────────
getChatInput(): Locator {
return this.container.getByRole('textbox');
return this.container.getByRole('textbox').or(this.container.getByRole('combobox'));
}
getComposer(): Locator {
return this.container.getByTestId('instance-ai-composer');
}
getSendButton(): Locator {
@ -216,6 +220,80 @@ export class InstanceAiPage extends BasePage {
);
}
getMentionButton(): Locator {
return this.container.getByTestId('instance-ai-mention-button');
}
getMentionMenu(): Locator {
return this.page.getByTestId('instance-ai-mention-menu-content');
}
getMentionMenuItem(name: string | RegExp): Locator {
return this.page.getByRole('menuitem', { name });
}
async highlightMentionWithKeyboard(name: string | RegExp): Promise<void> {
const item = this.getMentionMenuItem(name);
await item.waitFor({ state: 'visible' });
const input = this.getChatInput();
for (let index = 0; index < 20; index++) {
if ((await item.getAttribute('data-virtual-highlighted')) !== null) {
return;
}
await input.press('ArrowDown');
}
throw new Error(`Could not highlight mention menu item: ${String(name)}`);
}
async selectMentionWithKeyboard(name: string | RegExp): Promise<void> {
await this.highlightMentionWithKeyboard(name);
await this.getChatInput().press('Enter');
}
async openHighlightedMentionSubmenu(): Promise<void> {
await this.getChatInput().press('ArrowRight');
}
getComposerWorkflowChip(name: string | RegExp): Locator {
return this.container.getByTestId('attachment-preview-resource').filter({ hasText: name });
}
getComposerWorkflowRemoveButton(name: string | RegExp): Locator {
return this.getComposerWorkflowChip(name).getByRole('button', {
name: 'Remove workflow context',
});
}
getComposerNodeChip(name: string | RegExp): Locator {
return this.container.getByTestId('nodes-chip-node').filter({ hasText: name });
}
getComposerGroupChip(name: string | RegExp): Locator {
return this.container.getByTestId('nodes-chip-group').filter({ hasText: name });
}
getUserMessageByText(text: string | RegExp): Locator {
return this.getUserMessages().filter({ hasText: text });
}
getWorkflowChipInMessage(message: Locator, name: string | RegExp): Locator {
return message.getByTestId('attachment-preview-resource').filter({ hasText: name });
}
getNodeChipInMessage(message: Locator, name: string | RegExp): Locator {
return message.getByRole('group', { name });
}
async reloadThread(): Promise<void> {
await this.page.reload();
await this.getChatInput().waitFor({ state: 'visible', timeout: 30_000 });
}
getCurrentPath(): string {
return new URL(this.page.url()).pathname;
}
// ── Confirmations ─────────────────────────────────────────────────
getConfirmApproveButton(): Locator {

View File

@ -0,0 +1,178 @@
import { nanoid } from 'nanoid';
import type { TestRequirements } from '../../../Types';
import { expect, instanceAiTestConfig, test } from './fixtures';
const mentionsTestConfig = {
...instanceAiTestConfig,
capability: {
...instanceAiTestConfig.capability,
env: {
...instanceAiTestConfig.capability.env,
N8N_FEATURE_FLAG_OVERRIDES: JSON.stringify({
'116_at_mentions_enabled': true,
'104_canvas_aia_node_context': false,
}),
},
},
} as const;
const requirements: TestRequirements = {
storage: {
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ '116_at_mentions_enabled': true }),
},
};
test.use(mentionsTestConfig);
test.describe(
'Instance AI mentions',
{
annotation: [{ type: 'owner', description: 'instanceAI' }],
},
() => {
test(
'stages workflow context before the first message and restores it after reload',
{
annotation: [
{
type: 'expectation-slug',
description: 'should-persist-messages-after-page-reload',
},
],
},
async ({ n8n, api, setupRequirements, a11y }) => {
await setupRequirements(requirements);
const project = await api.projects.getMyPersonalProject();
const workflow = await api.workflows.createWorkflow(
{
name: `Mentioned workflow ${nanoid()}`,
nodes: [
{
id: 'if-node',
name: 'If',
type: 'n8n-nodes-base.if',
typeVersion: 2.2,
position: [0, 0],
parameters: {},
},
],
connections: {},
},
project.id,
);
await n8n.start.fromInstanceAi();
await expect(n8n.instanceAi.getMentionButton()).toBeEnabled();
const input = n8n.instanceAi.getChatInput();
await input.fill('@');
await input.pressSequentially(workflow.name);
await expect(n8n.instanceAi.getMentionMenu()).toBeVisible();
await expect(n8n.instanceAi.getMentionMenuItem(workflow.name)).toBeVisible();
await expect(input).toBeFocused();
const composerBounds = await n8n.instanceAi.getComposer().boundingBox();
const menuBounds = await n8n.instanceAi.getMentionMenu().boundingBox();
expect(composerBounds).not.toBeNull();
expect(menuBounds).not.toBeNull();
expect(Math.abs((composerBounds?.width ?? 0) - (menuBounds?.width ?? 0))).toBeLessThan(1);
const violations = await a11y.check('instance-ai');
expect(a11y.scans.at(-1)?.bucket).toBe('instance-ai');
expect(violations).toEqual([]);
await n8n.instanceAi.selectMentionWithKeyboard(workflow.name);
await expect(input).toHaveValue(`"${workflow.name}"`);
await expect(n8n.instanceAi.getComposerWorkflowChip(workflow.name)).toBeVisible();
expect(n8n.instanceAi.getCurrentPath()).toBe('/assistant');
await expect(n8n.instanceAi.getPreviewTabByName(workflow.name)).toBeHidden();
await input.fill('Remember this persistence message');
await input.press('Enter');
await n8n.instanceAi.waitForResponseComplete(120_000);
const threadId = n8n.instanceAi.getCurrentThreadId();
const userMessage = n8n.instanceAi.getUserMessageByText(
'Remember this persistence message',
);
await expect(
n8n.instanceAi.getWorkflowChipInMessage(userMessage, workflow.name),
).toBeVisible();
await expect(n8n.instanceAi.getPreviewTabByName(workflow.name)).toBeVisible();
await n8n.instanceAi.reloadThread();
expect(n8n.instanceAi.getCurrentThreadId()).toBe(threadId);
await expect(
n8n.instanceAi.getWorkflowChipInMessage(userMessage, workflow.name),
).toBeVisible();
await expect(n8n.instanceAi.getPreviewTabByName(workflow.name)).toBeVisible();
await input.fill('@');
await input.pressSequentially('if');
await expect(n8n.instanceAi.getMentionMenuItem('If')).toBeVisible();
},
);
test(
'keeps keyboard-selected child context after parent removal and reload',
{
annotation: [
{
type: 'expectation-slug',
description: 'should-display-user-and-assistant-messages-in-timeline',
},
],
},
async ({ n8n, api, setupRequirements }) => {
await setupRequirements(requirements);
const project = await api.projects.getMyPersonalProject();
const imported = await api.workflows.importWorkflowFromFile(
'Canvas-node-groups-persisted-fixture.json',
{ projectId: project.id },
);
const workflowName = imported.createdWorkflow.name;
const thread = await api.createInstanceAiThread(project.id);
await n8n.start.fromInstanceAiThread(thread.id);
const input = n8n.instanceAi.getChatInput();
await input.fill('@');
await input.pressSequentially(workflowName);
await n8n.instanceAi.selectMentionWithKeyboard(workflowName);
await expect(n8n.instanceAi.getPreviewTabByName(workflowName)).toBeVisible();
await input.fill('@');
await n8n.instanceAi.highlightMentionWithKeyboard(workflowName);
await n8n.instanceAi.openHighlightedMentionSubmenu();
await n8n.instanceAi.selectMentionWithKeyboard('Persisted group');
await input.fill('@');
await n8n.instanceAi.highlightMentionWithKeyboard(workflowName);
await n8n.instanceAi.openHighlightedMentionSubmenu();
await n8n.instanceAi.highlightMentionWithKeyboard('Persisted group');
await n8n.instanceAi.openHighlightedMentionSubmenu();
await n8n.instanceAi.selectMentionWithKeyboard('Set A');
await expect(n8n.instanceAi.getComposerWorkflowChip(workflowName)).toBeVisible();
await expect(n8n.instanceAi.getComposerGroupChip('Persisted group')).toBeVisible();
await expect(n8n.instanceAi.getComposerNodeChip('Set A')).toBeVisible();
const removeWorkflow = n8n.instanceAi.getComposerWorkflowRemoveButton(workflowName);
await removeWorkflow.focus();
await removeWorkflow.press('Space');
await expect(n8n.instanceAi.getComposerWorkflowChip(workflowName)).toBeHidden();
await expect(n8n.instanceAi.getComposerGroupChip('Persisted group')).toBeVisible();
await expect(n8n.instanceAi.getComposerNodeChip('Set A')).toBeVisible();
await expect(n8n.instanceAi.getPreviewTabByName(workflowName)).toBeVisible();
await input.fill('Say hello back to me');
await input.press('Enter');
await n8n.instanceAi.waitForResponseComplete(120_000);
await n8n.instanceAi.reloadThread();
const userMessage = n8n.instanceAi.getUserMessageByText('Say hello back to me');
await expect(
n8n.instanceAi.getNodeChipInMessage(userMessage, 'Persisted group'),
).toBeVisible();
await expect(n8n.instanceAi.getNodeChipInMessage(userMessage, 'Set A')).toBeVisible();
await expect(n8n.instanceAi.getPreviewTabByName(workflowName)).toBeVisible();
},
);
},
);