fix(app): recover transient timeline loads

This commit is contained in:
Mohamed Boudra
2026-07-28 16:45:39 +02:00
parent 139e6c9b9a
commit f2be3587b3
11 changed files with 171 additions and 29 deletions
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
@@ -131,6 +132,17 @@ describe("history start pagination", () => {
expect(retried.shouldLoad).toBe(true);
});
it("latches an attempt that becomes invalid before entering flight", () => {
const requested = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({
status: "latched",
});
});
it("does not mistake repeated edge observations for a finished request", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
@@ -33,6 +33,20 @@ export function rearmHistoryStartPagination(
return state.status === "latched" ? { status: "ready" } : state;
}
export function abandonHistoryStartPaginationRequest(
state: HistoryStartPaginationState,
requestedProgressKey: string,
): HistoryStartPaginationState {
if (
state.status !== "loading" ||
state.requestObserved ||
state.requestedProgressKey !== requestedProgressKey
) {
return state;
}
return { status: "latched" };
}
export function evaluateHistoryStartPagination(
state: HistoryStartPaginationState,
input: HistoryStartPaginationInput,
@@ -32,6 +32,7 @@ import {
resolveBottomAnchorTransportBehavior,
} from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
@@ -176,7 +177,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
setHistoryStartPaginationState(transition.state);
}
if (transition.shouldLoad) {
onNearHistoryStart();
const requestedProgressKey = olderHistoryProgressKey;
if (requestedProgressKey === null) {
return;
}
void (async () => {
const started = await onNearHistoryStart();
if (started !== false) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
}
},
);
@@ -370,10 +370,10 @@ describe("createWebStreamStrategy", () => {
expect(onNearHistoryStart).not.toHaveBeenCalled();
});
it("retries a latched history request when the user scrolls up in a short timeline", async () => {
it("retries a skipped history request when the user scrolls up in a short timeline", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn();
const onNearHistoryStart = vi.fn(async () => false);
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
@@ -417,23 +417,13 @@ describe("createWebStreamStrategy", () => {
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -120 }));
scrollContainer.dispatchEvent(new Event("scroll"));
await Promise.resolve();
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
act(() => {
root?.render(strategy.render({ ...renderInput, isLoadingOlderHistory: true }));
});
act(() => {
root?.render(strategy.render(renderInput));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
act(() => {
await act(async () => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -120 }));
await Promise.resolve();
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
});
+15 -1
View File
@@ -17,6 +17,7 @@ import { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
@@ -260,7 +261,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
};
historyStartPrependAnchorActiveRef.current = false;
onNearHistoryStart();
const requestedProgressKey = olderHistoryProgressKey;
void (async () => {
const started = await onNearHistoryStart();
if (started !== false) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
},
);
const evaluateHistoryStart = useStableEvent(() => {
+1 -1
View File
@@ -69,7 +69,7 @@ export interface StreamRenderInput {
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
onNearBottomChange: (value: boolean) => void;
onNearHistoryStart: () => void;
onNearHistoryStart: () => void | Promise<boolean>;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
olderHistoryProgressKey: string | null;
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { runAssistantImageOperationWithRetry } from "./retry";
describe("assistant image retry", () => {
it("recovers from transient failures while the image remains mounted", async () => {
const waits: number[] = [];
let attempts = 0;
const result = await runAssistantImageOperationWithRetry({
operation: async () => {
attempts += 1;
if (attempts < 3) {
throw new Error("transient");
}
return "loaded";
},
delaysMs: [10, 20, 30],
wait: async (delayMs) => {
waits.push(delayMs);
},
});
expect({ attempts, waits, result }).toEqual({
attempts: 3,
waits: [10, 20],
result: "loaded",
});
});
it("stops retrying after the image is released", async () => {
let stopped = false;
let attempts = 0;
await expect(
runAssistantImageOperationWithRetry({
operation: async () => {
attempts += 1;
throw new Error("transient");
},
delaysMs: [10, 20],
shouldStop: () => stopped,
wait: async () => {
stopped = true;
},
}),
).rejects.toThrow("transient");
expect(attempts).toBe(1);
});
});
+31
View File
@@ -0,0 +1,31 @@
export const ASSISTANT_IMAGE_RETRY_DELAYS_MS = [100, 400, 1_200] as const;
export async function runAssistantImageOperationWithRetry<T>(input: {
operation: () => Promise<T>;
delaysMs?: readonly number[];
shouldStop?: () => boolean;
wait?: (delayMs: number) => Promise<void>;
}): Promise<T> {
const delays = input.delaysMs ?? ASSISTANT_IMAGE_RETRY_DELAYS_MS;
const shouldStop = input.shouldStop ?? (() => false);
const wait =
input.wait ??
(async (delayMs: number) => {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
});
for (let attempt = 0; ; attempt += 1) {
try {
return await input.operation();
} catch (error) {
const delay = delays[attempt];
if (delay === undefined || shouldStop()) {
throw error;
}
await wait(delay);
if (shouldStop()) {
throw error;
}
}
}
}
@@ -30,6 +30,7 @@ import {
type AssistantImageLifecycle,
type AssistantImageLifecycleEvent,
} from "./lifecycle";
import { runAssistantImageOperationWithRetry } from "./retry";
interface AssistantImageRenderBinding {
uri: string;
@@ -104,7 +105,10 @@ function useAttachmentAcquisition(
setState({ status: "loading" });
void (async () => {
try {
const attachment = await acquireAttachment(acquisition);
const attachment = await runAssistantImageOperationWithRetry({
operation: async () => await acquireAttachment(acquisition),
shouldStop: () => disposed,
});
if (!disposed) {
setState({ status: "loaded", attachment });
}
@@ -207,7 +211,10 @@ function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): Previ
setState({ status: "loading" });
void (async () => {
try {
const uri = await resolveAttachmentPreviewUrl(current);
const uri = await runAssistantImageOperationWithRetry({
operation: async () => await resolveAttachmentPreviewUrl(current),
shouldStop: () => disposed,
});
if (disposed) {
await releaseAttachmentPreviewUrl({ attachment: current, url: uri });
return;
@@ -84,7 +84,7 @@ describe("loadOlderAgentHistory", () => {
const client = createClient();
const inFlight = createInFlight();
await loadOlderAgentHistory(agentId, {
const started = await loadOlderAgentHistory(agentId, {
client,
cursor: undefined,
hasOlder: true,
@@ -94,13 +94,14 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([false]);
expect(started).toBe(false);
});
it("no-ops when the daemon says no older history exists", async () => {
const client = createClient();
const inFlight = createInFlight();
await loadOlderAgentHistory(agentId, {
const started = await loadOlderAgentHistory(agentId, {
client,
cursor: someCursor,
hasOlder: false,
@@ -110,13 +111,14 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([false]);
expect(started).toBe(false);
});
it("no-ops when a request is already in flight", async () => {
const client = createClient();
const inFlight = createInFlight(true);
await loadOlderAgentHistory(agentId, {
const started = await loadOlderAgentHistory(agentId, {
client,
cursor: someCursor,
hasOlder: true,
@@ -126,13 +128,14 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([true]);
expect(started).toBe(true);
});
it("requests the page before the current start cursor and clears in-flight on success", async () => {
const client = createClient();
const inFlight = createInFlight();
await loadOlderAgentHistory(agentId, {
const started = await loadOlderAgentHistory(agentId, {
client,
cursor: someCursor,
hasOlder: true,
@@ -152,6 +155,7 @@ describe("loadOlderAgentHistory", () => {
},
]);
expect(inFlight.values).toEqual([false, true, false]);
expect(started).toBe(true);
});
it("shows a panel toast, warns, and clears in-flight on failure", async () => {
@@ -40,11 +40,14 @@ export interface LoadOlderAgentHistoryDeps {
export async function loadOlderAgentHistory(
agentId: string,
deps: LoadOlderAgentHistoryDeps,
): Promise<void> {
): Promise<boolean> {
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } =
deps;
if (!client || !cursor || !hasOlder || isLoadingOlder) {
return;
if (isLoadingOlder) {
return true;
}
if (!client || !cursor || !hasOlder) {
return false;
}
setInFlight(true);
@@ -62,6 +65,7 @@ export async function loadOlderAgentHistory(
} finally {
setInFlight(false);
}
return true;
}
export function useLoadOlderAgentHistory({
@@ -105,10 +109,10 @@ export function useLoadOlderAgentHistory({
[agentId, serverId, setOlderFetchInFlight],
);
const loadOlder = useCallback(() => {
const loadOlder = useCallback(async (): Promise<boolean> => {
const session = useSessionStore.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
void loadOlderAgentHistory(agentId, {
return await loadOlderAgentHistory(agentId, {
client: session?.client
? {
fetchAgentTimeline: (timelineAgentId, request) =>