feat(editor): Move AI Assistant button to canvas action buttons (#16879)

This commit is contained in:
Daria
2025-07-10 13:33:30 +03:00
committed by GitHub
parent 3edadb5a75
commit 2294c3d71b
12 changed files with 135 additions and 83 deletions

View File

@@ -35,8 +35,8 @@ describe('AI Assistant::enabled', () => {
});
it('renders placeholder UI', () => {
aiAssistant.getters.askAssistantFloatingButton().should('be.visible');
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.getters.askAssistantCanvasActionButton().should('be.visible');
aiAssistant.getters.askAssistantCanvasActionButton().click();
aiAssistant.getters.askAssistantChat().should('be.visible');
aiAssistant.getters.placeholderMessage().should('be.visible');
aiAssistant.getters.chatInput().should('be.visible');
@@ -47,7 +47,7 @@ describe('AI Assistant::enabled', () => {
});
it('should resize assistant chat up', () => {
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.getters.askAssistantCanvasActionButton().click();
aiAssistant.getters.askAssistantSidebarResizer().should('be.visible');
aiAssistant.getters.askAssistantChat().should('be.visible');
aiAssistant.getters.askAssistantChat().then((element) => {
@@ -64,7 +64,7 @@ describe('AI Assistant::enabled', () => {
});
it('should resize assistant chat down', () => {
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.getters.askAssistantCanvasActionButton().click();
aiAssistant.getters.askAssistantSidebarResizer().should('be.visible');
aiAssistant.getters.askAssistantChat().should('be.visible');
aiAssistant.getters.askAssistantChat().then((element) => {
@@ -300,11 +300,11 @@ describe('AI Assistant::enabled', () => {
}
});
}).as('chatRequest');
aiAssistant.actions.openChat();
aiAssistant.actions.openChatFromCanvas();
aiAssistant.actions.sendMessage('Hello');
cy.wait('@chatRequest');
aiAssistant.actions.closeChat();
aiAssistant.actions.openChat();
aiAssistant.actions.openChatFromCanvas();
// After closing and reopening the chat, all messages should be still there
aiAssistant.getters.chatMessagesAll().should('have.length', 2);
// End the session
@@ -313,7 +313,7 @@ describe('AI Assistant::enabled', () => {
aiAssistant.getters.chatMessagesSystem().should('have.length', 1);
aiAssistant.getters.chatMessagesSystem().first().should('contain.text', 'session has ended');
aiAssistant.actions.closeChat();
aiAssistant.actions.openChat();
aiAssistant.actions.openChatFromCanvas();
// Now, session should be reset
aiAssistant.getters.placeholderMessage().should('be.visible');
});
@@ -324,7 +324,7 @@ describe('AI Assistant::enabled', () => {
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
wf.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
aiAssistant.actions.openChat();
aiAssistant.actions.openChatFromCanvas();
aiAssistant.actions.sendMessage('Hello');
wf.actions.openNode(SCHEDULE_TRIGGER_NODE_NAME);
ndv.getters.nodeExecuteButton().click();
@@ -339,7 +339,7 @@ describe('AI Assistant::enabled', () => {
}).as('chatRequest');
wf.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
aiAssistant.actions.openChat();
aiAssistant.actions.openChatFromCanvas();
nodeCreatorFeature.actions.openNodeCreator();
aiAssistant.getters.chatInput().type('Hello{Enter}');
@@ -488,8 +488,8 @@ describe('General help', () => {
fixture: 'aiAssistant/responses/code_snippet_response.json',
}).as('chatRequest');
aiAssistant.getters.askAssistantFloatingButton().should('be.visible');
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.getters.askAssistantCanvasActionButton().should('be.visible');
aiAssistant.getters.askAssistantCanvasActionButton().click();
aiAssistant.getters.askAssistantChat().should('be.visible');
aiAssistant.getters.placeholderMessage().should('be.visible');
aiAssistant.getters.chatInput().should('be.visible');
@@ -537,7 +537,7 @@ describe('General help', () => {
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.getters.askAssistantCanvasActionButton().click();
aiAssistant.actions.sendMessage('What is wrong with this workflow?');
cy.wait('@chatRequest').then((interception) => {
@@ -557,7 +557,7 @@ describe('General help', () => {
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.getters.askAssistantCanvasActionButton().click();
wf.getters.zoomToFitButton().click();
aiAssistant.actions.sendMessage('What is wrong with this workflow?');

View File

@@ -13,6 +13,7 @@ export class AIAssistant extends BasePage {
getters = {
askAssistantFloatingButton: () => cy.getByTestId('ask-assistant-floating-button'),
askAssistantCanvasActionButton: () => cy.getByTestId('ask-assistant-canvas-action-button'),
askAssistantSidebar: () => cy.getByTestId('ask-assistant-sidebar'),
askAssistantSidebarResizer: () =>
this.getters.askAssistantSidebar().find('[class^=_resizer][data-dir=left]').first(),
@@ -53,7 +54,11 @@ export class AIAssistant extends BasePage {
this.getters.closeChatButton().click();
this.getters.askAssistantChat().should('not.be.visible');
},
openChat: () => {
openChatFromCanvas: () => {
this.getters.askAssistantCanvasActionButton().click();
this.getters.askAssistantChat().should('be.visible');
},
openChatFromNdv: () => {
this.getters.askAssistantFloatingButton().click();
this.getters.askAssistantChat().should('be.visible');
},

View File

@@ -4,7 +4,6 @@ import { ref } from 'vue';
import { useI18n } from '../../composables/useI18n';
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
import AssistantText from '../AskAssistantText/AssistantText.vue';
import BetaTag from '../BetaTag/BetaTag.vue';
const { t } = useI18n();
@@ -48,9 +47,6 @@ function onMouseLeave() {
"
/>
</div>
<div>
<BetaTag />
</div>
</div>
</button>
</template>
@@ -92,7 +88,6 @@ function onMouseLeave() {
.text {
position: absolute;
top: -1px;
display: flex;
flex-direction: column;
align-items: end;

View File

@@ -21,13 +21,6 @@ exports[`AskAssistantButton > renders button with unread messages correctly 1`]
Ask Assistant
</span>
</div>
<div>
<div
class="beta"
>
beta
</div>
</div>
</div>
</button>
</div>
@@ -83,13 +76,6 @@ exports[`AskAssistantButton > renders default button correctly 1`] = `
Ask Assistant
</span>
</div>
<div>
<div
class="beta"
>
beta
</div>
</div>
</div>
</button>
</div>

View File

@@ -16,7 +16,6 @@ import type { ChatUI } from '../../types/assistant';
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoadingMessage.vue';
import AssistantText from '../AskAssistantText/AssistantText.vue';
import BetaTag from '../BetaTag/BetaTag.vue';
import InlineAskAssistantButton from '../InlineAskAssistantButton/InlineAskAssistantButton.vue';
import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon';
@@ -123,7 +122,6 @@ function onSubmitFeedback(feedback: string) {
<AssistantIcon size="large" />
<AssistantText size="large" :text="title" />
</div>
<BetaTag />
<slot name="header" />
</div>
<div :class="$style.back" data-test-id="close-chat-button" @click="onClose">

View File

@@ -54,11 +54,6 @@ exports[`AskAssistantChat > does not render retry button if no error is present
AI Assistant
</span>
</div>
<div
class="beta"
>
beta
</div>
</div>
@@ -247,11 +242,6 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
AI Assistant
</span>
</div>
<div
class="beta"
>
beta
</div>
</div>
@@ -1064,11 +1054,6 @@ exports[`AskAssistantChat > renders default placeholder chat correctly 1`] = `
AI Assistant
</span>
</div>
<div
class="beta"
>
beta
</div>
</div>
@@ -1250,11 +1235,6 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
AI Assistant
</span>
</div>
<div
class="beta"
>
beta
</div>
</div>
@@ -1520,11 +1500,6 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1`
AI Assistant
</span>
</div>
<div
class="beta"
>
beta
</div>
</div>
@@ -1725,11 +1700,6 @@ exports[`AskAssistantChat > renders message with code snippet 1`] = `
AI Assistant
</span>
</div>
<div
class="beta"
>
beta
</div>
</div>
@@ -1988,11 +1958,6 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = `
AI Assistant
</span>
</div>
<div
class="beta"
>
beta
</div>
</div>

View File

@@ -195,6 +195,7 @@
"aiAssistant.prompts.currentView.credentialsList": "The user is currently looking at the list of credentials.",
"aiAssistant.prompts.currentView.executionsView": "The user is currently looking at the list of executions for the currently open workflow.",
"aiAssistant.prompts.currentView.workflowEditor": "The user is currently looking at the current workflow in n8n editor, without any specific node selected.",
"aiAssistant.tooltip": "Ask Assistant",
"banners.confirmEmail.message.1": "To secure your account and prevent future access issues, please confirm your",
"banners.confirmEmail.message.2": "email address.",
"banners.confirmEmail.button": "Confirm email",

View File

@@ -38,7 +38,10 @@ useHistoryHelper(route);
const loading = ref(true);
const defaultLocale = computed(() => rootStore.defaultLocale);
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtonsOnCanvas);
const showAssistantFloatingButton = computed(
() =>
assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.hideAssistantFloatingButton,
);
const hasContentFooter = ref(false);
const appGrid = ref<Element | null>(null);
@@ -129,7 +132,7 @@ watch(
<Modals />
</div>
<Telemetry />
<AskAssistantFloatingButton v-if="showAssistantButton" />
<AskAssistantFloatingButton v-if="showAssistantFloatingButton" />
</div>
<AssistantsHub />
</div>

View File

@@ -38,7 +38,11 @@ const onClick = () => {
<template>
<div
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
v-if="
assistantStore.canShowAssistantButtonsOnCanvas &&
!assistantStore.isAssistantOpen &&
!assistantStore.hideAssistantFloatingButton
"
:class="$style.container"
data-test-id="ask-assistant-floating-button"
:style="{ '--canvas-panel-height-offset': `${logsStore.height}px` }"
@@ -64,16 +68,9 @@ const onClick = () => {
<style lang="scss" module>
.container {
position: absolute;
bottom: calc(var(--canvas-panel-height-offset, 0px) + var(--spacing-s));
bottom: var(--spacing-2xl);
right: var(--spacing-s);
z-index: var(--z-index-ask-assistant-floating-button);
/* Prevent overlap with 'Execute Workflow' / 'Open Chat' buttons on small screens */
@include mixins.breakpoint('sm-only') {
bottom: calc(
var(--canvas-panel-height-offset, 0px) + var(--spacing-s) + var(--spacing-xs) + 42px
);
}
}
.tooltip {

View File

@@ -19,7 +19,9 @@ import type {
} from '@/Interface';
import { useActions } from './NodeCreator/composables/useActions';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import AssistantIcon from '@n8n/design-system/components/AskAssistantIcon/AssistantIcon.vue';
import { useI18n } from '@n8n/i18n';
import { useAssistantStore } from '@/stores/assistant.store';
type Props = {
nodeViewScale: number;
@@ -44,6 +46,7 @@ const uiStore = useUIStore();
const focusPanelStore = useFocusPanelStore();
const posthogStore = usePostHog();
const i18n = useI18n();
const assistantStore = useAssistantStore();
const { getAddedNodesAndConnections } = useActions();
@@ -82,6 +85,17 @@ function nodeTypeSelected(value: NodeTypeSelectedPayload[]) {
emit('addNodes', getAddedNodesAndConnections(value));
closeNodeCreator(true);
}
function onAskAssistantButtonClick() {
if (!assistantStore.chatWindowOpen)
assistantStore.trackUserOpenedAssistant({
source: 'canvas',
task: 'placeholder',
has_existing_session: !assistantStore.isSessionEnded,
});
assistantStore.toggleChatOpen();
}
</script>
<template>
@@ -125,6 +139,24 @@ function nodeTypeSelected(value: NodeTypeSelectedPayload[]) {
@click="focusPanelStore.toggleFocusPanel"
/>
</KeyboardShortcutTooltip>
<n8n-tooltip placement="left">
<template #content> {{ i18n.baseText('aiAssistant.tooltip') }}</template>
<n8n-button
v-if="assistantStore.canShowAssistantButtonsOnCanvas"
type="tertiary"
size="large"
square
:class="$style.icon"
data-test-id="ask-assistant-canvas-action-button"
@click="onAskAssistantButtonClick"
>
<template #default>
<div>
<AssistantIcon size="large" />
</div>
</template>
</n8n-button>
</n8n-tooltip>
</div>
<Suspense>
<LazyNodeCreator
@@ -146,4 +178,14 @@ function nodeTypeSelected(value: NodeTypeSelectedPayload[]) {
padding: var(--spacing-s);
pointer-events: all !important;
}
.icon {
display: inline-flex;
justify-content: center;
align-items: center;
svg {
display: block;
}
}
</style>

View File

@@ -7,6 +7,7 @@ import {
MIN_CHAT_WIDTH,
useAssistantStore,
} from '@/stores/assistant.store';
import { useWorkflowsStore } from './workflows.store';
import type { ChatRequest } from '@/types/assistant.types';
import { usePostHog } from './posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
@@ -43,11 +44,12 @@ const setAssistantEnabled = (enabled: boolean) => {
};
let currentRouteName = ENABLED_VIEWS[0];
let currentRouteParams = {};
vi.mock('vue-router', () => ({
useRoute: vi.fn(() =>
reactive({
path: '/',
params: {},
params: currentRouteParams,
name: currentRouteName,
}),
),
@@ -58,6 +60,7 @@ vi.mock('vue-router', () => ({
describe('AI Assistant store', () => {
beforeEach(() => {
vi.clearAllMocks();
currentRouteParams = {};
setActivePinia(createPinia());
settingsStore = useSettingsStore();
settingsStore.setSettings(
@@ -305,7 +308,7 @@ describe('AI Assistant store', () => {
[VIEWS.PROJECTS_CREDENTIALS, VIEWS.TEMPLATE_SETUP, VIEWS.CREDENTIALS].forEach((view) => {
it(`should show assistant if on ${view} page`, () => {
currentRouteName = VIEWS.PROJECTS_CREDENTIALS;
currentRouteName = view;
const assistantStore = useAssistantStore();
setAssistantEnabled(true);
@@ -315,6 +318,48 @@ describe('AI Assistant store', () => {
});
});
[
{ view: VIEWS.WORKFLOW, nodeId: 'nodeId' },
{ view: VIEWS.NEW_WORKFLOW },
{ view: VIEWS.EXECUTION_DEBUG },
].forEach(({ view, nodeId }) => {
it(`should show ai assistant floating button if on ${view} page`, () => {
currentRouteName = view;
currentRouteParams = nodeId ? { nodeId } : {};
const workflowsStore = useWorkflowsStore();
workflowsStore.activeNode = () => ({
id: 'test-node',
name: 'Test Node',
type: 'test',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
const assistantStore = useAssistantStore();
setAssistantEnabled(true);
expect(assistantStore.canShowAssistantButtonsOnCanvas).toBe(true);
expect(assistantStore.hideAssistantFloatingButton).toBe(false);
});
});
[{ view: VIEWS.WORKFLOW }, { view: VIEWS.NEW_WORKFLOW }].forEach(({ view }) => {
it(`should hide ai assistant floating button if on canvas of ${view} page`, () => {
currentRouteName = view;
const workflowsStore = useWorkflowsStore();
workflowsStore.activeNode = () => null;
const assistantStore = useAssistantStore();
setAssistantEnabled(true);
expect(assistantStore.canShowAssistantButtonsOnCanvas).toBe(true);
expect(assistantStore.hideAssistantFloatingButton).toBe(true);
});
});
it('should initialize assistant chat session on node error', async () => {
const context: ChatRequest.ErrorContext = {
error: {

View File

@@ -113,6 +113,11 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
const canShowAssistantButtonsOnCanvas = computed(
() => isAssistantEnabled.value && EDITABLE_CANVAS_VIEWS.includes(route.name as VIEWS),
);
const hideAssistantFloatingButton = computed(
() =>
(route.name === VIEWS.WORKFLOW || route.name === VIEWS.NEW_WORKFLOW) &&
!workflowsStore.activeNode(),
);
const unreadCount = computed(
() =>
@@ -161,6 +166,14 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
}, ASK_AI_SLIDE_OUT_DURATION_MS + 50);
}
function toggleChatOpen() {
if (chatWindowOpen.value) {
closeChat();
} else {
openChat();
}
}
function addAssistantMessages(newMessages: ChatRequest.MessageResponse[], id: string) {
const read = chatWindowOpen.value;
const messages = [...chatMessages.value].filter(
@@ -814,6 +827,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
return {
isAssistantEnabled,
canShowAssistantButtonsOnCanvas,
hideAssistantFloatingButton,
chatWidth,
chatMessages,
unreadCount,
@@ -827,6 +841,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
trackUserOpenedAssistant,
closeChat,
openChat,
toggleChatOpen,
updateWindowWidth,
isNodeErrorActive,
initErrorHelper,