Stabilize history pagination lifecycle

This commit is contained in:
Mohamed Boudra
2026-07-28 16:45:39 +02:00
parent d9e3450a7e
commit 0c26cc4d0e
11 changed files with 162 additions and 51 deletions
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { createHistoryStartSettleScheduler } from "./history-start-settle-scheduler";
describe("history start settle scheduler", () => {
it("does not restart its countdown when geometry keeps changing", () => {
const frames = new Map<number, () => void>();
let nextFrameId = 1;
const onSettle = vi.fn();
const scheduler = createHistoryStartSettleScheduler({
settleFrames: 2,
requestFrame(callback) {
const id = nextFrameId;
nextFrameId += 1;
frames.set(id, callback);
return id;
},
cancelFrame: (id) => frames.delete(id),
isSettling: () => true,
isLoading: () => false,
onSettle,
});
const runNextFrame = () => {
const next = frames.entries().next().value as [number, () => void] | undefined;
if (!next) {
throw new Error("Expected a scheduled settlement frame");
}
frames.delete(next[0]);
next[1]();
};
scheduler.schedule();
scheduler.schedule();
runNextFrame();
scheduler.schedule();
runNextFrame();
scheduler.schedule();
runNextFrame();
expect(onSettle).toHaveBeenCalledTimes(1);
expect(frames.size).toBe(0);
});
});
@@ -0,0 +1,50 @@
export interface HistoryStartSettleScheduler {
schedule(): void;
cancel(): void;
}
export function createHistoryStartSettleScheduler(input: {
settleFrames: number;
requestFrame: (callback: () => void) => number;
cancelFrame: (id: number) => void;
isSettling: () => boolean;
isLoading: () => boolean;
onSettle: () => void;
}): HistoryStartSettleScheduler {
let frameId: number | null = null;
let remainingFrames = 0;
const tick = () => {
if (!input.isSettling()) {
frameId = null;
remainingFrames = 0;
return;
}
if (input.isLoading() || remainingFrames > 0) {
if (!input.isLoading()) {
remainingFrames -= 1;
}
frameId = input.requestFrame(tick);
return;
}
frameId = null;
input.onSettle();
};
return {
schedule() {
if (frameId !== null) {
return;
}
remainingFrames = input.settleFrames;
frameId = input.requestFrame(tick);
},
cancel() {
if (frameId !== null) {
input.cancelFrame(frameId);
}
frameId = null;
remainingFrames = 0;
},
};
}
@@ -41,6 +41,10 @@ import {
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
} from "./history-start-settle-scheduler";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
@@ -127,7 +131,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartSettleFrameIdRef = useRef<number | null>(null);
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const historyItems = useMemo(() => {
if (segments.historyVirtualized.length === 0) {
@@ -183,7 +187,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
void (async () => {
const started = await onNearHistoryStart();
if (started !== false) {
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
@@ -205,30 +209,25 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
applyHistoryStartPaginationTransition(transition);
});
const scheduleHistoryStartSettle = useStableEvent(() => {
const pendingFrame = historyStartSettleFrameIdRef.current;
if (pendingFrame !== null) {
cancelAnimationFrame(pendingFrame);
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
scheduler = createHistoryStartSettleScheduler({
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame,
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
onSettle: () => {
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
},
});
historyStartSettleSchedulerRef.current = scheduler;
}
let remainingFrames = HISTORY_START_SETTLE_FRAMES;
const settle = () => {
if (historyStartPaginationStateRef.current.status !== "settling") {
historyStartSettleFrameIdRef.current = null;
return;
}
const input = getHistoryStartPaginationInput();
if (input.isLoadingOlderHistory || remainingFrames > 0) {
remainingFrames -= input.isLoadingOlderHistory ? 0 : 1;
historyStartSettleFrameIdRef.current = requestAnimationFrame(settle);
return;
}
historyStartSettleFrameIdRef.current = null;
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
};
historyStartSettleFrameIdRef.current = requestAnimationFrame(settle);
scheduler.schedule();
});
const clearNativeViewportSettling = useCallback(() => {
@@ -339,11 +338,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
const settleFrame = historyStartSettleFrameIdRef.current;
if (settleFrame !== null) {
historyStartSettleFrameIdRef.current = null;
cancelAnimationFrame(settleFrame);
}
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
@@ -130,7 +130,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -174,7 +174,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -230,7 +230,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -296,7 +296,7 @@ describe("createWebStreamStrategy", () => {
});
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn();
const onNearHistoryStart = vi.fn().mockReturnValue(true);
const renderInput = {
agentId: "agent",
boundary: {
@@ -370,10 +370,10 @@ describe("createWebStreamStrategy", () => {
expect(onNearHistoryStart).not.toHaveBeenCalled();
});
it("retries a skipped history request when the user scrolls up in a short timeline", async () => {
it("retries when a history callback does not start a request", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn(async () => false);
const onNearHistoryStart = vi.fn(async () => undefined);
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
@@ -392,7 +392,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart,
onNearHistoryStart: onNearHistoryStart as unknown as StreamRenderInput["onNearHistoryStart"],
isLoadingOlderHistory: false,
hasOlderHistory: true,
olderHistoryProgressKey: "epoch-1:20",
@@ -449,7 +449,7 @@ describe("createWebStreamStrategy", () => {
);
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn();
const onNearHistoryStart = vi.fn().mockReturnValue(true);
const initialHistory = Array.from({ length: 20 }, (_, index) => userMessage(index + 60));
const renderInput: StreamRenderInput = {
agentId: "agent",
@@ -566,7 +566,7 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -674,7 +674,7 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -771,7 +771,7 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -870,7 +870,7 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest: null,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -264,7 +264,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const requestedProgressKey = olderHistoryProgressKey;
void (async () => {
const started = await onNearHistoryStart();
if (started !== false) {
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
+1 -1
View File
@@ -69,7 +69,7 @@ export interface StreamRenderInput {
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
onNearBottomChange: (value: boolean) => void;
onNearHistoryStart: () => void | Promise<boolean>;
onNearHistoryStart: () => boolean | Promise<boolean>;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
olderHistoryProgressKey: string | null;
+4 -3
View File
@@ -77,6 +77,7 @@ import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./bottom-anchor-controller";
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
@@ -250,7 +251,7 @@ export interface AgentStreamViewProps {
hasOlder: boolean;
isLoadingOlder: boolean;
progressKey: string | null;
onLoadOlder: () => void;
onLoadOlder: () => boolean | Promise<boolean>;
};
}
@@ -694,7 +695,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
toast={toast}
>
<AssistantMessage
occurrenceKey={item.id}
occurrenceKey={createAssistantImageOccurrenceKey({ agentId, itemId: item.id })}
message={item.text}
timestamp={item.timestamp.getTime()}
workspaceRoot={workspaceRoot}
@@ -705,7 +706,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
</AssistantFileLinkResolverProvider>
);
},
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
[agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
);
const renderThoughtItem = useCallback(
@@ -3,6 +3,7 @@ import {
createAssistantImageAcquisitionCache,
createAssistantImageFileAcquisitionKey,
createAssistantImageFilePreviewAttachmentId,
createAssistantImageOccurrenceKey,
} from "./acquisition-cache";
describe("assistant image acquisition cache", () => {
@@ -90,6 +91,7 @@ describe("assistant image acquisition cache", () => {
it("scopes persisted file previews to the rendered message occurrence", () => {
const first = createAssistantImageFilePreviewAttachmentId({
serverId: "server-1",
occurrenceKey: "message-1:image-1",
mimeType: "image/png",
path: "/workspace/screenshot.png",
@@ -98,6 +100,7 @@ describe("assistant image acquisition cache", () => {
contentLength: 512,
});
const second = createAssistantImageFilePreviewAttachmentId({
serverId: "server-1",
occurrenceKey: "message-2:image-1",
mimeType: "image/png",
path: "/workspace/screenshot.png",
@@ -109,6 +112,13 @@ describe("assistant image acquisition cache", () => {
expect(second).not.toBe(first);
});
it("scopes message occurrences to their agent", () => {
const first = createAssistantImageOccurrenceKey({ agentId: "agent-1", itemId: "message-1" });
const second = createAssistantImageOccurrenceKey({ agentId: "agent-2", itemId: "message-1" });
expect(second).not.toBe(first);
});
it("retains successful values until their cache entry is evicted", async () => {
const retained: string[] = [];
const released: string[] = [];
@@ -5,7 +5,15 @@ export interface AssistantImageAcquisitionCache<T> {
size(): number;
}
export function createAssistantImageOccurrenceKey(input: {
agentId: string;
itemId: string;
}): string {
return `${input.agentId}:${input.itemId}`;
}
export function createAssistantImageFilePreviewAttachmentId(input: {
serverId?: string;
occurrenceKey: string;
mimeType: string;
path: string;
@@ -19,7 +27,7 @@ export function createAssistantImageFilePreviewAttachmentId(input: {
size: input.size,
modifiedAt: input.modifiedAt,
contentLength: input.contentLength,
contentKey: input.occurrenceKey,
contentKey: `${input.serverId ?? "unknown-server"}:${input.occurrenceKey}`,
});
}
@@ -157,6 +157,7 @@ function createFileAcquisition(input: {
}
return await persistAttachmentFromBytes({
id: createAssistantImageFilePreviewAttachmentId({
serverId: input.serverId,
occurrenceKey: input.occurrenceKey,
mimeType: file.mime,
path: file.path || resolution.path,
@@ -104,10 +104,12 @@ function ProviderSubagentPanel() {
.catch(() => undefined);
}, [client, serverId, supported, target.parentAgentId, target.subagentId]);
const loadOlder = useCallback(() => {
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) return;
const loadOlder = useCallback((): boolean => {
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
return false;
}
const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null;
if (firstSeq === null) return;
if (firstSeq === null) return false;
setIsLoadingOlder(true);
void client
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
@@ -121,6 +123,7 @@ function ProviderSubagentPanel() {
})
.catch(() => undefined)
.finally(() => setIsLoadingOlder(false));
return true;
}, [
client,
isLoadingOlder,