mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user