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

@@ -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"

View File

@@ -2975,24 +2975,6 @@ describe('useCanvasOperations', () => {
});
});
describe('toggleChatOpen', () => {
it('should invoke workflowsStore#toggleLogsPanelOpen with 2nd argument passed through as 1st argument', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const { toggleChatOpen } = useCanvasOperations({ router });
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
await toggleChatOpen('main');
expect(workflowsStore.toggleLogsPanelOpen).toHaveBeenCalledWith(undefined);
await toggleChatOpen('main', true);
expect(workflowsStore.toggleLogsPanelOpen).toHaveBeenCalledWith(true);
await toggleChatOpen('main', false);
expect(workflowsStore.toggleLogsPanelOpen).toHaveBeenCalledWith(false);
});
});
describe('importTemplate', () => {
it('should import template to canvas', async () => {
const projectsStore = mockedStore(useProjectsStore);

View File

@@ -106,6 +106,9 @@ import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
import { isPresent } from '../utils/typesUtils';
import { useProjectsStore } from '@/stores/projects.store';
import type { CanvasLayoutEvent } from './useCanvasLayout';
import { chatEventBus } from '@n8n/chat/event-buses';
import { isChatNode } from '@/components/CanvasChat/utils';
import { useLogsStore } from '@/stores/logs.store';
type AddNodeData = Partial<INodeUi> & {
type: string;
@@ -147,6 +150,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
const nodeCreatorStore = useNodeCreatorStore();
const executionsStore = useExecutionsStore();
const projectsStore = useProjectsStore();
const logsStore = useLogsStore();
const i18n = useI18n();
const toast = useToast();
@@ -2031,10 +2035,14 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
return data;
}
async function toggleChatOpen(source: 'node' | 'main', isOpen?: boolean) {
function startChat(source?: 'node' | 'main') {
if (!workflowsStore.allNodes.some(isChatNode)) {
return;
}
const workflow = workflowsStore.getCurrentWorkflow();
workflowsStore.toggleLogsPanelOpen(isOpen);
logsStore.toggleOpen(true);
const payload = {
workflow_id: workflow.id,
@@ -2043,6 +2051,10 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
void externalHooks.run('nodeView.onOpenChat', payload);
telemetry.track('User clicked chat open button', payload);
setTimeout(() => {
chatEventBus.emit('focusInput');
}, 0);
}
async function importTemplate({
@@ -2121,7 +2133,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
initializeWorkspace,
resolveNodeWebhook,
openExecution,
toggleChatOpen,
startChat,
importTemplate,
tryToOpenSubworkflowInNewTab,
};

View File

@@ -42,6 +42,7 @@ import { useTelemetry } from './useTelemetry';
import { useSettingsStore } from '@/stores/settings.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
import { useCanvasOperations } from './useCanvasOperations';
import { useAgentRequestStore } from '@/stores/agentRequest.store';
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
@@ -59,6 +60,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
const workflowsStore = useWorkflowsStore();
const executionsStore = useExecutionsStore();
const { dirtinessByName } = useNodeDirtiness();
const { startChat } = useCanvasOperations({ router: useRunWorkflowOpts.router });
function sortNodesByYPosition(nodes: string[]) {
return [...nodes].sort((a, b) => {
@@ -204,7 +206,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
// and halt the execution
if (!chatHasInputData && !chatHasPinData) {
workflowsStore.chatPartialExecutionDestinationNode = options.destinationNode;
workflowsStore.toggleLogsPanelOpen(true);
startChat();
return;
}
}

View File

@@ -10,9 +10,9 @@ import { useExternalHooks } from './useExternalHooks';
import { VIEWS } from '@/constants';
import type { ApplicationError } from 'n8n-workflow';
import { useStyles } from './useStyles';
import { useCanvasStore } from '@/stores/canvas.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useLogsStore } from '@/stores/logs.store';
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
node: {
@@ -31,7 +31,7 @@ export function useToast() {
const i18n = useI18n();
const settingsStore = useSettingsStore();
const { APP_Z_INDEXES } = useStyles();
const canvasStore = useCanvasStore();
const logsStore = useLogsStore();
const ndvStore = useNDVStore();
function showMessage(messageData: Partial<NotificationOptions>, track = true) {
@@ -41,7 +41,7 @@ export function useToast() {
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
offset:
(settingsStore.isAiAssistantEnabled ? 64 : 0) +
(ndvStore.activeNode === null ? canvasStore.panelHeight : 0),
(ndvStore.activeNode === null ? logsStore.height : 0),
appendTo: '#app-grid',
customClass: 'content-toast',
};

View File

@@ -484,6 +484,7 @@ export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
export const LOCAL_STORAGE_LOGS_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL = 'N8N_LOGS_DETAILS_PANEL';
export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
export const COMMUNITY_PLUS_DOCS_URL =

View File

@@ -98,6 +98,7 @@
"generic.workflows": "Workflows",
"generic.rename": "Rename",
"generic.missing.permissions": "Missing permissions to perform this action",
"generic.shortcutHint": "Or press",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",

View File

@@ -9,23 +9,15 @@ export const useCanvasStore = defineStore('canvas', () => {
const loadingService = useLoadingService();
const newNodeInsertPosition = ref<XYPosition | null>(null);
const panelHeight = ref(0);
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
const aiNodes = computed<INodeUi[]>(() =>
nodes.value.filter((node) => node.type.includes('langchain')),
);
function setPanelHeight(height: number) {
panelHeight.value = height;
}
return {
newNodeInsertPosition,
isLoading: loadingService.isLoading,
aiNodes,
panelHeight: computed(() => panelHeight.value),
setPanelHeight,
startLoading: loadingService.startLoading,
setLoadingText: loadingService.setLoadingText,
stopLoading: loadingService.stopLoading,

View File

@@ -0,0 +1,91 @@
import {
LOG_DETAILS_PANEL_STATE,
LOGS_PANEL_STATE,
type LogDetailsPanelState,
} from '@/components/CanvasChat/types/logs';
import { useTelemetry } from '@/composables/useTelemetry';
import { LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL, LOCAL_STORAGE_LOGS_PANEL_OPEN } from '@/constants';
import { useLocalStorage } from '@vueuse/core';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
export const useLogsStore = defineStore('logs', () => {
const isOpen = useLocalStorage(LOCAL_STORAGE_LOGS_PANEL_OPEN, false);
const preferPoppedOut = ref(false);
const state = computed(() =>
isOpen.value
? preferPoppedOut.value
? LOGS_PANEL_STATE.FLOATING
: LOGS_PANEL_STATE.ATTACHED
: LOGS_PANEL_STATE.CLOSED,
);
const height = ref(0);
const detailsState = useLocalStorage<LogDetailsPanelState>(
LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL,
LOG_DETAILS_PANEL_STATE.OUTPUT,
{ writeDefaults: false },
);
const telemetry = useTelemetry();
function setHeight(value: number) {
height.value = value;
}
function toggleOpen(value?: boolean) {
isOpen.value = value ?? !isOpen.value;
}
function setPreferPoppedOut(value: boolean) {
preferPoppedOut.value = value;
}
function toggleInputOpen(open?: boolean) {
const statesWithInput: LogDetailsPanelState[] = [
LOG_DETAILS_PANEL_STATE.INPUT,
LOG_DETAILS_PANEL_STATE.BOTH,
];
const wasOpen = statesWithInput.includes(detailsState.value);
if (open === wasOpen) {
return;
}
detailsState.value = wasOpen ? LOG_DETAILS_PANEL_STATE.OUTPUT : LOG_DETAILS_PANEL_STATE.BOTH;
telemetry.track('User toggled log view sub pane', {
pane: 'input',
newState: wasOpen ? 'hidden' : 'visible',
});
}
function toggleOutputOpen(open?: boolean) {
const statesWithOutput: LogDetailsPanelState[] = [
LOG_DETAILS_PANEL_STATE.OUTPUT,
LOG_DETAILS_PANEL_STATE.BOTH,
];
const wasOpen = statesWithOutput.includes(detailsState.value);
if (open === wasOpen) {
return;
}
detailsState.value = wasOpen ? LOG_DETAILS_PANEL_STATE.INPUT : LOG_DETAILS_PANEL_STATE.BOTH;
telemetry.track('User toggled log view sub pane', {
pane: 'output',
newState: wasOpen ? 'hidden' : 'visible',
});
}
return {
state,
isOpen: computed(() => state.value !== LOGS_PANEL_STATE.CLOSED),
detailsState: computed(() => detailsState.value),
height: computed(() => height.value),
setHeight,
toggleOpen,
setPreferPoppedOut,
toggleInputOpen,
toggleOutputOpen,
};
});

View File

@@ -6,7 +6,6 @@ import {
DUPLICATE_POSTFFIX,
ERROR_TRIGGER_NODE_TYPE,
FORM_NODE_TYPE,
LOCAL_STORAGE_LOGS_PANEL_OPEN,
MAX_WORKFLOW_NAME_LENGTH,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
START_NODE_TYPE,
@@ -93,9 +92,8 @@ import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useUsersStore } from '@/stores/users.store';
import { updateCurrentUserSettings } from '@/api/users';
import { useExecutingNode } from '@/composables/useExecutingNode';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
import { useLocalStorage } from '@vueuse/core';
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
import { useLogsStore } from './logs.store';
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
name: '',
@@ -131,6 +129,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const rootStore = useRootStore();
const nodeHelpers = useNodeHelpers();
const usersStore = useUsersStore();
const logsStore = useLogsStore();
const version = computed(() => settingsStore.partialExecutionVersion);
const workflow = ref<IWorkflowDb>(createEmptyWorkflow());
@@ -153,15 +152,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const isInDebugMode = ref(false);
const chatMessages = ref<string[]>([]);
const chatPartialExecutionDestinationNode = ref<string | null>(null);
const isLogsPanelOpen = useLocalStorage(LOCAL_STORAGE_LOGS_PANEL_OPEN, false);
const preferPopOutLogsView = ref(false);
const logsPanelState = computed(() =>
isLogsPanelOpen.value
? preferPopOutLogsView.value
? LOGS_PANEL_STATE.FLOATING
: LOGS_PANEL_STATE.ATTACHED
: LOGS_PANEL_STATE.CLOSED,
);
const {
executingNode,
@@ -1323,7 +1313,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
// If chat trigger node is removed, close chat
if (node.type === CHAT_TRIGGER_NODE_TYPE && !settingsStore.isNewLogsEnabled) {
toggleLogsPanelOpen(false);
logsStore.toggleOpen(false);
}
if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) {
@@ -1812,14 +1802,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
// End Canvas V2 Functions
//
function toggleLogsPanelOpen(isOpen?: boolean) {
isLogsPanelOpen.value = isOpen ?? !isLogsPanelOpen.value;
}
function setPreferPoppedOutLogsView(value: boolean) {
preferPopOutLogsView.value = value;
}
function markExecutionAsStopped() {
setActiveExecutionId(undefined);
clearNodeExecutionQueue();
@@ -1882,9 +1864,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
getAllLoadedFinishedExecutions,
getWorkflowExecution,
getPastChatMessages,
logsPanelState: computed(() => logsPanelState.value),
toggleLogsPanelOpen,
setPreferPoppedOutLogsView,
outgoingConnectionsByNodeName,
incomingConnectionsByNodeName,
nodeHasOutputConnection,

View File

@@ -116,12 +116,13 @@ import { isValidNodeConnectionType } from '@/utils/typeGuards';
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import { useBuilderStore } from '@/stores/builder.store';
import { useFoldersStore } from '@/stores/folders.store';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useAgentRequestStore } from '@/stores/agentRequest.store';
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
import { useLogsStore } from '@/stores/logs.store';
defineOptions({
name: 'NodeView',
@@ -175,6 +176,7 @@ const templatesStore = useTemplatesStore();
const builderStore = useBuilderStore();
const foldersStore = useFoldersStore();
const agentRequestStore = useAgentRequestStore();
const logsStore = useLogsStore();
const canvasEventBus = createEventBus<CanvasEventBusEvents>();
@@ -224,7 +226,7 @@ const {
editableWorkflow,
editableWorkflowObject,
lastClickPosition,
toggleChatOpen,
startChat,
} = useCanvasOperations({ router });
const { applyExecutionData } = useExecutionDebugging();
useClipboard({ onPaste: onClipboardPaste });
@@ -272,7 +274,7 @@ const keyBindingsEnabled = computed(() => {
return !ndvStore.activeNode && uiStore.activeModals.length === 0;
});
const isLogsPanelOpen = computed(() => workflowsStore.logsPanelState !== LOGS_PANEL_STATE.CLOSED);
const isLogsPanelOpen = computed(() => logsStore.isOpen);
/**
* Initialization
@@ -1358,12 +1360,8 @@ const chatTriggerNodePinnedData = computed(() => {
return workflowsStore.pinDataByNodeName(chatTriggerNode.value.name);
});
async function onToggleChat() {
await toggleChatOpen('main');
}
async function onOpenChat() {
await toggleChatOpen('main', true);
function onOpenChat() {
startChat('main');
}
/**
@@ -1933,6 +1931,9 @@ onBeforeUnmount(() => {
@update:node:parameters="onUpdateNodeParameters"
@update:node:inputs="onUpdateNodeInputs"
@update:node:outputs="onUpdateNodeOutputs"
@update:logs-open="logsStore.toggleOpen($event)"
@update:logs:input-open="logsStore.toggleInputOpen"
@update:logs:output-open="logsStore.toggleOutputOpen"
@open:sub-workflow="onOpenSubWorkflow"
@click:node="onClickNode"
@click:node:add="onClickNodeAdd"
@@ -1958,6 +1959,7 @@ onBeforeUnmount(() => {
@selection:end="onSelectionEnd"
@drag-and-drop="onDragAndDrop"
@tidy-up="onTidyUp"
@start-chat="startChat()"
>
<Suspense>
<LazySetupWorkflowCredentialsButton :class="$style.setupCredentialsButtonWrapper" />
@@ -1972,12 +1974,25 @@ onBeforeUnmount(() => {
@mouseleave="onRunWorkflowButtonMouseLeave"
@click="runEntireWorkflow('main')"
/>
<CanvasChatButton
v-if="containsChatTriggerNodes"
:type="isLogsPanelOpen ? 'tertiary' : 'primary'"
:label="isLogsPanelOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
@click="onToggleChat"
/>
<template v-if="containsChatTriggerNodes">
<CanvasChatButton
v-if="isLogsPanelOpen"
type="tertiary"
:label="i18n.baseText('chat.hide')"
@click="logsStore.toggleOpen(false)"
/>
<KeyboardShortcutTooltip
v-else
:label="i18n.baseText('chat.open')"
:shortcut="{ keys: ['c'] }"
>
<CanvasChatButton
type="primary"
:label="i18n.baseText('chat.open')"
@click="onOpenChat"
/>
</KeyboardShortcutTooltip>
</template>
<CanvasStopCurrentExecutionButton
v-if="isStopExecutionButtonVisible"
:stopping="isStoppingExecution"