mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(editor): Agent preview thread wiring update and error markers (#38826)
This commit is contained in:
parent
d7e2dcd44d
commit
8fcb236ecf
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user