feat(editor): Hover actions on the logs overview (#14386)

This commit is contained in:
Suguru Inoue
2025-04-07 10:35:29 +02:00
committed by GitHub
parent 89d2eb7aa3
commit 8f9ea23019
22 changed files with 317 additions and 130 deletions

View File

@@ -0,0 +1,4 @@
describe('Logs', () => {
// TODO: the test can be written without AI nodes once https://linear.app/n8n/issue/SUG-22 is implemented
it('should open NDV with the run index that corresponds to clicked log entry');
});

View File

@@ -1547,7 +1547,7 @@ export interface IN8nPromptResponse {
export type InputPanel = { export type InputPanel = {
nodeName?: string; nodeName?: string;
run?: number; run: number;
branch?: number; branch?: number;
data: { data: {
isEmpty: boolean; isEmpty: boolean;
@@ -1555,6 +1555,7 @@ export type InputPanel = {
}; };
export type OutputPanel = { export type OutputPanel = {
run: number;
branch?: number; branch?: number;
data: { data: {
isEmpty: boolean; isEmpty: boolean;

View File

@@ -175,7 +175,7 @@ describe('CanvasChat', () => {
return matchedNode; return matchedNode;
}); });
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED; workflowsStore.logsPanelState = LOGS_PANEL_STATE.ATTACHED;
workflowsStore.isLogsPanelOpen = true; workflowsStore.isLogsPanelOpen = true;
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse; workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2']; workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
@@ -198,7 +198,7 @@ describe('CanvasChat', () => {
}); });
it('should not render chat when panel is closed', async () => { it('should not render chat when panel is closed', async () => {
workflowsStore.chatPanelState = LOGS_PANEL_STATE.CLOSED; workflowsStore.logsPanelState = LOGS_PANEL_STATE.CLOSED;
const { queryByTestId } = renderComponent(); const { queryByTestId } = renderComponent();
await waitFor(() => { await waitFor(() => {
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument(); expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
@@ -390,7 +390,7 @@ describe('CanvasChat', () => {
isLoading: computed(() => false), isLoading: computed(() => false),
}); });
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED; workflowsStore.logsPanelState = LOGS_PANEL_STATE.ATTACHED;
workflowsStore.allowFileUploads = true; workflowsStore.allowFileUploads = true;
}); });
@@ -555,7 +555,7 @@ describe('CanvasChat', () => {
}); });
// Close chat panel // Close chat panel
workflowsStore.chatPanelState = LOGS_PANEL_STATE.CLOSED; workflowsStore.logsPanelState = LOGS_PANEL_STATE.CLOSED;
await waitFor(() => { await waitFor(() => {
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0); expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
}); });
@@ -565,14 +565,14 @@ describe('CanvasChat', () => {
const { unmount, rerender } = renderComponent(); const { unmount, rerender } = renderComponent();
// Set initial state // Set initial state
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED; workflowsStore.logsPanelState = LOGS_PANEL_STATE.ATTACHED;
workflowsStore.isLogsPanelOpen = true; workflowsStore.isLogsPanelOpen = true;
// Unmount and remount // Unmount and remount
unmount(); unmount();
await rerender({}); await rerender({});
expect(workflowsStore.chatPanelState).toBe(LOGS_PANEL_STATE.ATTACHED); expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.ATTACHED);
expect(workflowsStore.isLogsPanelOpen).toBe(true); expect(workflowsStore.isLogsPanelOpen).toBe(true);
}); });
}); });

View File

@@ -29,7 +29,7 @@ const pipContent = useTemplateRef('pipContent');
// Computed properties // Computed properties
const workflow = computed(() => workflowsStore.getCurrentWorkflow()); const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const chatPanelState = computed(() => workflowsStore.chatPanelState); const chatPanelState = computed(() => workflowsStore.logsPanelState);
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages); const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const resultData = computed(() => workflowsStore.getWorkflowRunData); const resultData = computed(() => workflowsStore.getWorkflowRunData);
@@ -57,7 +57,7 @@ const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
} }
telemetry.track('User toggled log view', { new_state: 'attached' }); telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPanelState(LOGS_PANEL_STATE.ATTACHED); workflowsStore.setPreferPoppedOutLogsView(false);
}, },
}); });
@@ -80,12 +80,13 @@ defineExpose({
}); });
const closePanel = () => { const closePanel = () => {
workflowsStore.setPanelState(LOGS_PANEL_STATE.CLOSED); workflowsStore.toggleLogsPanelOpen(false);
}; };
function onPopOut() { function onPopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' }); telemetry.track('User toggled log view', { new_state: 'floating' });
workflowsStore.setPanelState(LOGS_PANEL_STATE.FLOATING); workflowsStore.toggleLogsPanelOpen(true);
workflowsStore.setPreferPoppedOutLogsView(true);
} }
// Watchers // Watchers

View File

@@ -36,9 +36,16 @@ export const manualTriggerNode = createTestNode({ name: 'Manual' });
export const aiAgentNode = createTestNode({ name: 'AI Agent', type: AGENT_NODE_TYPE }); export const aiAgentNode = createTestNode({ name: 'AI Agent', type: AGENT_NODE_TYPE });
export const aiModelNode = createTestNode({ name: 'AI Model' }); export const aiModelNode = createTestNode({ name: 'AI Model' });
export const simpleWorkflow = createTestWorkflow({ export const aiManualWorkflow = createTestWorkflow({
nodes: [manualTriggerNode], nodes: [manualTriggerNode, aiAgentNode, aiModelNode],
connections: {}, connections: {
Manual: {
main: [[{ node: 'AI Agent', index: 0, type: 'main' }]],
},
'AI Model': {
ai_languageModel: [[{ node: 'AI Agent', index: 0, type: 'ai_languageModel' }]],
},
},
}); });
export const aiChatWorkflow = createTestWorkflow({ export const aiChatWorkflow = createTestWorkflow({
@@ -53,7 +60,7 @@ export const aiChatWorkflow = createTestWorkflow({
}, },
}); });
export const executionResponse: IExecutionResponse = { export const aiChatExecutionResponse: IExecutionResponse = {
id: 'test-exec-id', id: 'test-exec-id',
finished: true, finished: true,
mode: 'manual', mode: 'manual',
@@ -102,3 +109,52 @@ export const executionResponse: IExecutionResponse = {
startedAt: new Date('2025-03-26T00:00:00.001Z'), startedAt: new Date('2025-03-26T00:00:00.001Z'),
stoppedAt: new Date('2025-03-26T00:00:02.000Z'), stoppedAt: new Date('2025-03-26T00:00:02.000Z'),
}; };
export const aiManualExecutionResponse: IExecutionResponse = {
id: 'test-exec-id-2',
finished: true,
mode: 'manual',
status: 'success',
data: {
resultData: {
runData: {
'AI Agent': [
{
executionStatus: 'success',
startTime: +new Date('2025-03-30T00:00:00.002Z'),
executionTime: 12,
source: [],
data: {},
},
],
'AI Model': [
{
executionStatus: 'success',
startTime: +new Date('2025-03-30T00:00:00.003Z'),
executionTime: 3456,
source: [],
data: {
ai_languageModel: [
[
{
json: {
tokenUsage: {
completionTokens: 4,
promptTokens: 5,
totalTokens: 6,
},
},
},
],
],
},
},
],
},
},
},
workflowData: aiManualWorkflow,
createdAt: new Date('2025-03-30T00:00:00.000Z'),
startedAt: new Date('2025-03-30T00:00:00.001Z'),
stoppedAt: new Date('2025-03-30T00:00:02.000Z'),
};

View File

@@ -42,7 +42,7 @@ export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => voi
const canvasNodes = computed(() => workflowsStore.allNodes); const canvasNodes = computed(() => workflowsStore.allNodes);
const allConnections = computed(() => workflowsStore.allConnections); const allConnections = computed(() => workflowsStore.allConnections);
const chatPanelState = computed(() => workflowsStore.chatPanelState); const logsPanelState = computed(() => workflowsStore.logsPanelState);
const workflow = computed(() => workflowsStore.getCurrentWorkflow()); const workflow = computed(() => workflowsStore.getCurrentWorkflow());
// Initialize features with injected dependencies // Initialize features with injected dependencies
@@ -125,7 +125,7 @@ export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => voi
// Watchers // Watchers
watch( watch(
() => chatPanelState.value, () => logsPanelState.value,
(state) => { (state) => {
if (state !== LOGS_PANEL_STATE.CLOSED) { if (state !== LOGS_PANEL_STATE.CLOSED) {
setChatTriggerNode(); setChatTriggerNode();

View File

@@ -8,7 +8,12 @@ import { setActivePinia } from 'pinia';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { h } from 'vue'; import { h } from 'vue';
import { executionResponse, aiChatWorkflow, simpleWorkflow, nodeTypes } from '../__test__/data'; import {
aiChatExecutionResponse,
aiChatWorkflow,
aiManualWorkflow,
nodeTypes,
} from '../__test__/data';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
describe('LogsPanel', () => { describe('LogsPanel', () => {
@@ -47,16 +52,14 @@ describe('LogsPanel', () => {
}); });
it('should render collapsed panel by default', async () => { it('should render collapsed panel by default', async () => {
workflowsStore.setWorkflow(simpleWorkflow);
const rendered = render(); const rendered = render();
expect(await rendered.findByTestId('logs-overview-header')).toBeInTheDocument(); expect(await rendered.findByTestId('logs-overview-header')).toBeInTheDocument();
expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument(); expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument();
}); });
it('should render logs panel only if the workflow has no chat trigger', async () => { it('should only render logs panel if the workflow has no chat trigger', async () => {
workflowsStore.setWorkflow(simpleWorkflow); workflowsStore.setWorkflow(aiManualWorkflow);
const rendered = render(); const rendered = render();
@@ -99,7 +102,7 @@ describe('LogsPanel', () => {
it('should open log details panel when a log entry is clicked in the logs overview panel', async () => { it('should open log details panel when a log entry is clicked in the logs overview panel', async () => {
workflowsStore.setWorkflow(aiChatWorkflow); workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(executionResponse); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render(); const rendered = render();
@@ -114,7 +117,7 @@ describe('LogsPanel', () => {
it("should show the button to toggle panel in the header of log details panel when it's opened", async () => { it("should show the button to toggle panel in the header of log details panel when it's opened", async () => {
workflowsStore.setWorkflow(aiChatWorkflow); workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(executionResponse); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render(); const rendered = render();

View File

@@ -16,7 +16,7 @@ import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPane
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const canvasStore = useCanvasStore(); const canvasStore = useCanvasStore();
const panelState = computed(() => workflowsStore.chatPanelState); const panelState = computed(() => workflowsStore.logsPanelState);
const container = ref<HTMLElement>(); const container = ref<HTMLElement>();
const selectedLogEntry = ref<LogEntryIdentity | undefined>(undefined); const selectedLogEntry = ref<LogEntryIdentity | undefined>(undefined);
const pipContainer = useTemplateRef('pipContainer'); const pipContainer = useTemplateRef('pipContainer');
@@ -49,7 +49,7 @@ const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
} }
telemetry.track('User toggled log view', { new_state: 'attached' }); telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPanelState(LOGS_PANEL_STATE.ATTACHED); workflowsStore.setPreferPoppedOutLogsView(false);
}, },
}); });
const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({ const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({
@@ -60,19 +60,17 @@ const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$p
})); }));
function onToggleOpen() { function onToggleOpen() {
if (panelState.value === LOGS_PANEL_STATE.CLOSED) { workflowsStore.toggleLogsPanelOpen();
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPanelState(LOGS_PANEL_STATE.ATTACHED); telemetry.track('User toggled log view', {
} else { new_state: panelState.value === LOGS_PANEL_STATE.CLOSED ? 'attached' : 'collapsed',
telemetry.track('User toggled log view', { new_state: 'collapsed' }); });
workflowsStore.setPanelState(LOGS_PANEL_STATE.CLOSED);
}
} }
function handleClickHeader() { function handleClickHeader() {
if (panelState.value === LOGS_PANEL_STATE.CLOSED) { if (panelState.value === LOGS_PANEL_STATE.CLOSED) {
telemetry.track('User toggled log view', { new_state: 'attached' }); telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPanelState(LOGS_PANEL_STATE.ATTACHED); workflowsStore.toggleLogsPanelOpen(true);
} }
} }
@@ -82,7 +80,8 @@ function handleSelectLogEntry(selected: LogEntryIdentity | undefined) {
function onPopOut() { function onPopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' }); telemetry.track('User toggled log view', { new_state: 'floating' });
workflowsStore.setPanelState(LOGS_PANEL_STATE.FLOATING); workflowsStore.toggleLogsPanelOpen(true);
workflowsStore.setPreferPoppedOutLogsView(true);
} }
watch([panelState, height], ([state, h]) => { watch([panelState, height], ([state, h]) => {

View File

@@ -7,11 +7,21 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { h, type ExtractPropTypes } from 'vue'; import { h, type ExtractPropTypes } from 'vue';
import { fireEvent, waitFor, within } from '@testing-library/vue'; import { fireEvent, waitFor, within } from '@testing-library/vue';
import { aiAgentNode, executionResponse, aiChatWorkflow } from '../../__test__/data'; import {
aiAgentNode,
aiChatExecutionResponse,
aiChatWorkflow,
aiManualExecutionResponse,
aiManualWorkflow,
} from '../../__test__/data';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useNDVStore } from '@/stores/ndv.store';
describe('LogsOverviewPanel', () => { describe('LogsOverviewPanel', () => {
let pinia: TestingPinia; let pinia: TestingPinia;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>; let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let pushConnectionStore: ReturnType<typeof mockedStore<typeof usePushConnectionStore>>;
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
function render(props: ExtractPropTypes<typeof LogsOverviewPanel>) { function render(props: ExtractPropTypes<typeof LogsOverviewPanel>) {
return renderComponent(LogsOverviewPanel, { return renderComponent(LogsOverviewPanel, {
@@ -36,6 +46,11 @@ describe('LogsOverviewPanel', () => {
workflowsStore = mockedStore(useWorkflowsStore); workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.setWorkflow(aiChatWorkflow); workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(null); workflowsStore.setWorkflowExecutionData(null);
pushConnectionStore = mockedStore(usePushConnectionStore);
pushConnectionStore.isConnected = true;
ndvStore = mockedStore(useNDVStore);
}); });
it('should not render body if the panel is not open', () => { it('should not render body if the panel is not open', () => {
@@ -51,7 +66,7 @@ describe('LogsOverviewPanel', () => {
}); });
it('should render summary text and executed nodes if there is an execution', async () => { it('should render summary text and executed nodes if there is an execution', async () => {
workflowsStore.setWorkflowExecutionData(executionResponse); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render({ isOpen: true, node: aiAgentNode }); const rendered = render({ isOpen: true, node: aiAgentNode });
const summary = within(rendered.container.querySelector('.summary')!); const summary = within(rendered.container.querySelector('.summary')!);
@@ -79,7 +94,34 @@ describe('LogsOverviewPanel', () => {
expect(row2.queryByText('555 Tokens')).toBeInTheDocument(); expect(row2.queryByText('555 Tokens')).toBeInTheDocument();
// collapse tree // collapse tree
await fireEvent.click(row1.getByRole('button')); await fireEvent.click(row1.getAllByLabelText('Toggle row')[0]);
await waitFor(() => expect(tree.queryAllByRole('treeitem')).toHaveLength(1)); await waitFor(() => expect(tree.queryAllByRole('treeitem')).toHaveLength(1));
}); });
it('should open NDV if the button is clicked', async () => {
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render({ isOpen: true, node: aiAgentNode });
const aiAgentRow = rendered.getAllByRole('treeitem')[0];
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Open...')[0]);
await waitFor(() => expect(ndvStore.activeNodeName).toBe('AI Agent'));
});
it('should trigger partial execution if the button is clicked', async () => {
workflowsStore.setWorkflow(aiManualWorkflow);
workflowsStore.setWorkflowExecutionData(aiManualExecutionResponse);
const spyRun = vi.spyOn(workflowsStore, 'runWorkflow');
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render({ isOpen: true, node: aiAgentNode });
const aiAgentRow = rendered.getAllByRole('treeitem')[0];
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Test step')[0]);
await waitFor(() =>
expect(spyRun).toHaveBeenCalledWith(expect.objectContaining({ destinationNode: 'AI Agent' })),
);
});
}); });

View File

@@ -5,7 +5,7 @@ import { useI18n } from '@/composables/useI18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system'; import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
import { computed } from 'vue'; import { computed, nextTick } from 'vue';
import { ElTree, type TreeNode as ElTreeNode } from 'element-plus'; import { ElTree, type TreeNode as ElTreeNode } from 'element-plus';
import { import {
createAiData, createAiData,
@@ -20,6 +20,9 @@ import { useTelemetry } from '@/composables/useTelemetry';
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue'; import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
import { type LogEntryIdentity } from '@/components/CanvasChat/types/logs'; import { type LogEntryIdentity } from '@/components/CanvasChat/types/logs';
import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.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';
const { node, isOpen, selected } = defineProps<{ const { node, isOpen, selected } = defineProps<{
isOpen: boolean; isOpen: boolean;
@@ -34,6 +37,9 @@ defineSlots<{ actions: {} }>();
const locale = useI18n(); const locale = useI18n();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const router = useRouter();
const runWorkflow = useRunWorkflow({ router });
const ndvStore = useNDVStore();
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
const isClearExecutionButtonVisible = useClearExecutionButtonVisible(); const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
const workflow = computed(() => workflowsStore.getCurrentWorkflow()); const workflow = computed(() => workflowsStore.getCurrentWorkflow());
@@ -106,6 +112,17 @@ function handleSwitchView(value: 'overview' | 'details') {
function handleToggleExpanded(treeNode: ElTreeNode) { function handleToggleExpanded(treeNode: ElTreeNode) {
treeNode.expanded = !treeNode.expanded; treeNode.expanded = !treeNode.expanded;
} }
async function handleOpenNdv(treeNode: TreeNode) {
ndvStore.setActiveNodeName(treeNode.node);
// HACK: defer setting the output run index to not be overridden by other effects
await nextTick(() => ndvStore.setOutputRunIndex(treeNode.runIndex));
}
async function handleTriggerPartialExecution(treeNode: TreeNode) {
await runWorkflow.runWorkflow({ destinationNode: treeNode.node });
}
</script> </script>
<template> <template>
@@ -179,6 +196,8 @@ function handleToggleExpanded(treeNode: ElTreeNode) {
:is-compact="selected !== undefined" :is-compact="selected !== undefined"
:should-show-consumed-tokens="consumedTokens.totalTokens > 0" :should-show-consumed-tokens="consumedTokens.totalTokens > 0"
@toggle-expanded="handleToggleExpanded" @toggle-expanded="handleToggleExpanded"
@open-ndv="handleOpenNdv"
@trigger-partial-execution="handleTriggerPartialExecution"
/> />
</template> </template>
</ElTree> </ElTree>

View File

@@ -4,7 +4,7 @@ import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDa
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed } from 'vue'; import { computed } from 'vue';
import { type INodeUi } from '@/Interface'; import { type INodeUi } from '@/Interface';
import { N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system'; import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
import { type ITaskData } from 'n8n-workflow'; import { type ITaskData } from 'n8n-workflow';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { upperFirst } from 'lodash-es'; import { upperFirst } from 'lodash-es';
@@ -21,7 +21,11 @@ const props = defineProps<{
isCompact: boolean; isCompact: boolean;
}>(); }>();
const emit = defineEmits<{ toggleExpanded: [node: ElTreeNode] }>(); const emit = defineEmits<{
toggleExpanded: [node: ElTreeNode];
triggerPartialExecution: [node: TreeNode];
openNdv: [node: TreeNode];
}>();
const locale = useI18n(); const locale = useI18n();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
@@ -140,43 +144,67 @@ function isLastChild(level: number) {
:class="$style.compactErrorIcon" :class="$style.compactErrorIcon"
/> />
<N8nIconButton <N8nIconButton
type="secondary"
size="small"
icon="play"
style="color: var(--color-text-base)"
:aria-label="locale.baseText('logs.overview.body.run')"
:class="[$style.partialExecutionButton, depth > 0 ? $style.unavailable : '']"
@click.stop="emit('triggerPartialExecution', props.data)"
/>
<N8nIconButton
type="secondary"
size="small"
icon="external-link-alt"
style="color: var(--color-text-base)"
:class="$style.openNdvButton"
:aria-label="locale.baseText('logs.overview.body.open')"
@click.stop="emit('openNdv', props.data)"
/>
<N8nButton
v-if="!isCompact || props.data.children.length > 0" v-if="!isCompact || props.data.children.length > 0"
type="secondary" type="secondary"
size="medium" size="small"
:icon="props.node.expanded ? 'chevron-down' : 'chevron-up'" :square="true"
:style="{ :style="{
visibility: props.data.children.length === 0 ? 'hidden' : '', visibility: props.data.children.length === 0 ? 'hidden' : '',
color: 'var(--color-text-base)', // give higher specificity than the style from the component itself color: 'var(--color-text-base)', // give higher specificity than the style from the component itself
}" }"
:class="$style.toggleButton" :class="$style.toggleButton"
:aria-label="locale.baseText('logs.overview.body.toggleRow')"
@click.stop="emit('toggleExpanded', props.node)" @click.stop="emit('toggleExpanded', props.node)"
/> >
<N8nIcon size="medium" :icon="props.node.expanded ? 'chevron-down' : 'chevron-up'" />
</N8nButton>
</div> </div>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.container { .container {
display: flex; display: flex;
align-items: stretch; align-items: center;
justify-content: stretch; justify-content: stretch;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
z-index: 1; z-index: 1;
--row-gap-thickness: 1px;
& > * { & > * {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
padding: var(--spacing-2xs); padding: var(--spacing-2xs);
margin-bottom: var(--row-gap-thickness);
} }
} }
.background { .background {
position: absolute; position: absolute;
left: calc(var(--indent-depth) * 32px); left: calc(var(--row-gap-thickness) + var(--indent-depth) * 32px);
top: 0; top: 0;
width: calc(100% - var(--indent-depth) * 32px); width: calc(100% - var(--indent-depth) * 32px - var(--row-gap-thickness));
height: 100%; height: calc(100% - var(--row-gap-thickness));
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
z-index: -1; z-index: -1;
@@ -197,6 +225,7 @@ function isLastChild(level: number) {
align-self: stretch; align-self: stretch;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
margin-bottom: 0;
&.connectorCurved:before { &.connectorCurved:before {
content: ''; content: '';
@@ -220,6 +249,7 @@ function isLastChild(level: number) {
} }
.icon { .icon {
margin-left: var(--row-gap-thickness);
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -283,6 +313,26 @@ function isLastChild(level: number) {
} }
} }
.partialExecutionButton,
.openNdvButton {
transition: none;
/* By default, take space but keep invisible */
visibility: hidden;
.container.compact & {
/* When compact, collapse to save space */
display: none;
}
.container:hover &:not(.unavailable) {
visibility: visible;
display: inline-flex;
}
}
.partialExecutionButton,
.openNdvButton,
.toggleButton { .toggleButton {
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
@@ -290,9 +340,15 @@ function isLastChild(level: number) {
background: transparent; background: transparent;
margin-inline-end: var(--spacing-5xs); margin-inline-end: var(--spacing-5xs);
color: var(--color-text-base); color: var(--color-text-base);
align-items: center;
justify-content: center;
&:hover { &:hover {
background: transparent; background: transparent;
} }
} }
.toggleButton {
display: inline-flex;
}
</style> </style>

View File

@@ -34,7 +34,7 @@ const toggleButtonText = computed(() =>
size="small" size="small"
icon-size="medium" icon-size="medium"
:aria-label="popOutButtonText" :aria-label="popOutButtonText"
@click="emit('popOut')" @click.stop="emit('popOut')"
/> />
</N8nTooltip> </N8nTooltip>
<N8nTooltip <N8nTooltip

View File

@@ -78,9 +78,6 @@ const { APP_Z_INDEXES } = useStyles();
const settingsEventBus = createEventBus(); const settingsEventBus = createEventBus();
const redrawRequired = ref(false); const redrawRequired = ref(false);
const runInputIndex = ref(-1);
const runOutputIndex = ref(-1);
const isLinkingEnabled = ref(true);
const selectedInput = ref<string | undefined>(); const selectedInput = ref<string | undefined>();
const triggerWaitingWarningEnabled = ref(false); const triggerWaitingWarningEnabled = ref(false);
const isDragging = ref(false); const isDragging = ref(false);
@@ -248,12 +245,6 @@ const maxOutputRun = computed(() => {
return 0; return 0;
}); });
const outputRun = computed(() =>
runOutputIndex.value === -1
? maxOutputRun.value
: Math.min(runOutputIndex.value, maxOutputRun.value),
);
const maxInputRun = computed(() => { const maxInputRun = computed(() => {
if (inputNode.value === null || activeNode.value === null) { if (inputNode.value === null || activeNode.value === null) {
return 0; return 0;
@@ -290,15 +281,23 @@ const maxInputRun = computed(() => {
return 0; return 0;
}); });
const isLinkingEnabled = computed(() => ndvStore.isRunIndexLinkingEnabled);
const outputRun = computed(() =>
ndvStore.output.run === -1
? maxOutputRun.value
: Math.min(ndvStore.output.run, maxOutputRun.value),
);
const inputRun = computed(() => { const inputRun = computed(() => {
if (isLinkingEnabled.value && maxOutputRun.value === maxInputRun.value) { if (isLinkingEnabled.value && maxOutputRun.value === maxInputRun.value) {
return outputRun.value; return outputRun.value;
} }
if (runInputIndex.value === -1) { if (ndvStore.input.run === -1) {
return maxInputRun.value; return maxInputRun.value;
} }
return Math.min(runInputIndex.value, maxInputRun.value); return Math.min(ndvStore.input.run, maxInputRun.value);
}); });
const canLinkRuns = computed( const canLinkRuns = computed(
@@ -443,13 +442,13 @@ const onPanelsInit = (e: { position: number }) => {
}; };
const onLinkRunToOutput = () => { const onLinkRunToOutput = () => {
isLinkingEnabled.value = true; ndvStore.setRunIndexLinkingEnabled(true);
trackLinking('output'); trackLinking('output');
}; };
const onUnlinkRun = (pane: string) => { const onUnlinkRun = (pane: string) => {
runInputIndex.value = runOutputIndex.value; ndvStore.setInputRunIndex(outputRun.value);
isLinkingEnabled.value = false; ndvStore.setRunIndexLinkingEnabled(false);
trackLinking(pane); trackLinking(pane);
}; };
@@ -476,8 +475,8 @@ const trackLinking = (pane: string) => {
}; };
const onLinkRunToInput = () => { const onLinkRunToInput = () => {
runOutputIndex.value = runInputIndex.value; ndvStore.setOutputRunIndex(inputRun.value);
isLinkingEnabled.value = true; ndvStore.setRunIndexLinkingEnabled(true);
trackLinking('input'); trackLinking('input');
}; };
@@ -553,15 +552,12 @@ const trackRunChange = (run: number, pane: string) => {
}; };
const onRunOutputIndexChange = (run: number) => { const onRunOutputIndexChange = (run: number) => {
runOutputIndex.value = run; ndvStore.setOutputRunIndex(run);
trackRunChange(run, 'output'); trackRunChange(run, 'output');
}; };
const onRunInputIndexChange = (run: number) => { const onRunInputIndexChange = (run: number) => {
runInputIndex.value = run; ndvStore.setInputRunIndex(run);
if (linked.value) {
runOutputIndex.value = run;
}
trackRunChange(run, 'input'); trackRunChange(run, 'input');
}; };
@@ -570,8 +566,8 @@ const onOutputTableMounted = (e: { avgRowHeight: number }) => {
}; };
const onInputNodeChange = (value: string, index: number) => { const onInputNodeChange = (value: string, index: number) => {
runInputIndex.value = -1; ndvStore.setInputRunIndex(-1);
isLinkingEnabled.value = true; ndvStore.setRunIndexLinkingEnabled(true);
selectedInput.value = value; selectedInput.value = value;
telemetry.track('User changed ndv input dropdown', { telemetry.track('User changed ndv input dropdown', {
@@ -621,9 +617,6 @@ watch(
} }
if (node && node.name !== oldNode?.name && !isActiveStickyNode.value) { if (node && node.name !== oldNode?.name && !isActiveStickyNode.value) {
runInputIndex.value = -1;
runOutputIndex.value = -1;
isLinkingEnabled.value = true;
selectedInput.value = undefined; selectedInput.value = undefined;
triggerWaitingWarningEnabled.value = false; triggerWaitingWarningEnabled.value = false;
avgOutputRowHeight.value = 0; avgOutputRowHeight.value = 0;
@@ -674,26 +667,12 @@ watch(
{ immediate: true }, { immediate: true },
); );
watch(maxOutputRun, () => {
runOutputIndex.value = -1;
});
watch(maxInputRun, () => {
runInputIndex.value = -1;
});
watch(inputNodeName, (nodeName) => { watch(inputNodeName, (nodeName) => {
setTimeout(() => { setTimeout(() => {
ndvStore.setInputNodeName(nodeName); ndvStore.setInputNodeName(nodeName);
}, 0); }, 0);
}); });
watch(inputRun, (inputRun) => {
setTimeout(() => {
ndvStore.setInputRunIndex(inputRun);
}, 0);
});
onMounted(() => { onMounted(() => {
dataPinningEventBus.on('data-pinning-discovery', setIsTooltipVisible); dataPinningEventBus.on('data-pinning-discovery', setIsTooltipVisible);
}); });

View File

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

View File

@@ -53,7 +53,6 @@ import { nextTick } from 'vue';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import type { CanvasLayoutEvent } from './useCanvasLayout'; import type { CanvasLayoutEvent } from './useCanvasLayout';
import { useTelemetry } from './useTelemetry'; import { useTelemetry } from './useTelemetry';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
vi.mock('vue-router', async (importOriginal) => { vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<{}>(); const actual = await importOriginal<{}>();
@@ -2928,28 +2927,20 @@ describe('useCanvasOperations', () => {
}); });
describe('toggleChatOpen', () => { describe('toggleChatOpen', () => {
it('should invoke workflowsStore#setPanelState with 1st argument "docked" if the chat panel is closed', async () => { it('should invoke workflowsStore#toggleLogsPanelOpen with 2nd argument passed through as 1st argument', async () => {
const workflowsStore = mockedStore(useWorkflowsStore); const workflowsStore = mockedStore(useWorkflowsStore);
const { toggleChatOpen } = useCanvasOperations({ router }); const { toggleChatOpen } = useCanvasOperations({ router });
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject()); workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
workflowsStore.chatPanelState = LOGS_PANEL_STATE.CLOSED;
await toggleChatOpen('main'); await toggleChatOpen('main');
expect(workflowsStore.toggleLogsPanelOpen).toHaveBeenCalledWith(undefined);
expect(workflowsStore.setPanelState).toHaveBeenCalledWith(LOGS_PANEL_STATE.ATTACHED); await toggleChatOpen('main', true);
}); expect(workflowsStore.toggleLogsPanelOpen).toHaveBeenCalledWith(true);
it('should invoke workflowsStore#setPanelState with 1st argument "collapsed" if the chat panel is open', async () => { await toggleChatOpen('main', false);
const workflowsStore = mockedStore(useWorkflowsStore); expect(workflowsStore.toggleLogsPanelOpen).toHaveBeenCalledWith(false);
const { toggleChatOpen } = useCanvasOperations({ router });
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED;
await toggleChatOpen('main');
expect(workflowsStore.setPanelState).toHaveBeenCalledWith(LOGS_PANEL_STATE.CLOSED);
}); });
}); });

View File

@@ -101,7 +101,6 @@ import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
import { isPresent } from '../utils/typesUtils'; import { isPresent } from '../utils/typesUtils';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import type { CanvasLayoutEvent } from './useCanvasLayout'; import type { CanvasLayoutEvent } from './useCanvasLayout';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
type AddNodeData = Partial<INodeUi> & { type AddNodeData = Partial<INodeUi> & {
type: string; type: string;
@@ -1974,14 +1973,10 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
return data; return data;
} }
async function toggleChatOpen(source: 'node' | 'main') { async function toggleChatOpen(source: 'node' | 'main', isOpen?: boolean) {
const workflow = workflowsStore.getCurrentWorkflow(); const workflow = workflowsStore.getCurrentWorkflow();
workflowsStore.setPanelState( workflowsStore.toggleLogsPanelOpen(isOpen);
workflowsStore.chatPanelState === LOGS_PANEL_STATE.CLOSED
? LOGS_PANEL_STATE.ATTACHED
: LOGS_PANEL_STATE.CLOSED,
);
const payload = { const payload = {
workflow_id: workflow.id, workflow_id: workflow.id,

View File

@@ -40,7 +40,6 @@ import { useTelemetry } from './useTelemetry';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store'; import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useNodeDirtiness } from '@/composables/useNodeDirtiness'; import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) { export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
@@ -183,7 +182,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
// and halt the execution // and halt the execution
if (!chatHasInputData && !chatHasPinData) { if (!chatHasInputData && !chatHasPinData) {
workflowsStore.chatPartialExecutionDestinationNode = options.destinationNode; workflowsStore.chatPartialExecutionDestinationNode = options.destinationNode;
workflowsStore.setPanelState(LOGS_PANEL_STATE.ATTACHED); workflowsStore.toggleLogsPanelOpen(true);
return; return;
} }
} }

View File

@@ -39,7 +39,7 @@ export function useToast() {
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
offset: offset:
settingsStore.isAiAssistantEnabled || settingsStore.isAiAssistantEnabled ||
workflowsStore.chatPanelState === LOGS_PANEL_STATE.ATTACHED workflowsStore.logsPanelState === LOGS_PANEL_STATE.ATTACHED
? 64 ? 64
: 0, : 0,
appendTo: '#app-grid', appendTo: '#app-grid',

View File

@@ -992,6 +992,9 @@
"logs.overview.body.empty.action": "Execute the workflow", "logs.overview.body.empty.action": "Execute the workflow",
"logs.overview.body.summaryText": "{status} in {time}", "logs.overview.body.summaryText": "{status} in {time}",
"logs.overview.body.started": "Started {time}", "logs.overview.body.started": "Started {time}",
"logs.overview.body.run": "Test step",
"logs.overview.body.open": "Open...",
"logs.overview.body.toggleRow": "Toggle row",
"mainSidebar.aboutN8n": "About n8n", "mainSidebar.aboutN8n": "About n8n",
"mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "", "mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "",
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete", "mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",

View File

@@ -38,6 +38,7 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
const localStorageAutoCompleteIsOnboarded = useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED); const localStorageAutoCompleteIsOnboarded = useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED);
const activeNodeName = ref<string | null>(null); const activeNodeName = ref<string | null>(null);
const isRunIndexLinkingEnabled = ref(true);
const mainPanelDimensions = ref<MainPanelDimensions>({ const mainPanelDimensions = ref<MainPanelDimensions>({
unknown: { ...DEFAULT_MAIN_PANEL_DIMENSIONS }, unknown: { ...DEFAULT_MAIN_PANEL_DIMENSIONS },
regular: { ...DEFAULT_MAIN_PANEL_DIMENSIONS }, regular: { ...DEFAULT_MAIN_PANEL_DIMENSIONS },
@@ -48,7 +49,7 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
const pushRef = ref(''); const pushRef = ref('');
const input = ref<InputPanel>({ const input = ref<InputPanel>({
nodeName: undefined, nodeName: undefined,
run: undefined, run: -1,
branch: undefined, branch: undefined,
data: { data: {
isEmpty: true, isEmpty: true,
@@ -59,6 +60,7 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
'schema', 'schema',
); );
const output = ref<OutputPanel>({ const output = ref<OutputPanel>({
run: -1,
branch: undefined, branch: undefined,
data: { data: {
isEmpty: true, isEmpty: true,
@@ -213,15 +215,36 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
const isNDVOpen = computed(() => activeNodeName.value !== null); const isNDVOpen = computed(() => activeNodeName.value !== null);
const setActiveNodeName = (nodeName: string | null): void => { const setActiveNodeName = (nodeName: string | null): void => {
if (nodeName === activeNodeName.value) {
return;
}
activeNodeName.value = nodeName; activeNodeName.value = nodeName;
// Reset run index
input.value.run = -1;
output.value.run = -1;
isRunIndexLinkingEnabled.value = true;
}; };
const setInputNodeName = (nodeName: string | undefined): void => { const setInputNodeName = (nodeName: string | undefined): void => {
input.value.nodeName = nodeName; input.value.nodeName = nodeName;
}; };
const setInputRunIndex = (run?: number): void => { const setInputRunIndex = (run: number = -1): void => {
input.value.run = run; input.value.run = run;
if (isRunIndexLinkingEnabled.value) {
setOutputRunIndex(run);
}
};
const setOutputRunIndex = (run: number = -1): void => {
output.value.run = run;
};
const setRunIndexLinkingEnabled = (value: boolean): void => {
isRunIndexLinkingEnabled.value = value;
}; };
const setMainPanelDimensions = (params: { const setMainPanelDimensions = (params: {
@@ -404,9 +427,12 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
expressionOutputItemIndex, expressionOutputItemIndex,
isTableHoverOnboarded, isTableHoverOnboarded,
mainPanelDimensions, mainPanelDimensions,
isRunIndexLinkingEnabled,
setActiveNodeName, setActiveNodeName,
setInputNodeName, setInputNodeName,
setInputRunIndex, setInputRunIndex,
setOutputRunIndex,
setRunIndexLinkingEnabled,
setMainPanelDimensions, setMainPanelDimensions,
setNDVPushRef, setNDVPushRef,
resetNDVPushRef, resetNDVPushRef,

View File

@@ -91,7 +91,7 @@ import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { updateCurrentUserSettings } from '@/api/users'; import { updateCurrentUserSettings } from '@/api/users';
import { useExecutingNode } from '@/composables/useExecutingNode'; import { useExecutingNode } from '@/composables/useExecutingNode';
import { LOGS_PANEL_STATE, type LogsPanelState } from '@/components/CanvasChat/types/logs'; import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = { const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
name: '', name: '',
@@ -146,7 +146,15 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const isInDebugMode = ref(false); const isInDebugMode = ref(false);
const chatMessages = ref<string[]>([]); const chatMessages = ref<string[]>([]);
const chatPartialExecutionDestinationNode = ref<string | null>(null); const chatPartialExecutionDestinationNode = ref<string | null>(null);
const chatPanelState = ref<LogsPanelState>(LOGS_PANEL_STATE.CLOSED); const isLogsPanelOpen = ref(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, addExecutingNode, removeExecutingNode, clearNodeExecutionQueue } = const { executingNode, addExecutingNode, removeExecutingNode, clearNodeExecutionQueue } =
useExecutingNode(); useExecutingNode();
@@ -1207,7 +1215,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
// If chat trigger node is removed, close chat // If chat trigger node is removed, close chat
if (node.type === CHAT_TRIGGER_NODE_TYPE && !settingsStore.isNewLogsEnabled) { if (node.type === CHAT_TRIGGER_NODE_TYPE && !settingsStore.isNewLogsEnabled) {
setPanelState(LOGS_PANEL_STATE.CLOSED); toggleLogsPanelOpen(false);
} }
if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) { if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) {
@@ -1670,8 +1678,12 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
// End Canvas V2 Functions // End Canvas V2 Functions
// //
function setPanelState(state: LogsPanelState) { function toggleLogsPanelOpen(isOpen?: boolean) {
chatPanelState.value = state; isLogsPanelOpen.value = isOpen ?? !isLogsPanelOpen.value;
}
function setPreferPoppedOutLogsView(value: boolean) {
preferPopOutLogsView.value = value;
} }
function markExecutionAsStopped() { function markExecutionAsStopped() {
@@ -1733,8 +1745,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
getAllLoadedFinishedExecutions, getAllLoadedFinishedExecutions,
getWorkflowExecution, getWorkflowExecution,
getPastChatMessages, getPastChatMessages,
chatPanelState: computed(() => chatPanelState.value), logsPanelState: computed(() => logsPanelState.value),
setPanelState, toggleLogsPanelOpen,
setPreferPoppedOutLogsView,
outgoingConnectionsByNodeName, outgoingConnectionsByNodeName,
incomingConnectionsByNodeName, incomingConnectionsByNodeName,
nodeHasOutputConnection, nodeHasOutputConnection,

View File

@@ -274,7 +274,7 @@ const keyBindingsEnabled = computed(() => {
return !ndvStore.activeNode && uiStore.activeModals.length === 0; return !ndvStore.activeNode && uiStore.activeModals.length === 0;
}); });
const isChatOpen = computed(() => workflowsStore.chatPanelState !== LOGS_PANEL_STATE.CLOSED); const isLogsPanelOpen = computed(() => workflowsStore.logsPanelState !== LOGS_PANEL_STATE.CLOSED);
/** /**
* Initialization * Initialization
@@ -1287,8 +1287,8 @@ const chatTriggerNodePinnedData = computed(() => {
return workflowsStore.pinDataByNodeName(chatTriggerNode.value.name); return workflowsStore.pinDataByNodeName(chatTriggerNode.value.name);
}); });
async function onOpenChat() { async function onOpenChat(isOpen?: boolean) {
await toggleChatOpen('main'); await toggleChatOpen('main', isOpen);
} }
/** /**
@@ -1788,9 +1788,9 @@ onBeforeUnmount(() => {
/> />
<CanvasChatButton <CanvasChatButton
v-if="containsChatTriggerNodes" v-if="containsChatTriggerNodes"
:type="isChatOpen ? 'tertiary' : 'primary'" :type="isLogsPanelOpen ? 'tertiary' : 'primary'"
:label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')" :label="isLogsPanelOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
@click="onOpenChat" @click="onOpenChat(!isLogsPanelOpen)"
/> />
<CanvasStopCurrentExecutionButton <CanvasStopCurrentExecutionButton
v-if="isStopExecutionButtonVisible" v-if="isStopExecutionButtonVisible"