diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 08effab6e0..5f08069a42 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -58,7 +58,12 @@ const MeasuredRow: FC = memo( ({ cache, rowKey, parent, index, style, children }) => ( {({ registerChild }) => ( -
} style={style} className="px-3"> +
} + style={style} + className="px-3" + data-testid="convo-list-row" + > {children}
)} @@ -341,6 +346,24 @@ const Conversations: FC = ({ return () => cancelAnimationFrame(frameId); }, [flattenedItems, containerRef]); + /** CellMeasurerCache(fixedWidth) keys heights by row, not width. Rows first measured + * at a narrow width (e.g. mid expand-animation from a collapsed sidebar) would + * otherwise persist their wrapped heights — re-measure when the width changes. */ + const measuredWidthRef = useRef(0); + useEffect(() => { + if (listWidth === 0 || listWidth === measuredWidthRef.current) { + return; + } + measuredWidthRef.current = listWidth; + const frameId = requestAnimationFrame(() => { + cache.clearAll(); + if (containerRef.current && 'recomputeRowHeights' in containerRef.current) { + containerRef.current.recomputeRowHeights(0); + } + }); + return () => cancelAnimationFrame(frameId); + }, [listWidth, cache, containerRef]); + const rowRenderer = useCallback( ({ index, key, parent, style }) => { const item = flattenedItems[index]; diff --git a/e2e/specs/mock/db.ts b/e2e/specs/mock/db.ts new file mode 100644 index 0000000000..4646db613d --- /dev/null +++ b/e2e/specs/mock/db.ts @@ -0,0 +1,69 @@ +import fs from 'fs'; +import path from 'path'; +import { MongoClient } from 'mongodb'; +import type { Db } from 'mongodb'; + +const DEFAULT_MONGO_URI = 'mongodb://127.0.0.1:27017/LibreChat-e2e'; +/** Written by e2e/setup/start-server.js on boot; authoritative even for memory MongoDB. */ +const RUNTIME_ENV_PATH = path.resolve(__dirname, '../.test-results/runtime-env.json'); + +function getMongoUri(): string { + try { + const env = JSON.parse(fs.readFileSync(RUNTIME_ENV_PATH, 'utf8')) as { MONGO_URI?: string }; + if (env.MONGO_URI) { + return env.MONGO_URI; + } + } catch { + /* fall through to env/default */ + } + return process.env.MONGO_URI ?? DEFAULT_MONGO_URI; +} + +/** Connect to the e2e MongoDB, run `fn`, and always close the client. */ +export async function withMongo(fn: (db: Db) => Promise): Promise { + const client = new MongoClient(getMongoUri()); + await client.connect(); + try { + return await fn(client.db()); + } finally { + await client.close(); + } +} + +export interface SeedConvo { + conversationId: string; + title: string; + /** Drives the sidebar date group ("Today", "Previous 7 days", ...). */ + updatedAt: Date; +} + +/** + * Inserts conversation documents directly (bypassing mongoose timestamps) so their + * `updatedAt` can be backdated into specific sidebar date groups. + */ +export async function seedConversations(userEmail: string, convos: SeedConvo[]): Promise { + await withMongo(async (db) => { + const user = await db.collection('users').findOne({ email: userEmail }); + if (!user) { + throw new Error(`E2E seed: user "${userEmail}" not found`); + } + const userId = user._id.toString(); + const docs = convos.map((convo) => ({ + conversationId: convo.conversationId, + title: convo.title, + user: userId, + endpoint: 'openAI', + isArchived: false, + createdAt: convo.updatedAt, + updatedAt: convo.updatedAt, + __v: 0, + })); + await db.collection('conversations').insertMany(docs); + }); +} + +export async function deleteConversations(conversationIds: string[]): Promise { + await withMongo(async (db) => { + await db.collection('conversations').deleteMany({ conversationId: { $in: conversationIds } }); + }); +} diff --git a/e2e/specs/mock/sidebar.spec.ts b/e2e/specs/mock/sidebar.spec.ts index e622145d4b..16a80e5cce 100644 --- a/e2e/specs/mock/sidebar.spec.ts +++ b/e2e/specs/mock/sidebar.spec.ts @@ -1,5 +1,9 @@ +import { randomUUID } from 'crypto'; import { expect, test } from '@playwright/test'; import type { Page } from '@playwright/test'; +import { getE2EUser } from '../../setup/user'; +import { deleteConversations, seedConversations } from './db'; +import type { SeedConvo } from './db'; /** Size of the virtualized chat list grid vs. its measured container. */ const sizes = (page: Page) => @@ -80,3 +84,95 @@ test.describe('sidebar chat list', () => { expect(shrunken.gridH).toBeLessThan(reopened.gridH); }); }); + +/** + * Regression: expanding the sidebar from a collapsed reload first measured the + * virtualized conversation rows mid-animation (narrow width), so date-group headers + * ("Previous 7 days", ...) wrapped and cached oversized heights. With `fixedWidth` + * the cache never re-measured at full width, leaving a gap between each header's + * text and the row beneath it. + */ +const DAY_MS = 24 * 60 * 60 * 1000; +const userEmail = getE2EUser().email; + +/** + * The header `

` is single-line; its row wrapper should hug it (just the small + * top margin). A stale wrapped measurement inflates the wrapper well past this. + */ +const MAX_HEADER_PADDING = 24; + +const GROUPS = [ + { label: 'Today', offsetDays: 0 }, + { label: 'Previous 7 days', offsetDays: 3 }, + { label: 'Previous 30 days', offsetDays: 15 }, +] as const; + +function buildSeed(): SeedConvo[] { + const now = Date.now(); + return GROUPS.flatMap((group, groupIndex) => + [0, 1].map((n) => ({ + conversationId: randomUUID(), + title: `E2E ${group.label} #${n}`, + // Subtract minutes so "today" entries stay strictly in the past and ordered. + updatedAt: new Date(now - group.offsetDays * DAY_MS - (groupIndex + n + 1) * 60_000), + })), + ); +} + +// The DateLabel

exposes an aria-label ("Chats from {date}"), so its accessible +// name is the full phrase, not the visible group label. +const heading = (page: Page, label: string) => + page.getByRole('heading', { name: `Chats from ${label}`, exact: true }); + +const headerRow = (page: Page, label: string) => + page.getByTestId('convo-list-row').filter({ has: heading(page, label) }); + +test.describe('sidebar conversation grouping', () => { + let seeded: SeedConvo[] = []; + + test.afterEach(async () => { + if (seeded.length) { + await deleteConversations(seeded.map((c) => c.conversationId)); + seeded = []; + } + }); + + test('keeps date-group spacing tight after expanding from a collapsed reload', async ({ + page, + }) => { + test.setTimeout(60000); + seeded = buildSeed(); + await seedConversations(userEmail, seeded); + + // Default load is expanded: confirm the seeded conversations render at all. + await page.goto('/c/new', { timeout: 10000 }); + await expect(page.getByTestId('convo-item').first()).toBeVisible({ timeout: 15000 }); + + // Force the collapsed start state, then reload so the list mounts collapsed. + await page.evaluate(() => + localStorage.setItem('unifiedSidebarExpanded', JSON.stringify(false)), + ); + await page.reload({ timeout: 10000 }); + await expect(page.getByTestId('open-sidebar-button')).toBeVisible(); + + // Expand: rows first measure during the width animation — the regression window. + await page.getByTestId('open-sidebar-button').click(); + await expect(page.getByTestId('close-sidebar-button')).toBeVisible(); + await expect(page.getByTestId('convo-item').first()).toBeVisible({ timeout: 15000 }); + + // Each header row must hug its single-line text, not retain an inflated height. + for (const { label } of GROUPS) { + const row = headerRow(page, label); + await expect(row).toBeVisible({ timeout: 10000 }); + const rowBox = await row.boundingBox(); + const textBox = await heading(page, label).boundingBox(); + expect(rowBox, `row "${label}" should have a bounding box`).not.toBeNull(); + expect(textBox, `heading "${label}" should have a bounding box`).not.toBeNull(); + const padding = rowBox!.height - textBox!.height; + expect( + padding, + `header "${label}" row (${rowBox!.height}px) should hug its text (${textBox!.height}px)`, + ).toBeLessThan(MAX_HEADER_PADDING); + } + }); +});