Files
hermes-agent/apps/desktop/src/components/assistant-ui/streaming.test.tsx
T
Brooklyn Nicholson 64ab17182a 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.
2026-05-16 21:17:36 -05:00

501 lines
14 KiB
TypeScript

import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useEffect, useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Thread } from './thread'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
const resizeObservers = new Set<TestResizeObserver>()
class TestResizeObserver {
private target: Element | null = null
constructor(private readonly callback: ResizeObserverCallback) {
resizeObservers.add(this)
}
observe(target: Element) {
this.target = target
}
unobserve() {}
disconnect() {
resizeObservers.delete(this)
}
trigger(height: number) {
if (!this.target) {
return
}
this.callback(
[
{
contentRect: { height } as DOMRectReadOnly,
target: this.target
} as ResizeObserverEntry
],
this as unknown as ResizeObserver
)
}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
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))
})
}
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'Stream a response' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(text: string, running = true): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text }],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} 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',
role: 'assistant',
content: [{ type: 'reasoning', text }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantMultiReasoningMessage(texts: string[]): ThreadMessage {
return {
id: 'assistant-reasoning-multi-1',
role: 'assistant',
content: texts.map(text => ({ type: 'reasoning', text })),
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantTodoMessage(
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>,
running = true
): ThreadMessage {
const suffix = todos.map(todo => `${todo.id}:${todo.status}`).join('|') || 'empty'
return {
id: `assistant-todo-${running ? 'running' : 'done'}-${suffix}`,
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'todo-1',
toolName: 'todo',
args: { todos },
argsText: JSON.stringify({ todos }),
...(running ? {} : { result: { todos } })
}
],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantReasoningTodoMessage(
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>
): ThreadMessage {
return {
id: 'assistant-reasoning-todo-1',
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Let me make a quick todo list.' },
{
type: 'tool-call',
toolCallId: 'todo-1',
toolName: 'todo',
args: { todos },
argsText: JSON.stringify({ todos }),
result: { todos }
},
{ type: 'text', text: 'Done — fake list created.' }
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function StreamingHarness() {
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
const [isRunning, setIsRunning] = useState(true)
useEffect(() => {
const first = window.setTimeout(() => {
setMessages([userMessage(), assistantMessage('first chunk')])
}, 50)
const second = window.setTimeout(() => {
setMessages([userMessage(), assistantMessage('first chunk second chunk')])
}, 500)
const complete = window.setTimeout(() => {
setMessages([userMessage(), assistantMessage('first chunk second chunk', false)])
setIsRunning(false)
}, 700)
return () => {
window.clearTimeout(first)
window.clearTimeout(second)
window.clearTimeout(complete)
}
}, [])
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread loading={isRunning && messages.at(-1)?.role !== 'assistant' ? 'response' : undefined} />
</AssistantRuntimeProvider>
)
}
function TodoHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: message.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
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.')],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function GroupedReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantMultiReasoningMessage([' First thought.', ' Second thought.'])],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function IntroHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread intro={{ personality: 'default', seed: 1 }} />
</AssistantRuntimeProvider>
)
}
describe('assistant-ui streaming renderer', () => {
beforeEach(() => {
resizeObservers.clear()
})
it('renders assistant text incrementally before completion', async () => {
const { container } = render(<StreamingHarness />)
expect(screen.getByRole('status', { name: 'Hermes is loading a response' })).toBeTruthy()
await wait(80)
await waitFor(() => {
expect(container.textContent).toContain('first chunk')
})
expect(container.textContent).not.toContain('second chunk')
expect(screen.queryByRole('status', { name: 'Hermes is loading a response' })).toBeNull()
await wait(500)
await waitFor(() => {
expect(container.textContent).toContain('first chunk second chunk')
})
await wait(250)
await waitFor(() => {
expect(container.textContent).toContain('first chunk second chunk')
})
})
it('does not render composer clearance for intro-only threads', () => {
const { container } = render(<IntroHarness />)
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 />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
await wait(0)
await act(async () => {
fireEvent.wheel(viewport, { deltaY: -120 })
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('renders reasoning text without a leading token space', () => {
const { container } = render(<ReasoningHarness />)
fireEvent.click(screen.getByRole('button', { name: /thinking/i }))
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe(
'The user is asking what this file is.'
)
})
it('groups consecutive reasoning parts under one thinking disclosure', () => {
const { container } = render(<GroupedReasoningHarness />)
const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]')
expect(disclosures.length).toBe(1)
fireEvent.click(disclosures[0].querySelector('button')!)
const reasoningParts = container.querySelectorAll('[data-slot="aui_reasoning-text"]')
expect(reasoningParts.length).toBe(2)
expect(reasoningParts[0]?.textContent).toBe('First thought.')
expect(reasoningParts[1]?.textContent).toBe('Second thought.')
})
it('renders live todo rows during a running turn', () => {
const { container } = render(
<TodoHarness
message={assistantTodoMessage([
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
{ content: 'Boil water', id: 'boil', status: 'in_progress' }
])}
/>
)
const ui = within(container)
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeTruthy()
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
expect(ui.getByText('Gather ingredients')).toBeTruthy()
expect(ui.queryByText(/pending/i)).toBeNull()
expect(ui.queryByRole('button', { name: /todo/i })).toBeNull()
})
it('renders archived todos after turn completion regardless of pending state', () => {
const first = render(
<TodoHarness message={assistantTodoMessage([{ content: 'Boil water', id: 'boil', status: 'pending' }], false)} />
)
const ui = within(first.container)
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
first.unmount()
const second = render(
<TodoHarness
message={assistantTodoMessage([{ content: 'Serve latte', id: 'serve', status: 'completed' }], false)}
/>
)
const archivedUi = within(second.container)
expect(archivedUi.getAllByText('Serve latte').length).toBeGreaterThan(0)
})
it('hoists todo outside the thinking disclosure when reasoning is present', () => {
const { container } = render(
<TodoHarness
message={assistantReasoningTodoMessage([
{ content: 'Buy oats', id: 'oats', status: 'completed' },
{ content: "Reply to Sam's email", id: 'email', status: 'in_progress' }
])}
/>
)
const todoPanel = container.querySelector('[data-slot="aui_todo-hoisted"]')
const thinkingDisclosure = container.querySelector('[data-slot="aui_thinking-disclosure"]')
expect(todoPanel).toBeTruthy()
expect(thinkingDisclosure).toBeTruthy()
expect(Boolean(thinkingDisclosure?.contains(todoPanel as Node))).toBe(false)
})
})