mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🪟 fix: Re-measure Sidebar Chat List on Width Change to Fix Date-Group Spacing (#13981)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
* 🪟 fix: Re-measure sidebar chat list on width change to fix date-group spacing When the sidebar is expanded from a collapsed reload, virtualized rows first measure mid-animation at a narrow width, so date-group headers wrap and cache an inflated height. CellMeasurerCache(fixedWidth) keys heights by row, not width, so the stale height persists once full width is reached — leaving gaps under headers. Invalidate the measurement cache and recompute row heights whenever the measured list width changes. Adds a Playwright mock e2e (seeds backdated convos across date groups via a new db helper) that fails without the fix and passes with it. * 🧪 test: Harden sidebar e2e (runtime-env path, midnight-safe seed, convo isolation) Addresses Codex review on PR #13981: - db.ts honors E2E_RUNTIME_ENV_PATH when locating the runtime Mongo URI. - Seed timestamps anchor on local noon so the Today group stays in-day near midnight. - Clear the shared user's conversations before seeding so later date-group headers are not pushed below the virtualized viewport by other specs' leftover chats.
This commit is contained in:
parent
c948606a8c
commit
186b738d2d
3 changed files with 217 additions and 1 deletions
|
|
@ -58,7 +58,12 @@ const MeasuredRow: FC<MeasuredRowProps> = memo(
|
|||
({ cache, rowKey, parent, index, style, children }) => (
|
||||
<CellMeasurer cache={cache} columnIndex={0} key={rowKey} parent={parent} rowIndex={index}>
|
||||
{({ registerChild }) => (
|
||||
<div ref={registerChild as React.LegacyRef<HTMLDivElement>} style={style} className="px-3">
|
||||
<div
|
||||
ref={registerChild as React.LegacyRef<HTMLDivElement>}
|
||||
style={style}
|
||||
className="px-3"
|
||||
data-testid="convo-list-row"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -341,6 +346,24 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
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];
|
||||
|
|
|
|||
91
e2e/specs/mock/db.ts
Normal file
91
e2e/specs/mock/db.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
* e2e/setup/start-server.js writes the active Mongo URI (memory-Mongo port included)
|
||||
* here on boot. It honors `E2E_RUNTIME_ENV_PATH`, so resolve the same override the
|
||||
* server used before falling back to the default location.
|
||||
*/
|
||||
function getRuntimeEnvPath(): string {
|
||||
return (
|
||||
process.env.E2E_RUNTIME_ENV_PATH ?? path.resolve(__dirname, '../.test-results/runtime-env.json')
|
||||
);
|
||||
}
|
||||
|
||||
function getMongoUri(): string {
|
||||
try {
|
||||
const env = JSON.parse(fs.readFileSync(getRuntimeEnvPath(), '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;
|
||||
}
|
||||
|
||||
async function resolveUserId(db: Db, userEmail: string): Promise<string> {
|
||||
const user = await db.collection('users').findOne({ email: userEmail });
|
||||
if (!user) {
|
||||
throw new Error(`E2E seed: user "${userEmail}" not found`);
|
||||
}
|
||||
return user._id.toString();
|
||||
}
|
||||
|
||||
/** Connect to the e2e MongoDB, run `fn`, and always close the client. */
|
||||
export async function withMongo<T>(fn: (db: Db) => Promise<T>): Promise<T> {
|
||||
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<void> {
|
||||
await withMongo(async (db) => {
|
||||
const userId = await resolveUserId(db, userEmail);
|
||||
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<void> {
|
||||
await withMongo(async (db) => {
|
||||
await db.collection('conversations').deleteMany({ conversationId: { $in: conversationIds } });
|
||||
});
|
||||
}
|
||||
|
||||
/** Clears every conversation for the user so the seeded date groups are not pushed
|
||||
* below the virtualized viewport by rows left behind by other specs. */
|
||||
export async function clearUserConversations(userEmail: string): Promise<void> {
|
||||
await withMongo(async (db) => {
|
||||
const userId = await resolveUserId(db, userEmail);
|
||||
await db.collection('conversations').deleteMany({ user: userId });
|
||||
});
|
||||
}
|
||||
|
|
@ -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 { clearUserConversations, 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,101 @@ 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 `<h2>` 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[] {
|
||||
// Anchor on local noon so the zero-day group stays inside "today" even when the
|
||||
// spec runs right after midnight; second-level offsets keep ordering within a day.
|
||||
const noon = new Date();
|
||||
noon.setHours(12, 0, 0, 0);
|
||||
const base = noon.getTime();
|
||||
return GROUPS.flatMap((group, groupIndex) =>
|
||||
[0, 1].map((n) => ({
|
||||
conversationId: randomUUID(),
|
||||
title: `E2E ${group.label} #${n}`,
|
||||
updatedAt: new Date(base - group.offsetDays * DAY_MS - (groupIndex + n) * 1000),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// The DateLabel <h2> 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);
|
||||
// Isolate from rows other specs leave on the shared user, which could otherwise
|
||||
// push the later date-group headers below the virtualized viewport.
|
||||
await clearUserConversations(userEmail);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue