mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
fix(ai-builder): Fix session timeline/trace not showing on preview dock when full screen (#36930)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user