fix: Chat side panel open/close behaviour and builder state accuracy (no-changelog) (#20525)

This commit is contained in:
Michael Drury 2025-10-09 10:53:22 +01:00 committed by GitHub
parent f4963a7c64
commit ac3efc5685
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 803 additions and 289 deletions

View File

@ -24,6 +24,8 @@ export const STORES = {
COLLABORATION: 'collaboration',
ASSISTANT: 'assistant',
BUILDER: 'builder',
CHAT_PANEL: 'chatPanel',
CHAT_PANEL_STATE: 'chatPanelState',
BECOME_TEMPLATE_CREATOR: 'becomeTemplateCreator',
PROJECTS: 'projects',
API_KEYS: 'apiKeys',

View File

@ -15,8 +15,8 @@ import {
HIRING_BANNER,
VIEWS,
} from '@/constants';
import { useChatPanelStore } from '@/features/assistant/chatPanel.store';
import { useAssistantStore } from '@/features/assistant/assistant.store';
import { useBuilderStore } from '@/stores/builder.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
@ -38,7 +38,7 @@ import { hasPermission } from './utils/rbac/permissions';
const route = useRoute();
const rootStore = useRootStore();
const assistantStore = useAssistantStore();
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
@ -71,8 +71,7 @@ const isDemoMode = computed(() => route.name === VIEWS.DEMO);
const hasContentFooter = ref(false);
const appGrid = ref<Element | null>(null);
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
const builderSidebarWidth = computed(() => builderStore.chatWidth);
const chatPanelWidth = computed(() => chatPanelStore.width);
useTelemetryContext({ ndv_source: computed(() => ndvStore.lastSetActiveNodeSource) });
@ -107,8 +106,8 @@ const updateGridWidth = async () => {
uiStore.appGridDimensions = { width, height };
}
};
// As assistant sidebar width changes, recalculate the total width regularly
watch([assistantSidebarWidth, builderSidebarWidth], async () => {
// As chat panel width changes, recalculate the total width regularly
watch(chatPanelWidth, async () => {
await updateGridWidth();
});
@ -211,6 +210,7 @@ useExposeCssVar('--ask-assistant-floating-button-bottom-offset', askAiFloatingBu
grid-area: banners;
z-index: var(--z-index-top-banners);
}
.content {
display: flex;
flex-direction: column;
@ -230,6 +230,7 @@ useExposeCssVar('--ask-assistant-floating-button-bottom-offset', askAiFloatingBu
display: block;
}
}
.contentWrapper {
display: flex;
grid-area: content;

View File

@ -30,6 +30,7 @@ import CopyInput from '../CopyInput.vue';
import CredentialInputs from './CredentialInputs.vue';
import GoogleAuthButton from './GoogleAuthButton.vue';
import OauthButton from './OauthButton.vue';
import { useChatPanelStore } from '@/features/assistant/chatPanel.store';
import { useAssistantStore } from '@/features/assistant/assistant.store';
import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue';
@ -41,6 +42,7 @@ import {
N8nNotice,
N8nText,
} from '@n8n/design-system';
type Props = {
mode: string;
credentialType: ICredentialType;
@ -82,6 +84,7 @@ const rootStore = useRootStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const assistantStore = useAssistantStore();
const chatPanelStore = useChatPanelStore();
const i18n = useI18n();
const telemetry = useTelemetry();
@ -223,7 +226,7 @@ async function onAskAssistantClick() {
});
return;
}
await assistantStore.initCredHelp(props.credentialType);
await chatPanelStore.openWithCredHelp(props.credentialType);
}
watch(showOAuthSuccessBanner, (newValue, oldValue) => {
@ -414,6 +417,7 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
.askAssistantButton {
display: flex;
align-items: center;
> span {
margin-left: var(--spacing-3xs);
font-size: var(--font-size-s);

View File

@ -20,6 +20,7 @@ import type {
import { sanitizeHtml } from '@/utils/htmlUtils';
import { MAX_DISPLAY_DATA_SIZE, NEW_ASSISTANT_SESSION_MODAL, VIEWS } from '@/constants';
import type { BaseTextKey } from '@n8n/i18n';
import { useChatPanelStore } from '@/features/assistant/chatPanel.store';
import { useAssistantStore } from '@/features/assistant/assistant.store';
import type { ChatRequest } from '@/features/assistant/assistant.types';
import { useUIStore } from '@/stores/ui.store';
@ -32,6 +33,7 @@ import {
N8nIconButton,
N8nTooltip,
} from '@n8n/design-system';
type Props = {
// TODO: .node can be undefined
error: NodeError | NodeApiError | NodeOperationError;
@ -52,6 +54,7 @@ const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const rootStore = useRootStore();
const assistantStore = useAssistantStore();
const chatPanelStore = useChatPanelStore();
const uiStore = useUIStore();
const workflowId = computed(() => workflowsStore.workflowId);
@ -444,7 +447,7 @@ async function onAskAssistantClick() {
});
return;
}
await assistantStore.initErrorHelper(errorHelp);
await chatPanelStore.openWithErrorHelper(errorHelp);
assistantStore.trackUserOpenedAssistant({
source: 'error',
task: 'error',
@ -754,6 +757,7 @@ async function onAskAssistantClick() {
margin-bottom: var(--spacing-xs);
margin-top: var(--spacing-xs);
flex-direction: row-reverse;
span {
margin-right: var(--spacing-5xs);
margin-left: var(--spacing-5xs);
@ -776,6 +780,7 @@ async function onAskAssistantClick() {
width: 100%;
overflow: auto;
background: var(--color-background-light);
code {
font-size: var(--font-size-s);
}
@ -797,6 +802,7 @@ async function onAskAssistantClick() {
align-items: center;
justify-content: center;
cursor: pointer;
&:hover {
color: var(--color-primary);
}

View File

@ -20,7 +20,8 @@ import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useAssistantStore } from '@/features/assistant/assistant.store';
import { useBuilderStore } from '@/stores/builder.store';
import { useBuilderStore } from '@/features/assistant/builder.store';
import { useChatPanelStore } from '@/features/assistant/chatPanel.store';
import { N8nAssistantIcon, N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system';
@ -51,6 +52,7 @@ const i18n = useI18n();
const telemetry = useTelemetry();
const assistantStore = useAssistantStore();
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
const { getAddedNodesAndConnections } = useActions();
@ -101,11 +103,11 @@ function toggleFocusPanel() {
async function onAskAssistantButtonClick() {
if (builderStore.isAIBuilderEnabled) {
await builderStore.toggleChat();
await chatPanelStore.toggle({ mode: 'builder' });
} else {
assistantStore.toggleChat();
await chatPanelStore.toggle({ mode: 'assistant' });
}
if (builderStore.isAssistantOpen || assistantStore.isAssistantOpen) {
if (chatPanelStore.isOpen) {
assistantStore.trackUserOpenedAssistant({
source: 'canvas',
task: 'placeholder',

View File

@ -12,8 +12,7 @@ import NodesListPanel from './Panel/NodesListPanel.vue';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useUIStore } from '@/stores/ui.store';
import { DRAG_EVENT_DATA_KEY } from '@/constants';
import { useAssistantStore } from '@/features/assistant/assistant.store';
import { useBuilderStore } from '@/stores/builder.store';
import { useChatPanelStore } from '@/features/assistant/chatPanel.store';
import type { NodeTypeSelectedPayload } from '@/Interface';
import { onClickOutside } from '@vueuse/core';
@ -37,8 +36,7 @@ const emit = defineEmits<{
nodeTypeSelected: [value: NodeTypeSelectedPayload[]];
}>();
const uiStore = useUIStore();
const assistantStore = useAssistantStore();
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
const { setShowScrim, setActions, setMergeNodes } = useNodeCreatorStore();
const { generateMergedNodesAndActions } = useActionsGenerator();
@ -58,11 +56,8 @@ const nodeCreatorInlineStyle = computed(() => {
});
function getRightOffset() {
if (assistantStore.isAssistantOpen) {
return assistantStore.chatWidth;
}
if (builderStore.isAssistantOpen) {
return builderStore.chatWidth;
if (chatPanelStore.isOpen) {
return chatPanelStore.width;
}
return 0;

View File

@ -1,15 +1,20 @@
<script setup lang="ts">
import { NODE_CREATOR_OPEN_SOURCES } from '@/constants';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useBuilderStore } from '@/stores/builder.store';
import { useChatPanelStore } from '@/features/assistant/chatPanel.store';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { N8nIcon } from '@n8n/design-system';
const nodeCreatorStore = useNodeCreatorStore();
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
const i18n = useI18n();
const isChatWindowOpen = computed(
() => chatPanelStore.isOpen && chatPanelStore.isBuilderModeActive,
);
const onAddFirstStepClick = () => {
if (nodeCreatorStore.isCreateNodeActive) {
nodeCreatorStore.isCreateNodeActive = false;
@ -21,7 +26,7 @@ const onAddFirstStepClick = () => {
};
async function onBuildWithAIClick() {
await builderStore.toggleChat();
await chatPanelStore.toggle({ mode: 'builder' });
}
</script>
@ -55,12 +60,7 @@ async function onBuildWithAIClick() {
<!-- Build with AI Button -->
<div :class="$style.option">
<div
:class="[
$style.selectedButtonHighlight,
{ [$style.highlighted]: builderStore.isAssistantOpen },
]"
>
<div :class="[$style.selectedButtonHighlight, { [$style.highlighted]: isChatWindowOpen }]">
<button
:class="[$style.button]"
data-test-id="canvas-build-with-ai-button"

View File

@ -1,8 +1,8 @@
import { builderCreditsUpdated } from './builderCreditsUpdated';
import type { BuilderCreditsPushMessage } from '@n8n/api-types/push/builder-credits';
import { useBuilderStore } from '@/stores/builder.store';
import { useBuilderStore } from '@/features/assistant/builder.store';
vi.mock('@/stores/builder.store', () => ({
vi.mock('@/features/assistant/builder.store', () => ({
useBuilderStore: vi.fn(),
}));

View File

@ -1,5 +1,5 @@
import type { BuilderCreditsPushMessage } from '@n8n/api-types/push/builder-credits';
import { useBuilderStore } from '@/stores/builder.store';
import { useBuilderStore } from '@/features/assistant/builder.store';
export async function builderCreditsUpdated(event: BuilderCreditsPushMessage): Promise<void> {
const builderStore = useBuilderStore();

View File

@ -1,12 +1,15 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import {
useChatPanelStore,
DEFAULT_CHAT_WIDTH,
ENABLED_VIEWS,
MAX_CHAT_WIDTH,
MIN_CHAT_WIDTH,
useAssistantStore,
} from '@/features/assistant/assistant.store';
} from './chatPanel.store';
import { ASSISTANT_ENABLED_VIEWS } from './constants';
const ENABLED_VIEWS = ASSISTANT_ENABLED_VIEWS;
import { useAssistantStore } from '@/features/assistant/assistant.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ChatRequest } from '@/features/assistant/assistant.types';
import { usePostHog } from '@/stores/posthog.store';
@ -79,46 +82,47 @@ describe('AI Assistant store', () => {
it('initializes with default values', () => {
const assistantStore = useAssistantStore();
const chatPanelStore = useChatPanelStore();
expect(assistantStore.chatWidth).toBe(DEFAULT_CHAT_WIDTH);
expect(chatPanelStore.width).toBe(DEFAULT_CHAT_WIDTH);
expect(assistantStore.chatMessages).toEqual([]);
expect(assistantStore.chatWindowOpen).toBe(false);
expect(chatPanelStore.isOpen).toBe(false);
expect(assistantStore.streaming).toBeUndefined();
});
it('can change chat width', () => {
const assistantStore = useAssistantStore();
const chatPanelStore = useChatPanelStore();
assistantStore.updateWindowWidth(400);
expect(assistantStore.chatWidth).toBe(400);
chatPanelStore.updateWidth(400);
expect(chatPanelStore.width).toBe(400);
});
it('should not allow chat width to be less than the minimal width', () => {
const assistantStore = useAssistantStore();
const chatPanelStore = useChatPanelStore();
assistantStore.updateWindowWidth(100);
expect(assistantStore.chatWidth).toBe(MIN_CHAT_WIDTH);
chatPanelStore.updateWidth(100);
expect(chatPanelStore.width).toBe(MIN_CHAT_WIDTH);
});
it('should not allow chat width to be more than the maximal width', () => {
const assistantStore = useAssistantStore();
const chatPanelStore = useChatPanelStore();
assistantStore.updateWindowWidth(2000);
expect(assistantStore.chatWidth).toBe(MAX_CHAT_WIDTH);
chatPanelStore.updateWidth(2000);
expect(chatPanelStore.width).toBe(MAX_CHAT_WIDTH);
});
it('should open chat window', () => {
const assistantStore = useAssistantStore();
it('should open chat window', async () => {
const chatPanelStore = useChatPanelStore();
assistantStore.openChat();
expect(assistantStore.chatWindowOpen).toBe(true);
await chatPanelStore.open({ mode: 'assistant' });
expect(chatPanelStore.isOpen).toBe(true);
});
it('should close chat window', () => {
const assistantStore = useAssistantStore();
const chatPanelStore = useChatPanelStore();
assistantStore.closeChat();
expect(assistantStore.chatWindowOpen).toBe(false);
chatPanelStore.close();
expect(chatPanelStore.isOpen).toBe(false);
});
it('can add a simple assistant message', () => {

View File

@ -1,11 +1,11 @@
import { chatWithAssistant, replaceCode } from '@/api/ai';
import {
VIEWS,
EDITABLE_CANVAS_VIEWS,
type VIEWS,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
CREDENTIAL_EDIT_MODAL_KEY,
ASK_AI_SLIDE_OUT_DURATION_MS,
EDITABLE_CANVAS_VIEWS,
} from '@/constants';
import { ASSISTANT_ENABLED_VIEWS } from './constants';
import { STORES } from '@n8n/stores';
import type { ChatRequest } from '@/features/assistant/assistant.types';
import type { ChatUI } from '@n8n/design-system/types/assistant';
@ -28,31 +28,18 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
import AiUpdatedCodeMessage from '@/components/AiUpdatedCodeMessage.vue';
import { useChatPanelStateStore } from './chatPanelState.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useAIAssistantHelpers } from '@/features/assistant/composables/useAIAssistantHelpers';
export const MAX_CHAT_WIDTH = 425;
export const MIN_CHAT_WIDTH = 380;
export const DEFAULT_CHAT_WIDTH = 400;
export const ENABLED_VIEWS = [
...EDITABLE_CANVAS_VIEWS,
VIEWS.EXECUTION_PREVIEW,
VIEWS.WORKFLOWS,
VIEWS.CREDENTIALS,
VIEWS.PROJECTS_CREDENTIALS,
VIEWS.PROJECTS_WORKFLOWS,
VIEWS.PROJECT_SETTINGS,
VIEWS.TEMPLATE_SETUP,
];
export const ENABLED_VIEWS = ASSISTANT_ENABLED_VIEWS;
const READABLE_TYPES = ['code-diff', 'text', 'block'];
export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
const chatWidth = ref<number>(DEFAULT_CHAT_WIDTH);
const settings = useSettingsStore();
const rootStore = useRootStore();
const chatPanelStateStore = useChatPanelStateStore();
const chatMessages = ref<ChatUI.AssistantMessage[]>([]);
const chatWindowOpen = ref<boolean>(false);
const usersStore = useUsersStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
@ -102,7 +89,12 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
return !sessionStarted || sessionExplicitlyEnded;
});
const isAssistantOpen = computed(() => canShowAssistant.value && chatWindowOpen.value);
const isAssistantOpen = computed(
() =>
canShowAssistant.value &&
chatPanelStateStore.isOpen &&
chatPanelStateStore.activeMode === 'assistant',
);
const isAssistantEnabled = computed(() => settings.isAiAssistantEnabled);
@ -144,43 +136,8 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
currentSessionWorkflowId.value = workflowsStore.workflowId;
}
// As assistant sidebar opens and closes, use window width to calculate the container width
// This will prevent animation race conditions from making ndv twitchy
function openChat() {
chatWindowOpen.value = true;
chatMessages.value = chatMessages.value.map((msg) => ({ ...msg, read: true }));
uiStore.appGridDimensions = {
...uiStore.appGridDimensions,
width: window.innerWidth - chatWidth.value,
};
}
function closeChat() {
chatWindowOpen.value = false;
// Looks smoother if we wait for slide animation to finish before updating the grid width
// Has to wait for longer than SlideTransition duration
setTimeout(() => {
uiStore.appGridDimensions = {
...uiStore.appGridDimensions,
width: window.innerWidth,
};
// If session has ended, reset the chat
if (isSessionEnded.value) {
resetAssistantChat();
}
}, ASK_AI_SLIDE_OUT_DURATION_MS + 50);
}
function toggleChat() {
if (isAssistantOpen.value) {
closeChat();
} else {
openChat();
}
}
function addAssistantMessages(newMessages: ChatRequest.MessageResponse[], id: string) {
const read = chatWindowOpen.value;
const read = chatPanelStateStore.isOpen && chatPanelStateStore.activeMode === 'assistant';
const messages = [...chatMessages.value].filter(
(msg) => !(msg.id === id && msg.role === 'assistant'),
);
@ -242,10 +199,6 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
chatMessages.value = messages;
}
function updateWindowWidth(width: number) {
chatWidth.value = Math.min(Math.max(width, MIN_CHAT_WIDTH), MAX_CHAT_WIDTH);
}
function isNodeErrorActive(context: ChatRequest.ErrorContext) {
const targetNode = context.node.name;
@ -439,7 +392,6 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
chatSessionCredType.value = credentialType;
addUserMessage(userMessage, id);
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
openChat();
streaming.value = true;
let payload: ChatRequest.InitSupportChat | ChatRequest.InitCredHelp = {
@ -497,8 +449,6 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
);
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.analyzingError'));
openChat();
streaming.value = true;
const payload: ChatRequest.RequestPayload['payload'] = {
role: 'user',
@ -833,7 +783,6 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
isAssistantEnabled,
canShowAssistantButtonsOnCanvas,
hideAssistantFloatingButton,
chatWidth,
chatMessages,
unreadCount,
streaming,
@ -845,10 +794,6 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
isFloatingButtonShown,
onNodeExecution,
trackUserOpenedAssistant,
openChat,
closeChat,
toggleChat,
updateWindowWidth,
isNodeErrorActive,
initErrorHelper,
initSupportChat,
@ -856,7 +801,6 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
applyCodeDiff,
undoCodeDiff,
resetAssistantChat,
chatWindowOpen,
addAssistantMessages,
assistantThinkingMessage,
chatSessionError,

View File

@ -4,12 +4,21 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { ENABLED_VIEWS, useBuilderStore } from '@/stores/builder.store';
import { usePostHog } from './posthog.store';
import { useBuilderStore } from './builder.store';
import {
useChatPanelStore,
DEFAULT_CHAT_WIDTH,
MAX_CHAT_WIDTH,
MIN_CHAT_WIDTH,
} from './chatPanel.store';
import { BUILDER_ENABLED_VIEWS } from './constants';
const ENABLED_VIEWS = BUILDER_ENABLED_VIEWS;
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
import { defaultSettings } from '../__tests__/defaults';
import { defaultSettings } from '@/__tests__/defaults';
import merge from 'lodash/merge';
import { DEFAULT_POSTHOG_SETTINGS } from './posthog.store.test';
import { DEFAULT_POSTHOG_SETTINGS } from '@/stores/posthog.store.test';
import {
WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT,
WORKFLOW_BUILDER_RELEASE_EXPERIMENT,
@ -25,13 +34,7 @@ import {
} from '@/composables/useWorkflowState';
import type { Telemetry } from '@/plugins/telemetry';
import type { ChatUI } from '@n8n/design-system/types/assistant';
import {
DEFAULT_CHAT_WIDTH,
MAX_CHAT_WIDTH,
MIN_CHAT_WIDTH,
} from '@/features/assistant/assistant.store';
import { type INodeTypeDescription } from 'n8n-workflow';
import type {} from 'n8n-workflow';
import { mockedStore } from '@/__tests__/utils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@ -166,46 +169,47 @@ describe('AI Builder store', () => {
it('initializes with default values', () => {
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
expect(builderStore.chatWidth).toBe(DEFAULT_CHAT_WIDTH);
expect(chatPanelStore.width).toBe(DEFAULT_CHAT_WIDTH);
expect(builderStore.chatMessages).toEqual([]);
expect(builderStore.chatWindowOpen).toBe(false);
expect(chatPanelStore.isOpen).toBe(false);
expect(builderStore.streaming).toBe(false);
});
it('can change chat width', () => {
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
builderStore.updateWindowWidth(400);
expect(builderStore.chatWidth).toBe(400);
chatPanelStore.updateWidth(400);
expect(chatPanelStore.width).toBe(400);
});
it('should not allow chat width to be less than the minimal width', () => {
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
builderStore.updateWindowWidth(100);
expect(builderStore.chatWidth).toBe(MIN_CHAT_WIDTH);
chatPanelStore.updateWidth(100);
expect(chatPanelStore.width).toBe(MIN_CHAT_WIDTH);
});
it('should not allow chat width to be more than the maximal width', () => {
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
builderStore.updateWindowWidth(2000);
expect(builderStore.chatWidth).toBe(MAX_CHAT_WIDTH);
chatPanelStore.updateWidth(2000);
expect(chatPanelStore.width).toBe(MAX_CHAT_WIDTH);
});
it('should open chat window', async () => {
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
await builderStore.openChat();
expect(builderStore.chatWindowOpen).toBe(true);
await chatPanelStore.open({ mode: 'builder' });
expect(chatPanelStore.isOpen).toBe(true);
});
it('should close chat window', () => {
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
builderStore.closeChat();
expect(builderStore.chatWindowOpen).toBe(false);
chatPanelStore.close();
expect(chatPanelStore.isOpen).toBe(false);
});
it('can process a simple assistant message through API', async () => {
@ -1550,6 +1554,7 @@ describe('AI Builder store', () => {
it('should call fetchBuilderCredits when opening chat', async () => {
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
// Mock posthog to return variant for release experiment
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
@ -1568,7 +1573,7 @@ describe('AI Builder store', () => {
// Mock loadSessions to prevent actual API call
vi.spyOn(chatAPI, 'getAiSessions').mockResolvedValueOnce({ sessions: [] });
await builderStore.openChat();
await chatPanelStore.open({ mode: 'builder' });
expect(mockGetBuilderCredits).toHaveBeenCalled();
expect(builderStore.creditsQuota).toBe(100);

View File

@ -1,29 +1,23 @@
import type { VIEWS } from '@/constants';
import {
DEFAULT_NEW_WORKFLOW_NAME,
ASK_AI_SLIDE_OUT_DURATION_MS,
EDITABLE_CANVAS_VIEWS,
WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT,
WORKFLOW_BUILDER_RELEASE_EXPERIMENT,
EDITABLE_CANVAS_VIEWS,
} from '@/constants';
import { BUILDER_ENABLED_VIEWS } from './constants';
import { STORES } from '@n8n/stores';
import type { ChatUI } from '@n8n/design-system/types/assistant';
import { isToolMessage, isWorkflowUpdatedMessage } from '@n8n/design-system/types/assistant';
import { defineStore } from 'pinia';
import { computed, watch, ref } from 'vue';
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useSettingsStore } from './settings.store';
import { useSettingsStore } from '@/stores/settings.store';
import { assert } from '@n8n/utils/assert';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useUIStore } from './ui.store';
import { usePostHog } from './posthog.store';
import {
DEFAULT_CHAT_WIDTH,
MAX_CHAT_WIDTH,
MIN_CHAT_WIDTH,
} from '@/features/assistant/assistant.store';
import { useWorkflowsStore } from './workflows.store';
import { usePostHog } from '@/stores/posthog.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useBuilderMessages } from '@/composables/useBuilderMessages';
import { chatWithBuilder, getAiSessions, getBuilderCredits } from '@/api/ai';
import { generateMessageId, createBuilderPayload } from '@/helpers/builderHelpers';
@ -33,19 +27,18 @@ import pick from 'lodash/pick';
import { jsonParse } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { injectWorkflowState } from '@/composables/useWorkflowState';
import { useNodeTypesStore } from './nodeTypes.store';
import { useCredentialsStore } from './credentials.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { getAuthTypeForNodeCredential, getMainAuthField } from '@/utils/nodeTypesUtils';
import { stringSizeInBytes } from '@/utils/typesUtils';
import { useChatPanelStateStore } from './chatPanelState.store';
const INFINITE_CREDITS = -1;
export const ENABLED_VIEWS = [...EDITABLE_CANVAS_VIEWS];
export const ENABLED_VIEWS = BUILDER_ENABLED_VIEWS;
export const useBuilderStore = defineStore(STORES.BUILDER, () => {
// Core state
const chatWidth = ref<number>(DEFAULT_CHAT_WIDTH);
const chatMessages = ref<ChatUI.AssistantMessage[]>([]);
const chatWindowOpen = ref<boolean>(false);
const streaming = ref<boolean>(false);
const assistantThinkingMessage = ref<string | undefined>();
const streamingAbortController = ref<AbortController | null>(null);
@ -54,11 +47,11 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
const creditsClaimed = ref<number | undefined>();
// Store dependencies
const chatPanelStateStore = useChatPanelStateStore();
const settings = useSettingsStore();
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
const workflowState = injectWorkflowState();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
@ -99,7 +92,12 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
() => isAssistantEnabled.value && EDITABLE_CANVAS_VIEWS.includes(route.name as VIEWS),
);
const isAssistantOpen = computed(() => canShowAssistant.value && chatWindowOpen.value);
const isAssistantOpen = computed(
() =>
canShowAssistant.value &&
chatPanelStateStore.isOpen &&
chatPanelStateStore.activeMode === 'builder',
);
const isAIBuilderEnabled = computed(() => {
// Check license first
@ -160,62 +158,6 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
initialGeneration.value = false;
}
/**
* Opens the chat panel and adjusts the canvas viewport to make room.
*/
async function openChat() {
chatMessages.value = [];
await fetchBuilderCredits();
await loadSessions();
uiStore.appGridDimensions = {
...uiStore.appGridDimensions,
width: window.innerWidth - chatWidth.value,
};
chatWindowOpen.value = true;
}
/**
* Closes the chat panel with a delayed viewport restoration.
* The delay (ASK_AI_SLIDE_OUT_DURATION_MS + 50ms) ensures the slide-out animation
* completes before expanding the canvas, preventing visual jarring.
* Messages remain in memory.
*/
function closeChat() {
chatWindowOpen.value = false;
// Looks smoother if we wait for slide animation to finish before updating the grid width
// Has to wait for longer than SlideTransition duration
setTimeout(() => {
if (!window) {
return; // for unit testing
}
uiStore.appGridDimensions = {
...uiStore.appGridDimensions,
width: window.innerWidth,
};
}, ASK_AI_SLIDE_OUT_DURATION_MS + 50);
}
/**
* Toggles between open and closed state for the chat panel.
*/
async function toggleChat() {
if (isAssistantOpen.value) {
closeChat();
} else {
await openChat();
}
}
/**
* Updates chat panel width with enforced boundaries.
* Width is clamped between MIN_CHAT_WIDTH (330px) and MAX_CHAT_WIDTH (650px)
* to ensure usability on various screen sizes.
*/
function updateWindowWidth(width: number) {
chatWidth.value = Math.min(Math.max(width, MIN_CHAT_WIDTH), MAX_CHAT_WIDTH);
}
// Message handling functions
function addLoadingAssistantMessage(message: string) {
assistantThinkingMessage.value = message;
@ -570,6 +512,11 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
return JSON.stringify(pick(workflowsStore.workflow, ['nodes', 'connections']));
}
function updateBuilderCredits(quota?: number, claimed?: number) {
creditsQuota.value = quota;
creditsClaimed.value = claimed;
}
async function fetchBuilderCredits() {
const releaseExperimentVariant = posthogStore.getVariant(
WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name,
@ -581,39 +528,21 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
try {
const response = await getBuilderCredits(rootStore.restApiContext);
updateBuilderCredits(response.creditsQuota, response.creditsClaimed);
} catch (error) {
} catch {
// Keep default values on error
}
}
function updateBuilderCredits(quota?: number, claimed?: number) {
creditsQuota.value = quota;
creditsClaimed.value = claimed;
}
// Watch for route changes and close chat when leaving enabled views
watch(
() => route.name,
(newRoute) => {
// Close the chat window when navigating away from canvas/enabled views
if (!ENABLED_VIEWS.includes(newRoute as VIEWS) && chatWindowOpen.value) {
chatWindowOpen.value = false;
}
},
);
// Public API
return {
// State
isAssistantEnabled,
canShowAssistantButtonsOnCanvas,
chatWidth,
chatMessages,
streaming,
isAssistantOpen,
canShowAssistant,
assistantThinkingMessage,
chatWindowOpen,
isAIBuilderEnabled,
workflowPrompt,
toolMessages,
@ -627,11 +556,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
hasNoCreditsRemaining,
// Methods
updateWindowWidth,
stopStreaming,
closeChat,
openChat,
toggleChat,
resetBuilderChat,
sendChatMessage,
loadSessions,

View File

@ -0,0 +1,362 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import {
useChatPanelStore,
DEFAULT_CHAT_WIDTH,
MIN_CHAT_WIDTH,
MAX_CHAT_WIDTH,
} from './chatPanel.store';
import { useChatPanelStateStore } from './chatPanelState.store';
import { useUIStore } from '@/stores/ui.store';
import { useAssistantStore } from '@/features/assistant/assistant.store';
import { useBuilderStore } from './builder.store';
import { ASSISTANT_ENABLED_VIEWS, BUILDER_ENABLED_VIEWS } from './constants';
import { VIEWS } from '@/constants';
import { reactive } from 'vue';
import { mockedStore } from '@/__tests__/utils';
import type { ICredentialType } from 'n8n-workflow';
import type { ChatRequest } from '@/features/assistant/assistant.types';
import type { ChatUI } from '@n8n/design-system/types/assistant';
// Mock vue-router
const mockRoute = reactive({ name: VIEWS.WORKFLOW });
vi.mock('vue-router', () => ({
useRoute: () => mockRoute,
}));
// Mock window.innerWidth
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1920,
});
describe('chatPanel.store', () => {
let chatPanelStore: ReturnType<typeof useChatPanelStore>;
let chatPanelStateStore: ReturnType<typeof useChatPanelStateStore>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
let assistantStore: ReturnType<typeof mockedStore<typeof useAssistantStore>>;
let builderStore: ReturnType<typeof mockedStore<typeof useBuilderStore>>;
beforeEach(() => {
vi.clearAllTimers();
vi.useFakeTimers();
setActivePinia(
createTestingPinia({
createSpy: vi.fn,
stubActions: false, // Don't stub actions so actual store logic runs
}),
);
chatPanelStateStore = useChatPanelStateStore();
uiStore = mockedStore(useUIStore);
assistantStore = mockedStore(useAssistantStore);
builderStore = mockedStore(useBuilderStore);
// Reset to default route
mockRoute.name = VIEWS.WORKFLOW;
// Mock store methods
assistantStore.initCredHelp = vi.fn().mockResolvedValue(undefined);
assistantStore.initErrorHelper = vi.fn().mockResolvedValue(undefined);
assistantStore.resetAssistantChat = vi.fn();
assistantStore.isSessionEnded = false;
assistantStore.chatMessages = [];
builderStore.fetchBuilderCredits = vi.fn().mockResolvedValue(undefined);
builderStore.loadSessions = vi.fn().mockResolvedValue(undefined);
builderStore.resetBuilderChat = vi.fn();
builderStore.chatMessages = [];
uiStore.appGridDimensions = { width: 1920, height: 1080 };
// Initialize chatPanelStore after all mocks are set up
chatPanelStore = useChatPanelStore();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('initialization', () => {
it('should initialize with correct default values', () => {
expect(chatPanelStore.isOpen).toBe(false);
expect(chatPanelStore.width).toBe(DEFAULT_CHAT_WIDTH);
expect(chatPanelStore.activeMode).toBe('builder');
expect(chatPanelStore.isAssistantModeActive).toBe(false);
expect(chatPanelStore.isBuilderModeActive).toBe(true);
});
it('should expose constants', () => {
expect(chatPanelStore.DEFAULT_CHAT_WIDTH).toBe(DEFAULT_CHAT_WIDTH);
expect(chatPanelStore.MIN_CHAT_WIDTH).toBe(MIN_CHAT_WIDTH);
expect(chatPanelStore.MAX_CHAT_WIDTH).toBe(MAX_CHAT_WIDTH);
});
});
describe('open', () => {
it('should open panel in builder mode', async () => {
mockRoute.name = BUILDER_ENABLED_VIEWS[0];
await chatPanelStore.open({ mode: 'builder' });
expect(chatPanelStateStore.isOpen).toBe(true);
expect(chatPanelStateStore.activeMode).toBe('builder');
expect(builderStore.fetchBuilderCredits).toHaveBeenCalled();
expect(builderStore.loadSessions).toHaveBeenCalled();
expect(uiStore.appGridDimensions.width).toBe(window.innerWidth - DEFAULT_CHAT_WIDTH);
});
it('should open panel in assistant mode', async () => {
mockRoute.name = ASSISTANT_ENABLED_VIEWS[0];
assistantStore.chatMessages = [
{
id: '1',
role: 'user',
type: 'text',
content: 'test',
read: false,
} as ChatUI.AssistantMessage,
];
await chatPanelStore.open({ mode: 'assistant' });
expect(chatPanelStateStore.isOpen).toBe(true);
expect(chatPanelStateStore.activeMode).toBe('assistant');
expect(assistantStore.chatMessages[0].read).toBe(true);
});
it('should close panel if mode is not enabled in current view', async () => {
mockRoute.name = VIEWS.HOMEPAGE; // Not in BUILDER_ENABLED_VIEWS
await chatPanelStore.open({ mode: 'builder' });
expect(chatPanelStateStore.isOpen).toBe(false);
});
it('should not change mode if no mode option provided', async () => {
mockRoute.name = BUILDER_ENABLED_VIEWS[0];
chatPanelStateStore.activeMode = 'builder';
await chatPanelStore.open();
expect(chatPanelStateStore.activeMode).toBe('builder');
});
});
describe('close', () => {
beforeEach(async () => {
mockRoute.name = BUILDER_ENABLED_VIEWS[0];
await chatPanelStore.open({ mode: 'builder' });
});
it('should close panel immediately', () => {
chatPanelStore.close();
expect(chatPanelStateStore.isOpen).toBe(false);
});
it('should reset UI grid dimensions after timeout', () => {
chatPanelStore.close();
vi.runAllTimers();
expect(uiStore.appGridDimensions.width).toBe(window.innerWidth);
});
it('should reset builder chat after timeout', () => {
chatPanelStore.close();
vi.runAllTimers();
expect(builderStore.resetBuilderChat).toHaveBeenCalled();
});
it('should reset assistant chat only if session ended', () => {
assistantStore.isSessionEnded = true;
chatPanelStore.close();
vi.runAllTimers();
expect(assistantStore.resetAssistantChat).toHaveBeenCalled();
});
it('should not reset assistant chat if session not ended', () => {
assistantStore.isSessionEnded = false;
chatPanelStore.close();
vi.runAllTimers();
expect(assistantStore.resetAssistantChat).not.toHaveBeenCalled();
});
});
describe('toggle', () => {
it('should open panel when closed', async () => {
mockRoute.name = BUILDER_ENABLED_VIEWS[0];
expect(chatPanelStateStore.isOpen).toBe(false);
await chatPanelStore.toggle({ mode: 'builder' });
expect(chatPanelStateStore.isOpen).toBe(true);
});
it('should close panel when open', async () => {
mockRoute.name = BUILDER_ENABLED_VIEWS[0];
await chatPanelStore.open({ mode: 'builder' });
expect(chatPanelStateStore.isOpen).toBe(true);
await chatPanelStore.toggle();
expect(chatPanelStateStore.isOpen).toBe(false);
});
});
describe('switchMode', () => {
beforeEach(async () => {
mockRoute.name = ASSISTANT_ENABLED_VIEWS[0]; // Supports both modes
await chatPanelStore.open({ mode: 'builder' });
});
it('should switch from builder to assistant mode', () => {
chatPanelStore.switchMode('assistant');
expect(chatPanelStateStore.activeMode).toBe('assistant');
expect(chatPanelStore.isAssistantModeActive).toBe(true);
expect(chatPanelStore.isBuilderModeActive).toBe(false);
});
it('should switch from assistant to builder mode', () => {
chatPanelStateStore.activeMode = 'assistant';
chatPanelStore.switchMode('builder');
expect(chatPanelStateStore.activeMode).toBe('builder');
expect(chatPanelStore.isAssistantModeActive).toBe(false);
expect(chatPanelStore.isBuilderModeActive).toBe(true);
});
it('should close panel if mode not enabled in current view', () => {
mockRoute.name = VIEWS.HOMEPAGE; // Only supports assistant
chatPanelStore.switchMode('builder');
expect(chatPanelStateStore.isOpen).toBe(false);
});
});
describe('updateWidth', () => {
it('should update width within bounds', () => {
chatPanelStore.updateWidth(410);
expect(chatPanelStateStore.width).toBe(410);
});
it('should clamp width to MIN_CHAT_WIDTH', () => {
chatPanelStore.updateWidth(200);
expect(chatPanelStateStore.width).toBe(MIN_CHAT_WIDTH);
});
it('should clamp width to MAX_CHAT_WIDTH', () => {
chatPanelStore.updateWidth(500);
expect(chatPanelStateStore.width).toBe(MAX_CHAT_WIDTH);
});
it('should update UI grid dimensions when panel is open', async () => {
mockRoute.name = BUILDER_ENABLED_VIEWS[0];
await chatPanelStore.open({ mode: 'builder' });
chatPanelStore.updateWidth(420);
expect(uiStore.appGridDimensions.width).toBe(window.innerWidth - 420);
});
it('should not update UI grid dimensions when panel is closed', () => {
const initialWidth = uiStore.appGridDimensions.width;
chatPanelStore.updateWidth(420);
expect(uiStore.appGridDimensions.width).toBe(initialWidth);
});
});
describe('openWithCredHelp', () => {
it('should initialize cred help and open in assistant mode', async () => {
mockRoute.name = ASSISTANT_ENABLED_VIEWS[0];
const credType: ICredentialType = {
name: 'googleSheetsOAuth2Api',
displayName: 'Google Sheets OAuth2 API',
properties: [],
};
await chatPanelStore.openWithCredHelp(credType);
expect(assistantStore.initCredHelp).toHaveBeenCalledWith(credType);
expect(chatPanelStateStore.isOpen).toBe(true);
expect(chatPanelStateStore.activeMode).toBe('assistant');
});
});
describe('openWithErrorHelper', () => {
it('should initialize error helper and open in assistant mode', async () => {
mockRoute.name = ASSISTANT_ENABLED_VIEWS[0];
const errorContext: ChatRequest.ErrorContext = {
error: {
name: 'TestError',
message: 'test error',
},
node: {
id: 'node-1',
name: 'Test Node',
type: 'n8n-nodes-base.test',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
};
await chatPanelStore.openWithErrorHelper(errorContext);
expect(assistantStore.initErrorHelper).toHaveBeenCalledWith(errorContext);
expect(chatPanelStateStore.isOpen).toBe(true);
expect(chatPanelStateStore.activeMode).toBe('assistant');
});
});
describe('computed properties', () => {
it('should compute isAssistantModeActive correctly', () => {
chatPanelStateStore.activeMode = 'assistant';
expect(chatPanelStore.isAssistantModeActive).toBe(true);
expect(chatPanelStore.isBuilderModeActive).toBe(false);
});
it('should compute isBuilderModeActive correctly', () => {
chatPanelStateStore.activeMode = 'builder';
expect(chatPanelStore.isBuilderModeActive).toBe(true);
expect(chatPanelStore.isAssistantModeActive).toBe(false);
});
it('should expose isOpen as computed', () => {
chatPanelStateStore.isOpen = true;
expect(chatPanelStore.isOpen).toBe(true);
chatPanelStateStore.isOpen = false;
expect(chatPanelStore.isOpen).toBe(false);
});
it('should expose width as computed', () => {
chatPanelStateStore.width = 420;
expect(chatPanelStore.width).toBe(420);
});
it('should expose activeMode as computed', () => {
chatPanelStateStore.activeMode = 'assistant';
expect(chatPanelStore.activeMode).toBe('assistant');
});
});
});

View File

@ -0,0 +1,199 @@
import { computed, watch } from 'vue';
import { defineStore } from 'pinia';
import { STORES } from '@n8n/stores';
import { useUIStore } from '@/stores/ui.store';
import { useRoute } from 'vue-router';
import { ASK_AI_SLIDE_OUT_DURATION_MS } from '@/constants';
import type { VIEWS } from '@/constants';
import { ASSISTANT_ENABLED_VIEWS, BUILDER_ENABLED_VIEWS } from './constants';
import { useChatPanelStateStore, type ChatPanelMode } from './chatPanelState.store';
import { useAssistantStore } from './assistant.store';
import { useBuilderStore } from './builder.store';
import type { ICredentialType } from 'n8n-workflow';
import type { ChatRequest } from './assistant.types';
export const MAX_CHAT_WIDTH = 425;
export const MIN_CHAT_WIDTH = 380;
export const DEFAULT_CHAT_WIDTH = 400;
/**
* Type guard to check if a route name is a valid VIEWS value within the enabled views
* Performs runtime validation to safely narrow the type without unsafe assertions
*/
function isEnabledView(
route: string | symbol | undefined,
views: readonly VIEWS[],
): route is VIEWS {
return typeof route === 'string' && (views as readonly string[]).includes(route);
}
export const useChatPanelStore = defineStore(STORES.CHAT_PANEL, () => {
const uiStore = useUIStore();
const route = useRoute();
const chatPanelStateStore = useChatPanelStateStore();
// Computed
const isAssistantModeActive = computed(() => chatPanelStateStore.activeMode === 'assistant');
const isBuilderModeActive = computed(() => chatPanelStateStore.activeMode === 'builder');
// Actions
async function open(options?: { mode?: ChatPanelMode }) {
const mode = options?.mode;
if (mode) {
chatPanelStateStore.activeMode = mode;
}
// Check if the mode is enabled in the current view
const enabledViews =
chatPanelStateStore.activeMode === 'assistant'
? ASSISTANT_ENABLED_VIEWS
: BUILDER_ENABLED_VIEWS;
const currentRoute = route?.name;
if (!isEnabledView(currentRoute, enabledViews)) {
// Mode is not enabled in current view, close the panel instead
close();
return;
}
// Handle mode-specific initialization
if (chatPanelStateStore.activeMode === 'builder') {
const builderStore = useBuilderStore();
builderStore.chatMessages = [];
await builderStore.fetchBuilderCredits();
await builderStore.loadSessions();
} else if (chatPanelStateStore.activeMode === 'assistant') {
const assistantStore = useAssistantStore();
assistantStore.chatMessages = assistantStore.chatMessages.map((msg) => ({
...msg,
read: true,
}));
}
chatPanelStateStore.isOpen = true;
// Update UI grid dimensions when opening
uiStore.appGridDimensions = {
...uiStore.appGridDimensions,
width: window.innerWidth - chatPanelStateStore.width,
};
}
function close() {
chatPanelStateStore.isOpen = false;
// Wait for slide animation to finish before updating grid width and resetting
setTimeout(() => {
uiStore.appGridDimensions = {
...uiStore.appGridDimensions,
width: window.innerWidth,
};
const assistantStore = useAssistantStore();
const builderStore = useBuilderStore();
// Reset assistant only if session has ended
if (assistantStore.isSessionEnded) {
assistantStore.resetAssistantChat();
}
// Always reset builder
builderStore.resetBuilderChat();
}, ASK_AI_SLIDE_OUT_DURATION_MS + 50);
}
async function toggle(options?: { mode?: ChatPanelMode }) {
if (chatPanelStateStore.isOpen) {
close();
} else {
await open(options);
}
}
function switchMode(mode: ChatPanelMode) {
// Check if the mode is enabled in the current view
const enabledViews = mode === 'assistant' ? ASSISTANT_ENABLED_VIEWS : BUILDER_ENABLED_VIEWS;
const currentRoute = route?.name;
if (!isEnabledView(currentRoute, enabledViews)) {
// Mode is not enabled in current view, close the panel
close();
return;
}
// Switch the mode without re-initialization
chatPanelStateStore.activeMode = mode;
}
function updateWidth(newWidth: number) {
const clampedWidth = Math.min(Math.max(newWidth, MIN_CHAT_WIDTH), MAX_CHAT_WIDTH);
chatPanelStateStore.width = clampedWidth;
if (chatPanelStateStore.isOpen) {
uiStore.appGridDimensions = {
...uiStore.appGridDimensions,
width: window.innerWidth - clampedWidth,
};
}
}
/**
* Opens assistant with credential help context
*/
async function openWithCredHelp(credentialType: ICredentialType) {
const assistantStore = useAssistantStore();
await assistantStore.initCredHelp(credentialType);
await open({ mode: 'assistant' });
}
/**
* Opens assistant with error helper context
*/
async function openWithErrorHelper(context: ChatRequest.ErrorContext) {
const assistantStore = useAssistantStore();
await assistantStore.initErrorHelper(context);
await open({ mode: 'assistant' });
}
// Watch route changes and close if panel can't be shown in current view
watch(
() => route?.name,
(newRoute) => {
if (!chatPanelStateStore.isOpen || !newRoute) {
return;
}
const builderStore = useBuilderStore();
const enabledViews =
chatPanelStateStore.activeMode === 'assistant'
? ASSISTANT_ENABLED_VIEWS
: BUILDER_ENABLED_VIEWS;
if (!isEnabledView(newRoute, enabledViews)) {
close();
} else if (isEnabledView(newRoute, BUILDER_ENABLED_VIEWS)) {
// If entering an editable canvas view with builder mode active, refresh state
builderStore.resetBuilderChat();
}
},
);
return {
// State - expose from chatPanelStateStore
isOpen: computed(() => chatPanelStateStore.isOpen),
width: computed(() => chatPanelStateStore.width),
activeMode: computed(() => chatPanelStateStore.activeMode),
// Computed
isAssistantModeActive,
isBuilderModeActive,
// Actions
open,
close,
toggle,
switchMode,
updateWidth,
openWithCredHelp,
openWithErrorHelper,
// Constants
DEFAULT_CHAT_WIDTH,
MIN_CHAT_WIDTH,
MAX_CHAT_WIDTH,
};
});

View File

@ -0,0 +1,24 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { STORES } from '@n8n/stores';
export type ChatPanelMode = 'assistant' | 'builder';
export const DEFAULT_CHAT_WIDTH = 400;
/**
* Shared reactive state for chat panel that can be imported without circular dependencies.
* This is a simple store that only holds state, no actions.
* Updated by chatPanel.store.ts, read by assistant/builder stores.
*/
export const useChatPanelStateStore = defineStore(STORES.CHAT_PANEL_STATE, () => {
const isOpen = ref(false);
const width = ref(DEFAULT_CHAT_WIDTH);
const activeMode = ref<ChatPanelMode>('builder');
return {
isOpen,
width,
activeMode,
};
});

View File

@ -89,7 +89,7 @@ import { fireEvent } from '@testing-library/vue';
import { faker } from '@faker-js/faker';
import AskAssistantBuild from './AskAssistantBuild.vue';
import { useBuilderStore } from '@/stores/builder.store';
import { useBuilderStore } from '../../builder.store';
import { mockedStore } from '@/__tests__/utils';
import { STORES } from '@n8n/stores';
import { useWorkflowsStore } from '@/stores/workflows.store';

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useBuilderStore } from '@/stores/builder.store';
import { useBuilderStore } from '../../builder.store';
import { useUsersStore } from '@/stores/users.store';
import { computed, watch, ref } from 'vue';
import { useTelemetry } from '@/composables/useTelemetry';

View File

@ -13,7 +13,7 @@ import { CHAT_TRIGGER_NODE_TYPE } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useLogsStore } from '@/stores/logs.store';
import { useBuilderStore } from '@/stores/builder.store';
import { useBuilderStore } from '../../builder.store';
const workflowValidationIssuesRef = ref<
Array<{ node: string; type: string; value: string | string[] }>

View File

@ -15,7 +15,7 @@ import { isChatNode } from '@/utils/aiUtils';
import { useToast } from '@/composables/useToast';
import { N8nTooltip } from '@n8n/design-system';
import { nextTick } from 'vue';
import { useBuilderStore } from '@/stores/builder.store';
import { useBuilderStore } from '../../builder.store';
interface Emits {
/** Emitted when workflow execution completes */

View File

@ -1,30 +1,42 @@
<script lang="ts" setup>
import { useBuilderStore } from '@/stores/builder.store';
import { useBuilderStore } from '@/features/assistant/builder.store';
import { useChatPanelStore } from '@/features/assistant/chatPanel.store';
import { useAssistantStore } from '@/features/assistant/assistant.store';
import { useDebounce } from '@/composables/useDebounce';
import { computed, onBeforeUnmount, ref } from 'vue';
import SlideTransition from '@/components/transitions/SlideTransition.vue';
import AskAssistantBuild from './Agent/AskAssistantBuild.vue';
import AskAssistantChat from './Chat/AskAssistantChat.vue';
import { useRoute } from 'vue-router';
import { BUILDER_ENABLED_VIEWS } from '../constants';
import type { VIEWS } from '@/constants';
import { N8nResizeWrapper } from '@n8n/design-system';
import HubSwitcher from '@/features/assistant/components/HubSwitcher.vue';
const builderStore = useBuilderStore();
const assistantStore = useAssistantStore();
const chatPanelStore = useChatPanelStore();
const route = useRoute();
const askAssistantBuildRef = ref<InstanceType<typeof AskAssistantBuild>>();
const askAssistantChatRef = ref<InstanceType<typeof AskAssistantChat>>();
const isBuildMode = ref(builderStore.isAIBuilderEnabled);
const isBuildMode = computed(() => chatPanelStore.isBuilderModeActive);
const chatWidth = computed(() => chatPanelStore.width);
const chatWidth = computed(() => {
return isBuildMode.value ? builderStore.chatWidth : assistantStore.chatWidth;
// Show toggle only when both modes are available in current view
const canToggleModes = computed(() => {
const currentRoute = route?.name;
return (
builderStore.isAIBuilderEnabled &&
currentRoute &&
BUILDER_ENABLED_VIEWS.includes(currentRoute as VIEWS)
);
});
function onResize(data: { direction: string; x: number; width: number }) {
builderStore.updateWindowWidth(data.width);
assistantStore.updateWindowWidth(data.width);
chatPanelStore.updateWidth(data.width);
}
function onResizeDebounced(data: { direction: string; x: number; width: number }) {
@ -32,38 +44,33 @@ function onResizeDebounced(data: { direction: string; x: number; width: number }
}
async function toggleAssistantMode() {
const wasOpen = builderStore.isAssistantOpen || assistantStore.isAssistantOpen;
const wasOpen = chatPanelStore.isOpen;
const switchingToBuild = !isBuildMode.value;
isBuildMode.value = !isBuildMode.value;
const newMode = switchingToBuild ? 'builder' : 'assistant';
if (wasOpen) {
// If chat is already open, just toggle the window flags without reloading
if (switchingToBuild) {
// Load sessions first if builder has no messages
// Load sessions before switching mode if builder has no messages
if (builderStore.chatMessages.length === 0) {
await builderStore.fetchBuilderCredits();
await builderStore.loadSessions();
}
builderStore.chatWindowOpen = true;
assistantStore.chatWindowOpen = false;
} else {
assistantStore.chatWindowOpen = true;
builderStore.chatWindowOpen = false;
}
// Now switch the mode - data is already loaded
chatPanelStore.switchMode(newMode);
} else {
// Opening from closed state - use full open logic
if (isBuildMode.value) {
await builderStore.openChat();
if (switchingToBuild) {
await chatPanelStore.open({ mode: 'builder' });
} else {
assistantStore.openChat();
await chatPanelStore.open({ mode: 'assistant' });
}
}
}
function onClose() {
builderStore.closeChat();
assistantStore.closeChat();
chatPanelStore.close();
}
function onSlideEnterComplete() {
@ -77,14 +84,14 @@ function onSlideEnterComplete() {
const unsubscribeAssistantStore = assistantStore.$onAction(({ name }) => {
// When assistant is opened from error or credentials help
// switch from build mode to chat mode
if (['toggleChat', 'openChat', 'initErrorHelper', 'initCredHelp'].includes(name)) {
isBuildMode.value = false;
if (['initErrorHelper', 'initCredHelp'].includes(name)) {
chatPanelStore.switchMode('assistant');
}
});
const unsubscribeBuilderStore = builderStore.$onAction(({ name }) => {
if (['toggleChat', 'openChat', 'sendChatMessage'].includes(name)) {
isBuildMode.value = true;
if (['sendChatMessage'].includes(name)) {
chatPanelStore.switchMode('builder');
}
});
@ -97,9 +104,11 @@ onBeforeUnmount(() => {
<template>
<SlideTransition @after-enter="onSlideEnterComplete">
<N8nResizeWrapper
v-show="builderStore.isAssistantOpen || assistantStore.isAssistantOpen"
v-show="chatPanelStore.isOpen"
:supported-directions="['left']"
:width="chatWidth"
:min-width="chatPanelStore.MIN_CHAT_WIDTH"
:max-width="chatPanelStore.MAX_CHAT_WIDTH"
:class="$style.resizeWrapper"
data-test-id="ask-assistant-sidebar"
@resize="onResizeDebounced"
@ -107,13 +116,13 @@ onBeforeUnmount(() => {
<div :style="{ width: `${chatWidth}px` }" :class="$style.wrapper">
<div :class="$style.assistantContent">
<AskAssistantBuild v-if="isBuildMode" ref="askAssistantBuildRef" @close="onClose">
<template #header>
<template v-if="canToggleModes" #header>
<HubSwitcher :is-build-mode="isBuildMode" @toggle="toggleAssistantMode" />
</template>
</AskAssistantBuild>
<AskAssistantChat v-else ref="askAssistantChatRef" @close="onClose">
<!-- Header switcher is only visible when AIBuilder is enabled -->
<template v-if="builderStore.isAIBuilderEnabled" #header>
<!-- Header switcher is only visible when both modes are available in current view -->
<template v-if="canToggleModes" #header>
<HubSwitcher :is-build-mode="isBuildMode" @toggle="toggleAssistantMode" />
</template>
</AskAssistantChat>

View File

@ -2,13 +2,15 @@
import { useI18n } from '@n8n/i18n';
import { useStyles } from '@/composables/useStyles';
import { useAssistantStore } from '@/features/assistant/assistant.store';
import { useBuilderStore } from '@/stores/builder.store';
import { useBuilderStore } from '../../builder.store';
import { useChatPanelStore } from '../../chatPanel.store';
import { computed } from 'vue';
import { N8nAskAssistantButton, N8nAssistantAvatar, N8nTooltip } from '@n8n/design-system';
const assistantStore = useAssistantStore();
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
const i18n = useI18n();
const { APP_Z_INDEXES } = useStyles();
@ -28,11 +30,17 @@ const lastUnread = computed(() => {
const onClick = async () => {
if (builderStore.isAIBuilderEnabled) {
await builderStore.toggleChat();
// Toggle with appropriate mode based on current state
if (chatPanelStore.isOpen && chatPanelStore.isBuilderModeActive) {
chatPanelStore.close();
} else {
await chatPanelStore.open({ mode: 'builder' });
}
} else {
assistantStore.toggleChat();
// For assistant-only mode
await chatPanelStore.toggle({ mode: 'assistant' });
}
if (builderStore.isAssistantOpen || assistantStore.isAssistantOpen) {
if (chatPanelStore.isOpen) {
assistantStore.trackUserOpenedAssistant({
source: 'canvas',
task: 'placeholder',

View File

@ -3,14 +3,17 @@ import { NEW_ASSISTANT_SESSION_MODAL } from '@/constants';
import Modal from '@/components/Modal.vue';
import { useI18n } from '@n8n/i18n';
import { useUIStore } from '@/stores/ui.store';
import { useChatPanelStore } from '../../chatPanel.store';
import type { ChatRequest } from '@/features/assistant/assistant.types';
import { useAssistantStore } from '@/features/assistant/assistant.store';
import type { ICredentialType } from 'n8n-workflow';
import { N8nAssistantIcon, N8nAssistantText, N8nButton, N8nText } from '@n8n/design-system';
const i18n = useI18n();
const uiStore = useUIStore();
const assistantStore = useAssistantStore();
const chatPanelStore = useChatPanelStore();
const props = defineProps<{
name: string;
@ -25,14 +28,14 @@ const close = () => {
const startNewSession = async () => {
if ('errorHelp' in props.data.context) {
await assistantStore.initErrorHelper(props.data.context.errorHelp);
await chatPanelStore.openWithErrorHelper(props.data.context.errorHelp);
assistantStore.trackUserOpenedAssistant({
source: 'error',
task: 'error',
has_existing_session: true,
});
} else if ('credHelp' in props.data.context) {
await assistantStore.initCredHelp(props.data.context.credHelp.credType);
await chatPanelStore.openWithCredHelp(props.data.context.credHelp.credType);
}
close();
};
@ -80,6 +83,7 @@ const startNewSession = async () => {
p {
line-height: normal;
}
p + p {
margin-top: 10px;
}

View File

@ -0,0 +1,20 @@
import { EDITABLE_CANVAS_VIEWS, VIEWS } from '@/constants';
/**
* Views where the Assistant chat panel can be shown
*/
export const ASSISTANT_ENABLED_VIEWS = [
...EDITABLE_CANVAS_VIEWS,
VIEWS.EXECUTION_PREVIEW,
VIEWS.WORKFLOWS,
VIEWS.CREDENTIALS,
VIEWS.PROJECTS_CREDENTIALS,
VIEWS.PROJECTS_WORKFLOWS,
VIEWS.PROJECT_SETTINGS,
VIEWS.TEMPLATE_SETUP,
];
/**
* Views where the Builder chat panel can be shown
*/
export const BUILDER_ENABLED_VIEWS = [...EDITABLE_CANVAS_VIEWS];

View File

@ -122,7 +122,7 @@ import { shouldIgnoreCanvasShortcut } from '@/utils/canvasUtils';
import { getSampleWorkflowByTemplateId } from '@/features/templates/utils/workflowSamples';
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import { useBuilderStore } from '@/stores/builder.store';
import { useBuilderStore } from '@/features/assistant/builder.store';
import { usePostHog } from '@/stores/posthog.store';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useWorkflowExtraction } from '@/composables/useWorkflowExtraction';