diff --git a/packages/app/src/agent-stream/history-start-settle-scheduler.test.ts b/packages/app/src/agent-stream/history-start-settle-scheduler.test.ts new file mode 100644 index 0000000000..d47650b057 --- /dev/null +++ b/packages/app/src/agent-stream/history-start-settle-scheduler.test.ts @@ -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 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); + }); +}); diff --git a/packages/app/src/agent-stream/history-start-settle-scheduler.ts b/packages/app/src/agent-stream/history-start-settle-scheduler.ts new file mode 100644 index 0000000000..6c93472fca --- /dev/null +++ b/packages/app/src/agent-stream/history-start-settle-scheduler.ts @@ -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; + }, + }; +} diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 09948b354d..cb802918c8 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -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(null); + const historyStartSettleSchedulerRef = useRef(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]); diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 99a3a7ab11..3ef54b1c91 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -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(); - 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(); - 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(); - 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, diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 0c6f79b228..1001754d2d 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -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({ diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 12ded1aecd..15836f623c 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -69,7 +69,7 @@ export interface StreamRenderInput { routeBottomAnchorRequest: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady: boolean; onNearBottomChange: (value: boolean) => void; - onNearHistoryStart: () => void | Promise; + onNearHistoryStart: () => boolean | Promise; isLoadingOlderHistory: boolean; hasOlderHistory: boolean; olderHistoryProgressKey: string | null; diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 0d6bde3c08..63308e56b3 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -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; }; } @@ -694,7 +695,7 @@ const AgentStreamViewComponent = forwardRef ); }, - [client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], + [agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], ); const renderThoughtItem = useCallback( diff --git a/packages/app/src/assistant-image/acquisition-cache.test.ts b/packages/app/src/assistant-image/acquisition-cache.test.ts index 2b0ee9f675..099f88caee 100644 --- a/packages/app/src/assistant-image/acquisition-cache.test.ts +++ b/packages/app/src/assistant-image/acquisition-cache.test.ts @@ -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[] = []; diff --git a/packages/app/src/assistant-image/acquisition-cache.ts b/packages/app/src/assistant-image/acquisition-cache.ts index 32f0586cce..6d21379bff 100644 --- a/packages/app/src/assistant-image/acquisition-cache.ts +++ b/packages/app/src/assistant-image/acquisition-cache.ts @@ -5,7 +5,15 @@ export interface AssistantImageAcquisitionCache { 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}`, }); } diff --git a/packages/app/src/assistant-image/use-assistant-image.ts b/packages/app/src/assistant-image/use-assistant-image.ts index 6125342e90..e2955669d0 100644 --- a/packages/app/src/assistant-image/use-assistant-image.ts +++ b/packages/app/src/assistant-image/use-assistant-image.ts @@ -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, diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index a5bac3b7bf..12558a2ed9 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -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,