perf(editor): Render chat sidebar faster (no-changelog) (#22039)

This commit is contained in:
Suguru Inoue 2025-11-20 14:55:14 +01:00 committed by GitHub
parent 11e898eafe
commit 11e111e372
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 463 additions and 96 deletions

View File

@ -275,7 +275,16 @@ export interface ChatHubMessageDto {
attachments: Array<{ fileName?: string; mimeType?: string }>;
}
export type ChatHubConversationsResponse = ChatHubSessionDto[];
export class ChatHubConversationsRequest extends Z.class({
limit: z.coerce.number().int().min(1).max(100),
cursor: z.string().uuid().optional(),
}) {}
export interface ChatHubConversationsResponse {
data: ChatHubSessionDto[];
nextCursor: string | null;
hasMore: boolean;
}
export interface ChatHubConversationDto {
messages: Record<ChatMessageId, ChatHubMessageDto>;

View File

@ -33,6 +33,7 @@ export {
ChatHubRegenerateMessageRequest,
ChatHubEditMessageRequest,
ChatHubUpdateConversationRequest,
ChatHubConversationsRequest,
type ChatMessageId,
type ChatSessionId,
type ChatHubMessageDto,

View File

@ -48,9 +48,9 @@ describe('chatHub', () => {
describe('getConversations', () => {
it('should list empty conversations', async () => {
const conversations = await chatHubService.getConversations(member.id);
const conversations = await chatHubService.getConversations(member.id, 20);
expect(conversations).toBeDefined();
expect(conversations).toHaveLength(0);
expect(conversations.data).toHaveLength(0);
});
it("should list user's own conversations in expected order", async () => {
@ -83,11 +83,182 @@ describe('chatHub', () => {
tools: [],
});
const conversations = await chatHubService.getConversations(member.id);
expect(conversations).toHaveLength(3);
expect(conversations[0].id).toBe(session1.id);
expect(conversations[1].id).toBe(session2.id);
expect(conversations[2].id).toBe(session3.id);
const conversations = await chatHubService.getConversations(member.id, 20);
expect(conversations.data).toHaveLength(3);
expect(conversations.data[0].id).toBe(session1.id);
expect(conversations.data[1].id).toBe(session2.id);
expect(conversations.data[2].id).toBe(session3.id);
});
describe('pagination', () => {
it('should return hasMore=false and nextCursor=null when all sessions fit in one page', async () => {
await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'session 1',
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
tools: [],
});
const conversations = await chatHubService.getConversations(member.id, 10);
expect(conversations.data).toHaveLength(1);
expect(conversations.hasMore).toBe(false);
expect(conversations.nextCursor).toBeNull();
});
it('should fetch next page using cursor', async () => {
const session1 = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'session 1',
lastMessageAt: new Date('2025-01-05T00:00:00Z'),
tools: [],
});
const session2 = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'session 2',
lastMessageAt: new Date('2025-01-04T00:00:00Z'),
tools: [],
});
const session3 = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'session 3',
lastMessageAt: new Date('2025-01-03T00:00:00Z'),
tools: [],
});
const session4 = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'session 4',
lastMessageAt: new Date('2025-01-02T00:00:00Z'),
tools: [],
});
// First page
const page1 = await chatHubService.getConversations(member.id, 2);
expect(page1.data).toHaveLength(2);
expect(page1.data[0].id).toBe(session1.id);
expect(page1.data[1].id).toBe(session2.id);
expect(page1.hasMore).toBe(true);
expect(page1.nextCursor).toBe(session2.id);
// Second page using cursor
const page2 = await chatHubService.getConversations(member.id, 2, page1.nextCursor!);
expect(page2.data).toHaveLength(2);
expect(page2.data[0].id).toBe(session3.id);
expect(page2.data[1].id).toBe(session4.id);
expect(page2.hasMore).toBe(false);
expect(page2.nextCursor).toBeNull();
});
it('should handle sessions with same lastMessageAt using id for ordering', async () => {
const sameDate = new Date('2025-01-01T00:00:00Z');
const session1 = await sessionsRepository.createChatSession({
id: '00000000-0000-0000-0000-000000000001',
ownerId: member.id,
title: 'Session 1',
lastMessageAt: sameDate,
tools: [],
});
const session2 = await sessionsRepository.createChatSession({
id: '00000000-0000-0000-0000-000000000002',
ownerId: member.id,
title: 'Session 2',
lastMessageAt: sameDate,
tools: [],
});
const session3 = await sessionsRepository.createChatSession({
id: '00000000-0000-0000-0000-000000000003',
ownerId: member.id,
title: 'Session 3',
lastMessageAt: sameDate,
tools: [],
});
// Fetch first page
const page1 = await chatHubService.getConversations(member.id, 2);
expect(page1.data).toHaveLength(2);
expect(page1.data[0].id).toBe(session1.id);
expect(page1.data[1].id).toBe(session2.id);
expect(page1.hasMore).toBe(true);
// Fetch second page
const page2 = await chatHubService.getConversations(member.id, 2, page1.nextCursor!);
expect(page2.data).toHaveLength(1);
expect(page2.data[0].id).toBe(session3.id);
expect(page2.hasMore).toBe(false);
});
it('should throw error when cursor session does not exist', async () => {
await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'session 1',
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
tools: [],
});
const nonExistentCursor = '00000000-0000-0000-0000-000000000000';
await expect(
chatHubService.getConversations(member.id, 10, nonExistentCursor),
).rejects.toThrow('Cursor session not found');
});
it('should throw error when cursor session belongs to different user', async () => {
await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'Member Session',
lastMessageAt: new Date('2025-01-02T00:00:00Z'),
tools: [],
});
const adminSession = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: admin.id,
title: 'Admin Session',
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
tools: [],
});
await expect(
chatHubService.getConversations(member.id, 10, adminSession.id),
).rejects.toThrow('Cursor session not found');
});
it('should handle sessions with null lastMessageAt', async () => {
const session1 = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'Session with date',
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
tools: [],
});
const session2 = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'Session without date',
lastMessageAt: null,
tools: [],
});
const conversations = await chatHubService.getConversations(member.id, 10);
expect(conversations.data).toHaveLength(2);
expect(conversations.data[0].id).toBe(session1.id);
expect(conversations.data[1].id).toBe(session2.id);
});
});
});

View File

@ -10,6 +10,7 @@ import {
ChatMessageId,
ChatHubCreateAgentRequest,
ChatHubUpdateAgentRequest,
ChatHubConversationsRequest,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { AuthenticatedRequest } from '@n8n/db';
@ -22,6 +23,7 @@ import {
Delete,
Param,
Patch,
Query,
} from '@n8n/decorators';
import type { Response } from 'express';
import { strict as assert } from 'node:assert';
@ -59,8 +61,9 @@ export class ChatHubController {
async getConversations(
req: AuthenticatedRequest,
_res: Response,
@Query query: ChatHubConversationsRequest,
): Promise<ChatHubConversationsResponse> {
return await this.chatService.getConversations(req.user.id);
return await this.chatService.getConversations(req.user.id, query.limit, query.cursor);
}
@Get('/conversations/:sessionId')

View File

@ -1683,24 +1683,36 @@ export class ChatHubService {
/**
* Get all conversations for a user
*/
async getConversations(userId: string): Promise<ChatHubConversationsResponse> {
const sessions = await this.sessionRepository.getManyByUserId(userId);
async getConversations(
userId: string,
limit: number,
cursor?: string,
): Promise<ChatHubConversationsResponse> {
const sessions = await this.sessionRepository.getManyByUserId(userId, limit + 1, cursor);
return sessions.map((session) => ({
id: session.id,
title: session.title,
ownerId: session.ownerId,
lastMessageAt: session.lastMessageAt?.toISOString() ?? null,
credentialId: session.credentialId,
provider: session.provider,
model: session.model,
workflowId: session.workflowId,
agentId: session.agentId,
agentName: session.agentName,
createdAt: session.createdAt.toISOString(),
updatedAt: session.updatedAt.toISOString(),
tools: session.tools,
}));
const hasMore = sessions.length > limit;
const data = hasMore ? sessions.slice(0, limit) : sessions;
const nextCursor = hasMore ? data[data.length - 1].id : null;
return {
data: data.map((session) => ({
id: session.id,
title: session.title,
ownerId: session.ownerId,
lastMessageAt: session.lastMessageAt?.toISOString() ?? null,
credentialId: session.credentialId,
provider: session.provider,
model: session.model,
workflowId: session.workflowId,
agentId: session.agentId,
agentName: session.agentName,
createdAt: session.createdAt.toISOString(),
updatedAt: session.updatedAt.toISOString(),
tools: session.tools,
})),
nextCursor,
hasMore,
};
}
/**

View File

@ -2,6 +2,8 @@ import { withTransaction } from '@n8n/db';
import { Service } from '@n8n/di';
import { DataSource, EntityManager, Repository } from '@n8n/typeorm';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ChatHubSession } from './chat-hub-session.entity';
@Service()
@ -56,11 +58,33 @@ export class ChatHubSessionRepository extends Repository<ChatHubSession> {
});
}
async getManyByUserId(userId: string) {
return await this.find({
where: { ownerId: userId },
order: { lastMessageAt: 'DESC', id: 'ASC' },
});
async getManyByUserId(userId: string, limit: number, cursor?: string) {
const queryBuilder = this.createQueryBuilder('session')
.where('session.ownerId = :userId', { userId })
.orderBy("COALESCE(session.lastMessageAt, '1970-01-01')", 'DESC')
.addOrderBy('session.id', 'ASC');
if (cursor) {
const cursorSession = await this.findOne({
where: { id: cursor, ownerId: userId },
});
if (!cursorSession) {
throw new NotFoundError('Cursor session not found');
}
queryBuilder.andWhere(
'(session.lastMessageAt < :lastMessageAt OR (session.lastMessageAt = :lastMessageAt AND session.id > :id))',
{
lastMessageAt: cursorSession.lastMessageAt,
id: cursorSession.id,
},
);
}
queryBuilder.take(limit);
return await queryBuilder.getMany();
}
async getOneById(id: string, userId: string, trx?: EntityManager) {

View File

@ -141,19 +141,19 @@ const modelFromQuery = computed<ChatModelDto | null>(() => {
}
if (typeof agentId === 'string') {
return chatStore.getAgent({ provider: 'custom-agent', agentId }) ?? null;
return chatStore.getAgent({ provider: 'custom-agent', agentId });
}
if (typeof workflowId === 'string') {
return chatStore.getAgent({ provider: 'n8n', workflowId }) ?? null;
return chatStore.getAgent({ provider: 'n8n', workflowId });
}
return null;
});
const selectedModel = computed<ChatModelDto | undefined>(() => {
const selectedModel = computed<ChatModelDto | null>(() => {
if (!chatStore.agentsReady) {
return undefined;
return null;
}
if (modelFromQuery.value) {
@ -163,14 +163,14 @@ const selectedModel = computed<ChatModelDto | undefined>(() => {
if (currentConversation.value?.provider) {
const model = unflattenModel(currentConversation.value);
return model ? chatStore.getAgent(model) : undefined;
return model ? chatStore.getAgent(model) : null;
}
if (chatStore.streaming?.sessionId === sessionId.value) {
return chatStore.getAgent(chatStore.streaming.model);
}
return defaultModel.value ? chatStore.getAgent(defaultModel.value) : undefined;
return defaultModel.value ? chatStore.getAgent(defaultModel.value) : null;
});
const { credentialsByProvider, selectCredential } = useChatCredentials(
@ -501,7 +501,7 @@ function onFilesDropped(files: File[]) {
<ChatConversationHeader
ref="headerRef"
:selected-model="selectedModel ?? null"
:selected-model="selectedModel"
:credentials="credentialsByProvider"
:ready-to-show-model-selector="chatStore.agentsReady"
@select-model="handleSelectModel"
@ -563,7 +563,7 @@ function onFilesDropped(files: File[]) {
<ChatPrompt
ref="inputRef"
:class="$style.prompt"
:selected-model="selectedModel ?? null"
:selected-model="selectedModel"
:selected-tools="selectedTools"
:is-responding="isResponding"
:is-tools-selectable="canSelectTools"

View File

@ -97,8 +97,16 @@ export const stopGenerationApi = async (
export const fetchConversationsApi = async (
context: IRestApiContext,
limit: number,
cursor?: string,
): Promise<ChatHubConversationsResponse> => {
const apiEndpoint = '/chat/conversations';
const queryParams = new URLSearchParams();
queryParams.append('limit', limit.toString());
if (cursor) {
queryParams.append('cursor', cursor);
}
const apiEndpoint = `/chat/conversations?${queryParams.toString()}`;
return await makeRestApiRequest<ChatHubConversationsResponse>(context, 'GET', apiEndpoint);
};

View File

@ -35,6 +35,7 @@ import {
type EnrichedStructuredChunk,
type ChatHubMessageStatus,
type ChatModelDto,
type ChatHubConversationsResponse,
} from '@n8n/api-types';
import type {
CredentialsMap,
@ -56,7 +57,9 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
const telemetry = useTelemetry();
const agents = ref<ChatModelsResponse>();
const sessions = ref<ChatHubSessionDto[]>();
const sessions = ref<ChatHubConversationsResponse>();
const sessionsLoadingMore = ref(false);
const currentEditingAgent = ref<ChatHubAgentDto | null>(null);
const streaming = ref<ChatStreamingState>();
@ -270,8 +273,39 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
return agents.value;
}
async function fetchSessions() {
sessions.value = await fetchSessionsApi(rootStore.restApiContext);
async function fetchSessions(reset: boolean) {
if (sessionsLoadingMore.value) {
return;
}
if (!reset && sessions.value && !sessions.value.hasMore && sessions.value.data.length > 0) {
return;
}
if (!reset) {
sessionsLoadingMore.value = true;
}
try {
const cursor = reset ? undefined : (sessions.value?.nextCursor ?? undefined);
const [response] = await Promise.all([
fetchSessionsApi(rootStore.restApiContext, 40, cursor),
new Promise((resolve) => setTimeout(resolve, 500)),
]);
sessions.value = {
...response,
data: [...(reset ? [] : (sessions.value?.data ?? [])), ...response.data],
};
} finally {
sessionsLoadingMore.value = false;
}
}
async function fetchMoreSessions() {
if (sessions.value?.hasMore && !sessionsLoadingMore.value) {
await fetchSessions(false);
}
}
async function fetchMessages(sessionId: string) {
@ -302,25 +336,30 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
addMessage(streaming.value.sessionId, message);
if (sessions.value?.some((session) => session.id === streaming.value?.sessionId)) {
if (sessions.value?.data.some((session) => session.id === streaming.value?.sessionId)) {
return;
}
sessions.value = [
...(sessions.value ?? []),
{
id: streaming.value.sessionId,
title: 'New Chat',
ownerId: '',
lastMessageAt: new Date().toISOString(),
credentialId: null,
agentName: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tools: [],
...flattenModel(streaming.value.model),
},
];
sessions.value = {
hasMore: false,
nextCursor: null,
...sessions.value,
data: [
...(sessions.value?.data ?? []),
{
id: streaming.value.sessionId,
title: 'New Chat',
ownerId: '',
lastMessageAt: new Date().toISOString(),
credentialId: null,
agentName: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tools: [],
...flattenModel(streaming.value.model),
},
],
};
}
function ensureMessage(sessionId: ChatSessionId, messageId: ChatMessageId): ChatMessage {
@ -402,8 +441,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
3,
);
// update the conversation list
await fetchSessions();
// update the conversation list to reflect the new title
await fetchSessions(true);
}
function onStreamError(error: Error) {
@ -607,7 +646,11 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
}
function updateSession(sessionId: ChatSessionId, toUpdate: Partial<ChatHubSessionDto>) {
sessions.value = sessions.value?.map((session) =>
if (!sessions.value) {
return;
}
sessions.value.data = sessions.value.data?.map((session) =>
session.id === sessionId
? {
...session,
@ -618,7 +661,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
}
async function updateToolsInSession(sessionId: ChatSessionId, tools: INode[]) {
const session = sessions.value?.find((s) => s.id === sessionId);
const session = sessions.value?.data?.find((s) => s.id === sessionId);
if (!session) {
throw new Error(`Session with ID ${sessionId} not found`);
}
@ -644,7 +687,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
async function deleteSession(sessionId: ChatSessionId) {
await deleteConversationApi(rootStore.restApiContext, sessionId);
sessions.value = sessions.value?.filter((session) => session.id !== sessionId);
if (sessions.value) {
sessions.value = {
...sessions.value,
data: sessions.value.data?.filter((session) => session.id !== sessionId),
};
}
}
function switchAlternative(sessionId: ChatSessionId, messageId: ChatMessageId) {
@ -730,13 +778,13 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
}
function getAgent(model: ChatHubConversationModel) {
if (!agents.value) return;
if (!agents.value) return null;
const agent = agents.value[model.provider].models.find((agent) => isMatchedAgent(agent, model));
if (!agent) {
if (model.provider === 'custom-agent' || model.provider === 'n8n') {
return;
return null;
}
// Allow custom models chosen by ID even if they are not in the fetched list
@ -774,9 +822,11 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
/**
* conversations
*/
sessions: computed(() => sessions.value ?? []),
sessions: computed(() => sessions.value?.data ?? []),
sessionsReady: computed(() => sessions.value !== undefined),
sessionsLoading: computed(() => sessionsLoadingMore.value),
fetchSessions,
fetchMoreSessions,
renameSession,
updateSessionModel,
deleteSession,

View File

@ -75,7 +75,7 @@ function loadAgent() {
name.value = customAgent.name;
description.value = customAgent.description ?? '';
systemPrompt.value = customAgent.systemPrompt;
selectedModel.value = chatStore.getAgent(customAgent) ?? null;
selectedModel.value = chatStore.getAgent(customAgent);
tools.value = customAgent.tools || [];
if (customAgent.credentialId) {

View File

@ -3,7 +3,7 @@ import { useClipboard } from '@/app/composables/useClipboard';
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
import ChatTypingIndicator from '@/features/ai/chatHub/components/ChatTypingIndicator.vue';
import { useChatHubMarkdownOptions } from '@/features/ai/chatHub/composables/useChatHubMarkdownOptions';
import type { ChatMessageId } from '@n8n/api-types';
import type { ChatMessageId, ChatModelDto } from '@n8n/api-types';
import { N8nButton, N8nIcon, N8nInput } from '@n8n/design-system';
import { useSpeechSynthesis } from '@vueuse/core';
import type MarkdownIt from 'markdown-it';
@ -13,7 +13,7 @@ import VueMarkdown from 'vue-markdown-render';
import type { ChatMessage } from '../chat.types';
import ChatMessageActions from './ChatMessageActions.vue';
import { unflattenModel } from '@/features/ai/chatHub/chat.utils';
import { useAgent } from '@/features/ai/chatHub/composables/useAgent';
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import ChatFile from '@n8n/chat/components/ChatFile.vue';
import { buildChatAttachmentUrl } from '@/features/ai/chatHub/chat.api';
import { useRootStore } from '@n8n/stores/useRootStore';
@ -38,6 +38,7 @@ const emit = defineEmits<{
}>();
const clipboard = useClipboard();
const chatStore = useChatStore();
const rootStore = useRootStore();
const editedText = ref('');
@ -52,8 +53,11 @@ const speech = useSpeechSynthesis(messageContent, {
volume: 1,
});
const model = computed(() => unflattenModel(message));
const agent = useAgent(model);
const agent = computed<ChatModelDto | null>(() => {
const model = unflattenModel(message);
return model ? chatStore.getAgent(model) : null;
});
const attachments = computed(() =>
message.attachments.map(({ fileName, mimeType }, index) => ({

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import { unflattenModel } from '@/features/ai/chatHub/chat.utils';
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
import ChatSidebarLink from '@/features/ai/chatHub/components/ChatSidebarLink.vue';
import { useAgent } from '@/features/ai/chatHub/composables/useAgent';
import { CHAT_CONVERSATION_VIEW } from '@/features/ai/chatHub/constants';
import { type ChatHubSessionDto } from '@n8n/api-types';
import { type ChatModelDto, type ChatHubSessionDto } from '@n8n/api-types';
import { N8nInput } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
@ -24,11 +24,31 @@ const emit = defineEmits<{
const input = useTemplateRef('input');
const editedLabel = ref('');
const chatStore = useChatStore();
type SessionAction = 'rename' | 'delete';
const model = computed(() => unflattenModel(session));
const agent = useAgent(model);
const agent = computed<ChatModelDto | null>(() => {
const model = unflattenModel(session);
if (!model) {
return null;
}
const agent = chatStore.getAgent(model);
if (agent) {
return agent;
}
return {
model,
name: session.agentName || '',
description: null,
createdAt: null,
updatedAt: null,
};
});
const dropdownItems = computed<Array<ActionDropdownItem<SessionAction>>>(() => [
{
@ -107,7 +127,7 @@ watch(
/>
</template>
<template #icon>
<ChatAgentAvatar :agent="agent ?? null" size="sm" />
<ChatAgentAvatar :agent="agent" size="sm" />
</template>
</ChatSidebarLink>
</template>

View File

@ -13,7 +13,9 @@ import { N8nIconButton, N8nScrollArea, N8nText } from '@n8n/design-system';
import Logo from '@n8n/design-system/components/N8nLogo';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useIntersectionObserver } from '@vueuse/core';
import ChatSessionMenuItem from './ChatSessionMenuItem.vue';
import SkeletonMenuItem from './SkeletonMenuItem.vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
defineProps<{ isMobileDevice: boolean }>();
@ -28,11 +30,11 @@ const settingsStore = useSettingsStore();
const telemetry = useTelemetry();
const renamingSessionId = ref<string>();
const loadMoreTrigger = ref<HTMLElement | null>(null);
const currentSessionId = computed(() =>
typeof route.params.id === 'string' ? route.params.id : undefined,
);
const readyToShowConversations = computed(() => chatStore.agentsReady && chatStore.sessionsReady);
const groupedConversations = computed(() => groupConversationsByDate(chatStore.sessions));
@ -84,8 +86,20 @@ function handleNewChatClick() {
sidebar.toggleOpen(false);
}
useIntersectionObserver(
loadMoreTrigger,
([{ isIntersecting }]) => {
if (isIntersecting) {
void chatStore.fetchMoreSessions();
}
},
{ threshold: 0.1 },
);
onMounted(() => {
void chatStore.fetchSessions();
if (!chatStore.sessionsReady) {
void chatStore.fetchSessions(true);
}
});
</script>
@ -131,8 +145,18 @@ onMounted(() => {
/>
</div>
<N8nScrollArea as-child type="scroll">
<div v-if="readyToShowConversations" :class="$style.items">
<div v-for="group in groupedConversations" :key="group.group" :class="$style.group">
<div :class="$style.items">
<div
v-if="groupedConversations.length === 0 && !chatStore.sessionsReady"
:class="$style.group"
>
<SkeletonMenuItem v-for="i in 10" :key="`loading-${i}`" />
</div>
<div
v-for="(group, index) in groupedConversations"
:key="group.group"
:class="$style.group"
>
<N8nText :class="$style.groupHeader" size="small" bold color="text-light">
{{ group.group }}
</N8nText>
@ -147,7 +171,12 @@ onMounted(() => {
@confirm-rename="handleConfirmRename"
@delete="handleDeleteSession"
/>
<template v-if="index === groupedConversations.length - 1 && chatStore.sessionsLoading">
<SkeletonMenuItem v-for="i in 10" :key="i" />
</template>
</div>
<div ref="loadMoreTrigger" :class="$style.loadMoreTrigger"></div>
</div>
</N8nScrollArea>
<MainSidebarUserArea :fully-expanded="true" :is-collapsed="false" />
@ -203,6 +232,11 @@ onMounted(() => {
padding: 0 var(--spacing--4xs) var(--spacing--3xs) var(--spacing--4xs);
}
.loadMoreTrigger {
height: 1px;
width: 100%;
}
.loading,
.empty {
padding: var(--spacing--xs);

View File

@ -0,0 +1,46 @@
<template>
<div :class="$style.skeletonItem">
<div :class="$style.skeletonAvatar"></div>
<div :class="$style.skeletonText"></div>
</div>
</template>
<style lang="scss" module>
.skeletonItem {
display: flex;
align-items: center;
padding: var(--spacing--3xs);
gap: var(--spacing--3xs);
border-radius: var(--spacing--4xs);
height: 30px;
}
.skeletonAvatar,
.skeletonText {
background: var(--color--foreground);
animation: skeleton-pulse 1s ease-in-out infinite;
}
.skeletonAvatar {
width: 16px;
height: 16px;
border-radius: 50%;
flex-shrink: 0;
}
.skeletonText {
height: 14px;
width: 80%;
border-radius: var(--radius--sm);
}
@keyframes skeleton-pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 0.3;
}
}
</style>

View File

@ -1,15 +0,0 @@
import type { ChatHubConversationModel, ChatModelDto } from '@n8n/api-types';
import { computed, type ComputedRef, type MaybeRef, toValue } from 'vue';
import { useChatStore } from '../chat.store';
export function useAgent(
model: MaybeRef<ChatHubConversationModel | null | undefined>,
): ComputedRef<ChatModelDto | undefined> {
const chatStore = useChatStore();
return computed(() => {
const modelValue = toValue(model);
return modelValue ? chatStore.getAgent(modelValue) : undefined;
});
}