mirror of
https://github.com/n8n-io/n8n
synced 2026-04-19 13:05:54 +02:00
feat(editor): Add collapsible sidebar and deferred thread creation to Instance AI (no-changelog) (#28459)
This commit is contained in:
committed by
GitHub
parent
d17211342e
commit
465478a829
@@ -5102,6 +5102,7 @@
|
||||
"instanceAi.toolResult.dataTruncated": "Data truncated for display.",
|
||||
"instanceAi.thread.new": "New chat",
|
||||
"instanceAi.sidebar.back": "Back",
|
||||
"instanceAi.sidebar.threads": "Threads",
|
||||
"instanceAi.message.reasoning": "Reasoning",
|
||||
"instanceAi.sidebar.noThreads": "No conversations yet",
|
||||
"instanceAi.sidebar.group.thisWeek": "This week",
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
N8nResizeWrapper,
|
||||
N8nScrollArea,
|
||||
N8nText,
|
||||
N8nButton,
|
||||
} from '@n8n/design-system';
|
||||
import { useScroll, useWindowSize } from '@vueuse/core';
|
||||
import { useLocalStorage, useScroll, useWindowSize } from '@vueuse/core';
|
||||
import { N8nCallout } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { InstanceAiAttachment } from '@n8n/api-types';
|
||||
@@ -30,7 +31,12 @@ import { useInstanceAiSettingsStore } from './instanceAiSettings.store';
|
||||
import { useCanvasPreview } from './useCanvasPreview';
|
||||
import { useEventRelay } from './useEventRelay';
|
||||
import { useExecutionPushEvents } from './useExecutionPushEvents';
|
||||
import { INSTANCE_AI_SETTINGS_VIEW, NEW_CONVERSATION_TITLE } from './constants';
|
||||
import {
|
||||
INSTANCE_AI_VIEW,
|
||||
INSTANCE_AI_SETTINGS_VIEW,
|
||||
INSTANCE_AI_THREAD_VIEW,
|
||||
NEW_CONVERSATION_TITLE,
|
||||
} from './constants';
|
||||
import { INSTANCE_AI_EMPTY_STATE_SUGGESTIONS } from './emptyStateSuggestions';
|
||||
import InstanceAiMessage from './components/InstanceAiMessage.vue';
|
||||
import InstanceAiInput from './components/InstanceAiInput.vue';
|
||||
@@ -47,6 +53,10 @@ import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHe
|
||||
import InstanceAiWorkflowPreview from './components/InstanceAiWorkflowPreview.vue';
|
||||
import InstanceAiDataTablePreview from './components/InstanceAiDataTablePreview.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
threadId?: string;
|
||||
}>();
|
||||
|
||||
const store = useInstanceAiStore();
|
||||
const settingsStore = useInstanceAiSettingsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
@@ -111,7 +121,16 @@ const showEmptyStateLayout = computed(() => !store.hasMessages && !store.isHydra
|
||||
// Load persisted threads from Mastra storage on mount
|
||||
onMounted(() => {
|
||||
pushConnectionStore.pushConnect();
|
||||
void store.loadThreads();
|
||||
void store.loadThreads().then((loaded) => {
|
||||
if (!loaded || !props.threadId) return;
|
||||
// After threads load, validate deep-link: redirect if thread doesn't exist
|
||||
if (!store.threads.some((t) => t.id === props.threadId)) {
|
||||
void router.replace({ name: INSTANCE_AI_VIEW });
|
||||
} else if (props.threadId !== store.currentThreadId) {
|
||||
// Thread exists on server — now safe to switch
|
||||
store.switchThread(props.threadId);
|
||||
}
|
||||
});
|
||||
void store.fetchCredits();
|
||||
store.startCreditsPushListener();
|
||||
void nextTick(() => chatInputRef.value?.focus());
|
||||
@@ -150,9 +169,20 @@ const showArtifactsPanel = ref(true);
|
||||
const showDebugPanel = ref(false);
|
||||
const isDebugEnabled = computed(() => localStorage.getItem('instanceAi.debugMode') === 'true');
|
||||
|
||||
// --- Sidebar resize ---
|
||||
// --- Sidebar collapse & resize ---
|
||||
const sidebarCollapsed = useLocalStorage('instanceAi.sidebarCollapsed', false);
|
||||
const sidebarWidth = ref(260);
|
||||
|
||||
function toggleSidebarCollapse() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
}
|
||||
|
||||
function handleSidebarResize({ width }: { width: number }) {
|
||||
// Drag below min-width threshold → auto-collapse
|
||||
if (width <= 200) {
|
||||
sidebarCollapsed.value = true;
|
||||
return;
|
||||
}
|
||||
sidebarWidth.value = width;
|
||||
}
|
||||
|
||||
@@ -286,11 +316,6 @@ onUnmounted(() => {
|
||||
settingsStore.stopGatewayPushListener();
|
||||
});
|
||||
|
||||
// --- Route-thread synchronization ---
|
||||
const routeThreadId = computed(() =>
|
||||
typeof route.params.threadId === 'string' ? route.params.threadId : null,
|
||||
);
|
||||
|
||||
function reconnectThreadIfHydrationApplied(threadId: string): void {
|
||||
void store.loadHistoricalMessages(threadId).then((hydrationStatus) => {
|
||||
if (hydrationStatus === 'stale') return;
|
||||
@@ -300,17 +325,11 @@ function reconnectThreadIfHydrationApplied(threadId: string): void {
|
||||
}
|
||||
|
||||
watch(
|
||||
routeThreadId,
|
||||
() => props.threadId,
|
||||
(threadId) => {
|
||||
if (!threadId) {
|
||||
// /instance-ai base route (no :threadId) — bootstrap default thread + SSE
|
||||
if ((store.threads?.length ?? 0) === 0) {
|
||||
store.threads.push({
|
||||
id: store.currentThreadId,
|
||||
title: NEW_CONVERSATION_TITLE,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
// /instance-ai base route (no :threadId) — thread appears in sidebar
|
||||
// only after the first message is sent (via syncThread in sendMessage)
|
||||
if (store.sseState === 'disconnected') {
|
||||
reconnectThreadIfHydrationApplied(store.currentThreadId);
|
||||
}
|
||||
@@ -328,16 +347,11 @@ watch(
|
||||
// Clear execution tracking for previous thread
|
||||
executionTracking.clearAll();
|
||||
|
||||
// Deep-link hydration: ensure thread exists in sidebar
|
||||
if (!store.threads.some((t) => t.id === threadId)) {
|
||||
store.threads.push({
|
||||
id: threadId,
|
||||
title: NEW_CONVERSATION_TITLE,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
// Only switch to threads that exist in the sidebar (loaded from server).
|
||||
// Unknown thread IDs are validated after loadThreads completes (see onMounted).
|
||||
if (store.threads.some((t) => t.id === threadId)) {
|
||||
store.switchThread(threadId);
|
||||
}
|
||||
// switchThread already calls loadHistoricalMessages internally
|
||||
store.switchThread(threadId);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -355,11 +369,23 @@ const eventRelay = useEventRelay({
|
||||
});
|
||||
|
||||
// --- Message handlers ---
|
||||
async function handleSubmit(message: string, attachments?: InstanceAiAttachment[]) {
|
||||
function handleSubmit(message: string, attachments?: InstanceAiAttachment[]) {
|
||||
// Reset scroll on new user message
|
||||
userScrolledUp.value = false;
|
||||
preview.markUserSentMessage();
|
||||
await store.sendMessage(message, attachments, rootStore.pushRef);
|
||||
const shouldUpdateRoute = !props.threadId;
|
||||
const threadId = store.currentThreadId;
|
||||
void store.sendMessage(message, attachments, rootStore.pushRef).then(() => {
|
||||
// After the first message is sent, update the URL to include the thread ID
|
||||
// so the thread is addressable and appears in the sidebar.
|
||||
// Only update the route if the thread was persisted (syncThread succeeded).
|
||||
if (shouldUpdateRoute && store.threads.some((t) => t.id === threadId)) {
|
||||
void router.replace({
|
||||
name: INSTANCE_AI_THREAD_VIEW,
|
||||
params: { threadId },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
@@ -371,11 +397,14 @@ function handleStop() {
|
||||
<div :class="$style.container" data-test-id="instance-ai-container">
|
||||
<!-- Resizable sidebar -->
|
||||
<N8nResizeWrapper
|
||||
v-if="!sidebarCollapsed"
|
||||
:class="$style.sidebar"
|
||||
:width="sidebarWidth"
|
||||
:style="{ width: `${sidebarWidth}px` }"
|
||||
:supported-directions="['right']"
|
||||
:is-resizing-enabled="true"
|
||||
:min-width="200"
|
||||
:max-width="400"
|
||||
@resize="handleSidebarResize"
|
||||
>
|
||||
<InstanceAiThreadList />
|
||||
@@ -385,6 +414,18 @@ function handleStop() {
|
||||
<div :class="$style.chatArea">
|
||||
<!-- Header -->
|
||||
<div :class="$style.header">
|
||||
<N8nButton
|
||||
:icon="sidebarCollapsed ? 'list' : 'panel-left'"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
data-test-id="instance-ai-sidebar-toggle"
|
||||
:icon-only="!sidebarCollapsed"
|
||||
@click="toggleSidebarCollapse"
|
||||
>
|
||||
<template v-if="sidebarCollapsed">{{
|
||||
i18n.baseText('instanceAi.sidebar.threads')
|
||||
}}</template>
|
||||
</N8nButton>
|
||||
<N8nHeading tag="h2" size="small" :class="$style.headerTitle">
|
||||
{{ currentThreadTitle }}
|
||||
</N8nHeading>
|
||||
@@ -599,6 +640,9 @@ function handleStop() {
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: var(--border);
|
||||
}
|
||||
|
||||
.readOnlyBanner {
|
||||
|
||||
@@ -1,368 +1,103 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '@testing-library/vue';
|
||||
import { defineComponent, h, reactive, ref } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import InstanceAiView from '../InstanceAiView.vue';
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
const localStorageStub = {
|
||||
getItem: vi.fn(() => 'false'),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const originalLocalStorage = globalThis.localStorage;
|
||||
|
||||
let didStubResizeObserver = false;
|
||||
|
||||
const createStoreState = () =>
|
||||
reactive({
|
||||
threads: [] as unknown[],
|
||||
currentThreadId: 'thread-1',
|
||||
messages: [] as unknown[],
|
||||
isLowCredits: false,
|
||||
sseState: 'connected',
|
||||
creditsRemaining: 100,
|
||||
creditsQuota: 200,
|
||||
hasMessages: false,
|
||||
isHydratingThread: false,
|
||||
isStreaming: false,
|
||||
debugMode: false,
|
||||
loadThreads: vi.fn(),
|
||||
fetchCredits: vi.fn(),
|
||||
startCreditsPushListener: vi.fn(),
|
||||
stopCreditsPushListener: vi.fn(),
|
||||
closeSSE: vi.fn(),
|
||||
loadHistoricalMessages: vi.fn(async () => 'applied'),
|
||||
loadThreadStatus: vi.fn(),
|
||||
connectSSE: vi.fn(),
|
||||
switchThread: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
cancelRun: vi.fn(),
|
||||
});
|
||||
|
||||
const storeRef = { current: createStoreState() };
|
||||
const routeRef = reactive({
|
||||
params: {} as Record<string, unknown>,
|
||||
path: '/instance-ai',
|
||||
matched: [] as unknown[],
|
||||
fullPath: '/instance-ai',
|
||||
query: {} as Record<string, unknown>,
|
||||
hash: '',
|
||||
meta: {} as Record<string, unknown>,
|
||||
});
|
||||
|
||||
vi.mock('../instanceAi.store', () => ({
|
||||
useInstanceAiStore: () => storeRef.current,
|
||||
}));
|
||||
|
||||
vi.mock('../instanceAiSettings.store', () => ({
|
||||
useInstanceAiSettingsStore: () => ({
|
||||
isLocalGatewayDisabled: true,
|
||||
refreshModuleSettings: vi.fn(async () => undefined),
|
||||
startDaemonProbing: vi.fn(),
|
||||
startGatewayPushListener: vi.fn(),
|
||||
pollGatewayStatus: vi.fn(),
|
||||
stopDaemonProbing: vi.fn(),
|
||||
stopGatewayPolling: vi.fn(),
|
||||
stopGatewayPushListener: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/pushConnection.store', () => ({
|
||||
usePushConnectionStore: () => ({
|
||||
pushConnect: vi.fn(),
|
||||
pushDisconnect: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/integrations/sourceControl.ee/sourceControl.store', () => ({
|
||||
useSourceControlStore: () => ({
|
||||
preferences: {
|
||||
branchReadOnly: false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: () => ({
|
||||
pushRef: 'push-ref-1',
|
||||
}),
|
||||
}));
|
||||
import { useInstanceAiStore } from '../instanceAi.store';
|
||||
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
|
||||
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
|
||||
|
||||
vi.mock('@/app/composables/useDocumentTitle', () => ({
|
||||
useDocumentTitle: () => ({
|
||||
set: vi.fn(),
|
||||
}),
|
||||
useDocumentTitle: () => ({ set: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/usePageRedirectionHelper', () => ({
|
||||
usePageRedirectionHelper: () => ({
|
||||
goToUpgrade: vi.fn(),
|
||||
usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useRoute: () => ({
|
||||
params: {},
|
||||
path: '/instance-ai',
|
||||
matched: [],
|
||||
fullPath: '/instance-ai',
|
||||
query: {},
|
||||
hash: '',
|
||||
meta: {},
|
||||
}),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeRef,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
vi.mock('@vueuse/core', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useScroll: () => ({ arrivedState: { bottom: true } }),
|
||||
useWindowSize: () => ({ width: ref(1200) }),
|
||||
useLocalStorage: (_key: string, defaultValue: unknown) => ref(defaultValue),
|
||||
}));
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useScroll: () => ({
|
||||
arrivedState: { bottom: true },
|
||||
}),
|
||||
useWindowSize: () => ({
|
||||
width: ref(1200),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/design-system', () => ({
|
||||
N8nHeading: {
|
||||
name: 'N8nHeadingStub',
|
||||
template: '<div><slot /></div>',
|
||||
const InstanceAiInputStub = defineComponent({
|
||||
name: 'InstanceAiInputStub',
|
||||
props: {
|
||||
suggestions: { type: Array, required: false },
|
||||
isStreaming: { type: Boolean, required: false },
|
||||
},
|
||||
N8nIconButton: {
|
||||
name: 'N8nIconButtonStub',
|
||||
template: '<button />',
|
||||
setup(props, { expose }) {
|
||||
expose({ focus: vi.fn() });
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ 'data-test-id': 'instance-ai-input-stub' },
|
||||
props.suggestions === undefined ? 'unset' : String(props.suggestions.length),
|
||||
);
|
||||
},
|
||||
N8nResizeWrapper: {
|
||||
name: 'N8nResizeWrapperStub',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
N8nScrollArea: {
|
||||
name: 'N8nScrollAreaStub',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
N8nText: {
|
||||
name: 'N8nTextStub',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const previewMock = {
|
||||
isPreviewVisible: ref(false),
|
||||
activeWorkflowId: ref(null),
|
||||
activeExecutionId: ref(null),
|
||||
activeDataTableId: ref(null),
|
||||
activeDataTableProjectId: ref(null),
|
||||
allArtifactTabs: ref([]),
|
||||
activeTabId: ref(null),
|
||||
workflowRefreshKey: ref(0),
|
||||
dataTableRefreshKey: ref(0),
|
||||
openWorkflowPreview: vi.fn(),
|
||||
openDataTablePreview: vi.fn(),
|
||||
selectTab: vi.fn(),
|
||||
closePreview: vi.fn(),
|
||||
markUserSentMessage: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../useCanvasPreview', () => ({
|
||||
useCanvasPreview: () => previewMock,
|
||||
}));
|
||||
|
||||
vi.mock('../useEventRelay', () => ({
|
||||
useEventRelay: () => ({
|
||||
handleIframeReady: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const executionTracking = {
|
||||
workflowExecutions: ref(new Map()),
|
||||
clearAll: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
getBufferedEvents: vi.fn(() => []),
|
||||
};
|
||||
|
||||
vi.mock('../useExecutionPushEvents', () => ({
|
||||
useExecutionPushEvents: () => executionTracking,
|
||||
}));
|
||||
|
||||
vi.mock('../components/InstanceAiThreadList.vue', () => ({
|
||||
default: {
|
||||
name: 'InstanceAiThreadListStub',
|
||||
template: '<div data-test-id="thread-list" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/InstanceAiEmptyState.vue', () => ({
|
||||
default: {
|
||||
name: 'InstanceAiEmptyStateStub',
|
||||
template: '<div data-test-id="empty-state" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/InstanceAiStatusBar.vue', () => ({
|
||||
default: {
|
||||
name: 'InstanceAiStatusBarStub',
|
||||
template: '<div data-test-id="status-bar" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/InstanceAiInput.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'InstanceAiInputStub',
|
||||
props: {
|
||||
suggestions: {
|
||||
type: Array,
|
||||
required: false,
|
||||
},
|
||||
isStreaming: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
const renderView = createComponentRenderer(InstanceAiView, {
|
||||
global: {
|
||||
stubs: {
|
||||
InstanceAiInput: InstanceAiInputStub,
|
||||
},
|
||||
setup(props, { expose }) {
|
||||
expose({
|
||||
focus: vi.fn(),
|
||||
});
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ 'data-test-id': 'instance-ai-input-stub' },
|
||||
props.suggestions === undefined ? 'unset' : String(props.suggestions.length),
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/InstanceAiConfirmationPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'InstanceAiConfirmationPanelStub',
|
||||
template: '<div data-test-id="confirmation-panel" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/InstanceAiMessage.vue', () => ({
|
||||
default: {
|
||||
name: 'InstanceAiMessageStub',
|
||||
template: '<div data-test-id="instance-ai-message" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/InstanceAiArtifactsPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'InstanceAiArtifactsPanelStub',
|
||||
template: '<div data-test-id="artifacts-panel" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/InstanceAiMemoryPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'InstanceAiMemoryPanelStub',
|
||||
template: '<div data-test-id="memory-panel" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/InstanceAiDebugPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'InstanceAiDebugPanelStub',
|
||||
template: '<div data-test-id="debug-panel" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/InstanceAiPreviewTabBar.vue', () => ({
|
||||
default: {
|
||||
name: 'InstanceAiPreviewTabBarStub',
|
||||
template: '<div data-test-id="preview-tab-bar" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/InstanceAiWorkflowPreview.vue', () => ({
|
||||
default: {
|
||||
name: 'InstanceAiWorkflowPreviewStub',
|
||||
template: '<div data-test-id="workflow-preview" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/InstanceAiDataTablePreview.vue', () => ({
|
||||
default: {
|
||||
name: 'InstanceAiDataTablePreviewStub',
|
||||
template: '<div data-test-id="data-table-preview" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/ai/assistant/components/Agent/CreditWarningBanner.vue', () => ({
|
||||
default: {
|
||||
name: 'CreditWarningBannerStub',
|
||||
template: '<div data-test-id="credit-warning-banner" />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/ai/assistant/components/Agent/CreditsSettingsDropdown.vue', () => ({
|
||||
default: {
|
||||
name: 'CreditsSettingsDropdownStub',
|
||||
template: '<div data-test-id="credits-dropdown" />',
|
||||
},
|
||||
}));
|
||||
|
||||
const renderView = () => render(InstanceAiView);
|
||||
});
|
||||
|
||||
describe('InstanceAiView', () => {
|
||||
beforeAll(() => {
|
||||
if (typeof globalThis.ResizeObserver === 'undefined') {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||
didStubResizeObserver = true;
|
||||
}
|
||||
|
||||
vi.stubGlobal('localStorage', localStorageStub);
|
||||
});
|
||||
let store: ReturnType<typeof mockedStore<typeof useInstanceAiStore>>;
|
||||
let settingsStore: ReturnType<typeof mockedStore<typeof useInstanceAiSettingsStore>>;
|
||||
|
||||
beforeEach(() => {
|
||||
storeRef.current = createStoreState();
|
||||
routeRef.params = {};
|
||||
routeRef.path = '/instance-ai';
|
||||
routeRef.fullPath = '/instance-ai';
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
setActivePinia(pinia);
|
||||
|
||||
store = mockedStore(useInstanceAiStore);
|
||||
settingsStore = mockedStore(useInstanceAiSettingsStore);
|
||||
const pushStore = mockedStore(usePushConnectionStore);
|
||||
|
||||
store.currentThreadId = 'thread-1';
|
||||
store.loadThreads.mockResolvedValue(true);
|
||||
store.fetchCredits.mockResolvedValue(undefined);
|
||||
store.loadHistoricalMessages.mockResolvedValue('applied');
|
||||
store.connectSSE.mockResolvedValue(undefined);
|
||||
store.closeSSE.mockReturnValue(undefined);
|
||||
settingsStore.isLocalGatewayDisabled = true;
|
||||
settingsStore.refreshModuleSettings.mockResolvedValue(undefined);
|
||||
pushStore.pushConnect.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (typeof originalLocalStorage === 'undefined') {
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
} else {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: originalLocalStorage,
|
||||
});
|
||||
}
|
||||
|
||||
if (didStubResizeObserver) {
|
||||
if (typeof originalResizeObserver === 'undefined') {
|
||||
Reflect.deleteProperty(globalThis, 'ResizeObserver');
|
||||
} else {
|
||||
Object.defineProperty(globalThis, 'ResizeObserver', {
|
||||
configurable: true,
|
||||
value: originalResizeObserver,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('passes the fixed suggestions to the empty-state composer', () => {
|
||||
const { getByTestId } = renderView();
|
||||
|
||||
expect(getByTestId('instance-ai-input-stub')).toHaveTextContent('4');
|
||||
});
|
||||
|
||||
it('does not pass suggestions once the thread has messages', () => {
|
||||
storeRef.current.hasMessages = true;
|
||||
storeRef.current.messages = [
|
||||
store.hasMessages = true;
|
||||
store.messages = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
@@ -370,42 +105,37 @@ describe('InstanceAiView', () => {
|
||||
isStreaming: false,
|
||||
createdAt: '2026-04-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
] as typeof store.messages;
|
||||
|
||||
const { getByTestId } = renderView();
|
||||
|
||||
expect(getByTestId('instance-ai-input-stub')).toHaveTextContent('unset');
|
||||
});
|
||||
|
||||
it('does not pass suggestions while an existing thread is hydrating', () => {
|
||||
storeRef.current.isHydratingThread = true;
|
||||
store.isHydratingThread = true;
|
||||
|
||||
const { getByTestId } = renderView();
|
||||
|
||||
expect(getByTestId('instance-ai-input-stub')).toHaveTextContent('unset');
|
||||
});
|
||||
|
||||
it('does not reconnect when direct hydration is stale', async () => {
|
||||
storeRef.current.sseState = 'disconnected';
|
||||
storeRef.current.loadHistoricalMessages = vi.fn(async () => 'stale');
|
||||
store.sseState = 'disconnected';
|
||||
store.loadHistoricalMessages.mockResolvedValue('stale');
|
||||
|
||||
renderView();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(storeRef.current.loadHistoricalMessages).toHaveBeenCalledWith('thread-1');
|
||||
expect(store.loadHistoricalMessages).toHaveBeenCalledWith('thread-1');
|
||||
});
|
||||
expect(storeRef.current.loadThreadStatus).not.toHaveBeenCalled();
|
||||
expect(storeRef.current.connectSSE).not.toHaveBeenCalled();
|
||||
expect(store.loadThreadStatus).not.toHaveBeenCalled();
|
||||
expect(store.connectSSE).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reconnects on same-thread re-entry when hydration is skipped', async () => {
|
||||
routeRef.params = { threadId: 'thread-1' };
|
||||
routeRef.path = '/instance-ai/thread-1';
|
||||
routeRef.fullPath = '/instance-ai/thread-1';
|
||||
storeRef.current.currentThreadId = 'thread-1';
|
||||
storeRef.current.sseState = 'disconnected';
|
||||
storeRef.current.hasMessages = true;
|
||||
storeRef.current.messages = [
|
||||
store.currentThreadId = 'thread-1';
|
||||
store.sseState = 'disconnected';
|
||||
store.hasMessages = true;
|
||||
store.messages = [
|
||||
{
|
||||
id: 'msg-history',
|
||||
role: 'assistant',
|
||||
@@ -413,15 +143,15 @@ describe('InstanceAiView', () => {
|
||||
isStreaming: false,
|
||||
createdAt: '2026-04-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
storeRef.current.loadHistoricalMessages = vi.fn(async () => 'skipped');
|
||||
] as typeof store.messages;
|
||||
store.loadHistoricalMessages.mockResolvedValue('skipped');
|
||||
|
||||
renderView();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(storeRef.current.loadHistoricalMessages).toHaveBeenCalledWith('thread-1');
|
||||
expect(store.loadHistoricalMessages).toHaveBeenCalledWith('thread-1');
|
||||
});
|
||||
expect(storeRef.current.loadThreadStatus).toHaveBeenCalledWith('thread-1');
|
||||
expect(storeRef.current.connectSSE).toHaveBeenCalledWith('thread-1');
|
||||
expect(store.loadThreadStatus).toHaveBeenCalledWith('thread-1');
|
||||
expect(store.connectSSE).toHaveBeenCalledWith('thread-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { getRelativeDate } from '@/features/ai/chatHub/chat.utils';
|
||||
import { N8nActionDropdown, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
|
||||
import {
|
||||
N8nActionDropdown,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
N8nText,
|
||||
N8nScrollArea,
|
||||
} from '@n8n/design-system';
|
||||
import type { ActionDropdownItem } from '@n8n/design-system/types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { INSTANCE_AI_THREAD_VIEW } from '../constants';
|
||||
import { INSTANCE_AI_VIEW, INSTANCE_AI_THREAD_VIEW } from '../constants';
|
||||
import { useInstanceAiStore } from '../instanceAi.store';
|
||||
|
||||
const store = useInstanceAiStore();
|
||||
@@ -56,17 +62,22 @@ const groupedThreads = computed(() => {
|
||||
});
|
||||
|
||||
function handleNewThread() {
|
||||
const threadId = store.newThread();
|
||||
void router.push({ name: INSTANCE_AI_THREAD_VIEW, params: { threadId } });
|
||||
if (!store.hasMessages) return;
|
||||
store.newThread();
|
||||
void router.push({ name: INSTANCE_AI_VIEW });
|
||||
}
|
||||
|
||||
async function handleDeleteThread(threadId: string) {
|
||||
const { wasActive } = await store.deleteThread(threadId);
|
||||
if (wasActive) {
|
||||
void router.push({
|
||||
name: INSTANCE_AI_THREAD_VIEW,
|
||||
params: { threadId: store.currentThreadId },
|
||||
});
|
||||
if (store.threads.length > 0) {
|
||||
void router.push({
|
||||
name: INSTANCE_AI_THREAD_VIEW,
|
||||
params: { threadId: store.currentThreadId },
|
||||
});
|
||||
} else {
|
||||
void router.push({ name: INSTANCE_AI_VIEW });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +128,7 @@ function handleThreadAction(action: string, threadId: string) {
|
||||
</button>
|
||||
|
||||
<!-- Thread list -->
|
||||
<div :class="$style.threadList">
|
||||
<N8nScrollArea :class="$style.threadList">
|
||||
<template v-if="groupedThreads.length > 0">
|
||||
<div v-for="group in groupedThreads" :key="group.label" :class="$style.group">
|
||||
<N8nText :class="$style.groupLabel" tag="div" size="small" color="text-light">
|
||||
@@ -176,7 +187,7 @@ function handleThreadAction(action: string, threadId: string) {
|
||||
{{ i18n.baseText('instanceAi.sidebar.noThreads') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</N8nScrollArea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -184,9 +195,8 @@ function handleThreadAction(action: string, threadId: string) {
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-right: var(--border);
|
||||
background: var(--color--background--light-2);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.newChatButton {
|
||||
@@ -221,7 +231,7 @@ function handleThreadAction(action: string, threadId: string) {
|
||||
|
||||
.threadList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
|
||||
@@ -542,12 +542,6 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
resetThreadRuntimeState(null);
|
||||
currentThreadId.value = newThreadId;
|
||||
|
||||
threads.value.unshift({
|
||||
id: newThreadId,
|
||||
title: NEW_CONVERSATION_TITLE,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
connectSSE(newThreadId);
|
||||
return newThreadId;
|
||||
}
|
||||
@@ -579,16 +573,11 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
// Switch to first remaining thread
|
||||
switchThread(threads.value[0].id);
|
||||
} else {
|
||||
// No threads left — create a new one
|
||||
// No threads left — prepare a fresh thread (added to sidebar on first message)
|
||||
const freshId = uuidv4();
|
||||
closeSSE();
|
||||
resetThreadRuntimeState(null);
|
||||
currentThreadId.value = freshId;
|
||||
threads.value.push({
|
||||
id: freshId,
|
||||
title: NEW_CONVERSATION_TITLE,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
connectSSE(freshId);
|
||||
}
|
||||
}
|
||||
@@ -596,7 +585,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
return { currentThreadId: currentThreadId.value, wasActive };
|
||||
}
|
||||
|
||||
async function loadThreads(): Promise<void> {
|
||||
async function loadThreads(): Promise<boolean> {
|
||||
try {
|
||||
const result = await fetchThreadsApi(rootStore.restApiContext);
|
||||
for (const thread of result.threads) {
|
||||
@@ -613,8 +602,10 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
metadata: t.metadata ?? undefined,
|
||||
}));
|
||||
threads.value = [...localOnly, ...serverThreads];
|
||||
return true;
|
||||
} catch {
|
||||
// Silently ignore — threads will remain client-side only
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export const InstanceAiModule: FrontendModuleDescription = {
|
||||
name: INSTANCE_AI_VIEW,
|
||||
path: '/instance-ai',
|
||||
component: InstanceAiView,
|
||||
props: true,
|
||||
meta: {
|
||||
layout: 'instanceAi',
|
||||
middleware: ['authenticated', 'custom'],
|
||||
@@ -27,6 +28,7 @@ export const InstanceAiModule: FrontendModuleDescription = {
|
||||
name: INSTANCE_AI_THREAD_VIEW,
|
||||
path: '/instance-ai/:threadId',
|
||||
component: InstanceAiView,
|
||||
props: true,
|
||||
meta: {
|
||||
layout: 'instanceAi',
|
||||
middleware: ['authenticated', 'custom'],
|
||||
|
||||
@@ -225,7 +225,13 @@ export function useCanvasPreview({ store, route, workflowExecutions }: UseCanvas
|
||||
|
||||
watch(
|
||||
() => route.params.threadId,
|
||||
() => {
|
||||
(threadId, oldThreadId) => {
|
||||
// Skip if this is the initial route setup (e.g. URL updated from
|
||||
// /instance-ai to /instance-ai/:threadId after the first message)
|
||||
if (!oldThreadId) return;
|
||||
// Skip if the thread ID hasn't actually changed
|
||||
if (threadId === oldThreadId) return;
|
||||
|
||||
wasCanvasOpenBeforeSwitch.value = isPreviewVisible.value;
|
||||
pendingRestore.value = true;
|
||||
activeTabId.value = null;
|
||||
|
||||
@@ -23,6 +23,10 @@ test.describe(
|
||||
// Should show empty input in the new thread
|
||||
await expect(n8n.instanceAi.getChatInput()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Send a message to materialize the new thread in the sidebar
|
||||
await n8n.instanceAi.sendMessage('Second thread message');
|
||||
await n8n.instanceAi.waitForResponseComplete();
|
||||
|
||||
// Thread count should increase
|
||||
await expect(n8n.instanceAi.sidebar.getThreadItems()).toHaveCount(threadCountBefore + 1, {
|
||||
timeout: 10_000,
|
||||
@@ -82,16 +86,15 @@ test.describe(
|
||||
await n8n.instanceAi.sendMessage('Thread to delete');
|
||||
await n8n.instanceAi.waitForResponseComplete();
|
||||
|
||||
// Create a second thread so we have somewhere to go after deletion
|
||||
await n8n.instanceAi.sidebar.getNewThreadButton().click();
|
||||
await expect(n8n.instanceAi.getChatInput()).toBeVisible({ timeout: 10_000 });
|
||||
// Verify target thread is visible in the sidebar
|
||||
const targetThread = n8n.instanceAi.sidebar.getThreadByTitle('Thread to Delete');
|
||||
await expect(targetThread).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Hover the target thread to reveal the three-dots button, then click it
|
||||
const targetThread = n8n.instanceAi.sidebar.getThreadByTitle('Thread to Delete');
|
||||
await targetThread.hover();
|
||||
const actionButton = n8n.instanceAi.sidebar.getThreadActionsTrigger(targetThread);
|
||||
await expect(actionButton).toBeVisible({ timeout: 5_000 });
|
||||
await actionButton.click({ force: true });
|
||||
await actionButton.click();
|
||||
|
||||
// Click delete option in the dropdown
|
||||
await expect(n8n.instanceAi.sidebar.getDeleteMenuItem()).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
Reference in New Issue
Block a user