feat(editor): Always show collapsed panel at the bottom of canvas (#13715)

This commit is contained in:
autologie
2025-03-18 17:09:09 +01:00
committed by GitHub
parent bc15bb18d9
commit 2e9d3ad3e1
19 changed files with 895 additions and 248 deletions

View File

@@ -3,6 +3,7 @@ import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import { useAssistantStore } from '@/stores/assistant.store';
import { useCanvasStore } from '@/stores/canvas.store';
import { useNDVStore } from '@/stores/ndv.store';
import AssistantAvatar from '@n8n/design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
import AskAssistantButton from '@n8n/design-system/components/AskAssistantButton/AskAssistantButton.vue';
import { computed } from 'vue';
@@ -11,6 +12,8 @@ const assistantStore = useAssistantStore();
const i18n = useI18n();
const { APP_Z_INDEXES } = useStyles();
const canvasStore = useCanvasStore();
const ndvStore = useNDVStore();
const bottom = computed(() => (ndvStore.activeNode === null ? canvasStore.panelHeight : 0));
const lastUnread = computed(() => {
const msg = assistantStore.lastUnread;
@@ -41,7 +44,7 @@ const onClick = () => {
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
:class="$style.container"
data-test-id="ask-assistant-floating-button"
:style="{ '--canvas-panel-height-offset': `${canvasStore.panelHeight}px` }"
:style="{ bottom: `${bottom}px` }"
>
<n8n-tooltip
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
@@ -64,8 +67,8 @@ const onClick = () => {
<style lang="scss" module>
.container {
position: absolute;
bottom: calc(var(--canvas-panel-height-offset, 0px) + var(--spacing-s));
right: var(--spacing-s);
margin: var(--spacing-s);
right: 0;
z-index: var(--z-index-ask-assistant-floating-button);
}

View File

@@ -1,44 +1,25 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import { provide, watch, computed, ref, watchEffect, useTemplateRef } from 'vue';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import type { Router } from 'vue-router';
import { useRouter } from 'vue-router';
import { chatEventBus } from '@n8n/chat/event-buses';
import { VIEWS } from '@/constants';
import { v4 as uuid } from 'uuid';
import { computed, ref, watchEffect, useTemplateRef } from 'vue';
// Components
import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
import ChatLogsPanel from './components/ChatLogsPanel.vue';
// Composables
import { useChatTrigger } from './composables/useChatTrigger';
import { useChatMessaging } from './composables/useChatMessaging';
import { useResize } from './composables/useResize';
import { useI18n } from '@/composables/useI18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
// Types
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
import type { RunWorkflowChatPayload } from './composables/useChatMessaging';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCanvasStore } from '@/stores/canvas.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
import { N8nResizeWrapper } from '@n8n/design-system';
import { useTelemetry } from '@/composables/useTelemetry';
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
const workflowsStore = useWorkflowsStore();
const canvasStore = useCanvasStore();
const nodeTypesStore = useNodeTypesStore();
const nodeHelpers = useNodeHelpers();
const router = useRouter();
// Component state
const messages = ref<ChatMessage[]>([]);
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
const isDisabled = ref(false);
const container = ref<HTMLElement>();
const pipContainer = useTemplateRef('pipContainer');
@@ -47,49 +28,12 @@ const pipContent = useTemplateRef('pipContent');
// Computed properties
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const allConnections = computed(() => workflowsStore.allConnections);
const chatPanelState = computed(() => workflowsStore.chatPanelState);
const canvasNodes = computed(() => workflowsStore.allNodes);
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const resultData = computed(() => workflowsStore.getWorkflowRunData);
// Expose internal state for testing
defineExpose({
messages,
currentSessionId,
isDisabled,
workflow,
});
const telemetry = useTelemetry();
const { runWorkflow } = useRunWorkflow({ router });
// Initialize features with injected dependencies
const {
chatTriggerNode,
connectedNode,
allowFileUploads,
allowedFilesMimeTypes,
setChatTriggerNode,
setConnectedNode,
} = useChatTrigger({
workflow,
canvasNodes,
getNodeByName: workflowsStore.getNodeByName,
getNodeType: nodeTypesStore.getNodeType,
});
const { sendMessage, getChatMessages, isLoading } = useChatMessaging({
chatTrigger: chatTriggerNode,
connectedNode,
messages,
sessionId: currentSessionId,
workflow,
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
getWorkflowResultDataByNodeName: workflowsStore.getWorkflowResultDataByNodeName,
onRunChatWorkflow,
});
const {
height,
chatWidth,
@@ -116,175 +60,34 @@ const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
},
});
// Extracted pure functions for better testability
function createChatConfig(params: {
messages: Chat['messages'];
sendMessage: Chat['sendMessage'];
currentSessionId: Chat['currentSessionId'];
isLoading: Ref<boolean>;
isDisabled: Ref<boolean>;
allowFileUploads: Ref<boolean>;
locale: ReturnType<typeof useI18n>;
}): { chatConfig: Chat; chatOptions: ChatOptions } {
const chatConfig: Chat = {
messages: params.messages,
sendMessage: params.sendMessage,
initialMessages: ref([]),
currentSessionId: params.currentSessionId,
waitingForResponse: params.isLoading,
};
const {
currentSessionId,
messages,
chatTriggerNode,
connectedNode,
sendMessage,
refreshSession,
displayExecution,
} = useChatState(isDisabled, onWindowResize);
const chatOptions: ChatOptions = {
i18n: {
en: {
title: '',
footer: '',
subtitle: '',
inputPlaceholder: params.locale.baseText('chat.window.chat.placeholder'),
getStarted: '',
closeButtonTooltip: '',
},
},
webhookUrl: '',
mode: 'window',
showWindowCloseButton: true,
disabled: params.isDisabled,
allowFileUploads: params.allowFileUploads,
allowedFilesMimeTypes,
};
return { chatConfig, chatOptions };
}
function displayExecution(params: { router: Router; workflowId: string; executionId: string }) {
const route = params.router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: params.workflowId, executionId: params.executionId },
});
window.open(route.href, '_blank');
}
function refreshSession(params: { messages: Ref<ChatMessage[]>; currentSessionId: Ref<string> }) {
workflowsStore.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
params.messages.value = [];
params.currentSessionId.value = uuid().replace(/-/g, '');
}
// Event handlers
const handleDisplayExecution = (executionId: string) => {
displayExecution({
router,
workflowId: workflow.value.id,
executionId,
});
};
const handleRefreshSession = () => {
refreshSession({
messages,
currentSessionId,
});
};
// Expose internal state for testing
defineExpose({
messages,
currentSessionId,
isDisabled,
workflow,
});
const closePanel = () => {
workflowsStore.setPanelState('closed');
};
// This function creates a promise that resolves when the workflow execution completes
// It's used to handle the loading state while waiting for the workflow to finish
async function createExecutionPromise() {
return await new Promise<void>((resolve) => {
const resolveIfFinished = (isRunning: boolean) => {
if (!isRunning) {
unwatch();
resolve();
}
};
// Watch for changes in the workflow execution status
const unwatch = watch(() => workflowsStore.isWorkflowRunning, resolveIfFinished);
resolveIfFinished(workflowsStore.isWorkflowRunning);
});
}
async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
const runWorkflowOptions: Parameters<typeof runWorkflow>[0] = {
triggerNode: payload.triggerNode,
nodeData: payload.nodeData,
source: payload.source,
};
if (workflowsStore.chatPartialExecutionDestinationNode) {
runWorkflowOptions.destinationNode = workflowsStore.chatPartialExecutionDestinationNode;
workflowsStore.chatPartialExecutionDestinationNode = null;
}
const response = await runWorkflow(runWorkflowOptions);
if (response) {
await createExecutionPromise();
workflowsStore.appendChatMessage(payload.message);
return response;
}
return;
}
function onPopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' });
workflowsStore.setPanelState('floating');
}
// Initialize chat config
const { chatConfig, chatOptions } = createChatConfig({
messages,
sendMessage,
currentSessionId,
isLoading,
isDisabled,
allowFileUploads,
locale: useI18n(),
});
// Provide chat context
provide(ChatSymbol, chatConfig);
provide(ChatOptionsSymbol, chatOptions);
// Watchers
watch(
chatPanelState,
(state) => {
if (state !== 'closed') {
setChatTriggerNode();
setConnectedNode();
if (messages.value.length === 0) {
messages.value = getChatMessages();
}
setTimeout(() => {
onWindowResize();
chatEventBus.emit('focusInput');
}, 0);
}
},
{ immediate: true },
);
watch(
() => allConnections.value,
() => {
if (canvasStore.isLoading) return;
setTimeout(() => {
if (!chatTriggerNode.value) {
setChatTriggerNode();
}
setConnectedNode();
}, 0);
},
{ deep: true },
);
watchEffect(() => {
canvasStore.setPanelHeight(chatPanelState.value === 'attached' ? height.value : 0);
});
@@ -319,8 +122,8 @@ watchEffect(() => {
:past-chat-messages="previousChatMessages"
:show-close-button="!isPoppedOut && !connectedNode"
@close="closePanel"
@refresh-session="handleRefreshSession"
@display-execution="handleDisplayExecution"
@refresh-session="refreshSession"
@display-execution="displayExecution"
@send-message="sendMessage"
/>
</div>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
import { useSettingsStore } from '@/stores/settings.store';
const { isNewLogsEnabled } = useSettingsStore();
</script>
<template>
<LogsPanel v-if="isNewLogsEnabled" />
<CanvasChat v-else />
</template>

View File

@@ -10,21 +10,29 @@ import ChatInput from '@n8n/chat/components/Input.vue';
import { computed, ref } from 'vue';
import { useClipboard } from '@/composables/useClipboard';
import { useToast } from '@/composables/useToast';
import PanelHeader from '@/components/CanvasChat/components/PanelHeader.vue';
import { N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system';
interface Props {
pastChatMessages: string[];
messages: ChatMessage[];
sessionId: string;
showCloseButton?: boolean;
isOpen?: boolean;
isNewLogsEnabled?: boolean;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
isOpen: true,
isNewLogsEnabled: false,
});
const emit = defineEmits<{
displayExecution: [id: string];
sendMessage: [message: string];
refreshSession: [];
close: [];
clickHeader: [];
}>();
const clipboard = useClipboard();
@@ -33,6 +41,12 @@ const toast = useToast();
const previousMessageIndex = ref(0);
const sessionIdText = computed(() =>
locale.baseText('chat.window.session.id', {
interpolate: { id: `${props.sessionId.slice(0, 5)}...` },
}),
);
const inputPlaceholder = computed(() => {
if (props.messages.length > 0) {
return locale.baseText('chat.window.chat.placeholder');
@@ -124,11 +138,49 @@ async function copySessionId() {
<template>
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
<header :class="$style.chatHeader">
<PanelHeader
v-if="isNewLogsEnabled"
:title="locale.baseText('chat.window.title')"
@click="emit('clickHeader')"
>
<template #actions>
<N8nTooltip v-if="clipboard.isSupported.value">
<template #content>
{{ sessionId }}
<br />
{{ locale.baseText('chat.window.session.id.copy') }}
</template>
<N8nButton
data-test-id="chat-session-id"
type="secondary"
size="mini"
@click.stop="copySessionId"
>{{ sessionIdText }}</N8nButton
>
</N8nTooltip>
<N8nTooltip
v-if="messages.length > 0"
:content="locale.baseText('chat.window.session.resetSession')"
>
<N8nIconButton
:class="$style.headerButton"
data-test-id="refresh-session-button"
outline
type="secondary"
size="small"
icon-size="medium"
icon="undo"
:title="locale.baseText('chat.window.session.reset')"
@click.stop="onRefreshSession"
/>
</N8nTooltip>
</template>
</PanelHeader>
<header v-else :class="$style.chatHeader">
<span :class="$style.chatTitle">{{ locale.baseText('chat.window.title') }}</span>
<div :class="$style.session">
<span>{{ locale.baseText('chat.window.session.title') }}</span>
<n8n-tooltip placement="left">
<N8nTooltip placement="left">
<template #content>
{{ sessionId }}
</template>
@@ -138,8 +190,8 @@ async function copySessionId() {
@click="clipboard.isSupported.value ? copySessionId() : null"
>{{ sessionId }}</span
>
</n8n-tooltip>
<n8n-icon-button
</N8nTooltip>
<N8nIconButton
:class="$style.headerButton"
data-test-id="refresh-session-button"
outline
@@ -149,7 +201,7 @@ async function copySessionId() {
:title="locale.baseText('chat.window.session.reset')"
@click="onRefreshSession"
/>
<n8n-icon-button
<N8nIconButton
v-if="showCloseButton"
:class="$style.headerButton"
outline
@@ -160,8 +212,14 @@ async function copySessionId() {
/>
</div>
</header>
<main :class="$style.chatBody">
<MessagesList :messages="messages" :class="$style.messages">
<main v-if="isOpen" :class="$style.chatBody">
<MessagesList
:messages="messages"
:class="$style.messages"
:empty-text="
isNewLogsEnabled ? locale.baseText('chat.window.chat.emptyChatMessage.v2') : undefined
"
>
<template #beforeMessage="{ message }">
<MessageOptionTooltip
v-if="message.sender === 'bot' && !message.id.includes('preload')"
@@ -193,7 +251,7 @@ async function copySessionId() {
</MessagesList>
</main>
<div :class="$style.messagesInput">
<div v-if="isOpen" :class="$style.messagesInput">
<ChatInput
data-test-id="lm-chat-inputs"
:placeholder="inputPlaceholder"
@@ -201,7 +259,7 @@ async function copySessionId() {
>
<template v-if="pastChatMessages.length > 0" #leftPanel>
<div :class="$style.messagesHistory">
<n8n-button
<N8nButton
title="Navigate to previous message"
icon="chevron-up"
type="tertiary"
@@ -209,7 +267,7 @@ async function copySessionId() {
size="mini"
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowUp' })"
/>
<n8n-button
<N8nButton
title="Navigate to next message"
icon="chevron-down"
type="tertiary"
@@ -289,6 +347,9 @@ async function copySessionId() {
display: flex;
height: 100%;
overflow: auto;
flex-direction: column;
align-items: center;
justify-content: center;
}
.messages {

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
defineProps<{ title: string }>();
defineSlots<{ actions: {} }>();
const emit = defineEmits<{ click: [] }>();
</script>
<template>
<header :class="$style.container" @click="emit('click')">
<span :class="$style.title">{{ title }}</span>
<div :class="$style.actions">
<slot name="actions" />
</div>
</header>
</template>
<style lang="scss" module>
.container {
font-size: var(--font-size-2xs);
font-weight: 400;
line-height: 18px;
text-align: left;
padding-inline: var(--spacing-s);
padding-block: var(--spacing-2xs);
background-color: var(--color-foreground-xlight);
display: flex;
justify-content: space-between;
align-items: center;
line-height: var(--font-line-height-compact);
&:last-child {
/** Panel collapsed */
cursor: pointer;
}
&:not(:last-child) {
/** Panel open */
border-bottom: 1px solid var(--color-foreground-base);
}
}
.title {
font-weight: 600;
flex-grow: 1;
flex-shrink: 1;
}
.actions {
display: flex;
align-items: center;
color: var(--color-text-base);
max-width: 70%;
/* Let button heights not affect the header height */
margin-block: calc(-1 * var(--spacing-s));
}
.actions button {
border: none;
color: var(--color-text-light);
}
</style>

View File

@@ -0,0 +1,226 @@
import type { RunWorkflowChatPayload } from '@/components/CanvasChat/composables/useChatMessaging';
import { useChatMessaging } from '@/components/CanvasChat/composables/useChatMessaging';
import { useChatTrigger } from '@/components/CanvasChat/composables/useChatTrigger';
import { useI18n } from '@/composables/useI18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { VIEWS } from '@/constants';
import { type INodeUi } from '@/Interface';
import { useCanvasStore } from '@/stores/canvas.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
import { type INode } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import type { Ref } from 'vue';
import { computed, provide, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
interface ChatState {
currentSessionId: Ref<string>;
messages: Ref<ChatMessage[]>;
chatTriggerNode: Ref<INodeUi | null>;
connectedNode: Ref<INode | null>;
sendMessage: (message: string, files?: File[]) => Promise<void>;
refreshSession: () => void;
displayExecution: (executionId: string) => void;
}
export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => void): ChatState {
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const canvasStore = useCanvasStore();
const router = useRouter();
const nodeHelpers = useNodeHelpers();
const { runWorkflow } = useRunWorkflow({ router });
const messages = ref<ChatMessage[]>([]);
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
const canvasNodes = computed(() => workflowsStore.allNodes);
const allConnections = computed(() => workflowsStore.allConnections);
const chatPanelState = computed(() => workflowsStore.chatPanelState);
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
// Initialize features with injected dependencies
const {
chatTriggerNode,
connectedNode,
allowFileUploads,
allowedFilesMimeTypes,
setChatTriggerNode,
setConnectedNode,
} = useChatTrigger({
workflow,
canvasNodes,
getNodeByName: workflowsStore.getNodeByName,
getNodeType: nodeTypesStore.getNodeType,
});
const { sendMessage, getChatMessages, isLoading } = useChatMessaging({
chatTrigger: chatTriggerNode,
connectedNode,
messages,
sessionId: currentSessionId,
workflow,
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
getWorkflowResultDataByNodeName: workflowsStore.getWorkflowResultDataByNodeName,
onRunChatWorkflow,
});
// Extracted pure functions for better testability
function createChatConfig(params: {
messages: Chat['messages'];
sendMessage: Chat['sendMessage'];
currentSessionId: Chat['currentSessionId'];
isLoading: Ref<boolean>;
isDisabled: Ref<boolean>;
allowFileUploads: Ref<boolean>;
locale: ReturnType<typeof useI18n>;
}): { chatConfig: Chat; chatOptions: ChatOptions } {
const chatConfig: Chat = {
messages: params.messages,
sendMessage: params.sendMessage,
initialMessages: ref([]),
currentSessionId: params.currentSessionId,
waitingForResponse: params.isLoading,
};
const chatOptions: ChatOptions = {
i18n: {
en: {
title: '',
footer: '',
subtitle: '',
inputPlaceholder: params.locale.baseText('chat.window.chat.placeholder'),
getStarted: '',
closeButtonTooltip: '',
},
},
webhookUrl: '',
mode: 'window',
showWindowCloseButton: true,
disabled: params.isDisabled,
allowFileUploads: params.allowFileUploads,
allowedFilesMimeTypes,
};
return { chatConfig, chatOptions };
}
// Initialize chat config
const { chatConfig, chatOptions } = createChatConfig({
messages,
sendMessage,
currentSessionId,
isLoading,
isDisabled,
allowFileUploads,
locale: useI18n(),
});
// Provide chat context
provide(ChatSymbol, chatConfig);
provide(ChatOptionsSymbol, chatOptions);
// Watchers
watch(
() => chatPanelState.value,
(state) => {
if (state !== 'closed') {
setChatTriggerNode();
setConnectedNode();
if (messages.value.length === 0) {
messages.value = getChatMessages();
}
setTimeout(() => {
onWindowResize();
chatEventBus.emit('focusInput');
}, 0);
}
},
{ immediate: true },
);
watch(
() => allConnections.value,
() => {
if (canvasStore.isLoading) return;
setTimeout(() => {
if (!chatTriggerNode.value) {
setChatTriggerNode();
}
setConnectedNode();
}, 0);
},
{ deep: true },
);
// This function creates a promise that resolves when the workflow execution completes
// It's used to handle the loading state while waiting for the workflow to finish
async function createExecutionPromise() {
return await new Promise<void>((resolve) => {
const resolveIfFinished = (isRunning: boolean) => {
if (!isRunning) {
unwatch();
resolve();
}
};
// Watch for changes in the workflow execution status
const unwatch = watch(() => workflowsStore.isWorkflowRunning, resolveIfFinished);
resolveIfFinished(workflowsStore.isWorkflowRunning);
});
}
async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
const runWorkflowOptions: Parameters<typeof runWorkflow>[0] = {
triggerNode: payload.triggerNode,
nodeData: payload.nodeData,
source: payload.source,
};
if (workflowsStore.chatPartialExecutionDestinationNode) {
runWorkflowOptions.destinationNode = workflowsStore.chatPartialExecutionDestinationNode;
workflowsStore.chatPartialExecutionDestinationNode = null;
}
const response = await runWorkflow(runWorkflowOptions);
if (response) {
await createExecutionPromise();
workflowsStore.appendChatMessage(payload.message);
return response;
}
return;
}
function refreshSession() {
workflowsStore.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
messages.value = [];
currentSessionId.value = uuid().replace(/-/g, '');
}
function displayExecution(executionId: string) {
const route = router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: workflow.value.id, executionId },
});
window.open(route.href, '_blank');
}
return {
currentSessionId,
messages,
chatTriggerNode,
connectedNode,
sendMessage,
refreshSession,
displayExecution,
};
}

View File

@@ -0,0 +1,88 @@
import { renderComponent } from '@/__tests__/render';
import { fireEvent, waitFor } from '@testing-library/vue';
import { mockedStore } from '@/__tests__/utils';
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { createRouter, createWebHistory } from 'vue-router';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createTestNode } from '@/__tests__/mocks';
import { CHAT_TRIGGER_NODE_TYPE } from '@/constants';
import { h } from 'vue';
describe('LogsPanel', () => {
let pinia: TestingPinia;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
function render() {
return renderComponent(LogsPanel, {
global: {
plugins: [
createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: () => h('div') }],
}),
pinia,
],
},
});
}
beforeEach(() => {
pinia = createTestingPinia({ stubActions: false, fakeApp: true });
setActivePinia(pinia);
settingsStore = mockedStore(useSettingsStore);
settingsStore.isNewLogsEnabled = true;
workflowsStore = mockedStore(useWorkflowsStore);
});
it('renders collapsed panel by default', async () => {
const rendered = render();
expect(await rendered.findByText('Logs')).toBeInTheDocument();
expect(
rendered.queryByText('Nothing to display yet', { exact: false }),
).not.toBeInTheDocument();
});
it('renders chat panel if the workflow has chat trigger', async () => {
workflowsStore.workflowTriggerNodes = [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE })];
const rendered = render();
expect(await rendered.findByText('Chat')).toBeInTheDocument();
});
it('opens collapsed panel when clicked', async () => {
const rendered = render();
await rendered.findByText('Logs');
await fireEvent.click(rendered.getByText('Logs'));
expect(
await rendered.findByText('Nothing to display yet', { exact: false }),
).toBeInTheDocument();
});
it('toggles panel when chevron icon button is clicked', async () => {
const rendered = render();
await rendered.findByText('Logs');
await fireEvent.click(rendered.getAllByRole('button').pop()!);
expect(rendered.getByText('Nothing to display yet', { exact: false })).toBeInTheDocument();
await fireEvent.click(rendered.getAllByRole('button').pop()!);
await waitFor(() =>
expect(
rendered.queryByText('Nothing to display yet', { exact: false }),
).not.toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed, ref, useTemplateRef, watch } from 'vue';
import { N8nIconButton, N8nResizeWrapper, N8nTooltip } from '@n8n/design-system';
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
import { useResize } from '@/components/CanvasChat/composables/useResize';
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
import { useTelemetry } from '@/composables/useTelemetry';
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
import { useCanvasStore } from '@/stores/canvas.store';
import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
const workflowsStore = useWorkflowsStore();
const canvasStore = useCanvasStore();
const panelState = computed(() => workflowsStore.chatPanelState);
const container = ref<HTMLElement>();
const pipContainer = useTemplateRef('pipContainer');
const pipContent = useTemplateRef('pipContent');
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const hasChat = computed(() =>
workflowsStore.workflowTriggerNodes.some((node) =>
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
),
);
const locales = useI18n();
const telemetry = useTelemetry();
const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } =
useResize(container);
const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState(
ref(false),
onWindowResize,
);
const appStyles = useStyles();
const tooltipZIndex = computed(() => appStyles.APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON + 100);
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
initialHeight: 400,
initialWidth: window.document.body.offsetWidth * 0.8,
container: pipContainer,
content: pipContent,
shouldPopOut: computed(() => panelState.value === 'floating'),
onRequestClose: () => {
if (panelState.value === 'closed') {
return;
}
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPanelState('attached');
},
});
function handleToggleOpen() {
if (panelState.value === 'closed') {
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPanelState('attached');
} else {
telemetry.track('User toggled log view', { new_state: 'collapsed' });
workflowsStore.setPanelState('closed');
}
}
function handleClickHeader() {
if (panelState.value === 'closed') {
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPanelState('attached');
}
}
function onPopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' });
workflowsStore.setPanelState('floating');
}
watch([panelState, height], ([state, h]) => {
canvasStore.setPanelHeight(
state === 'floating' ? 0 : state === 'attached' ? h : 32 /* collapsed panel height */,
);
});
</script>
<template>
<div ref="pipContainer">
<div ref="pipContent" :class="$style.pipContent">
<N8nResizeWrapper
:height="height"
:supported-directions="['top']"
:is-resizing-enabled="panelState === 'attached'"
:style="rootStyles"
:class="[$style.resizeWrapper, panelState === 'closed' ? '' : $style.isOpen]"
@resize="onResizeDebounced"
>
<div ref="container" :class="$style.container">
<N8nResizeWrapper
v-if="hasChat"
:supported-directions="['right']"
:is-resizing-enabled="panelState !== 'closed'"
:width="chatWidth"
:class="$style.chat"
:window="pipWindow"
@resize="onResizeChatDebounced"
>
<ChatMessagesPanel
data-test-id="canvas-chat"
:is-open="panelState !== 'closed'"
:messages="messages"
:session-id="currentSessionId"
:past-chat-messages="previousChatMessages"
:show-close-button="false"
:is-new-logs-enabled="true"
@close="handleToggleOpen"
@refresh-session="refreshSession"
@display-execution="displayExecution"
@send-message="sendMessage"
@click-header="handleClickHeader"
/>
</N8nResizeWrapper>
<LogsOverviewPanel :is-open="panelState !== 'closed'" @click-header="handleClickHeader">
<template #actions>
<N8nTooltip
v-if="canPopOut && !isPoppedOut"
:z-index="tooltipZIndex"
:content="locales.baseText('runData.panel.actions.popOut')"
>
<N8nIconButton
icon="pop-out"
type="secondary"
size="small"
icon-size="medium"
@click="onPopOut"
/>
</N8nTooltip>
<N8nTooltip
v-if="panelState !== 'floating'"
:z-index="tooltipZIndex"
:content="
locales.baseText(
panelState === 'attached'
? 'runData.panel.actions.collapse'
: 'runData.panel.actions.open',
)
"
>
<N8nIconButton
type="secondary"
size="small"
icon-size="medium"
:icon="panelState === 'attached' ? 'chevron-down' : 'chevron-up'"
style="color: var(--color-text-base)"
@click.stop="handleToggleOpen"
/>
</N8nTooltip>
</template>
</LogsOverviewPanel>
</div>
</N8nResizeWrapper>
</div>
</div>
</template>
<style lang="scss" module>
@media all and (display-mode: picture-in-picture) {
.resizeWrapper {
height: 100% !important;
max-height: 100vh !important;
}
}
.pipContent {
height: 100%;
position: relative;
overflow: hidden;
}
.resizeWrapper {
height: auto;
min-height: 0;
flex-basis: 0;
border-top: 1px solid var(--color-foreground-base);
background-color: var(--color-background-light);
display: flex;
align-items: stretch;
justify-content: stretch;
&.isOpen {
height: var(--panel-height);
min-height: 4rem;
max-height: 90vh;
flex-basis: content;
}
}
.container {
display: flex;
flex-grow: 1;
& > *:not(:last-child) {
border-right: 1px solid var(--color-foreground-base);
}
}
.chat {
width: var(--chat-width);
flex-shrink: 0;
max-width: 100%;
}
</style>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import PanelHeader from '@/components/CanvasChat/components/PanelHeader.vue';
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
import { useI18n } from '@/composables/useI18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { N8nButton, N8nText, N8nTooltip } from '@n8n/design-system';
defineProps<{ isOpen: boolean }>();
const emit = defineEmits<{ clickHeader: [] }>();
const locale = useI18n();
const workflowsStore = useWorkflowsStore();
const nodeHelpers = useNodeHelpers();
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
defineSlots<{ actions: {} }>();
function onClearExecutionData() {
workflowsStore.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
}
</script>
<template>
<div :class="$style.container">
<PanelHeader
:title="locale.baseText('logs.overview.header.title')"
@click="emit('clickHeader')"
>
<template #actions>
<N8nTooltip
v-if="isClearExecutionButtonVisible"
:content="locale.baseText('logs.overview.header.actions.clearExecution.tooltip')"
>
<N8nButton
size="mini"
type="secondary"
icon="trash"
icon-size="medium"
@click.stop="onClearExecutionData"
>{{ locale.baseText('logs.overview.header.actions.clearExecution') }}</N8nButton
>
</N8nTooltip>
<slot name="actions" />
</template>
</PanelHeader>
<div v-if="isOpen" :class="[$style.content, $style.empty]">
<N8nText tag="p" size="medium" color="text-base" :class="$style.emptyText">
{{ locale.baseText('logs.overview.body.empty.message') }}
</N8nText>
</div>
</div>
</template>
<style lang="scss" module>
.container {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: stretch;
}
.content {
padding: var(--spacing-2xs);
flex-grow: 1;
&.empty {
display: flex;
align-items: center;
justify-content: center;
}
}
.emptyText {
max-width: 20em;
text-align: center;
}
</style>