diff --git a/src/renderer/src/components/chat/ChatView.dom.test.ts b/src/renderer/src/components/chat/ChatView.dom.test.ts index 0d91c71f..f3975b09 100644 --- a/src/renderer/src/components/chat/ChatView.dom.test.ts +++ b/src/renderer/src/components/chat/ChatView.dom.test.ts @@ -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() + class TestResizeObserver { private readonly callback: ResizeObserverCallback @@ -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, @@ -79,6 +83,27 @@ function getVirtualRows(container: HTMLElement = document.body): HTMLElement[] { return Array.from(container.querySelectorAll('[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)) @@ -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', @@ -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 } }, @@ -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) + }) + }) }) diff --git a/src/renderer/src/components/chat/ChatView.tsx b/src/renderer/src/components/chat/ChatView.tsx index 9bb8eb75..5970208e 100644 --- a/src/renderer/src/components/chat/ChatView.tsx +++ b/src/renderer/src/components/chat/ChatView.tsx @@ -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 { @@ -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(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 ( +
+ {showDateDivider && } + +
+ ) +} + function VirtualizedMessageList({ messages, authUser, @@ -171,26 +240,21 @@ function VirtualizedMessageList({ const showDateDivider = !previousMessage || !isSameDay(previousMessage.timestamp, message.timestamp) return ( -
- {showDateDivider && } - -
+ 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} + /> ) })}