feat(editor): Polish Instance AI chat list sidebar (no-changelog) (#29463)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tuukka Kantola
2026-05-04 07:17:47 +00:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 34d7a02df7
commit 8c0faa27c4
10 changed files with 232 additions and 86 deletions
@@ -735,6 +735,7 @@ export interface InstanceAiThreadSummary {
id: string;
title: string;
createdAt: string;
updatedAt: string;
metadata?: Record<string, unknown>;
}
@@ -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)) };
}
@@ -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",
@@ -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<string | undefined>(() => {
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<string>([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() {
<template>
<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 />
</N8nResizeWrapper>
<Transition name="sidebar-slide">
<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 @collapse="toggleSidebarCollapse" />
</N8nResizeWrapper>
</Transition>
<!-- Main chat area -->
<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">
<Transition name="sidebar-toggle-fade">
<span v-if="sidebarCollapsed" :class="$style.sidebarToggle">
<N8nTooltip
:content="i18n.baseText('instanceAi.sidebar.chatHistory')"
placement="bottom"
:show-after="TOOLTIP_DELAY_MS"
>
<N8nIconButton
icon="history"
variant="ghost"
size="small"
icon-size="large"
data-test-id="instance-ai-sidebar-toggle"
:aria-label="i18n.baseText('instanceAi.sidebar.chatHistory')"
@click="toggleSidebarCollapse"
/>
</N8nTooltip>
</span>
</Transition>
<N8nHeading v-if="currentThreadTitle" tag="h2" size="small" :class="$style.headerTitle">
{{ currentThreadTitle }}
</N8nHeading>
<N8nText
@@ -455,8 +482,8 @@ function handleStop() {
<N8nIconButton
icon="cog"
variant="ghost"
size="medium"
:class="$style.settingsButton"
size="small"
icon-size="large"
data-test-id="instance-ai-settings-button"
@click="goToSettings"
/>
@@ -464,7 +491,8 @@ function handleStop() {
v-if="isDebugEnabled"
icon="bug"
variant="ghost"
size="medium"
size="small"
icon-size="large"
:class="{ [$style.activeButton]: showDebugPanel }"
@click="
showDebugPanel = !showDebugPanel;
@@ -475,7 +503,8 @@ function handleStop() {
v-if="!preview.isPreviewVisible.value"
icon="panel-right"
variant="ghost"
size="medium"
size="small"
icon-size="large"
@click="showArtifactsPanel = !showArtifactsPanel"
/>
</div>
@@ -717,11 +746,11 @@ function handleStop() {
}
.header {
padding: var(--spacing--sm) var(--spacing--lg);
padding: var(--spacing--2xs) var(--spacing--xs);
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--spacing--xs);
gap: var(--spacing--2xs);
background-color: var(--color--background--light-2);
}
@@ -740,8 +769,8 @@ function handleStop() {
gap: var(--spacing--4xs);
}
.settingsButton {
padding: var(--spacing--xs);
.sidebarToggle {
display: inline-flex;
}
.activeButton {
@@ -877,4 +906,36 @@ function handleStop() {
.fade-leave-active {
transition: opacity 0.2s ease;
}
.sidebar-slide-enter-active,
.sidebar-slide-leave-active {
transition:
width 0.2s cubic-bezier(0.16, 1, 0.3, 1),
min-width 0.2s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.2s ease;
overflow: hidden;
}
.sidebar-slide-enter-from,
.sidebar-slide-leave-to {
width: 0 !important;
min-width: 0 !important;
opacity: 0;
}
// Entry-point icon button: fade in slightly after the sidebar has begun
// collapsing, fade out quickly when the sidebar starts opening — so the
// crossover feels intentional rather than abrupt.
.sidebar-toggle-fade-enter-from,
.sidebar-toggle-fade-leave-to {
opacity: 0;
}
.sidebar-toggle-fade-enter-active {
transition: opacity 0.15s ease;
}
.sidebar-toggle-fade-leave-active {
transition: opacity 0.1s ease;
}
</style>
@@ -2,10 +2,11 @@
import { getRelativeDate } from '@/features/ai/chatHub/chat.utils';
import {
N8nActionDropdown,
N8nIcon,
N8nIconButton,
N8nText,
N8nScrollArea,
N8nTooltip,
TOOLTIP_DELAY_MS,
} from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types';
import { useI18n } from '@n8n/i18n';
@@ -14,6 +15,8 @@ import { useRouter } from 'vue-router';
import { INSTANCE_AI_VIEW, INSTANCE_AI_THREAD_VIEW } from '../constants';
import { useInstanceAiStore } from '../instanceAi.store';
const emit = defineEmits<{ collapse: [] }>();
const store = useInstanceAiStore();
const i18n = useI18n();
const router = useRouter();
@@ -47,8 +50,12 @@ const groupedThreads = computed(() => {
const now = new Date();
const groups = new Map<string, typeof store.threads>();
// Group by last activity, not creation date — a thread created weeks ago
// but messaged today belongs under "Today", matching the backend ordering
// (memory.service returns threads sorted by updatedAt desc) and the
// chatHub sidebar's `groupConversationsByDate` behaviour.
for (const thread of store.threads) {
const group = getRelativeDate(now, thread.createdAt);
const group = getRelativeDate(now, thread.updatedAt ?? thread.createdAt);
if (!groups.has(group)) {
groups.set(group, []);
}
@@ -115,17 +122,44 @@ function handleThreadAction(action: string, threadId: string) {
<template>
<div :class="$style.container" data-test-id="instance-ai-thread-list">
<!-- New chat button -->
<button
:class="$style.newChatButton"
data-test-id="instance-ai-new-thread-button"
@click="handleNewThread"
>
<div :class="$style.newChatIcon">
<N8nIcon icon="plus" size="medium" />
<!-- Sidebar header -->
<div :class="$style.header">
<N8nText :class="$style.title" tag="div" size="medium" bold>
{{ i18n.baseText('instanceAi.sidebar.chatHistory') }}
</N8nText>
<div :class="$style.headerActions">
<N8nTooltip
:content="i18n.baseText('instanceAi.sidebar.collapse')"
placement="bottom"
:show-after="TOOLTIP_DELAY_MS"
>
<N8nIconButton
icon="chevrons-left"
variant="ghost"
size="small"
icon-size="large"
:aria-label="i18n.baseText('instanceAi.sidebar.collapse')"
data-test-id="instance-ai-sidebar-collapse"
@click="emit('collapse')"
/>
</N8nTooltip>
<N8nTooltip
:content="i18n.baseText('instanceAi.thread.new')"
placement="bottom"
:show-after="TOOLTIP_DELAY_MS"
>
<N8nIconButton
icon="plus"
variant="ghost"
size="small"
icon-size="large"
:aria-label="i18n.baseText('instanceAi.thread.new')"
data-test-id="instance-ai-new-thread-button"
@click="handleNewThread"
/>
</N8nTooltip>
</div>
{{ i18n.baseText('instanceAi.thread.new') }}
</button>
</div>
<!-- Thread list -->
<N8nScrollArea :class="$style.threadList">
@@ -199,34 +233,27 @@ function handleThreadAction(action: string, threadId: string) {
min-height: 0;
}
.newChatButton {
.header {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--sm);
background: none;
border: none;
cursor: pointer;
font-family: var(--font-family);
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
color: var(--color--text);
width: 100%;
&:hover {
background: var(--color--background--light-1);
}
gap: var(--spacing--3xs);
padding: var(--spacing--2xs) var(--spacing--3xs) var(--spacing--2xs) var(--spacing--sm);
min-height: 40px;
}
.newChatIcon {
.title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color--text);
}
.headerActions {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color--primary);
color: white;
gap: var(--spacing--5xs);
}
.threadList {
@@ -640,6 +640,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
id: t.id,
title: t.title || NEW_CONVERSATION_TITLE,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
metadata: t.metadata ?? undefined,
}));
threads.value = [...localOnly, ...serverThreads];
@@ -659,6 +660,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
const existingThread = threads.value.find((thread) => thread.id === threadId);
if (existingThread) {
existingThread.createdAt = result.thread.createdAt;
existingThread.updatedAt = result.thread.updatedAt;
existingThread.title = result.thread.title || existingThread.title;
return;
}
@@ -667,6 +669,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
id: result.thread.id,
title: result.thread.title || NEW_CONVERSATION_TITLE,
createdAt: result.thread.createdAt,
updatedAt: result.thread.updatedAt,
});
}
@@ -318,6 +318,11 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
setCurrentProject(null);
}
if (newRoute?.path?.includes('instance-ai')) {
projectNavActiveId.value = 'instance-ai';
setCurrentProject(null);
}
if (newRoute?.path?.includes('workflow/')) {
if (currentProjectId.value) {
projectNavActiveId.value = currentProjectId.value;
@@ -19,6 +19,26 @@ export class InstanceAiPage extends BasePage {
return this.page.getByTestId('instance-ai-container');
}
getSidebarToggle(): Locator {
return this.getContainer().getByTestId('instance-ai-sidebar-toggle');
}
/**
* Expand the chat-history sidebar if it isn't already open. The sidebar
* starts collapsed by default, so any test that needs to query thread
* items must open it first. Idempotent — does nothing if already open.
*
* Waits for the thread-list to become visible so callers can immediately
* query thread items without racing the 200ms slide-in transition.
*/
async openSidebar(): Promise<void> {
const toggle = this.getSidebarToggle();
if (await toggle.isVisible()) {
await toggle.click();
}
await this.getContainer().getByTestId('instance-ai-thread-list').waitFor({ state: 'visible' });
}
// ── Messages ──────────────────────────────────────────────────────
getChatInput(): Locator {
@@ -45,6 +45,9 @@ test.describe(
await n8n.navigate.toInstanceAi();
await expect(n8n.instanceAi.getChatInput()).toBeVisible({ timeout: 30_000 });
// Sidebar starts collapsed; open it so the thread list is queryable.
await n8n.instanceAi.openSidebar();
// The old thread should be visible in the sidebar. Its title is generated by the model,
// so restore it by position rather than by exact title text.
const oldThread = n8n.instanceAi.sidebar.getThreadItems().first();
@@ -15,6 +15,9 @@ test.describe(
await n8n.instanceAi.sendMessage('First thread message');
await n8n.instanceAi.waitForResponseComplete();
// Sidebar starts collapsed; open it so the thread list is queryable.
await n8n.instanceAi.openSidebar();
const threadCountBefore = await n8n.instanceAi.sidebar.getThreadItems().count();
// Click new thread button
@@ -40,6 +43,10 @@ test.describe(
await n8n.instanceAi.sendMessage('Message in first thread');
await n8n.instanceAi.waitForResponseComplete();
// Sidebar starts collapsed; open it so the new-thread button and
// thread list are queryable.
await n8n.instanceAi.openSidebar();
// Create second thread
await n8n.instanceAi.sidebar.getNewThreadButton().click();
await expect(n8n.instanceAi.getChatInput()).toBeVisible({ timeout: 10_000 });
@@ -65,6 +72,9 @@ test.describe(
await n8n.instanceAi.sendMessage('Thread to rename');
await n8n.instanceAi.waitForResponseComplete();
// Sidebar starts collapsed; open it so the thread list is queryable.
await n8n.instanceAi.openSidebar();
// Double-click the thread to enter rename mode
const thread = n8n.instanceAi.sidebar.getThreadItems().first();
await thread.dblclick();
@@ -88,6 +98,9 @@ test.describe(
await n8n.instanceAi.sendMessage('Thread to delete');
await n8n.instanceAi.waitForResponseComplete();
// Sidebar starts collapsed; open it so the thread list is queryable.
await n8n.instanceAi.openSidebar();
// Verify target thread is visible in the sidebar. Its generated title is not part of
// the behavior under test, so use the current thread item instead of title text.
const targetThread = n8n.instanceAi.sidebar.getThreadItems().first();