mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(editor): Roll out Assistant mentions (no-changelog) (#39370)
This commit is contained in:
parent
c53a5e4dd8
commit
ebbf129719
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -48,6 +48,7 @@ function createService() {
|
|||
progressiveBuildingEnabled: false,
|
||||
setupPanelEnabled: false,
|
||||
nodeUsageEnabled: false,
|
||||
nodeContextEnabled: false,
|
||||
folderExplorationEnabled: false,
|
||||
aiPreferencesEnabled: false,
|
||||
instanceContextEnabled: false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = '@';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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' }],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]))(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -396,6 +396,7 @@ const totalNodeCount = computed(() =>
|
|||
|
||||
.chipAnchor {
|
||||
display: inline-flex;
|
||||
max-width: min(var(--spacing--5xl), 100%);
|
||||
}
|
||||
|
||||
.panel {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user