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 (