feat(editor): Add collapsible sidebar and deferred thread creation to Instance AI (no-changelog) (#28459)

This commit is contained in:
Raúl Gómez Morales
2026-04-17 12:00:37 +02:00
committed by GitHub
parent d17211342e
commit 465478a829
8 changed files with 203 additions and 416 deletions

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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');
});
});

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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'],

View File

@@ -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;

View File

@@ -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 });