From 7e63e56ccd0628563b73836ad3197eed13f19da9 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Mon, 15 Sep 2025 15:31:03 +0200 Subject: [PATCH] fix(editor): Keep chat session when switching to other tabs (#19483) --- .../logs/__test__/useChatMessaging.test.ts | 37 +++++++++++++---- .../logs/components/LogsPanel.test.ts | 41 +++++++++++-------- .../logs/composables/useChatMessaging.ts | 16 ++++---- .../features/logs/composables/useChatState.ts | 33 ++++++++++----- .../editor-ui/src/stores/logs.store.ts | 26 ++++++++++++ .../testing/playwright/pages/CanvasPage.ts | 5 +++ .../playwright/tests/ui/30-langchain.spec.ts | 20 +++++++++ 7 files changed, 133 insertions(+), 45 deletions(-) diff --git a/packages/frontend/editor-ui/src/features/logs/__test__/useChatMessaging.test.ts b/packages/frontend/editor-ui/src/features/logs/__test__/useChatMessaging.test.ts index b3e4406f08..bf9d67d27d 100644 --- a/packages/frontend/editor-ui/src/features/logs/__test__/useChatMessaging.test.ts +++ b/packages/frontend/editor-ui/src/features/logs/__test__/useChatMessaging.test.ts @@ -19,33 +19,33 @@ vi.mock('../logs.utils', () => { describe('useChatMessaging', () => { let chatMessaging: ReturnType; let chatTrigger: Ref; - let messages: Ref; - let sessionId: Ref; + let sessionId: string; let executionResultData: ComputedRef; let onRunChatWorkflow: ( payload: RunWorkflowChatPayload, ) => Promise; let ws: Ref; let executionData: IRunExecutionData['resultData'] | undefined = undefined; + let onNewMessage: (message: ChatMessage) => void; beforeEach(() => { executionData = undefined; createTestingPinia(); chatTrigger = ref(null); - messages = ref([]); - sessionId = ref('session-id'); + sessionId = 'session-id'; executionResultData = computed(() => executionData); onRunChatWorkflow = vi.fn().mockResolvedValue({ executionId: 'execution-id', } as IExecutionPushResponse); + onNewMessage = vi.fn(); ws = ref(null); chatMessaging = useChatMessaging({ chatTrigger, - messages, sessionId, executionResultData, onRunChatWorkflow, + onNewMessage, ws, }); }); @@ -60,7 +60,13 @@ describe('useChatMessaging', () => { const messageText = 'Hello, world!'; await chatMessaging.sendMessage(messageText); - expect(messages.value).toHaveLength(1); + expect(onNewMessage).toHaveBeenCalledTimes(1); + expect(onNewMessage).toHaveBeenCalledWith({ + id: expect.any(String), + sender: 'user', + sessionId: 'session-id', + text: messageText, + }); }); it('should send message via WebSocket if open', async () => { @@ -74,7 +80,7 @@ describe('useChatMessaging', () => { expect(ws.value.send).toHaveBeenCalledWith( JSON.stringify({ - sessionId: sessionId.value, + sessionId, action: 'sendMessage', chatInput: messageText, }), @@ -99,7 +105,14 @@ describe('useChatMessaging', () => { } as unknown as IRunExecutionData['resultData']; await chatMessaging.sendMessage(messageText); - expect(messages.value).toHaveLength(2); + expect(onNewMessage).toHaveBeenCalledTimes(2); + expect(onNewMessage).toHaveBeenCalledWith({ + id: expect.any(String), + sender: 'user', + sessionId: 'session-id', + text: messageText, + }); + expect(onNewMessage).toHaveBeenCalledWith('Last node response'); }); it('should startWorkflowWithMessage and not add final message if responseMode is responseNode and version is 1.3', async () => { @@ -120,6 +133,12 @@ describe('useChatMessaging', () => { } as unknown as IRunExecutionData['resultData']; await chatMessaging.sendMessage(messageText); - expect(messages.value).toHaveLength(1); + expect(onNewMessage).toHaveBeenCalledTimes(1); + expect(onNewMessage).toHaveBeenCalledWith({ + id: expect.any(String), + sender: 'user', + sessionId: 'session-id', + text: messageText, + }); }); }); diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts index c4eec679ca..14ac57c49a 100644 --- a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts @@ -682,16 +682,18 @@ describe('LogsPanel', () => { ]; beforeEach(() => { - vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation(({ messages }) => { - messages.value.push(...mockMessages); + vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation( + ({ onNewMessage: addChatMessage }) => { + addChatMessage(mockMessages[0]); - return { - sendMessage: vi.fn(), - previousMessageIndex: ref(0), - isLoading: computed(() => false), - setLoadingState: vi.fn(), - }; - }); + return { + sendMessage: vi.fn(), + previousMessageIndex: ref(0), + isLoading: computed(() => false), + setLoadingState: vi.fn(), + }; + }, + ); }); it('should allow copying session ID', async () => { @@ -826,16 +828,19 @@ describe('LogsPanel', () => { sender: 'bot', }, ]; - vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation(({ messages }) => { - messages.value.push(...mockMessages); + vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation( + ({ onNewMessage: addChatMessage }) => { + addChatMessage(mockMessages[0]); + addChatMessage(mockMessages[1]); - return { - sendMessage: sendMessageSpy, - previousMessageIndex: ref(0), - isLoading: computed(() => false), - setLoadingState: vi.fn(), - }; - }); + return { + sendMessage: sendMessageSpy, + previousMessageIndex: ref(0), + isLoading: computed(() => false), + setLoadingState: vi.fn(), + }; + }, + ); }); it('should repost user message with new execution', async () => { diff --git a/packages/frontend/editor-ui/src/features/logs/composables/useChatMessaging.ts b/packages/frontend/editor-ui/src/features/logs/composables/useChatMessaging.ts index 7701583b05..80600cad09 100644 --- a/packages/frontend/editor-ui/src/features/logs/composables/useChatMessaging.ts +++ b/packages/frontend/editor-ui/src/features/logs/composables/useChatMessaging.ts @@ -28,22 +28,22 @@ export type RunWorkflowChatPayload = { }; export interface ChatMessagingDependencies { chatTrigger: Ref; - messages: Ref; - sessionId: Ref; + sessionId: string; executionResultData: ComputedRef; onRunChatWorkflow: ( payload: RunWorkflowChatPayload, ) => Promise; ws: Ref; + onNewMessage: (message: ChatMessage) => void; } export function useChatMessaging({ chatTrigger, - messages, sessionId, executionResultData, onRunChatWorkflow, ws, + onNewMessage, }: ChatMessagingDependencies) { const locale = useI18n(); const { showError } = useToast(); @@ -116,7 +116,7 @@ export function useChatMessaging({ const inputPayload: INodeExecutionData = { json: { - sessionId: sessionId.value, + sessionId, action: 'sendMessage', [inputKey]: message, }, @@ -166,7 +166,7 @@ export function useChatMessaging({ : undefined; if (chatMessage !== undefined) { - messages.value.push(chatMessage); + onNewMessage(chatMessage); } } @@ -200,16 +200,16 @@ export function useChatMessaging({ const newMessage: ChatMessage & { sessionId: string } = { text: message, sender: 'user', - sessionId: sessionId.value, + sessionId, id: uuid(), files, }; - messages.value.push(newMessage); + onNewMessage(newMessage); if (ws.value?.readyState === WebSocket.OPEN && !isLoading.value) { ws.value.send( JSON.stringify({ - sessionId: sessionId.value, + sessionId, action: 'sendMessage', chatInput: message, files: await processFiles(files), diff --git a/packages/frontend/editor-ui/src/features/logs/composables/useChatState.ts b/packages/frontend/editor-ui/src/features/logs/composables/useChatState.ts index cb92367a9a..bff94b0495 100644 --- a/packages/frontend/editor-ui/src/features/logs/composables/useChatState.ts +++ b/packages/frontend/editor-ui/src/features/logs/composables/useChatState.ts @@ -3,7 +3,7 @@ import { useChatMessaging } from '@/features/logs/composables/useChatMessaging'; import { useI18n } from '@n8n/i18n'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useRunWorkflow } from '@/composables/useRunWorkflow'; -import { VIEWS } from '@/constants'; +import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useRootStore } from '@n8n/stores/useRootStore'; import { ChatOptionsSymbol } from '@n8n/chat/constants'; @@ -44,8 +44,8 @@ export function useChatState(isReadOnly: boolean): ChatState { const { runWorkflow } = useRunWorkflow({ router }); const ws = ref(null); - const messages = ref([]); - const currentSessionId = ref(uuid().replace(/-/g, '')); + const messages = computed(() => logsStore.chatSessionMessages); + const currentSessionId = computed(() => logsStore.chatSessionId); const previousChatMessages = computed(() => workflowsStore.getPastChatMessages); const chatTriggerNode = computed(() => workflowsStore.allNodes.find(isChatNode) ?? null); @@ -68,10 +68,10 @@ export function useChatState(isReadOnly: boolean): ChatState { const { sendMessage, isLoading, setLoadingState } = useChatMessaging({ chatTrigger: chatTriggerNode, - messages, - sessionId: currentSessionId, + sessionId: currentSessionId.value, executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData), onRunChatWorkflow, + onNewMessage: logsStore.addChatMessage, ws, }); @@ -194,7 +194,7 @@ export function useChatState(isReadOnly: boolean): ChatState { sessionId: currentSessionId.value, id: uuid(), }; - messages.value.push(newMessage); + logsStore.addChatMessage(newMessage); if (logsStore.isOpen) { chatEventBus.emit('focusInput'); @@ -216,8 +216,8 @@ export function useChatState(isReadOnly: boolean): ChatState { function refreshSession() { workflowsStore.setWorkflowExecutionData(null); nodeHelpers.updateNodesExecutionIssues(); - messages.value = []; - currentSessionId.value = uuid().replace(/-/g, ''); + logsStore.resetChatSessionId(); + logsStore.resetMessages(); if (logsStore.isOpen) { chatEventBus.emit('focusInput'); @@ -232,9 +232,22 @@ export function useChatState(isReadOnly: boolean): ChatState { window.open(route.href, '_blank'); } + watch( + () => workflowsStore.workflowId, + (_newWorkflowId, prevWorkflowId) => { + if (prevWorkflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) { + return; + } + + refreshSession(); + }, + ); + return { - currentSessionId, - messages: computed(() => (isReadOnly ? restoredChatMessages.value : messages.value)), + currentSessionId: computed(() => logsStore.chatSessionId), + messages: computed(() => + isReadOnly ? restoredChatMessages.value : logsStore.chatSessionMessages, + ), previousChatMessages, sendMessage, refreshSession, diff --git a/packages/frontend/editor-ui/src/stores/logs.store.ts b/packages/frontend/editor-ui/src/stores/logs.store.ts index 3aee5d188d..6ed0a861e9 100644 --- a/packages/frontend/editor-ui/src/stores/logs.store.ts +++ b/packages/frontend/editor-ui/src/stores/logs.store.ts @@ -10,6 +10,8 @@ import { useLocalStorage } from '@vueuse/core'; import { defineStore } from 'pinia'; import { computed, ref } from 'vue'; import { LOG_DETAILS_PANEL_STATE, LOGS_PANEL_STATE } from '@/features/logs/logs.constants'; +import type { ChatMessage } from '@n8n/chat/types'; +import { v4 as uuid } from 'uuid'; export const useLogsStore = defineStore('logs', () => { const isOpen = useLocalStorage(LOCAL_STORAGE_LOGS_PANEL_OPEN, false); @@ -39,10 +41,25 @@ export const useLogsStore = defineStore('logs', () => { const telemetry = useTelemetry(); + const chatSessionId = ref(getNewSessionId()); + const chatSessionMessages = ref([]); + function setHeight(value: number) { height.value = value; } + function getNewSessionId(): string { + return uuid().replace(/-/g, ''); + } + + function resetChatSessionId() { + chatSessionId.value = getNewSessionId(); + } + + function resetMessages() { + chatSessionMessages.value = []; + } + function toggleOpen(value?: boolean) { isOpen.value = value ?? !isOpen.value; } @@ -101,6 +118,10 @@ export const useLogsStore = defineStore('logs', () => { isLogSelectionSyncedWithCanvas.value = value ?? !isLogSelectionSyncedWithCanvas.value; } + function addChatMessage(message: ChatMessage) { + chatSessionMessages.value.push(message); + } + return { state, isOpen: computed(() => state.value !== LOGS_PANEL_STATE.CLOSED), @@ -109,6 +130,9 @@ export const useLogsStore = defineStore('logs', () => { ), height: computed(() => height.value), isLogSelectionSyncedWithCanvas: computed(() => isLogSelectionSyncedWithCanvas.value), + chatSessionId: computed(() => chatSessionId.value), + chatSessionMessages: computed(() => chatSessionMessages.value), + addChatMessage, setHeight, toggleOpen, setPreferPoppedOut, @@ -116,5 +140,7 @@ export const useLogsStore = defineStore('logs', () => { toggleInputOpen, toggleOutputOpen, toggleLogSelectionSync, + resetChatSessionId, + resetMessages, }; }); diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index adad4d30fa..48618fd248 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -207,6 +207,11 @@ export class CanvasPage extends BasePage { async clickExecutionsTab(): Promise { await this.page.getByRole('radio', { name: 'Executions' }).click(); } + + async clickEditorTab(): Promise { + await this.page.getByRole('radio', { name: 'Editor' }).click(); + } + async setWorkflowName(name: string): Promise { await this.clickByTestId('inline-edit-preview'); await this.fillByTestId('inline-edit-input', name); diff --git a/packages/testing/playwright/tests/ui/30-langchain.spec.ts b/packages/testing/playwright/tests/ui/30-langchain.spec.ts index a32d92c841..2acaab9207 100644 --- a/packages/testing/playwright/tests/ui/30-langchain.spec.ts +++ b/packages/testing/playwright/tests/ui/30-langchain.spec.ts @@ -529,4 +529,24 @@ test.describe('Langchain Integration @capability:proxy', () => { await expect(n8n.canvas.getManualChatLatestBotMessage()).toContainText('this_my_field_4'); }); }); + + test('should keep the same session when switching tabs', async ({ n8n }) => { + await n8n.start.fromImportedWorkflow('Test_workflow_chat_partial_execution.json'); + await n8n.canvas.clickZoomToFitButton(); + + await n8n.canvas.logsPanel.open(); + + // Send a message + await n8n.canvas.logsPanel.sendManualChatMessage('Test'); + await expect(n8n.canvas.getManualChatLatestBotMessage()).toContainText('this_my_field'); + + await n8n.canvas.clickExecutionsTab(); + + await n8n.canvas.clickEditorTab(); + await expect(n8n.canvas.getManualChatLatestBotMessage()).toContainText('this_my_field'); + + // Refresh session + await n8n.page.getByTestId('refresh-session-button').click(); + await expect(n8n.canvas.getManualChatMessages()).not.toBeAttached(); + }); });