fix(editor): Keep chat session when switching to other tabs (#19483)

This commit is contained in:
Mutasem Aldmour
2025-09-15 15:31:03 +02:00
committed by GitHub
parent 5a63304014
commit 7e63e56ccd
7 changed files with 133 additions and 45 deletions

View File

@@ -19,33 +19,33 @@ vi.mock('../logs.utils', () => {
describe('useChatMessaging', () => {
let chatMessaging: ReturnType<typeof useChatMessaging>;
let chatTrigger: Ref<INodeUi | null>;
let messages: Ref<ChatMessage[]>;
let sessionId: Ref<string>;
let sessionId: string;
let executionResultData: ComputedRef<IRunExecutionData['resultData'] | undefined>;
let onRunChatWorkflow: (
payload: RunWorkflowChatPayload,
) => Promise<IExecutionPushResponse | undefined>;
let ws: Ref<WebSocket | null>;
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,
});
});
});

View File

@@ -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 () => {

View File

@@ -28,22 +28,22 @@ export type RunWorkflowChatPayload = {
};
export interface ChatMessagingDependencies {
chatTrigger: Ref<INodeUi | null>;
messages: Ref<ChatMessage[]>;
sessionId: Ref<string>;
sessionId: string;
executionResultData: ComputedRef<IRunExecutionData['resultData'] | undefined>;
onRunChatWorkflow: (
payload: RunWorkflowChatPayload,
) => Promise<IExecutionPushResponse | undefined>;
ws: Ref<WebSocket | null>;
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),

View File

@@ -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<WebSocket | null>(null);
const messages = ref<ChatMessage[]>([]);
const currentSessionId = ref<string>(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,

View File

@@ -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<string>(getNewSessionId());
const chatSessionMessages = ref<ChatMessage[]>([]);
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,
};
});

View File

@@ -207,6 +207,11 @@ export class CanvasPage extends BasePage {
async clickExecutionsTab(): Promise<void> {
await this.page.getByRole('radio', { name: 'Executions' }).click();
}
async clickEditorTab(): Promise<void> {
await this.page.getByRole('radio', { name: 'Editor' }).click();
}
async setWorkflowName(name: string): Promise<void> {
await this.clickByTestId('inline-edit-preview');
await this.fillByTestId('inline-edit-input', name);

View File

@@ -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();
});
});