fix(timeline): keep history loading geometry stable

Reserve a fixed history-start gutter regardless of pagination state so showing or hiding the loader cannot shift timeline content.
This commit is contained in:
Mohamed Boudra
2026-07-31 13:34:50 +02:00
parent a0785d7a42
commit b69870892d
6 changed files with 45 additions and 39 deletions
@@ -1,6 +1,7 @@
import { expect, test } from "./fixtures";
import {
expectSameOlderHistoryLoadingOperation,
expectStableHistoryStartGutter,
expectTimelineAtHistoryStart,
expectTimelinePromptCentered,
expectTimelinePromptNotMounted,
@@ -27,6 +28,27 @@ import {
} from "./helpers/timeline-pagination";
test.describe("Agent timeline pagination", () => {
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
page,
}) => {
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectStableHistoryStartGutter(page);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
await expectStableHistoryStartGutter(page);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await expectStableHistoryStartGutter(page);
} finally {
await agent.cleanup();
}
});
test("loads one page each time the user returns to history start", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
@@ -286,6 +286,14 @@ export async function rememberTimelineViewport(page: Page): Promise<TimelineView
return readTimelineViewport(page);
}
export async function expectStableHistoryStartGutter(page: Page): Promise<void> {
const gutter = page.getByTestId("older-history-slot");
await expect(gutter).toBeAttached();
await expect
.poll(() => gutter.evaluate((element) => element.getBoundingClientRect().height))
.toBe(32);
}
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const row = timeline.getByTestId("user-message").filter({ hasText: prompt });
@@ -4,7 +4,6 @@ import {
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
isHistoryStartSlotReserved,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
@@ -23,15 +22,11 @@ const visibleHistoryStart: HistoryStartPaginationInput = {
};
describe("history start pagination", () => {
it("waits for user scroll intent before loading or reserving the loader slot", () => {
it("waits for user scroll intent before loading", () => {
const dormant = createHistoryStartPaginationState();
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false });
expect(isHistoryStartSlotReserved(evaluated.state, true)).toBe(false);
expect(isHistoryStartSlotReserved(rearmHistoryStartPagination(evaluated.state), true)).toBe(
true,
);
});
it("waits for anchored page geometry before authorizing another page", () => {
@@ -34,13 +34,6 @@ export function rearmHistoryStartPagination(
return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state;
}
export function isHistoryStartSlotReserved(
state: HistoryStartPaginationState,
hasOlderHistory: boolean,
): boolean {
return state.status !== "dormant" && (hasOlderHistory || state.status !== "ready");
}
export function abandonHistoryStartPaginationRequest(
state: HistoryStartPaginationState,
requestedProgressKey: string,
@@ -36,7 +36,6 @@ import {
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
isHistoryStartSlotReserved,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
@@ -59,9 +58,8 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
const historyStartSlotStyle: ViewStyle = {
alignItems: "center",
justifyContent: "center",
minHeight: 32,
paddingTop: 4,
paddingBottom: 8,
height: 32,
flexShrink: 0,
};
const HISTORY_START_SETTLE_FRAMES = 2;
@@ -584,20 +582,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const historyFooterContent = useMemo(() => {
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
if (!isHistoryStartSlotReserved(historyStartPaginationState, hasOlderHistory)) {
return null;
}
return (
<View
style={historyStartSlotStyle}
testID={isLoadingOperation ? "load-older-history-spinner" : undefined}
>
<View style={historyStartSlotStyle} testID="older-history-slot">
{isLoadingOperation ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<View testID="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</View>
);
}, [hasOlderHistory, historyStartPaginationState]);
}, [historyStartPaginationState]);
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
// data or the live header changes, preserving the row identities above.
+7 -13
View File
@@ -21,7 +21,6 @@ import {
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
isHistoryStartSlotReserved,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
@@ -69,9 +68,8 @@ const historyStartSlotStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 32,
paddingTop: 4,
paddingBottom: 8,
height: 32,
flexShrink: 0,
};
const mountedHistoryRowStyle: CSSProperties = {
@@ -785,20 +783,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [renderLiveAuxiliary]);
const historyStartSlot = useMemo(() => {
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
if (!isHistoryStartSlotReserved(historyStartPaginationState, hasOlderHistory)) {
return null;
}
return (
<div
style={historyStartSlotStyle}
data-testid={isLoadingOperation ? "load-older-history-spinner" : undefined}
>
<div style={historyStartSlotStyle} data-testid="older-history-slot">
{isLoadingOperation ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<div data-testid="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</div>
) : null}
</div>
);
}, [hasOlderHistory, historyStartPaginationState]);
}, [historyStartPaginationState]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&