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 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 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 and presentation; it cannot recover historical batching or steering. An underfilled viewport
user-driven by scrolling upward. 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 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 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; return agent;
} }
test("loads complete long turns and preserves expansion while revealing older history", async ({ async function expectInitialFillIsConcealed(page: 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); test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await useChatDisplay(page, "collapseTurns"); await useChatDisplay(page, "collapseTurns");
const agent = await seedWholeTurnHistory(); const agent = await seedWholeTurnHistory();
try { try {
const history = await holdOlderHistoryPages(page, agent); const history = await holdOlderHistoryPages(page, agent);
await openAgentRoute(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); await expectComposerVisible(page);
const latest = page const latest = page
.getByTestId("turn-summary") .getByTestId("turn-summary")
.getByRole("button", { name: "Ran 120 commands", exact: true }); .getByRole("button", { name: "Ran 120 commands", exact: true });
await expect(latest).toBeVisible(); 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( await expect(
page page
.getByTestId("turn-summary") .getByTestId("turn-summary")
.getByRole("button", { name: "Ran 60 commands", exact: true }), .getByRole("button", { name: "Ran 60 commands", exact: true }),
).toBeVisible(); ).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); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
history.expectNoRepeatedEntries(); history.expectNoRepeatedEntries();
await latest.scrollIntoViewIfNeeded();
await latest.click(); await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally { } 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 { import {
abandonHistoryStartPaginationRequest, createHistoryStartPagination,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput, type HistoryStartPaginationInput,
} from "./history-start-pagination"; } from "./history-start-pagination";
function createArmedHistoryStartPaginationState() { const initial: HistoryStartPaginationInput = {
return rearmHistoryStartPagination(createHistoryStartPaginationState());
}
const visibleHistoryStart: HistoryStartPaginationInput = {
distanceFromHistoryStart: 0, distanceFromHistoryStart: 0,
isUnderfilled: true,
hasOlderHistory: true, hasOlderHistory: true,
isLoadingOlderHistory: false, isLoadingOlderHistory: false,
isReady: true, 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", () => { describe("history start pagination", () => {
it("treats locally revealed history as progress before remote pagination", () => { it("waits for a delayed request and its committed page before settling", async () => {
const first = evaluateHistoryStartPagination( const h = harness();
createArmedHistoryStartPaginationState(), h.pagination.update();
visibleHistoryStart, h.set({ progressKey: "10" });
); expect(h.settles).toBe(0);
const localReveal = evaluateHistoryStartPagination(first.state, { h.requests[0](true);
...visibleHistoryStart, await Promise.resolve();
progressKey: "epoch-1:20:local-60", expect(h.settles).toBe(1);
}); expect(h.pagination.state.visible).toBe(false);
h.set({ isUnderfilled: false });
expect(first.shouldLoad).toBe(true); h.pagination.settle();
expect(localReveal.state).toEqual({ expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
status: "settling", h.pagination.dispose();
loadedProgressKey: "epoch-1:20:local-60",
});
}); });
it("waits for user scroll intent before loading", () => { it("waits for the committed page after a request completes", async () => {
const dormant = createHistoryStartPaginationState(); const h = harness();
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart); h.pagination.update();
h.requests[0](true);
expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false }); await Promise.resolve();
expect(h.settles).toBe(0);
h.set({ progressKey: "10" });
expect(h.settles).toBe(1);
h.pagination.dispose();
}); });
it("continues short pages and reveals on exhaustion", async () => {
it("waits for anchored page geometry before authorizing another page", () => { const h = harness();
const first = evaluateHistoryStartPagination( h.pagination.update();
createArmedHistoryStartPaginationState(), h.set({ progressKey: "10" });
visibleHistoryStart, h.requests[0](true);
); await Promise.resolve();
const inFlight = evaluateHistoryStartPagination(first.state, { h.pagination.settle();
...visibleHistoryStart, expect(h.requests).toHaveLength(2);
isLoadingOlderHistory: true, h.set({ hasOlderHistory: false });
}); h.requests[1](true);
const pageApplied = evaluateHistoryStartPagination(inFlight.state, { await Promise.resolve();
...visibleHistoryStart, expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
isLoadingOlderHistory: true, h.pagination.dispose();
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("releases a failed or stalled request and allows retry", async () => {
it("loads one page each time anchored geometry leaves and returns to history start", () => { vi.useFakeTimers();
const first = evaluateHistoryStartPagination( const h = harness();
createArmedHistoryStartPaginationState(), h.pagination.update();
visibleHistoryStart, vi.advanceTimersByTime(15_000);
); expect(h.pagination.state).toEqual({ status: "failed", visible: true });
const pageApplied = evaluateHistoryStartPagination(first.state, { h.pagination.rearm();
...visibleHistoryStart, expect(h.requests).toHaveLength(2);
progressKey: "epoch-1:10", h.requests[1](false);
}); await Promise.resolve();
const settledAway = settleHistoryStartPagination(pageApplied.state, { expect(h.pagination.state.status).toBe("failed");
...visibleHistoryStart, h.pagination.dispose();
distanceFromHistoryStart: 300, vi.useRealTimers();
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("keeps visible history visible while filling", () => {
it("continues the same loading operation when anchored geometry remains at history start", () => { const h = harness(true);
const first = evaluateHistoryStartPagination( h.pagination.update();
createArmedHistoryStartPaginationState(), expect(h.pagination.state.visible).toBe(true);
visibleHistoryStart, h.pagination.dispose();
);
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("does not load a full viewport until user intent and respects inactive views", () => {
it("latches a request that finishes without cursor progress", () => { const h = harness();
const first = evaluateHistoryStartPagination( h.set({ isUnderfilled: false });
createArmedHistoryStartPaginationState(), expect(h.requests).toHaveLength(0);
visibleHistoryStart, h.set({ isReady: false });
); h.pagination.rearm();
const inFlight = evaluateHistoryStartPagination(first.state, { expect(h.requests).toHaveLength(0);
...visibleHistoryStart, h.set({ isReady: true });
isLoadingOlderHistory: true, expect(h.requests).toHaveLength(1);
}); h.pagination.dispose();
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("preserves the reading position after an anchored page moves away from the start", async () => {
it("allows another user attempt after a request finishes without progress", () => { const h = harness(true);
const first = evaluateHistoryStartPagination( h.set({ isUnderfilled: false });
createArmedHistoryStartPaginationState(), h.pagination.rearm();
visibleHistoryStart, h.set({ progressKey: "10", distanceFromHistoryStart: 300 });
); h.requests[0](true);
const inFlight = evaluateHistoryStartPagination(first.state, { await Promise.resolve();
...visibleHistoryStart, h.pagination.settle();
isLoadingOlderHistory: true, expect(h.requests).toHaveLength(1);
}); h.set({ distanceFromHistoryStart: 0 });
const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart); expect(h.requests).toHaveLength(2);
const retried = evaluateHistoryStartPagination( h.pagination.dispose();
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("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 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 { export interface HistoryStartPaginationInput {
distanceFromHistoryStart: number; distanceFromHistoryStart: number;
isUnderfilled: boolean;
hasOlderHistory: boolean; hasOlderHistory: boolean;
isLoadingOlderHistory: boolean; isLoadingOlderHistory: boolean;
isReady: boolean; isReady: boolean;
progressKey: string | null; progressKey: string | null;
} }
export interface HistoryStartPaginationTransition { export interface HistoryStartPaginationState {
state: HistoryStartPaginationState; status: "dormant" | "ready" | "loading" | "settling" | "failed";
shouldLoad: boolean; visible: boolean;
}
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
return { status: "dormant" };
} }
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean { export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
return state.status === "loading" || state.status === "settling"; return state.status === "loading" || state.status === "settling";
} }
export function rearmHistoryStartPagination( /** Owns one pagination operation, including the initial viewport reveal. */
state: HistoryStartPaginationState, export function createHistoryStartPagination(input: {
): HistoryStartPaginationState { initiallyVisible: boolean;
return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state; 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( const publish = (status: HistoryStartPaginationState["status"], visible = state.visible) => {
state: HistoryStartPaginationState, if (status === state.status && visible === state.visible) return;
requestedProgressKey: string, state = { status, visible };
): HistoryStartPaginationState { input.changed(state);
if ( };
state.status !== "loading" || const clear = () => {
state.requestObserved || generation++;
state.requestedProgressKey !== requestedProgressKey clearTimeout(timeout);
) { timeout = undefined;
return state; };
} const finish = (status: HistoryStartPaginationState["status"]) => {
return { status: "latched" }; clear();
} publish(status, true);
};
const fail = () => finish("failed");
export function evaluateHistoryStartPagination( const update = () => {
state: HistoryStartPaginationState, const geometry = input.read();
input: HistoryStartPaginationInput, if (!geometry) return;
): HistoryStartPaginationTransition { if (state.status === "loading") {
if (state.status === "dormant") { if (!completed) return;
return { state, shouldLoad: false }; if (geometry.progressKey !== null && geometry.progressKey !== requestedKey) {
} publish("settling");
if (state.status === "loading") { input.scheduleSettle();
if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) { } else if (!geometry.hasOlderHistory) {
return { finish("dormant");
state: { status: "settling", loadedProgressKey: input.progressKey }, }
shouldLoad: false, return;
};
} }
if (input.isLoadingOlderHistory && !state.requestObserved) { if (state.status === "settling" || state.status === "failed" || !geometry.isReady) return;
return { state: { ...state, requestObserved: true }, shouldLoad: false }; const atStart = geometry.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (state.status === "dormant" && !geometry.isUnderfilled) {
publish("dormant", true);
return;
} }
if (!input.isLoadingOlderHistory && !input.hasOlderHistory) { if (!atStart || (!geometry.isUnderfilled && state.status !== "ready")) return;
return { state: { status: "latched" }, shouldLoad: false }; if (!geometry.hasOlderHistory) {
finish("dormant");
return;
} }
if ( if (geometry.isLoadingOlderHistory || geometry.progressKey === null) return;
state.requestObserved && fill = geometry.isUnderfilled;
!input.isLoadingOlderHistory && requestedKey = geometry.progressKey;
input.progressKey === state.requestedProgressKey completed = false;
) { publish("loading");
return { state: { status: "latched" }, shouldLoad: false }; const request = ++generation;
} timeout = setTimeout(() => {
return { state, shouldLoad: false }; if (request === generation) fail();
} }, 15_000);
void input.load().then(
if (state.status === "settling") { (applied) => {
return { state, shouldLoad: false }; if (request !== generation) return undefined;
} if (!applied) {
fail();
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; return undefined;
if (!isAtHistoryStart) { }
return state.status === "ready" completed = true;
? { state, shouldLoad: false } update();
: { state: { status: "ready" }, shouldLoad: false }; return undefined;
} },
if ( () => {
state.status === "latched" || if (request === generation) fail();
!input.isReady || },
!input.hasOlderHistory || );
input.isLoadingOlderHistory || };
input.progressKey === null
) {
return { state, shouldLoad: false };
}
return { return {
state: { get state() {
status: "loading", return state;
requestedProgressKey: input.progressKey,
requestObserved: false,
}, },
shouldLoad: true, update,
}; settle() {
} if (state.status !== "settling") return;
const geometry = input.read();
export function settleHistoryStartPagination( if (!geometry?.isReady) return;
state: HistoryStartPaginationState, clear();
input: HistoryStartPaginationInput, if (fill && !geometry.isUnderfilled) {
): HistoryStartPaginationTransition { publish("dormant", true);
if (state.status !== "settling") { } else if (geometry.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
return { state, shouldLoad: false }; publish("ready", true);
} } else if (!geometry.hasOlderHistory) {
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; publish("dormant", true);
if ( } else {
!isAtHistoryStart || publish("ready");
!input.isReady || update();
!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,
}, },
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 { import { Fragment, type ReactElement, useCallback, useEffect, useMemo, useRef } from "react";
Fragment,
type ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { import {
FlatList, FlatList,
Platform, Platform,
@@ -19,6 +11,9 @@ import {
} from "react-native"; } from "react-native";
import { withUnistyles } from "react-native-unistyles"; import { withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; 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 { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
@@ -34,15 +29,11 @@ import {
resolveBottomAnchorTransportBehavior, resolveBottomAnchorTransportBehavior,
} from "./strategy"; } from "./strategy";
import { import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation, isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput, type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination"; } from "./history-start-pagination";
import { useHistoryStartPagination } from "./use-history-start-pagination";
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
import { useStreamTopOverlayInset } from "./top-overlay"; import { useStreamTopOverlayInset } from "./top-overlay";
import { import {
createHistoryStartSettleScheduler, createHistoryStartSettleScheduler,
@@ -65,6 +56,16 @@ const historyStartSlotStyle: ViewStyle = {
flexShrink: 0, flexShrink: 0,
}; };
const HISTORY_START_SETTLE_FRAMES = 2; 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; const NATIVE_STREAM_WINDOW_SIZE = 10;
function keyExtractor(item: { id: string }): string { function keyExtractor(item: { id: string }): string {
@@ -83,6 +84,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef, viewportRef,
routeBottomAnchorRequest, routeBottomAnchorRequest,
isAuthoritativeHistoryReady, isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange, onNearBottomChange,
onNearHistoryStart, onNearHistoryStart,
isLoadingOlderHistory, isLoadingOlderHistory,
@@ -93,6 +96,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
baseListContentContainerStyle, baseListContentContainerStyle,
strategy, strategy,
} = props; } = props;
const isActive = useRetainedPanelActive();
const { t } = useTranslation();
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers; const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const flatListRef = useRef<FlatList<StreamItem>>(null); const flatListRef = useRef<FlatList<StreamItem>>(null);
const streamViewportMetricsRef = useRef({ const streamViewportMetricsRef = useRef({
@@ -113,10 +118,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const isNativeViewportSettlingRef = useRef(false); const isNativeViewportSettlingRef = useRef(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null); const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false); const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null); const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const historyItems = useMemo(() => { const historyItems = useMemo(() => {
@@ -132,48 +133,40 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
metrics.viewportMeasuredForKey === metrics.containerKey && metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey; metrics.contentMeasuredForKey === metrics.containerKey;
return { return {
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY, ...nativeHistoryStartGeometry({
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
offsetY: metrics.offsetY,
topOverlayInset,
bottomPadding: streamKeyboardInset.contentContainerPaddingBottom,
}),
hasOlderHistory, hasOlderHistory,
isLoadingOlderHistory, isLoadingOlderHistory,
isReady: historyStartReadyRef.current && hasMeasuredViewport, isReady:
isActive &&
isAuthoritativeHistoryReady &&
historyStartReadyRef.current &&
hasMeasuredViewport,
progressKey: olderHistoryProgressKey, progressKey: olderHistoryProgressKey,
}; };
}); });
const applyHistoryStartPaginationTransition = useStableEvent( const { state: historyStartPaginationState, controller: historyStartPagination } =
(transition: HistoryStartPaginationTransition) => { useHistoryStartPagination({
const previousState = historyStartPaginationStateRef.current; initiallyVisible: !isInitialHistoryLoading,
historyStartPaginationStateRef.current = transition.state; read: getHistoryStartPaginationInput,
if (transition.state !== previousState) { load: async () => onNearHistoryStart(),
setHistoryStartPaginationState(transition.state); scheduleSettle: () => scheduleHistoryStartSettle(),
} });
if (transition.shouldLoad) { const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
const requestedProgressKey = olderHistoryProgressKey; const resetHistoryStart = useStableEvent(() =>
if (requestedProgressKey === null) { historyStartPagination.reset(!isInitialHistoryLoading),
return;
}
void (async () => {
const started = await onNearHistoryStart();
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
}
},
); );
const evaluateHistoryStart = useStableEvent(() => { const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
const transition = evaluateHistoryStartPagination( const isHistoryStartSettling = useStableEvent(
historyStartPaginationStateRef.current, () => historyStartPagination.state.status === "settling",
getHistoryStartPaginationInput(), );
); const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
applyHistoryStartPaginationTransition(transition); const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm());
});
const scheduleHistoryStartSettle = useStableEvent(() => { const scheduleHistoryStartSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current; let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) { if (!scheduler) {
@@ -181,15 +174,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
settleFrames: HISTORY_START_SETTLE_FRAMES, settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: requestAnimationFrame, requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame, cancelFrame: cancelAnimationFrame,
isSettling: () => historyStartPaginationStateRef.current.status === "settling", isSettling: () => isHistoryStartSettling(),
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory, isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
onSettle: () => { onSettle: () => historyStartPagination.settle(),
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
},
}); });
historyStartSettleSchedulerRef.current = scheduler; historyStartSettleSchedulerRef.current = scheduler;
} }
@@ -328,9 +315,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
clearNativeViewportSettling(); clearNativeViewportSettling();
isNativeViewportSettlingRef.current = false; isNativeViewportSettlingRef.current = false;
historyStartReadyRef.current = false; historyStartReadyRef.current = false;
const initialHistoryStartState = createHistoryStartPaginationState(); resetHistoryStart();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true; historyStartReadyRef.current = true;
evaluateHistoryStart(); evaluateHistoryStart();
@@ -338,10 +323,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => { return () => {
cancelAnimationFrame(frame); cancelAnimationFrame(frame);
clearPendingUserScrollEnd(); clearPendingUserScrollEnd();
disposeHistoryStart();
historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null; historyStartSettleSchedulerRef.current = null;
}; };
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]); }, [
agentId,
clearNativeViewportSettling,
clearPendingUserScrollEnd,
disposeHistoryStart,
evaluateHistoryStart,
resetHistoryStart,
]);
useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]); useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]);
@@ -421,18 +414,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}); });
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => { const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
finishInitialFill();
clearPendingUserScrollEnd(); clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true; isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event); scrollKeyboardDismiss.onScrollBeginDrag(event);
bottomAnchorController.beginUserScroll(); bottomAnchorController.beginUserScroll();
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); historyStartPagination.rearm();
if (rearmed !== historyStartPaginationStateRef.current) {
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
}
}); });
useEffect(() => {
if (revealInitialHistory) finishInitialFill();
}, [finishInitialFill, revealInitialHistory]);
// Defer drag end so momentum can take ownership, but capture the terminal // Defer drag end so momentum can take ownership, but capture the terminal
// gesture position now because layout may move the viewport in the meantime. // gesture position now because layout may move the viewport in the meantime.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => { const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -506,22 +499,25 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentHeight: nextContentHeight, contentHeight: nextContentHeight,
}); });
evaluateHistoryStart(); evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") { if (isHistoryStartSettling()) {
scheduleHistoryStartSettle(); scheduleHistoryStartSettle();
} }
}); });
useEffect(() => { useEffect(() => {
evaluateHistoryStart(); evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") { if (isHistoryStartSettling()) {
scheduleHistoryStartSettle(); scheduleHistoryStartSettle();
} }
}, [ }, [
evaluateHistoryStart, evaluateHistoryStart,
hasOlderHistory, hasOlderHistory,
isActive,
isAuthoritativeHistoryReady,
isLoadingOlderHistory, isLoadingOlderHistory,
olderHistoryProgressKey, olderHistoryProgressKey,
scheduleHistoryStartSettle, scheduleHistoryStartSettle,
isHistoryStartSettling,
]); ]);
const renderItem = useStableEvent( const renderItem = useStableEvent(
@@ -566,50 +562,64 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
return ( return (
<View style={historyStartSlotStyle} testID="older-history-slot"> <View style={historyStartSlotStyle} testID="older-history-slot">
{isLoadingOperation ? ( {isLoadingOperation && (
<View testID="load-older-history-spinner"> <View testID="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} /> <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View> </View>
) : null} )}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<Button variant="ghost" size="sm" onPress={retryHistoryStart}>
{t("paneFind.retry")}
</Button>
)}
</View> </View>
); );
}, [historyStartPaginationState]); }, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]);
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when // RN's FlatList strictMode keeps its internal renderItem wrapper stable when
// data or the live header changes, preserving the row identities above. // data or the live header changes, preserving the row identities above.
return ( return (
<FlatList <View style={viewportStyle}>
{...listInsetProps} <FlatList
ref={flatListRef} {...listInsetProps}
data={historyRows} ref={flatListRef}
renderItem={renderItem} data={historyRows}
keyExtractor={keyExtractor} renderItem={renderItem}
strictMode keyExtractor={keyExtractor}
testID="agent-chat-scroll" strictMode
nativeID="agent-chat-scroll-native-virtualized" testID="agent-chat-scroll"
ListHeaderComponent={liveHeaderContent ?? undefined} nativeID="agent-chat-scroll-native-virtualized"
ListFooterComponent={historyFooterContent ?? undefined} ListHeaderComponent={liveHeaderContent ?? undefined}
contentContainerStyle={listContentContainerStyle} ListFooterComponent={historyFooterContent ?? undefined}
style={listStyle} contentContainerStyle={listContentContainerStyle}
onLayout={handleListLayout} style={[listStyle, { opacity: historyStartPaginationState.visible ? 1 : 0 }]}
onScroll={handleScroll} onLayout={handleListLayout}
onScrollBeginDrag={handleScrollBeginDrag} onScroll={handleScroll}
onScrollEndDrag={handleScrollEndDrag} onScrollBeginDrag={handleScrollBeginDrag}
onMomentumScrollBegin={handleMomentumScrollBegin} onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollEnd={handleMomentumScrollEnd} onMomentumScrollBegin={handleMomentumScrollBegin}
scrollEventThrottle={16} onMomentumScrollEnd={handleMomentumScrollEnd}
onContentSizeChange={handleContentSizeChange} scrollEventThrottle={16}
maintainVisibleContentPosition={maintainVisibleContentPosition} onContentSizeChange={handleContentSizeChange}
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial maintainVisibleContentPosition={maintainVisibleContentPosition}
// render small, then refill a deeper window in batches large enough to keep up. // Tall Markdown rows exhaust a shallow window during flings. Keep the initial
initialNumToRender={2} // render small, then refill a deeper window in batches large enough to keep up.
maxToRenderPerBatch={10} initialNumToRender={2}
windowSize={NATIVE_STREAM_WINDOW_SIZE} maxToRenderPerBatch={10}
removeClippedSubviews={false} windowSize={NATIVE_STREAM_WINDOW_SIZE}
scrollEnabled={scrollEnabled} removeClippedSubviews={false}
showsVerticalScrollIndicator scrollEnabled={scrollEnabled}
inverted 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, routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true, isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {}, onNearBottomChange: () => {},
onNearHistoryStart: () => true, onNearHistoryStart: async () => true,
isLoadingOlderHistory: false, isLoadingOlderHistory: false,
hasOlderHistory: false, hasOlderHistory: false,
olderHistoryProgressKey: null, olderHistoryProgressKey: null,
@@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null, routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true, isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {}, onNearBottomChange: () => {},
onNearHistoryStart: () => true, onNearHistoryStart: async () => true,
isLoadingOlderHistory: false, isLoadingOlderHistory: false,
hasOlderHistory: false, hasOlderHistory: false,
olderHistoryProgressKey: null, olderHistoryProgressKey: null,
+74 -80
View File
@@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel"; import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { useTranslation } from "react-i18next";
import { getStreamItemMessageId, type StreamItem } from "@/types/stream"; import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; 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 { useRevisedHistoryRows } from "./history-row-revision";
import { createStreamStrategy } from "./strategy"; import { createStreamStrategy } from "./strategy";
import { import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation, isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput, type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination"; } from "./history-start-pagination";
import { useHistoryStartPagination } from "./use-history-start-pagination";
import { import {
createHistoryStartSettleScheduler, createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler, type HistoryStartSettleScheduler,
@@ -77,6 +73,14 @@ const historyStartSlotStyle: CSSProperties = {
}; };
const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" }; 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 = { const streamRowStyle: CSSProperties = {
display: "flex", display: "flex",
@@ -298,6 +302,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
viewportRef, viewportRef,
routeBottomAnchorRequest, routeBottomAnchorRequest,
isAuthoritativeHistoryReady, isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange, onNearBottomChange,
onReadingPositionChange, onReadingPositionChange,
onNearHistoryStart, onNearHistoryStart,
@@ -318,6 +324,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
[historyMounted, historyVirtualized, inputSegments], [historyMounted, historyVirtualized, inputSegments],
); );
const isActive = useRetainedPanelActive(); const isActive = useRetainedPanelActive();
const { t } = useTranslation();
const rowsHostRef = useRef<HTMLDivElement | null>(null);
const auxiliaryRef = useRef<HTMLDivElement | null>(null);
const isActiveRef = useRef(isActive); const isActiveRef = useRef(isActive);
const scrollContainerRef = useRef<HTMLElement | null>(null); const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = 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 pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null); const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false); const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const readingAnchor = useMemo(createReadingAnchor, []); const readingAnchor = useMemo(createReadingAnchor, []);
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>()); const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null); const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
@@ -479,60 +484,39 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer); !followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
return { return {
distanceFromHistoryStart: scrollContainer.scrollTop, distanceFromHistoryStart: scrollContainer.scrollTop,
isUnderfilled:
(rowsHostRef.current?.getBoundingClientRect().height ?? 0) +
(auxiliaryRef.current?.getBoundingClientRect().height ?? 0) <
scrollContainer.clientHeight,
hasOlderHistory, hasOlderHistory,
isLoadingOlderHistory, isLoadingOlderHistory,
// An explicit jump owns the landing position. Revealing another local // An explicit jump owns the landing position. Revealing another local
// page here changes the virtual index before the target has mounted. // page here changes the virtual index before the target has mounted.
isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(), isReady:
historyStartReadyRef.current &&
isAuthoritativeHistoryReady &&
bottomAnchorSettled &&
!isJumpSettling(),
progressKey: olderHistoryProgressKey, progressKey: olderHistoryProgressKey,
}; };
}); });
const applyHistoryStartPaginationTransition = useStableEvent( const { state: historyStartPaginationState, controller: historyStartPagination } =
(transition: HistoryStartPaginationTransition) => { useHistoryStartPagination({
const previousState = historyStartPaginationStateRef.current; initiallyVisible: !isInitialHistoryLoading,
historyStartPaginationStateRef.current = transition.state; read: getHistoryStartPaginationInput,
if (transition.state !== previousState) { load: async () => onNearHistoryStart(),
setHistoryStartPaginationState(transition.state); scheduleSettle: () => scheduleHistoryStartPrependSettle(),
} });
if (!transition.shouldLoad || olderHistoryProgressKey === null) { const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
return; const resetHistoryStart = useStableEvent(() =>
} historyStartPagination.reset(!isInitialHistoryLoading),
const requestedProgressKey = olderHistoryProgressKey;
void (async () => {
const started = await onNearHistoryStart();
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
},
); );
const evaluateHistoryStart = useStableEvent(() => { const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
const input = getHistoryStartPaginationInput(); const isHistoryStartSettling = useStableEvent(
if (!input) { () => historyStartPagination.state.status === "settling",
return; );
} const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
const transition = evaluateHistoryStartPagination( const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm());
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 scheduleHistoryStartPrependSettle = useStableEvent(() => { const scheduleHistoryStartPrependSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current; let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) { if (!scheduler) {
@@ -540,22 +524,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
settleFrames: HISTORY_START_SETTLE_FRAMES, settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: (callback) => window.requestAnimationFrame(callback), requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (frame) => window.cancelAnimationFrame(frame), cancelFrame: (frame) => window.cancelAnimationFrame(frame),
isSettling: () => historyStartPaginationStateRef.current.status === "settling", isSettling: () => isHistoryStartSettling(),
isLoading: () => { isLoading: () => {
const input = getHistoryStartPaginationInput(); const input = getHistoryStartPaginationInput();
return !input || input.isLoadingOlderHistory; return !input || input.isLoadingOlderHistory;
}, },
onSettle: () => { onSettle: () => historyStartPagination.settle(),
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
},
}); });
historyStartSettleSchedulerRef.current = scheduler; historyStartSettleSchedulerRef.current = scheduler;
} }
@@ -782,6 +756,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]); ]);
const stopFollowingOutputFromUserIntent = useStableEvent(() => { const stopFollowingOutputFromUserIntent = useStableEvent(() => {
finishInitialFill();
cancelPendingStickToBottom(); cancelPendingStickToBottom();
if (followOutputRef.current) { if (followOutputRef.current) {
setFollowOutput(false); setFollowOutput(false);
@@ -789,6 +764,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
rearmHistoryStartFromUserIntent(); rearmHistoryStartFromUserIntent();
}); });
useEffect(() => {
if (revealInitialHistory) finishInitialFill();
}, [finishInitialFill, revealInitialHistory]);
const handleDomScroll = useCallback(() => { const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) { if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
@@ -827,9 +806,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]); ]);
useEffect(() => { useEffect(() => {
const initialHistoryStartState = createHistoryStartPaginationState(); resetHistoryStart();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
readingAnchor.reset(); readingAnchor.reset();
const frame = window.requestAnimationFrame(() => { const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true; historyStartReadyRef.current = true;
@@ -838,10 +815,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => { return () => {
window.cancelAnimationFrame(frame); window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false; historyStartReadyRef.current = false;
disposeHistoryStart();
historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null; historyStartSettleSchedulerRef.current = null;
}; };
}, [evaluateHistoryStart, props.agentId, readingAnchor]); }, [disposeHistoryStart, evaluateHistoryStart, props.agentId, readingAnchor, resetHistoryStart]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) { if (!isActiveRef.current || !isActivationReady) {
@@ -930,16 +908,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
updateScrollMetrics(); updateScrollMetrics();
} }
evaluateHistoryStart(); evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") { if (isHistoryStartSettling()) {
scheduleHistoryStartPrependSettle(); scheduleHistoryStartPrependSettle();
} }
}, [ }, [
evaluateHistoryStart, evaluateHistoryStart,
hasOlderHistory, hasOlderHistory,
isActive, isActive,
isAuthoritativeHistoryReady,
isLoadingOlderHistory, isLoadingOlderHistory,
olderHistoryProgressKey, olderHistoryProgressKey,
scheduleHistoryStartPrependSettle, scheduleHistoryStartPrependSettle,
isHistoryStartSettling,
segments.historyMounted.length, segments.historyMounted.length,
segments.historyVirtualized.length, segments.historyVirtualized.length,
segments.liveHead.length, segments.liveHead.length,
@@ -978,7 +958,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
lastObservedViewportGeometryRef.current = nextGeometry; lastObservedViewportGeometryRef.current = nextGeometry;
} }
reconcileReadingPosition(); reconcileReadingPosition();
if (historyStartPaginationStateRef.current.status === "settling") { if (isHistoryStartSettling()) {
scheduleHistoryStartPrependSettle(); scheduleHistoryStartPrependSettle();
} }
updateScrollMetrics(); updateScrollMetrics();
@@ -1012,6 +992,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
isActive, isActive,
reportReadingPosition, reportReadingPosition,
scheduleHistoryStartPrependSettle, scheduleHistoryStartPrependSettle,
isHistoryStartSettling,
forceStickToBottom, forceStickToBottom,
updateScrollMetrics, updateScrollMetrics,
measureVirtualizedRows, measureVirtualizedRows,
@@ -1229,8 +1210,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Detached reading has one owner: reconcileReadingPosition. // Detached reading has one owner: reconcileReadingPosition.
overflowAnchor: followOutput ? "auto" : "none", overflowAnchor: followOutput ? "auto" : "none",
scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined, scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined,
opacity: historyStartPaginationState.visible ? 1 : 0,
}; };
}, [followOutput, isMobileBreakpoint, scrollEnabled]); }, [followOutput, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]);
const viewportStyle = useMemo( const viewportStyle = useMemo(
(): CSSProperties => ({ (): CSSProperties => ({
position: "relative", position: "relative",
@@ -1307,14 +1289,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
return ( return (
<div style={historyStartSlotStyle} data-testid="older-history-slot"> <div style={historyStartSlotStyle} data-testid="older-history-slot">
{isLoadingOperation ? ( {isLoadingOperation && (
<div data-testid="load-older-history-spinner"> <div data-testid="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} /> <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</div> </div>
) : null} )}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<button type="button" onClick={rearmHistoryStartFromUserIntent}>
{t("paneFind.retry")}
</button>
)}
</div> </div>
); );
}, [historyStartPaginationState]); }, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]);
const shouldRenderEmpty = const shouldRenderEmpty =
!boundary.hasMountedHistory && !boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory && !boundary.hasVirtualizedHistory &&
@@ -1334,7 +1323,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
> >
<div ref={handleContentRef} style={contentContainerStyle}> <div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot} {historyStartSlot}
<div style={streamRowsHostStyle}> <div ref={rowsHostRef} style={streamRowsHostStyle}>
<div style={virtualRowsContainerStyle} /> <div style={virtualRowsContainerStyle} />
{[ {[
...virtualRows.map((virtualRow) => { ...virtualRows.map((virtualRow) => {
@@ -1362,10 +1351,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
...mountedRows, ...mountedRows,
]} ]}
</div> </div>
{liveAuxiliary} <div ref={auxiliaryRef}>{liveAuxiliary}</div>
{shouldRenderEmpty ? listEmptyComponent : null} {shouldRenderEmpty ? listEmptyComponent : null}
</div> </div>
</div> </div>
{!historyStartPaginationState.visible ? (
<div data-testid="agent-initial-fill" style={initialFillStyle}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</div>
) : null}
{scrollEnabled && !isMobileBreakpoint ? ( {scrollEnabled && !isMobileBreakpoint ? (
<DomOverlayScrollbar <DomOverlayScrollbar
scrollContainerRef={scrollContainerRef} scrollContainerRef={scrollContainerRef}
+3 -1
View File
@@ -87,11 +87,13 @@ export interface StreamRenderInput {
viewportRef: RefObject<StreamViewportHandle | null>; viewportRef: RefObject<StreamViewportHandle | null>;
routeBottomAnchorRequest: BottomAnchorRouteRequest | null; routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean; isAuthoritativeHistoryReady: boolean;
isInitialHistoryLoading?: boolean;
revealInitialHistory?: boolean;
onNearBottomChange: (value: boolean) => void; onNearBottomChange: (value: boolean) => void;
// The history row under the top of the viewport, for surfaces that mark where the reader // 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. // is in the transcript. Only the web viewport measures it today.
onReadingPositionChange?: (rowId: string | null) => void; onReadingPositionChange?: (rowId: string | null) => void;
onNearHistoryStart: () => boolean | Promise<boolean>; onNearHistoryStart: () => Promise<boolean>;
isLoadingOlderHistory: boolean; isLoadingOlderHistory: boolean;
hasOlderHistory: boolean; hasOlderHistory: boolean;
olderHistoryProgressKey: string | null; 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; turnPresentation: TurnPresentation;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean; isAuthoritativeHistoryReady?: boolean;
isInitialHistoryLoading?: boolean;
revealInitialHistory?: boolean;
/** Tail space required by a transparent overlay rendered at the bottom edge. */ /** Tail space required by a transparent overlay rendered at the bottom edge. */
bottomOverlayTailClearance?: number; bottomOverlayTailClearance?: number;
/** Bottom offset required for controls floating above that overlay. */ /** Bottom offset required for controls floating above that overlay. */
@@ -337,6 +339,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
turnPresentation, turnPresentation,
routeBottomAnchorRequest = null, routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true, isAuthoritativeHistoryReady = true,
isInitialHistoryLoading,
revealInitialHistory,
bottomOverlayTailClearance = 0, bottomOverlayTailClearance = 0,
bottomOverlayControlClearance, bottomOverlayControlClearance,
toast, toast,
@@ -1218,6 +1222,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef, viewportRef,
routeBottomAnchorRequest, routeBottomAnchorRequest,
isAuthoritativeHistoryReady, isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: setIsNearBottom, onNearBottomChange: setIsNearBottom,
onReadingPositionChange: handleReadingPositionChange, onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder, onNearHistoryStart: loadOlder,
@@ -1369,6 +1375,12 @@ function agentStreamViewPropsEqual(
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) { if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
reasons.push("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) { if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
reasons.push("bottomOverlayTailClearance"); reasons.push("bottomOverlayTailClearance");
} }
@@ -129,7 +129,7 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]); expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([true]); 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 () => { 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, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected", projection: "projected",
wholeTurns: true, wholeTurns: true,
timeout: 10_000,
}, },
}, },
]); ]);
@@ -169,7 +170,7 @@ describe("loadOlderAgentHistory", () => {
const toast = createToast(); const toast = createToast();
const logger = createLogger(); const logger = createLogger();
await loadOlderAgentHistory(agentId, { const applied = await loadOlderAgentHistory(agentId, {
client, client,
cursor: someCursor, cursor: someCursor,
hasOlder: true, hasOlder: true,
@@ -179,6 +180,7 @@ describe("loadOlderAgentHistory", () => {
logger, logger,
}); });
expect(applied).toBe(false);
expect(client.calls).toHaveLength(1); expect(client.calls).toHaveLength(1);
expect(toast.shown).toEqual([ expect(toast.shown).toEqual([
{ {
@@ -18,6 +18,7 @@ export interface LoadOlderAgentHistoryClient {
cursor: { epoch: string; seq: number }; cursor: { epoch: string; seq: number };
limit: number; limit: number;
projection: "projected"; projection: "projected";
timeout: number;
}, },
) => Promise<unknown>; ) => Promise<unknown>;
} }
@@ -44,7 +45,7 @@ export async function loadOlderAgentHistory(
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } = const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } =
deps; deps;
if (isLoadingOlder) { if (isLoadingOlder) {
return true; return false;
} }
if (!client || !cursor || !hasOlder) { if (!client || !cursor || !hasOlder) {
return false; return false;
@@ -52,16 +53,17 @@ export async function loadOlderAgentHistory(
setInFlight(true); setInFlight(true);
try { try {
await client.fetchAgentTimeline( await client.fetchAgentTimeline(agentId, {
agentId, ...planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }),
planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }), timeout: 10_000,
); });
} catch (error) { } catch (error) {
(logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error); (logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error);
toast?.show(failedMessage ?? i18n.t("loadOlderHistory.failed"), { toast?.show(failedMessage ?? i18n.t("loadOlderHistory.failed"), {
durationMs: 2200, durationMs: 2200,
testID: "agent-load-older-history-toast", testID: "agent-load-older-history-toast",
}); });
return false;
} finally { } finally {
setInFlight(false); setInFlight(false);
} }
+16
View File
@@ -1021,6 +1021,10 @@ function ChatAgentContent({
effectiveAgent={effectiveAgent} effectiveAgent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest} routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
shouldHoldInitialFill={
routeBottomAnchorRequest?.reason === "initial-entry" && replicaTimelineStatus !== "painted"
}
hasCachedHistory={replicaTimelineStatus === "painted"}
toastApi={toastApi} toastApi={toastApi}
toast={toastState} toast={toastState}
dismiss={dismissToast} dismiss={dismissToast}
@@ -1051,6 +1055,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
effectiveAgent, effectiveAgent,
routeBottomAnchorRequest, routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory, hasAppliedAuthoritativeHistory,
shouldHoldInitialFill,
hasCachedHistory,
toastApi, toastApi,
toast, toast,
dismiss, dismiss,
@@ -1077,6 +1083,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
effectiveAgent: AgentScreenAgent; effectiveAgent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest; routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean; hasAppliedAuthoritativeHistory: boolean;
shouldHoldInitialFill: boolean;
hasCachedHistory: boolean;
toastApi: ToastApi; toastApi: ToastApi;
toast: ToastState | null; toast: ToastState | null;
dismiss: () => void; dismiss: () => void;
@@ -1203,6 +1211,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
agent={effectiveAgent} agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest} routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
isInitialHistoryLoading={shouldHoldInitialFill && !showHistorySyncError}
revealInitialHistory={hasCachedHistory || showHistorySyncError}
hasActiveComposer={hasActiveComposer} hasActiveComposer={hasActiveComposer}
hasVisibleAgentTracks={hasVisibleAgentTracks} hasVisibleAgentTracks={hasVisibleAgentTracks}
toast={toastApi} toast={toastApi}
@@ -1339,6 +1349,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
agent, agent,
routeBottomAnchorRequest, routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory, hasAppliedAuthoritativeHistory,
isInitialHistoryLoading,
revealInitialHistory,
hasActiveComposer, hasActiveComposer,
hasVisibleAgentTracks, hasVisibleAgentTracks,
toast, toast,
@@ -1351,6 +1363,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
agent: AgentScreenAgent; agent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest; routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean; hasAppliedAuthoritativeHistory: boolean;
isInitialHistoryLoading: boolean;
revealInitialHistory: boolean;
hasActiveComposer: boolean; hasActiveComposer: boolean;
hasVisibleAgentTracks: boolean; hasVisibleAgentTracks: boolean;
toast: ReturnType<typeof useToastHost>["api"]; toast: ReturnType<typeof useToastHost>["api"];
@@ -1419,6 +1433,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
pendingPermissions={pendingPermissions} pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest} routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
isInitialHistoryLoading={isInitialHistoryLoading}
revealInitialHistory={revealInitialHistory}
bottomOverlayTailClearance={bottomOverlayTailClearance} bottomOverlayTailClearance={bottomOverlayTailClearance}
bottomOverlayControlClearance={bottomOverlayControlClearance} bottomOverlayControlClearance={bottomOverlayControlClearance}
toast={toast} toast={toast}
@@ -26,6 +26,7 @@ import {
providerSubagentLifecycleStatus, providerSubagentLifecycleStatus,
refreshProviderSubagents, refreshProviderSubagents,
observeProviderSubagentTimeline, observeProviderSubagentTimeline,
loadOlderProviderSubagentTimeline,
useProviderSubagentStore, useProviderSubagentStore,
} from "@/subagents/provider-store"; } from "@/subagents/provider-store";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -180,26 +181,24 @@ function ProviderSubagentPanel() {
}); });
}, [client, supported, serverId, target.parentAgentId, target.subagentId]); }, [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) { if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
return false; return false;
} }
const firstSeq = timeline.cursor?.startSeq ?? null;
if (firstSeq === null) return false;
setIsLoadingOlder(true); setIsLoadingOlder(true);
void client try {
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, { return await loadOlderProviderSubagentTimeline({
direction: "before", client,
cursor: { epoch: timeline.epoch, seq: firstSeq }, serverId,
parentAgentId: target.parentAgentId,
subagentId: target.subagentId,
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
}) });
.then((payload) => { } catch {
useProviderSubagentStore.getState().replaceTimeline(serverId, payload); return false;
return undefined; } finally {
}) setIsLoadingOlder(false);
.catch(() => undefined) }
.finally(() => setIsLoadingOlder(false));
return true;
}, [ }, [
client, client,
isLoadingOlder, isLoadingOlder,
@@ -1,7 +1,9 @@
import { createHistoryStartPagination } from "@/agent-stream/history-start-pagination";
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; 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 type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
import { import {
loadOlderProviderSubagentTimeline,
observeProviderSubagentTimeline, observeProviderSubagentTimeline,
providerSubagentKey, providerSubagentKey,
refreshProviderSubagents, refreshProviderSubagents,
@@ -565,6 +567,95 @@ describe("projected child history", () => {
.getState() .getState()
.replaceTimeline(SERVER_ID, response(value, seqStart, seqEnd, direction)); .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", () => { test("replaces overlapping fetched text and continues live streaming", () => {
page("AB", 1, 2); page("AB", 1, 2);
stream(3, "C"); 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. */ /** 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({ export function observeProviderSubagentTimeline({
client, client,
serverId, serverId,