mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 17:27:14 +02:00
fix: Chat side panel open/close behaviour and builder state accuracy (no-changelog) (#20525)
This commit is contained in:
parent
f4963a7c64
commit
ac3efc5685
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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[] }>
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user