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

@@ -461,7 +461,6 @@ describe('Langchain Integration', () => {
getManualChatMessages().should('contain', 'this_my_field_1');
cy.getByTestId('refresh-session-button').click();
cy.get('button').contains('Reset').click();
getManualChatMessages().should('not.exist');
sendManualChatMessage('Another test');

View File

@@ -33,6 +33,7 @@ interface ResizeProps {
gridSize?: number;
supportedDirections?: Direction[];
outset?: boolean;
window?: Window;
}
const props = withDefaults(defineProps<ResizeProps>(), {
@@ -44,6 +45,7 @@ const props = withDefaults(defineProps<ResizeProps>(), {
scale: 1,
gridSize: 20,
outset: false,
window: undefined,
supportedDirections: () => [],
});
@@ -125,8 +127,8 @@ const mouseUp = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
emit('resizeend');
window.removeEventListener('mousemove', mouseMove);
window.removeEventListener('mouseup', mouseUp);
(props.window ?? window).removeEventListener('mousemove', mouseMove);
(props.window ?? window).removeEventListener('mouseup', mouseUp);
document.body.style.cursor = 'unset';
state.dir.value = '';
};
@@ -149,8 +151,8 @@ const resizerMove = (event: MouseEvent) => {
state.vHeight.value = props.height;
state.vWidth.value = props.width;
window.addEventListener('mousemove', mouseMove);
window.addEventListener('mouseup', mouseUp);
(props.window ?? window).addEventListener('mousemove', mouseMove);
(props.window ?? window).addEventListener('mouseup', mouseUp);
emit('resizestart');
};
</script>

View File

@@ -4,6 +4,7 @@ import type { PropType } from 'vue';
import type { IN8nButton } from '@n8n/design-system/types';
import { useInjectTooltipAppendTo } from '../../composables/useTooltipAppendTo';
import N8nButton from '../N8nButton';
export type Justify =
@@ -37,10 +38,16 @@ const props = defineProps({
defineOptions({
inheritAttrs: false,
});
const appendTo = useInjectTooltipAppendTo();
</script>
<template>
<ElTooltip v-bind="{ ...props, ...$attrs }" :popper-class="props.popperClass ?? 'n8n-tooltip'">
<ElTooltip
v-bind="{ ...props, ...$attrs }"
:append-to="props.appendTo ?? appendTo"
:popper-class="props.popperClass ?? 'n8n-tooltip'"
>
<slot />
<template #content>
<slot name="content">

View File

@@ -0,0 +1,19 @@
import type { ElTooltip } from 'element-plus';
import { computed, type ComputedRef, inject, type InjectionKey, provide } from 'vue';
const TOOLTIP_APPEND_TO = 'TOOLTIP_APPEND_TO' as unknown as InjectionKey<Value>;
type Value = ComputedRef<InstanceType<typeof ElTooltip>['$props']['appendTo']>;
export function useProvideTooltipAppendTo(el: Value) {
provide(TOOLTIP_APPEND_TO, el);
}
export function useInjectTooltipAppendTo(): Value {
const injected = inject(
TOOLTIP_APPEND_TO,
computed(() => undefined),
);
return injected;
}

View File

@@ -110,6 +110,16 @@ declare global {
getVariant: (name: string) => string | boolean | undefined;
override: (name: string, value: string) => void;
};
// https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture
documentPictureInPicture?: {
window: Window | null;
requestWindow: (options?: {
width?: number;
height?: number;
preferInitialWindowPlacement?: boolean;
disallowReturnToOpener?: boolean;
}) => Promise<Window>;
};
// eslint-disable-next-line @typescript-eslint/naming-convention
Cypress: unknown;
}

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>

View File

@@ -2923,28 +2923,28 @@ describe('useCanvasOperations', () => {
});
describe('toggleChatOpen', () => {
it('should invoke workflowsStore#setPanelOpen with 2nd argument `true` if the chat panel is closed', async () => {
it('should invoke workflowsStore#setPanelState with 1st argument "docked" if the chat panel is closed', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const { toggleChatOpen } = useCanvasOperations({ router });
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
workflowsStore.isChatPanelOpen = false;
workflowsStore.chatPanelState = 'closed';
await toggleChatOpen('main');
expect(workflowsStore.setPanelOpen).toHaveBeenCalledWith('chat', true);
expect(workflowsStore.setPanelState).toHaveBeenCalledWith('attached');
});
it('should invoke workflowsStore#setPanelOpen with 2nd argument `false` if the chat panel is open', async () => {
it('should invoke workflowsStore#setPanelState with 1st argument "collapsed" if the chat panel is open', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const { toggleChatOpen } = useCanvasOperations({ router });
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
workflowsStore.isChatPanelOpen = true;
workflowsStore.chatPanelState = 'attached';
await toggleChatOpen('main');
expect(workflowsStore.setPanelOpen).toHaveBeenCalledWith('chat', false);
expect(workflowsStore.setPanelState).toHaveBeenCalledWith('closed');
});
});

View File

@@ -1970,7 +1970,9 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
async function toggleChatOpen(source: 'node' | 'main') {
const workflow = workflowsStore.getCurrentWorkflow();
workflowsStore.setPanelOpen('chat', !workflowsStore.isChatPanelOpen);
workflowsStore.setPanelState(
workflowsStore.chatPanelState === 'closed' ? 'attached' : 'closed',
);
const payload = {
workflow_id: workflow.id,

View File

@@ -1,6 +1,7 @@
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, inject, onBeforeUnmount, onMounted, ref, unref } from 'vue';
import { useClipboard as useClipboardCore } from '@vueuse/core';
import { useDebounce } from '@/composables/useDebounce';
import { IsInPiPWindowSymbol } from '@/constants';
type ClipboardEventFn = (data: string, event?: ClipboardEvent) => void;
@@ -12,6 +13,7 @@ export function useClipboard(
},
) {
const { debounce } = useDebounce();
const isInPiPWindow = inject(IsInPiPWindowSymbol, false);
const { copy, copied, isSupported, text } = useClipboardCore({ legacy: true });
const ignoreClasses = ['el-messsage-box', 'ignore-key-press-canvas'];
@@ -76,7 +78,9 @@ export function useClipboard(
return {
copy,
copied,
isSupported,
// When the `copy()` method is invoked from inside of the document picture-in-picture (PiP) window, it throws the error "Document is not focused".
// Therefore, we disable copying features in the PiP window for now.
isSupported: computed(() => isSupported && !unref(isInPiPWindow)),
text,
onPaste: onPasteCallback,
};

View File

@@ -182,7 +182,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
// and halt the execution
if (!chatHasInputData && !chatHasPinData) {
workflowsStore.chatPartialExecutionDestinationNode = options.destinationNode;
workflowsStore.setPanelOpen('chat', true);
workflowsStore.setPanelState('attached');
return;
}
}

View File

@@ -36,7 +36,8 @@ export function useToast() {
dangerouslyUseHTMLString: true,
position: 'bottom-right',
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
offset: settingsStore.isAiAssistantEnabled || workflowsStore.isChatPanelOpen ? 64 : 0,
offset:
settingsStore.isAiAssistantEnabled || workflowsStore.chatPanelState === 'attached' ? 64 : 0,
appendTo: '#app-grid',
customClass: 'content-toast',
};

View File

@@ -9,7 +9,7 @@ import type {
CanvasNodeHandleInjectionData,
CanvasNodeInjectionData,
} from '@/types';
import type { InjectionKey } from 'vue';
import type { InjectionKey, MaybeRefOrGetter } from 'vue';
export const MAX_WORKFLOW_SIZE = 1024 * 1024 * 16; // Workflow size limit in bytes
export const MAX_EXPECTED_REQUEST_SIZE = 2048; // Expected maximum workflow request metadata (i.e. headers) size in bytes
@@ -909,6 +909,9 @@ export const CanvasKey = 'canvas' as unknown as InjectionKey<CanvasInjectionData
export const CanvasNodeKey = 'canvasNode' as unknown as InjectionKey<CanvasNodeInjectionData>;
export const CanvasNodeHandleKey =
'canvasNodeHandle' as unknown as InjectionKey<CanvasNodeHandleInjectionData>;
export const IsInPiPWindowSymbol = 'IsInPipWindow' as unknown as InjectionKey<
MaybeRefOrGetter<boolean>
>;
/** Auth */
export const BROWSER_ID_STORAGE_KEY = 'n8n-browserId';

View File

@@ -209,9 +209,7 @@
"chat.window.chat.unpinAndExecute.cancel": "Cancel",
"chat.window.chat.response.empty": "[No response. Make sure the last executed node outputs the content to display here]",
"chat.window.session.title": "Session",
"chat.window.session.reset.title": "Reset session?",
"chat.window.session.reset.warning": "This will clear all chat messages and the current execution data",
"chat.window.session.reset.confirm": "Reset",
"chat.window.session.reset": "Reset",
"chatEmbed.infoTip.description": "Add chat to external applications using the n8n chat package.",
"chatEmbed.infoTip.link": "More info",
"chatEmbed.title": "Embed Chat in your website",

View File

@@ -51,7 +51,7 @@ export const faRefresh: IconDefinition = {
export const faTriangle: IconDefinition = {
prefix: 'fas',
iconName: 'triangle',
iconName: 'triangle' as IconName,
icon: [
512,
512,
@@ -132,3 +132,15 @@ export const statusUnknown: IconDefinition = {
'M13.8668 8.36613L11.9048 7.978C11.967 7.66329 12 7.33649 12 7C12 6.66351 11.967 6.3367 11.9048 6.022L13.8668 5.63387C13.9542 6.07571 14 6.5325 14 7C14 7.4675 13.9542 7.92429 13.8668 8.36613ZM12.821 3.11069L11.159 4.22333C10.7934 3.67721 10.3228 3.2066 9.77667 2.84098L10.8893 1.17904C11.6527 1.6901 12.3099 2.34733 12.821 3.11069ZM8.36613 0.133238L7.978 2.09521C7.66329 2.03296 7.33649 2 7 2C6.66351 2 6.3367 2.03296 6.022 2.09521L5.63387 0.133238C6.07571 0.0458286 6.5325 0 7 0C7.4675 0 7.92429 0.0458285 8.36613 0.133238ZM3.11069 1.17904L4.22333 2.84098C3.67721 3.2066 3.2066 3.67721 2.84098 4.22333L1.17904 3.11069C1.6901 2.34733 2.34733 1.6901 3.11069 1.17904ZM0.133238 5.63387C0.0458285 6.07571 0 6.5325 0 7C0 7.4675 0.0458286 7.92429 0.133238 8.36613L2.09521 7.978C2.03296 7.6633 2 7.33649 2 7C2 6.66351 2.03296 6.33671 2.09521 6.022L0.133238 5.63387ZM1.17904 10.8893L2.84098 9.77667C3.2066 10.3228 3.67721 10.7934 4.22333 11.159L3.11069 12.821C2.34733 12.3099 1.6901 11.6527 1.17904 10.8893ZM5.63387 13.8668L6.022 11.9048C6.33671 11.967 6.66351 12 7 12C7.33649 12 7.6633 11.967 7.978 11.9048L8.36613 13.8668C7.92429 13.9542 7.4675 14 7 14C6.5325 14 6.07571 13.9542 5.63387 13.8668ZM10.8893 12.821L9.77667 11.159C10.3228 10.7934 10.7934 10.3228 11.159 9.77667L12.821 10.8893C12.3099 11.6527 11.6527 12.3099 10.8893 12.821Z',
],
};
export const faPopOut: IconDefinition = {
prefix: 'fas',
iconName: 'pop-out' as IconName,
icon: [
16,
16,
[],
'',
'M13.3333 12.5525V12.4489C14.2278 12.0756 14.8571 11.1925 14.8571 10.1632V3.61924C14.8571 2.96252 14.5962 2.3327 14.1318 1.86832C13.6675 1.40395 13.0376 1.14307 12.3809 1.14307H5.90473C5.38113 1.14296 4.87098 1.30883 4.44756 1.61684C4.02414 1.92485 3.70926 2.35915 3.54816 2.85734H3.39501C2.70016 2.85734 2.10892 3.10191 1.70206 3.5842C1.30739 4.05124 1.14282 4.67372 1.14282 5.33352V12.0002C1.14282 12.8078 1.43463 13.5346 1.98854 14.0573C2.54168 14.5777 3.30892 14.8535 4.19044 14.8535H7.17711L10.2826 14.8573H10.2842C11.0278 14.8611 11.7645 14.7049 12.336 14.3392C12.9303 13.9582 13.3333 13.3525 13.3333 12.5525ZM3.39501 4.0002H3.42854V10.1625C3.42854 10.8192 3.68942 11.449 4.1538 11.9134C4.61817 12.3777 5.248 12.6386 5.90473 12.6386H12.1874C12.163 12.9571 12.003 13.1948 11.7196 13.3761C11.3897 13.588 10.8891 13.7175 10.2887 13.7144H10.2857L7.17558 13.7106H4.19044C3.54816 13.7106 3.07806 13.5125 2.7733 13.2253C2.47006 12.9403 2.28568 12.5259 2.28568 12.0002V5.33352C2.28568 4.84971 2.40758 4.52057 2.5752 4.32096C2.73139 4.13658 2.98054 4.0002 3.39501 4.0002ZM8.01673 3.80972H11.619C11.7706 3.80972 11.9159 3.86992 12.0231 3.97709C12.1302 4.08425 12.1904 4.22959 12.1904 4.38115V7.98418C12.1904 8.13573 12.1302 8.28107 12.0231 8.38823C11.9159 8.4954 11.7706 8.5556 11.619 8.5556C11.4675 8.5556 11.3221 8.4954 11.215 8.38823C11.1078 8.28107 11.0476 8.13573 11.0476 7.98418V5.76019L7.07044 9.73731C7.0177 9.79186 6.95463 9.83536 6.8849 9.86528C6.81517 9.89519 6.74018 9.91092 6.6643 9.91154C6.58843 9.91217 6.51319 9.89767 6.44298 9.86891C6.37277 9.84014 6.30899 9.79768 6.25536 9.74401C6.20173 9.69033 6.15933 9.62651 6.13063 9.55627C6.10193 9.48603 6.08751 9.41078 6.0882 9.3349C6.0889 9.25903 6.1047 9.18406 6.13468 9.11435C6.16466 9.04465 6.20822 8.98162 6.26282 8.92893L10.24 4.95257H8.01673C7.86517 4.95257 7.71983 4.89237 7.61267 4.7852C7.5055 4.67804 7.4453 4.5327 7.4453 4.38115C7.4453 4.22959 7.5055 4.08425 7.61267 3.97709C7.71983 3.86992 7.86517 3.80972 8.01673 3.80972Z',
],
};

View File

@@ -184,6 +184,7 @@ import {
statusCanceled,
statusNew,
statusUnknown,
faPopOut,
} from './custom';
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
@@ -377,6 +378,8 @@ export const FontAwesomePlugin: Plugin = {
addIcon(statusNew);
addIcon(statusUnknown);
addIcon(faPopOut);
app.component('FontAwesomeIcon', FontAwesomeIcon);
},
};

View File

@@ -116,6 +116,8 @@ const createEmptyWorkflow = (): IWorkflowDb => ({
let cachedWorkflowKey: string | null = '';
let cachedWorkflow: Workflow | null = null;
type ChatPanelState = 'closed' | 'attached' | 'floating';
export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const uiStore = useUIStore();
const telemetry = useTelemetry();
@@ -145,8 +147,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const isInDebugMode = ref(false);
const chatMessages = ref<string[]>([]);
const chatPartialExecutionDestinationNode = ref<string | null>(null);
const isChatPanelOpen = ref(false);
const isLogsPanelOpen = ref(false);
const chatPanelState = ref<ChatPanelState>('closed');
const { executingNode, addExecutingNode, removeExecutingNode, clearNodeExecutionQueue } =
useExecutingNode();
@@ -1207,7 +1208,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
// If chat trigger node is removed, close chat
if (node.type === CHAT_TRIGGER_NODE_TYPE) {
setPanelOpen('chat', false);
setPanelState('closed');
}
if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) {
@@ -1669,12 +1670,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
// End Canvas V2 Functions
//
function setPanelOpen(panel: 'chat' | 'logs', isOpen: boolean) {
if (panel === 'chat') {
isChatPanelOpen.value = isOpen;
}
// Logs panel open/close is tied to the chat panel open/close
isLogsPanelOpen.value = isOpen;
function setPanelState(state: ChatPanelState) {
chatPanelState.value = state;
}
function markExecutionAsStopped() {
@@ -1736,9 +1733,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
getAllLoadedFinishedExecutions,
getWorkflowExecution,
getPastChatMessages,
isChatPanelOpen: computed(() => isChatPanelOpen.value),
isLogsPanelOpen: computed(() => isLogsPanelOpen.value),
setPanelOpen,
chatPanelState: computed(() => chatPanelState.value),
setPanelState,
outgoingConnectionsByNodeName,
incomingConnectionsByNodeName,
nodeHasOutputConnection,

View File

@@ -272,7 +272,7 @@ const keyBindingsEnabled = computed(() => {
return !ndvStore.activeNode && uiStore.activeModals.length === 0;
});
const isChatOpen = computed(() => workflowsStore.isChatPanelOpen);
const isChatOpen = computed(() => workflowsStore.chatPanelState !== 'closed');
/**
* Initialization