From d704dd0c148200ff143c847c55ba131a6628781b Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sun, 26 Jul 2026 18:47:08 -0700 Subject: [PATCH] fix(chat): prevent virtual rows overlapping after mid-list growth --- .../ChatPane/parts/MessageList.test.tsx | 60 ++++++++++++++++++- .../thread/ChatPane/parts/MessageList.tsx | 47 ++++++++++++--- 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/thread/ChatPane/parts/MessageList.test.tsx b/src/renderer/components/thread/ChatPane/parts/MessageList.test.tsx index 893cde02..73165fad 100644 --- a/src/renderer/components/thread/ChatPane/parts/MessageList.test.tsx +++ b/src/renderer/components/thread/ChatPane/parts/MessageList.test.tsx @@ -59,6 +59,8 @@ const { unsubscribeMock: vi.fn<() => void>(), })); +const MOCK_ROW_SIZE = 100; + vi.mock("@legendapp/list/react", async () => { const React = await import("react"); return { @@ -110,10 +112,15 @@ vi.mock("@legendapp/list/react", async () => { > {props.ListHeaderComponent} {props.data.length === 0 ? props.ListEmptyComponent : null} + {/* Mirror LegendList's absolutely positioned per-row containers so + row code that reads/writes sibling offsets is exercised. */} {props.data.map((item, index) => ( - +
{props.renderItem({ item, index })} - +
))} {props.ListFooterComponent} @@ -402,6 +409,55 @@ describe("MessageList", () => { } }); + it("pushes the rows below down when a mid-list row grows, before LegendList reflows", () => { + const observed: Array<{ target: Element; callback: () => void }> = []; + const originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(private readonly callback: () => void) {} + observe(target: Element) { + observed.push({ target, callback: this.callback }); + } + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + + try { + const threadId = "thread-1"; + seedCompletedItem(threadId, "assistant-1", "assistant_message"); + seedCompletedItem(threadId, "user-1", "user_message"); + render(); + + const row = screen.getByText("assistant-1").closest("[data-chat-virtual-row='true']"); + if (!(row instanceof HTMLDivElement)) throw new Error("missing anchor row"); + const nextContainer = row.parentElement?.nextElementSibling; + if (!(nextContainer instanceof HTMLDivElement)) throw new Error("missing following row"); + const resize = observed.find((entry) => entry.target === row); + if (!resize) throw new Error("row is not observed"); + + const grow = (height: number) => { + Object.defineProperties(row, { + offsetHeight: { configurable: true, value: height }, + offsetWidth: { configurable: true, value: 500 }, + }); + act(() => resize.callback()); + }; + + grow(120); + const topAfterBaseline = Number.parseFloat(nextContainer.style.top); + setItemSizeMock.mockClear(); + + grow(180); + + expect(setItemSizeMock).toHaveBeenCalledWith("assistant-1", { height: 180, width: 500 }); + // The 60px the row gained is handed to the following row immediately, so a + // prompt appended under a still-working row cannot paint over its last line + // (the "Worked for X" record) while LegendList catches up next frame. + expect(Number.parseFloat(nextContainer.style.top)).toBe(topAfterBaseline + 60); + } finally { + globalThis.ResizeObserver = originalResizeObserver; + } + }); + it("coalesces live streaming remeasurement to one animation frame", async () => { vi.useFakeTimers(); try { diff --git a/src/renderer/components/thread/ChatPane/parts/MessageList.tsx b/src/renderer/components/thread/ChatPane/parts/MessageList.tsx index aabf143c..46dbbce0 100644 --- a/src/renderer/components/thread/ChatPane/parts/MessageList.tsx +++ b/src/renderer/components/thread/ChatPane/parts/MessageList.tsx @@ -552,7 +552,7 @@ const VirtualChatListRow = memo(function VirtualChatListRow({ }, [entry, isLastEntry, scheduleLiveMeasure, threadId]); useLayoutEffect(() => { const element = rowElementRef.current; - if (!isLastEntry || !element) return; + if (!element) return; let previousHeight = element.offsetHeight; let previousWidth = element.offsetWidth; @@ -560,16 +560,26 @@ const VirtualChatListRow = memo(function VirtualChatListRow({ const nextHeight = element.offsetHeight; const nextWidth = element.offsetWidth; if (nextHeight === previousHeight && nextWidth === previousWidth) return; + const heightDelta = nextHeight - previousHeight; previousHeight = nextHeight; previousWidth = nextWidth; - // The smoothed Markdown renderer can grow between provider deltas. Its - // actual DOM resize is the earliest reliable signal and ResizeObserver - // runs after layout but before paint, so measure LegendList here instead - // of letting the tail paint below the reserved composer/safe-area band - // until the next stream event or animation frame catches up. Observe the - // single tail row through turn completion as well: the final unsmoothed - // text commit can land in the same update that marks the turn inactive. + // The smoothed Markdown renderer can grow between provider deltas, and the + // completion commit renders the final text concurrently — often a few + // frames after the store event. The DOM resize is the earliest reliable + // signal and ResizeObserver runs after layout but before paint, so measure + // LegendList here rather than waiting for the next stream event or frame. remeasureElement(entry.id, element, true); + // LegendList turns that size into new row offsets through a React render, + // which lands on the NEXT frame. Nothing sits below the tail, so there it + // is invisible — but a row that grows mid-list paints into its neighbour + // for that frame. Steering is when that shows: the prompt is appended + // under the still-working row, so when the turn closes, the row's final + // text plus its "Worked for X" line grow underneath the prompt bubble, + // which covers them until the reflow catches up. Shift the rows below by + // the same delta here, still before paint; LegendList rewrites the + // identical offsets on its own pass, so the two converge. Growth only — + // LegendList deliberately defers shrinks, and racing that would jitter. + if (!isLastEntry && heightDelta > 0) shiftFollowingRows(element, heightDelta); }); observer.observe(element); return () => observer.disconnect(); @@ -764,6 +774,27 @@ function getFixedTimelineEntrySize( return Math.round(renderedHeight + INLINE_IMAGE_ROW_CHROME_PX); } +/** + * Nudge the virtual rows below `element` by `delta` px, before paint, to cover + * the frame between a mid-list row growing and LegendList re-rendering the new + * offsets. Purely transient: LegendList owns `top` and rewrites it (to the same + * value) on its own pass. + */ +function shiftFollowingRows(element: HTMLElement, delta: number): void { + if (!Number.isFinite(delta) || Math.abs(delta) < 1) return; + const container = element.parentElement; + const parent = container?.parentElement; + if (!container || !parent) return; + const ownTop = Number.parseFloat(container.style.top); + if (!Number.isFinite(ownTop)) return; + for (const sibling of parent.children) { + if (!(sibling instanceof HTMLElement) || sibling === container) continue; + const top = Number.parseFloat(sibling.style.top); + if (!Number.isFinite(top) || top <= ownTop) continue; + sibling.style.top = `${top + delta}px`; + } +} + /** * Whether a completed row's measured height survives a remount unchanged, so its * cached measurement may be restored (see `writeTimelineMeasurements`, applied