diff --git a/cypress/e2e/50-logs.cy.ts b/cypress/e2e/50-logs.cy.ts
index 5d20e834cb..f370bf33d4 100644
--- a/cypress/e2e/50-logs.cy.ts
+++ b/cypress/e2e/50-logs.cy.ts
@@ -194,6 +194,7 @@ describe('Logs', () => {
it('should show logs for a workflow with a node that waits for webhook', () => {
workflow.navigateToNewWorkflowPage();
workflow.pasteWorkflow(Workflow_wait_for_webhook);
+ workflow.getCanvas().click('topLeft'); // click canvas to deselect nodes
workflow.clickZoomToFit();
logs.openLogsPanel();
@@ -202,7 +203,6 @@ describe('Logs', () => {
workflow.getNodesWithSpinner().should('contain.text', 'Wait');
workflow.getWaitingNodes().should('contain.text', 'Wait');
logs.getLogEntries().should('have.length', 2);
- logs.getLogEntries().eq(0).click(); // click selected row to deselect
logs.getLogEntries().eq(1).should('contain.text', 'Wait node');
logs.getLogEntries().eq(1).should('contain.text', 'Waiting');
@@ -224,6 +224,7 @@ describe('Logs', () => {
.getOverviewStatus()
.contains(/Success in [\d\.]+m?s/)
.should('exist');
+ logs.getLogEntries().eq(1).click(); // click selected row to deselect
logs.getLogEntries().should('have.length', 2);
logs.getLogEntries().eq(1).should('contain.text', 'Wait node');
logs.getLogEntries().eq(1).should('contain.text', 'Success');
diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json
index 4a6eab1bed..6d8bfd7dea 100644
--- a/packages/frontend/@n8n/i18n/src/locales/en.json
+++ b/packages/frontend/@n8n/i18n/src/locales/en.json
@@ -215,7 +215,7 @@
"chat.window.logsFromNode": "from {nodeName} node",
"chat.window.noChatNode": "No Chat Node",
"chat.window.noExecution": "Nothing got executed yet",
- "chat.window.chat.placeholder": "Type a message, or press ‘up’ arrow for previous one",
+ "chat.window.chat.placeholder": "Type message, or press ‘up’ for prev one",
"chat.window.chat.placeholderPristine": "Type a message",
"chat.window.chat.sendButtonText": "Send",
"chat.window.chat.provideMessage": "Please provide a message",
diff --git a/packages/frontend/editor-ui/src/components/RunData.vue b/packages/frontend/editor-ui/src/components/RunData.vue
index d24d617c5c..28ad161346 100644
--- a/packages/frontend/editor-ui/src/components/RunData.vue
+++ b/packages/frontend/editor-ui/src/components/RunData.vue
@@ -1601,10 +1601,12 @@ defineExpose({ enterEditMode });
-
+
+
+
{{ executingMessage }}
@@ -2302,6 +2304,12 @@ defineExpose({ enterEditMode });
}
}
+.executingMessage {
+ .compact & {
+ color: var(--color-text-light);
+ }
+}
+
@container (max-width: 240px) {
/* Hide title when the panel is too narrow */
.compact:hover .title {
diff --git a/packages/frontend/editor-ui/src/components/RunDataParsedAiContent.vue b/packages/frontend/editor-ui/src/components/RunDataParsedAiContent.vue
index 5aef8e702a..d187922474 100644
--- a/packages/frontend/editor-ui/src/components/RunDataParsedAiContent.vue
+++ b/packages/frontend/editor-ui/src/components/RunDataParsedAiContent.vue
@@ -175,6 +175,12 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) {
font-size: var(--font-size-xs);
}
}
+
+ p {
+ .compact & {
+ line-height: var(--font-line-height-xloose);
+ }
+ }
}
}
@@ -202,7 +208,7 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) {
.compact & {
padding-top: 0;
padding-inline: var(--spacing-2xs);
- font-size: var(--font-size-xs);
+ font-size: var(--font-size-2xs);
}
}
diff --git a/packages/frontend/editor-ui/src/composables/useKeybindings.ts b/packages/frontend/editor-ui/src/composables/useKeybindings.ts
index 7bf1d7480a..f562dcf841 100644
--- a/packages/frontend/editor-ui/src/composables/useKeybindings.ts
+++ b/packages/frontend/editor-ui/src/composables/useKeybindings.ts
@@ -1,7 +1,7 @@
import { useActiveElement, useEventListener } from '@vueuse/core';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import type { MaybeRef, Ref } from 'vue';
-import { computed, inject, unref } from 'vue';
+import { computed, inject, ref, unref } from 'vue';
import { PiPWindowSymbol } from '@/constants';
type KeyboardEventHandler =
@@ -30,7 +30,7 @@ export const useKeybindings = (
disabled: MaybeRef
;
},
) => {
- const pipWindow = inject(PiPWindowSymbol);
+ const pipWindow = inject(PiPWindowSymbol, ref());
const activeElement = useActiveElement({ window: pipWindow?.value });
const { isCtrlKeyPressed } = useDeviceSupport();
diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts
index 77f4b250eb..56dd89cfc8 100644
--- a/packages/frontend/editor-ui/src/constants.ts
+++ b/packages/frontend/editor-ui/src/constants.ts
@@ -482,7 +482,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_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION';
+export const LOCAL_STORAGE_LOGS_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION_ENABLED';
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL = 'N8N_LOGS_DETAILS_PANEL';
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL_SUB_NODE = 'N8N_LOGS_DETAILS_PANEL_SUB_NODE';
export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
diff --git a/packages/frontend/editor-ui/src/features/logs/__test__/data.ts b/packages/frontend/editor-ui/src/features/logs/__test__/data.ts
index 9f011ee86a..e4f7a3efca 100644
--- a/packages/frontend/editor-ui/src/features/logs/__test__/data.ts
+++ b/packages/frontend/editor-ui/src/features/logs/__test__/data.ts
@@ -20,7 +20,7 @@ export function createTestLogTreeCreationContext(
workflows: {},
subWorkflowData: {},
executionId: 'test-execution-id',
- depth: 0,
+ ancestorRunIndexes: [],
data: {
resultData: {
runData,
diff --git a/packages/frontend/editor-ui/src/features/logs/__test__/mocks.ts b/packages/frontend/editor-ui/src/features/logs/__test__/mocks.ts
index 13a1933cef..6f971f9191 100644
--- a/packages/frontend/editor-ui/src/features/logs/__test__/mocks.ts
+++ b/packages/frontend/editor-ui/src/features/logs/__test__/mocks.ts
@@ -17,7 +17,6 @@ export function createTestLogEntry(data: Partial = {}): LogEntry {
id: uuid(),
children: [],
consumedTokens: { completionTokens: 0, totalTokens: 0, promptTokens: 0, isEstimate: false },
- depth: 0,
workflow: createTestWorkflowObject(),
executionId,
execution: createTestWorkflowExecutionResponse({ id: executionId }).data!,
diff --git a/packages/frontend/editor-ui/src/features/logs/components/ChatMessagesPanel.vue b/packages/frontend/editor-ui/src/features/logs/components/ChatMessagesPanel.vue
index 0819bdee52..971593b25a 100644
--- a/packages/frontend/editor-ui/src/features/logs/components/ChatMessagesPanel.vue
+++ b/packages/frontend/editor-ui/src/features/logs/components/ChatMessagesPanel.vue
@@ -255,7 +255,7 @@ async function copySessionId() {
.chat {
--chat--spacing: var(--spacing-xs);
--chat--message--padding: var(--spacing-2xs);
- --chat--message--font-size: var(--font-size-xs);
+ --chat--message--font-size: var(--font-size-2xs);
--chat--input--font-size: var(--font-size-s);
--chat--input--placeholder--font-size: var(--font-size-xs);
--chat--message--bot--background: transparent;
@@ -269,7 +269,10 @@ async function copySessionId() {
--chat--color-typing: var(--color-text-light);
--chat--textarea--max-height: calc(var(--panel-height) * 0.3);
--chat--message--pre--background: var(--color-foreground-light);
- --chat--textarea--height: 2.5rem;
+ --chat--textarea--height: calc(
+ var(--chat--input--padding) * 2 + var(--chat--input--font-size) *
+ var(--chat--input--line-height)
+ );
height: 100%;
display: flex;
flex-direction: column;
@@ -381,4 +384,12 @@ async function copySessionId() {
--input-border-color: #4538a3;
}
}
+
+.messagesHistory {
+ height: var(--chat--textarea--height);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogsOverviewPanel.test.ts b/packages/frontend/editor-ui/src/features/logs/components/LogsOverviewPanel.test.ts
index a0afcafd20..19e743f44f 100644
--- a/packages/frontend/editor-ui/src/features/logs/components/LogsOverviewPanel.test.ts
+++ b/packages/frontend/editor-ui/src/features/logs/components/LogsOverviewPanel.test.ts
@@ -93,7 +93,8 @@ describe('LogsOverviewPanel', () => {
const row1 = within(tree.queryAllByRole('treeitem')[0]);
expect(row1.queryByText('AI Agent')).toBeInTheDocument();
- expect(row1.queryByText('Success in 1.778s')).toBeInTheDocument();
+ expect(row1.queryByText('Success')).toBeInTheDocument();
+ expect(row1.queryByText('in 1.778s')).toBeInTheDocument();
expect(row1.queryByText('Started 00:00:00.002, 26 Mar')).toBeInTheDocument();
const row2 = within(tree.queryAllByRole('treeitem')[1]);
diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogsOverviewPanel.vue b/packages/frontend/editor-ui/src/features/logs/components/LogsOverviewPanel.vue
index 94605b6ceb..2118be84b4 100644
--- a/packages/frontend/editor-ui/src/features/logs/components/LogsOverviewPanel.vue
+++ b/packages/frontend/editor-ui/src/features/logs/components/LogsOverviewPanel.vue
@@ -8,14 +8,11 @@ import LogsOverviewRow from '@/features/logs/components/LogsOverviewRow.vue';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useRouter } from 'vue-router';
import LogsViewExecutionSummary from '@/features/logs/components/LogsViewExecutionSummary.vue';
-import {
- getSubtreeTotalConsumedTokens,
- getTotalConsumedTokens,
- hasSubExecution,
-} from '@/features/logs/logs.utils';
+import { getSubtreeTotalConsumedTokens, getTotalConsumedTokens } from '@/features/logs/logs.utils';
import { useVirtualList } from '@vueuse/core';
import { type IExecutionResponse } from '@/Interface';
import type { LatestNodeInfo, LogEntry } from '@/features/logs/logs.types';
+import { getScrollbarWidth } from '@/utils/htmlUtils';
const {
isOpen,
@@ -43,7 +40,6 @@ const emit = defineEmits<{
clearExecutionData: [];
openNdv: [LogEntry];
toggleExpanded: [LogEntry];
- loadSubExecution: [LogEntry];
}>();
defineSlots<{ actions: {} }>();
@@ -57,6 +53,7 @@ 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 },
]);
+const hasStaticScrollbar = getScrollbarWidth() > 0;
const consumedTokens = computed(() =>
getTotalConsumedTokens(
...entries.map((entry) =>
@@ -73,6 +70,12 @@ const shouldShowTokenCountColumn = computed(
consumedTokens.value.totalTokens > 0 ||
entries.some((entry) => getSubtreeTotalConsumedTokens(entry, true).totalTokens > 0),
);
+const isExpanded = computed(() =>
+ flatLogEntries.reduce>((acc, entry, index, arr) => {
+ acc[entry.id] = arr[index + 1]?.parent?.id === entry.id;
+ return acc;
+ }, {}),
+);
const virtualList = useVirtualList(
toRef(() => flatLogEntries),
{ itemHeight: 32 },
@@ -82,14 +85,6 @@ function handleSwitchView(value: 'overview' | 'details') {
emit('select', value === 'overview' ? undefined : flatLogEntries[0]);
}
-function handleToggleExpanded(treeNode: LogEntry) {
- if (hasSubExecution(treeNode) && treeNode.children.length === 0) {
- emit('loadSubExecution', treeNode);
- return;
- }
- emit('toggleExpanded', treeNode);
-}
-
async function handleTriggerPartialExecution(treeNode: LogEntry) {
const latestName = latestNodeInfo[treeNode.node.id]?.name ?? treeNode.node.name;
@@ -98,25 +93,46 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
}
}
+// While executing, scroll to the bottom if there's no selection
+watch(
+ [() => execution?.status === 'running', () => flatLogEntries.length],
+ async ([isRunning, flatEntryCount], [wasRunning]) => {
+ await nextTick(() => {
+ if (selected === undefined && (isRunning || wasRunning)) {
+ virtualList.scrollTo(flatEntryCount - 1);
+ }
+ });
+ },
+ { immediate: true },
+);
+
// Scroll selected row into view
watch(
- () => selected,
- async (selection) => {
- if (selection && virtualList.list.value.every((e) => e.data.id !== selection.id)) {
- const index = flatLogEntries.findIndex((e) => e.id === selection?.id);
+ () => selected?.id,
+ async (selectedId) => {
+ await nextTick(() => {
+ if (selectedId === undefined) {
+ return;
+ }
+
+ const index = virtualList.list.value.some((e) => e.data.id === selectedId)
+ ? -1
+ : flatLogEntries.findIndex((e) => e.id === selectedId);
if (index >= 0) {
- // Wait for the node to be added to the list, and then scroll
- await nextTick(() => virtualList.scrollTo(index));
+ virtualList.scrollTo(index);
}
- }
+ });
},
{ immediate: true },
);
-
+
nodeTypeStore.getNodeType(props.data.node.type));
-const isSettled = computed(
- () =>
- props.data.runData?.executionStatus &&
- !['running', 'waiting'].includes(props.data.runData.executionStatus),
-);
+const isRunning = computed(() => props.data.runData?.executionStatus === 'running');
+const isWaiting = computed(() => props.data.runData?.executionStatus === 'waiting');
+const isSettled = computed(() => !isRunning.value && !isWaiting.value);
const isError = computed(() => !!props.data.runData?.error);
+const statusTextKeyPath = computed(() =>
+ isSettled.value ? 'logs.overview.body.summaryText.in' : 'logs.overview.body.summaryText.for',
+);
const startedAtText = computed(() => {
if (props.data.runData === undefined) {
return '—';
@@ -72,23 +73,21 @@ const subtreeConsumedTokens = computed(() =>
const hasChildren = computed(() => props.data.children.length > 0 || hasSubExecution(props.data));
-function isLastChild(level: number) {
- let parent = props.data.parent;
- let data: LogEntry | undefined = props.data;
+const indents = computed(() => {
+ const ret: Array<{ straight: boolean; curved: boolean }> = [];
- for (let i = 0; i < props.data.depth - level; i++) {
- data = parent;
- parent = parent?.parent;
+ let data: LogEntry = props.data;
+
+ while (data.parent !== undefined) {
+ const siblings = data.parent?.children ?? [];
+ const lastSibling = siblings[siblings.length - 1];
+
+ ret.unshift({ straight: lastSibling?.id !== data.id, curved: data === props.data });
+ data = data.parent;
}
- const siblings = parent?.children ?? [];
- const lastSibling = siblings[siblings.length - 1];
-
- return (
- (data === undefined && lastSibling === undefined) ||
- (data?.node === lastSibling?.node && data?.runIndex === lastSibling?.runIndex)
- );
-}
+ return ret;
+});
// Focus when selected: For scrolling into view and for keyboard navigation to work
watch(
@@ -119,16 +118,16 @@ watch(
}"
@click.stop="emit('toggleSelected')"
>
-
-
-
-
+
+
-
+
-
-
+
+
+
+
{{ statusText }}
- {{ statusText }}
{{ timeText }}
-
- {{
- locale.baseText('logs.overview.body.summaryText.for', {
- interpolate: { status: statusText, time: timeText },
- })
- }}
-
—
+
+
+
+
* {
overflow: hidden;
@@ -322,8 +316,8 @@ watch(
flex-shrink: 0;
width: 20%;
- .errorIcon {
- margin-right: var(--spacing-4xs);
+ .statusTextIcon {
+ margin-right: var(--spacing-5xs);
vertical-align: text-bottom;
}
}
@@ -395,4 +389,17 @@ watch(
.toggleButton {
display: inline-flex;
}
+
+.statusIcon {
+ color: var(--color-text-light);
+ flex-grow: 0;
+ flex-shrink: 0;
+ width: 26px;
+ height: 26px;
+ padding: var(--spacing-3xs);
+
+ &.placeholder {
+ color: transparent;
+ }
+}
diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts
index 1ef7958a61..e6b0a5111b 100644
--- a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts
+++ b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts
@@ -322,7 +322,8 @@ describe('LogsPanel', () => {
},
});
expect(await lastTreeItem.findByText('AI Agent')).toBeInTheDocument();
- expect(lastTreeItem.getByText('Success in 33ms')).toBeInTheDocument();
+ expect(await lastTreeItem.findByText('Success')).toBeInTheDocument();
+ expect(lastTreeItem.getByText('in 33ms')).toBeInTheDocument();
workflowsStore.setWorkflowExecutionData({
...workflowsStore.workflowExecutionData!,
diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.vue b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.vue
index 93e7a0afbd..441feaa21b 100644
--- a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.vue
+++ b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.vue
@@ -58,7 +58,7 @@ const {
const { entries, execution, hasChat, latestNodeNameById, resetExecutionData, loadSubExecution } =
useLogsExecutionData();
-const { flatLogEntries, toggleExpanded } = useLogsTreeExpand(entries);
+const { flatLogEntries, toggleExpanded } = useLogsTreeExpand(entries, loadSubExecution);
const { selected, select, selectNext, selectPrev } = useLogsSelection(
execution,
entries,
@@ -171,7 +171,6 @@ function handleOpenNdv(treeNode: LogEntry) {
@resizeend="handleResizeOverviewPanelEnd"
>
();
-const pipWindow = inject(PiPWindowSymbol);
+const pipWindow = inject(PiPWindowSymbol, ref());
const activeElement = useActiveElement({ window: pipWindow?.value });
const isBlurred = computed(() => {
@@ -25,3 +25,7 @@ useKeybindings(
{ disabled: isBlurred },
);
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogsViewRunData.vue b/packages/frontend/editor-ui/src/features/logs/components/LogsViewRunData.vue
index 5f87d2bc2d..df31db115f 100644
--- a/packages/frontend/editor-ui/src/features/logs/components/LogsViewRunData.vue
+++ b/packages/frontend/editor-ui/src/features/logs/components/LogsViewRunData.vue
@@ -9,6 +9,7 @@ import { N8nLink, N8nText } from '@n8n/design-system';
import { computed, inject, ref } from 'vue';
import { I18nT } from 'vue-i18n';
import { PiPWindowSymbol } from '@/constants';
+import { isSubNodeLog } from '../logs.utils';
const { title, logEntry, paneType } = defineProps<{
title: string;
@@ -28,7 +29,7 @@ const isMultipleInput = computed(
const runDataProps = computed<
Pick['$props'], 'node' | 'runIndex' | 'overrideOutputs'> | undefined
>(() => {
- if (logEntry.depth > 0 || paneType === 'output') {
+ if (isSubNodeLog(logEntry) || paneType === 'output') {
return { node: logEntry.node, runIndex: logEntry.runIndex };
}
@@ -81,7 +82,7 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
:disable-edit="true"
:disable-hover-highlight="true"
:display-mode="displayMode"
- :disable-ai-content="logEntry.depth === 0"
+ :disable-ai-content="!isSubNodeLog(logEntry)"
:is-executing="isExecuting"
table-header-bg-color="light"
@display-mode-change="handleChangeDisplayMode"
diff --git a/packages/frontend/editor-ui/src/features/logs/composables/useLogsSelection.ts b/packages/frontend/editor-ui/src/features/logs/composables/useLogsSelection.ts
index 4877284d2b..1b0b67b83b 100644
--- a/packages/frontend/editor-ui/src/features/logs/composables/useLogsSelection.ts
+++ b/packages/frontend/editor-ui/src/features/logs/composables/useLogsSelection.ts
@@ -12,8 +12,8 @@ import type { IExecutionResponse } from '@/Interface';
import { useCanvasStore } from '@/stores/canvas.store';
import { useLogsStore } from '@/stores/logs.store';
import { useUIStore } from '@/stores/ui.store';
-import { watch } from 'vue';
-import { computed, ref, type ComputedRef } from 'vue';
+import { shallowRef, watch } from 'vue';
+import { computed, type ComputedRef } from 'vue';
export function useLogsSelection(
execution: ComputedRef,
@@ -22,8 +22,12 @@ export function useLogsSelection(
toggleExpand: (entry: LogEntry, expand?: boolean) => void,
) {
const telemetry = useTelemetry();
- const manualLogEntrySelection = ref({ type: 'initial' });
- const selected = computed(() => findSelectedLogEntry(manualLogEntrySelection.value, tree.value));
+ const manualLogEntrySelection = shallowRef({ type: 'initial' });
+ const nodeNameToSelect = shallowRef();
+ const isExecutionStopped = computed(() => execution.value?.stoppedAt !== undefined);
+ const selected = computed(() =>
+ findSelectedLogEntry(manualLogEntrySelection.value, tree.value, !isExecutionStopped.value),
+ );
const logsStore = useLogsStore();
const uiStore = useUIStore();
const canvasStore = useCanvasStore();
@@ -38,7 +42,7 @@ export function useLogsSelection(
function select(value: LogEntry | undefined) {
manualLogEntrySelection.value =
- value === undefined ? { type: 'none' } : { type: 'selected', id: value.id };
+ value === undefined ? { type: 'none' } : { type: 'selected', entry: value };
if (value) {
syncSelectionToCanvasIfEnabled(value);
@@ -55,21 +59,31 @@ export function useLogsSelection(
function selectPrev() {
const entries = flatLogEntries.value;
+
+ if (entries.length === 0) {
+ return;
+ }
+
const prevEntry = selected.value
? (getEntryAtRelativeIndex(entries, selected.value.id, -1) ?? entries[0])
: entries[entries.length - 1];
- manualLogEntrySelection.value = { type: 'selected', id: prevEntry.id };
+ manualLogEntrySelection.value = { type: 'selected', entry: prevEntry };
syncSelectionToCanvasIfEnabled(prevEntry);
}
function selectNext() {
const entries = flatLogEntries.value;
+
+ if (entries.length === 0) {
+ return;
+ }
+
const nextEntry = selected.value
? (getEntryAtRelativeIndex(entries, selected.value.id, 1) ?? entries[entries.length - 1])
: entries[0];
- manualLogEntrySelection.value = { type: 'selected', id: nextEntry.id };
+ manualLogEntrySelection.value = { type: 'selected', entry: nextEntry };
syncSelectionToCanvasIfEnabled(nextEntry);
}
@@ -93,16 +107,19 @@ export function useLogsSelection(
canvasStore.hasRangeSelection ||
selected.value?.node.name === selectedOnCanvas
) {
+ nodeNameToSelect.value = undefined;
return;
}
const entry = findLogEntryRec((e) => e.node.name === selectedOnCanvas, tree.value);
if (!entry) {
+ nodeNameToSelect.value = selectedOnCanvas;
return;
}
- manualLogEntrySelection.value = { type: 'selected', id: entry.id };
+ nodeNameToSelect.value = undefined;
+ manualLogEntrySelection.value = { type: 'selected', entry };
let parent = entry.parent;
@@ -114,5 +131,22 @@ export function useLogsSelection(
{ immediate: true },
);
+ watch(
+ tree,
+ (t) => {
+ if (nodeNameToSelect.value === undefined) {
+ return;
+ }
+
+ const entry = findLogEntryRec((e) => e.node.name === nodeNameToSelect.value, t);
+
+ if (entry) {
+ nodeNameToSelect.value = undefined;
+ manualLogEntrySelection.value = { type: 'selected', entry };
+ }
+ },
+ { immediate: true },
+ );
+
return { selected, select, selectPrev, selectNext };
}
diff --git a/packages/frontend/editor-ui/src/features/logs/composables/useLogsTreeExpand.ts b/packages/frontend/editor-ui/src/features/logs/composables/useLogsTreeExpand.ts
index fa78811793..6be49d870f 100644
--- a/packages/frontend/editor-ui/src/features/logs/composables/useLogsTreeExpand.ts
+++ b/packages/frontend/editor-ui/src/features/logs/composables/useLogsTreeExpand.ts
@@ -1,14 +1,24 @@
-import { flattenLogEntries } from '@/features/logs/logs.utils';
-import { computed, ref, type ComputedRef } from 'vue';
+import { flattenLogEntries, hasSubExecution } from '@/features/logs/logs.utils';
+import { computed, shallowRef, type ComputedRef } from 'vue';
import type { LogEntry } from '../logs.types';
-export function useLogsTreeExpand(entries: ComputedRef) {
- const collapsedEntries = ref>({});
+export function useLogsTreeExpand(
+ entries: ComputedRef,
+ loadSubExecution: (logEntry: LogEntry) => Promise,
+) {
+ const collapsedEntries = shallowRef>({});
const flatLogEntries = computed(() => flattenLogEntries(entries.value, collapsedEntries.value));
function toggleExpanded(treeNode: LogEntry, expand?: boolean) {
- collapsedEntries.value[treeNode.id] =
- expand === undefined ? !collapsedEntries.value[treeNode.id] : !expand;
+ if (hasSubExecution(treeNode) && treeNode.children.length === 0) {
+ void loadSubExecution(treeNode);
+ return;
+ }
+
+ collapsedEntries.value = {
+ ...collapsedEntries.value,
+ [treeNode.id]: expand === undefined ? !collapsedEntries.value[treeNode.id] : !expand,
+ };
}
return {
diff --git a/packages/frontend/editor-ui/src/features/logs/logs.types.ts b/packages/frontend/editor-ui/src/features/logs/logs.types.ts
index 00083636e9..072bbc4401 100644
--- a/packages/frontend/editor-ui/src/features/logs/logs.types.ts
+++ b/packages/frontend/editor-ui/src/features/logs/logs.types.ts
@@ -7,7 +7,6 @@ export interface LogEntry {
node: INodeUi;
id: string;
children: LogEntry[];
- depth: number;
runIndex: number;
runData: ITaskData | undefined;
consumedTokens: LlmTokenUsageData;
@@ -18,7 +17,7 @@ export interface LogEntry {
export interface LogTreeCreationContext {
parent: LogEntry | undefined;
- depth: number;
+ ancestorRunIndexes: number[];
workflow: Workflow;
executionId: string;
data: IRunExecutionData;
@@ -34,7 +33,7 @@ export interface LatestNodeInfo {
export type LogEntrySelection =
| { type: 'initial' }
- | { type: 'selected'; id: string }
+ | { type: 'selected'; entry: LogEntry }
| { type: 'none' };
export type LogsPanelState = (typeof LOGS_PANEL_STATE)[keyof typeof LOGS_PANEL_STATE];
diff --git a/packages/frontend/editor-ui/src/features/logs/logs.utils.test.ts b/packages/frontend/editor-ui/src/features/logs/logs.utils.test.ts
index b6b23dd94e..5182623a6c 100644
--- a/packages/frontend/editor-ui/src/features/logs/logs.utils.test.ts
+++ b/packages/frontend/editor-ui/src/features/logs/logs.utils.test.ts
@@ -79,15 +79,13 @@ describe(getTreeNodeData, () => {
expect(logTree.length).toBe(1);
- expect(logTree[0].id).toBe('test-wf-id:A:test-execution-id:0');
- expect(logTree[0].depth).toBe(0);
+ expect(logTree[0].id).toBe('test-wf-id:test-node-id-a:0');
expect(logTree[0].runIndex).toBe(0);
expect(logTree[0].parent).toBe(undefined);
expect(logTree[0].runData?.startTime).toBe(1740528000000);
expect(logTree[0].children.length).toBe(2);
- expect(logTree[0].children[0].id).toBe('test-wf-id:B:test-execution-id:0');
- expect(logTree[0].children[0].depth).toBe(1);
+ expect(logTree[0].children[0].id).toBe('test-wf-id:test-node-id-b:0:0');
expect(logTree[0].children[0].runIndex).toBe(0);
expect(logTree[0].children[0].parent?.node.name).toBe('A');
expect(logTree[0].children[0].runData?.startTime).toBe(1740528000001);
@@ -95,23 +93,20 @@ describe(getTreeNodeData, () => {
expect(logTree[0].children[0].consumedTokens.completionTokens).toBe(1);
expect(logTree[0].children[0].children.length).toBe(1);
- expect(logTree[0].children[0].children[0].id).toBe('test-wf-id:C:test-execution-id:0');
- expect(logTree[0].children[0].children[0].depth).toBe(2);
+ expect(logTree[0].children[0].children[0].id).toBe('test-wf-id:test-node-id-c:0:0:0');
expect(logTree[0].children[0].children[0].runIndex).toBe(0);
expect(logTree[0].children[0].children[0].parent?.node.name).toBe('B');
expect(logTree[0].children[0].children[0].consumedTokens.isEstimate).toBe(true);
expect(logTree[0].children[0].children[0].consumedTokens.completionTokens).toBe(7);
- expect(logTree[0].children[1].id).toBe('test-wf-id:B:test-execution-id:1');
- expect(logTree[0].children[1].depth).toBe(1);
+ expect(logTree[0].children[1].id).toBe('test-wf-id:test-node-id-b:0:1');
expect(logTree[0].children[1].runIndex).toBe(1);
expect(logTree[0].children[1].parent?.node.name).toBe('A');
expect(logTree[0].children[1].consumedTokens.isEstimate).toBe(false);
expect(logTree[0].children[1].consumedTokens.completionTokens).toBe(4);
expect(logTree[0].children[1].children.length).toBe(1);
- expect(logTree[0].children[1].children[0].id).toBe('test-wf-id:C:test-execution-id:1');
- expect(logTree[0].children[1].children[0].depth).toBe(2);
+ expect(logTree[0].children[1].children[0].id).toBe('test-wf-id:test-node-id-c:0:1:1');
expect(logTree[0].children[1].children[0].runIndex).toBe(1);
expect(logTree[0].children[1].children[0].parent?.node.name).toBe('B');
expect(logTree[0].children[1].children[0].consumedTokens.completionTokens).toBe(0);
@@ -554,14 +549,15 @@ describe(getTreeNodeData, () => {
});
describe(findSelectedLogEntry, () => {
- function find(state: LogEntrySelection, response: IExecutionResponse) {
+ function find(state: LogEntrySelection, response: IExecutionResponse, isExecuting: boolean) {
return findSelectedLogEntry(
state,
createLogTree(createTestWorkflowObject(response.workflowData), response),
+ isExecuting,
);
}
- describe('when log is not manually selected', () => {
+ describe('when execution is finished and log is not manually selected', () => {
it('should return undefined if no execution data exists', () => {
const response = createTestWorkflowExecutionResponse({
workflowData: createTestWorkflow({
@@ -574,7 +570,7 @@ describe(findSelectedLogEntry, () => {
data: { resultData: { runData: {} } },
});
- expect(find({ type: 'initial' }, response)).toBe(undefined);
+ expect(find({ type: 'initial' }, response, false)).toBe(undefined);
});
it('should return first log entry with error', () => {
@@ -589,19 +585,27 @@ describe(findSelectedLogEntry, () => {
data: {
resultData: {
runData: {
- A: [createTestTaskData({ executionStatus: 'success' })],
- B: [createTestTaskData({ executionStatus: 'success' })],
+ A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
+ B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
C: [
- createTestTaskData({ executionStatus: 'success' }),
- createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
- createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
+ createTestTaskData({ executionStatus: 'success', startTime: 2 }),
+ createTestTaskData({
+ error: {} as ExecutionError,
+ executionStatus: 'error',
+ startTime: 3,
+ }),
+ createTestTaskData({
+ error: {} as ExecutionError,
+ executionStatus: 'error',
+ startTime: 4,
+ }),
],
},
},
},
});
- expect(find({ type: 'initial' }, response)).toEqual(
+ expect(find({ type: 'initial' }, response, false)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 1 }),
);
});
@@ -625,19 +629,27 @@ describe(findSelectedLogEntry, () => {
data: {
resultData: {
runData: {
- A: [createTestTaskData({ executionStatus: 'success' })],
- B: [createTestTaskData({ executionStatus: 'success' })],
+ A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
+ B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
C: [
- createTestTaskData({ executionStatus: 'success' }),
- createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
- createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
+ createTestTaskData({ executionStatus: 'success', startTime: 2 }),
+ createTestTaskData({
+ error: {} as ExecutionError,
+ executionStatus: 'error',
+ startTime: 3,
+ }),
+ createTestTaskData({
+ error: {} as ExecutionError,
+ executionStatus: 'error',
+ startTime: 4,
+ }),
],
},
},
},
});
- expect(find({ type: 'initial' }, response)).toEqual(
+ expect(find({ type: 'initial' }, response, false)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 1 }),
);
});
@@ -654,24 +666,55 @@ describe(findSelectedLogEntry, () => {
data: {
resultData: {
runData: {
- A: [createTestTaskData({ executionStatus: 'success' })],
- B: [createTestTaskData({ executionStatus: 'success' })],
+ A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
+ B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
C: [
- createTestTaskData({ executionStatus: 'success' }),
- createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
- createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
+ createTestTaskData({ executionStatus: 'success', startTime: 2 }),
+ createTestTaskData({ executionStatus: 'success', startTime: 3 }),
+ createTestTaskData({ executionStatus: 'success', startTime: 4 }),
],
},
},
},
});
- expect(find({ type: 'initial' }, response)).toEqual(
+ expect(find({ type: 'initial' }, response, false)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'B' }), runIndex: 0 }),
);
});
- it('should return first log entry if there is no log entry with error nor executed AI agent node', () => {
+ it('should return first log entry with error when it appears after a log entry for AI agent', () => {
+ const response = createTestWorkflowExecutionResponse({
+ workflowData: createTestWorkflow({
+ nodes: [
+ createTestNode({ name: 'A' }),
+ createTestNode({ name: 'B', type: AGENT_LANGCHAIN_NODE_TYPE }),
+ createTestNode({ name: 'C' }),
+ ],
+ }),
+ data: {
+ resultData: {
+ runData: {
+ A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
+ B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
+ C: [
+ createTestTaskData({
+ executionStatus: 'success',
+ error: {} as ExecutionError,
+ startTime: 2,
+ }),
+ ],
+ },
+ },
+ },
+ });
+
+ expect(find({ type: 'initial' }, response, false)).toEqual(
+ expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 0 }),
+ );
+ });
+
+ it('should return last log entry if there is no log entry with error nor executed AI agent node', () => {
const response = createTestWorkflowExecutionResponse({
workflowData: createTestWorkflow({
nodes: [
@@ -683,48 +726,98 @@ describe(findSelectedLogEntry, () => {
data: {
resultData: {
runData: {
- A: [createTestTaskData({ executionStatus: 'success' })],
- B: [createTestTaskData({ executionStatus: 'success' })],
+ A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
+ B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
C: [
- createTestTaskData({ executionStatus: 'success' }),
- createTestTaskData({ executionStatus: 'success' }),
- createTestTaskData({ executionStatus: 'success' }),
+ createTestTaskData({ executionStatus: 'success', startTime: 2 }),
+ createTestTaskData({ executionStatus: 'success', startTime: 3 }),
+ createTestTaskData({ executionStatus: 'success', startTime: 4 }),
],
},
},
},
});
- expect(find({ type: 'initial' }, response)).toEqual(
- expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 0 }),
+ expect(find({ type: 'initial' }, response, false)).toEqual(
+ expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 2 }),
);
});
});
describe('when log is manually selected', () => {
- it('should return manually selected log', () => {
- const response = createTestWorkflowExecutionResponse({
- id: 'my-exec-id',
- workflowData: createTestWorkflow({
- id: 'test-wf-id',
- nodes: [createTestNode({ name: 'A' }), createTestNode({ name: 'B' })],
- }),
- data: {
- resultData: {
- runData: {
- A: [createTestTaskData({ executionStatus: 'success' })],
- B: [createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' })],
- },
+ const nodeA = createTestNode({ name: 'A', id: 'a' });
+ const nodeB = createTestNode({ name: 'B', id: 'b' });
+ const workflowData = createTestWorkflow({
+ id: 'test-wf-id',
+ nodes: [nodeA, nodeB],
+ });
+ const response = createTestWorkflowExecutionResponse({
+ workflowData,
+ data: {
+ resultData: {
+ runData: {
+ A: [
+ createTestTaskData({ executionStatus: 'success' }),
+ createTestTaskData({ executionStatus: 'success' }),
+ createTestTaskData({ executionStatus: 'success' }),
+ ],
+ B: [createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' })],
},
},
- });
+ },
+ });
- const result = find({ type: 'selected', id: 'test-wf-id:A:my-exec-id:0' }, response);
+ it('should return manually selected log', () => {
+ const result = find(
+ { type: 'selected', entry: createTestLogEntry({ id: 'test-wf-id:a:0' }) },
+ response,
+ false,
+ );
expect(result).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 0 }),
);
});
+
+ it('should return the log with same node and closest run index as selected if the exact run index is not found in logs', () => {
+ const result = find(
+ {
+ type: 'selected',
+ entry: createTestLogEntry({
+ id: 'test-wf-id:a:4',
+ executionId: response.id,
+ node: nodeA,
+ runIndex: 4,
+ workflow: createTestWorkflowObject(workflowData),
+ }),
+ },
+ response,
+ false,
+ );
+
+ expect(result).toEqual(
+ expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 2 }),
+ );
+ });
+
+ it('should not fallback to the closest run index while executing', () => {
+ const result = find(
+ {
+ type: 'selected',
+ entry: createTestLogEntry({
+ id: 'test-wf-id:a:4',
+ executionId: response.id,
+ node: nodeA,
+ runIndex: 4,
+ workflow: createTestWorkflowObject(workflowData),
+ }),
+ },
+ response,
+ true,
+ );
+
+ expect(result).toBe(undefined);
+ });
});
});
@@ -909,28 +1002,24 @@ describe(createLogTree, () => {
expect(logs).toHaveLength(2);
expect(logs[0].node.name).toBe('A');
- expect(logs[0].depth).toBe(0);
expect(logs[0].workflow).toBe(workflow);
expect(logs[0].execution).toBe(rootExecutionData.data);
expect(logs[0].executionId).toBe('root-exec-id');
expect(logs[0].children).toHaveLength(0);
expect(logs[1].node.name).toBe('B');
- expect(logs[1].depth).toBe(0);
expect(logs[1].workflow).toBe(workflow);
expect(logs[1].execution).toBe(rootExecutionData.data);
expect(logs[1].executionId).toBe('root-exec-id');
expect(logs[1].children).toHaveLength(2);
expect(logs[1].children[0].node.name).toBe('C');
- expect(logs[1].children[0].depth).toBe(1);
expect(logs[1].children[0].workflow).toBe(subWorkflow);
expect(logs[1].children[0].execution).toBe(subExecutionData);
expect(logs[1].children[0].executionId).toBe('sub-exec-id');
expect(logs[1].children[0].children).toHaveLength(0);
expect(logs[1].children[1].node.name).toBe('C');
- expect(logs[1].children[1].depth).toBe(1);
expect(logs[1].children[1].workflow).toBe(subWorkflow);
expect(logs[1].children[1].execution).toBe(subExecutionData);
expect(logs[1].children[1].executionId).toBe('sub-exec-id');
diff --git a/packages/frontend/editor-ui/src/features/logs/logs.utils.ts b/packages/frontend/editor-ui/src/features/logs/logs.utils.ts
index 6e0e73173f..dd94dba578 100644
--- a/packages/frontend/editor-ui/src/features/logs/logs.utils.ts
+++ b/packages/frontend/editor-ui/src/features/logs/logs.utils.ts
@@ -17,8 +17,8 @@ import type { LogEntry, LogEntrySelection, LogTreeCreationContext } from './logs
import { isProxy, isReactive, isRef, toRaw } from 'vue';
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
import { type ChatMessage } from '@n8n/chat/types';
-import get from 'lodash-es/get';
-import isEmpty from 'lodash-es/isEmpty';
+import get from 'lodash/get';
+import isEmpty from 'lodash/isEmpty';
import { v4 as uuid } from 'uuid';
import { TOOL_EXECUTOR_NODE_NAME } from '@n8n/constants';
@@ -54,8 +54,9 @@ function createNode(
return {
parent: context.parent,
node,
- id: `${context.workflow.id}:${node.name}:${context.executionId}:${runIndex}`,
- depth: context.depth,
+ // The ID consists of workflow ID, node ID and run index (including ancestor's), which
+ // makes it possible to identify the same log across different executions
+ id: `${context.workflow.id}:${node.id}:${[...context.ancestorRunIndexes, runIndex].join(':')}`,
runIndex,
runData,
children,
@@ -85,7 +86,7 @@ function getChildNodes(
return createLogTreeRec({
...context,
parent: treeNode,
- depth: context.depth + 1,
+ ancestorRunIndexes: [...context.ancestorRunIndexes, runIndex ?? 0],
workflow,
executionId: subExecutionLocator.executionId,
data: subWorkflowRunData,
@@ -121,7 +122,7 @@ function getChildNodes(
return subNode
? getTreeNodeData(subNode, t, index, {
...context,
- depth: context.depth + 1,
+ ancestorRunIndexes: [...context.ancestorRunIndexes, runIndex ?? 0],
parent: treeNode,
})
: [];
@@ -171,28 +172,25 @@ export function getSubtreeTotalConsumedTokens(
return calculate(treeNode);
}
-function findLogEntryToAutoSelectRec(subTree: LogEntry[], depth: number): LogEntry | undefined {
- for (const entry of subTree) {
- if (entry.runData?.error) {
- return entry;
- }
+function findLogEntryToAutoSelect(subTree: LogEntry[]): LogEntry | undefined {
+ const entryWithError = findLogEntryRec((e) => !!e.runData?.error, subTree);
- const childAutoSelect = findLogEntryToAutoSelectRec(entry.children, depth + 1);
-
- if (childAutoSelect) {
- return childAutoSelect;
- }
-
- if (entry.node.type === AGENT_LANGCHAIN_NODE_TYPE) {
- if (isPlaceholderLog(entry) && entry.children.length > 0) {
- return entry.children[0];
- }
-
- return entry;
- }
+ if (entryWithError) {
+ return entryWithError;
}
- return depth === 0 ? subTree[0] : undefined;
+ const entryForAiAgent = findLogEntryRec(
+ (entry) =>
+ entry.node.type === AGENT_LANGCHAIN_NODE_TYPE ||
+ (entry.parent?.node.type === AGENT_LANGCHAIN_NODE_TYPE && isPlaceholderLog(entry.parent)),
+ subTree,
+ );
+
+ if (entryForAiAgent) {
+ return entryForAiAgent;
+ }
+
+ return subTree[subTree.length - 1];
}
export function createLogTree(
@@ -203,7 +201,7 @@ export function createLogTree(
) {
return createLogTreeRec({
parent: undefined,
- depth: 0,
+ ancestorRunIndexes: [],
executionId: response.id,
workflow,
workflows,
@@ -283,20 +281,33 @@ export function findLogEntryRec(
export function findSelectedLogEntry(
selection: LogEntrySelection,
entries: LogEntry[],
+ isExecuting: boolean,
): LogEntry | undefined {
switch (selection.type) {
case 'initial':
- return findLogEntryToAutoSelectRec(entries, 0);
+ return isExecuting ? undefined : findLogEntryToAutoSelect(entries);
case 'none':
return undefined;
case 'selected': {
- const entry = findLogEntryRec((e) => e.id === selection.id, entries);
+ const found = findLogEntryRec((e) => e.id === selection.entry.id, entries);
- if (entry) {
- return entry;
+ if (found === undefined && !isExecuting) {
+ for (let runIndex = selection.entry.runIndex - 1; runIndex >= 0; runIndex--) {
+ const fallback = findLogEntryRec(
+ (e) =>
+ e.workflow.id === selection.entry.workflow.id &&
+ e.node.id === selection.entry.node.id &&
+ e.runIndex === runIndex,
+ entries,
+ );
+
+ if (fallback !== undefined) {
+ return fallback;
+ }
+ }
}
- return findLogEntryToAutoSelectRec(entries, 0);
+ return found;
}
}
}
diff --git a/packages/frontend/editor-ui/src/stores/logs.store.ts b/packages/frontend/editor-ui/src/stores/logs.store.ts
index d1b0d4e244..3aee5d188d 100644
--- a/packages/frontend/editor-ui/src/stores/logs.store.ts
+++ b/packages/frontend/editor-ui/src/stores/logs.store.ts
@@ -32,7 +32,9 @@ export const useLogsStore = defineStore('logs', () => {
LOG_DETAILS_PANEL_STATE.BOTH,
{ writeDefaults: false },
);
- const isLogSelectionSyncedWithCanvas = useLocalStorage(LOCAL_STORAGE_LOGS_SYNC_SELECTION, false);
+ const isLogSelectionSyncedWithCanvas = useLocalStorage(LOCAL_STORAGE_LOGS_SYNC_SELECTION, true, {
+ writeDefaults: false,
+ });
const isSubNodeSelected = ref(false);
const telemetry = useTelemetry();
diff --git a/packages/frontend/editor-ui/src/utils/htmlUtils.ts b/packages/frontend/editor-ui/src/utils/htmlUtils.ts
index d5f251888c..dabf859112 100644
--- a/packages/frontend/editor-ui/src/utils/htmlUtils.ts
+++ b/packages/frontend/editor-ui/src/utils/htmlUtils.ts
@@ -73,3 +73,26 @@ export function isOutsideSelected(el: HTMLElement | null) {
selection.anchorOffset !== selection.focusOffset)
);
}
+
+let scrollbarWidth: number | undefined;
+
+export function getScrollbarWidth() {
+ if (scrollbarWidth !== undefined) {
+ return scrollbarWidth;
+ }
+
+ const outer = document.createElement('div');
+ const inner = document.createElement('div');
+
+ outer.style.visibility = 'hidden';
+ outer.style.overflow = 'scroll';
+ document.body.appendChild(outer);
+
+ outer.appendChild(inner);
+
+ scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
+
+ outer.parentElement?.removeChild(outer);
+
+ return scrollbarWidth;
+}