Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Draft
wants to merge 22 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes in this file makes it possible to listen to the right window object when the resizer is used in a PiP window

Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface ResizeProps {
gridSize?: number;
supportedDirections?: Direction[];
outset?: boolean;
windowObj?: Window;
}

const props = withDefaults(defineProps<ResizeProps>(), {
Expand All @@ -44,6 +45,7 @@ const props = withDefaults(defineProps<ResizeProps>(), {
scale: 1,
gridSize: 20,
outset: false,
windowObj: undefined,
supportedDirections: () => [],
});

Expand Down Expand Up @@ -125,8 +127,8 @@ const mouseUp = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
emit('resizeend');
window.removeEventListener('mousemove', mouseMove);
window.removeEventListener('mouseup', mouseUp);
(props.windowObj ?? window).removeEventListener('mousemove', mouseMove);
(props.windowObj ?? window).removeEventListener('mouseup', mouseUp);
document.body.style.cursor = 'unset';
state.dir.value = '';
};
Expand All @@ -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.windowObj ?? window).addEventListener('mousemove', mouseMove);
(props.windowObj ?? window).addEventListener('mouseup', mouseUp);
emit('resizestart');
};
</script>
Expand Down
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes in this file are needed to show tooltips properly in a PiP window.
I'm using provide/inject instead of props so that I don't have to modify individual usages of the tooltip component.

Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions packages/frontend/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed with product to remove the confirmation dialog of clearing the chat history. In the future we'll need to make the dialog work in PiP window as well. Right now it always shows in the main window even when the user is interacting in PiP window.

Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Expand Down
111 changes: 68 additions & 43 deletions packages/frontend/editor-ui/src/components/CanvasChat/CanvasChat.vue
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -26,6 +26,8 @@ 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';
const workflowsStore = useWorkflowsStore();
const canvasStore = useCanvasStore();
Expand All @@ -38,6 +40,8 @@ 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());
Expand Down Expand Up @@ -97,6 +101,8 @@ const {
onWindowResize,
} = useResize(container);
const { canPopOut, isPoppedOut, pipWindow, onPopOut } = usePiPWindow(pipContainer, pipContent);
// Extracted pure functions for better testability
function createChatConfig(params: {
messages: Chat['messages'];
Expand Down Expand Up @@ -267,54 +273,73 @@ watchEffect(() => {
</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 && (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">
<N8nResizeWrapper
v-if="isChatOpen"
:supported-directions="['right']"
:width="chatWidth"
:class="$style.chat"
:window-obj="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="isLogsOpen && connectedNode" :class="$style.logs">
<ChatLogsPanel
:key="`${resultData?.length ?? messages?.length}`"
:workflow="workflow"
:is-popped-out="isPoppedOut"
:can-pop-out="canPopOut"
data-test-id="canvas-chat-logs"
:node="connectedNode"
:slim="logsWidth < 700"
@close="closePanel"
@request-pop-out="onPopOut"
/>
</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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import { useI18n } from '@/composables/useI18n';

const emit = defineEmits<{
close: [];
requestPopOut: [];
}>();

defineProps<{
node: INode | null;
slim?: boolean;
workflow: Workflow;
canPopOut: boolean;
isPoppedOut: boolean;
}>();

const locale = useI18n();
Expand All @@ -27,14 +30,23 @@ const locale = useI18n();
}}
</span>
</div>
<n8n-icon-button
:class="$style.close"
outline
icon="times"
type="secondary"
size="mini"
@click="emit('close')"
/>
<div :class="$style.buttons">
<n8n-icon-button
v-if="canPopOut && !isPoppedOut"
icon="pop-out"
type="secondary"
size="medium"
@click="emit('requestPopOut')"
/>
<n8n-icon-button
v-if="!isPoppedOut"
outline
icon="times"
type="secondary"
size="medium"
@click="emit('close')"
/>
</div>
</header>
<div :class="$style.logs">
<RunDataAi
Expand Down Expand Up @@ -62,10 +74,6 @@ const locale = useI18n();
justify-content: space-between;
align-items: center;

.close {
border: none;
}

span {
font-weight: 100;
}
Expand All @@ -88,4 +96,13 @@ const locale = useI18n();
flex-grow: 1;
overflow: auto;
}

.buttons {
display: flex;
align-items: center;

& button {
border: none;
}
}
</style>
Loading