mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
Keep active previews and pagination requests alive
This commit is contained in:
@@ -428,6 +428,60 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("loads older history when the current page projects no anchor row", async () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const onNearHistoryStart = vi.fn().mockReturnValue(true);
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: false,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch-1:20",
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(strategy.render(renderInput));
|
||||
});
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("anchors prepended history independently from live content growth below it", async () => {
|
||||
let anchorTop = 0;
|
||||
let measuredScrollTop = 0;
|
||||
|
||||
@@ -256,15 +256,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1);
|
||||
const anchorElement =
|
||||
contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null;
|
||||
if (!scrollContainer || !anchorRow || !anchorElement) {
|
||||
return;
|
||||
if (scrollContainer && anchorRow && anchorElement) {
|
||||
historyStartPrependAnchorRef.current = {
|
||||
progressKey: olderHistoryProgressKey,
|
||||
rowId: anchorRow.id,
|
||||
viewportOffset:
|
||||
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
|
||||
};
|
||||
} else {
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
}
|
||||
historyStartPrependAnchorRef.current = {
|
||||
progressKey: olderHistoryProgressKey,
|
||||
rowId: anchorRow.id,
|
||||
viewportOffset:
|
||||
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
|
||||
};
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
void (async () => {
|
||||
|
||||
@@ -140,4 +140,26 @@ describe("assistant image acquisition cache", () => {
|
||||
released: ["attachment-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not evict a value while an active consumer retains it", async () => {
|
||||
const released: string[] = [];
|
||||
const cache = createAssistantImageAcquisitionCache<string>({
|
||||
capacity: 1,
|
||||
onRetain: (value) => () => released.push(value),
|
||||
});
|
||||
|
||||
const first = cache.acquireRetained("first", async () => "attachment-1");
|
||||
await first.promise;
|
||||
const second = cache.acquireRetained("second", async () => "attachment-2");
|
||||
await second.promise;
|
||||
|
||||
expect({ released, size: cache.size() }).toEqual({ released: [], size: 2 });
|
||||
|
||||
first.release();
|
||||
expect({ released, size: cache.size() }).toEqual({
|
||||
released: ["attachment-1"],
|
||||
size: 1,
|
||||
});
|
||||
second.release();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@ import { createPreviewAttachmentId } from "@/attachments/utils";
|
||||
|
||||
export interface AssistantImageAcquisitionCache<T> {
|
||||
acquire(key: string, locate: () => Promise<T>): Promise<T>;
|
||||
acquireRetained(
|
||||
key: string,
|
||||
locate: () => Promise<T>,
|
||||
): { promise: Promise<T>; value?: T; release: () => void };
|
||||
peek(key: string): T | undefined;
|
||||
size(): number;
|
||||
}
|
||||
@@ -53,6 +57,7 @@ export function createAssistantImageAcquisitionCache<T>(input: {
|
||||
resolved: boolean;
|
||||
value?: T;
|
||||
release: (() => void) | null;
|
||||
activeConsumers: number;
|
||||
}
|
||||
const entries = new Map<string, CacheEntry>();
|
||||
|
||||
@@ -64,42 +69,79 @@ export function createAssistantImageAcquisitionCache<T>(input: {
|
||||
entry.release = null;
|
||||
};
|
||||
|
||||
const enforceCapacity = () => {
|
||||
while (entries.size > input.capacity) {
|
||||
let evicted = false;
|
||||
for (const [key, entry] of entries) {
|
||||
if (entry.activeConsumers > 0) {
|
||||
continue;
|
||||
}
|
||||
evict(key, entry);
|
||||
evicted = true;
|
||||
break;
|
||||
}
|
||||
if (!evicted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const acquireEntry = (key: string, locate: () => Promise<T>, retain: boolean): CacheEntry => {
|
||||
const cached = entries.get(key);
|
||||
if (cached) {
|
||||
entries.delete(key);
|
||||
entries.set(key, cached);
|
||||
if (retain) {
|
||||
cached.activeConsumers += 1;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
const pending = locate();
|
||||
const entry: CacheEntry = {
|
||||
pending,
|
||||
resolved: false,
|
||||
release: null,
|
||||
activeConsumers: retain ? 1 : 0,
|
||||
};
|
||||
entries.set(key, entry);
|
||||
enforceCapacity();
|
||||
void (async () => {
|
||||
try {
|
||||
const value = await pending;
|
||||
const release = input.onRetain?.(value) ?? null;
|
||||
if (entries.get(key) === entry) {
|
||||
entry.value = value;
|
||||
entry.resolved = true;
|
||||
entry.release = release;
|
||||
} else {
|
||||
release?.();
|
||||
}
|
||||
} catch {
|
||||
evict(key, entry);
|
||||
}
|
||||
})();
|
||||
return entry;
|
||||
};
|
||||
|
||||
return {
|
||||
acquire(key, locate) {
|
||||
const cached = entries.get(key);
|
||||
if (cached) {
|
||||
entries.delete(key);
|
||||
entries.set(key, cached);
|
||||
return cached.pending;
|
||||
}
|
||||
const pending = locate();
|
||||
const entry: CacheEntry = { pending, resolved: false, release: null };
|
||||
entries.set(key, entry);
|
||||
if (entries.size > input.capacity) {
|
||||
const leastRecentlyUsedKey = entries.keys().next().value;
|
||||
if (leastRecentlyUsedKey !== undefined) {
|
||||
const leastRecentlyUsedEntry = entries.get(leastRecentlyUsedKey);
|
||||
if (leastRecentlyUsedEntry) {
|
||||
evict(leastRecentlyUsedKey, leastRecentlyUsedEntry);
|
||||
return acquireEntry(key, locate, false).pending;
|
||||
},
|
||||
acquireRetained(key, locate) {
|
||||
const entry = acquireEntry(key, locate, true);
|
||||
let released = false;
|
||||
return {
|
||||
promise: entry.pending,
|
||||
...(entry.resolved ? { value: entry.value } : {}),
|
||||
release() {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const value = await pending;
|
||||
const release = input.onRetain?.(value) ?? null;
|
||||
if (entries.get(key) === entry) {
|
||||
entry.value = value;
|
||||
entry.resolved = true;
|
||||
entry.release = release;
|
||||
} else {
|
||||
release?.();
|
||||
}
|
||||
} catch {
|
||||
evict(key, entry);
|
||||
}
|
||||
})();
|
||||
return pending;
|
||||
released = true;
|
||||
entry.activeConsumers = Math.max(0, entry.activeConsumers - 1);
|
||||
enforceCapacity();
|
||||
},
|
||||
};
|
||||
},
|
||||
peek(key) {
|
||||
const entry = entries.get(key);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ImageLoadEvent } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -301,16 +309,16 @@ function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): Previ
|
||||
? `${id}:${storageType}:${storageKey}:${mimeType}`
|
||||
: null;
|
||||
const [entry, setEntry] = useState<{ key: string | null; state: PreviewUrlState }>(() => {
|
||||
const cached = previewKey ? previewUrlCache.peek(previewKey) : undefined;
|
||||
return {
|
||||
key: previewKey,
|
||||
state: cached ? { status: "loaded", uri: cached.uri } : { status: "waiting" },
|
||||
state: { status: "waiting" },
|
||||
};
|
||||
});
|
||||
const getCurrentAttachment = useStableEvent(() => attachment ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
let disposed = false;
|
||||
let releaseCurrent: (() => void) | null = null;
|
||||
const current = getCurrentAttachment();
|
||||
|
||||
if (!current || !previewKey) {
|
||||
@@ -318,21 +326,34 @@ function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): Previ
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = previewUrlCache.peek(previewKey);
|
||||
if (cached) {
|
||||
setEntry({ key: previewKey, state: { status: "loaded", uri: cached.uri } });
|
||||
return;
|
||||
const acquireCurrent = () => {
|
||||
releaseCurrent?.();
|
||||
const retained = previewUrlCache.acquireRetained(previewKey, async () => ({
|
||||
attachment: current,
|
||||
uri: await resolveAttachmentPreviewUrl(current),
|
||||
}));
|
||||
releaseCurrent = retained.release;
|
||||
return retained;
|
||||
};
|
||||
const initial = acquireCurrent();
|
||||
if (initial.value) {
|
||||
setEntry({ key: previewKey, state: { status: "loaded", uri: initial.value.uri } });
|
||||
return () => {
|
||||
disposed = true;
|
||||
releaseCurrent?.();
|
||||
};
|
||||
}
|
||||
|
||||
setEntry({ key: previewKey, state: { status: "loading" } });
|
||||
void (async () => {
|
||||
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
|
||||
try {
|
||||
const preview = await runAssistantImageOperationWithRetry({
|
||||
operation: async () =>
|
||||
await previewUrlCache.acquire(previewKey, async () => ({
|
||||
attachment: current,
|
||||
uri: await resolveAttachmentPreviewUrl(current),
|
||||
})),
|
||||
operation: async () => {
|
||||
const retained = firstAttempt ?? acquireCurrent();
|
||||
firstAttempt = null;
|
||||
return await retained.promise;
|
||||
},
|
||||
shouldStop: () => disposed,
|
||||
});
|
||||
if (!disposed) {
|
||||
@@ -347,16 +368,13 @@ function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): Previ
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
releaseCurrent?.();
|
||||
};
|
||||
}, [getCurrentAttachment, previewKey]);
|
||||
|
||||
if (!previewKey) {
|
||||
return { status: "waiting" };
|
||||
}
|
||||
const cached = previewUrlCache.peek(previewKey);
|
||||
if (cached) {
|
||||
return { status: "loaded", uri: cached.uri };
|
||||
}
|
||||
return entry.key === previewKey ? entry.state : { status: "waiting" };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user