Compare commits

...
Author SHA1 Message Date
Mohamed Boudra b5ef3d6bd2 fix(app): settle padded and stalled chat history
Measure the native history edge using the same content area as viewport filling, and bound child history requests so retry can start after a stalled fetch.
2026-10-31 09:44:06 +01:00
Mohamed Boudra d57777ff3b 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.
2026-10-31 09:34:16 +01:00
17 changed files with 811 additions and 518 deletions
+2 -2
View File
@@ -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
+62 -10
View File
@@ -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 {
@@ -1,197 +1,238 @@
import { describe, expect, it } from "vitest";
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
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<boolean>((resolve) => requests.push(resolve)),
changed: () => {},
scheduleSettle: () => {
settles++;
},
});
return {
pagination,
requests,
get settles() {
return settles;
},
set(patch: Partial<HistoryStartPaginationInput>) {
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();
});
it.each([
[true, 123, 0],
[false, 123, 0],
[true, 0, 123],
[false, 0, 123],
])(
"handles native padding when short history has older=%s",
(hasOlderHistory, topOverlayInset, bottomPadding) => {
const geometry = nativeHistoryStartGeometry({
contentHeight: 825,
viewportHeight: 700,
offsetY: 0,
topOverlayInset,
bottomPadding,
});
let requests = 0;
const pagination = createHistoryStartPagination({
initiallyVisible: false,
read: () => ({ ...initial, ...geometry, hasOlderHistory }),
load: () => {
requests++;
return new Promise<boolean>(() => {});
},
changed: () => {},
scheduleSettle: () => {},
});
pagination.update();
expect(requests).toBe(hasOlderHistory ? 1 : 0);
expect(pagination.state.visible).toBe(!hasOlderHistory);
pagination.dispose();
},
);
@@ -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<boolean>;
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<typeof setTimeout> | 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,
};
}
@@ -0,0 +1,13 @@
export function nativeHistoryStartGeometry(input: {
contentHeight: number;
viewportHeight: number;
offsetY: number;
topOverlayInset: number;
bottomPadding: number;
}) {
const contentHeight = input.contentHeight - 32 - input.topOverlayInset - input.bottomPadding;
return {
distanceFromHistoryStart: contentHeight - input.viewportHeight - input.offsetY,
isUnderfilled: contentHeight < input.viewportHeight,
};
}
+120 -110
View File
@@ -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,11 @@ 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 { nativeHistoryStartGeometry } from "./native-history-start-geometry";
import { useStreamTopOverlayInset } from "./top-overlay";
import {
createHistoryStartSettleScheduler,
@@ -65,6 +56,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 +84,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange,
onNearHistoryStart,
isLoadingOlderHistory,
@@ -93,6 +96,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
baseListContentContainerStyle,
strategy,
} = props;
const isActive = useRetainedPanelActive();
const { t } = useTranslation();
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const flatListRef = useRef<FlatList<StreamItem>>(null);
const streamViewportMetricsRef = useRef({
@@ -113,10 +118,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const isNativeViewportSettlingRef = useRef(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const historyItems = useMemo(() => {
@@ -132,48 +133,40 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
return {
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
...nativeHistoryStartGeometry({
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
offsetY: metrics.offsetY,
topOverlayInset,
bottomPadding: streamKeyboardInset.contentContainerPaddingBottom,
}),
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 +174,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 +315,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 +323,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 +414,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
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<NativeScrollEvent>) => {
@@ -506,22 +499,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 +562,64 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
return (
<View style={historyStartSlotStyle} testID="older-history-slot">
{isLoadingOperation ? (
{isLoadingOperation && (
<View testID="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<Button variant="ghost" size="sm" onPress={retryHistoryStart}>
{t("paneFind.retry")}
</Button>
)}
</View>
);
}, [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 (
<FlatList
{...listInsetProps}
ref={flatListRef}
data={historyRows}
renderItem={renderItem}
keyExtractor={keyExtractor}
strictMode
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
contentContainerStyle={listContentContainerStyle}
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
// render small, then refill a deeper window in batches large enough to keep up.
initialNumToRender={2}
maxToRenderPerBatch={10}
windowSize={NATIVE_STREAM_WINDOW_SIZE}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
inverted
/>
<View style={viewportStyle}>
<FlatList
{...listInsetProps}
ref={flatListRef}
data={historyRows}
renderItem={renderItem}
keyExtractor={keyExtractor}
strictMode
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
contentContainerStyle={listContentContainerStyle}
style={[listStyle, { opacity: historyStartPaginationState.visible ? 1 : 0 }]}
onLayout={handleListLayout}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
// render small, then refill a deeper window in batches large enough to keep up.
initialNumToRender={2}
maxToRenderPerBatch={10}
windowSize={NATIVE_STREAM_WINDOW_SIZE}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
inverted
/>
{!historyStartPaginationState.visible ? (
<View pointerEvents="none" testID="agent-initial-fill" style={initialFillStyle}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</View>
);
}
@@ -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,
+74 -80
View File
@@ -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<HTMLDivElement | null>(null);
const auxiliaryRef = useRef<HTMLDivElement | null>(null);
const isActiveRef = useRef(isActive);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
@@ -351,10 +360,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const readingAnchor = useMemo(createReadingAnchor, []);
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(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 (
<div style={historyStartSlotStyle} data-testid="older-history-slot">
{isLoadingOperation ? (
{isLoadingOperation && (
<div data-testid="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</div>
) : null}
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<button type="button" onClick={rearmHistoryStartFromUserIntent}>
{t("paneFind.retry")}
</button>
)}
</div>
);
}, [historyStartPaginationState]);
}, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
@@ -1334,7 +1323,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
<div style={streamRowsHostStyle}>
<div ref={rowsHostRef} style={streamRowsHostStyle}>
<div style={virtualRowsContainerStyle} />
{[
...virtualRows.map((virtualRow) => {
@@ -1362,10 +1351,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
...mountedRows,
]}
</div>
{liveAuxiliary}
<div ref={auxiliaryRef}>{liveAuxiliary}</div>
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
</div>
{!historyStartPaginationState.visible ? (
<div data-testid="agent-initial-fill" style={initialFillStyle}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</div>
) : null}
{scrollEnabled && !isMobileBreakpoint ? (
<DomOverlayScrollbar
scrollContainerRef={scrollContainerRef}
+3 -1
View File
@@ -87,11 +87,13 @@ export interface StreamRenderInput {
viewportRef: RefObject<StreamViewportHandle | null>;
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<boolean>;
onNearHistoryStart: () => Promise<boolean>;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
olderHistoryProgressKey: string | null;
@@ -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<boolean>;
scheduleSettle: () => void;
}) {
const read = useStableEvent(input.read);
const load = useStableEvent(input.load);
const scheduleSettle = useStableEvent(input.scheduleSettle);
const [state, setState] = useState<HistoryStartPaginationState>(() => ({
status: "dormant",
visible: input.initiallyVisible,
}));
const controller = useRef<ReturnType<typeof createHistoryStartPagination> | null>(null);
if (!controller.current) {
controller.current = createHistoryStartPagination({
initiallyVisible: input.initiallyVisible,
read,
load,
scheduleSettle,
changed: setState,
});
}
return { state, controller: controller.current };
}
+12
View File
@@ -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<AgentStreamViewHandle, AgentStreamVi
turnPresentation,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
isInitialHistoryLoading,
revealInitialHistory,
bottomOverlayTailClearance = 0,
bottomOverlayControlClearance,
toast,
@@ -1218,6 +1222,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: setIsNearBottom,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
@@ -1369,6 +1375,12 @@ function agentStreamViewPropsEqual(
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
reasons.push("isAuthoritativeHistoryReady");
}
if (left.isInitialHistoryLoading !== right.isInitialHistoryLoading) {
reasons.push("isInitialHistoryLoading");
}
if (left.revealInitialHistory !== right.revealInitialHistory) {
reasons.push("revealInitialHistory");
}
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
reasons.push("bottomOverlayTailClearance");
}
@@ -129,7 +129,7 @@ describe("loadOlderAgentHistory", () => {
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([
{
@@ -18,6 +18,7 @@ export interface LoadOlderAgentHistoryClient {
cursor: { epoch: string; seq: number };
limit: number;
projection: "projected";
timeout: number;
},
) => Promise<unknown>;
}
@@ -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);
}
+16
View File
@@ -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<typeof useToastHost>["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}
@@ -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<boolean> => {
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,
@@ -1,7 +1,9 @@
import { createHistoryStartPagination } from "@/agent-stream/history-start-pagination";
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
import {
loadOlderProviderSubagentTimeline,
observeProviderSubagentTimeline,
providerSubagentKey,
refreshProviderSubagents,
@@ -565,6 +567,95 @@ 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<Payload>((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("bounds an older child request so a timeout permits another attempt", async () => {
vi.useFakeTimers();
try {
useProviderSubagentStore
.getState()
.replaceTimeline(SERVER_ID, { ...response("B", 2, 2), hasOlder: true });
const timeouts: Array<number | undefined> = [];
const client = {
fetchProviderSubagentTimeline: (
_parent: string,
_child: string,
options: { timeout?: number },
) => {
timeouts.push(options.timeout);
return new Promise<Payload>((_resolve, reject) =>
setTimeout(() => reject(new Error("timeout")), options.timeout ?? 60_000),
);
},
};
let loading = false;
const pagination = createHistoryStartPagination({
initiallyVisible: true,
read: () => ({
distanceFromHistoryStart: 0,
isUnderfilled: true,
hasOlderHistory: true,
isLoadingOlderHistory: loading,
isReady: true,
progressKey: "e:2",
}),
load: async () => {
loading = true;
try {
return await loadOlderProviderSubagentTimeline({
client,
serverId: SERVER_ID,
parentAgentId: PARENT_ID,
subagentId: SUBAGENT_ID,
limit: 100,
});
} catch {
return false;
} finally {
loading = false;
}
},
changed: () => {},
scheduleSettle: () => {},
});
pagination.update();
expect(timeouts).toEqual([10_000]);
await vi.advanceTimersByTimeAsync(10_000);
expect(pagination.state.status).toBe("failed");
pagination.rearm();
expect(timeouts).toEqual([10_000, 10_000]);
expect(pagination.state.status).toBe("loading");
pagination.dispose();
await vi.advanceTimersByTimeAsync(10_000);
} finally {
vi.useRealTimers();
}
});
test("replaces overlapping fetched text and continues live streaming", () => {
page("AB", 1, 2);
stream(3, "C");
@@ -355,6 +355,35 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
}));
/** Owns child history bootstrap and recovery while a pane observes the child. */
export async function loadOlderProviderSubagentTimeline({
client,
serverId,
parentAgentId,
subagentId,
limit,
}: {
client: Pick<DaemonClient, "fetchProviderSubagentTimeline">;
serverId: string;
parentAgentId: string;
subagentId: string;
limit: number;
}): Promise<boolean> {
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,
timeout: 10_000,
});
if (payload.error) return false;
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
return true;
}
export function observeProviderSubagentTimeline({
client,
serverId,