diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.test.ts b/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.test.ts index 098654f499..f35a6e8ffb 100644 --- a/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.test.ts +++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.test.ts @@ -277,7 +277,7 @@ describe('LogsPanel', () => { const rendered = render(); - expect(rendered.getByText('AI Agent')).toBeInTheDocument(); + expect(await rendered.findByText('AI Agent')).toBeInTheDocument(); operations.deleteNode(aiAgentNode.id); diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue b/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue index 2e71d33b47..3603824d43 100644 --- a/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue +++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue @@ -139,12 +139,17 @@ function handleResizeOverviewPanelEnd() { @resizeend="handleResizeOverviewPanelEnd" > { isOpen: false, isReadOnly: false, isCompact: false, + scrollToSelection: false, execution: { ...aiChatExecutionResponse, tree: createLogEntries( @@ -86,16 +87,17 @@ describe('LogsOverviewPanel', () => { expect(summary.queryByText('Success in 1.999s')).toBeInTheDocument(); expect(summary.queryByText('555 Tokens')).toBeInTheDocument(); + await fireEvent.click(rendered.getByText('Overview')); + const tree = within(rendered.getByRole('tree')); - expect(tree.queryAllByRole('treeitem')).toHaveLength(2); + await waitFor(() => expect(tree.queryAllByRole('treeitem')).toHaveLength(2)); const row1 = within(tree.queryAllByRole('treeitem')[0]); expect(row1.queryByText('AI Agent')).toBeInTheDocument(); expect(row1.queryByText('Success in 1.778s')).toBeInTheDocument(); expect(row1.queryByText('Started 00:00:00.002, 26 Mar')).toBeInTheDocument(); - expect(row1.queryByText('555 Tokens')).toBeInTheDocument(); const row2 = within(tree.queryAllByRole('treeitem')[1]); @@ -114,7 +116,7 @@ describe('LogsOverviewPanel', () => { const rendered = render({ isOpen: true, }); - const aiAgentRow = rendered.getAllByRole('treeitem')[0]; + const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0]; expect(ndvStore.activeNodeName).toBe(null); expect(ndvStore.output.run).toBe(undefined); @@ -140,10 +142,33 @@ describe('LogsOverviewPanel', () => { ), }, }); - const aiAgentRow = rendered.getAllByRole('treeitem')[0]; + const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0]; + await fireEvent.click(within(aiAgentRow).getAllByLabelText('Test step')[0]); await waitFor(() => 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(); + }); }); diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewPanel.vue b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewPanel.vue index 44fcdf23f9..b18ff8152f 100644 --- a/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewPanel.vue +++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewPanel.vue @@ -3,8 +3,7 @@ import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.v import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible'; import { useI18n } from '@/composables/useI18n'; import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system'; -import { computed, nextTick } from 'vue'; -import { ElTree, type TreeNode as ElTreeNode } from 'element-plus'; +import { ref, computed, nextTick, watch } from 'vue'; import { useTelemetry } from '@/composables/useTelemetry'; import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue'; import { useRunWorkflow } from '@/composables/useRunWorkflow'; @@ -13,20 +12,24 @@ import { useRouter } from 'vue-router'; import ExecutionSummary from '@/components/CanvasChat/future/components/ExecutionSummary.vue'; import { type ExecutionLogViewData, + flattenLogEntries, getSubtreeTotalConsumedTokens, getTotalConsumedTokens, type LatestNodeInfo, type LogEntry, } from '@/components/RunDataAi/utils'; +import { useVirtualList } from '@vueuse/core'; -const { isOpen, isReadOnly, selected, isCompact, execution, latestNodeInfo } = defineProps<{ - isOpen: boolean; - selected?: LogEntry; - isReadOnly: boolean; - isCompact: boolean; - execution?: ExecutionLogViewData; - latestNodeInfo: Record; -}>(); +const { isOpen, isReadOnly, selected, isCompact, execution, latestNodeInfo, scrollToSelection } = + defineProps<{ + isOpen: boolean; + selected?: LogEntry; + isReadOnly: boolean; + isCompact: boolean; + execution?: ExecutionLogViewData; + latestNodeInfo: Record; + scrollToSelection: boolean; + }>(); const emit = defineEmits<{ clickHeader: []; @@ -50,6 +53,11 @@ const switchViewOptions = computed(() => [ const consumedTokens = computed(() => getTotalConsumedTokens(...(execution?.tree ?? []).map(getSubtreeTotalConsumedTokens)), ); +const collapsedEntries = ref>({}); +const flatLogEntries = computed(() => + flattenLogEntries(execution?.tree ?? [], collapsedEntries.value), +); +const virtualList = useVirtualList(flatLogEntries, { itemHeight: 32 }); function handleClickNode(clicked: LogEntry) { if (selected?.node === clicked.node && selected?.runIndex === clicked.runIndex) { @@ -73,8 +81,8 @@ function handleSwitchView(value: 'overview' | 'details') { ); } -function handleToggleExpanded(treeNode: ElTreeNode) { - treeNode.expanded = !treeNode.expanded; +function handleToggleExpanded(treeNode: LogEntry) { + collapsedEntries.value[treeNode.id] = !collapsedEntries.value[treeNode.id]; } async function handleOpenNdv(treeNode: LogEntry) { @@ -90,6 +98,22 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) { await runWorkflow.runWorkflow({ destinationNode: latestName }); } } + +// Scroll selected row into view +watch( + () => (scrollToSelection ? selected : undefined), + async (entry) => { + if (entry) { + const index = flatLogEntries.value.findIndex((e) => e.id === entry.id); + + if (index >= 0) { + // Wait for the node to be added to the list, and then scroll + await nextTick(() => virtualList.scrollTo(index)); + } + } + }, + { immediate: true }, +); @@ -222,13 +240,6 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) { text-align: center; } -.scrollable { - flex-grow: 1; - flex-shrink: 1; - overflow: auto; - scroll-padding-block: var(--spacing-2xs); -} - .summary { padding: var(--spacing-2xs); } diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewRow.vue b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewRow.vue index f77b96ccc1..f9319fef3f 100644 --- a/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewRow.vue +++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewRow.vue @@ -1,6 +1,5 @@