diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index dd9c534e105..bde315d90e9 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1562,6 +1562,8 @@ "agentSessions.timeline.scrollBackward": "Scroll timeline backward", "agentSessions.timeline.scrollForward": "Scroll timeline forward", "agentSessions.timeline.searchPlaceholder": "Search", + "agentSessions.timeline.emptyState.heading": "New session", + "agentSessions.timeline.emptyState.description": "Send a message to start this session's timeline.", "agentSessions.timeline.triggeredVia": "Triggered via {source}", "agentSessions.workflowLog.loading": "Loading execution...", "agentSessions.workflowLog.stillRunning": "Still running", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionTimelineView.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionTimelineView.spec.ts new file mode 100644 index 00000000000..c540c9a0942 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionTimelineView.spec.ts @@ -0,0 +1,187 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- test-only patterns */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { shallowMount, flushPromises } from '@vue/test-utils'; +import { nextTick, reactive } from 'vue'; +import type { PushMessage } from '@n8n/api-types'; +import { N8nEmptyState } from '@n8n/design-system'; +import AgentSessionTimelineView from '../views/AgentSessionTimelineView.vue'; +import AgentSessionTimelinePanel from '../components/AgentSessionTimelinePanel.vue'; +import AgentPreviewDock from '../components/AgentPreviewDock.vue'; +import { AGENT_SESSION_DETAIL_VIEW } from '../constants'; + +interface SessionThread { + id: string; + updatedAt: string; +} + +const routeParams = reactive({ projectId: 'p1', agentId: 'a1', threadId: 'thread-a' }); +const routerPush = vi.fn(); +const routerReplace = vi.fn(); +const sessionThreads = reactive([]); +const pushListeners = new Set<(event: PushMessage) => void>(); + +vi.mock('vue-router', () => ({ + useRoute: () => ({ params: routeParams, query: {} }), + useRouter: () => ({ + push: routerPush, + replace: routerReplace, + resolve: () => ({ href: '#' }), + options: { history: { state: {} } }, + }), +})); + +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: () => ({ restApiContext: {} }), +})); + +vi.mock('@/features/collaboration/projects/projects.store', () => ({ + useProjectsStore: () => ({ personalProject: { id: 'p1' }, currentProject: null, myProjects: [] }), +})); + +vi.mock('@/app/stores/pushConnection.store', () => ({ + usePushConnectionStore: () => ({ + pushConnect: vi.fn(), + pushDisconnect: vi.fn(), + addEventListener: (listener: (event: PushMessage) => void) => { + pushListeners.add(listener); + return () => pushListeners.delete(listener); + }, + }), +})); + +vi.mock('@/features/agents/agentSessions.store', () => ({ + useAgentSessionsStore: () => ({ + threads: sessionThreads, + fetchThreads: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock('@/features/agents/composables/useAgentSessionLangSmithExport', () => ({ + useAgentSessionLangSmithExport: () => ({ + isEnabled: false, + isExporting: false, + sendSession: vi.fn(), + }), +})); + +vi.mock('@/features/agents/composables/useAgentApi', () => ({ + getAgent: vi.fn().mockResolvedValue({ id: 'a1', name: 'Agent One' }), +})); + +vi.mock('@/features/agents/composables/useAgentConfig', () => ({ + useAgentConfig: () => ({ + config: { value: null }, + fetchConfig: vi.fn().mockResolvedValue(undefined), + }), +})); + +/** Mimic the backend recording a turn for `threadId`. */ +function emitExecutionUpdate(threadId: string) { + const event: PushMessage = { + type: 'agentExecutionUpdated', + data: { projectId: 'p1', agentId: 'a1', threadId, executionId: 'e1' }, + }; + for (const listener of [...pushListeners]) listener(event); +} + +describe('AgentSessionTimelineView', () => { + beforeEach(() => { + routeParams.threadId = 'thread-a'; + sessionThreads.splice( + 0, + sessionThreads.length, + { id: 'thread-a', updatedAt: '2026-01-01T00:00:00.000Z' }, + { id: 'thread-c', updatedAt: '2026-01-03T00:00:00.000Z' }, + ); + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue( + 'thread-b' as unknown as ReturnType, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + pushListeners.clear(); + routerPush.mockClear(); + routerReplace.mockClear(); + }); + + it('replaces the stale thread with an empty state when a new preview session starts', async () => { + const wrapper = shallowMount(AgentSessionTimelineView); + await flushPromises(); + + expect(wrapper.findComponent(AgentSessionTimelinePanel).exists()).toBe(true); + expect(wrapper.findComponent(N8nEmptyState).exists()).toBe(false); + + await wrapper.findComponent(AgentPreviewDock).vm.$emit('new-session'); + await nextTick(); + + // The previous thread's error markers must not linger next to the new session. + expect(wrapper.findComponent(AgentSessionTimelinePanel).exists()).toBe(false); + expect(wrapper.findComponent(N8nEmptyState).exists()).toBe(true); + // Nothing to navigate to yet — the new session has no thread to fetch. + expect(routerReplace).not.toHaveBeenCalled(); + }); + + it('re-binds to the new session once the backend records its first turn', async () => { + const wrapper = shallowMount(AgentSessionTimelineView); + await flushPromises(); + await wrapper.findComponent(AgentPreviewDock).vm.$emit('new-session'); + await nextTick(); + + // A turn in some other thread of the same agent must not re-route this page. + emitExecutionUpdate('thread-c'); + await flushPromises(); + expect(routerReplace).not.toHaveBeenCalled(); + + emitExecutionUpdate('thread-b'); + await flushPromises(); + + expect(routerReplace).toHaveBeenCalledWith({ + name: AGENT_SESSION_DETAIL_VIEW, + params: { projectId: 'p1', agentId: 'a1', threadId: 'thread-b' }, + }); + }); + + it('lets the dock name and act on the loaded thread even when the list lacks it', async () => { + const wrapper = shallowMount(AgentSessionTimelineView); + await flushPromises(); + // Drop the route's thread from the list, as for a session outside the first page. + sessionThreads.splice(0, sessionThreads.length); + await nextTick(); + + const dock = wrapper.findComponent(AgentPreviewDock); + const panel = wrapper.findComponent(AgentSessionTimelinePanel); + expect(dock.props('hasSession')).toBe(false); + + // A loaded thread that is not the live session must not stand in for it. + await panel.vm.$emit('loaded', { + thread: { id: 'thread-z', title: 'Other', updatedAt: '2026-01-01T00:00:00.000Z' }, + executions: [], + }); + await nextTick(); + expect(dock.props('hasSession')).toBe(false); + expect(dock.props('sessionTitle')).not.toBe('Other'); + + await panel.vm.$emit('loaded', { + thread: { id: 'thread-a', title: 'Digimon villains', updatedAt: '2026-01-01T00:00:00.000Z' }, + executions: [], + }); + await nextTick(); + expect(dock.props('hasSession')).toBe(true); + expect(dock.props('sessionTitle')).toBe('Digimon villains'); + }); + + it('keeps the timeline when the preview switches to an existing session', async () => { + const wrapper = shallowMount(AgentSessionTimelineView); + await flushPromises(); + + await wrapper.findComponent(AgentPreviewDock).vm.$emit('session-select', 'thread-c'); + await nextTick(); + + // That session already has a thread, so the dock's own trace action owns the + // navigation — this page must not blank out or re-route on its own. + expect(wrapper.findComponent(AgentSessionTimelinePanel).exists()).toBe(true); + expect(wrapper.findComponent(N8nEmptyState).exists()).toBe(false); + expect(routerReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentSessionTimelinePanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentSessionTimelinePanel.vue index c203df87b6b..ff555079fb3 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentSessionTimelinePanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentSessionTimelinePanel.vue @@ -454,6 +454,10 @@ watch([() => props.projectId, () => props.agentId, () => props.threadId], loadTh min-height: 0; height: 100%; overflow: hidden; + /* Keep the timeline's own stacking below the preview dock, which overlays + this column as a sibling. Without it, a z-index inside the chart competes + with the dock and paints over the chat. */ + isolation: isolate; } .subHeader { display: flex; diff --git a/packages/frontend/editor-ui/src/features/agents/components/SessionTimelineChart.vue b/packages/frontend/editor-ui/src/features/agents/components/SessionTimelineChart.vue index f286d0e3d98..4345eb3ae2c 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/SessionTimelineChart.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/SessionTimelineChart.vue @@ -471,7 +471,8 @@ onBeforeUnmount(() => { border-radius: var(--radius--sm); background-color: var(--color--danger); content: ''; - z-index: 10; + /* Only needs to clear the block inside this cell. */ + z-index: 1; } .chart:has(.block:hover, .block.selected) .block:not(:hover):not(.selected) { diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue index b5d12064e63..c9f116f6368 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue @@ -21,11 +21,13 @@ import AgentSessionTimelineHeader from '@/features/agents/components/AgentSessio import AgentSessionTimelinePanel from '@/features/agents/components/AgentSessionTimelinePanel.vue'; import AgentPreviewDock from '@/features/agents/components/AgentPreviewDock.vue'; import { useAgentBuilderSession } from '@/features/agents/composables/useAgentBuilderSession'; +import { useAgentExecutionUpdates } from '@/features/agents/composables/useAgentExecutionUpdates'; import { getAgent } from '@/features/agents/composables/useAgentApi'; import { useAgentConfig } from '@/features/agents/composables/useAgentConfig'; import type { AgentResource } from '@/features/agents/types'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useI18n } from '@n8n/i18n'; +import { N8nEmptyState } from '@n8n/design-system'; import type { DropdownMenuItemProps, IconName, PathItem } from '@n8n/design-system'; import { computed, ref, watch } from 'vue'; import { useStorage } from '@vueuse/core'; @@ -63,12 +65,26 @@ const { activeChatSessionId, effectiveSessionId, currentSessionHasMessages, + currentSessionIsEphemeral, currentSessionTitle, sessionMenu, onSessionPick, onNewChat, } = useAgentBuilderSession({ routeBacked: computed(() => false) }); +/** + * True while the docked preview sits on a brand-new session that has no thread + * yet, so this page's thread is no longer what the preview is running. Picking + * an existing session is excluded: that one has a thread to show right away, + * and the dock's own trace action navigates to it. + */ +const isPreviewSessionStale = computed( + () => + currentSessionIsEphemeral.value && + effectiveSessionId.value !== undefined && + effectiveSessionId.value !== threadId.value, +); + const triggerSource = computed((): string | null => { if (executions.value.length === 0) return null; return executions.value[0].source ?? 'chat'; @@ -180,6 +196,23 @@ const hasLoadedThread = computed(() => thread.value?.id === threadId.value); const totalCost = computed(() => thread.value?.totalCost ?? 0); const durationLabel = computed(() => formatDuration(thread.value?.totalDuration ?? 0)); +/** + * The dock resolves the live session's title and its trace/export/delete + * gates by looking it up in the store's thread list. That list holds only the + * first page fetched on load, so it misses a session started since then and any + * thread opened by link from outside that page. When the loaded thread is the + * live session, it is the authoritative answer, so prefer it over the lookup. + */ +const dockShowsLoadedThread = computed( + () => thread.value !== null && thread.value.id === effectiveSessionId.value, +); +const dockSessionTitle = computed(() => + dockShowsLoadedThread.value ? sessionTitle.value : currentSessionTitle.value, +); +const dockHasSession = computed( + () => dockShowsLoadedThread.value || currentSessionHasMessages.value, +); + function onPanelLoaded(detail: ThreadDetail | null) { thread.value = detail?.thread ?? null; executions.value = detail?.executions ?? []; @@ -218,6 +251,32 @@ watch( { immediate: true }, ); +/** + * Clear this thread's data the instant the live preview session moves on, so + * its error markers/title/metrics don't linger next to a new session. + */ +watch(isPreviewSessionStale, (stale) => { + if (!stale) return; + thread.value = null; + executions.value = []; +}); + +/** + * The new session has no thread to fetch until the backend records its first + * turn, and that push is the signal it now has one — so re-bind the page to it. + */ +useAgentExecutionUpdates({ projectId, agentId, threadId: effectiveSessionId }, () => { + if (!isPreviewSessionStale.value || !effectiveSessionId.value) return; + void router.replace({ + name: AGENT_SESSION_DETAIL_VIEW, + params: { + projectId: projectId.value, + agentId: agentId.value, + threadId: effectiveSessionId.value, + }, + }); +}); + function formatDuration(ms: number): string { if (!ms || ms <= 0) return '0ms'; if (ms < 1000) return `${ms}ms`; @@ -303,17 +362,25 @@ function viewPreviewTrace() {
+
+ +