feat(editor): Popping logs out into a new window (#13788)

This commit is contained in:
autologie
2025-03-17 10:50:51 +01:00
committed by GitHub
parent 4a1e5798ff
commit 4d04c227a9
23 changed files with 459 additions and 149 deletions

View File

@@ -174,7 +174,7 @@ describe('CanvasChat', () => {
return matchedNode;
});
workflowsStore.isChatPanelOpen = true;
workflowsStore.chatPanelState = 'attached';
workflowsStore.isLogsPanelOpen = true;
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
@@ -197,7 +197,7 @@ describe('CanvasChat', () => {
});
it('should not render chat when panel is closed', async () => {
workflowsStore.isChatPanelOpen = false;
workflowsStore.chatPanelState = 'closed';
const { queryByTestId } = renderComponent();
await waitFor(() => {
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
@@ -338,17 +338,12 @@ describe('CanvasChat', () => {
});
});
it('should refresh session with confirmation when messages exist', async () => {
const { getByTestId, getByRole } = renderComponent();
it('should refresh session when messages exist', async () => {
const { getByTestId } = renderComponent();
const originalSessionId = getByTestId('chat-session-id').textContent;
await userEvent.click(getByTestId('refresh-session-button'));
const confirmButton = getByRole('dialog').querySelector('button.btn--confirm');
if (!confirmButton) throw new Error('Confirm button not found');
await userEvent.click(confirmButton);
expect(getByTestId('chat-session-id').textContent).not.toEqual(originalSessionId);
});
});
@@ -392,7 +387,7 @@ describe('CanvasChat', () => {
isLoading: computed(() => false),
});
workflowsStore.isChatPanelOpen = true;
workflowsStore.chatPanelState = 'attached';
workflowsStore.allowFileUploads = true;
});
@@ -554,7 +549,7 @@ describe('CanvasChat', () => {
});
// Close chat panel
workflowsStore.isChatPanelOpen = false;
workflowsStore.chatPanelState = 'closed';
await waitFor(() => {
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
});
@@ -564,14 +559,14 @@ describe('CanvasChat', () => {
const { unmount, rerender } = renderComponent();
// Set initial state
workflowsStore.isChatPanelOpen = true;
workflowsStore.chatPanelState = 'attached';
workflowsStore.isLogsPanelOpen = true;
// Unmount and remount
unmount();
await rerender({});
expect(workflowsStore.isChatPanelOpen).toBe(true);
expect(workflowsStore.chatPanelState).toBe('attached');
expect(workflowsStore.isLogsPanelOpen).toBe(true);
});
});
@@ -597,10 +592,10 @@ describe('CanvasChat', () => {
getChatMessages: getChatMessagesSpy,
});
workflowsStore.isChatPanelOpen = false;
workflowsStore.chatPanelState = 'closed';
const { rerender } = renderComponent();
workflowsStore.isChatPanelOpen = true;
workflowsStore.chatPanelState = 'attached';
await rerender({});
expect(getChatMessagesSpy).toHaveBeenCalled();

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import { provide, watch, computed, ref, watchEffect } 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';
@@ -26,6 +26,9 @@ 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';
const workflowsStore = useWorkflowsStore();
const canvasStore = useCanvasStore();
@@ -38,17 +41,15 @@ const messages = ref<ChatMessage[]>([]);
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
const isDisabled = ref(false);
const container = ref<HTMLElement>();
const pipContainer = useTemplateRef('pipContainer');
const pipContent = useTemplateRef('pipContent');
// Computed properties
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const allConnections = computed(() => workflowsStore.allConnections);
const isChatOpen = computed(() => {
const result = workflowsStore.isChatPanelOpen;
return result;
});
const chatPanelState = computed(() => workflowsStore.chatPanelState);
const canvasNodes = computed(() => workflowsStore.allNodes);
const isLogsOpen = computed(() => workflowsStore.isLogsPanelOpen);
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const resultData = computed(() => workflowsStore.getWorkflowRunData);
// Expose internal state for testing
@@ -59,6 +60,8 @@ defineExpose({
workflow,
});
const telemetry = useTelemetry();
const { runWorkflow } = useRunWorkflow({ router });
// Initialize features with injected dependencies
@@ -97,6 +100,22 @@ const {
onWindowResize,
} = useResize(container);
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
initialHeight: 400,
initialWidth: window.document.body.offsetWidth * 0.8,
container: pipContainer,
content: pipContent,
shouldPopOut: computed(() => chatPanelState.value === 'floating'),
onRequestClose: () => {
if (chatPanelState.value === 'closed') {
return;
}
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPanelState('attached');
},
});
// Extracted pure functions for better testability
function createChatConfig(params: {
messages: Chat['messages'];
@@ -169,7 +188,7 @@ const handleRefreshSession = () => {
};
const closePanel = () => {
workflowsStore.setPanelOpen('chat', false);
workflowsStore.setPanelState('closed');
};
// This function creates a promise that resolves when the workflow execution completes
@@ -211,6 +230,11 @@ async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
return;
}
function onPopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' });
workflowsStore.setPanelState('floating');
}
// Initialize chat config
const { chatConfig, chatOptions } = createChatConfig({
messages,
@@ -228,9 +252,9 @@ provide(ChatOptionsSymbol, chatOptions);
// Watchers
watch(
() => isChatOpen.value,
(isOpen) => {
if (isOpen) {
chatPanelState,
(state) => {
if (state !== 'closed') {
setChatTriggerNode();
setConnectedNode();
@@ -262,59 +286,91 @@ watch(
);
watchEffect(() => {
canvasStore.setPanelHeight(isChatOpen.value || isLogsOpen.value ? height.value : 0);
canvasStore.setPanelHeight(chatPanelState.value === 'attached' ? height.value : 0);
});
</script>
<template>
<N8nResizeWrapper
v-if="chatTriggerNode"
:is-resizing-enabled="isChatOpen || isLogsOpen"
:supported-directions="['top']"
:class="[$style.resizeWrapper, !isChatOpen && !isLogsOpen && $style.empty]"
:height="height"
:style="rootStyles"
@resize="onResizeDebounced"
>
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
<div v-if="isChatOpen || isLogsOpen" :class="$style.chatResizer">
<n8n-resize-wrapper
v-if="isChatOpen"
:supported-directions="['right']"
:width="chatWidth"
:class="$style.chat"
@resize="onResizeChatDebounced"
>
<div :class="$style.inner">
<ChatMessagesPanel
data-test-id="canvas-chat"
:messages="messages"
:session-id="currentSessionId"
:past-chat-messages="previousChatMessages"
:show-close-button="!connectedNode"
@close="closePanel"
@refresh-session="handleRefreshSession"
@display-execution="handleDisplayExecution"
@send-message="sendMessage"
/>
<div ref="pipContainer">
<div ref="pipContent" :class="$style.pipContent">
<N8nResizeWrapper
v-if="chatTriggerNode"
:is-resizing-enabled="!isPoppedOut && chatPanelState === 'attached'"
:supported-directions="['top']"
:class="[$style.resizeWrapper, chatPanelState === 'closed' && $style.empty]"
:height="height"
:style="rootStyles"
@resize="onResizeDebounced"
>
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
<div v-if="chatPanelState !== 'closed'" :class="$style.chatResizer">
<N8nResizeWrapper
:supported-directions="['right']"
:width="chatWidth"
:class="$style.chat"
:window="pipWindow"
@resize="onResizeChatDebounced"
>
<div :class="$style.inner">
<ChatMessagesPanel
data-test-id="canvas-chat"
:messages="messages"
:session-id="currentSessionId"
:past-chat-messages="previousChatMessages"
:show-close-button="!isPoppedOut && !connectedNode"
@close="closePanel"
@refresh-session="handleRefreshSession"
@display-execution="handleDisplayExecution"
@send-message="sendMessage"
/>
</div>
</N8nResizeWrapper>
<div v-if="connectedNode" :class="$style.logs">
<ChatLogsPanel
:key="`${resultData?.length ?? messages?.length}`"
:workflow="workflow"
data-test-id="canvas-chat-logs"
:node="connectedNode"
:slim="logsWidth < 700"
>
<template #actions>
<n8n-icon-button
v-if="canPopOut && !isPoppedOut"
icon="pop-out"
type="secondary"
size="medium"
@click="onPopOut"
/>
<n8n-icon-button
v-if="!isPoppedOut"
outline
icon="times"
type="secondary"
size="medium"
@click="closePanel"
/>
</template>
</ChatLogsPanel>
</div>
</div>
</n8n-resize-wrapper>
<div v-if="isLogsOpen && connectedNode" :class="$style.logs">
<ChatLogsPanel
:key="`${resultData?.length ?? messages?.length}`"
:workflow="workflow"
data-test-id="canvas-chat-logs"
:node="connectedNode"
:slim="logsWidth < 700"
@close="closePanel"
/>
</div>
</div>
</N8nResizeWrapper>
</div>
</N8nResizeWrapper>
</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%;
}
.resizeWrapper {
height: var(--panel-height);
min-height: 4rem;

View File

@@ -3,16 +3,14 @@ import type { INode, Workflow } from 'n8n-workflow';
import RunDataAi from '@/components/RunDataAi/RunDataAi.vue';
import { useI18n } from '@/composables/useI18n';
const emit = defineEmits<{
close: [];
}>();
defineProps<{
node: INode | null;
slim?: boolean;
workflow: Workflow;
}>();
defineSlots<{ actions: {} }>();
const locale = useI18n();
</script>
@@ -27,14 +25,9 @@ const locale = useI18n();
}}
</span>
</div>
<n8n-icon-button
:class="$style.close"
outline
icon="times"
type="secondary"
size="mini"
@click="emit('close')"
/>
<div :class="$style.actions">
<slot name="actions"></slot>
</div>
</header>
<div :class="$style.logs">
<RunDataAi
@@ -62,10 +55,6 @@ const locale = useI18n();
justify-content: space-between;
align-items: center;
.close {
border: none;
}
span {
font-weight: 100;
}
@@ -88,4 +77,13 @@ const locale = useI18n();
flex-grow: 1;
overflow: auto;
}
.actions {
display: flex;
align-items: center;
button {
border: none;
}
}
</style>

View File

@@ -7,8 +7,6 @@ import MessageOptionAction from './MessageOptionAction.vue';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
import ChatInput from '@n8n/chat/components/Input.vue';
import { useMessage } from '@/composables/useMessage';
import { MODAL_CONFIRM } from '@/constants';
import { computed, ref } from 'vue';
import { useClipboard } from '@/composables/useClipboard';
import { useToast } from '@/composables/useToast';
@@ -29,7 +27,6 @@ const emit = defineEmits<{
close: [];
}>();
const messageComposable = useMessage();
const clipboard = useClipboard();
const locale = useI18n();
const toast = useToast();
@@ -62,25 +59,8 @@ function sendMessage(message: string) {
emit('sendMessage', message);
}
async function onRefreshSession() {
// If there are no messages, refresh the session without asking
if (props.messages.length === 0) {
emit('refreshSession');
return;
}
const confirmResult = await messageComposable.confirm(
locale.baseText('chat.window.session.reset.warning'),
{
title: locale.baseText('chat.window.session.reset.title'),
type: 'warning',
confirmButtonText: locale.baseText('chat.window.session.reset.confirm'),
showClose: true,
},
);
if (confirmResult === MODAL_CONFIRM) {
emit('refreshSession');
}
function onRefreshSession() {
emit('refreshSession');
}
function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
@@ -131,8 +111,9 @@ function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
previousMessageIndex.value = 0;
}
}
function copySessionId() {
void clipboard.copy(props.sessionId);
async function copySessionId() {
await clipboard.copy(props.sessionId);
toast.showMessage({
title: locale.baseText('generic.copiedToClipboard'),
message: '',
@@ -151,9 +132,12 @@ function copySessionId() {
<template #content>
{{ sessionId }}
</template>
<span :class="$style.sessionId" data-test-id="chat-session-id" @click="copySessionId">{{
sessionId
}}</span>
<span
:class="[$style.sessionId, clipboard.isSupported.value ? $style.copyable : '']"
data-test-id="chat-session-id"
@click="clipboard.isSupported.value ? copySessionId() : null"
>{{ sessionId }}</span
>
</n8n-tooltip>
<n8n-icon-button
:class="$style.headerButton"
@@ -162,7 +146,7 @@ function copySessionId() {
type="secondary"
size="mini"
icon="undo"
:title="locale.baseText('chat.window.session.reset.confirm')"
:title="locale.baseText('chat.window.session.reset')"
@click="onRefreshSession"
/>
<n8n-icon-button
@@ -293,7 +277,9 @@ function copySessionId() {
text-overflow: ellipsis;
overflow: hidden;
cursor: pointer;
&.copyable {
cursor: pointer;
}
}
.headerButton {
max-height: 1.1rem;

View File

@@ -0,0 +1,110 @@
/* eslint-disable vue/one-component-per-file */
import { computed, defineComponent, h, ref } from 'vue';
import { usePiPWindow } from './usePiPWindow';
import { waitFor } from '@testing-library/vue';
import { renderComponent } from '@/__tests__/render';
describe(usePiPWindow, () => {
const documentPictureInPicture: NonNullable<Window['documentPictureInPicture']> = {
window: null,
requestWindow: async () =>
({
document: { body: { append: vi.fn(), removeChild: vi.fn() } },
addEventListener: vi.fn(),
close: vi.fn(),
}) as unknown as Window,
};
describe('canPopOut', () => {
it('should return false if window.documentPictureInPicture is not available', () => {
const MyComponent = defineComponent({
setup() {
const container = ref<HTMLDivElement | null>(null);
const content = ref<HTMLDivElement | null>(null);
const pipWindow = usePiPWindow({
container,
content,
shouldPopOut: computed(() => true),
onRequestClose: vi.fn(),
});
return () =>
h(
'div',
{ ref: container },
h('div', { ref: content }, String(pipWindow.canPopOut.value)),
);
},
});
const { queryByText } = renderComponent(MyComponent);
expect(queryByText('false')).toBeInTheDocument();
});
it('should return true if window.documentPictureInPicture is available', () => {
Object.assign(window, { documentPictureInPicture });
const MyComponent = defineComponent({
setup() {
const container = ref<HTMLDivElement | null>(null);
const content = ref<HTMLDivElement | null>(null);
const pipWindow = usePiPWindow({
container,
content,
shouldPopOut: computed(() => true),
onRequestClose: vi.fn(),
});
return () =>
h(
'div',
{ ref: container },
h('div', { ref: content }, String(pipWindow.canPopOut.value)),
);
},
});
const { queryByText } = renderComponent(MyComponent);
expect(queryByText('true')).toBeInTheDocument();
});
});
describe('isPoppedOut', () => {
beforeEach(() => {
Object.assign(window, { documentPictureInPicture });
});
it('should be set to true when popped out', async () => {
const shouldPopOut = ref(false);
const MyComponent = defineComponent({
setup() {
const container = ref<HTMLDivElement | null>(null);
const content = ref<HTMLDivElement | null>(null);
const pipWindow = usePiPWindow({
container,
content,
shouldPopOut: computed(() => shouldPopOut.value),
onRequestClose: vi.fn(),
});
return () =>
h(
'div',
{ ref: container },
h('div', { ref: content }, String(pipWindow.isPoppedOut.value)),
);
},
});
const { queryByText } = renderComponent(MyComponent);
expect(queryByText('false')).toBeInTheDocument();
shouldPopOut.value = true;
await waitFor(() => expect(queryByText('true')).toBeInTheDocument());
});
});
});

View File

@@ -0,0 +1,109 @@
import { IsInPiPWindowSymbol } from '@/constants';
import { useProvideTooltipAppendTo } from '@n8n/design-system/composables/useTooltipAppendTo';
import {
computed,
type ComputedRef,
onBeforeUnmount,
provide,
type Ref,
ref,
type ShallowRef,
watch,
} from 'vue';
interface UsePiPWindowOptions {
initialWidth?: number;
initialHeight?: number;
container: Readonly<ShallowRef<HTMLDivElement | null>>;
content: Readonly<ShallowRef<HTMLDivElement | null>>;
shouldPopOut: ComputedRef<boolean>;
onRequestClose: () => void;
}
interface UsePiPWindowReturn {
isPoppedOut: ComputedRef<boolean>;
canPopOut: ComputedRef<boolean>;
pipWindow?: Ref<Window | undefined>;
}
/**
* A composable that allows to pop out given content in document PiP (picture-in-picture) window
*/
export function usePiPWindow({
container,
content,
initialHeight,
initialWidth,
shouldPopOut,
onRequestClose,
}: UsePiPWindowOptions): UsePiPWindowReturn {
const pipWindow = ref<Window>();
const isUnmounting = ref(false);
const canPopOut = computed(() => !!window.documentPictureInPicture);
const isPoppedOut = computed(() => !!pipWindow.value);
const tooltipContainer = computed(() =>
isPoppedOut.value ? (content.value ?? undefined) : undefined,
);
provide(IsInPiPWindowSymbol, isPoppedOut);
useProvideTooltipAppendTo(tooltipContainer);
async function showPip() {
if (!content.value) {
return;
}
pipWindow.value =
pipWindow.value ??
(await window.documentPictureInPicture?.requestWindow({
width: initialWidth,
height: initialHeight,
disallowReturnToOpener: true,
}));
// Copy style sheets over from the initial document
// so that the content looks the same.
[...document.styleSheets].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
const style = document.createElement('style');
style.textContent = cssRules;
pipWindow.value?.document.head.appendChild(style);
} catch (e) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = styleSheet.type;
link.media = styleSheet.media as unknown as string;
link.href = styleSheet.href as string;
pipWindow.value?.document.head.appendChild(link);
}
});
// Move the content to the Picture-in-Picture window.
pipWindow.value?.document.body.append(content.value);
pipWindow.value?.addEventListener('pagehide', () => !isUnmounting.value && onRequestClose());
}
function hidePiP() {
pipWindow.value?.close();
pipWindow.value = undefined;
if (content.value) {
container.value?.appendChild(content.value);
}
}
// `requestAnimationFrame()` to make sure the content is already rendered
watch(shouldPopOut, (value) => (value ? requestAnimationFrame(showPip) : hidePiP()), {
immediate: true,
});
onBeforeUnmount(() => {
isUnmounting.value = true;
pipWindow.value?.close();
});
return { canPopOut, isPoppedOut, pipWindow };
}

View File

@@ -39,7 +39,7 @@ const uiStore = useUIStore();
const { runEntireWorkflow } = useRunWorkflow({ router });
const { toggleChatOpen } = useCanvasOperations({ router });
const isChatOpen = computed(() => workflowsStore.isChatPanelOpen);
const isChatOpen = computed(() => workflowsStore.chatPanelState !== 'closed');
const isExecuting = computed(() => uiStore.isActionActive.workflowRunning);
const testId = computed(() => `execute-workflow-button-${name}`);
</script>