feat(chat): implement pull-to-refresh functionality and pagination for chat history

This commit is contained in:
2026-07-15 18:19:35 +08:00
parent 05ca15be48
commit c37a2f9040
21 changed files with 892 additions and 66 deletions
@@ -4,8 +4,12 @@ import { createActor, fromCallback, waitFor } from "xstate";
import type { UiMessage } from "@/data/dto/chat";
import type { ChatEvent } from "@/stores/chat/chat-events";
import { chatMachine } from "@/stores/chat/chat-machine";
import type { LoadMoreHistoryActorEvent } from "@/stores/chat/machine/actors/history";
import { createTestChatMachine } from "./chat-machine.test-utils";
import {
createLoadHistoryCallback,
createTestChatMachine,
} from "./chat-machine.test-utils";
describe("chat history flow", () => {
it("enters user ready after history is loaded", async () => {
@@ -57,6 +61,8 @@ describe("chat history flow", () => {
localOverwritten: true,
localCount: 1,
networkCount: 1,
total: 1,
limit: 50,
},
});
});
@@ -87,4 +93,182 @@ describe("chat history flow", () => {
actor.stop();
});
it("loads older pages until total is exhausted and keeps existing messages", async () => {
const requests: LoadMoreHistoryActorEvent[] = [];
const latestMessage: UiMessage = {
id: "latest",
content: "current unlocked message",
isFromAI: true,
date: "2026-07-15",
};
const machine = chatMachine.provide({
actors: {
loadHistory: createLoadHistoryCallback(
[latestMessage],
[latestMessage],
{ total: 120, limit: 50 },
),
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
({ receive, sendBack }) => {
receive((event) => {
requests.push(event);
if (event.offset === 50) {
sendBack({
type: "ChatOlderHistoryLoaded",
output: {
messages: [
createHistoryMessage("older-50"),
{ ...latestMessage, content: "stale duplicate" },
],
offset: event.offset,
total: 120,
limit: 50,
},
});
return;
}
sendBack({
type: "ChatOlderHistoryLoaded",
output: {
messages: [createHistoryMessage("oldest-100")],
offset: event.offset,
total: 120,
limit: 50,
},
});
});
return () => undefined;
},
),
},
});
const actor = createActor(machine).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatLoadMoreHistoryRequested" });
await waitFor(
actor,
(snapshot) => snapshot.context.nextHistoryOffset === 100,
);
expect(requests[0]).toMatchObject({ offset: 50, limit: 50 });
expect(actor.getSnapshot().context.messages).toMatchObject([
{ id: "older-50" },
{ id: "latest", content: "current unlocked message" },
]);
actor.send({ type: "ChatLoadMoreHistoryRequested" });
await waitFor(
actor,
(snapshot) => snapshot.context.nextHistoryOffset === 150,
);
actor.send({ type: "ChatLoadMoreHistoryRequested" });
expect(requests).toHaveLength(2);
expect(actor.getSnapshot().context.nextHistoryOffset).toBeGreaterThanOrEqual(
actor.getSnapshot().context.historyTotal,
);
actor.stop();
});
it("keeps the offset after a failed page and allows retrying", async () => {
let requestCount = 0;
const machine = chatMachine.provide({
actors: {
loadHistory: createLoadHistoryCallback([], [], {
total: 75,
limit: 50,
}),
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
({ receive, sendBack }) => {
receive((event) => {
requestCount += 1;
if (requestCount === 1) {
sendBack({
type: "ChatOlderHistoryLoadFailed",
error: new Error("network failed"),
});
return;
}
sendBack({
type: "ChatOlderHistoryLoaded",
output: {
messages: [createHistoryMessage("retry-message")],
offset: event.offset,
total: 75,
limit: 50,
},
});
});
return () => undefined;
},
),
},
});
const actor = createActor(machine).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.send({ type: "ChatLoadMoreHistoryRequested" });
await waitFor(
actor,
(snapshot) =>
requestCount === 1 && !snapshot.context.isLoadingMoreHistory,
);
expect(actor.getSnapshot().context.nextHistoryOffset).toBe(50);
actor.send({ type: "ChatLoadMoreHistoryRequested" });
await waitFor(
actor,
(snapshot) => snapshot.context.nextHistoryOffset === 100,
);
expect(requestCount).toBe(2);
actor.stop();
});
it("does not request another page when total does not exceed limit", async () => {
let requested = false;
const machine = chatMachine.provide({
actors: {
loadHistory: createLoadHistoryCallback([], [], {
total: 50,
limit: 50,
}),
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
({ receive }) => {
receive(() => {
requested = true;
});
return () => undefined;
},
),
},
});
const actor = createActor(machine).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatLoadMoreHistoryRequested" });
expect(requested).toBe(false);
expect(actor.getSnapshot().context.isLoadingMoreHistory).toBe(false);
actor.stop();
});
});
function createHistoryMessage(id: string): UiMessage {
return {
id,
content: `Message ${id}`,
isFromAI: true,
date: "2026-07-15",
};
}