From d57777ff3b28772b34fbc9c711a0532c2d007bd7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 09:34:16 +0100 Subject: [PATCH] fix(app): fill short collapsed chat histories Collapsed whole turns can leave the initial viewport short even when older history exists. Continue paging through measured pages before revealing a fresh chat, while preserving visible history and the reading anchor. --- docs/timeline-sync.md | 4 +- .../app/e2e/browser/turn-collapse.spec.ts | 72 +++- .../history-start-pagination.test.ts | 363 +++++++++--------- .../agent-stream/history-start-pagination.ts | 221 ++++++----- .../app/src/agent-stream/strategy-native.tsx | 227 +++++------ .../src/agent-stream/strategy-web.test.tsx | 4 +- .../app/src/agent-stream/strategy-web.tsx | 154 ++++---- packages/app/src/agent-stream/strategy.ts | 4 +- .../use-history-start-pagination.ts | 33 ++ packages/app/src/agent-stream/view.tsx | 12 + .../use-load-older-agent-history.test.ts | 6 +- .../src/hooks/use-load-older-agent-history.ts | 12 +- packages/app/src/panels/agent-panel.tsx | 16 + .../src/panels/provider-subagent-panel.tsx | 29 +- .../app/src/subagents/provider-store.test.ts | 28 ++ packages/app/src/subagents/provider-store.ts | 28 ++ 16 files changed, 697 insertions(+), 516 deletions(-) create mode 100644 packages/app/src/agent-stream/use-history-start-pagination.ts diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 3d6af249bf..5f34255c1e 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -69,8 +69,8 @@ Source coverage still includes interleaved tool updates. Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received agent messages each start a fallback turn. This deterministic fallback is shared by paging -and presentation; it cannot recover historical batching or steering. Older history remains -user-driven by scrolling upward. +and presentation; it cannot recover historical batching or steering. An underfilled viewport +loads older history until it fills or history is exhausted. Further history is user-driven. A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling. A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index d990e33009..6aa26575cd 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -407,34 +407,86 @@ async function seedWholeTurnHistory() { return agent; } -test("loads complete long turns and preserves expansion while revealing older history", async ({ - page, -}) => { +async function expectInitialFillIsConcealed(page: Page) { + await expect(page.getByTestId("agent-initial-fill")).toBeVisible(); + await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "0"); +} + +async function expectFilledChatAtBottom(page: Page) { + await expect(page.getByTestId("agent-initial-fill")).toBeHidden(); + const chat = page.getByTestId("agent-chat-scroll"); + await expect(chat).toHaveCSS("opacity", "1"); + await expect + .poll(() => + chat.evaluate((element) => ({ + hasOverflow: element.scrollHeight > element.clientHeight, + atBottom: Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 2, + })), + ) + .toEqual({ hasOverflow: true, atBottom: true }); +} + +test("fills collapsed history before revealing a fresh chat", async ({ page }) => { test.setTimeout(120_000); + await page.setViewportSize({ width: 1280, height: 1000 }); await useChatDisplay(page, "collapseTurns"); const agent = await seedWholeTurnHistory(); try { const history = await holdOlderHistoryPages(page, agent); await openAgentRoute(page, agent); + await history.expectRequestedPages(1); + await expectInitialFillIsConcealed(page); + history.releasePage(1); + await history.expectRequestedPages(2); + await expectInitialFillIsConcealed(page); + history.releasePage(2); + await expectFilledChatAtBottom(page); + await page.setViewportSize({ width: 1280, height: 8000 }); + await history.expectRequestedPages(3); + await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "1"); + await expect(page.getByTestId("agent-initial-fill")).toBeHidden(); + history.releasePage(3); + await history.expectSettledWithRequestedPages(3); + history.expectNoRepeatedEntries(); + } finally { + await agent.cleanup(); + } +}); + +test("loads complete long turns and preserves expansion while revealing older history", async ({ + page, +}) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 1280, height: 1000 }); + await useChatDisplay(page, "collapseTurns"); + const agent = await seedWholeTurnHistory(); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentRoute(page, agent); + await history.expectRequestedPages(1); + history.releasePage(1); + await history.expectRequestedPages(2); + history.releasePage(2); + await expectFilledChatAtBottom(page); await expectComposerVisible(page); const latest = page .getByTestId("turn-summary") .getByRole("button", { name: "Ran 120 commands", exact: true }); await expect(latest).toBeVisible(); - await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); - await latest.click(); - await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); - await userScrollsTimelineToHistoryStart(page); - await history.expectRequestedPages(1); - history.releasePage(1); await expect( page .getByTestId("turn-summary") .getByRole("button", { name: "Ran 60 commands", exact: true }), ).toBeVisible(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + await latest.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(3); + history.releasePage(3); + await page.getByTestId("scroll-to-bottom-button").click(); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); history.expectNoRepeatedEntries(); - await latest.scrollIntoViewIfNeeded(); await latest.click(); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); } finally { diff --git a/packages/app/src/agent-stream/history-start-pagination.test.ts b/packages/app/src/agent-stream/history-start-pagination.test.ts index 9663d87e23..c7ebfca273 100644 --- a/packages/app/src/agent-stream/history-start-pagination.test.ts +++ b/packages/app/src/agent-stream/history-start-pagination.test.ts @@ -1,197 +1,204 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { - abandonHistoryStartPaginationRequest, - createHistoryStartPaginationState, - evaluateHistoryStartPagination, - isHistoryStartLoadingOperation, - rearmHistoryStartPagination, - settleHistoryStartPagination, + createHistoryStartPagination, type HistoryStartPaginationInput, } from "./history-start-pagination"; -function createArmedHistoryStartPaginationState() { - return rearmHistoryStartPagination(createHistoryStartPaginationState()); -} - -const visibleHistoryStart: HistoryStartPaginationInput = { +const initial: HistoryStartPaginationInput = { distanceFromHistoryStart: 0, + isUnderfilled: true, hasOlderHistory: true, isLoadingOlderHistory: false, isReady: true, - progressKey: "epoch-1:20", + progressKey: "20", }; +function harness(initiallyVisible = false) { + let geometry = { ...initial }; + const requests: Array<(value: boolean) => void> = []; + let settles = 0; + const pagination = createHistoryStartPagination({ + initiallyVisible, + read: () => geometry, + load: () => new Promise((resolve) => requests.push(resolve)), + changed: () => {}, + scheduleSettle: () => { + settles++; + }, + }); + return { + pagination, + requests, + get settles() { + return settles; + }, + set(patch: Partial) { + geometry = { ...geometry, ...patch }; + pagination.update(); + }, + }; +} + describe("history start pagination", () => { - it("treats locally revealed history as progress before remote pagination", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const localReveal = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:20:local-60", - }); - - expect(first.shouldLoad).toBe(true); - expect(localReveal.state).toEqual({ - status: "settling", - loadedProgressKey: "epoch-1:20:local-60", - }); + it("waits for a delayed request and its committed page before settling", async () => { + const h = harness(); + h.pagination.update(); + h.set({ progressKey: "10" }); + expect(h.settles).toBe(0); + h.requests[0](true); + await Promise.resolve(); + expect(h.settles).toBe(1); + expect(h.pagination.state.visible).toBe(false); + h.set({ isUnderfilled: false }); + h.pagination.settle(); + expect(h.pagination.state).toEqual({ status: "dormant", visible: true }); + h.pagination.dispose(); }); - it("waits for user scroll intent before loading", () => { - const dormant = createHistoryStartPaginationState(); - const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart); - - expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false }); + it("waits for the committed page after a request completes", async () => { + const h = harness(); + h.pagination.update(); + h.requests[0](true); + await Promise.resolve(); + expect(h.settles).toBe(0); + h.set({ progressKey: "10" }); + expect(h.settles).toBe(1); + h.pagination.dispose(); }); - - it("waits for anchored page geometry before authorizing another page", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const inFlight = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, - }); - const pageApplied = evaluateHistoryStartPagination(inFlight.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, - progressKey: "epoch-1:10", - }); - - expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([ - true, - false, - false, - ]); - expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" }); + it("continues short pages and reveals on exhaustion", async () => { + const h = harness(); + h.pagination.update(); + h.set({ progressKey: "10" }); + h.requests[0](true); + await Promise.resolve(); + h.pagination.settle(); + expect(h.requests).toHaveLength(2); + h.set({ hasOlderHistory: false }); + h.requests[1](true); + await Promise.resolve(); + expect(h.pagination.state).toEqual({ status: "dormant", visible: true }); + h.pagination.dispose(); }); - - it("loads one page each time anchored geometry leaves and returns to history start", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const pageApplied = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - const settledAway = settleHistoryStartPagination(pageApplied.state, { - ...visibleHistoryStart, - distanceFromHistoryStart: 300, - progressKey: "epoch-1:10", - }); - const returned = evaluateHistoryStartPagination(settledAway.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - - expect([ - first.shouldLoad, - pageApplied.shouldLoad, - settledAway.shouldLoad, - returned.shouldLoad, - ]).toEqual([true, false, false, true]); + it("releases a failed or stalled request and allows retry", async () => { + vi.useFakeTimers(); + const h = harness(); + h.pagination.update(); + vi.advanceTimersByTime(15_000); + expect(h.pagination.state).toEqual({ status: "failed", visible: true }); + h.pagination.rearm(); + expect(h.requests).toHaveLength(2); + h.requests[1](false); + await Promise.resolve(); + expect(h.pagination.state.status).toBe("failed"); + h.pagination.dispose(); + vi.useRealTimers(); }); - - it("continues the same loading operation when anchored geometry remains at history start", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const pageApplied = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - const continued = settleHistoryStartPagination(pageApplied.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - - expect(continued.shouldLoad).toBe(true); - expect(isHistoryStartLoadingOperation(first.state)).toBe(true); - expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true); - expect(isHistoryStartLoadingOperation(continued.state)).toBe(true); + it("keeps visible history visible while filling", () => { + const h = harness(true); + h.pagination.update(); + expect(h.pagination.state.visible).toBe(true); + h.pagination.dispose(); }); - - it("latches a request that finishes without cursor progress", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const inFlight = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, - }); - const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart); - const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart); - const away = evaluateHistoryStartPagination(duplicate.state, { - ...visibleHistoryStart, - distanceFromHistoryStart: 300, - }); - - expect(finished.state).toEqual({ status: "latched" }); - expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([ - false, - false, - false, - ]); - expect(away.state).toEqual({ status: "ready" }); + it("does not load a full viewport until user intent and respects inactive views", () => { + const h = harness(); + h.set({ isUnderfilled: false }); + expect(h.requests).toHaveLength(0); + h.set({ isReady: false }); + h.pagination.rearm(); + expect(h.requests).toHaveLength(0); + h.set({ isReady: true }); + expect(h.requests).toHaveLength(1); + h.pagination.dispose(); }); - - it("allows another user attempt after a request finishes without progress", () => { - const first = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const inFlight = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, - }); - const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart); - const retried = evaluateHistoryStartPagination( - rearmHistoryStartPagination(failed.state), - visibleHistoryStart, - ); - - expect(failed.state).toEqual({ status: "latched" }); - expect(retried.shouldLoad).toBe(true); - }); - - it("latches an attempt that becomes invalid before entering flight", () => { - const requested = evaluateHistoryStartPagination( - createArmedHistoryStartPaginationState(), - 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( - createArmedHistoryStartPaginationState(), - visibleHistoryStart, - ); - const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart); - - expect(repeated.state).toBe(first.state); - expect(repeated.shouldLoad).toBe(false); - }); - - it("waits while history loading is unavailable or already active", () => { - const state = createArmedHistoryStartPaginationState(); - - expect([ - evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad, - evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false }) - .shouldLoad, - evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true }) - .shouldLoad, - evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null }) - .shouldLoad, - ]).toEqual([false, false, false, false]); + it("preserves the reading position after an anchored page moves away from the start", async () => { + const h = harness(true); + h.set({ isUnderfilled: false }); + h.pagination.rearm(); + h.set({ progressKey: "10", distanceFromHistoryStart: 300 }); + h.requests[0](true); + await Promise.resolve(); + h.pagination.settle(); + expect(h.requests).toHaveLength(1); + h.set({ distanceFromHistoryStart: 0 }); + expect(h.requests).toHaveLength(2); + h.pagination.dispose(); }); }); + +it("ignores a timed-out response after the reader retries", async () => { + vi.useFakeTimers(); + const h = harness(); + h.pagination.update(); + vi.advanceTimersByTime(15_000); + h.pagination.rearm(); + h.requests[0](true); + await Promise.resolve(); + expect(h.pagination.state.status).toBe("loading"); + expect(h.settles).toBe(0); + h.requests[1](false); + await Promise.resolve(); + expect(h.pagination.state.status).toBe("failed"); + h.pagination.dispose(); + vi.useRealTimers(); +}); + +it("does not repeat a successful page that made no progress", async () => { + vi.useFakeTimers(); + const h = harness(); + h.pagination.update(); + h.requests[0](true); + await Promise.resolve(); + h.pagination.update(); + expect(h.requests).toHaveLength(1); + vi.advanceTimersByTime(15_000); + expect(h.pagination.state).toEqual({ status: "failed", visible: true }); + h.pagination.dispose(); + vi.useRealTimers(); +}); + +it("waits until the cursor and active history are available", () => { + const h = harness(); + h.set({ isReady: false, progressKey: null }); + h.set({ isReady: true, isLoadingOlderHistory: true }); + h.set({ isLoadingOlderHistory: false }); + expect(h.requests).toHaveLength(0); + h.set({ progressKey: "20" }); + expect(h.requests).toHaveLength(1); + h.pagination.dispose(); +}); + +it("reveals immediately on exhausted history without offering retry", () => { + const h = harness(); + h.set({ hasOlderHistory: false }); + expect(h.requests).toHaveLength(0); + expect(h.pagination.state).toEqual({ status: "dormant", visible: true }); + h.pagination.dispose(); +}); + +it("keeps a settled page pending while its view is inactive", async () => { + const h = harness(); + h.pagination.update(); + h.set({ progressKey: "10" }); + h.requests[0](true); + await Promise.resolve(); + h.set({ isReady: false }); + h.pagination.settle(); + expect(h.pagination.state.status).toBe("settling"); + expect(h.requests).toHaveLength(1); + h.set({ isReady: true, isUnderfilled: false }); + h.pagination.settle(); + expect(h.pagination.state).toEqual({ status: "dormant", visible: true }); + h.pagination.dispose(); +}); + +it("keeps cached history visible after synchronization and subsequent pagination", async () => { + const h = harness(); + h.pagination.reveal(); + h.pagination.update(); + h.set({ progressKey: "10" }); + h.requests[0](true); + await Promise.resolve(); + h.pagination.settle(); + expect(h.pagination.state.visible).toBe(true); + expect(h.requests).toHaveLength(2); + h.pagination.dispose(); +}); diff --git a/packages/app/src/agent-stream/history-start-pagination.ts b/packages/app/src/agent-stream/history-start-pagination.ts index 46914ae3e1..1020b9df07 100644 --- a/packages/app/src/agent-stream/history-start-pagination.ts +++ b/packages/app/src/agent-stream/history-start-pagination.ts @@ -1,138 +1,135 @@ export const HISTORY_START_THRESHOLD_PX = 96; -export type HistoryStartPaginationState = - | { status: "dormant" } - | { status: "ready" } - | { status: "loading"; requestedProgressKey: string; requestObserved: boolean } - | { status: "settling"; loadedProgressKey: string } - | { status: "latched" }; - export interface HistoryStartPaginationInput { distanceFromHistoryStart: number; + isUnderfilled: boolean; hasOlderHistory: boolean; isLoadingOlderHistory: boolean; isReady: boolean; progressKey: string | null; } -export interface HistoryStartPaginationTransition { - state: HistoryStartPaginationState; - shouldLoad: boolean; -} - -export function createHistoryStartPaginationState(): HistoryStartPaginationState { - return { status: "dormant" }; +export interface HistoryStartPaginationState { + status: "dormant" | "ready" | "loading" | "settling" | "failed"; + visible: boolean; } export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean { return state.status === "loading" || state.status === "settling"; } -export function rearmHistoryStartPagination( - state: HistoryStartPaginationState, -): HistoryStartPaginationState { - return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state; -} +/** Owns one pagination operation, including the initial viewport reveal. */ +export function createHistoryStartPagination(input: { + initiallyVisible: boolean; + read: () => HistoryStartPaginationInput | null; + load: () => Promise; + changed: (state: HistoryStartPaginationState) => void; + scheduleSettle: () => void; +}) { + let state: HistoryStartPaginationState = { status: "dormant", visible: input.initiallyVisible }; + let requestedKey: string | null = null; + let completed = false; + let fill = false; + let generation = 0; + let timeout: ReturnType | undefined; -export function abandonHistoryStartPaginationRequest( - state: HistoryStartPaginationState, - requestedProgressKey: string, -): HistoryStartPaginationState { - if ( - state.status !== "loading" || - state.requestObserved || - state.requestedProgressKey !== requestedProgressKey - ) { - return state; - } - return { status: "latched" }; -} + const publish = (status: HistoryStartPaginationState["status"], visible = state.visible) => { + if (status === state.status && visible === state.visible) return; + state = { status, visible }; + input.changed(state); + }; + const clear = () => { + generation++; + clearTimeout(timeout); + timeout = undefined; + }; + const finish = (status: HistoryStartPaginationState["status"]) => { + clear(); + publish(status, true); + }; + const fail = () => finish("failed"); -export function evaluateHistoryStartPagination( - state: HistoryStartPaginationState, - input: HistoryStartPaginationInput, -): HistoryStartPaginationTransition { - if (state.status === "dormant") { - return { state, shouldLoad: false }; - } - if (state.status === "loading") { - if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) { - return { - state: { status: "settling", loadedProgressKey: input.progressKey }, - shouldLoad: false, - }; + const update = () => { + const geometry = input.read(); + if (!geometry) return; + if (state.status === "loading") { + if (!completed) return; + if (geometry.progressKey !== null && geometry.progressKey !== requestedKey) { + publish("settling"); + input.scheduleSettle(); + } else if (!geometry.hasOlderHistory) { + finish("dormant"); + } + return; } - if (input.isLoadingOlderHistory && !state.requestObserved) { - return { state: { ...state, requestObserved: true }, shouldLoad: false }; + if (state.status === "settling" || state.status === "failed" || !geometry.isReady) return; + const atStart = geometry.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; + if (state.status === "dormant" && !geometry.isUnderfilled) { + publish("dormant", true); + return; } - if (!input.isLoadingOlderHistory && !input.hasOlderHistory) { - return { state: { status: "latched" }, shouldLoad: false }; + if (!atStart || (!geometry.isUnderfilled && state.status !== "ready")) return; + if (!geometry.hasOlderHistory) { + finish("dormant"); + return; } - if ( - state.requestObserved && - !input.isLoadingOlderHistory && - input.progressKey === state.requestedProgressKey - ) { - return { state: { status: "latched" }, shouldLoad: false }; - } - return { state, shouldLoad: false }; - } - - if (state.status === "settling") { - return { state, shouldLoad: false }; - } - - const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; - if (!isAtHistoryStart) { - return state.status === "ready" - ? { state, shouldLoad: false } - : { state: { status: "ready" }, shouldLoad: false }; - } - if ( - state.status === "latched" || - !input.isReady || - !input.hasOlderHistory || - input.isLoadingOlderHistory || - input.progressKey === null - ) { - return { state, shouldLoad: false }; - } + if (geometry.isLoadingOlderHistory || geometry.progressKey === null) return; + fill = geometry.isUnderfilled; + requestedKey = geometry.progressKey; + completed = false; + publish("loading"); + const request = ++generation; + timeout = setTimeout(() => { + if (request === generation) fail(); + }, 15_000); + void input.load().then( + (applied) => { + if (request !== generation) return undefined; + if (!applied) { + fail(); + return undefined; + } + completed = true; + update(); + return undefined; + }, + () => { + if (request === generation) fail(); + }, + ); + }; return { - state: { - status: "loading", - requestedProgressKey: input.progressKey, - requestObserved: false, + get state() { + return state; }, - shouldLoad: true, - }; -} - -export function settleHistoryStartPagination( - state: HistoryStartPaginationState, - input: HistoryStartPaginationInput, -): HistoryStartPaginationTransition { - if (state.status !== "settling") { - return { state, shouldLoad: false }; - } - const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; - if ( - !isAtHistoryStart || - !input.isReady || - !input.hasOlderHistory || - input.isLoadingOlderHistory || - input.progressKey === null - ) { - return { - state: isAtHistoryStart ? { status: "latched" } : { status: "ready" }, - shouldLoad: false, - }; - } - return { - state: { - status: "loading", - requestedProgressKey: input.progressKey, - requestObserved: false, + update, + settle() { + if (state.status !== "settling") return; + const geometry = input.read(); + if (!geometry?.isReady) return; + clear(); + if (fill && !geometry.isUnderfilled) { + publish("dormant", true); + } else if (geometry.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) { + publish("ready", true); + } else if (!geometry.hasOlderHistory) { + publish("dormant", true); + } else { + publish("ready"); + update(); + } }, - shouldLoad: true, + rearm() { + if (state.status === "dormant" || state.status === "failed") publish("ready", true); + update(); + }, + reveal() { + publish(state.status, true); + }, + reset(visible: boolean) { + clear(); + publish("dormant", visible); + }, + dispose: clear, }; } diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 7dc6e8d521..93a940dd50 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -1,12 +1,4 @@ -import { - Fragment, - type ReactElement, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { Fragment, type ReactElement, useCallback, useEffect, useMemo, useRef } from "react"; import { FlatList, Platform, @@ -19,6 +11,9 @@ import { } from "react-native"; import { withUnistyles } from "react-native-unistyles"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Button } from "@/components/ui/button"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useTranslation } from "react-i18next"; import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -34,15 +29,10 @@ import { resolveBottomAnchorTransportBehavior, } from "./strategy"; import { - abandonHistoryStartPaginationRequest, - createHistoryStartPaginationState, - evaluateHistoryStartPagination, isHistoryStartLoadingOperation, - rearmHistoryStartPagination, - settleHistoryStartPagination, type HistoryStartPaginationInput, - type HistoryStartPaginationTransition, } from "./history-start-pagination"; +import { useHistoryStartPagination } from "./use-history-start-pagination"; import { useStreamTopOverlayInset } from "./top-overlay"; import { createHistoryStartSettleScheduler, @@ -65,6 +55,16 @@ const historyStartSlotStyle: ViewStyle = { flexShrink: 0, }; const HISTORY_START_SETTLE_FRAMES = 2; +const viewportStyle: ViewStyle = { flex: 1 }; +const initialFillStyle: ViewStyle = { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: "center", + justifyContent: "center", +}; const NATIVE_STREAM_WINDOW_SIZE = 10; function keyExtractor(item: { id: string }): string { @@ -83,6 +83,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat viewportRef, routeBottomAnchorRequest, isAuthoritativeHistoryReady, + isInitialHistoryLoading, + revealInitialHistory, onNearBottomChange, onNearHistoryStart, isLoadingOlderHistory, @@ -93,6 +95,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat baseListContentContainerStyle, strategy, } = props; + const isActive = useRetainedPanelActive(); + const { t } = useTranslation(); const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers; const flatListRef = useRef>(null); const streamViewportMetricsRef = useRef({ @@ -113,10 +117,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const isNativeViewportSettlingRef = useRef(false); const nativeViewportSettlingFrameIdRef = useRef(null); const historyStartReadyRef = useRef(false); - const [historyStartPaginationState, setHistoryStartPaginationState] = useState( - createHistoryStartPaginationState, - ); - const historyStartPaginationStateRef = useRef(historyStartPaginationState); const historyStartSettleSchedulerRef = useRef(null); const historyItems = useMemo(() => { @@ -133,47 +133,39 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat metrics.contentMeasuredForKey === metrics.containerKey; return { distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY, + isUnderfilled: + metrics.contentHeight - + 32 - + topOverlayInset - + streamKeyboardInset.contentContainerPaddingBottom < + metrics.viewportHeight, hasOlderHistory, isLoadingOlderHistory, - isReady: historyStartReadyRef.current && hasMeasuredViewport, + isReady: + isActive && + isAuthoritativeHistoryReady && + historyStartReadyRef.current && + hasMeasuredViewport, progressKey: olderHistoryProgressKey, }; }); - const applyHistoryStartPaginationTransition = useStableEvent( - (transition: HistoryStartPaginationTransition) => { - const previousState = historyStartPaginationStateRef.current; - historyStartPaginationStateRef.current = transition.state; - if (transition.state !== previousState) { - setHistoryStartPaginationState(transition.state); - } - if (transition.shouldLoad) { - const requestedProgressKey = olderHistoryProgressKey; - if (requestedProgressKey === null) { - return; - } - void (async () => { - const started = await onNearHistoryStart(); - if (started === true) { - return; - } - applyHistoryStartPaginationTransition({ - state: abandonHistoryStartPaginationRequest( - historyStartPaginationStateRef.current, - requestedProgressKey, - ), - shouldLoad: false, - }); - })(); - } - }, + const { state: historyStartPaginationState, controller: historyStartPagination } = + useHistoryStartPagination({ + initiallyVisible: !isInitialHistoryLoading, + read: getHistoryStartPaginationInput, + load: async () => onNearHistoryStart(), + scheduleSettle: () => scheduleHistoryStartSettle(), + }); + const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update()); + const resetHistoryStart = useStableEvent(() => + historyStartPagination.reset(!isInitialHistoryLoading), ); - const evaluateHistoryStart = useStableEvent(() => { - const transition = evaluateHistoryStartPagination( - historyStartPaginationStateRef.current, - getHistoryStartPaginationInput(), - ); - applyHistoryStartPaginationTransition(transition); - }); + const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose()); + const isHistoryStartSettling = useStableEvent( + () => historyStartPagination.state.status === "settling", + ); + const finishInitialFill = useStableEvent(() => historyStartPagination.reveal()); + const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm()); const scheduleHistoryStartSettle = useStableEvent(() => { let scheduler = historyStartSettleSchedulerRef.current; if (!scheduler) { @@ -181,15 +173,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat settleFrames: HISTORY_START_SETTLE_FRAMES, requestFrame: requestAnimationFrame, cancelFrame: cancelAnimationFrame, - isSettling: () => historyStartPaginationStateRef.current.status === "settling", + isSettling: () => isHistoryStartSettling(), isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory, - onSettle: () => { - const transition = settleHistoryStartPagination( - historyStartPaginationStateRef.current, - getHistoryStartPaginationInput(), - ); - applyHistoryStartPaginationTransition(transition); - }, + onSettle: () => historyStartPagination.settle(), }); historyStartSettleSchedulerRef.current = scheduler; } @@ -328,9 +314,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat clearNativeViewportSettling(); isNativeViewportSettlingRef.current = false; historyStartReadyRef.current = false; - const initialHistoryStartState = createHistoryStartPaginationState(); - historyStartPaginationStateRef.current = initialHistoryStartState; - setHistoryStartPaginationState(initialHistoryStartState); + resetHistoryStart(); const frame = requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -338,10 +322,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat return () => { cancelAnimationFrame(frame); clearPendingUserScrollEnd(); + disposeHistoryStart(); historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]); + }, [ + agentId, + clearNativeViewportSettling, + clearPendingUserScrollEnd, + disposeHistoryStart, + evaluateHistoryStart, + resetHistoryStart, + ]); useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]); @@ -421,18 +413,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent) => { + finishInitialFill(); clearPendingUserScrollEnd(); isUserScrollActiveRef.current = true; scrollKeyboardDismiss.onScrollBeginDrag(event); bottomAnchorController.beginUserScroll(); - const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); - if (rearmed !== historyStartPaginationStateRef.current) { - historyStartPaginationStateRef.current = rearmed; - setHistoryStartPaginationState(rearmed); - evaluateHistoryStart(); - } + historyStartPagination.rearm(); }); + useEffect(() => { + if (revealInitialHistory) finishInitialFill(); + }, [finishInitialFill, revealInitialHistory]); + // Defer drag end so momentum can take ownership, but capture the terminal // gesture position now because layout may move the viewport in the meantime. const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent) => { @@ -506,22 +498,25 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentHeight: nextContentHeight, }); evaluateHistoryStart(); - if (historyStartPaginationStateRef.current.status === "settling") { + if (isHistoryStartSettling()) { scheduleHistoryStartSettle(); } }); useEffect(() => { evaluateHistoryStart(); - if (historyStartPaginationStateRef.current.status === "settling") { + if (isHistoryStartSettling()) { scheduleHistoryStartSettle(); } }, [ evaluateHistoryStart, hasOlderHistory, + isActive, + isAuthoritativeHistoryReady, isLoadingOlderHistory, olderHistoryProgressKey, scheduleHistoryStartSettle, + isHistoryStartSettling, ]); const renderItem = useStableEvent( @@ -566,50 +561,64 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); return ( - {isLoadingOperation ? ( + {isLoadingOperation && ( - ) : null} + )} + {!isLoadingOperation && + historyStartPaginationState.status === "failed" && + hasOlderHistory && ( + + )} ); - }, [historyStartPaginationState]); + }, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]); // RN's FlatList strictMode keeps its internal renderItem wrapper stable when // data or the live header changes, preserving the row identities above. return ( - + + + {!historyStartPaginationState.visible ? ( + + + + ) : null} + ); } diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 5ad0be03c2..3601c8b0b2 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -152,7 +152,7 @@ describe("createWebStreamStrategy", () => { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: () => {}, - onNearHistoryStart: () => true, + onNearHistoryStart: async () => true, isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: () => {}, - onNearHistoryStart: () => true, + onNearHistoryStart: async () => 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 9dae42bcc0..bbd1811f9c 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { useTranslation } from "react-i18next"; import { getStreamItemMessageId, type StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; @@ -30,15 +31,10 @@ import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./ import { useRevisedHistoryRows } from "./history-row-revision"; import { createStreamStrategy } from "./strategy"; import { - abandonHistoryStartPaginationRequest, - createHistoryStartPaginationState, - evaluateHistoryStartPagination, isHistoryStartLoadingOperation, - rearmHistoryStartPagination, - settleHistoryStartPagination, type HistoryStartPaginationInput, - type HistoryStartPaginationTransition, } from "./history-start-pagination"; +import { useHistoryStartPagination } from "./use-history-start-pagination"; import { createHistoryStartSettleScheduler, type HistoryStartSettleScheduler, @@ -77,6 +73,14 @@ const historyStartSlotStyle: CSSProperties = { }; const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" }; +const initialFillStyle: CSSProperties = { + position: "absolute", + inset: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + pointerEvents: "none", +}; const streamRowStyle: CSSProperties = { display: "flex", @@ -298,6 +302,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool viewportRef, routeBottomAnchorRequest, isAuthoritativeHistoryReady, + isInitialHistoryLoading, + revealInitialHistory, onNearBottomChange, onReadingPositionChange, onNearHistoryStart, @@ -318,6 +324,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool [historyMounted, historyVirtualized, inputSegments], ); const isActive = useRetainedPanelActive(); + const { t } = useTranslation(); + const rowsHostRef = useRef(null); + const auxiliaryRef = useRef(null); const isActiveRef = useRef(isActive); const scrollContainerRef = useRef(null); const contentRef = useRef(null); @@ -351,10 +360,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollTimeoutRef = useRef(null); const historyStartReadyRef = useRef(false); - const [historyStartPaginationState, setHistoryStartPaginationState] = useState( - createHistoryStartPaginationState, - ); - const historyStartPaginationStateRef = useRef(historyStartPaginationState); const readingAnchor = useMemo(createReadingAnchor, []); const measuredRowHeights = useRef(new Map()); const historyStartSettleSchedulerRef = useRef(null); @@ -479,60 +484,39 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool !followOutputRef.current || isScrollContainerNearBottom(scrollContainer); return { distanceFromHistoryStart: scrollContainer.scrollTop, + isUnderfilled: + (rowsHostRef.current?.getBoundingClientRect().height ?? 0) + + (auxiliaryRef.current?.getBoundingClientRect().height ?? 0) < + scrollContainer.clientHeight, hasOlderHistory, isLoadingOlderHistory, // An explicit jump owns the landing position. Revealing another local // page here changes the virtual index before the target has mounted. - isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(), + isReady: + historyStartReadyRef.current && + isAuthoritativeHistoryReady && + bottomAnchorSettled && + !isJumpSettling(), progressKey: olderHistoryProgressKey, }; }); - const applyHistoryStartPaginationTransition = useStableEvent( - (transition: HistoryStartPaginationTransition) => { - const previousState = historyStartPaginationStateRef.current; - historyStartPaginationStateRef.current = transition.state; - if (transition.state !== previousState) { - setHistoryStartPaginationState(transition.state); - } - if (!transition.shouldLoad || olderHistoryProgressKey === null) { - return; - } - const requestedProgressKey = olderHistoryProgressKey; - void (async () => { - const started = await onNearHistoryStart(); - if (started === true) { - return; - } - applyHistoryStartPaginationTransition({ - state: abandonHistoryStartPaginationRequest( - historyStartPaginationStateRef.current, - requestedProgressKey, - ), - shouldLoad: false, - }); - })(); - }, + const { state: historyStartPaginationState, controller: historyStartPagination } = + useHistoryStartPagination({ + initiallyVisible: !isInitialHistoryLoading, + read: getHistoryStartPaginationInput, + load: async () => onNearHistoryStart(), + scheduleSettle: () => scheduleHistoryStartPrependSettle(), + }); + const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update()); + const resetHistoryStart = useStableEvent(() => + historyStartPagination.reset(!isInitialHistoryLoading), ); - const evaluateHistoryStart = useStableEvent(() => { - const input = getHistoryStartPaginationInput(); - if (!input) { - return; - } - const transition = evaluateHistoryStartPagination( - historyStartPaginationStateRef.current, - input, - ); - applyHistoryStartPaginationTransition(transition); - }); - const rearmHistoryStartFromUserIntent = useStableEvent(() => { - const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); - if (rearmed === historyStartPaginationStateRef.current) { - return; - } - historyStartPaginationStateRef.current = rearmed; - setHistoryStartPaginationState(rearmed); - evaluateHistoryStart(); - }); + const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose()); + const isHistoryStartSettling = useStableEvent( + () => historyStartPagination.state.status === "settling", + ); + const finishInitialFill = useStableEvent(() => historyStartPagination.reveal()); + const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm()); const scheduleHistoryStartPrependSettle = useStableEvent(() => { let scheduler = historyStartSettleSchedulerRef.current; if (!scheduler) { @@ -540,22 +524,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool settleFrames: HISTORY_START_SETTLE_FRAMES, requestFrame: (callback) => window.requestAnimationFrame(callback), cancelFrame: (frame) => window.cancelAnimationFrame(frame), - isSettling: () => historyStartPaginationStateRef.current.status === "settling", + isSettling: () => isHistoryStartSettling(), isLoading: () => { const input = getHistoryStartPaginationInput(); return !input || input.isLoadingOlderHistory; }, - onSettle: () => { - const input = getHistoryStartPaginationInput(); - if (!input) { - return; - } - const transition = settleHistoryStartPagination( - historyStartPaginationStateRef.current, - input, - ); - applyHistoryStartPaginationTransition(transition); - }, + onSettle: () => historyStartPagination.settle(), }); historyStartSettleSchedulerRef.current = scheduler; } @@ -782,6 +756,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ]); const stopFollowingOutputFromUserIntent = useStableEvent(() => { + finishInitialFill(); cancelPendingStickToBottom(); if (followOutputRef.current) { setFollowOutput(false); @@ -789,6 +764,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool rearmHistoryStartFromUserIntent(); }); + useEffect(() => { + if (revealInitialHistory) finishInitialFill(); + }, [finishInitialFill, revealInitialHistory]); + const handleDomScroll = useCallback(() => { const scrollContainer = scrollContainerRef.current; if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) { @@ -827,9 +806,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ]); useEffect(() => { - const initialHistoryStartState = createHistoryStartPaginationState(); - historyStartPaginationStateRef.current = initialHistoryStartState; - setHistoryStartPaginationState(initialHistoryStartState); + resetHistoryStart(); readingAnchor.reset(); const frame = window.requestAnimationFrame(() => { historyStartReadyRef.current = true; @@ -838,10 +815,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return () => { window.cancelAnimationFrame(frame); historyStartReadyRef.current = false; + disposeHistoryStart(); historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [evaluateHistoryStart, props.agentId, readingAnchor]); + }, [disposeHistoryStart, evaluateHistoryStart, props.agentId, readingAnchor, resetHistoryStart]); useLayoutEffect(() => { if (!isActiveRef.current || !isActivationReady) { @@ -930,16 +908,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool updateScrollMetrics(); } evaluateHistoryStart(); - if (historyStartPaginationStateRef.current.status === "settling") { + if (isHistoryStartSettling()) { scheduleHistoryStartPrependSettle(); } }, [ evaluateHistoryStart, hasOlderHistory, isActive, + isAuthoritativeHistoryReady, isLoadingOlderHistory, olderHistoryProgressKey, scheduleHistoryStartPrependSettle, + isHistoryStartSettling, segments.historyMounted.length, segments.historyVirtualized.length, segments.liveHead.length, @@ -978,7 +958,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool lastObservedViewportGeometryRef.current = nextGeometry; } reconcileReadingPosition(); - if (historyStartPaginationStateRef.current.status === "settling") { + if (isHistoryStartSettling()) { scheduleHistoryStartPrependSettle(); } updateScrollMetrics(); @@ -1012,6 +992,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool isActive, reportReadingPosition, scheduleHistoryStartPrependSettle, + isHistoryStartSettling, forceStickToBottom, updateScrollMetrics, measureVirtualizedRows, @@ -1229,8 +1210,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool // Detached reading has one owner: reconcileReadingPosition. overflowAnchor: followOutput ? "auto" : "none", scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined, + opacity: historyStartPaginationState.visible ? 1 : 0, }; - }, [followOutput, isMobileBreakpoint, scrollEnabled]); + }, [followOutput, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]); const viewportStyle = useMemo( (): CSSProperties => ({ position: "relative", @@ -1307,14 +1289,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); return (
- {isLoadingOperation ? ( + {isLoadingOperation && (
- ) : null} + )} + {!isLoadingOperation && + historyStartPaginationState.status === "failed" && + hasOlderHistory && ( + + )}
); - }, [historyStartPaginationState]); + }, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]); const shouldRenderEmpty = !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory && @@ -1334,7 +1323,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool >
{historyStartSlot} -
+
{[ ...virtualRows.map((virtualRow) => { @@ -1362,10 +1351,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ...mountedRows, ]}
- {liveAuxiliary} +
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
+ {!historyStartPaginationState.visible ? ( +
+ +
+ ) : null} {scrollEnabled && !isMobileBreakpoint ? ( ; routeBottomAnchorRequest: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady: boolean; + isInitialHistoryLoading?: boolean; + revealInitialHistory?: boolean; onNearBottomChange: (value: boolean) => void; // The history row under the top of the viewport, for surfaces that mark where the reader // is in the transcript. Only the web viewport measures it today. onReadingPositionChange?: (rowId: string | null) => void; - onNearHistoryStart: () => boolean | Promise; + onNearHistoryStart: () => Promise; isLoadingOlderHistory: boolean; hasOlderHistory: boolean; olderHistoryProgressKey: string | null; diff --git a/packages/app/src/agent-stream/use-history-start-pagination.ts b/packages/app/src/agent-stream/use-history-start-pagination.ts new file mode 100644 index 0000000000..1fb6168857 --- /dev/null +++ b/packages/app/src/agent-stream/use-history-start-pagination.ts @@ -0,0 +1,33 @@ +import { useRef, useState } from "react"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { + createHistoryStartPagination, + type HistoryStartPaginationInput, + type HistoryStartPaginationState, +} from "./history-start-pagination"; + +export function useHistoryStartPagination(input: { + initiallyVisible: boolean; + read: () => HistoryStartPaginationInput | null; + load: () => Promise; + scheduleSettle: () => void; +}) { + const read = useStableEvent(input.read); + const load = useStableEvent(input.load); + const scheduleSettle = useStableEvent(input.scheduleSettle); + const [state, setState] = useState(() => ({ + status: "dormant", + visible: input.initiallyVisible, + })); + const controller = useRef | null>(null); + if (!controller.current) { + controller.current = createHistoryStartPagination({ + initiallyVisible: input.initiallyVisible, + read, + load, + scheduleSettle, + changed: setState, + }); + } + return { state, controller: controller.current }; +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index cb9999745b..f41fbd965d 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -281,6 +281,8 @@ export interface AgentStreamViewProps { turnPresentation: TurnPresentation; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; + isInitialHistoryLoading?: boolean; + revealInitialHistory?: boolean; /** Tail space required by a transparent overlay rendered at the bottom edge. */ bottomOverlayTailClearance?: number; /** Bottom offset required for controls floating above that overlay. */ @@ -337,6 +339,8 @@ const AgentStreamViewComponent = forwardRef { expect(client.calls).toEqual([]); expect(inFlight.values).toEqual([true]); - expect(started).toBe(true); + expect(started).toBe(false); }); it("requests the page before the current start cursor and clears in-flight on success", async () => { @@ -153,6 +153,7 @@ describe("loadOlderAgentHistory", () => { limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", wholeTurns: true, + timeout: 10_000, }, }, ]); @@ -169,7 +170,7 @@ describe("loadOlderAgentHistory", () => { const toast = createToast(); const logger = createLogger(); - await loadOlderAgentHistory(agentId, { + const applied = await loadOlderAgentHistory(agentId, { client, cursor: someCursor, hasOlder: true, @@ -179,6 +180,7 @@ describe("loadOlderAgentHistory", () => { logger, }); + expect(applied).toBe(false); expect(client.calls).toHaveLength(1); expect(toast.shown).toEqual([ { diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index 2bc3f5c9df..a3887dd7b2 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -18,6 +18,7 @@ export interface LoadOlderAgentHistoryClient { cursor: { epoch: string; seq: number }; limit: number; projection: "projected"; + timeout: number; }, ) => Promise; } @@ -44,7 +45,7 @@ export async function loadOlderAgentHistory( const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } = deps; if (isLoadingOlder) { - return true; + return false; } if (!client || !cursor || !hasOlder) { return false; @@ -52,16 +53,17 @@ export async function loadOlderAgentHistory( setInFlight(true); try { - await client.fetchAgentTimeline( - agentId, - planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }), - ); + await client.fetchAgentTimeline(agentId, { + ...planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }), + timeout: 10_000, + }); } catch (error) { (logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error); toast?.show(failedMessage ?? i18n.t("loadOlderHistory.failed"), { durationMs: 2200, testID: "agent-load-older-history-toast", }); + return false; } finally { setInFlight(false); } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 712d9d65a1..652bfd4b71 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1021,6 +1021,10 @@ function ChatAgentContent({ effectiveAgent={effectiveAgent} routeBottomAnchorRequest={routeBottomAnchorRequest} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} + shouldHoldInitialFill={ + routeBottomAnchorRequest?.reason === "initial-entry" && replicaTimelineStatus !== "painted" + } + hasCachedHistory={replicaTimelineStatus === "painted"} toastApi={toastApi} toast={toastState} dismiss={dismissToast} @@ -1051,6 +1055,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ effectiveAgent, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, + shouldHoldInitialFill, + hasCachedHistory, toastApi, toast, dismiss, @@ -1077,6 +1083,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ effectiveAgent: AgentScreenAgent; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; + shouldHoldInitialFill: boolean; + hasCachedHistory: boolean; toastApi: ToastApi; toast: ToastState | null; dismiss: () => void; @@ -1203,6 +1211,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ agent={effectiveAgent} routeBottomAnchorRequest={routeBottomAnchorRequest} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} + isInitialHistoryLoading={shouldHoldInitialFill && !showHistorySyncError} + revealInitialHistory={hasCachedHistory || showHistorySyncError} hasActiveComposer={hasActiveComposer} hasVisibleAgentTracks={hasVisibleAgentTracks} toast={toastApi} @@ -1339,6 +1349,8 @@ const AgentStreamSection = memo(function AgentStreamSection({ agent, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, + isInitialHistoryLoading, + revealInitialHistory, hasActiveComposer, hasVisibleAgentTracks, toast, @@ -1351,6 +1363,8 @@ const AgentStreamSection = memo(function AgentStreamSection({ agent: AgentScreenAgent; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; + isInitialHistoryLoading: boolean; + revealInitialHistory: boolean; hasActiveComposer: boolean; hasVisibleAgentTracks: boolean; toast: ReturnType["api"]; @@ -1419,6 +1433,8 @@ const AgentStreamSection = memo(function AgentStreamSection({ pendingPermissions={pendingPermissions} routeBottomAnchorRequest={routeBottomAnchorRequest} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} + isInitialHistoryLoading={isInitialHistoryLoading} + revealInitialHistory={revealInitialHistory} bottomOverlayTailClearance={bottomOverlayTailClearance} bottomOverlayControlClearance={bottomOverlayControlClearance} toast={toast} diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 99a38c2f8b..439f7e1ab3 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -26,6 +26,7 @@ import { providerSubagentLifecycleStatus, refreshProviderSubagents, observeProviderSubagentTimeline, + loadOlderProviderSubagentTimeline, useProviderSubagentStore, } from "@/subagents/provider-store"; import { useTranslation } from "react-i18next"; @@ -180,26 +181,24 @@ function ProviderSubagentPanel() { }); }, [client, supported, serverId, target.parentAgentId, target.subagentId]); - const loadOlder = useCallback((): boolean => { + const loadOlder = useCallback(async (): Promise => { if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) { return false; } - const firstSeq = timeline.cursor?.startSeq ?? null; - if (firstSeq === null) return false; setIsLoadingOlder(true); - void client - .fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, { - direction: "before", - cursor: { epoch: timeline.epoch, seq: firstSeq }, + try { + return await loadOlderProviderSubagentTimeline({ + client, + serverId, + parentAgentId: target.parentAgentId, + subagentId: target.subagentId, limit: TIMELINE_FETCH_PAGE_SIZE, - }) - .then((payload) => { - useProviderSubagentStore.getState().replaceTimeline(serverId, payload); - return undefined; - }) - .catch(() => undefined) - .finally(() => setIsLoadingOlder(false)); - return true; + }); + } catch { + return false; + } finally { + setIsLoadingOlder(false); + } }, [ client, isLoadingOlder, diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index 01d6880d5b..596425a620 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -2,6 +2,7 @@ import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, test } from "vitest"; import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; import { + loadOlderProviderSubagentTimeline, observeProviderSubagentTimeline, providerSubagentKey, refreshProviderSubagents, @@ -565,6 +566,33 @@ describe("projected child history", () => { .getState() .replaceTimeline(SERVER_ID, response(value, seqStart, seqEnd, direction)); } + test("older child history completes only after the delayed page is applied", async () => { + const tail = { ...response("B", 2, 2), hasOlder: true }; + useProviderSubagentStore.getState().replaceTimeline(SERVER_ID, tail); + let deliver!: (payload: Payload) => void; + const client = { + fetchProviderSubagentTimeline: () => + new Promise((resolve) => { + deliver = resolve; + }), + }; + let completed = false; + const request = loadOlderProviderSubagentTimeline({ + client, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + }).then((result) => { + completed = true; + return result; + }); + await Promise.resolve(); + expect(completed).toBe(false); + deliver({ ...response("A", 1, 1), direction: "before" }); + expect(await request).toBe(true); + expect(current().cursor?.startSeq).toBe(1); + }); test("replaces overlapping fetched text and continues live streaming", () => { page("AB", 1, 2); stream(3, "C"); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index fd44dae8d2..f3869c0a73 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -355,6 +355,34 @@ export const useProviderSubagentStore = create((set) => ( })); /** Owns child history bootstrap and recovery while a pane observes the child. */ +export async function loadOlderProviderSubagentTimeline({ + client, + serverId, + parentAgentId, + subagentId, + limit, +}: { + client: Pick; + serverId: string; + parentAgentId: string; + subagentId: string; + limit: number; +}): Promise { + const timeline = useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(serverId, parentAgentId, subagentId)); + const seq = timeline?.cursor?.startSeq; + if (!timeline?.hasOlder || !timeline.epoch || seq == null) return false; + const payload = await client.fetchProviderSubagentTimeline(parentAgentId, subagentId, { + direction: "before", + cursor: { epoch: timeline.epoch, seq }, + limit, + }); + if (payload.error) return false; + useProviderSubagentStore.getState().replaceTimeline(serverId, payload); + return true; +} + export function observeProviderSubagentTimeline({ client, serverId,