diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 2cf4f900d50..736ec001662 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index e215176695c..24e8a9e1452 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -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'; diff --git a/packages/@n8n/telemetry/src/events/instance-ai.ts b/packages/@n8n/telemetry/src/events/instance-ai.ts index 575993c5b2a..73d685d9e29 100644 --- a/packages/@n8n/telemetry/src/events/instance-ai.ts +++ b/packages/@n8n/telemetry/src/events/instance-ai.ts @@ -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: { diff --git a/packages/cli/src/modules/instance-ai/__tests__/canvas-node-context-flag-gate.test.ts b/packages/cli/src/modules/instance-ai/__tests__/canvas-node-context-flag-gate.test.ts deleted file mode 100644 index d1b4b88a407..00000000000 --- a/packages/cli/src/modules/instance-ai/__tests__/canvas-node-context-flag-gate.test.ts +++ /dev/null @@ -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; - let gate: CanvasNodeContextFlagGate; - const user = mock({ id: 'user-1' }); - - beforeEach(() => { - postHogClient = mock(); - 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); - }); -}); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-temporary-workflow.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-temporary-workflow.service.test.ts index 0d1eda44f2e..f153c20225d 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-temporary-workflow.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-temporary-workflow.service.test.ts @@ -48,6 +48,7 @@ function createService() { progressiveBuildingEnabled: false, setupPanelEnabled: false, nodeUsageEnabled: false, + nodeContextEnabled: false, folderExplorationEnabled: false, aiPreferencesEnabled: false, instanceContextEnabled: false, diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index 787a2164f02..16e794780b1 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -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, diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.nodesContext.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.nodesContext.test.ts index 679f3e68a1c..96ab5982fa2 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.nodesContext.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.nodesContext.test.ts @@ -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; + 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()]); }); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts index c53d30fb884..40661a94a2b 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts @@ -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; } diff --git a/packages/cli/src/modules/instance-ai/canvas-node-context-flag-gate.ts b/packages/cli/src/modules/instance-ai/canvas-node-context-flag-gate.ts deleted file mode 100644 index f309ab3c804..00000000000 --- a/packages/cli/src/modules/instance-ai/canvas-node-context-flag-gate.ts +++ /dev/null @@ -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 { - try { - const flags = await this.postHogClient.getFeatureFlags(user); - return flags?.[CANVAS_NODE_CONTEXT_FLAG] === true; - } catch { - return false; - } - } -} diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index d57bc09f329..5162c06b945 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -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, diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index 61736665ac6..4b4a921ca19 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -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>, instanceContextGates?: InstanceContextGates, + experimentGates?: Awaited>, ) { 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 { + 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, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.types.ts b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.types.ts index 69a968443f8..e08c593dc87 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.types.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.types.ts @@ -16,4 +16,6 @@ export interface ActionDropdownProps { maxHeight?: string | number; width?: string; modal?: boolean; + /** Prevents the menu from returning focus to its trigger when it closes. */ + suppressCloseAutoFocus?: boolean; } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue index b587923e750..571c9e06732 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue @@ -25,6 +25,7 @@ const props = withDefaults(defineProps>(), { disabled: false, maxHeight: '', modal: true, + suppressCloseAutoFocus: false, }); const $style = useCssModule(); @@ -110,6 +111,7 @@ const getItemClasses = (item: ActionDropdownItem): Record => :disabled="disabled" :teleported="teleported" :modal="modal" + :suppress-close-auto-focus="suppressCloseAutoFocus" :extra-popper-class="`${extraPopperClass ?? ''}`" :max-height="maxHeight" :width="width" diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/__snapshots__/ActionDropdown.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/__snapshots__/ActionDropdown.test.ts.snap index 6a91813031e..fbc0b31702c 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/__snapshots__/ActionDropdown.test.ts.snap +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/__snapshots__/ActionDropdown.test.ts.snap @@ -1,13 +1,15 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`components > N8nActionDropdown > should render custom styling correctly 1`] = ` -"
+"
+
" `; exports[`components > N8nActionDropdown > should render default styling correctly 1`] = ` -"
+"
+
" `; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue index bed6a9adebb..73b52120449 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue @@ -1,6 +1,7 @@ -