diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 3ef54b1c91..0204db41eb 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -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(); + 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; diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index dd341543bd..bc6966b18a 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -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 () => { diff --git a/packages/app/src/assistant-image/acquisition-cache.test.ts b/packages/app/src/assistant-image/acquisition-cache.test.ts index 6de05bd1ef..9a245f91fd 100644 --- a/packages/app/src/assistant-image/acquisition-cache.test.ts +++ b/packages/app/src/assistant-image/acquisition-cache.test.ts @@ -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({ + 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(); + }); }); diff --git a/packages/app/src/assistant-image/acquisition-cache.ts b/packages/app/src/assistant-image/acquisition-cache.ts index cbe3ade702..f76f594b88 100644 --- a/packages/app/src/assistant-image/acquisition-cache.ts +++ b/packages/app/src/assistant-image/acquisition-cache.ts @@ -2,6 +2,10 @@ import { createPreviewAttachmentId } from "@/attachments/utils"; export interface AssistantImageAcquisitionCache { acquire(key: string, locate: () => Promise): Promise; + acquireRetained( + key: string, + locate: () => Promise, + ): { promise: Promise; value?: T; release: () => void }; peek(key: string): T | undefined; size(): number; } @@ -53,6 +57,7 @@ export function createAssistantImageAcquisitionCache(input: { resolved: boolean; value?: T; release: (() => void) | null; + activeConsumers: number; } const entries = new Map(); @@ -64,42 +69,79 @@ export function createAssistantImageAcquisitionCache(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, 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); diff --git a/packages/app/src/assistant-image/use-assistant-image.ts b/packages/app/src/assistant-image/use-assistant-image.ts index ecf3d2a8b7..6dc714e1ff 100644 --- a/packages/app/src/assistant-image/use-assistant-image.ts +++ b/packages/app/src/assistant-image/use-assistant-image.ts @@ -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 | 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" }; }