feat(editor): Keyboard shortcuts for the log view (#15378)

This commit is contained in:
Suguru Inoue
2025-05-16 13:54:25 +02:00
committed by GitHub
parent de4c5fc716
commit 1935e62adf
40 changed files with 649 additions and 435 deletions

View File

@@ -2,7 +2,7 @@
import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import { useAssistantStore } from '@/stores/assistant.store';
import { useCanvasStore } from '@/stores/canvas.store';
import { useLogsStore } from '@/stores/logs.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';
@@ -10,7 +10,7 @@ import { computed } from 'vue';
const assistantStore = useAssistantStore();
const i18n = useI18n();
const { APP_Z_INDEXES } = useStyles();
const canvasStore = useCanvasStore();
const logsStore = useLogsStore();
const lastUnread = computed(() => {
const msg = assistantStore.lastUnread;
@@ -41,7 +41,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="{ '--canvas-panel-height-offset': `${logsStore.height}px` }"
>
<n8n-tooltip
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"

View File

@@ -16,7 +16,6 @@ import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import { chatEventBus } from '@n8n/chat/event-buses';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCanvasStore } from '@/stores/canvas.store';
import * as useChatMessaging from './composables/useChatMessaging';
import * as useChatTrigger from './composables/useChatTrigger';
import { useToast } from '@/composables/useToast';
@@ -25,6 +24,7 @@ import type { IExecutionResponse, INodeUi } from '@/Interface';
import type { ChatMessage } from '@n8n/chat/types';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { LOGS_PANEL_STATE } from './types/logs';
import { useLogsStore } from '@/stores/logs.store';
vi.mock('@/composables/useToast', () => {
const showMessage = vi.fn();
@@ -139,7 +139,7 @@ describe('CanvasChat', () => {
});
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let canvasStore: ReturnType<typeof mockedStore<typeof useCanvasStore>>;
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
beforeEach(() => {
@@ -160,7 +160,7 @@ describe('CanvasChat', () => {
setActivePinia(pinia);
workflowsStore = mockedStore(useWorkflowsStore);
canvasStore = mockedStore(useCanvasStore);
logsStore = mockedStore(useLogsStore);
nodeTypeStore = mockedStore(useNodeTypesStore);
// Setup default mocks
@@ -175,11 +175,12 @@ describe('CanvasChat', () => {
return matchedNode;
});
workflowsStore.logsPanelState = LOGS_PANEL_STATE.ATTACHED;
workflowsStore.isLogsPanelOpen = true;
logsStore.isOpen = true;
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
logsStore.state = LOGS_PANEL_STATE.ATTACHED;
nodeTypeStore.getNodeType = vi.fn().mockImplementation((nodeTypeName) => {
return mockNodeTypes.find((node) => node.name === nodeTypeName) ?? null;
});
@@ -198,7 +199,7 @@ describe('CanvasChat', () => {
});
it('should not render chat when panel is closed', async () => {
workflowsStore.logsPanelState = LOGS_PANEL_STATE.CLOSED;
logsStore.state = LOGS_PANEL_STATE.CLOSED;
const { queryByTestId } = renderComponent();
await waitFor(() => {
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
@@ -363,7 +364,7 @@ describe('CanvasChat', () => {
{ coords: { clientX: 0, clientY: 100 } },
]);
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
expect(logsStore.setHeight).toHaveBeenCalled();
});
it('should persist resize dimensions', () => {
@@ -388,7 +389,7 @@ describe('CanvasChat', () => {
isLoading: computed(() => false),
});
workflowsStore.logsPanelState = LOGS_PANEL_STATE.ATTACHED;
logsStore.state = LOGS_PANEL_STATE.ATTACHED;
workflowsStore.allowFileUploads = true;
});
@@ -544,15 +545,15 @@ describe('CanvasChat', () => {
renderComponent();
// Toggle logs panel
workflowsStore.isLogsPanelOpen = true;
logsStore.isOpen = true;
await waitFor(() => {
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
expect(logsStore.setHeight).toHaveBeenCalled();
});
// Close chat panel
workflowsStore.logsPanelState = LOGS_PANEL_STATE.CLOSED;
logsStore.state = LOGS_PANEL_STATE.CLOSED;
await waitFor(() => {
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
expect(logsStore.setHeight).toHaveBeenCalledWith(0);
});
});
@@ -560,15 +561,15 @@ describe('CanvasChat', () => {
const { unmount, rerender } = renderComponent();
// Set initial state
workflowsStore.logsPanelState = LOGS_PANEL_STATE.ATTACHED;
workflowsStore.isLogsPanelOpen = true;
logsStore.state = LOGS_PANEL_STATE.ATTACHED;
logsStore.isOpen = true;
// Unmount and remount
unmount();
await rerender({});
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.ATTACHED);
expect(workflowsStore.isLogsPanelOpen).toBe(true);
expect(logsStore.state).toBe(LOGS_PANEL_STATE.ATTACHED);
expect(logsStore.isOpen).toBe(true);
});
});

View File

@@ -9,16 +9,16 @@ import ChatLogsPanel from './components/ChatLogsPanel.vue';
import { useResize } from './composables/useResize';
// Types
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';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
import { useLogsStore } from '@/stores/logs.store';
const workflowsStore = useWorkflowsStore();
const canvasStore = useCanvasStore();
const logsStore = useLogsStore();
// Component state
const container = ref<HTMLElement>();
@@ -28,7 +28,7 @@ const pipContent = useTemplateRef('pipContent');
// Computed properties
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const chatPanelState = computed(() => workflowsStore.logsPanelState);
const chatPanelState = computed(() => logsStore.state);
const resultData = computed(() => workflowsStore.getWorkflowRunData);
const telemetry = useTelemetry();
@@ -55,7 +55,7 @@ const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
}
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPreferPoppedOutLogsView(false);
logsStore.setPreferPoppedOut(false);
},
});
@@ -78,22 +78,22 @@ defineExpose({
});
const closePanel = () => {
workflowsStore.toggleLogsPanelOpen(false);
logsStore.toggleOpen(false);
};
function onPopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' });
workflowsStore.toggleLogsPanelOpen(true);
workflowsStore.setPreferPoppedOutLogsView(true);
logsStore.toggleOpen(true);
logsStore.setPreferPoppedOut(true);
}
// Watchers
watchEffect(() => {
canvasStore.setPanelHeight(chatPanelState.value === LOGS_PANEL_STATE.ATTACHED ? height.value : 0);
logsStore.setHeight(chatPanelState.value === LOGS_PANEL_STATE.ATTACHED ? height.value : 0);
});
watch(
() => workflowsStore.logsPanelState,
chatPanelState,
(state) => {
if (state !== LOGS_PANEL_STATE.CLOSED) {
setTimeout(() => {

View File

@@ -10,8 +10,9 @@ import ChatInput from '@n8n/chat/components/Input.vue';
import { watch, computed, ref } from 'vue';
import { useClipboard } from '@/composables/useClipboard';
import { useToast } from '@/composables/useToast';
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
import LogsPanelHeader from '@/components/CanvasChat/future/components/LogsPanelHeader.vue';
import { N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system';
import { useSettingsStore } from '@/stores/settings.store';
interface Props {
pastChatMessages: string[];
@@ -40,6 +41,7 @@ const emit = defineEmits<{
const clipboard = useClipboard();
const locale = useI18n();
const toast = useToast();
const settingsStore = useSettingsStore();
const previousMessageIndex = ref(0);
@@ -140,7 +142,7 @@ async function copySessionId() {
watch(
() => props.isOpen,
(isOpen) => {
if (isOpen) {
if (isOpen && !settingsStore.isNewLogsEnabled) {
setTimeout(() => {
chatEventBus.emit('focusInput');
}, 0);
@@ -151,8 +153,13 @@ watch(
</script>
<template>
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
<PanelHeader
<div
:class="$style.chat"
data-test-id="workflow-lm-chat-dialog"
class="ignore-key-press-canvas"
tabindex="0"
>
<LogsPanelHeader
v-if="isNewLogsEnabled"
data-test-id="chat-header"
:title="locale.baseText('chat.window.title')"
@@ -191,7 +198,7 @@ watch(
/>
</N8nTooltip>
</template>
</PanelHeader>
</LogsPanelHeader>
<header v-else :class="$style.chatHeader">
<span :class="$style.chatTitle">{{ locale.baseText('chat.window.title') }}</span>
<div :class="$style.session">

View File

@@ -16,8 +16,8 @@ import { v4 as uuid } from 'uuid';
import type { Ref } from 'vue';
import { computed, provide, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { LOGS_PANEL_STATE } from '../types/logs';
import { restoreChatHistory } from '@/components/CanvasChat/utils';
import { useLogsStore } from '@/stores/logs.store';
interface ChatState {
currentSessionId: Ref<string>;
@@ -34,6 +34,7 @@ export function useChatState(isReadOnly: boolean): ChatState {
const locale = useI18n();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const logsStore = useLogsStore();
const router = useRouter();
const nodeHelpers = useNodeHelpers();
const { runWorkflow } = useRunWorkflow({ router });
@@ -42,7 +43,6 @@ export function useChatState(isReadOnly: boolean): ChatState {
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const logsPanelState = computed(() => workflowsStore.logsPanelState);
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
// Initialize features with injected dependencies
@@ -168,7 +168,7 @@ export function useChatState(isReadOnly: boolean): ChatState {
messages.value = [];
currentSessionId.value = uuid().replace(/-/g, '');
if (logsPanelState.value !== LOGS_PANEL_STATE.CLOSED) {
if (logsStore.isOpen) {
chatEventBus.emit('focusInput');
}
}

View File

@@ -19,7 +19,10 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { LOGS_PANEL_STATE } from '../types/logs';
import { IN_PROGRESS_EXECUTION_ID } from '@/constants';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useNDVStore } from '@/stores/ndv.store';
import { deepCopy } from 'n8n-workflow';
import { createTestTaskData } from '@/__tests__/mocks';
import { useLogsStore } from '@/stores/logs.store';
describe('LogsPanel', () => {
const VIEWPORT_HEIGHT = 800;
@@ -28,6 +31,8 @@ describe('LogsPanel', () => {
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
function render() {
return renderComponent(LogsPanel, {
@@ -53,11 +58,15 @@ describe('LogsPanel', () => {
workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.setWorkflowExecutionData(null);
workflowsStore.toggleLogsPanelOpen(false);
logsStore = mockedStore(useLogsStore);
logsStore.toggleOpen(false);
nodeTypeStore = mockedStore(useNodeTypesStore);
nodeTypeStore.setNodeTypes(nodeTypes);
ndvStore = mockedStore(useNDVStore);
Object.defineProperty(document.body, 'offsetHeight', {
configurable: true,
get() {
@@ -161,11 +170,11 @@ describe('LogsPanel', () => {
});
it('should open itself by pulling up the resizer', async () => {
workflowsStore.toggleLogsPanelOpen(false);
logsStore.toggleOpen(false);
const rendered = render();
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.CLOSED);
expect(logsStore.state).toBe(LOGS_PANEL_STATE.CLOSED);
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
@@ -174,17 +183,17 @@ describe('LogsPanel', () => {
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: 0 }));
await waitFor(() => {
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.ATTACHED);
expect(logsStore.state).toBe(LOGS_PANEL_STATE.ATTACHED);
expect(rendered.queryByTestId('logs-overview-body')).toBeInTheDocument();
});
});
it('should close itself by pulling down the resizer', async () => {
workflowsStore.toggleLogsPanelOpen(true);
logsStore.toggleOpen(true);
const rendered = render();
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.ATTACHED);
expect(logsStore.state).toBe(LOGS_PANEL_STATE.ATTACHED);
expect(rendered.queryByTestId('logs-overview-body')).toBeInTheDocument();
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
@@ -197,13 +206,13 @@ describe('LogsPanel', () => {
);
await waitFor(() => {
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.CLOSED);
expect(logsStore.state).toBe(LOGS_PANEL_STATE.CLOSED);
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
});
});
it('should reflect changes to execution data in workflow store if execution is in progress', async () => {
workflowsStore.toggleLogsPanelOpen(true);
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData({
...aiChatExecutionResponse,
@@ -271,8 +280,8 @@ describe('LogsPanel', () => {
const router = useRouter();
const operations = useCanvasOperations({ router });
workflowsStore.toggleLogsPanelOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(deepCopy(aiChatWorkflow));
workflowsStore.setWorkflowExecutionData({
...aiChatExecutionResponse,
id: '2345',
@@ -293,4 +302,90 @@ describe('LogsPanel', () => {
expect(workflowsStore.nodesByName['AI Agent']).toBeUndefined();
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
});
it('should open NDV if the button is clicked', async () => {
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render();
const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0];
expect(ndvStore.activeNodeName).toBe(null);
expect(ndvStore.output.run).toBe(undefined);
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Open...')[0]);
await waitFor(() => {
expect(ndvStore.activeNodeName).toBe('AI Agent');
expect(ndvStore.output.run).toBe(0);
});
});
it('should toggle subtree when chevron icon button is pressed', async () => {
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render();
const overview = within(rendered.getByTestId('logs-overview'));
await waitFor(() => expect(overview.queryAllByRole('treeitem')).toHaveLength(2));
expect(overview.queryByText('AI Agent')).toBeInTheDocument();
expect(overview.queryByText('AI Model')).toBeInTheDocument();
// Close subtree of AI Agent
await fireEvent.click(overview.getAllByLabelText('Toggle row')[0]);
await waitFor(() => expect(overview.queryAllByRole('treeitem')).toHaveLength(1));
expect(overview.queryByText('AI Agent')).toBeInTheDocument();
expect(overview.queryByText('AI Model')).not.toBeInTheDocument();
// Re-open subtree of AI Agent
await fireEvent.click(overview.getAllByLabelText('Toggle row')[0]);
await waitFor(() => expect(overview.queryAllByRole('treeitem')).toHaveLength(2));
expect(overview.queryByText('AI Agent')).toBeInTheDocument();
expect(overview.queryByText('AI Model')).toBeInTheDocument();
});
it('should toggle input and output panel when the button is clicked', async () => {
logsStore.toggleOpen(true);
logsStore.toggleInputOpen(false);
logsStore.toggleOutputOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render();
const header = within(rendered.getByTestId('log-details-header'));
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
await fireEvent.click(header.getByText('Input'));
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
await fireEvent.click(header.getByText('Output'));
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
});
it('should allow to select previous and next row via keyboard shortcut', async () => {
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render();
const overview = rendered.getByTestId('logs-overview');
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
await fireEvent.keyDown(overview, { key: 'K' });
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
await fireEvent.keyDown(overview, { key: 'J' });
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
});
});

View File

@@ -1,15 +1,19 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue';
import { nextTick, computed, useTemplateRef } from 'vue';
import { N8nResizeWrapper } from '@n8n/design-system';
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
import LogsDetailsPanel from '@/components/CanvasChat/future/components/LogDetailsPanel.vue';
import { type LogEntrySelection } from '@/components/CanvasChat/types/logs';
import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue';
import { useLayout } from '@/components/CanvasChat/future/composables/useLayout';
import { useExecutionData } from '@/components/CanvasChat/future/composables/useExecutionData';
import { findSelectedLogEntry, type LogEntry } from '@/components/RunDataAi/utils';
import { useLogsPanelLayout } from '@/components/CanvasChat/future/composables/useLogsPanelLayout';
import { useLogsExecutionData } from '@/components/CanvasChat/future/composables/useLogsExecutionData';
import { type LogEntry } from '@/components/RunDataAi/utils';
import { useNDVStore } from '@/stores/ndv.store';
import { ndvEventBus } from '@/event-bus';
import { useLogsSelection } from '@/components/CanvasChat/future/composables/useLogsSelection';
import { useLogsTreeExpand } from '@/components/CanvasChat/future/composables/useLogsTreeExpand';
import { useLogsStore } from '@/stores/logs.store';
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
@@ -18,6 +22,9 @@ const logsContainer = useTemplateRef('logsContainer');
const pipContainer = useTemplateRef('pipContainer');
const pipContent = useTemplateRef('pipContent');
const logsStore = useLogsStore();
const ndvStore = useNDVStore();
const {
height,
chatPanelWidth,
@@ -36,7 +43,7 @@ const {
onChatPanelResizeEnd,
onOverviewPanelResize,
onOverviewPanelResizeEnd,
} = useLayout(pipContainer, pipContent, container, logsContainer);
} = useLogsPanelLayout(pipContainer, pipContent, container, logsContainer);
const {
currentSessionId,
@@ -48,13 +55,15 @@ const {
} = useChatState(props.isReadOnly);
const { entries, execution, hasChat, latestNodeNameById, resetExecutionData, loadSubExecution } =
useExecutionData();
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
const selectedLogEntry = computed(() =>
findSelectedLogEntry(manualLogEntrySelection.value as LogEntrySelection, entries.value),
useLogsExecutionData();
const { flatLogEntries, toggleExpanded } = useLogsTreeExpand(entries);
const { selected, select, selectNext, selectPrev } = useLogsSelection(
execution,
entries,
flatLogEntries,
);
const isLogDetailsOpen = computed(() => isOpen.value && selectedLogEntry.value !== undefined);
const isLogDetailsOpen = computed(() => isOpen.value && selected.value !== undefined);
const isLogDetailsVisuallyOpen = computed(
() => isLogDetailsOpen.value && !isCollapsingDetailsPanel.value,
);
@@ -66,18 +75,26 @@ const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$p
onToggleOpen,
}));
function handleSelectLogEntry(selected: LogEntry | undefined) {
manualLogEntrySelection.value =
selected === undefined ? { type: 'none' } : { type: 'selected', id: selected.id };
}
function handleResizeOverviewPanelEnd() {
if (isOverviewPanelFullWidth.value) {
handleSelectLogEntry(undefined);
select(undefined);
}
onOverviewPanelResizeEnd();
}
async function handleOpenNdv(treeNode: LogEntry) {
ndvStore.setActiveNodeName(treeNode.node.name);
await nextTick(() => {
const source = treeNode.runData.source[0];
const inputBranch = source?.previousNodeOutput ?? 0;
ndvEventBus.emit('updateInputNodeName', source?.previousNode);
ndvEventBus.emit('setInputBranchIndex', inputBranch);
ndvStore.setOutputRunIndex(treeNode.runIndex);
});
}
</script>
<template>
@@ -92,7 +109,18 @@ function handleResizeOverviewPanelEnd() {
@resize="onResize"
@resizeend="onResizeEnd"
>
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
<div
ref="container"
:class="$style.container"
tabindex="-1"
@keydown.esc.stop="select(undefined)"
@keydown.j.stop="selectNext"
@keydown.down.stop.prevent="selectNext"
@keydown.k.stop="selectPrev"
@keydown.up.stop.prevent="selectPrev"
@keydown.space.stop="selected && toggleExpanded(selected)"
@keydown.enter.stop="selected && handleOpenNdv(selected)"
>
<N8nResizeWrapper
v-if="hasChat && (!props.isReadOnly || messages.length > 0)"
:supported-directions="['right']"
@@ -137,17 +165,16 @@ function handleResizeOverviewPanelEnd() {
:is-open="isOpen"
:is-read-only="isReadOnly"
:is-compact="isLogDetailsVisuallyOpen"
:selected="selectedLogEntry"
:entries="entries"
:selected="selected"
:execution="execution"
:scroll-to-selection="
manualLogEntrySelection.type !== 'selected' ||
manualLogEntrySelection.id !== selectedLogEntry?.id
"
:entries="entries"
:latest-node-info="latestNodeNameById"
:flat-log-entries="flatLogEntries"
@click-header="onToggleOpen(true)"
@select="handleSelectLogEntry"
@select="select"
@clear-execution-data="resetExecutionData"
@toggle-expanded="toggleExpanded"
@open-ndv="handleOpenNdv"
@load-sub-execution="loadSubExecution"
>
<template #actions>
@@ -159,13 +186,16 @@ function handleResizeOverviewPanelEnd() {
</LogsOverviewPanel>
</N8nResizeWrapper>
<LogsDetailsPanel
v-if="isLogDetailsVisuallyOpen && selectedLogEntry"
v-if="isLogDetailsVisuallyOpen && selected"
:class="$style.logDetails"
:is-open="isOpen"
:log-entry="selectedLogEntry"
:log-entry="selected"
:window="pipWindow"
:latest-info="latestNodeNameById[selectedLogEntry.id]"
:latest-info="latestNodeNameById[selected.id]"
:panels="logsStore.detailsState"
@click-header="onToggleOpen(true)"
@toggle-input-open="logsStore.toggleInputOpen"
@toggle-output-open="logsStore.toggleOutputOpen"
>
<template #actions>
<LogsPanelActions v-if="isLogDetailsVisuallyOpen" v-bind="logsPanelActionsProps" />

View File

@@ -1,4 +1,4 @@
import { fireEvent, waitFor, within } from '@testing-library/vue';
import { fireEvent, within } from '@testing-library/vue';
import { renderComponent } from '@/__tests__/render';
import LogDetailsPanel from './LogDetailsPanel.vue';
import { createRouter, createWebHistory } from 'vue-router';
@@ -14,6 +14,7 @@ import {
import { mockedStore } from '@/__tests__/utils';
import { useSettingsStore } from '@/stores/settings.store';
import { type FrontendSettings } from '@n8n/api-types';
import { LOG_DETAILS_PANEL_STATE } from '../../types/logs';
import type { LogEntry } from '@/components/RunDataAi/utils';
describe('LogDetailsPanel', () => {
@@ -91,11 +92,10 @@ describe('LogDetailsPanel', () => {
});
it('should show name, run status, input, and output of the node', async () => {
localStorage.setItem('N8N_LOGS_DETAIL_PANEL_CONTENT', 'both');
const rendered = render({
isOpen: true,
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
});
const header = within(rendered.getByTestId('log-details-header'));
@@ -108,34 +108,11 @@ describe('LogDetailsPanel', () => {
expect(await outputPanel.findByText('Hello!')).toBeInTheDocument();
});
it('should toggle input and output panel when the button is clicked', async () => {
const rendered = render({
isOpen: true,
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
});
const header = within(rendered.getByTestId('log-details-header'));
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
await fireEvent.click(header.getByText('Input'));
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
await fireEvent.click(header.getByText('Output'));
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
});
it('should close input panel by dragging the divider to the left end', async () => {
localStorage.setItem('N8N_LOGS_DETAIL_PANEL_CONTENT', 'both');
const rendered = render({
isOpen: true,
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
@@ -143,18 +120,14 @@ describe('LogDetailsPanel', () => {
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: 0 }));
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: 0 }));
await waitFor(() => {
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
});
expect(rendered.emitted()).toEqual({ toggleInputOpen: [[false]] });
});
it('should close output panel by dragging the divider to the right end', async () => {
localStorage.setItem('N8N_LOGS_DETAIL_PANEL_CONTENT', 'both');
const rendered = render({
isOpen: true,
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
@@ -162,9 +135,6 @@ describe('LogDetailsPanel', () => {
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 1000, clientY: 0 }));
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 1000, clientY: 0 }));
await waitFor(() => {
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
});
expect(rendered.emitted()).toEqual({ toggleOutputOpen: [[false]] });
});
});

View File

@@ -1,46 +1,46 @@
<script setup lang="ts">
import ExecutionSummary from '@/components/CanvasChat/future/components/ExecutionSummary.vue';
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
import RunDataView from '@/components/CanvasChat/future/components/RunDataView.vue';
import { useResizablePanel } from '@/components/CanvasChat/future/composables/useResizablePanel';
import { LOG_DETAILS_CONTENT, type LogDetailsContent } from '@/components/CanvasChat/types/logs';
import LogsViewExecutionSummary from '@/components/CanvasChat/future/components/LogsViewExecutionSummary.vue';
import LogsPanelHeader from '@/components/CanvasChat/future/components/LogsPanelHeader.vue';
import LogsViewRunData from '@/components/CanvasChat/future/components/LogsViewRunData.vue';
import { useResizablePanel } from '@/composables/useResizablePanel';
import {
LOG_DETAILS_PANEL_STATE,
type LogDetailsPanelState,
} from '@/components/CanvasChat/types/logs';
import NodeIcon from '@/components/NodeIcon.vue';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import NodeName from '@/components/CanvasChat/future/components/NodeName.vue';
import LogsViewNodeName from '@/components/CanvasChat/future/components/LogsViewNodeName.vue';
import {
getSubtreeTotalConsumedTokens,
type LogEntry,
type LatestNodeInfo,
} from '@/components/RunDataAi/utils';
import { N8nButton, N8nResizeWrapper } from '@n8n/design-system';
import { useLocalStorage } from '@vueuse/core';
import { computed, useTemplateRef } from 'vue';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
const MIN_IO_PANEL_WIDTH = 200;
const { isOpen, logEntry, window, latestInfo } = defineProps<{
const { isOpen, logEntry, window, latestInfo, panels } = defineProps<{
isOpen: boolean;
logEntry: LogEntry;
window?: Window;
latestInfo?: LatestNodeInfo;
panels: LogDetailsPanelState;
}>();
const emit = defineEmits<{ clickHeader: [] }>();
const emit = defineEmits<{
clickHeader: [];
toggleInputOpen: [] | [boolean];
toggleOutputOpen: [] | [boolean];
}>();
defineSlots<{ actions: {} }>();
const locale = useI18n();
const telemetry = useTelemetry();
const nodeTypeStore = useNodeTypesStore();
const content = useLocalStorage<LogDetailsContent>(
'N8N_LOGS_DETAIL_PANEL_CONTENT',
LOG_DETAILS_CONTENT.OUTPUT,
{ writeDefaults: false },
);
const type = computed(() => nodeTypeStore.getNodeType(logEntry.node.type));
const consumedTokens = computed(() => getSubtreeTotalConsumedTokens(logEntry, false));
const isTriggerNode = computed(() => type.value?.group.includes('trigger'));
@@ -53,45 +53,15 @@ const resizer = useResizablePanel('N8N_LOGS_INPUT_PANEL_WIDTH', {
allowCollapse: true,
allowFullSize: true,
});
const shouldResize = computed(() => content.value === LOG_DETAILS_CONTENT.BOTH);
function handleToggleInput(open?: boolean) {
const wasOpen = [LOG_DETAILS_CONTENT.INPUT, LOG_DETAILS_CONTENT.BOTH].includes(content.value);
if (open === wasOpen) {
return;
}
content.value = wasOpen ? LOG_DETAILS_CONTENT.OUTPUT : LOG_DETAILS_CONTENT.BOTH;
telemetry.track('User toggled log view sub pane', {
pane: 'input',
newState: wasOpen ? 'hidden' : 'visible',
});
}
function handleToggleOutput(open?: boolean) {
const wasOpen = [LOG_DETAILS_CONTENT.OUTPUT, LOG_DETAILS_CONTENT.BOTH].includes(content.value);
if (open === wasOpen) {
return;
}
content.value = wasOpen ? LOG_DETAILS_CONTENT.INPUT : LOG_DETAILS_CONTENT.BOTH;
telemetry.track('User toggled log view sub pane', {
pane: 'output',
newState: wasOpen ? 'hidden' : 'visible',
});
}
const shouldResize = computed(() => panels === LOG_DETAILS_PANEL_STATE.BOTH);
function handleResizeEnd() {
if (resizer.isCollapsed.value) {
handleToggleInput(false);
emit('toggleInputOpen', false);
}
if (resizer.isFullSize.value) {
handleToggleOutput(false);
emit('toggleOutputOpen', false);
}
resizer.onResizeEnd();
@@ -100,7 +70,7 @@ function handleResizeEnd() {
<template>
<div ref="container" :class="$style.container" data-test-id="log-details">
<PanelHeader
<LogsPanelHeader
data-test-id="log-details-header"
:class="$style.header"
@click="emit('clickHeader')"
@@ -108,12 +78,12 @@ function handleResizeEnd() {
<template #title>
<div :class="$style.title">
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
<NodeName
<LogsViewNodeName
:latest-name="latestInfo?.name ?? logEntry.node.name"
:name="logEntry.node.name"
:is-deleted="latestInfo?.deleted ?? false"
/>
<ExecutionSummary
<LogsViewExecutionSummary
v-if="isOpen"
:class="$style.executionSummary"
:status="logEntry.runData.executionStatus ?? 'unknown'"
@@ -124,29 +94,39 @@ function handleResizeEnd() {
</template>
<template #actions>
<div v-if="isOpen && !isTriggerNode" :class="$style.actions">
<N8nButton
size="mini"
type="secondary"
:class="content === LOG_DETAILS_CONTENT.OUTPUT ? '' : $style.pressed"
@click.stop="handleToggleInput"
<KeyboardShortcutTooltip
:label="locale.baseText('generic.shortcutHint')"
:shortcut="{ keys: ['i'] }"
>
{{ locale.baseText('logs.details.header.actions.input') }}
</N8nButton>
<N8nButton
size="mini"
type="secondary"
:class="content === LOG_DETAILS_CONTENT.INPUT ? '' : $style.pressed"
@click.stop="handleToggleOutput"
<N8nButton
size="mini"
type="secondary"
:class="panels === LOG_DETAILS_PANEL_STATE.OUTPUT ? '' : $style.pressed"
@click.stop="emit('toggleInputOpen')"
>
{{ locale.baseText('logs.details.header.actions.input') }}
</N8nButton>
</KeyboardShortcutTooltip>
<KeyboardShortcutTooltip
:label="locale.baseText('generic.shortcutHint')"
:shortcut="{ keys: ['o'] }"
>
{{ locale.baseText('logs.details.header.actions.output') }}
</N8nButton>
<N8nButton
size="mini"
type="secondary"
:class="panels === LOG_DETAILS_PANEL_STATE.INPUT ? '' : $style.pressed"
@click.stop="emit('toggleOutputOpen')"
>
{{ locale.baseText('logs.details.header.actions.output') }}
</N8nButton>
</KeyboardShortcutTooltip>
</div>
<slot name="actions" />
</template>
</PanelHeader>
</LogsPanelHeader>
<div v-if="isOpen" :class="$style.content" data-test-id="logs-details-body">
<N8nResizeWrapper
v-if="!isTriggerNode && content !== LOG_DETAILS_CONTENT.OUTPUT"
v-if="!isTriggerNode && panels !== LOG_DETAILS_PANEL_STATE.OUTPUT"
:class="{
[$style.inputResizer]: true,
[$style.collapsed]: resizer.isCollapsed.value,
@@ -160,15 +140,15 @@ function handleResizeEnd() {
@resize="resizer.onResize"
@resizeend="handleResizeEnd"
>
<RunDataView
<LogsViewRunData
data-test-id="log-details-input"
pane-type="input"
:title="locale.baseText('logs.details.header.actions.input')"
:log-entry="logEntry"
/>
</N8nResizeWrapper>
<RunDataView
v-if="isTriggerNode || content !== LOG_DETAILS_CONTENT.INPUT"
<LogsViewRunData
v-if="isTriggerNode || panels !== LOG_DETAILS_PANEL_STATE.INPUT"
data-test-id="log-details-output"
pane-type="output"
:class="$style.outputPanel"

View File

@@ -14,23 +14,22 @@ import {
aiManualWorkflow,
} from '../../__test__/data';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useNDVStore } from '@/stores/ndv.store';
import { createTestWorkflowObject } from '@/__tests__/mocks';
import { createLogTree } from '@/components/RunDataAi/utils';
import { createLogTree, flattenLogEntries } from '@/components/RunDataAi/utils';
describe('LogsOverviewPanel', () => {
let pinia: TestingPinia;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let pushConnectionStore: ReturnType<typeof mockedStore<typeof usePushConnectionStore>>;
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
function render(props: Partial<InstanceType<typeof LogsOverviewPanel>['$props']>) {
const logs = createLogTree(createTestWorkflowObject(aiChatWorkflow), aiChatExecutionResponse);
const mergedProps: InstanceType<typeof LogsOverviewPanel>['$props'] = {
isOpen: false,
isReadOnly: false,
isCompact: false,
scrollToSelection: false,
entries: createLogTree(createTestWorkflowObject(aiChatWorkflow), aiChatExecutionResponse),
flatLogEntries: flattenLogEntries(logs, {}),
entries: logs,
latestNodeInfo: {},
execution: aiChatExecutionResponse,
...props,
@@ -59,8 +58,6 @@ describe('LogsOverviewPanel', () => {
pushConnectionStore = mockedStore(usePushConnectionStore);
pushConnectionStore.isConnected = true;
ndvStore = mockedStore(useNDVStore);
});
it('should not render body if the panel is not open', () => {
@@ -70,7 +67,12 @@ describe('LogsOverviewPanel', () => {
});
it('should render empty text if there is no execution', () => {
const rendered = render({ isOpen: true, entries: [], execution: undefined });
const rendered = render({
isOpen: true,
flatLogEntries: [],
entries: [],
execution: undefined,
});
expect(rendered.queryByTestId('logs-overview-empty')).toBeInTheDocument();
});
@@ -101,35 +103,20 @@ describe('LogsOverviewPanel', () => {
expect(row2.queryByText('in 1.777s')).toBeInTheDocument();
expect(row2.queryByText('Started 00:00:00.003, 26 Mar')).toBeInTheDocument();
expect(row2.queryByText('555 Tokens')).toBeInTheDocument();
// collapse tree
await fireEvent.click(row1.getAllByLabelText('Toggle row')[0]);
await waitFor(() => expect(tree.queryAllByRole('treeitem')).toHaveLength(1));
});
it('should open NDV if the button is clicked', async () => {
const rendered = render({
isOpen: true,
});
const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0];
expect(ndvStore.activeNodeName).toBe(null);
expect(ndvStore.output.run).toBe(undefined);
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Open...')[0]);
await waitFor(() => {
expect(ndvStore.activeNodeName).toBe('AI Agent');
expect(ndvStore.output.run).toBe(0);
});
});
it('should trigger partial execution if the button is clicked', async () => {
const spyRun = vi.spyOn(workflowsStore, 'runWorkflow');
const logs = createLogTree(
createTestWorkflowObject(aiManualWorkflow),
aiManualExecutionResponse,
);
const rendered = render({
isOpen: true,
entries: createLogTree(createTestWorkflowObject(aiManualWorkflow), aiManualExecutionResponse),
execution: aiManualExecutionResponse,
entries: logs,
flatLogEntries: flattenLogEntries(logs, {}),
});
const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0];
@@ -138,26 +125,4 @@ describe('LogsOverviewPanel', () => {
expect(spyRun).toHaveBeenCalledWith(expect.objectContaining({ destinationNode: 'AI Agent' })),
);
});
it('should toggle subtree when chevron icon button is pressed', async () => {
const rendered = render({ isOpen: true });
await waitFor(() => expect(rendered.queryAllByRole('treeitem')).toHaveLength(2));
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
expect(rendered.queryByText('AI Model')).toBeInTheDocument();
// Close subtree of AI Agent
await fireEvent.click(rendered.getAllByLabelText('Toggle row')[0]);
await waitFor(() => expect(rendered.queryAllByRole('treeitem')).toHaveLength(1));
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
expect(rendered.queryByText('AI Model')).not.toBeInTheDocument();
// Re-open subtree of AI Agent
await fireEvent.click(rendered.getAllByLabelText('Toggle row')[0]);
await waitFor(() => expect(rendered.queryAllByRole('treeitem')).toHaveLength(2));
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
expect(rendered.queryByText('AI Model')).toBeInTheDocument();
});
});

View File

@@ -1,27 +1,21 @@
<script setup lang="ts">
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
import LogsPanelHeader from '@/components/CanvasChat/future/components/LogsPanelHeader.vue';
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
import { useI18n } from '@/composables/useI18n';
import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
import { ref, computed, nextTick, watch } from 'vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { computed, nextTick, toRef, watch } from 'vue';
import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useNDVStore } from '@/stores/ndv.store';
import { useRouter } from 'vue-router';
import ExecutionSummary from '@/components/CanvasChat/future/components/ExecutionSummary.vue';
import LogsViewExecutionSummary from '@/components/CanvasChat/future/components/LogsViewExecutionSummary.vue';
import {
getDefaultCollapsedEntries,
flattenLogEntries,
getSubtreeTotalConsumedTokens,
getTotalConsumedTokens,
hasSubExecution,
type LatestNodeInfo,
type LogEntry,
getDepth,
} from '@/components/RunDataAi/utils';
import { useVirtualList } from '@vueuse/core';
import { ndvEventBus } from '@/event-bus';
import { type IExecutionResponse } from '@/Interface';
const {
@@ -31,35 +25,35 @@ const {
isCompact,
execution,
entries,
flatLogEntries,
latestNodeInfo,
scrollToSelection,
} = defineProps<{
isOpen: boolean;
selected?: LogEntry;
isReadOnly: boolean;
isCompact: boolean;
entries: LogEntry[];
execution?: IExecutionResponse;
entries: LogEntry[];
flatLogEntries: LogEntry[];
latestNodeInfo: Record<string, LatestNodeInfo>;
scrollToSelection: boolean;
}>();
const emit = defineEmits<{
clickHeader: [];
select: [LogEntry | undefined];
clearExecutionData: [];
openNdv: [LogEntry];
toggleExpanded: [LogEntry];
loadSubExecution: [LogEntry];
}>();
defineSlots<{ actions: {} }>();
const locale = useI18n();
const telemetry = useTelemetry();
const router = useRouter();
const runWorkflow = useRunWorkflow({ router });
const ndvStore = useNDVStore();
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
const isEmpty = computed(() => entries.length === 0 || execution === undefined);
const isEmpty = computed(() => flatLogEntries.length === 0 || execution === undefined);
const switchViewOptions = computed(() => [
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' as const },
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
@@ -74,60 +68,27 @@ const consumedTokens = computed(() =>
),
),
);
const shouldShowTokenCountColumn = computed(
() =>
consumedTokens.value.totalTokens > 0 ||
entries.some((entry) => getSubtreeTotalConsumedTokens(entry, true).totalTokens > 0),
);
const manuallyCollapsedEntries = ref<Record<string, boolean>>({});
const collapsedEntries = computed(() => ({
...getDefaultCollapsedEntries(entries),
...manuallyCollapsedEntries.value,
}));
const flatLogEntries = computed(() => flattenLogEntries(entries, collapsedEntries.value));
const virtualList = useVirtualList(flatLogEntries, { itemHeight: 32 });
function handleClickNode(clicked: LogEntry) {
if (selected?.id === clicked.id) {
emit('select', undefined);
return;
}
emit('select', clicked);
telemetry.track('User selected node in log view', {
node_type: clicked.node.type,
node_id: clicked.node.id,
execution_id: execution?.id,
workflow_id: execution?.workflowData.id,
subworkflow_depth: getDepth(clicked),
});
}
const virtualList = useVirtualList(
toRef(() => flatLogEntries),
{ itemHeight: 32 },
);
function handleSwitchView(value: 'overview' | 'details') {
emit('select', value === 'overview' || entries.length === 0 ? undefined : entries[0]);
emit('select', value === 'overview' ? undefined : flatLogEntries[0]);
}
async function handleToggleExpanded(treeNode: LogEntry) {
function handleToggleExpanded(treeNode: LogEntry) {
if (hasSubExecution(treeNode) && treeNode.children.length === 0) {
emit('loadSubExecution', treeNode);
return;
}
manuallyCollapsedEntries.value[treeNode.id] = !collapsedEntries.value[treeNode.id];
}
async function handleOpenNdv(treeNode: LogEntry) {
ndvStore.setActiveNodeName(treeNode.node.name);
await nextTick(() => {
const source = treeNode.runData.source[0];
const inputBranch = source?.previousNodeOutput ?? 0;
ndvEventBus.emit('updateInputNodeName', source?.previousNode);
ndvEventBus.emit('setInputBranchIndex', inputBranch);
ndvStore.setOutputRunIndex(treeNode.runIndex);
});
emit('toggleExpanded', treeNode);
}
async function handleTriggerPartialExecution(treeNode: LogEntry) {
@@ -140,10 +101,10 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
// Scroll selected row into view
watch(
() => (scrollToSelection ? selected?.id : undefined),
async (selectedId) => {
if (selectedId) {
const index = flatLogEntries.value.findIndex((e) => e.id === selectedId);
() => selected,
async (selection) => {
if (selection && virtualList.list.value.every((e) => e.data.id !== selection.id)) {
const index = flatLogEntries.findIndex((e) => e.id === selection?.id);
if (index >= 0) {
// Wait for the node to be added to the list, and then scroll
@@ -157,7 +118,7 @@ watch(
<template>
<div :class="$style.container" data-test-id="logs-overview">
<PanelHeader
<LogsPanelHeader
:title="locale.baseText('logs.overview.header.title')"
data-test-id="logs-overview-header"
@click="emit('clickHeader')"
@@ -180,7 +141,7 @@ watch(
</N8nTooltip>
<slot name="actions" />
</template>
</PanelHeader>
</LogsPanelHeader>
<div
v-if="isOpen"
:class="[$style.content, isEmpty ? $style.empty : '']"
@@ -197,7 +158,7 @@ watch(
{{ locale.baseText('logs.overview.body.empty.message') }}
</N8nText>
<template v-else>
<ExecutionSummary
<LogsViewExecutionSummary
data-test-id="logs-overview-status"
:class="$style.summary"
:status="execution.status"
@@ -219,12 +180,12 @@ watch(
:is-compact="isCompact"
:should-show-token-count-column="shouldShowTokenCountColumn"
:latest-info="latestNodeInfo[data.node.id]"
:expanded="!collapsedEntries[data.id]"
:expanded="virtualList.list.value[index + 1]?.data.parent?.id === data.id"
:can-open-ndv="data.executionId === execution?.id"
@click.stop="handleClickNode(data)"
@toggle-expanded="handleToggleExpanded"
@open-ndv="handleOpenNdv"
@trigger-partial-execution="handleTriggerPartialExecution"
@toggle-expanded="handleToggleExpanded(data)"
@open-ndv="emit('openNdv', data)"
@trigger-partial-execution="handleTriggerPartialExecution(data)"
@toggle-selected="emit('select', selected?.id === data.id ? undefined : data)"
/>
</div>
</div>

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, nextTick, useTemplateRef, watch } from 'vue';
import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { upperFirst } from 'lodash-es';
import { useI18n } from '@/composables/useI18n';
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
import LogsViewConsumedTokenCountText from '@/components/CanvasChat/future/components/LogsViewConsumedTokenCountText.vue';
import { I18nT } from 'vue-i18n';
import { toDayMonth, toTime } from '@/utils/formatters/dateFormatter';
import NodeName from '@/components/CanvasChat/future/components/NodeName.vue';
import LogsViewNodeName from '@/components/CanvasChat/future/components/LogsViewNodeName.vue';
import {
getSubtreeTotalConsumedTokens,
type LatestNodeInfo,
@@ -26,11 +26,13 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
toggleExpanded: [node: LogEntry];
triggerPartialExecution: [node: LogEntry];
openNdv: [node: LogEntry];
toggleExpanded: [];
toggleSelected: [];
triggerPartialExecution: [];
openNdv: [];
}>();
const container = useTemplateRef('containerRef');
const locale = useI18n();
const nodeTypeStore = useNodeTypesStore();
const type = computed(() => nodeTypeStore.getNodeType(props.data.node.type));
@@ -75,12 +77,26 @@ function isLastChild(level: number) {
(data?.node === lastSibling?.node && data?.runIndex === lastSibling?.runIndex)
);
}
// Focus when selected: For scrolling into view and for keyboard navigation to work
watch(
() => props.isSelected,
(isSelected) => {
void nextTick(() => {
if (isSelected) {
container.value?.focus();
}
});
},
{ immediate: true },
);
</script>
<template>
<div
ref="containerRef"
role="treeitem"
tabindex="0"
tabindex="-1"
:aria-expanded="props.data.children.length > 0 && props.expanded"
:aria-selected="props.isSelected"
:class="{
@@ -89,6 +105,7 @@ function isLastChild(level: number) {
[$style.error]: isError,
[$style.selected]: props.isSelected,
}"
@click.stop="emit('toggleSelected')"
>
<template v-for="level in props.data.depth" :key="level">
<div
@@ -101,7 +118,7 @@ function isLastChild(level: number) {
</template>
<div :class="$style.background" :style="{ '--indent-depth': props.data.depth }" />
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
<NodeName
<LogsViewNodeName
:class="$style.name"
:latest-name="latestInfo?.name ?? props.data.node.name"
:name="props.data.node.name"
@@ -137,7 +154,7 @@ function isLastChild(level: number) {
size="small"
:class="$style.consumedTokens"
>
<ConsumedTokenCountText
<LogsViewConsumedTokenCountText
v-if="
subtreeConsumedTokens.totalTokens > 0 &&
(props.data.children.length === 0 || !props.expanded)
@@ -165,7 +182,7 @@ function isLastChild(level: number) {
:disabled="props.latestInfo?.deleted"
:class="$style.openNdvButton"
:aria-label="locale.baseText('logs.overview.body.open')"
@click.stop="emit('openNdv', props.data)"
@click.stop="emit('openNdv')"
/>
<N8nIconButton
v-if="
@@ -179,7 +196,7 @@ function isLastChild(level: number) {
:aria-label="locale.baseText('logs.overview.body.run')"
:class="[$style.partialExecutionButton, props.data.depth > 0 ? $style.unavailable : '']"
:disabled="props.latestInfo?.deleted || props.latestInfo?.disabled"
@click.stop="emit('triggerPartialExecution', props.data)"
@click.stop="emit('triggerPartialExecution')"
/>
<N8nButton
v-if="!isCompact || hasChildren"
@@ -192,7 +209,7 @@ function isLastChild(level: number) {
}"
:class="$style.toggleButton"
:aria-label="locale.baseText('logs.overview.body.toggleRow')"
@click.stop="emit('toggleExpanded', props.data)"
@click.stop="emit('toggleExpanded')"
>
<N8nIcon size="medium" :icon="props.expanded ? 'chevron-down' : 'chevron-up'" />
</N8nButton>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
@@ -33,7 +34,12 @@ const toggleButtonText = computed(() =>
@click.stop="emit('popOut')"
/>
</N8nTooltip>
<N8nTooltip v-if="showToggleButton" :z-index="tooltipZIndex" :content="toggleButtonText">
<KeyboardShortcutTooltip
v-if="showToggleButton"
:label="locales.baseText('generic.shortcutHint')"
:shortcut="{ keys: ['l'] }"
:z-index="tooltipZIndex"
>
<N8nIconButton
type="secondary"
size="small"
@@ -43,7 +49,7 @@ const toggleButtonText = computed(() =>
style="color: var(--color-text-base)"
@click.stop="emit('toggleOpen')"
/>
</N8nTooltip>
</KeyboardShortcutTooltip>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
import LogsViewConsumedTokenCountText from '@/components/CanvasChat/future/components/LogsViewConsumedTokenCountText.vue';
import { useI18n } from '@/composables/useI18n';
import { type LlmTokenUsageData } from '@/Interface';
import { N8nText } from '@n8n/design-system';
@@ -29,7 +29,7 @@ const executionStatusText = computed(() =>
<template>
<N8nText tag="div" color="text-light" size="small" :class="$style.container">
<span>{{ executionStatusText }}</span>
<ConsumedTokenCountText
<LogsViewConsumedTokenCountText
v-if="consumedTokens.totalTokens > 0"
:consumed-tokens="consumedTokens"
/>

View File

@@ -1,5 +1,5 @@
import { setActivePinia } from 'pinia';
import { useExecutionData } from './useExecutionData';
import { useLogsExecutionData } from './useLogsExecutionData';
import { waitFor } from '@testing-library/vue';
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
@@ -18,7 +18,7 @@ import { useToast } from '@/composables/useToast';
vi.mock('@/composables/useToast');
describe(useExecutionData, () => {
describe(useLogsExecutionData, () => {
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
@@ -72,7 +72,7 @@ describe(useExecutionData, () => {
}),
);
const { loadSubExecution, entries } = useExecutionData();
const { loadSubExecution, entries } = useLogsExecutionData();
expect(entries.value).toHaveLength(2);
expect(entries.value[1].children).toHaveLength(0);
@@ -101,7 +101,7 @@ describe(useExecutionData, () => {
new Error('test execution fetch fail'),
);
const { loadSubExecution, entries } = useExecutionData();
const { loadSubExecution, entries } = useLogsExecutionData();
await loadSubExecution(entries.value[1]);
await waitFor(() => expect(showErrorSpy).toHaveBeenCalled());

View File

@@ -15,7 +15,7 @@ import {
import { parse } from 'flatted';
import { useToast } from '@/composables/useToast';
export function useExecutionData() {
export function useLogsExecutionData() {
const nodeHelpers = useNodeHelpers();
const workflowsStore = useWorkflowsStore();
const toast = useToast();
@@ -135,7 +135,7 @@ export function useExecutionData() {
);
return {
execution: execData,
execution: computed(() => execData.value),
entries,
hasChat,
latestNodeNameById,

View File

@@ -6,22 +6,19 @@ import {
} from '../../composables/useResize';
import { LOGS_PANEL_STATE } from '../../types/logs';
import { usePiPWindow } from '../../composables/usePiPWindow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCanvasStore } from '@/stores/canvas.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { watch } from 'vue';
import { useResizablePanel } from './useResizablePanel';
import { useResizablePanel } from '../../../../composables/useResizablePanel';
import { useLogsStore } from '@/stores/logs.store';
export function useLayout(
export function useLogsPanelLayout(
pipContainer: Readonly<ShallowRef<HTMLElement | null>>,
pipContent: Readonly<ShallowRef<HTMLElement | null>>,
container: Readonly<ShallowRef<HTMLElement | null>>,
logsContainer: Readonly<ShallowRef<HTMLElement | null>>,
) {
const canvasStore = useCanvasStore();
const logsStore = useLogsStore();
const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore();
const panelState = computed(() => workflowsStore.logsPanelState);
const resizer = useResizablePanel(LOCAL_STORAGE_PANEL_HEIGHT, {
container: document.body,
@@ -49,9 +46,9 @@ export function useLayout(
});
const isOpen = computed(() =>
panelState.value === LOGS_PANEL_STATE.CLOSED
? resizer.isResizing.value && resizer.size.value > 0
: !resizer.isCollapsed.value,
logsStore.isOpen
? !resizer.isCollapsed.value
: resizer.isResizing.value && resizer.size.value > 0,
);
const isCollapsingDetailsPanel = computed(() => overviewPanelResizer.isFullSize.value);
@@ -60,25 +57,25 @@ export function useLayout(
initialWidth: window.document.body.offsetWidth * 0.8,
container: pipContainer,
content: pipContent,
shouldPopOut: computed(() => panelState.value === LOGS_PANEL_STATE.FLOATING),
shouldPopOut: computed(() => logsStore.state === LOGS_PANEL_STATE.FLOATING),
onRequestClose: () => {
if (!isOpen.value) {
return;
}
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPreferPoppedOutLogsView(false);
logsStore.setPreferPoppedOut(false);
},
});
function handleToggleOpen(open?: boolean) {
const wasOpen = panelState.value !== LOGS_PANEL_STATE.CLOSED;
const wasOpen = logsStore.isOpen;
if (open === wasOpen) {
return;
}
workflowsStore.toggleLogsPanelOpen(open);
logsStore.toggleOpen(open);
telemetry.track('User toggled log view', {
new_state: wasOpen ? 'collapsed' : 'attached',
@@ -87,12 +84,12 @@ export function useLayout(
function handlePopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' });
workflowsStore.toggleLogsPanelOpen(true);
workflowsStore.setPreferPoppedOutLogsView(true);
logsStore.toggleOpen(true);
logsStore.setPreferPoppedOut(true);
}
function handleResizeEnd() {
if (panelState.value === LOGS_PANEL_STATE.CLOSED && !resizer.isCollapsed.value) {
if (!logsStore.isOpen && !resizer.isCollapsed.value) {
handleToggleOpen(true);
}
@@ -104,9 +101,9 @@ export function useLayout(
}
watch(
[panelState, resizer.size],
[() => logsStore.state, resizer.size],
([state, height]) => {
canvasStore.setPanelHeight(
logsStore.setHeight(
state === LOGS_PANEL_STATE.FLOATING
? 0
: state === LOGS_PANEL_STATE.ATTACHED

View File

@@ -0,0 +1,55 @@
import type { LogEntrySelection } from '@/components/CanvasChat/types/logs';
import {
findSelectedLogEntry,
getDepth,
getEntryAtRelativeIndex,
type LogEntry,
} from '@/components/RunDataAi/utils';
import { useTelemetry } from '@/composables/useTelemetry';
import type { IExecutionResponse } from '@/Interface';
import { computed, ref, type ComputedRef } from 'vue';
export function useLogsSelection(
execution: ComputedRef<IExecutionResponse | undefined>,
tree: ComputedRef<LogEntry[]>,
flatLogEntries: ComputedRef<LogEntry[]>,
) {
const telemetry = useTelemetry();
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
const selected = computed(() => findSelectedLogEntry(manualLogEntrySelection.value, tree.value));
function select(value: LogEntry | undefined) {
manualLogEntrySelection.value =
value === undefined ? { type: 'none' } : { type: 'selected', id: value.id };
if (value) {
telemetry.track('User selected node in log view', {
node_type: value.node.type,
node_id: value.node.id,
execution_id: execution.value?.id,
workflow_id: execution.value?.workflowData.id,
subworkflow_depth: getDepth(value),
});
}
}
function selectPrev() {
const entries = flatLogEntries.value;
const prevEntry = selected.value
? (getEntryAtRelativeIndex(entries, selected.value.id, -1) ?? entries[0])
: entries[entries.length - 1];
select(prevEntry);
}
function selectNext() {
const entries = flatLogEntries.value;
const nextEntry = selected.value
? (getEntryAtRelativeIndex(entries, selected.value.id, 1) ?? entries[entries.length - 1])
: entries[0];
select(nextEntry);
}
return { selected, select, selectPrev, selectNext };
}

View File

@@ -0,0 +1,16 @@
import { flattenLogEntries, type LogEntry } from '@/components/RunDataAi/utils';
import { computed, ref, type ComputedRef } from 'vue';
export function useLogsTreeExpand(entries: ComputedRef<LogEntry[]>) {
const collapsedEntries = ref<Record<string, boolean>>({});
const flatLogEntries = computed(() => flattenLogEntries(entries.value, collapsedEntries.value));
function toggleExpanded(treeNode: LogEntry) {
collapsedEntries.value[treeNode.id] = !collapsedEntries.value[treeNode.id];
}
return {
flatLogEntries,
toggleExpanded,
};
}

View File

@@ -1,180 +0,0 @@
import { type ResizeData } from '@n8n/design-system';
import { useResizablePanel } from './useResizablePanel';
import { v4 as uuid } from 'uuid';
import { nextTick } from 'vue';
describe(useResizablePanel, () => {
let localStorageKey = uuid();
let container = document.createElement('div');
let resizeData: ResizeData;
beforeEach(() => {
localStorageKey = uuid();
container = document.createElement('div');
Object.defineProperty(container, 'offsetWidth', {
configurable: true,
get() {
return 1000;
},
});
Object.defineProperty(container, 'offsetHeight', {
configurable: true,
get() {
return 800;
},
});
Object.defineProperty(container, 'getBoundingClientRect', {
configurable: true,
get() {
return () =>
({
x: 0,
y: 0,
width: 1000,
height: 800,
}) as DOMRect;
},
});
resizeData = {
height: Math.random(),
width: Math.random(),
dX: Math.random(),
dY: Math.random(),
x: Math.random(),
y: Math.random(),
direction: 'right',
};
});
it('should return defaultSize if value is missing in local storage', () => {
const { size } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
expect(size.value).toBe(444);
});
it('should restore value from local storage if valid number is stored', () => {
window.localStorage.setItem(localStorageKey, '0.333');
const { size } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
expect(size.value).toBe(333);
});
it('should return defaultSize if invalid value is stored in local storage', () => {
window.localStorage.setItem(localStorageKey, '333');
const { size } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
expect(size.value).toBe(444);
});
it('should update size when onResize is called', () => {
const { size, onResize } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
onResize({ ...resizeData, x: 555 });
expect(size.value).toBe(555);
});
it('should calculate and return height if position is "bottom"', () => {
const { size, onResize } = useResizablePanel(localStorageKey, {
container,
defaultSize: 444,
position: 'bottom',
});
onResize({ ...resizeData, y: 222 });
expect(size.value).toBe(578); // container height minus y
});
it('should return size bound in the range between minSize and maxSize', () => {
const { size, onResize } = useResizablePanel(localStorageKey, {
container,
defaultSize: 444,
minSize: 200,
maxSize: (containerSize) => containerSize * 0.9,
});
onResize({ ...resizeData, x: 100 });
expect(size.value).toBe(200);
onResize({ ...resizeData, x: 950 });
expect(size.value).toBe(900);
});
it('should update manually updated size so that proportion is maintained when container is resized', async () => {
const spyResizeObserver = vi.spyOn(window, 'ResizeObserver');
const { size, onResize } = useResizablePanel(localStorageKey, {
container,
defaultSize: 444,
minSize: 200,
maxSize: (containerSize) => containerSize * 0.9,
});
expect(spyResizeObserver).toHaveBeenCalledTimes(1);
onResize({ ...resizeData, x: 600 });
expect(size.value).toBe(600);
Object.defineProperty(container, 'offsetWidth', {
configurable: true,
get() {
return 500;
},
});
spyResizeObserver.mock.calls[0]?.[0]?.([], {} as ResizeObserver);
await nextTick();
expect(size.value).toBe(300);
});
it('should return 0 and isCollapsed=true while resizing beyond minSize if allowCollapse is set to true', () => {
const { size, isCollapsed, onResize, onResizeEnd } = useResizablePanel(localStorageKey, {
container,
defaultSize: 444,
minSize: 300,
allowCollapse: true,
});
expect(size.value).toBe(444);
expect(isCollapsed.value).toBe(false);
onResize({ ...resizeData, x: 200 });
expect(size.value).toBe(300);
expect(isCollapsed.value).toBe(false);
onResize({ ...resizeData, x: 10 });
expect(size.value).toBe(0);
expect(isCollapsed.value).toBe(true);
onResizeEnd();
expect(size.value).toBe(300);
expect(isCollapsed.value).toBe(false);
});
it('should return container size and isFullSize=true while resizing close to container size if allowFullSize is set to true', () => {
const { size, isFullSize, onResize, onResizeEnd } = useResizablePanel(localStorageKey, {
container,
defaultSize: 444,
maxSize: 800,
allowFullSize: true,
});
expect(size.value).toBe(444);
expect(isFullSize.value).toBe(false);
onResize({ ...resizeData, x: 900 });
expect(size.value).toBe(800);
expect(isFullSize.value).toBe(false);
onResize({ ...resizeData, x: 999 });
expect(size.value).toBe(1000);
expect(isFullSize.value).toBe(true);
onResizeEnd();
expect(size.value).toBe(800);
expect(isFullSize.value).toBe(false);
});
});

View File

@@ -1,166 +0,0 @@
import { type ResizeData } from '@n8n/design-system';
import { useLocalStorage } from '@vueuse/core';
import { computed, type MaybeRef, ref, unref, watch } from 'vue';
type GetSize = number | ((containerSize: number) => number);
interface UseResizerV2Options {
/**
* Container element, to which relative size is calculated (doesn't necessarily have to be DOM parent node)
*/
container: MaybeRef<HTMLElement | null>;
/**
* Default size in pixels
*/
defaultSize: GetSize;
/**
* Minimum size in pixels
*/
minSize?: GetSize;
/**
* Maximum size in pixels
*/
maxSize?: GetSize;
/**
* Which end of the container the resizable element itself is located
*/
position?: 'left' | 'bottom';
/**
* If set to true, snaps to default size when resizing close to it
*/
snap?: boolean;
/**
* If set to true, resizing beyond minSize sets size to 0 and isCollapsed to true
* until onResizeEnd is called
*/
allowCollapse?: boolean;
/**
* If set to true, resizing beyond maxSize sets size to the container size and
* isFullSize to true until onResizeEnd is called
*/
allowFullSize?: boolean;
}
export function useResizablePanel(
localStorageKey: string,
{
container,
defaultSize,
snap = true,
minSize = 0,
maxSize = (size) => size,
position = 'left',
allowCollapse,
allowFullSize,
}: UseResizerV2Options,
) {
const containerSize = ref(0);
const persistedSize = useLocalStorage(localStorageKey, -1, { writeDefaults: false });
const isResizing = ref(false);
const sizeOnResizeStart = ref<number>();
const minSizeValue = computed(() => resolveSize(minSize, containerSize.value));
const maxSizeValue = computed(() => resolveSize(maxSize, containerSize.value));
const constrainedSize = computed(() => {
const sizeInPixels =
persistedSize.value >= 0 && persistedSize.value <= 1
? containerSize.value * persistedSize.value
: -1;
if (isResizing.value && allowCollapse && sizeInPixels < 30) {
return 0;
}
if (isResizing.value && allowFullSize && sizeInPixels > containerSize.value - 30) {
return containerSize.value;
}
const defaultSizeValue = resolveSize(defaultSize, containerSize.value);
if (Number.isNaN(sizeInPixels) || !Number.isFinite(sizeInPixels) || sizeInPixels < 0) {
return defaultSizeValue;
}
return Math.max(
minSizeValue.value,
Math.min(
snap && Math.abs(defaultSizeValue - sizeInPixels) < 30 ? defaultSizeValue : sizeInPixels,
maxSizeValue.value,
),
);
});
function getSize(el: { width: number; height: number }) {
return position === 'bottom' ? el.height : el.width;
}
function getOffsetSize(el: { offsetWidth: number; offsetHeight: number }) {
return position === 'bottom' ? el.offsetHeight : el.offsetWidth;
}
function getValue(data: { x: number; y: number }) {
return position === 'bottom' ? data.y : data.x;
}
function resolveSize(getter: GetSize, containerSizeValue: number): number {
return typeof getter === 'number' ? getter : getter(containerSizeValue);
}
function onResize(data: ResizeData) {
const containerRect = unref(container)?.getBoundingClientRect();
const newSizeInPixels = Math.max(
0,
position === 'bottom'
? (containerRect ? getSize(containerRect) : 0) - getValue(data)
: getValue(data) - (containerRect ? getValue(containerRect) : 0),
);
isResizing.value = true;
persistedSize.value = newSizeInPixels / containerSize.value;
if (sizeOnResizeStart.value === undefined) {
sizeOnResizeStart.value = persistedSize.value;
}
}
function onResizeEnd() {
// If resizing ends with either collapsing or maximizing the panel, restore size at the start of dragging
if (
(minSizeValue.value > 0 && constrainedSize.value <= 0) ||
(maxSizeValue.value < containerSize.value && constrainedSize.value >= containerSize.value)
) {
persistedSize.value = sizeOnResizeStart.value;
}
sizeOnResizeStart.value = undefined;
isResizing.value = false;
}
watch(
() => unref(container),
(el, _, onCleanUp) => {
if (!el) {
return;
}
const observer = new ResizeObserver(() => {
containerSize.value = getOffsetSize(el);
});
observer.observe(el);
containerSize.value = getOffsetSize(el);
onCleanUp(() => observer.disconnect());
},
{ immediate: true },
);
return {
isResizing: computed(() => isResizing.value),
isCollapsed: computed(() => isResizing.value && constrainedSize.value <= 0),
isFullSize: computed(() => isResizing.value && constrainedSize.value >= containerSize.value),
size: constrainedSize,
onResize,
onResizeEnd,
};
}

View File

@@ -11,10 +11,11 @@ export const LOGS_PANEL_STATE = {
export type LogsPanelState = (typeof LOGS_PANEL_STATE)[keyof typeof LOGS_PANEL_STATE];
export const LOG_DETAILS_CONTENT = {
export const LOG_DETAILS_PANEL_STATE = {
INPUT: 'input',
OUTPUT: 'output',
BOTH: 'both',
};
} as const;
export type LogDetailsContent = (typeof LOG_DETAILS_CONTENT)[keyof typeof LOG_DETAILS_CONTENT];
export type LogDetailsPanelState =
(typeof LOG_DETAILS_PANEL_STATE)[keyof typeof LOG_DETAILS_PANEL_STATE];

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { KeyboardShortcut } from '@/Interface';
import { N8nKeyboardShortcut, N8nTooltip } from '@n8n/design-system';
import type { Placement } from 'element-plus';
interface Props {
@@ -11,15 +12,15 @@ withDefaults(defineProps<Props>(), { placement: 'top', shortcut: undefined });
</script>
<template>
<n8n-tooltip :placement="placement" :show-after="500">
<N8nTooltip :placement="placement" :show-after="500">
<template #content>
<div :class="$style.shortcut">
<div :class="$style.label">{{ label }}</div>
<n8n-keyboard-shortcut v-if="shortcut" v-bind="shortcut" />
<N8nKeyboardShortcut v-if="shortcut" v-bind="shortcut" />
</div>
</template>
<slot />
</n8n-tooltip>
</N8nTooltip>
</template>
<style lang="scss" module>

View File

@@ -580,6 +580,16 @@ export function flattenLogEntries(
return ret;
}
export function getEntryAtRelativeIndex(
entries: LogEntry[],
id: string,
relativeIndex: number,
): LogEntry | undefined {
const offset = entries.findIndex((e) => e.id === id);
return offset === -1 ? undefined : entries[offset + relativeIndex];
}
function sortLogEntries<T extends { runData: ITaskData }>(a: T, b: T) {
// We rely on execution index only when startTime is different
// Because it is reset to 0 when execution is waited, and therefore not necessarily unique

View File

@@ -69,6 +69,9 @@ const emit = defineEmits<{
'update:node:parameters': [id: string, parameters: Record<string, unknown>];
'update:node:inputs': [id: string];
'update:node:outputs': [id: string];
'update:logs-open': [open?: boolean];
'update:logs:input-open': [open?: boolean];
'update:logs:output-open': [open?: boolean];
'click:node': [id: string, position: XYPosition];
'click:node:add': [id: string, handle: string];
'run:node': [id: string];
@@ -100,6 +103,7 @@ const emit = defineEmits<{
'viewport:change': [viewport: ViewportTransform, dimensions: Dimensions];
'selection:end': [position: XYPosition];
'open:sub-workflow': [nodeId: string];
'start-chat': [];
}>();
const props = withDefaults(
@@ -287,6 +291,9 @@ const keyMap = computed(() => {
ArrowRight: emitWithLastSelectedNode(selectRightNode),
shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes),
shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes),
l: () => emit('update:logs-open'),
i: () => emit('update:logs:input-open'),
o: () => emit('update:logs:output-open'),
};
if (props.readOnly) return readOnlyKeymap;
@@ -305,6 +312,7 @@ const keyMap = computed(() => {
ctrl_enter: () => emit('run:workflow'),
ctrl_s: () => emit('save:workflow'),
shift_alt_t: async () => await onTidyUp({ source: 'keyboard-shortcut' }),
c: () => emit('start-chat'),
};
return fullKeymap;
});

View File

@@ -1,10 +1,12 @@
<script lang="ts" setup>
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useI18n } from '@/composables/useI18n';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { CHAT_TRIGGER_NODE_TYPE } from '@/constants';
import { useLogsStore } from '@/stores/logs.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { N8nButton } from '@n8n/design-system';
import { computed, useCssModule } from 'vue';
import { useRouter } from 'vue-router';
@@ -35,10 +37,11 @@ const containerClass = computed(() => ({
const router = useRouter();
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const logsStore = useLogsStore();
const { runEntireWorkflow } = useRunWorkflow({ router });
const { toggleChatOpen } = useCanvasOperations({ router });
const { startChat } = useCanvasOperations({ router });
const isChatOpen = computed(() => workflowsStore.logsPanelState !== LOGS_PANEL_STATE.CLOSED);
const isChatOpen = computed(() => logsStore.isOpen);
const isExecuting = computed(() => workflowsStore.isWorkflowRunning);
const testId = computed(() => `execute-workflow-button-${name}`);
</script>
@@ -52,15 +55,31 @@ const testId = computed(() => `execute-workflow-button-${name}`);
</div>
<template v-if="!readOnly">
<N8nButton
v-if="type === CHAT_TRIGGER_NODE_TYPE"
:type="isChatOpen ? 'secondary' : 'primary'"
size="large"
:disabled="isExecuting"
:data-test-id="testId"
:label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
@click.capture="toggleChatOpen('node')"
/>
<template v-if="type === CHAT_TRIGGER_NODE_TYPE">
<N8nButton
v-if="isChatOpen"
type="secondary"
size="large"
:disabled="isExecuting"
:data-test-id="testId"
:label="i18n.baseText('chat.hide')"
@click.capture="logsStore.toggleOpen(false)"
/>
<KeyboardShortcutTooltip
v-else
:label="i18n.baseText('chat.open')"
:shortcut="{ keys: ['c'] }"
>
<N8nButton
type="primary"
size="large"
:disabled="isExecuting"
:data-test-id="testId"
:label="i18n.baseText('chat.open')"
@click.capture="startChat('node')"
/>
</KeyboardShortcutTooltip>
</template>
<N8nButton
v-else
type="primary"