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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => (
<React.Fragment key={props.keyExtractor(item, index)}>
<div
key={props.keyExtractor(item, index)}
style={{ position: "absolute", top: `${index * MOCK_ROW_SIZE}px` }}
>
{props.renderItem({ item, index })}
</React.Fragment>
</div>
))}
{props.ListFooterComponent}
</div>
Expand Down Expand Up @@ -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(<MessageList threadId={threadId} entries={makeEntries(["assistant-1", "user-1"])} />);

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 {
Expand Down
47 changes: 39 additions & 8 deletions src/renderer/components/thread/ChatPane/parts/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -552,24 +552,34 @@ 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;
const observer = new ResizeObserver(() => {
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();
Expand Down Expand Up @@ -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
Expand Down