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

@@ -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,