From 465478a829bf008a492dd68828ce6b79deae7cd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Fri, 17 Apr 2026 12:00:37 +0200 Subject: [PATCH] feat(editor): Add collapsible sidebar and deferred thread creation to Instance AI (no-changelog) (#28459) --- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../features/ai/instanceAi/InstanceAiView.vue | 102 ++-- .../__tests__/InstanceAiView.test.ts | 438 ++++-------------- .../components/InstanceAiThreadList.vue | 38 +- .../ai/instanceAi/instanceAi.store.ts | 17 +- .../ai/instanceAi/module.descriptor.ts | 2 + .../ai/instanceAi/useCanvasPreview.ts | 8 +- .../instance-ai/instance-ai-sidebar.spec.ts | 13 +- 8 files changed, 203 insertions(+), 416 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 487a09e8e0a..5822adf64c2 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue index 72183ee7038..c5fed228e18 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue @@ -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() {
@@ -385,6 +414,18 @@ function handleStop() {
+ + + {{ currentThreadTitle }} @@ -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 { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiView.test.ts index fa60244cb64..a265d88f605 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiView.test.ts @@ -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, - path: '/instance-ai', - matched: [] as unknown[], - fullPath: '/instance-ai', - query: {} as Record, - hash: '', - meta: {} as Record, -}); - -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: '
', +const InstanceAiInputStub = defineComponent({ + name: 'InstanceAiInputStub', + props: { + suggestions: { type: Array, required: false }, + isStreaming: { type: Boolean, required: false }, }, - N8nIconButton: { - name: 'N8nIconButtonStub', - template: '