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

View file

@ -58,6 +58,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.6",
"@tanstack/react-virtual": "^3.13.24",
"@vscode/codicons": "^0.0.45",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
@ -88,7 +89,6 @@
"unicode-animations": "^1.0.3",
"unified": "^11.0.5",
"unist-util-visit-parents": "^6.0.2",
"use-stick-to-bottom": "^1.1.4",
"vfile": "^6.0.3",
"web-haptics": "^0.0.6"
},

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>

View file

@ -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 }}
/>
)
}

View file

@ -51,6 +51,34 @@ vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return {
cancel: () => {},
finished: Promise.resolve()
} as unknown as Animation
}
// jsdom returns 0 for offset*; the virtualizer reads those to size its
// viewport. Fall through to client* (which tests can override) or a sane
// default so virtualized items render.
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
async function wait(ms: number) {
await act(async () => {
await new Promise(resolve => window.setTimeout(resolve, ms))
@ -85,6 +113,23 @@ function assistantMessage(text: string, running = true): ThreadMessage {
} as ThreadMessage
}
function assistantErrorMessage(error: string): ThreadMessage {
return {
id: 'assistant-error-1',
role: 'assistant',
content: [],
status: { type: 'incomplete', reason: 'error', error },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantReasoningMessage(text: string): ThreadMessage {
return {
id: 'assistant-reasoning-1',
@ -232,6 +277,20 @@ function TodoHarness({ message }: { message: ThreadMessage }) {
)
}
function MessageHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function ReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantReasoningMessage(' The user is asking what this file is.')],
@ -311,6 +370,12 @@ describe('assistant-ui streaming renderer', () => {
expect(container.querySelector('[data-slot="aui_composer-clearance"]')).toBeNull()
})
it('renders assistant provider errors inline', () => {
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
})
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
const { container } = render(<StreamingHarness />)

View file

@ -0,0 +1,306 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
import { type ComponentProps, type FC, type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'
import { cn } from '@/lib/utils'
import { setThreadScrolledUp } from '@/store/thread-scroll'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
type MessageGroup = { id: string; index: number; kind: 'standalone' } | { id: string; indices: number[]; kind: 'turn' }
interface VirtualizedThreadProps {
clampToComposer: boolean
components: ThreadMessageComponents
emptyPlaceholder?: ReactNode
loadingIndicator?: ReactNode
sessionKey?: string | null
}
function buildGroups(signature: string): MessageGroup[] {
if (!signature) {
return []
}
const messages = signature.split('\n').map(row => {
const [index, id, role] = row.split(':')
return { id, index: Number(index), role }
})
const groups: MessageGroup[] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'user') {
groups.push({ id: message.id, index: message.index, kind: 'standalone' })
continue
}
const indices = [message.index]
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
indices.push(messages[++i].index)
}
groups.push({ id: message.id, indices, kind: 'turn' })
}
return groups
}
export const VirtualizedThread: FC<VirtualizedThreadProps> = ({
clampToComposer,
components,
emptyPlaceholder,
loadingIndicator,
sessionKey
}) => {
const messageSignature = useAuiState(s =>
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
)
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
const scrollerRef = useRef<HTMLDivElement | null>(null)
const virtualizer = useVirtualizer({
count: groups.length,
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
getItemKey: index => groups[index]?.id ?? index,
getScrollElement: () => scrollerRef.current,
// Seed the rect so the initial range mounts something before
// `observeElementRect` reports the real layout (it overrides this).
initialRect: { height: 600, width: 800 },
overscan: OVERSCAN
})
useThreadScrollAnchor({
enabled: !renderEmpty,
groupCount: groups.length,
scrollerRef,
sessionKey: sessionKey ?? null,
virtualizer
})
const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()
const paddingTop = virtualItems[0]?.start ?? 0
const paddingBottom = Math.max(0, totalSize - (virtualItems.at(-1)?.end ?? 0))
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
>
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-slot="aui_thread-viewport"
ref={scrollerRef}
>
{renderEmpty ? (
<div
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
data-slot="aui_thread-content"
>
{emptyPlaceholder}
</div>
) : (
<div
className={cn(
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
data-slot="aui_thread-content"
>
{/* Natural-flow virtualization: mounted items render as normal
flex siblings so `position: sticky` on the human bubble
resolves against the scroller without transform interference.
Padding spacers reserve scroll space for unmounted items. */}
<div style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
{virtualItems.map(virtualItem => {
const group = groups[virtualItem.index]
if (!group) {
return null
}
return (
<div
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
data-index={virtualItem.index}
key={virtualItem.key}
ref={virtualizer.measureElement}
>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</div>
)
})}
</div>
{loadingIndicator}
{clampToComposer && (
<div
aria-hidden="true"
className="shrink-0"
data-slot="aui_composer-clearance"
style={{ height: 'var(--thread-last-message-clearance)' }}
/>
)}
</div>
)}
</div>
</div>
)
}
interface ScrollAnchorOptions {
enabled: boolean
groupCount: number
scrollerRef: React.RefObject<HTMLDivElement | null>
sessionKey: string | null
virtualizer: Virtualizer<HTMLDivElement, Element>
}
function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, virtualizer }: ScrollAnchorOptions) {
// `armed` = parked at bottom, content growth should follow. Cleared on
// user-driven upward scroll; re-armed when they reach bottom again.
const armedRef = useRef(true)
const lastTopRef = useRef(0)
const prevSessionKeyRef = useRef(sessionKey)
const prevGroupCountRef = useRef(0)
const pinToBottom = useCallback(() => {
const el = scrollerRef.current
if (!el) {
return
}
el.scrollTop = el.scrollHeight
lastTopRef.current = el.scrollTop
}, [scrollerRef])
const jumpToBottom = useCallback(() => {
armedRef.current = true
if (groupCount > 0) {
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
}
requestAnimationFrame(() => {
if (armedRef.current) {
pinToBottom()
}
})
}, [groupCount, pinToBottom, virtualizer])
useEffect(() => () => setThreadScrolledUp(false), [])
// Track at-bottom state, dim composer when scrolled up, disarm on user
// scroll/wheel/touch.
useEffect(() => {
const el = scrollerRef.current
if (!el) {
return undefined
}
const disarm = () => {
armedRef.current = false
}
const onScroll = () => {
const top = el.scrollTop
if (top + 1 < lastTopRef.current) {
armedRef.current = false
}
lastTopRef.current = top
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
if (atBottom) {
armedRef.current = true
}
setThreadScrolledUp(!atBottom)
}
const onWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
disarm()
}
}
el.addEventListener('scroll', onScroll, { passive: true })
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchmove', disarm, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchmove', disarm)
}
}, [scrollerRef])
// Follow content growth (streaming, item measurements, loading indicator)
// while armed.
useEffect(() => {
if (!enabled) {
return undefined
}
const el = scrollerRef.current
if (!el) {
return undefined
}
const observer = new ResizeObserver(() => {
if (armedRef.current) {
pinToBottom()
}
})
observer.observe(el)
if (el.firstElementChild) {
observer.observe(el.firstElementChild)
}
return () => observer.disconnect()
}, [enabled, pinToBottom, scrollerRef])
// Jump to bottom on session change OR when an empty thread first gets
// content. Both share the same intent and the same effect.
useEffect(() => {
const sessionChanged = prevSessionKeyRef.current !== sessionKey
const becameNonEmpty = prevGroupCountRef.current === 0 && groupCount > 0
prevSessionKeyRef.current = sessionKey
prevGroupCountRef.current = groupCount
if (enabled && (sessionChanged || becameNonEmpty)) {
jumpToBottom()
}
}, [enabled, groupCount, jumpToBottom, sessionKey])
useAuiEvent('thread.runStart', jumpToBottom)
}

View file

@ -1,22 +1,18 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import {
ActionBarPrimitive,
AuiIf,
BranchPickerPrimitive,
ComposerPrimitive,
ErrorPrimitive,
MessagePrimitive,
ThreadPrimitive,
type ToolCallMessagePartProps,
useAui,
useAuiEvent,
useAuiState
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { IconPlayerStopFilled } from '@tabler/icons-react'
import {
type ClipboardEvent,
type ComponentProps,
type FC,
type FocusEvent,
type FormEvent,
@ -29,7 +25,6 @@ import {
useRef,
useState
} from 'react'
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance'
import {
@ -56,6 +51,7 @@ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
@ -85,16 +81,10 @@ import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notifyError } from '@/store/notifications'
import { setThreadScrolledUp } from '@/store/thread-scroll'
import { $voicePlayback } from '@/store/voice-playback'
type ThreadLoadingState = 'response' | 'session'
interface StickyStateFlags {
escapedFromLock: boolean
isAtBottom: boolean
}
interface MessageActionProps {
messageId: string
messageText: string
@ -129,17 +119,6 @@ const INTERRUPTED_ONLY_RE = /^_?\[interrupted\]_?$/i
const isInterruptedOnlyMessage = (text: string) => INTERRUPTED_ONLY_RE.test(text.trim())
function resetStickyState(state: StickyStateFlags) {
state.escapedFromLock = false
state.isAtBottom = true
}
function pinElementToBottom(el: HTMLElement) {
el.scrollTop = el.scrollHeight
return el.scrollTop
}
export const Thread: FC<{
clampToComposer?: boolean
cwd?: string | null
@ -161,8 +140,6 @@ export const Thread: FC<{
sessionId = null,
sessionKey
}) => {
const introHero = useAuiState(s => Boolean(intro) && s.thread.isEmpty)
const messageComponents = useMemo(
() => ({
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
@ -173,279 +150,31 @@ export const Thread: FC<{
[cwd, gateway, onBranchInNewChat, onCancel, sessionId]
)
const emptyPlaceholder = intro ? (
<div
className="flex min-h-0 w-full flex-col items-center justify-center"
style={{ paddingBottom: 'var(--composer-measured-height)' }}
>
<Intro {...intro} />
</div>
) : undefined
return (
<GeneratedImageProvider>
<ThreadPrimitive.Root className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<ThreadPrimitive.ViewportProvider>
<StickToBottom
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
initial="instant"
resize="instant"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
>
<ThreadScrollSync sessionKey={sessionKey} />
<StickToBottom.Content
className={cn(
'scroll-auto mx-auto min-h-full w-full max-w-(--composer-width) min-w-0 gap-(--conversation-turn-gap) px-6',
introHero
? 'grid grid-rows-[minmax(0,1fr)_auto] py-8'
: 'flex flex-col pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
data-slot="aui_thread-content"
scrollClassName="overflow-x-hidden overflow-y-auto overscroll-contain"
>
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>
{intro ? (
<div
className="flex min-h-0 w-full flex-col items-center justify-center"
style={{ paddingBottom: 'var(--composer-measured-height)' }}
>
<Intro {...intro} />
</div>
) : null}
</AuiIf>
<GroupedThreadMessages components={messageComponents} />
{loading === 'response' && <ResponseLoadingIndicator />}
{clampToComposer && (
<div
aria-hidden="true"
className="shrink-0"
style={{ height: 'var(--thread-last-message-clearance)' }}
/>
)}
</StickToBottom.Content>
</StickToBottom>
</ThreadPrimitive.ViewportProvider>
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<VirtualizedThread
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
</ThreadPrimitive.Root>
</div>
</GeneratedImageProvider>
)
}
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
function GroupedThreadMessages({ components }: { components: ThreadMessageComponents }) {
const messageSignature = useAuiState(s =>
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
)
const groups = useMemo(() => {
const messages = messageSignature
? messageSignature.split('\n').map(row => {
const [index, id, role] = row.split(':')
return { id, index: Number(index), role }
})
: []
const result: Array<{ id: string; indices: number[]; role: string }> = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'user') {
result.push({ id: message.id, indices: [message.index], role: message.role })
continue
}
const indices = [message.index]
let j = i + 1
while (j < messages.length && messages[j].role !== 'user') {
indices.push(messages[j].index)
j++
}
result.push({ id: message.id, indices, role: 'turn' })
i = j - 1
}
return result
}, [messageSignature])
return (
<>
{groups.map(group =>
group.role === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
key={group.id}
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.indices[0]} key={group.id} />
)
)}
</>
)
}
const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) => {
const { scrollRef, isAtBottom, state } = useStickToBottomContext()
const sessionKeyRef = useRef<string | null>(sessionKey ?? null)
const armedRef = useRef<ScrollBehavior | null>(null)
const pinRafRef = useRef<number | null>(null)
const previousScrollTopRef = useRef(0)
const suppressNextScrollEventRef = useRef(false)
const messageCount = useAuiState(s => s.thread.messages.length)
const prevMessageCountRef = useRef(messageCount)
useEffect(() => {
setThreadScrolledUp(!isAtBottom)
}, [isAtBottom])
useEffect(() => {
return () => {
setThreadScrolledUp(false)
}
}, [])
const armAndPin = useCallback(
(behavior: ScrollBehavior) => {
const el = scrollRef.current
if (!el) {
return
}
armedRef.current = behavior
resetStickyState(state)
suppressNextScrollEventRef.current = true
previousScrollTopRef.current = pinElementToBottom(el)
},
[scrollRef, state]
)
useEffect(() => {
const el = scrollRef.current
if (!el) {
return
}
const observer = new ResizeObserver(() => {
if (pinRafRef.current !== null) {
return
}
pinRafRef.current = window.requestAnimationFrame(() => {
pinRafRef.current = null
if (!armedRef.current) {
return
}
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight)
if (distance < 2) {
armedRef.current = null
return
}
suppressNextScrollEventRef.current = true
previousScrollTopRef.current = pinElementToBottom(el)
})
})
observer.observe(el)
const content = el.firstElementChild
if (content) {
observer.observe(content)
}
return () => {
observer.disconnect()
if (pinRafRef.current !== null) {
window.cancelAnimationFrame(pinRafRef.current)
pinRafRef.current = null
}
}
}, [scrollRef])
useEffect(() => {
const el = scrollRef.current
if (!el) {
return
}
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) {
armedRef.current = null
}
}
const onTouch = () => {
armedRef.current = null
}
const onScroll = () => {
const currentTop = el.scrollTop
if (suppressNextScrollEventRef.current) {
suppressNextScrollEventRef.current = false
previousScrollTopRef.current = currentTop
return
}
if (currentTop + 1 < previousScrollTopRef.current) {
armedRef.current = null
}
previousScrollTopRef.current = currentTop
}
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchmove', onTouch, { passive: true })
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchmove', onTouch)
el.removeEventListener('scroll', onScroll)
}
}, [scrollRef])
useEffect(() => {
const next = sessionKey ?? null
if (sessionKeyRef.current === next) {
return
}
sessionKeyRef.current = next
prevMessageCountRef.current = 0
armAndPin('auto')
}, [armAndPin, sessionKey])
useEffect(() => {
const prev = prevMessageCountRef.current
prevMessageCountRef.current = messageCount
if (prev === 0 && messageCount > 0) {
armAndPin('auto')
}
}, [armAndPin, messageCount])
useAuiEvent('thread.runStart', () => {
armAndPin('instant')
})
return null
}
function pickPrimaryPreviewTarget(targets: string[]): string[] {
if (targets.length <= 1) {
return targets
@ -1108,7 +837,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
const [focusRequestId, setFocusRequestId] = useState(0)
const [submitting, setSubmitting] = useState(false)
const expanded = draft.includes('\n') || draft.length > 96
const expanded = draft.includes('\n')
const canSubmit = draft.trim().length > 0
const at = useAtCompletions({ cwd, gateway, sessionId })
const slash = useSlashCompletions({ gateway })

View file

@ -626,21 +626,8 @@ canvas {
}
}
/* Last thread row with a message root — avoids composer overlap (Chromium/Electron). */
[data-slot='aui_thread-content']
> :nth-last-child(
1
of
:is(
[data-slot='aui_assistant-message-root'],
[data-slot='aui_user-message-root'],
[data-slot='aui_system-message-root'],
[data-slot='aui_edit-composer-root'],
[data-slot='aui_response-loading']
)
) {
margin-bottom: var(--thread-last-message-clearance);
}
/* Bottom clearance lives on [data-slot='aui_composer-clearance']
virtualized items unmount, so :nth-last-child can't fire reliably. */
[data-slot='aui_assistant-message-content'] {
padding-left: var(--message-text-indent);

36
package-lock.json generated
View file

@ -83,6 +83,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.6",
"@tanstack/react-virtual": "^3.13.24",
"@vscode/codicons": "^0.0.45",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
@ -113,7 +114,6 @@
"unicode-animations": "^1.0.3",
"unified": "^11.0.5",
"unist-util-visit-parents": "^6.0.2",
"use-stick-to-bottom": "^1.1.4",
"vfile": "^6.0.3",
"web-haptics": "^0.0.6"
},
@ -7846,6 +7846,31 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.24",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
"dependencies": {
"@tanstack/virtual-core": "3.14.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@ -21389,15 +21414,6 @@
}
}
},
"node_modules/use-stick-to-bottom": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/use-stick-to-bottom/-/use-stick-to-bottom-1.1.4.tgz",
"integrity": "sha512-2w/lydkrwhWMv1vCaEhYbzMDhgbwIodHpAHPV0/xKJErRkbjDEUe1EWmvr6Fwb+qhiERjc1EWgAEZaSaF69CpA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",