Load complete chat history when reaching the top (#2481)
* fix(chat): load older timeline history consistently Pagination depended on scroll events, so short or compacted initial pages could never request older history. Reevaluate edge visibility as layout and history change, and standardize loading indicators on the canonical app spinner. * fix(chat): keep older history loading reliably Use authoritative timeline cursor progress so merged rows cannot stall pagination. Wait for bottom anchoring before evaluating the history edge, and re-arm retries on a fresh upward gesture without automatic failure loops.
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
expectLoadedTimelineDoesNotScroll,
|
||||
expectTimelinePromptNotMounted,
|
||||
expectTimelinePromptVisible,
|
||||
holdNextOlderTimelinePage,
|
||||
makeLoadedTimelineFitViewport,
|
||||
openAgentTimeline,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
seedLongMockAgentTimeline,
|
||||
@@ -25,4 +28,21 @@ test.describe("Agent timeline pagination", () => {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads older history when the initial page does not fill the viewport", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
try {
|
||||
await makeLoadedTimelineFitViewport(page);
|
||||
const olderPage = await holdNextOlderTimelinePage(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectLoadedTimelineDoesNotScroll(page);
|
||||
await olderPage.expectLoading();
|
||||
olderPage.release();
|
||||
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,11 @@ interface CreatedAgentTimelineGate {
|
||||
waitForForwardedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AgentTimelineResponseGate {
|
||||
release(): void;
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
@@ -118,3 +123,53 @@ export async function delayCreatedAgentInitialTailResponse(
|
||||
waitForForwardedResponse: () => forwardedResponse,
|
||||
};
|
||||
}
|
||||
|
||||
export async function delayAgentOlderTimelineResponse(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<AgentTimelineResponseGate> {
|
||||
let releaseRequested = false;
|
||||
let delayedResponseSeen = false;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let resolveDelayedResponse: (() => void) | null = null;
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
resolveDelayedResponse = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
!delayedResponseSeen &&
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId &&
|
||||
payload.direction === "before"
|
||||
) {
|
||||
delayedResponseSeen = true;
|
||||
resolveDelayedResponse?.();
|
||||
if (releaseRequested) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForDelayedResponse: () => delayedResponse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import {
|
||||
delayAgentOlderTimelineResponse,
|
||||
type AgentTimelineResponseGate,
|
||||
} from "./agent-timeline-gate";
|
||||
|
||||
interface LongTimelineAgentOptions {
|
||||
turns: number;
|
||||
@@ -53,6 +57,39 @@ export async function expectTimelinePromptNotMounted(page: Page, prompt: string)
|
||||
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 1280, height: 8_000 });
|
||||
}
|
||||
|
||||
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
return element.scrollHeight <= element.clientHeight;
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function holdNextOlderTimelinePage(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<AgentTimelineResponseGate & { expectLoading(): Promise<void> }> {
|
||||
const gate = await delayAgentOlderTimelineResponse(page, agent.agentId);
|
||||
return {
|
||||
...gate,
|
||||
async expectLoading() {
|
||||
await gate.waitForDelayedResponse();
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.hover();
|
||||
|
||||
Reference in New Issue
Block a user