fix(ai-builder): Fix session timeline/trace not showing on preview dock when full screen (#36930)

This commit is contained in:
Anne Aguirre
2026-08-25 08:09:23 +00:00
committed by GitHub
parent 9fbd9fbc65
commit 80ff0f3ca2
6 changed files with 223 additions and 17 deletions
@@ -7927,6 +7927,7 @@
"agents.builder.header.saving": "Saving…",
"agents.builder.header.saved": "Saved",
"agents.builder.preview.button": "Preview",
"agents.builder.preview.showChat": "Show chat",
"agents.builder.preview.disabledTooltip": "This agent's configuration has errors. Resolve them before previewing.",
"agents.builder.preview.layout.change": "Change layout",
"agents.builder.preview.layout.floating": "Floating",
@@ -127,6 +127,39 @@ describe('usePushConnectionStore', () => {
expect(store.isConnectionRequested).toBe(true);
});
test('should keep the connection open until the last owner disconnects', () => {
const { store, mockWebSocketClient } = createTestInitialState();
store.pushConnect();
store.pushConnect();
expect(mockWebSocketClient.connect).toHaveBeenCalledTimes(1);
expect(store.isConnectionRequested).toBe(true);
vi.advanceTimersByTime(500);
store.pushDisconnect();
expect(mockWebSocketClient.disconnect).not.toHaveBeenCalled();
expect(store.isConnectionRequested).toBe(true);
store.pushDisconnect();
vi.advanceTimersByTime(500);
expect(mockWebSocketClient.disconnect).toHaveBeenCalledTimes(1);
expect(store.isConnectionRequested).toBe(false);
});
test('should ignore disconnect calls that exceed connect calls', () => {
const { store, mockWebSocketClient } = createTestInitialState();
store.pushConnect();
vi.advanceTimersByTime(500);
store.pushDisconnect();
store.pushDisconnect();
vi.advanceTimersByTime(500);
expect(mockWebSocketClient.disconnect).toHaveBeenCalledTimes(1);
});
test('should not disconnect if connect is called during disconnect debounce window', () => {
const { store, mockWebSocketClient } = createTestInitialState();
@@ -123,6 +123,9 @@ export const usePushConnectionStore = defineStore(STORES.PUSH, () => {
*/
let disconnectTimeout: ReturnType<typeof setTimeout> | null = null;
/** Number of active pushConnect owners; disconnect only when this reaches zero. */
let connectionOwnerCount = 0;
const pushConnect = () => {
recentConnectIntent = true;
@@ -141,12 +144,31 @@ export const usePushConnectionStore = defineStore(STORES.PUSH, () => {
disconnectTimeout = null;
}
connectionOwnerCount++;
if (connectionOwnerCount !== 1) {
return;
}
isConnectionRequested.value = true;
isConnecting.value = true;
client.value.connect();
};
const pushDisconnect = () => {
if (connectionOwnerCount === 0) {
return;
}
connectionOwnerCount--;
if (connectionOwnerCount > 0) {
if (disconnectTimeout) {
clearTimeout(disconnectTimeout);
disconnectTimeout = null;
}
return;
}
// If connect was called recently, don't disconnect
// (handles race condition where new view mounts before old view unmounts)
if (recentConnectIntent) {
@@ -160,7 +182,7 @@ export const usePushConnectionStore = defineStore(STORES.PUSH, () => {
disconnectTimeout = setTimeout(() => {
// Double-check in case connect was called while we were waiting
if (recentConnectIntent) {
if (recentConnectIntent || connectionOwnerCount > 0) {
disconnectTimeout = null;
return;
}
@@ -28,19 +28,27 @@ const defaultAgentConfig: AgentJsonConfig = {
instructions: 'Help.',
};
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string, options?: { interpolate?: Record<string, string> }) => {
const translations: Record<string, string> = {
'agents.chat.input.placeholder.withAgent': `Message ${options?.interpolate?.agentName}`,
'agents.chat.misconfigured.issuesPrefix': 'Check:',
'agents.chat.misconfigured.missing.tools': 'Tool configuration',
'agents.chat.misconfigured.missing.mcpServers': 'MCP server',
'agents.chat.misconfigured.missing.subAgents.agents': 'Sub-agent',
};
return translations[key] ?? key;
},
}),
vi.mock('@n8n/i18n', () => {
const baseText = (key: string, options?: { interpolate?: Record<string, string> }) => {
const translations: Record<string, string> = {
'agents.chat.input.placeholder.withAgent': `Message ${options?.interpolate?.agentName}`,
'agents.chat.misconfigured.issuesPrefix': 'Check:',
'agents.chat.misconfigured.missing.tools': 'Tool configuration',
'agents.chat.misconfigured.missing.mcpServers': 'MCP server',
'agents.chat.misconfigured.missing.subAgents.agents': 'Sub-agent',
};
return translations[key] ?? key;
};
const i18n = { baseText };
return { useI18n: () => i18n, i18n };
});
vi.mock('../components/AgentSessionTimelinePanel.vue', () => ({
default: {
name: 'AgentSessionTimelinePanel',
props: ['projectId', 'agentId', 'threadId'],
template: '<div data-testid="agent-preview-session-timeline" />',
},
}));
vi.mock('@n8n/design-system', () => ({
@@ -38,6 +38,14 @@ vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (key: string) => key }),
}));
vi.mock('../components/AgentSessionTimelinePanel.vue', () => ({
default: {
name: 'AgentSessionTimelinePanel',
props: ['projectId', 'agentId', 'threadId'],
template: '<div data-testid="agent-preview-session-timeline" />',
},
}));
vi.mock('@n8n/design-system', () => ({
N8nButton: {
name: 'N8nButton',
@@ -79,14 +87,24 @@ const AgentPreviewChatPageStub = {
name: 'AgentPreviewChatPage',
props: ['beforeSend', 'layout'],
emits: ['continue-loaded', 'open-build', 'send-to-assistant'],
setup(_props: unknown, { expose }: { expose: (exposed: Record<string, unknown>) => void }) {
expose({ focusInput: vi.fn() });
},
template: '<div data-testid="agent-preview-chat-page-stub" />',
};
const AgentSessionTimelinePanelStub = {
name: 'AgentSessionTimelinePanel',
props: ['projectId', 'agentId', 'threadId'],
template: '<div data-testid="agent-preview-session-timeline" />',
};
function mountDock(
overrides: Partial<{
hasSession: boolean;
effectiveSessionId?: string;
beforeSend: () => Promise<void> | void;
isOpen: boolean;
}> = {},
attachTo?: HTMLElement,
) {
@@ -107,7 +125,10 @@ function mountDock(
...overrides,
},
global: {
stubs: { AgentPreviewChatPage: AgentPreviewChatPageStub },
stubs: {
AgentPreviewChatPage: AgentPreviewChatPageStub,
AgentSessionTimelinePanel: AgentSessionTimelinePanelStub,
},
},
});
}
@@ -287,6 +308,80 @@ describe('AgentPreviewDock', () => {
host.remove();
outsideButton.remove();
});
it('shows the session timeline in full-page layout without navigating', async () => {
localStorage.setItem('N8N_AGENT_PREVIEW_LAYOUT', 'fullpage');
const wrapper = mountDock();
await wrapper.get('[data-testid="agent-preview-view-session-btn"]').trigger('click');
expect(wrapper.emitted('view-trace')).toBeUndefined();
expect(wrapper.find('[data-testid="agent-preview-session-timeline"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-preview-chat-page-stub"]').isVisible()).toBe(false);
expect(wrapper.find('[data-testid="agent-preview-show-chat-btn"]').exists()).toBe(true);
expect(
wrapper
.find('[data-testid="agent-preview-show-chat-btn"]')
.find('[data-icon="message-circle"]')
.exists(),
).toBe(true);
});
it('returns to chat from the full-page timeline view', async () => {
localStorage.setItem('N8N_AGENT_PREVIEW_LAYOUT', 'fullpage');
const wrapper = mountDock();
await wrapper.get('[data-testid="agent-preview-view-session-btn"]').trigger('click');
await wrapper.get('[data-testid="agent-preview-show-chat-btn"]').trigger('click');
expect(wrapper.find('[data-testid="agent-preview-session-timeline"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-preview-chat-page-stub"]').isVisible()).toBe(true);
expect(wrapper.find('[data-testid="agent-preview-view-session-btn"]').exists()).toBe(true);
expect(wrapper.emitted('view-trace')).toBeUndefined();
});
it('returns to chat when switching from full-page timeline to docked layout', async () => {
localStorage.setItem('N8N_AGENT_PREVIEW_LAYOUT', 'fullpage');
const wrapper = mountDock();
const layoutMenu = wrapper.findAllComponents({ name: 'N8nDropdownMenu' })[1];
await wrapper.get('[data-testid="agent-preview-view-session-btn"]').trigger('click');
expect(wrapper.find('[data-testid="agent-preview-session-timeline"]').exists()).toBe(true);
layoutMenu?.vm.$emit('select', 'docked');
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="agent-preview-session-timeline"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-preview-chat-page-stub"]').isVisible()).toBe(true);
expect(wrapper.emitted('view-trace')).toBeUndefined();
});
it('returns to chat when starting a new session from the full-page timeline', async () => {
localStorage.setItem('N8N_AGENT_PREVIEW_LAYOUT', 'fullpage');
const wrapper = mountDock();
await wrapper.get('[data-testid="agent-preview-view-session-btn"]').trigger('click');
await wrapper.get('[data-testid="agent-preview-new-chat-btn"]').trigger('click');
expect(wrapper.find('[data-testid="agent-preview-session-timeline"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-preview-chat-page-stub"]').isVisible()).toBe(true);
expect(wrapper.emitted('new-session')).toEqual([[]]);
expect(wrapper.emitted('view-trace')).toBeUndefined();
});
it('returns to chat when the dock closes while showing the timeline', async () => {
localStorage.setItem('N8N_AGENT_PREVIEW_LAYOUT', 'fullpage');
const wrapper = mountDock();
await wrapper.get('[data-testid="agent-preview-view-session-btn"]').trigger('click');
expect(wrapper.find('[data-testid="agent-preview-session-timeline"]').exists()).toBe(true);
await wrapper.setProps({ isOpen: false });
await wrapper.setProps({ isOpen: true });
expect(wrapper.find('[data-testid="agent-preview-session-timeline"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-preview-view-session-btn"]').exists()).toBe(true);
});
});
describe('AgentPreviewChatPage', () => {
@@ -10,7 +10,7 @@ import {
} from '@n8n/design-system';
import type { DropdownMenuItemProps } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { computed, nextTick, useTemplateRef, watch } from 'vue';
import { computed, nextTick, useTemplateRef, watch, ref } from 'vue';
import { useStorage } from '@vueuse/core';
import { useRouter } from 'vue-router';
@@ -27,6 +27,10 @@ import type {
} from '../types';
import AgentPersonalisationIcon from './AgentPersonalisationIcon.vue';
import AgentPreviewChatPage from './AgentPreviewChatPage.vue';
import AgentSessionTimelinePanel from './AgentSessionTimelinePanel.vue';
type DockBody = 'chat' | 'timeline';
const dockView = ref<DockBody>('chat');
interface SessionOption {
id: string;
@@ -133,9 +137,17 @@ function getLayoutAriaLabel() {
function viewTrace() {
if (!props.hasSession || !props.effectiveSessionId) return;
if (layout.value === PreviewLayout.Fullpage) {
dockView.value = 'timeline';
return;
}
emit('view-trace');
}
function showChat() {
dockView.value = 'chat';
}
function exportSession() {
if (!props.hasSession || !props.effectiveSessionId) return;
void sendSession({
@@ -146,6 +158,7 @@ function exportSession() {
}
function createNewSession() {
showChat();
emit('new-session');
}
@@ -170,6 +183,15 @@ function isFocusWithinDock() {
return dock.value?.contains(document.activeElement) === true;
}
watch(
[layout, () => props.isOpen, () => props.hasSession],
function resetDockView([nextLayout, isOpen, hasSession]) {
if (nextLayout !== PreviewLayout.Fullpage || !isOpen || !hasSession) {
showChat();
}
},
);
watch(
[() => props.isOpen, () => props.initialized, () => props.effectiveSessionId],
async function focusPreviewInput([isOpen, initialized, sessionId]) {
@@ -241,12 +263,19 @@ useKeybindings({
<div :class="$style.actions">
<N8nTooltip
v-if="props.hasSession && props.effectiveSessionId"
:content="i18n.baseText('agents.builder.preview.viewSession')"
:content="
i18n.baseText(
dockView === 'chat'
? 'agents.builder.preview.viewSession'
: ('agents.builder.preview.showChat' as BaseTextKey),
)
"
placement="bottom"
:show-after="TOOLTIP_DELAY_MS"
data-testid="agent-preview-view-session-tooltip"
>
<N8nIconButton
v-if="dockView === 'chat'"
icon="list-tree"
variant="ghost"
size="small"
@@ -255,6 +284,16 @@ useKeybindings({
data-testid="agent-preview-view-session-btn"
@click="viewTrace"
/>
<N8nIconButton
v-else
icon="message-circle"
variant="ghost"
size="small"
icon-size="large"
:aria-label="i18n.baseText('agents.builder.preview.showChat' as BaseTextKey)"
data-testid="agent-preview-show-chat-btn"
@click="showChat"
/>
</N8nTooltip>
<N8nTooltip
@@ -313,6 +352,7 @@ useKeybindings({
</header>
<AgentPreviewChatPage
v-show="dockView === 'chat'"
ref="previewChatPage"
:initialized="props.initialized"
:project-id="props.projectId"
@@ -329,6 +369,13 @@ useKeybindings({
@open-build="emit('open-build')"
@send-to-assistant="emit('send-to-assistant', $event)"
/>
<AgentSessionTimelinePanel
v-if="dockView === 'timeline' && props.effectiveSessionId"
:project-id="props.projectId"
:agent-id="props.agentId"
:thread-id="props.effectiveSessionId"
data-testid="agent-preview-session-timeline"
/>
</div>
</aside>
</template>