diff --git a/apps/desktop/src/app/chat/sidebar/virtual-session-list.test.tsx b/apps/desktop/src/app/chat/sidebar/virtual-session-list.test.tsx new file mode 100644 index 0000000000..3dec464df1 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/virtual-session-list.test.tsx @@ -0,0 +1,85 @@ +import { cleanup, render } from '@testing-library/react' +import type * as React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { SidebarListRow } from '@/lib/session-date-groups' + +import { VirtualSessionList } from './virtual-session-list' + +const virtualizer = { + getTotalSize: () => 68, + getVirtualItems: () => [ + { end: 26, index: 0, start: 0 }, + { end: 68, index: 1, start: 26 } + ], + measureElement: vi.fn() +} + +vi.mock('@dnd-kit/sortable', () => ({ useSortable: vi.fn() })) +vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: vi.fn() } } })) +vi.mock('@tanstack/react-virtual', () => ({ useVirtualizer: () => virtualizer })) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + sidebar: { + dateDivider: { + earlierThisMonth: 'Earlier this month', + lastMonth: 'Last month', + lastWeek: 'Last week', + older: 'Older', + today: 'Today', + yesterday: 'Yesterday' + } + } + } + }) +})) + +vi.mock('./chrome', () => ({ + SidebarDateDivider: ({ label, ...props }: { label: string } & React.ComponentProps<'div'>) => ( +
+ ) +})) + +vi.mock('./session-row', () => ({ SidebarSessionRow: () => null })) + +afterEach(cleanup) + +const rows: SidebarListRow[] = [ + { key: 'today', kind: 'divider', label: 'Today' }, + { key: 'older', kind: 'divider', label: 'Older' } +] + +const noop = () => {} + +describe('VirtualSessionList', () => { + it('positions measured rows independently within a total-size spacer', () => { + const { getByTestId } = render( + + ) + + const firstItem = getByTestId('divider-Today').parentElement + const secondItem = getByTestId('divider-Older').parentElement + const spacer = firstItem?.parentElement + + expect(firstItem?.dataset.index).toBe('0') + expect(firstItem?.style.position).toBe('absolute') + expect(firstItem?.style.transform).toBe('translateY(0px)') + expect(secondItem?.dataset.index).toBe('1') + expect(secondItem?.style.transform).toBe('translateY(26px)') + expect(spacer?.className).toBe('relative') + expect(spacer?.style.height).toBe('68px') + expect(spacer?.style.paddingTop).toBe('') + expect(spacer?.style.paddingBottom).toBe('') + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx index f99edb5f02..2a10fd9a3f 100644 --- a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +++ b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx @@ -2,7 +2,7 @@ import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { useVirtualizer } from '@tanstack/react-virtual' import type * as React from 'react' -import { type FC, useCallback, useRef } from 'react' +import { type FC, useRef } from 'react' import type { SessionInfo } from '@/hermes' import { useI18n } from '@/i18n' @@ -88,8 +88,6 @@ export const VirtualSessionList: FC = ({ const virtualItems = virtualizer.getVirtualItems() const totalSize = virtualizer.getTotalSize() - const paddingTop = virtualItems[0]?.start ?? 0 - const paddingBottom = Math.max(0, totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)) const rows = virtualItems.map(virtualItem => { const row = listRows[virtualItem.index] @@ -98,16 +96,23 @@ export const VirtualSessionList: FC = ({ return null } + const itemStyle: React.CSSProperties = { + left: 0, + position: 'absolute', + top: 0, + transform: `translateY(${virtualItem.start}px)`, + width: '100%' + } + // Dividers are non-sortable, self-measured rows interleaved with sessions. if (row.kind === 'divider') { return ( - +
+ +
) } @@ -129,21 +134,13 @@ export const VirtualSessionList: FC = ({ } return reorderable ? ( - +
+ +
) : ( - +
+ +
) }) @@ -164,41 +161,25 @@ export const VirtualSessionList: FC = ({ )} ref={scrollerRef} > -
- {rows} -
+
{rows}
) } interface VirtualSortableRowProps { - index: number - measureRef: (node: Element | null) => void rowProps: SessionRowCommonProps session: SessionInfo } -function VirtualSortableRow({ index, measureRef, rowProps, session }: VirtualSortableRowProps) { +function VirtualSortableRow({ rowProps, session }: VirtualSortableRowProps) { const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id: session.id }) - // Merge dnd-kit's setNodeRef with the virtualizer's measureElement so - // the row participates in both DnD hit-testing and TanStack height - // measurement. - const refMerged = useCallback( - (node: HTMLDivElement | null) => { - setNodeRef(node) - measureRef(node) - }, - [measureRef, setNodeRef] - ) - return (