feat(desktop): virtualize chat thread + sidebar via TanStack Virtual
Replaces `use-stick-to-bottom` and per-row session rendering with `@tanstack/react-virtual`, matching what Cursor uses. Chat thread (`thread-virtualizer.tsx`): - Natural-flow virtualization (padding spacers, not absolute items) so `position: sticky` on the human bubble still resolves cleanly against the scroller. - Custom at-bottom anchor: pins when armed, disarms on user-driven upward scroll, re-arms at bottom, jumps on session switch + `thread.runStart`. - Loading indicator and `--thread-last-message-clearance` move to a real `[data-slot=aui_composer-clearance]` node; drops the brittle `:nth-last-child(1 of …)` rule that can't fire reliably under virtualization. Sidebar (`virtual-session-list.tsx`): - Flat agents list virtualizes at >=25 rows; pinned and workspace-grouped paths stay direct-render. - `SortableContext` keeps all IDs; only the window mounts; dnd-kit's `setNodeRef` is merged with `virtualizer.measureElement` so rows participate in both DnD hit-testing and TanStack measurement. Drops `use-stick-to-bottom`. Streaming test gets a global `offsetWidth/offsetHeight` stub so the virtualizer's viewport sizing works in jsdom; the scroll-up-doesn't-pull-back invariant still passes.
This commit is contained in:
@@ -62,6 +62,9 @@ import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
import { VirtualSessionList } from './virtual-session-list'
|
||||
|
||||
const VIRTUALIZE_THRESHOLD = 25
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{ id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' },
|
||||
@@ -539,6 +542,8 @@ function SidebarSessionsSection({
|
||||
renderRows(items)
|
||||
)
|
||||
|
||||
const flatVirtualized = !showEmptyState && !groups?.length && sessions.length >= VIRTUALIZE_THRESHOLD
|
||||
|
||||
let inner: React.ReactNode
|
||||
|
||||
if (showEmptyState) {
|
||||
@@ -559,6 +564,19 @@ function SidebarSessionsSection({
|
||||
) : (
|
||||
groupNodes
|
||||
)
|
||||
} else if (flatVirtualized) {
|
||||
inner = (
|
||||
<VirtualSessionList
|
||||
activeSessionId={activeSessionId}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
onTogglePin={onTogglePin}
|
||||
pinned={pinned}
|
||||
sessions={sessions}
|
||||
sortable={sortable}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
inner = renderSessionList(sessions)
|
||||
}
|
||||
@@ -572,11 +590,15 @@ function SidebarSessionsSection({
|
||||
inner
|
||||
)
|
||||
|
||||
// The virtualizer owns its own scroller, so suppress the wrapper's overflow
|
||||
// to avoid a double scroll container.
|
||||
const resolvedContentClassName = cn(contentClassName, flatVirtualized && 'overflow-y-visible')
|
||||
|
||||
return (
|
||||
<SidebarGroup className={rootClassName}>
|
||||
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
|
||||
{open && (
|
||||
<SidebarGroupContent className={contentClassName}>
|
||||
<SidebarGroupContent className={resolvedContentClassName}>
|
||||
{body}
|
||||
{footer}
|
||||
</SidebarGroupContent>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { type FC, useCallback, useMemo, useRef } from 'react'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
|
||||
interface SessionRowCommonProps {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
}
|
||||
|
||||
interface VirtualSessionListProps {
|
||||
activeSessionId: null | string
|
||||
className?: string
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
pinned: boolean
|
||||
sessions: SessionInfo[]
|
||||
sortable: boolean
|
||||
workingSessionIdSet: Set<string>
|
||||
}
|
||||
|
||||
const ROW_ESTIMATE_PX = 28
|
||||
const OVERSCAN_ROWS = 12
|
||||
|
||||
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
activeSessionId,
|
||||
className,
|
||||
onDeleteSession,
|
||||
onResumeSession,
|
||||
onTogglePin,
|
||||
pinned,
|
||||
sessions,
|
||||
sortable,
|
||||
workingSessionIdSet
|
||||
}) => {
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
const ids = useMemo(() => sessions.map(s => s.id), [sessions])
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: sessions.length,
|
||||
estimateSize: () => ROW_ESTIMATE_PX,
|
||||
getItemKey: index => sessions[index]?.id ?? index,
|
||||
getScrollElement: () => scrollerRef.current,
|
||||
// jsdom-friendly default; the real rect takes over on first observe.
|
||||
initialRect: { height: 600, width: 240 },
|
||||
overscan: OVERSCAN_ROWS
|
||||
})
|
||||
|
||||
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 session = sessions[virtualItem.index]
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
const commonProps: SessionRowCommonProps = {
|
||||
isPinned: pinned,
|
||||
isSelected: session.id === activeSessionId,
|
||||
isWorking: workingSessionIdSet.has(session.id),
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(session.id),
|
||||
onResume: () => onResumeSession(session.id)
|
||||
}
|
||||
|
||||
return sortable ? (
|
||||
<VirtualSortableRow
|
||||
index={virtualItem.index}
|
||||
key={session.id}
|
||||
measureRef={virtualizer.measureElement}
|
||||
rowProps={commonProps}
|
||||
session={session}
|
||||
/>
|
||||
) : (
|
||||
<SidebarSessionRow
|
||||
{...commonProps}
|
||||
data-index={virtualItem.index}
|
||||
key={session.id}
|
||||
ref={virtualizer.measureElement}
|
||||
session={session}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const list = (
|
||||
<div className={cn('relative min-h-0 flex-1 overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
|
||||
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
|
||||
{rows}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return sortable ? (
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{list}
|
||||
</SortableContext>
|
||||
) : (
|
||||
list
|
||||
)
|
||||
}
|
||||
|
||||
interface VirtualSortableRowProps {
|
||||
index: number
|
||||
measureRef: (node: Element | null) => void
|
||||
rowProps: SessionRowCommonProps
|
||||
session: SessionInfo
|
||||
}
|
||||
|
||||
function VirtualSortableRow({ index, measureRef, 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 (
|
||||
<SidebarSessionRow
|
||||
{...rowProps}
|
||||
data-index={index}
|
||||
dragging={isDragging}
|
||||
dragHandleProps={{ ...attributes, ...listeners }}
|
||||
ref={refMerged}
|
||||
reorderable
|
||||
session={session}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user