Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/renderer/src/components/chat/ChatView.dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ vi.mock('@/lib/ipc', () => ({
}))

const ROW_HEIGHT = 88
const MESSAGE_LINE_HEIGHT = 20
const VIEWPORT_HEIGHT = 100
const VIEWPORT_WIDTH = 800

const resizeObserverHeights = new WeakMap<Element, number>()

class TestResizeObserver {
private readonly callback: ResizeObserverCallback

Expand All @@ -45,6 +48,7 @@ class TestResizeObserver {
observe(target: Element): void {
const height = target instanceof HTMLElement ? target.offsetHeight : ROW_HEIGHT
const width = target instanceof HTMLElement ? target.offsetWidth : VIEWPORT_WIDTH
resizeObserverHeights.set(target, height)
queueMicrotask(() => {
this.callback([{
target,
Expand Down Expand Up @@ -79,6 +83,27 @@ function getVirtualRows(container: HTMLElement = document.body): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>('[data-testid="chat-virtual-row"]'))
}

function getVirtualRowStart(row: HTMLElement): number {
const match = row.style.transform.match(/^translateY\((-?[\d.]+)px\)$/)
return match ? Number(match[1]) : Number.NaN
}

function expectMountedRowsNotToOverlap(container: HTMLElement): void {
const rows = getVirtualRows(container).sort(
(left, right) => Number(left.dataset.index) - Number(right.dataset.index)
)

for (let index = 1; index < rows.length; index += 1) {
const previous = rows[index - 1]
const current = rows[index]
if (Number(current.dataset.index) !== Number(previous.dataset.index) + 1) continue

expect(getVirtualRowStart(current)).toBeGreaterThanOrEqual(
getVirtualRowStart(previous) + previous.offsetHeight
)
}
}

function getMountedIndexes(container: HTMLElement = document.body): number[] {
return getVirtualRows(container)
.map((row) => Number(row.dataset.index))
Expand All @@ -99,6 +124,16 @@ function makeMessages(count: number): ChatMessage[] {
}))
}

function makeLargeToolCallPayload(lines = 24): string {
return [
'Called agent_relay.post_message({ channel: "term-fidelity", result: {',
...Array.from({ length: lines - 2 }, (_, index) =>
` chunk_${index}: "status-${index}-sha-0123456789abcdef"`
),
'} })'
].join('\n')
}

function seedChat(messages: ChatMessage[]): void {
const project: Project = {
id: 'project-1',
Expand Down Expand Up @@ -156,6 +191,9 @@ beforeAll(() => {
get() {
const element = this as HTMLElement
if (element.classList.contains('overflow-y-auto')) return VIEWPORT_HEIGHT
if (element.dataset.testid === 'chat-virtual-row') {
return ROW_HEIGHT + element.querySelectorAll('br').length * MESSAGE_LINE_HEIGHT
}
return getStyledHeight(element) ?? ROW_HEIGHT
}
},
Expand Down Expand Up @@ -265,4 +303,57 @@ describe('ChatView virtualization', () => {
expect(after).not.toEqual(before)
})
})

it('uses the observed layout height for an initially large tool-call result', async () => {
const messages = makeMessages(3)
messages[0] = { ...messages[0], body: makeLargeToolCallPayload() }
seedChat(messages)

const { container } = render(React.createElement(ChatView))

await waitFor(() => {
const rows = getVirtualRows(container)
expect(rows).toHaveLength(messages.length)
expect(rows[0].offsetHeight).toBeGreaterThan(ROW_HEIGHT)
expect(resizeObserverHeights.get(rows[0])).toBe(rows[0].offsetHeight)
expectMountedRowsNotToOverlap(container)
})
})

it('remeasures a live-growing tool-call result before the next row can overlap', async () => {
const messages = makeMessages(3)
seedChat(messages)

const { container } = render(React.createElement(ChatView))
let firstRowBeforeGrowth: HTMLElement | null = null

await waitFor(() => {
const rows = getVirtualRows(container)
expect(rows).toHaveLength(messages.length)
expect(resizeObserverHeights.get(rows[0])).toBe(rows[0].offsetHeight)
expectMountedRowsNotToOverlap(container)
firstRowBeforeGrowth = rows[0]
})

// The test observer deliberately has not delivered a follow-up entry yet,
// reproducing the pre-paint window where the DOM has grown but the
// virtualizer would otherwise retain the mounted row's cached 88px size.
act(() => {
useAgentStore.setState((state) => ({
messages: state.messages.map((message, index) =>
index === 0
? { ...message, body: makeLargeToolCallPayload(36) }
: message
)
}))
})

await waitFor(() => {
const firstRow = getVirtualRows(container)[0]
expect(firstRow).toBe(firstRowBeforeGrowth)
expect(firstRow.offsetHeight).toBeGreaterThan(ROW_HEIGHT)
expect(resizeObserverHeights.get(firstRow)).toBe(ROW_HEIGHT)
expectMountedRowsNotToOverlap(container)
})
})
})
104 changes: 84 additions & 20 deletions src/renderer/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useStickToBottom, type StickToBottomInstance } from 'use-stick-to-bottom'
import {
Expand Down Expand Up @@ -117,6 +117,75 @@ interface VirtualizedMessageListProps {
onReact: (messageId: string, emoji: string) => void
}

interface VirtualizedMessageRowProps {
index: number
start: number
message: ChatMessageType
showDateDivider: boolean
showRoute: boolean
canInteractWithMessages: boolean
activeThread: boolean
authUser?: AuthUser | null
measureElement: (node: HTMLDivElement | null) => void
resizeItem: (index: number, size: number) => void
onReply: (message: ChatMessageType) => void
onReact: (messageId: string, emoji: string) => void
}

function VirtualizedMessageRow({
index,
start,
message,
showDateDivider,
showRoute,
canInteractWithMessages,
activeThread,
authUser,
measureElement,
resizeItem,
onReply,
onReact
}: VirtualizedMessageRowProps): React.ReactNode {
const rowRef = useRef<HTMLDivElement | null>(null)
const setRowRef = useCallback((node: HTMLDivElement | null) => {
rowRef.current = node
measureElement(node)
}, [measureElement])

// ResizeObserver remains the general guard for font, viewport, and width
// changes. A live message body can grow during an already-mounted row,
// though, and waiting for the observer leaves one paint where the following
// row still uses the old start. Measure just that changed row during the
// layout commit so react-virtual moves its successors before paint.
useLayoutEffect(() => {
const row = rowRef.current
if (!row) return
resizeItem(index, row.offsetHeight)
}, [canInteractWithMessages, index, message, resizeItem, showDateDivider, showRoute])

return (
<div
ref={setRowRef}
data-index={index}
data-testid="chat-virtual-row"
className="absolute left-0 top-0 w-full overflow-visible"
style={{ transform: `translateY(${start}px)` }}
>
{showDateDivider && <DateDivider timestamp={message.timestamp} />}
<ChatMessage
message={message}
authUser={authUser}
showRoute={showRoute}
showActions={canInteractWithMessages}
showThreadSummary={canInteractWithMessages}
activeThread={activeThread}
onReply={canInteractWithMessages ? onReply : undefined}
onReact={canInteractWithMessages ? onReact : undefined}
/>
</div>
)
}

function VirtualizedMessageList({
messages,
authUser,
Expand Down Expand Up @@ -171,26 +240,21 @@ function VirtualizedMessageList({
const showDateDivider = !previousMessage || !isSameDay(previousMessage.timestamp, message.timestamp)

return (
<div
<VirtualizedMessageRow
key={virtualRow.key}
ref={messageVirtualizer.measureElement}
data-index={index}
data-testid="chat-virtual-row"
className="absolute left-0 top-0 w-full overflow-visible"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
{showDateDivider && <DateDivider timestamp={message.timestamp} />}
<ChatMessage
message={message}
authUser={authUser}
showRoute={!activeChannelName && !directMessageParticipants}
showActions={canInteractWithMessages}
showThreadSummary={canInteractWithMessages}
activeThread={activeThreadMessageId === message.id}
onReply={canInteractWithMessages ? onReply : undefined}
onReact={canInteractWithMessages ? onReact : undefined}
/>
</div>
index={index}
start={virtualRow.start}
message={message}
authUser={authUser}
showDateDivider={showDateDivider}
showRoute={!activeChannelName && !directMessageParticipants}
canInteractWithMessages={canInteractWithMessages}
activeThread={activeThreadMessageId === message.id}
measureElement={messageVirtualizer.measureElement}
resizeItem={messageVirtualizer.resizeItem}
onReply={onReply}
onReact={onReact}
/>
)
})}
</div>
Expand Down
Loading