diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index cdac3ec0b90..7e86d6c2aea 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -735,6 +735,7 @@ export interface InstanceAiThreadSummary { id: string; title: string; createdAt: string; + updatedAt: string; metadata?: Record; } diff --git a/packages/cli/src/modules/instance-ai/storage/typeorm-memory-storage.ts b/packages/cli/src/modules/instance-ai/storage/typeorm-memory-storage.ts index aa37eb8b6db..659a12bfeb2 100644 --- a/packages/cli/src/modules/instance-ai/storage/typeorm-memory-storage.ts +++ b/packages/cli/src/modules/instance-ai/storage/typeorm-memory-storage.ts @@ -503,6 +503,17 @@ export class TypeORMMemoryStorage extends MemoryStorage { return entity; }); const saved = await this.messageRepo.save(entities); + + // Bump updatedAt on each parent thread so the chat list orders by last + // activity. Without this, an existing thread that receives a new message + // keeps its original updatedAt and stays buried in the "Older" group. + const threadIds = [ + ...new Set(messages.map((m) => m.threadId).filter((id): id is string => !!id)), + ]; + if (threadIds.length > 0) { + await this.threadRepo.update(threadIds, { updatedAt: new Date() }); + } + return { messages: saved.map((e) => this.entityToMessage(e)) }; } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index ea2c370524c..4890cfde006 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5133,6 +5133,8 @@ "instanceAi.thread.new": "New chat", "instanceAi.sidebar.back": "Back", "instanceAi.sidebar.threads": "Threads", + "instanceAi.sidebar.chatHistory": "Chat history", + "instanceAi.sidebar.collapse": "Collapse sidebar", "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 909caa6c7de..c759a52211d 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue @@ -9,16 +9,17 @@ import { useTemplateRef, watch, } from 'vue'; -import { useRoute, useRouter } from 'vue-router'; +import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'; import { N8nHeading, N8nIconButton, N8nResizeWrapper, N8nScrollArea, N8nText, - N8nButton, + N8nTooltip, + TOOLTIP_DELAY_MS, } from '@n8n/design-system'; -import { useLocalStorage, useScroll, useWindowSize } from '@vueuse/core'; +import { useScroll, useSessionStorage, useWindowSize } from '@vueuse/core'; import { N8nCallout } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; import type { InstanceAiAttachment } from '@n8n/api-types'; @@ -90,17 +91,20 @@ const displayedMessages = computed(() => store.messages.filter(messageHasVisible const executionTracking = useExecutionPushEvents(); // --- Header title --- -const currentThreadTitle = computed(() => { +// Returns the resolved title once we have one, or undefined while we're still +// figuring out which thread to show. Rendering only on a defined value avoids +// the "New conversation" \u2192 real title flash when resuming a recent thread. +const currentThreadTitle = computed(() => { const thread = store.threads.find((t) => t.id === store.currentThreadId); - if (!thread || thread.title === NEW_CONVERSATION_TITLE) { - const firstUserMsg = store.messages.find((m) => m.role === 'user'); - if (firstUserMsg?.content) { - const text = firstUserMsg.content.trim(); - return text.length > 60 ? text.slice(0, 60) + '\u2026' : text; - } - return NEW_CONVERSATION_TITLE; + if (thread && thread.title && thread.title !== NEW_CONVERSATION_TITLE) { + return thread.title; } - return thread.title; + const firstUserMsg = store.messages.find((m) => m.role === 'user'); + if (firstUserMsg?.content) { + const text = firstUserMsg.content.trim(); + return text.length > 60 ? text.slice(0, 60) + '\u2026' : text; + } + return undefined; }); // --- Canvas / data table preview --- @@ -132,7 +136,8 @@ const showEmptyStateLayout = computed(() => !props.threadId); // Load persisted threads from Mastra storage on mount onMounted(() => { void store.loadThreads().then((loaded) => { - if (!loaded || !props.threadId) return; + if (!loaded) return; + if (!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 }); @@ -179,13 +184,26 @@ const showDebugPanel = ref(false); const isDebugEnabled = computed(() => localStorage.getItem('instanceAi.debugMode') === 'true'); // --- Sidebar collapse & resize --- -const sidebarCollapsed = useLocalStorage('instanceAi.sidebarCollapsed', false); +// Session-scoped: survives page refresh, resets when the user navigates away +// from the AI chat view (see onBeforeRouteLeave below). +const sidebarCollapsed = useSessionStorage('instanceAi.sidebarCollapsed', true); const sidebarWidth = ref(260); function toggleSidebarCollapse() { sidebarCollapsed.value = !sidebarCollapsed.value; } +// Reset to collapsed when leaving the AI chat namespace, so the next entry +// starts collapsed by default. Refreshes (which don't trigger the guard) keep +// the user's current open/closed state. +const CHAT_ROUTE_NAMES = new Set([INSTANCE_AI_VIEW, INSTANCE_AI_THREAD_VIEW]); +onBeforeRouteLeave((to) => { + const name = typeof to.name === 'string' ? to.name : undefined; + if (!name || !CHAT_ROUTE_NAMES.has(name)) { + sidebarCollapsed.value = true; + } +}); + function handleSidebarResize({ width }: { width: number }) { // Drag below min-width threshold → auto-collapse if (width <= 200) { @@ -334,10 +352,10 @@ watch( () => props.threadId, (threadId) => { if (!threadId) { - // /instance-ai base route (no :threadId) — reset to a clean empty - // state. Without this, `currentThreadId` keeps pointing at the - // last thread and the sidebar highlights it alongside the empty - // main view (AI-2408). A new thread is created on the first + // /instance-ai base route (no :threadId): always show the empty + // state. Without this, `currentThreadId` keeps pointing at the last + // thread and the sidebar highlights it alongside the empty main + // view (AI-2408). A new thread is created on the first // `sendMessage` via `syncThread`. store.clearCurrentThread(); return; @@ -403,37 +421,46 @@ function handleStop() {