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:
Brooklyn Nicholson
2026-05-16 21:17:36 -05:00
parent 8acd825afc
commit 64ab17182a
8 changed files with 592 additions and 318 deletions
+23 -1
View File
@@ -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 }}
/>
)
}