fix(editor): Agent preview thread wiring update and error markers (#38826)

This commit is contained in:
Anne Aguirre 2026-09-17 08:03:00 +00:00 committed by GitHub
parent d7e2dcd44d
commit 8fcb236ecf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 273 additions and 3 deletions

View File

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

View File

@ -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<SessionThread[]>([]);
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<typeof globalThis.crypto.randomUUID>,
);
});
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();
});
});

View File

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

View File

@ -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) {

View File

@ -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() {
<div :class="[$style.content, { [$style.previewOpen]: isPreviewOpen }]">
<AgentSessionTimelinePanel
v-if="!isPreviewSessionStale"
:project-id="projectId"
:agent-id="agentId"
:thread-id="threadId"
@loaded="onPanelLoaded"
/>
<div v-else :class="$style.newSessionEmpty">
<N8nEmptyState
:icon="{ type: 'icon', value: 'message-square' }"
:heading="i18n.baseText('agentSessions.timeline.emptyState.heading')"
:description="i18n.baseText('agentSessions.timeline.emptyState.description')"
/>
</div>
<AgentPreviewDock
:is-open="isPreviewOpen"
:session-title="currentSessionTitle"
:session-title="dockSessionTitle"
:session-options="sessionMenu"
:has-session="currentSessionHasMessages"
:has-session="dockHasSession"
:initialized="previewInitialized"
:project-id="projectId"
:agent-id="agentId"
@ -356,4 +423,13 @@ function viewPreviewTrace() {
transition: none;
}
}
.newSessionEmpty {
display: flex;
flex: 1 1 auto;
min-height: 0;
align-items: center;
justify-content: center;
padding: var(--spacing--xl);
}
</style>