mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-18 09:51:59 +00:00
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:
parent
8acd825afc
commit
64ab17182a
8 changed files with 592 additions and 318 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
149
apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx
Normal file
149
apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx
Normal 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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />)
|
||||
|
||||
|
|
|
|||
306
apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx
Normal file
306
apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx
Normal 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)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
36
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue