mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5ef3d6bd2 | ||
|
|
d57777ff3b |
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user