mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
fix(app): recover transient timeline loads
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user