mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9e56e1eb9 | ||
|
|
3274d67959 | ||
|
|
2fbc5f4b59 | ||
|
|
833b4ef80a | ||
|
|
39f1058005 | ||
|
|
fd65985e9d |
@@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
|
||||
|
||||
## Local build + install
|
||||
|
||||
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
|
||||
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
|
||||
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
|
||||
regression renders real gradient pixels across growing and shrinking layouts, including a nested
|
||||
group. From `packages/app/android`, run it on your selected emulator:
|
||||
|
||||
```bash
|
||||
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
|
||||
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
|
||||
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
|
||||
It backports
|
||||
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
|
||||
|
||||
@@ -69,8 +69,8 @@ Source coverage still includes interleaved tool updates.
|
||||
|
||||
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
|
||||
agent messages each start a fallback turn. This deterministic fallback is shared by paging
|
||||
and presentation; it cannot recover historical batching or steering. An underfilled viewport
|
||||
loads older history until it fills or history is exhausted. Further history is user-driven.
|
||||
and presentation; it cannot recover historical batching or steering. Older history remains
|
||||
user-driven by scrolling upward.
|
||||
|
||||
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
|
||||
|
||||
@@ -407,86 +407,34 @@ async function seedWholeTurnHistory() {
|
||||
return agent;
|
||||
}
|
||||
|
||||
async function expectInitialFillIsConcealed(page: Page) {
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "0");
|
||||
}
|
||||
|
||||
async function expectFilledChatAtBottom(page: Page) {
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
|
||||
const chat = page.getByTestId("agent-chat-scroll");
|
||||
await expect(chat).toHaveCSS("opacity", "1");
|
||||
await expect
|
||||
.poll(() =>
|
||||
chat.evaluate((element) => ({
|
||||
hasOverflow: element.scrollHeight > element.clientHeight,
|
||||
atBottom: Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 2,
|
||||
})),
|
||||
)
|
||||
.toEqual({ hasOverflow: true, atBottom: true });
|
||||
}
|
||||
|
||||
test("fills collapsed history before revealing a fresh chat", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedWholeTurnHistory();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentRoute(page, agent);
|
||||
await history.expectRequestedPages(1);
|
||||
await expectInitialFillIsConcealed(page);
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
await expectInitialFillIsConcealed(page);
|
||||
history.releasePage(2);
|
||||
await expectFilledChatAtBottom(page);
|
||||
await page.setViewportSize({ width: 1280, height: 8000 });
|
||||
await history.expectRequestedPages(3);
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "1");
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
|
||||
history.releasePage(3);
|
||||
await history.expectSettledWithRequestedPages(3);
|
||||
history.expectNoRepeatedEntries();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads complete long turns and preserves expansion while revealing older history", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedWholeTurnHistory();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentRoute(page, agent);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
history.releasePage(2);
|
||||
await expectFilledChatAtBottom(page);
|
||||
await expectComposerVisible(page);
|
||||
const latest = page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 120 commands", exact: true });
|
||||
await expect(latest).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 60 commands", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(3);
|
||||
history.releasePage(3);
|
||||
await page.getByTestId("scroll-to-bottom-button").click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
history.expectNoRepeatedEntries();
|
||||
await latest.scrollIntoViewIfNeeded();
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
} finally {
|
||||
|
||||
@@ -1,238 +1,197 @@
|
||||
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createHistoryStartPagination,
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
const initial: HistoryStartPaginationInput = {
|
||||
function createArmedHistoryStartPaginationState() {
|
||||
return rearmHistoryStartPagination(createHistoryStartPaginationState());
|
||||
}
|
||||
|
||||
const visibleHistoryStart: HistoryStartPaginationInput = {
|
||||
distanceFromHistoryStart: 0,
|
||||
isUnderfilled: true,
|
||||
hasOlderHistory: true,
|
||||
isLoadingOlderHistory: false,
|
||||
isReady: true,
|
||||
progressKey: "20",
|
||||
progressKey: "epoch-1:20",
|
||||
};
|
||||
|
||||
function harness(initiallyVisible = false) {
|
||||
let geometry = { ...initial };
|
||||
const requests: Array<(value: boolean) => void> = [];
|
||||
let settles = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible,
|
||||
read: () => geometry,
|
||||
load: () => new Promise<boolean>((resolve) => requests.push(resolve)),
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {
|
||||
settles++;
|
||||
},
|
||||
});
|
||||
return {
|
||||
pagination,
|
||||
requests,
|
||||
get settles() {
|
||||
return settles;
|
||||
},
|
||||
set(patch: Partial<HistoryStartPaginationInput>) {
|
||||
geometry = { ...geometry, ...patch };
|
||||
pagination.update();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("history start pagination", () => {
|
||||
it("waits for a delayed request and its committed page before settling", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
expect(h.settles).toBe(0);
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
expect(h.settles).toBe(1);
|
||||
expect(h.pagination.state.visible).toBe(false);
|
||||
h.set({ isUnderfilled: false });
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("waits for the committed page after a request completes", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
expect(h.settles).toBe(0);
|
||||
h.set({ progressKey: "10" });
|
||||
expect(h.settles).toBe(1);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("continues short pages and reveals on exhaustion", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.settle();
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.set({ hasOlderHistory: false });
|
||||
h.requests[1](true);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("releases a failed or stalled request and allows retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
vi.advanceTimersByTime(15_000);
|
||||
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
|
||||
h.pagination.rearm();
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.requests[1](false);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state.status).toBe("failed");
|
||||
h.pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
it("keeps visible history visible while filling", () => {
|
||||
const h = harness(true);
|
||||
h.pagination.update();
|
||||
expect(h.pagination.state.visible).toBe(true);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("does not load a full viewport until user intent and respects inactive views", () => {
|
||||
const h = harness();
|
||||
h.set({ isUnderfilled: false });
|
||||
expect(h.requests).toHaveLength(0);
|
||||
h.set({ isReady: false });
|
||||
h.pagination.rearm();
|
||||
expect(h.requests).toHaveLength(0);
|
||||
h.set({ isReady: true });
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("preserves the reading position after an anchored page moves away from the start", async () => {
|
||||
const h = harness(true);
|
||||
h.set({ isUnderfilled: false });
|
||||
h.pagination.rearm();
|
||||
h.set({ progressKey: "10", distanceFromHistoryStart: 300 });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.settle();
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.set({ distanceFromHistoryStart: 0 });
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a timed-out response after the reader retries", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
vi.advanceTimersByTime(15_000);
|
||||
h.pagination.rearm();
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state.status).toBe("loading");
|
||||
expect(h.settles).toBe(0);
|
||||
h.requests[1](false);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state.status).toBe("failed");
|
||||
h.pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not repeat a successful page that made no progress", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.update();
|
||||
expect(h.requests).toHaveLength(1);
|
||||
vi.advanceTimersByTime(15_000);
|
||||
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
|
||||
h.pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("waits until the cursor and active history are available", () => {
|
||||
const h = harness();
|
||||
h.set({ isReady: false, progressKey: null });
|
||||
h.set({ isReady: true, isLoadingOlderHistory: true });
|
||||
h.set({ isLoadingOlderHistory: false });
|
||||
expect(h.requests).toHaveLength(0);
|
||||
h.set({ progressKey: "20" });
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("reveals immediately on exhausted history without offering retry", () => {
|
||||
const h = harness();
|
||||
h.set({ hasOlderHistory: false });
|
||||
expect(h.requests).toHaveLength(0);
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("keeps a settled page pending while its view is inactive", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.set({ isReady: false });
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state.status).toBe("settling");
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.set({ isReady: true, isUnderfilled: false });
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("keeps cached history visible after synchronization and subsequent pagination", async () => {
|
||||
const h = harness();
|
||||
h.pagination.reveal();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state.visible).toBe(true);
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, 123, 0],
|
||||
[false, 123, 0],
|
||||
[true, 0, 123],
|
||||
[false, 0, 123],
|
||||
])(
|
||||
"handles native padding when short history has older=%s",
|
||||
(hasOlderHistory, topOverlayInset, bottomPadding) => {
|
||||
const geometry = nativeHistoryStartGeometry({
|
||||
contentHeight: 825,
|
||||
viewportHeight: 700,
|
||||
offsetY: 0,
|
||||
topOverlayInset,
|
||||
bottomPadding,
|
||||
it("treats locally revealed history as progress before remote pagination", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const localReveal = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:20:local-60",
|
||||
});
|
||||
let requests = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: false,
|
||||
read: () => ({ ...initial, ...geometry, hasOlderHistory }),
|
||||
load: () => {
|
||||
requests++;
|
||||
return new Promise<boolean>(() => {});
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
|
||||
expect(first.shouldLoad).toBe(true);
|
||||
expect(localReveal.state).toEqual({
|
||||
status: "settling",
|
||||
loadedProgressKey: "epoch-1:20:local-60",
|
||||
});
|
||||
pagination.update();
|
||||
expect(requests).toBe(hasOlderHistory ? 1 : 0);
|
||||
expect(pagination.state.visible).toBe(!hasOlderHistory);
|
||||
pagination.dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
it("waits for user scroll intent before loading", () => {
|
||||
const dormant = createHistoryStartPaginationState();
|
||||
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
|
||||
|
||||
expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false });
|
||||
});
|
||||
|
||||
it("waits for anchored page geometry before authorizing another page", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const pageApplied = evaluateHistoryStartPagination(inFlight.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" });
|
||||
});
|
||||
|
||||
it("loads one page each time anchored geometry leaves and returns to history start", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const pageApplied = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const settledAway = settleHistoryStartPagination(pageApplied.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 300,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const returned = evaluateHistoryStartPagination(settledAway.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect([
|
||||
first.shouldLoad,
|
||||
pageApplied.shouldLoad,
|
||||
settledAway.shouldLoad,
|
||||
returned.shouldLoad,
|
||||
]).toEqual([true, false, false, true]);
|
||||
});
|
||||
|
||||
it("continues the same loading operation when anchored geometry remains at history start", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const pageApplied = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const continued = settleHistoryStartPagination(pageApplied.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect(continued.shouldLoad).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(first.state)).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(continued.state)).toBe(true);
|
||||
});
|
||||
|
||||
it("latches a request that finishes without cursor progress", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
|
||||
const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart);
|
||||
const away = evaluateHistoryStartPagination(duplicate.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 300,
|
||||
});
|
||||
|
||||
expect(finished.state).toEqual({ status: "latched" });
|
||||
expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(away.state).toEqual({ status: "ready" });
|
||||
});
|
||||
|
||||
it("allows another user attempt after a request finishes without progress", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
|
||||
const retried = evaluateHistoryStartPagination(
|
||||
rearmHistoryStartPagination(failed.state),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect(failed.state).toEqual({ status: "latched" });
|
||||
expect(retried.shouldLoad).toBe(true);
|
||||
});
|
||||
|
||||
it("latches an attempt that becomes invalid before entering flight", () => {
|
||||
const requested = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({
|
||||
status: "latched",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mistake repeated edge observations for a finished request", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
|
||||
|
||||
expect(repeated.state).toBe(first.state);
|
||||
expect(repeated.shouldLoad).toBe(false);
|
||||
});
|
||||
|
||||
it("waits while history loading is unavailable or already active", () => {
|
||||
const state = createArmedHistoryStartPaginationState();
|
||||
|
||||
expect([
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false })
|
||||
.shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true })
|
||||
.shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null })
|
||||
.shouldLoad,
|
||||
]).toEqual([false, false, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,135 +1,138 @@
|
||||
export const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
export type HistoryStartPaginationState =
|
||||
| { status: "dormant" }
|
||||
| { status: "ready" }
|
||||
| { status: "loading"; requestedProgressKey: string; requestObserved: boolean }
|
||||
| { status: "settling"; loadedProgressKey: string }
|
||||
| { status: "latched" };
|
||||
|
||||
export interface HistoryStartPaginationInput {
|
||||
distanceFromHistoryStart: number;
|
||||
isUnderfilled: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
isLoadingOlderHistory: boolean;
|
||||
isReady: boolean;
|
||||
progressKey: string | null;
|
||||
}
|
||||
|
||||
export interface HistoryStartPaginationState {
|
||||
status: "dormant" | "ready" | "loading" | "settling" | "failed";
|
||||
visible: boolean;
|
||||
export interface HistoryStartPaginationTransition {
|
||||
state: HistoryStartPaginationState;
|
||||
shouldLoad: boolean;
|
||||
}
|
||||
|
||||
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
|
||||
return { status: "dormant" };
|
||||
}
|
||||
|
||||
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
|
||||
return state.status === "loading" || state.status === "settling";
|
||||
}
|
||||
|
||||
/** Owns one pagination operation, including the initial viewport reveal. */
|
||||
export function createHistoryStartPagination(input: {
|
||||
initiallyVisible: boolean;
|
||||
read: () => HistoryStartPaginationInput | null;
|
||||
load: () => Promise<boolean>;
|
||||
changed: (state: HistoryStartPaginationState) => void;
|
||||
scheduleSettle: () => void;
|
||||
}) {
|
||||
let state: HistoryStartPaginationState = { status: "dormant", visible: input.initiallyVisible };
|
||||
let requestedKey: string | null = null;
|
||||
let completed = false;
|
||||
let fill = false;
|
||||
let generation = 0;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
export function rearmHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
): HistoryStartPaginationState {
|
||||
return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state;
|
||||
}
|
||||
|
||||
const publish = (status: HistoryStartPaginationState["status"], visible = state.visible) => {
|
||||
if (status === state.status && visible === state.visible) return;
|
||||
state = { status, visible };
|
||||
input.changed(state);
|
||||
};
|
||||
const clear = () => {
|
||||
generation++;
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
};
|
||||
const finish = (status: HistoryStartPaginationState["status"]) => {
|
||||
clear();
|
||||
publish(status, true);
|
||||
};
|
||||
const fail = () => finish("failed");
|
||||
export function abandonHistoryStartPaginationRequest(
|
||||
state: HistoryStartPaginationState,
|
||||
requestedProgressKey: string,
|
||||
): HistoryStartPaginationState {
|
||||
if (
|
||||
state.status !== "loading" ||
|
||||
state.requestObserved ||
|
||||
state.requestedProgressKey !== requestedProgressKey
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return { status: "latched" };
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const geometry = input.read();
|
||||
if (!geometry) return;
|
||||
if (state.status === "loading") {
|
||||
if (!completed) return;
|
||||
if (geometry.progressKey !== null && geometry.progressKey !== requestedKey) {
|
||||
publish("settling");
|
||||
input.scheduleSettle();
|
||||
} else if (!geometry.hasOlderHistory) {
|
||||
finish("dormant");
|
||||
}
|
||||
return;
|
||||
export function evaluateHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: HistoryStartPaginationInput,
|
||||
): HistoryStartPaginationTransition {
|
||||
if (state.status === "dormant") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
if (state.status === "loading") {
|
||||
if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) {
|
||||
return {
|
||||
state: { status: "settling", loadedProgressKey: input.progressKey },
|
||||
shouldLoad: false,
|
||||
};
|
||||
}
|
||||
if (state.status === "settling" || state.status === "failed" || !geometry.isReady) return;
|
||||
const atStart = geometry.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (state.status === "dormant" && !geometry.isUnderfilled) {
|
||||
publish("dormant", true);
|
||||
return;
|
||||
if (input.isLoadingOlderHistory && !state.requestObserved) {
|
||||
return { state: { ...state, requestObserved: true }, shouldLoad: false };
|
||||
}
|
||||
if (!atStart || (!geometry.isUnderfilled && state.status !== "ready")) return;
|
||||
if (!geometry.hasOlderHistory) {
|
||||
finish("dormant");
|
||||
return;
|
||||
if (!input.isLoadingOlderHistory && !input.hasOlderHistory) {
|
||||
return { state: { status: "latched" }, shouldLoad: false };
|
||||
}
|
||||
if (geometry.isLoadingOlderHistory || geometry.progressKey === null) return;
|
||||
fill = geometry.isUnderfilled;
|
||||
requestedKey = geometry.progressKey;
|
||||
completed = false;
|
||||
publish("loading");
|
||||
const request = ++generation;
|
||||
timeout = setTimeout(() => {
|
||||
if (request === generation) fail();
|
||||
}, 15_000);
|
||||
void input.load().then(
|
||||
(applied) => {
|
||||
if (request !== generation) return undefined;
|
||||
if (!applied) {
|
||||
fail();
|
||||
return undefined;
|
||||
}
|
||||
completed = true;
|
||||
update();
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
if (request === generation) fail();
|
||||
},
|
||||
);
|
||||
};
|
||||
if (
|
||||
state.requestObserved &&
|
||||
!input.isLoadingOlderHistory &&
|
||||
input.progressKey === state.requestedProgressKey
|
||||
) {
|
||||
return { state: { status: "latched" }, shouldLoad: false };
|
||||
}
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
|
||||
if (state.status === "settling") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
|
||||
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (!isAtHistoryStart) {
|
||||
return state.status === "ready"
|
||||
? { state, shouldLoad: false }
|
||||
: { state: { status: "ready" }, shouldLoad: false };
|
||||
}
|
||||
if (
|
||||
state.status === "latched" ||
|
||||
!input.isReady ||
|
||||
!input.hasOlderHistory ||
|
||||
input.isLoadingOlderHistory ||
|
||||
input.progressKey === null
|
||||
) {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
state: {
|
||||
status: "loading",
|
||||
requestedProgressKey: input.progressKey,
|
||||
requestObserved: false,
|
||||
},
|
||||
update,
|
||||
settle() {
|
||||
if (state.status !== "settling") return;
|
||||
const geometry = input.read();
|
||||
if (!geometry?.isReady) return;
|
||||
clear();
|
||||
if (fill && !geometry.isUnderfilled) {
|
||||
publish("dormant", true);
|
||||
} else if (geometry.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
|
||||
publish("ready", true);
|
||||
} else if (!geometry.hasOlderHistory) {
|
||||
publish("dormant", true);
|
||||
} else {
|
||||
publish("ready");
|
||||
update();
|
||||
}
|
||||
shouldLoad: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function settleHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: HistoryStartPaginationInput,
|
||||
): HistoryStartPaginationTransition {
|
||||
if (state.status !== "settling") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (
|
||||
!isAtHistoryStart ||
|
||||
!input.isReady ||
|
||||
!input.hasOlderHistory ||
|
||||
input.isLoadingOlderHistory ||
|
||||
input.progressKey === null
|
||||
) {
|
||||
return {
|
||||
state: isAtHistoryStart ? { status: "latched" } : { status: "ready" },
|
||||
shouldLoad: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: {
|
||||
status: "loading",
|
||||
requestedProgressKey: input.progressKey,
|
||||
requestObserved: false,
|
||||
},
|
||||
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,
|
||||
shouldLoad: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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,4 +1,12 @@
|
||||
import { Fragment, type ReactElement, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Platform,
|
||||
@@ -11,9 +19,6 @@ import {
|
||||
} from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -29,11 +34,15 @@ import {
|
||||
resolveBottomAnchorTransportBehavior,
|
||||
} from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
} from "./history-start-pagination";
|
||||
import { useHistoryStartPagination } from "./use-history-start-pagination";
|
||||
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
|
||||
import { useStreamTopOverlayInset } from "./top-overlay";
|
||||
import {
|
||||
createHistoryStartSettleScheduler,
|
||||
@@ -56,16 +65,6 @@ const historyStartSlotStyle: ViewStyle = {
|
||||
flexShrink: 0,
|
||||
};
|
||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||
const viewportStyle: ViewStyle = { flex: 1 };
|
||||
const initialFillStyle: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
const NATIVE_STREAM_WINDOW_SIZE = 10;
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
@@ -84,8 +83,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange,
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory,
|
||||
@@ -96,8 +93,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
baseListContentContainerStyle,
|
||||
strategy,
|
||||
} = props;
|
||||
const isActive = useRetainedPanelActive();
|
||||
const { t } = useTranslation();
|
||||
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
const streamViewportMetricsRef = useRef({
|
||||
@@ -118,6 +113,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const isNativeViewportSettlingRef = useRef(false);
|
||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
||||
createHistoryStartPaginationState,
|
||||
);
|
||||
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
|
||||
const historyItems = useMemo(() => {
|
||||
@@ -133,40 +132,48 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
metrics.viewportMeasuredForKey === metrics.containerKey &&
|
||||
metrics.contentMeasuredForKey === metrics.containerKey;
|
||||
return {
|
||||
...nativeHistoryStartGeometry({
|
||||
contentHeight: metrics.contentHeight,
|
||||
viewportHeight: metrics.viewportHeight,
|
||||
offsetY: metrics.offsetY,
|
||||
topOverlayInset,
|
||||
bottomPadding: streamKeyboardInset.contentContainerPaddingBottom,
|
||||
}),
|
||||
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
isReady:
|
||||
isActive &&
|
||||
isAuthoritativeHistoryReady &&
|
||||
historyStartReadyRef.current &&
|
||||
hasMeasuredViewport,
|
||||
isReady: historyStartReadyRef.current && hasMeasuredViewport,
|
||||
progressKey: olderHistoryProgressKey,
|
||||
};
|
||||
});
|
||||
const { state: historyStartPaginationState, controller: historyStartPagination } =
|
||||
useHistoryStartPagination({
|
||||
initiallyVisible: !isInitialHistoryLoading,
|
||||
read: getHistoryStartPaginationInput,
|
||||
load: async () => onNearHistoryStart(),
|
||||
scheduleSettle: () => scheduleHistoryStartSettle(),
|
||||
});
|
||||
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
|
||||
const resetHistoryStart = useStableEvent(() =>
|
||||
historyStartPagination.reset(!isInitialHistoryLoading),
|
||||
const applyHistoryStartPaginationTransition = useStableEvent(
|
||||
(transition: HistoryStartPaginationTransition) => {
|
||||
const previousState = historyStartPaginationStateRef.current;
|
||||
historyStartPaginationStateRef.current = transition.state;
|
||||
if (transition.state !== previousState) {
|
||||
setHistoryStartPaginationState(transition.state);
|
||||
}
|
||||
if (transition.shouldLoad) {
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
if (requestedProgressKey === null) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const started = await onNearHistoryStart();
|
||||
if (started === true) {
|
||||
return;
|
||||
}
|
||||
applyHistoryStartPaginationTransition({
|
||||
state: abandonHistoryStartPaginationRequest(
|
||||
historyStartPaginationStateRef.current,
|
||||
requestedProgressKey,
|
||||
),
|
||||
shouldLoad: false,
|
||||
});
|
||||
})();
|
||||
}
|
||||
},
|
||||
);
|
||||
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
|
||||
const isHistoryStartSettling = useStableEvent(
|
||||
() => historyStartPagination.state.status === "settling",
|
||||
);
|
||||
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
|
||||
const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm());
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const transition = evaluateHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
getHistoryStartPaginationInput(),
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
});
|
||||
const scheduleHistoryStartSettle = useStableEvent(() => {
|
||||
let scheduler = historyStartSettleSchedulerRef.current;
|
||||
if (!scheduler) {
|
||||
@@ -174,9 +181,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
settleFrames: HISTORY_START_SETTLE_FRAMES,
|
||||
requestFrame: requestAnimationFrame,
|
||||
cancelFrame: cancelAnimationFrame,
|
||||
isSettling: () => isHistoryStartSettling(),
|
||||
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
||||
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
|
||||
onSettle: () => historyStartPagination.settle(),
|
||||
onSettle: () => {
|
||||
const transition = settleHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
getHistoryStartPaginationInput(),
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
},
|
||||
});
|
||||
historyStartSettleSchedulerRef.current = scheduler;
|
||||
}
|
||||
@@ -315,7 +328,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
clearNativeViewportSettling();
|
||||
isNativeViewportSettlingRef.current = false;
|
||||
historyStartReadyRef.current = false;
|
||||
resetHistoryStart();
|
||||
const initialHistoryStartState = createHistoryStartPaginationState();
|
||||
historyStartPaginationStateRef.current = initialHistoryStartState;
|
||||
setHistoryStartPaginationState(initialHistoryStartState);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
@@ -323,18 +338,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearPendingUserScrollEnd();
|
||||
disposeHistoryStart();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [
|
||||
agentId,
|
||||
clearNativeViewportSettling,
|
||||
clearPendingUserScrollEnd,
|
||||
disposeHistoryStart,
|
||||
evaluateHistoryStart,
|
||||
resetHistoryStart,
|
||||
]);
|
||||
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
|
||||
|
||||
useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]);
|
||||
|
||||
@@ -414,18 +421,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
|
||||
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
finishInitialFill();
|
||||
clearPendingUserScrollEnd();
|
||||
isUserScrollActiveRef.current = true;
|
||||
scrollKeyboardDismiss.onScrollBeginDrag(event);
|
||||
bottomAnchorController.beginUserScroll();
|
||||
historyStartPagination.rearm();
|
||||
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
||||
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
|
||||
// gesture position now because layout may move the viewport in the meantime.
|
||||
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
@@ -499,25 +506,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentHeight: nextContentHeight,
|
||||
});
|
||||
evaluateHistoryStart();
|
||||
if (isHistoryStartSettling()) {
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartSettle();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
evaluateHistoryStart();
|
||||
if (isHistoryStartSettling()) {
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartSettle();
|
||||
}
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isActive,
|
||||
isAuthoritativeHistoryReady,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartSettle,
|
||||
isHistoryStartSettling,
|
||||
]);
|
||||
|
||||
const renderItem = useStableEvent(
|
||||
@@ -562,64 +566,50 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
|
||||
return (
|
||||
<View style={historyStartSlotStyle} testID="older-history-slot">
|
||||
{isLoadingOperation && (
|
||||
{isLoadingOperation ? (
|
||||
<View testID="load-older-history-spinner">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
)}
|
||||
{!isLoadingOperation &&
|
||||
historyStartPaginationState.status === "failed" &&
|
||||
hasOlderHistory && (
|
||||
<Button variant="ghost" size="sm" onPress={retryHistoryStart}>
|
||||
{t("paneFind.retry")}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]);
|
||||
}, [historyStartPaginationState]);
|
||||
|
||||
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
|
||||
// data or the live header changes, preserving the row identities above.
|
||||
return (
|
||||
<View style={viewportStyle}>
|
||||
<FlatList
|
||||
{...listInsetProps}
|
||||
ref={flatListRef}
|
||||
data={historyRows}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
strictMode
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={liveHeaderContent ?? undefined}
|
||||
ListFooterComponent={historyFooterContent ?? undefined}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
style={[listStyle, { opacity: historyStartPaginationState.visible ? 1 : 0 }]}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEndDrag}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
|
||||
// render small, then refill a deeper window in batches large enough to keep up.
|
||||
initialNumToRender={2}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={NATIVE_STREAM_WINDOW_SIZE}
|
||||
removeClippedSubviews={false}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
inverted
|
||||
/>
|
||||
{!historyStartPaginationState.visible ? (
|
||||
<View pointerEvents="none" testID="agent-initial-fill" style={initialFillStyle}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<FlatList
|
||||
{...listInsetProps}
|
||||
ref={flatListRef}
|
||||
data={historyRows}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
strictMode
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={liveHeaderContent ?? undefined}
|
||||
ListFooterComponent={historyFooterContent ?? undefined}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEndDrag}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
|
||||
// render small, then refill a deeper window in batches large enough to keep up.
|
||||
initialNumToRender={2}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={NATIVE_STREAM_WINDOW_SIZE}
|
||||
removeClippedSubviews={false}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
inverted
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: async () => true,
|
||||
onNearHistoryStart: () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
@@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: async () => true,
|
||||
onNearHistoryStart: () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
|
||||
@@ -20,7 +20,6 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
@@ -31,10 +30,15 @@ import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
} from "./history-start-pagination";
|
||||
import { useHistoryStartPagination } from "./use-history-start-pagination";
|
||||
import {
|
||||
createHistoryStartSettleScheduler,
|
||||
type HistoryStartSettleScheduler,
|
||||
@@ -73,14 +77,6 @@ const historyStartSlotStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" };
|
||||
const initialFillStyle: CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
const streamRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
@@ -302,8 +298,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange,
|
||||
onReadingPositionChange,
|
||||
onNearHistoryStart,
|
||||
@@ -324,9 +318,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
[historyMounted, historyVirtualized, inputSegments],
|
||||
);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const { t } = useTranslation();
|
||||
const rowsHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const auxiliaryRef = useRef<HTMLDivElement | null>(null);
|
||||
const isActiveRef = useRef(isActive);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const contentRef = useRef<HTMLElement | null>(null);
|
||||
@@ -360,6 +351,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
||||
createHistoryStartPaginationState,
|
||||
);
|
||||
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
||||
const readingAnchor = useMemo(createReadingAnchor, []);
|
||||
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
@@ -484,39 +479,60 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
|
||||
return {
|
||||
distanceFromHistoryStart: scrollContainer.scrollTop,
|
||||
isUnderfilled:
|
||||
(rowsHostRef.current?.getBoundingClientRect().height ?? 0) +
|
||||
(auxiliaryRef.current?.getBoundingClientRect().height ?? 0) <
|
||||
scrollContainer.clientHeight,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
// An explicit jump owns the landing position. Revealing another local
|
||||
// page here changes the virtual index before the target has mounted.
|
||||
isReady:
|
||||
historyStartReadyRef.current &&
|
||||
isAuthoritativeHistoryReady &&
|
||||
bottomAnchorSettled &&
|
||||
!isJumpSettling(),
|
||||
isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(),
|
||||
progressKey: olderHistoryProgressKey,
|
||||
};
|
||||
});
|
||||
const { state: historyStartPaginationState, controller: historyStartPagination } =
|
||||
useHistoryStartPagination({
|
||||
initiallyVisible: !isInitialHistoryLoading,
|
||||
read: getHistoryStartPaginationInput,
|
||||
load: async () => onNearHistoryStart(),
|
||||
scheduleSettle: () => scheduleHistoryStartPrependSettle(),
|
||||
});
|
||||
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
|
||||
const resetHistoryStart = useStableEvent(() =>
|
||||
historyStartPagination.reset(!isInitialHistoryLoading),
|
||||
const applyHistoryStartPaginationTransition = useStableEvent(
|
||||
(transition: HistoryStartPaginationTransition) => {
|
||||
const previousState = historyStartPaginationStateRef.current;
|
||||
historyStartPaginationStateRef.current = transition.state;
|
||||
if (transition.state !== previousState) {
|
||||
setHistoryStartPaginationState(transition.state);
|
||||
}
|
||||
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
|
||||
return;
|
||||
}
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
void (async () => {
|
||||
const started = await onNearHistoryStart();
|
||||
if (started === true) {
|
||||
return;
|
||||
}
|
||||
applyHistoryStartPaginationTransition({
|
||||
state: abandonHistoryStartPaginationRequest(
|
||||
historyStartPaginationStateRef.current,
|
||||
requestedProgressKey,
|
||||
),
|
||||
shouldLoad: false,
|
||||
});
|
||||
})();
|
||||
},
|
||||
);
|
||||
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
|
||||
const isHistoryStartSettling = useStableEvent(
|
||||
() => historyStartPagination.state.status === "settling",
|
||||
);
|
||||
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
|
||||
const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm());
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const transition = evaluateHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
input,
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
});
|
||||
const rearmHistoryStartFromUserIntent = useStableEvent(() => {
|
||||
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
||||
if (rearmed === historyStartPaginationStateRef.current) {
|
||||
return;
|
||||
}
|
||||
historyStartPaginationStateRef.current = rearmed;
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
|
||||
let scheduler = historyStartSettleSchedulerRef.current;
|
||||
if (!scheduler) {
|
||||
@@ -524,12 +540,22 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
settleFrames: HISTORY_START_SETTLE_FRAMES,
|
||||
requestFrame: (callback) => window.requestAnimationFrame(callback),
|
||||
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
|
||||
isSettling: () => isHistoryStartSettling(),
|
||||
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
||||
isLoading: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
return !input || input.isLoadingOlderHistory;
|
||||
},
|
||||
onSettle: () => historyStartPagination.settle(),
|
||||
onSettle: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const transition = settleHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
input,
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
},
|
||||
});
|
||||
historyStartSettleSchedulerRef.current = scheduler;
|
||||
}
|
||||
@@ -756,7 +782,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]);
|
||||
|
||||
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
||||
finishInitialFill();
|
||||
cancelPendingStickToBottom();
|
||||
if (followOutputRef.current) {
|
||||
setFollowOutput(false);
|
||||
@@ -764,10 +789,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
rearmHistoryStartFromUserIntent();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (revealInitialHistory) finishInitialFill();
|
||||
}, [finishInitialFill, revealInitialHistory]);
|
||||
|
||||
const handleDomScroll = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
|
||||
@@ -806,7 +827,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
resetHistoryStart();
|
||||
const initialHistoryStartState = createHistoryStartPaginationState();
|
||||
historyStartPaginationStateRef.current = initialHistoryStartState;
|
||||
setHistoryStartPaginationState(initialHistoryStartState);
|
||||
readingAnchor.reset();
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
@@ -815,11 +838,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
historyStartReadyRef.current = false;
|
||||
disposeHistoryStart();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [disposeHistoryStart, evaluateHistoryStart, props.agentId, readingAnchor, resetHistoryStart]);
|
||||
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current || !isActivationReady) {
|
||||
@@ -908,18 +930,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
updateScrollMetrics();
|
||||
}
|
||||
evaluateHistoryStart();
|
||||
if (isHistoryStartSettling()) {
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isActive,
|
||||
isAuthoritativeHistoryReady,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
isHistoryStartSettling,
|
||||
segments.historyMounted.length,
|
||||
segments.historyVirtualized.length,
|
||||
segments.liveHead.length,
|
||||
@@ -958,7 +978,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
lastObservedViewportGeometryRef.current = nextGeometry;
|
||||
}
|
||||
reconcileReadingPosition();
|
||||
if (isHistoryStartSettling()) {
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
updateScrollMetrics();
|
||||
@@ -992,7 +1012,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
isActive,
|
||||
reportReadingPosition,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
isHistoryStartSettling,
|
||||
forceStickToBottom,
|
||||
updateScrollMetrics,
|
||||
measureVirtualizedRows,
|
||||
@@ -1210,9 +1229,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
// Detached reading has one owner: reconcileReadingPosition.
|
||||
overflowAnchor: followOutput ? "auto" : "none",
|
||||
scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined,
|
||||
opacity: historyStartPaginationState.visible ? 1 : 0,
|
||||
};
|
||||
}, [followOutput, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]);
|
||||
}, [followOutput, isMobileBreakpoint, scrollEnabled]);
|
||||
const viewportStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
position: "relative",
|
||||
@@ -1289,21 +1307,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
|
||||
return (
|
||||
<div style={historyStartSlotStyle} data-testid="older-history-slot">
|
||||
{isLoadingOperation && (
|
||||
{isLoadingOperation ? (
|
||||
<div data-testid="load-older-history-spinner">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOperation &&
|
||||
historyStartPaginationState.status === "failed" &&
|
||||
hasOlderHistory && (
|
||||
<button type="button" onClick={rearmHistoryStartFromUserIntent}>
|
||||
{t("paneFind.retry")}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]);
|
||||
}, [historyStartPaginationState]);
|
||||
const shouldRenderEmpty =
|
||||
!boundary.hasMountedHistory &&
|
||||
!boundary.hasVirtualizedHistory &&
|
||||
@@ -1323,7 +1334,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
>
|
||||
<div ref={handleContentRef} style={contentContainerStyle}>
|
||||
{historyStartSlot}
|
||||
<div ref={rowsHostRef} style={streamRowsHostStyle}>
|
||||
<div style={streamRowsHostStyle}>
|
||||
<div style={virtualRowsContainerStyle} />
|
||||
{[
|
||||
...virtualRows.map((virtualRow) => {
|
||||
@@ -1351,15 +1362,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
...mountedRows,
|
||||
]}
|
||||
</div>
|
||||
<div ref={auxiliaryRef}>{liveAuxiliary}</div>
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
</div>
|
||||
{!historyStartPaginationState.visible ? (
|
||||
<div data-testid="agent-initial-fill" style={initialFillStyle}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</div>
|
||||
) : null}
|
||||
{scrollEnabled && !isMobileBreakpoint ? (
|
||||
<DomOverlayScrollbar
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
|
||||
@@ -87,13 +87,11 @@ export interface StreamRenderInput {
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
isInitialHistoryLoading?: boolean;
|
||||
revealInitialHistory?: boolean;
|
||||
onNearBottomChange: (value: boolean) => void;
|
||||
// The history row under the top of the viewport, for surfaces that mark where the reader
|
||||
// is in the transcript. Only the web viewport measures it today.
|
||||
onReadingPositionChange?: (rowId: string | null) => void;
|
||||
onNearHistoryStart: () => Promise<boolean>;
|
||||
onNearHistoryStart: () => boolean | Promise<boolean>;
|
||||
isLoadingOlderHistory: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
olderHistoryProgressKey: string | null;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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,8 +281,6 @@ export interface AgentStreamViewProps {
|
||||
turnPresentation: TurnPresentation;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
isInitialHistoryLoading?: boolean;
|
||||
revealInitialHistory?: boolean;
|
||||
/** Tail space required by a transparent overlay rendered at the bottom edge. */
|
||||
bottomOverlayTailClearance?: number;
|
||||
/** Bottom offset required for controls floating above that overlay. */
|
||||
@@ -339,8 +337,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
turnPresentation,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
bottomOverlayTailClearance = 0,
|
||||
bottomOverlayControlClearance,
|
||||
toast,
|
||||
@@ -1222,8 +1218,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange: setIsNearBottom,
|
||||
onReadingPositionChange: handleReadingPositionChange,
|
||||
onNearHistoryStart: loadOlder,
|
||||
@@ -1375,12 +1369,6 @@ function agentStreamViewPropsEqual(
|
||||
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
|
||||
reasons.push("isAuthoritativeHistoryReady");
|
||||
}
|
||||
if (left.isInitialHistoryLoading !== right.isInitialHistoryLoading) {
|
||||
reasons.push("isInitialHistoryLoading");
|
||||
}
|
||||
if (left.revealInitialHistory !== right.revealInitialHistory) {
|
||||
reasons.push("revealInitialHistory");
|
||||
}
|
||||
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
|
||||
reasons.push("bottomOverlayTailClearance");
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("loadOlderAgentHistory", () => {
|
||||
|
||||
expect(client.calls).toEqual([]);
|
||||
expect(inFlight.values).toEqual([true]);
|
||||
expect(started).toBe(false);
|
||||
expect(started).toBe(true);
|
||||
});
|
||||
|
||||
it("requests the page before the current start cursor and clears in-flight on success", async () => {
|
||||
@@ -153,7 +153,6 @@ describe("loadOlderAgentHistory", () => {
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
timeout: 10_000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -170,7 +169,7 @@ describe("loadOlderAgentHistory", () => {
|
||||
const toast = createToast();
|
||||
const logger = createLogger();
|
||||
|
||||
const applied = await loadOlderAgentHistory(agentId, {
|
||||
await loadOlderAgentHistory(agentId, {
|
||||
client,
|
||||
cursor: someCursor,
|
||||
hasOlder: true,
|
||||
@@ -180,7 +179,6 @@ describe("loadOlderAgentHistory", () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(applied).toBe(false);
|
||||
expect(client.calls).toHaveLength(1);
|
||||
expect(toast.shown).toEqual([
|
||||
{
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface LoadOlderAgentHistoryClient {
|
||||
cursor: { epoch: string; seq: number };
|
||||
limit: number;
|
||||
projection: "projected";
|
||||
timeout: number;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
@@ -45,7 +44,7 @@ export async function loadOlderAgentHistory(
|
||||
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } =
|
||||
deps;
|
||||
if (isLoadingOlder) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
if (!client || !cursor || !hasOlder) {
|
||||
return false;
|
||||
@@ -53,17 +52,16 @@ export async function loadOlderAgentHistory(
|
||||
|
||||
setInFlight(true);
|
||||
try {
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
...planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }),
|
||||
timeout: 10_000,
|
||||
});
|
||||
await client.fetchAgentTimeline(
|
||||
agentId,
|
||||
planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }),
|
||||
);
|
||||
} catch (error) {
|
||||
(logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error);
|
||||
toast?.show(failedMessage ?? i18n.t("loadOlderHistory.failed"), {
|
||||
durationMs: 2200,
|
||||
testID: "agent-load-older-history-toast",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setInFlight(false);
|
||||
}
|
||||
|
||||
@@ -1021,10 +1021,6 @@ function ChatAgentContent({
|
||||
effectiveAgent={effectiveAgent}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
shouldHoldInitialFill={
|
||||
routeBottomAnchorRequest?.reason === "initial-entry" && replicaTimelineStatus !== "painted"
|
||||
}
|
||||
hasCachedHistory={replicaTimelineStatus === "painted"}
|
||||
toastApi={toastApi}
|
||||
toast={toastState}
|
||||
dismiss={dismissToast}
|
||||
@@ -1055,8 +1051,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
effectiveAgent,
|
||||
routeBottomAnchorRequest,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
shouldHoldInitialFill,
|
||||
hasCachedHistory,
|
||||
toastApi,
|
||||
toast,
|
||||
dismiss,
|
||||
@@ -1083,8 +1077,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
effectiveAgent: AgentScreenAgent;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
shouldHoldInitialFill: boolean;
|
||||
hasCachedHistory: boolean;
|
||||
toastApi: ToastApi;
|
||||
toast: ToastState | null;
|
||||
dismiss: () => void;
|
||||
@@ -1211,8 +1203,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
agent={effectiveAgent}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
isInitialHistoryLoading={shouldHoldInitialFill && !showHistorySyncError}
|
||||
revealInitialHistory={hasCachedHistory || showHistorySyncError}
|
||||
hasActiveComposer={hasActiveComposer}
|
||||
hasVisibleAgentTracks={hasVisibleAgentTracks}
|
||||
toast={toastApi}
|
||||
@@ -1349,8 +1339,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
agent,
|
||||
routeBottomAnchorRequest,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
hasActiveComposer,
|
||||
hasVisibleAgentTracks,
|
||||
toast,
|
||||
@@ -1363,8 +1351,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
agent: AgentScreenAgent;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
isInitialHistoryLoading: boolean;
|
||||
revealInitialHistory: boolean;
|
||||
hasActiveComposer: boolean;
|
||||
hasVisibleAgentTracks: boolean;
|
||||
toast: ReturnType<typeof useToastHost>["api"];
|
||||
@@ -1433,8 +1419,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
pendingPermissions={pendingPermissions}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
||||
isInitialHistoryLoading={isInitialHistoryLoading}
|
||||
revealInitialHistory={revealInitialHistory}
|
||||
bottomOverlayTailClearance={bottomOverlayTailClearance}
|
||||
bottomOverlayControlClearance={bottomOverlayControlClearance}
|
||||
toast={toast}
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
providerSubagentLifecycleStatus,
|
||||
refreshProviderSubagents,
|
||||
observeProviderSubagentTimeline,
|
||||
loadOlderProviderSubagentTimeline,
|
||||
useProviderSubagentStore,
|
||||
} from "@/subagents/provider-store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -181,24 +180,26 @@ function ProviderSubagentPanel() {
|
||||
});
|
||||
}, [client, supported, serverId, target.parentAgentId, target.subagentId]);
|
||||
|
||||
const loadOlder = useCallback(async (): Promise<boolean> => {
|
||||
const loadOlder = useCallback((): boolean => {
|
||||
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
|
||||
return false;
|
||||
}
|
||||
const firstSeq = timeline.cursor?.startSeq ?? null;
|
||||
if (firstSeq === null) return false;
|
||||
setIsLoadingOlder(true);
|
||||
try {
|
||||
return await loadOlderProviderSubagentTimeline({
|
||||
client,
|
||||
serverId,
|
||||
parentAgentId: target.parentAgentId,
|
||||
subagentId: target.subagentId,
|
||||
void client
|
||||
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
|
||||
direction: "before",
|
||||
cursor: { epoch: timeline.epoch, seq: firstSeq },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoadingOlder(false);
|
||||
}
|
||||
})
|
||||
.then((payload) => {
|
||||
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setIsLoadingOlder(false));
|
||||
return true;
|
||||
}, [
|
||||
client,
|
||||
isLoadingOlder,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createHistoryStartPagination } from "@/agent-stream/history-start-pagination";
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
loadOlderProviderSubagentTimeline,
|
||||
observeProviderSubagentTimeline,
|
||||
providerSubagentKey,
|
||||
refreshProviderSubagents,
|
||||
@@ -567,95 +565,6 @@ describe("projected child history", () => {
|
||||
.getState()
|
||||
.replaceTimeline(SERVER_ID, response(value, seqStart, seqEnd, direction));
|
||||
}
|
||||
test("older child history completes only after the delayed page is applied", async () => {
|
||||
const tail = { ...response("B", 2, 2), hasOlder: true };
|
||||
useProviderSubagentStore.getState().replaceTimeline(SERVER_ID, tail);
|
||||
let deliver!: (payload: Payload) => void;
|
||||
const client = {
|
||||
fetchProviderSubagentTimeline: () =>
|
||||
new Promise<Payload>((resolve) => {
|
||||
deliver = resolve;
|
||||
}),
|
||||
};
|
||||
let completed = false;
|
||||
const request = loadOlderProviderSubagentTimeline({
|
||||
client,
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
limit: 100,
|
||||
}).then((result) => {
|
||||
completed = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(completed).toBe(false);
|
||||
deliver({ ...response("A", 1, 1), direction: "before" });
|
||||
expect(await request).toBe(true);
|
||||
expect(current().cursor?.startSeq).toBe(1);
|
||||
});
|
||||
test("bounds an older child request so a timeout permits another attempt", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
useProviderSubagentStore
|
||||
.getState()
|
||||
.replaceTimeline(SERVER_ID, { ...response("B", 2, 2), hasOlder: true });
|
||||
const timeouts: Array<number | undefined> = [];
|
||||
const client = {
|
||||
fetchProviderSubagentTimeline: (
|
||||
_parent: string,
|
||||
_child: string,
|
||||
options: { timeout?: number },
|
||||
) => {
|
||||
timeouts.push(options.timeout);
|
||||
return new Promise<Payload>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error("timeout")), options.timeout ?? 60_000),
|
||||
);
|
||||
},
|
||||
};
|
||||
let loading = false;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: true,
|
||||
read: () => ({
|
||||
distanceFromHistoryStart: 0,
|
||||
isUnderfilled: true,
|
||||
hasOlderHistory: true,
|
||||
isLoadingOlderHistory: loading,
|
||||
isReady: true,
|
||||
progressKey: "e:2",
|
||||
}),
|
||||
load: async () => {
|
||||
loading = true;
|
||||
try {
|
||||
return await loadOlderProviderSubagentTimeline({
|
||||
client,
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
limit: 100,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
});
|
||||
pagination.update();
|
||||
expect(timeouts).toEqual([10_000]);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(pagination.state.status).toBe("failed");
|
||||
pagination.rearm();
|
||||
expect(timeouts).toEqual([10_000, 10_000]);
|
||||
expect(pagination.state.status).toBe("loading");
|
||||
pagination.dispose();
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
test("replaces overlapping fetched text and continues live streaming", () => {
|
||||
page("AB", 1, 2);
|
||||
stream(3, "C");
|
||||
|
||||
@@ -355,35 +355,6 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
||||
}));
|
||||
|
||||
/** Owns child history bootstrap and recovery while a pane observes the child. */
|
||||
export async function loadOlderProviderSubagentTimeline({
|
||||
client,
|
||||
serverId,
|
||||
parentAgentId,
|
||||
subagentId,
|
||||
limit,
|
||||
}: {
|
||||
client: Pick<DaemonClient, "fetchProviderSubagentTimeline">;
|
||||
serverId: string;
|
||||
parentAgentId: string;
|
||||
subagentId: string;
|
||||
limit: number;
|
||||
}): Promise<boolean> {
|
||||
const timeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(serverId, parentAgentId, subagentId));
|
||||
const seq = timeline?.cursor?.startSeq;
|
||||
if (!timeline?.hasOlder || !timeline.epoch || seq == null) return false;
|
||||
const payload = await client.fetchProviderSubagentTimeline(parentAgentId, subagentId, {
|
||||
direction: "before",
|
||||
cursor: { epoch: timeline.epoch, seq },
|
||||
limit,
|
||||
timeout: 10_000,
|
||||
});
|
||||
if (payload.error) return false;
|
||||
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function observeProviderSubagentTimeline({
|
||||
client,
|
||||
serverId,
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts src/server/daemon-e2e/concurrent-agent-refresh.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import type { AgentClient, AgentSession } from "../agent/agent-sdk-types.js";
|
||||
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const artifactRoot = path.resolve(".artifacts");
|
||||
mkdirSync(artifactRoot, { recursive: true });
|
||||
const resources: { daemon: TestPaseoDaemon; clients: DaemonClient[]; cwd: string }[] = [];
|
||||
const releases: (() => void)[] = [];
|
||||
afterEach(async () => {
|
||||
releases.splice(0).forEach((release) => release());
|
||||
for (const { daemon, clients, cwd } of resources.splice(0)) {
|
||||
await Promise.all(clients.map((client) => client.close()));
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function heldHistoryProvider() {
|
||||
const historyStarted = deferred();
|
||||
const releaseHistory = deferred();
|
||||
releases.push(releaseHistory.resolve);
|
||||
const base = createTestAgentClient("claude");
|
||||
let resumes = 0;
|
||||
const provider: AgentClient = {
|
||||
provider: base.provider,
|
||||
capabilities: base.capabilities,
|
||||
createSession: (...args) => base.createSession(...args),
|
||||
fetchCatalog: (...args) => base.fetchCatalog(...args),
|
||||
isAvailable: (...args) => base.isAvailable(...args),
|
||||
async resumeSession(...args) {
|
||||
resumes += 1;
|
||||
const session = await base.resumeSession(...args);
|
||||
// Delay provider history through its public interface, keeping the manager real.
|
||||
return new Proxy(session, {
|
||||
get(target, key: keyof AgentSession) {
|
||||
if (key === "streamHistory")
|
||||
return async function* () {
|
||||
historyStarted.resolve();
|
||||
await releaseHistory.promise;
|
||||
yield* target.streamHistory();
|
||||
};
|
||||
const value = Reflect.get(target, key);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
return { provider, historyStarted, releaseHistory, resumes: () => resumes };
|
||||
}
|
||||
|
||||
async function setup(provider: AgentClient, agentId?: string, appVersions = ["0.14.0", "0.14.0"]) {
|
||||
const cwd = mkdtempSync(path.join(artifactRoot, "concurrent-refresh-"));
|
||||
const daemon = await createTestPaseoDaemon({ agentClients: { claude: provider } });
|
||||
const clients = appVersions.map(
|
||||
(appVersion) => new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion }),
|
||||
);
|
||||
resources.push({ daemon, clients, cwd });
|
||||
await Promise.all(clients.map((client) => client.connect()));
|
||||
await Promise.all(clients.map((client) => client.fetchAgents({ subscribe: {} })));
|
||||
const agent = await daemon.daemon.agentManager.createAgent({ provider: "claude", cwd }, agentId, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
return { clients, agent, cwd };
|
||||
}
|
||||
|
||||
test("refreshes from separate connections share the reload until history is ready", async () => {
|
||||
const provider = heldHistoryProvider();
|
||||
const { clients, agent } = await setup(provider.provider);
|
||||
const first = clients[0].refreshAgent(agent.id);
|
||||
await provider.historyStarted.promise;
|
||||
const second = clients[1].refreshAgent(agent.id);
|
||||
// The following RPC traverses the same connection after the second refresh request.
|
||||
await clients[1].fetchAgents();
|
||||
provider.releaseHistory.resolve();
|
||||
const results = await Promise.all([first, second]);
|
||||
expect(results.map((result) => result.agentId)).toEqual([agent.id, agent.id]);
|
||||
expect(provider.resumes()).toBe(1);
|
||||
await clients[0].refreshAgent(agent.id);
|
||||
expect(provider.resumes()).toBe(2);
|
||||
});
|
||||
|
||||
test("refreshing the same id in separate daemons does not share a provider reload", async () => {
|
||||
const provider = heldHistoryProvider();
|
||||
const firstDaemon = await setup(provider.provider);
|
||||
const secondDaemon = await setup(createTestAgentClient("claude"), firstDaemon.agent.id);
|
||||
const first = firstDaemon.clients[0].refreshAgent(firstDaemon.agent.id);
|
||||
await provider.historyStarted.promise;
|
||||
const second = secondDaemon.clients[0].refreshAgent(secondDaemon.agent.id);
|
||||
await secondDaemon.clients[0].fetchAgents();
|
||||
// The other daemon must finish without releasing the first one's provider history.
|
||||
let deadline: ReturnType<typeof setTimeout> | undefined;
|
||||
let independent: boolean;
|
||||
try {
|
||||
independent = await Promise.race([
|
||||
second.then(() => true),
|
||||
new Promise<boolean>((resolve) => {
|
||||
deadline = setTimeout(() => resolve(false), 5000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
}
|
||||
provider.releaseHistory.resolve();
|
||||
await Promise.all([first, second]);
|
||||
expect(independent).toBe(true);
|
||||
});
|
||||
|
||||
test("concurrent refreshes share a failure and a later retry starts a new reload", async () => {
|
||||
const resumeStarted = deferred();
|
||||
const releaseResume = deferred();
|
||||
releases.push(releaseResume.resolve);
|
||||
let resumes = 0;
|
||||
const provider = createTestAgentClient("claude", {
|
||||
beforeResumeSession: async () => {
|
||||
resumes += 1;
|
||||
if (resumes === 1) {
|
||||
resumeStarted.resolve();
|
||||
await releaseResume.promise;
|
||||
throw new Error("provider resume failed");
|
||||
}
|
||||
},
|
||||
});
|
||||
const { clients, agent } = await setup(provider);
|
||||
const first = clients[0].refreshAgent(agent.id);
|
||||
// Attach handlers before releasing the rejected provider operation.
|
||||
const outcome = Promise.allSettled([first]);
|
||||
await resumeStarted.promise;
|
||||
const second = clients[1].refreshAgent(agent.id);
|
||||
const retryOutcome = Promise.allSettled([second]);
|
||||
await clients[1].fetchAgents();
|
||||
releaseResume.resolve();
|
||||
const results = [...(await outcome), ...(await retryOutcome)];
|
||||
expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]);
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected")
|
||||
expect(String(result.reason)).toContain("provider resume failed");
|
||||
}
|
||||
expect(resumes).toBe(1);
|
||||
await expect(clients[0].refreshAgent(agent.id)).resolves.toMatchObject({ agentId: agent.id });
|
||||
expect(resumes).toBe(2);
|
||||
});
|
||||
|
||||
test("a legacy caller restores its workspace when joining a modern refresh", async () => {
|
||||
const provider = heldHistoryProvider();
|
||||
const {
|
||||
clients: [modern, legacy],
|
||||
cwd,
|
||||
} = await setup(provider.provider, undefined, ["0.1.105", "0.1.104"]);
|
||||
// An independent repository prevents archive from owning the enclosing test checkout.
|
||||
execFileSync("git", ["init", "-b", "main", cwd], { stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Paseo Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"fixture",
|
||||
],
|
||||
{ cwd, stdio: "pipe" },
|
||||
);
|
||||
const created = await modern.createWorkspace({ source: { kind: "directory", path: cwd } });
|
||||
expect(created.error).toBeNull();
|
||||
const workspace = created.workspace!;
|
||||
const agent = await modern.createAgent({ provider: "claude", cwd, workspaceId: workspace.id });
|
||||
await modern.archiveWorkspace(workspace.id);
|
||||
expect((await modern.fetchWorkspaces()).entries.some((entry) => entry.id === workspace.id)).toBe(
|
||||
false,
|
||||
);
|
||||
const first = modern.refreshAgent(agent.id);
|
||||
await provider.historyStarted.promise;
|
||||
expect((await modern.fetchWorkspaces()).entries.some((entry) => entry.id === workspace.id)).toBe(
|
||||
false,
|
||||
);
|
||||
const joining = legacy.refreshAgent(agent.id);
|
||||
const outcomes = Promise.allSettled([first, joining]);
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await legacy.fetchWorkspaces()).entries.some((entry) => entry.id === workspace.id),
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
.toBe(true);
|
||||
} finally {
|
||||
provider.releaseHistory.resolve();
|
||||
}
|
||||
expect((await outcomes).map((result) => result.status)).toEqual(["fulfilled", "fulfilled"]);
|
||||
});
|
||||
@@ -682,6 +682,9 @@ interface ClientActivity {
|
||||
appVisibilityChangedAt: Date;
|
||||
}
|
||||
|
||||
// Connections on one daemon share refreshes; independent managers own independent agents.
|
||||
const pendingAgentRefreshes = new WeakMap<AgentManager, Map<string, Promise<ManagedAgent>>>();
|
||||
|
||||
export class Session {
|
||||
private readonly connectedHostSession: ConnectedHostSession | null;
|
||||
private routed = false;
|
||||
@@ -4759,39 +4762,26 @@ export class Session {
|
||||
|
||||
try {
|
||||
await this.restoreOwningWorkspaceForLegacyAgentRefresh(agentId);
|
||||
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
|
||||
let snapshot: ManagedAgent;
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
snapshot = await this.agentManager.reloadAgentSession(agentId, undefined, {
|
||||
rehydrateFromDisk: true,
|
||||
});
|
||||
} else {
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
const registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds();
|
||||
if (!isStoredAgentProviderAvailable(record, registeredProviderIds)) {
|
||||
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
|
||||
}
|
||||
if (!toAgentPersistenceHandle(registeredProviderIds, record.persistence)) {
|
||||
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`);
|
||||
}
|
||||
// Share the loader's per-agent in-flight operation with timeline fetches.
|
||||
// Unarchiving publishes the record before provider resume finishes, so
|
||||
// the agent pane can otherwise race this request and resume it twice.
|
||||
snapshot = await ensureAgentLoaded(agentId, {
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
broadcastTimeline: true,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
// Runtime replacement is serialized, but the reply also needs provider history.
|
||||
// Another replacement during hydration can remove the agent before that reply.
|
||||
// Share the whole reload and hydration across connections, including failures.
|
||||
const refreshes =
|
||||
pendingAgentRefreshes.get(this.agentManager) ?? new Map<string, Promise<ManagedAgent>>();
|
||||
pendingAgentRefreshes.set(this.agentManager, refreshes);
|
||||
let refresh = refreshes.get(agentId);
|
||||
if (!refresh) {
|
||||
const started = this.refreshAgentFromPersistence(agentId);
|
||||
refresh = started;
|
||||
refreshes.set(agentId, started);
|
||||
void started
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (refreshes.get(agentId) === started) refreshes.delete(agentId);
|
||||
});
|
||||
}
|
||||
await this.agentManager.hydrateTimelineFromProvider(agentId, { broadcast: true });
|
||||
await this.agentUpdates.forwardLiveAgent(snapshot);
|
||||
const snapshot = await refresh;
|
||||
const timelineSize = this.agentManager.getTimeline(agentId).length;
|
||||
await this.agentUpdates.forwardLiveAgent(snapshot);
|
||||
if (requestId) {
|
||||
this.emit({
|
||||
type: "status",
|
||||
@@ -4829,6 +4819,41 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshAgentFromPersistence(agentId: string): Promise<ManagedAgent> {
|
||||
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
|
||||
let snapshot: ManagedAgent;
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
snapshot = await this.agentManager.reloadAgentSession(agentId, undefined, {
|
||||
rehydrateFromDisk: true,
|
||||
});
|
||||
} else {
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
const registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds();
|
||||
if (!isStoredAgentProviderAvailable(record, registeredProviderIds)) {
|
||||
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
|
||||
}
|
||||
if (!toAgentPersistenceHandle(registeredProviderIds, record.persistence)) {
|
||||
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`);
|
||||
}
|
||||
// Share the loader's per-agent in-flight operation with timeline fetches.
|
||||
// Unarchiving publishes the record before provider resume finishes, so
|
||||
// the agent pane can otherwise race this request and resume it twice.
|
||||
snapshot = await ensureAgentLoaded(agentId, {
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
broadcastTimeline: true,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
}
|
||||
await this.agentManager.hydrateTimelineFromProvider(agentId, { broadcast: true });
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async handleCancelAgentRequest(agentId: string, requestId?: string): Promise<void> {
|
||||
this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`);
|
||||
|
||||
|
||||
@@ -59,3 +59,130 @@ index 0c854d6..7dc2828 100644
|
||||
}
|
||||
return props.transform as TransformsStyle['transform'];
|
||||
}
|
||||
diff --git a/node_modules/react-native-svg/android/build.gradle b/node_modules/react-native-svg/android/build.gradle
|
||||
--- a/node_modules/react-native-svg/android/build.gradle
|
||||
+++ b/node_modules/react-native-svg/android/build.gradle
|
||||
@@ -103,6 +103,7 @@
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
+ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
minSdkVersion safeExtGet('minSdkVersion', 16)
|
||||
//noinspection OldTargetApi
|
||||
targetSdkVersion safeExtGet('targetSdkVersion', 28)
|
||||
@@ -136,6 +137,8 @@
|
||||
}
|
||||
|
||||
dependencies {
|
||||
+ androidTestImplementation 'androidx.test:runner:1.6.2'
|
||||
+ androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
implementation 'com.facebook.react:react-native:+'
|
||||
implementation("com.facebook.fresco:fresco:${FRESCO_VERSION}") {
|
||||
exclude group: 'com.facebook.soloader'
|
||||
diff --git a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
|
||||
--- a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
|
||||
+++ b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
|
||||
@@ -159,6 +159,8 @@
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
super.onSizeChanged(w, h, oldw, oldh);
|
||||
+ // Percentage lengths and paths depend on the canvas size, not just SVG props.
|
||||
+ clearChildCache();
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
diff --git a/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java
|
||||
@@ -0,0 +1,90 @@
|
||||
+package com.horcrux.svg;
|
||||
+
|
||||
+import static org.junit.Assert.assertEquals;
|
||||
+import static org.junit.Assert.assertTrue;
|
||||
+
|
||||
+import android.graphics.Bitmap;
|
||||
+import android.graphics.Canvas;
|
||||
+import android.graphics.Color;
|
||||
+import android.view.View;
|
||||
+import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
+import androidx.test.platform.app.InstrumentationRegistry;
|
||||
+import com.facebook.react.bridge.BridgeReactContext;
|
||||
+import com.facebook.react.bridge.DynamicFromObject;
|
||||
+import com.facebook.react.bridge.JavaOnlyArray;
|
||||
+import com.facebook.react.bridge.JavaOnlyMap;
|
||||
+import com.facebook.react.soloader.OpenSourceMergedSoMapping;
|
||||
+import com.facebook.react.uimanager.DisplayMetricsHolder;
|
||||
+import com.facebook.soloader.SoLoader;
|
||||
+import org.junit.Test;
|
||||
+import org.junit.runner.RunWith;
|
||||
+
|
||||
+@RunWith(AndroidJUnit4.class)
|
||||
+public class SvgResizeTest {
|
||||
+ @Test
|
||||
+ public void percentageFadeFollowsCanvasGrowthAndShrinkage() {
|
||||
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
|
||||
+ android.content.Context app = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
+ DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(app);
|
||||
+ try {
|
||||
+ SoLoader.init(app, OpenSourceMergedSoMapping.INSTANCE);
|
||||
+ } catch (java.io.IOException exception) {
|
||||
+ throw new RuntimeException(exception);
|
||||
+ }
|
||||
+ BridgeReactContext context = new DrawingContext(app);
|
||||
+ SvgView svg = new SvgView(context);
|
||||
+ LinearGradientView gradient = new LinearGradientView(context);
|
||||
+ gradient.setName("fade");
|
||||
+ gradient.setX1(new DynamicFromObject("0%"));
|
||||
+ gradient.setY1(new DynamicFromObject("0%"));
|
||||
+ gradient.setX2(new DynamicFromObject("0%"));
|
||||
+ gradient.setY2(new DynamicFromObject("100%"));
|
||||
+ gradient.setGradientUnits(0);
|
||||
+ gradient.setGradient(JavaOnlyArray.of(0.0, Color.RED, 1.0, Color.TRANSPARENT));
|
||||
+ svg.addView(gradient);
|
||||
+ // Exercise recursive invalidation as well as the direct child case.
|
||||
+ GroupView group = new GroupView(context);
|
||||
+ RectView rectangle = new RectView(context);
|
||||
+ rectangle.setX(new DynamicFromObject(0.0));
|
||||
+ rectangle.setY(new DynamicFromObject(0.0));
|
||||
+ rectangle.setWidth(new DynamicFromObject("100%"));
|
||||
+ rectangle.setHeight(new DynamicFromObject("100%"));
|
||||
+ rectangle.setFill(JavaOnlyMap.of("type", 1, "brushRef", "fade"));
|
||||
+ group.addView(rectangle);
|
||||
+ svg.addView(group);
|
||||
+
|
||||
+ assertFade(svg, 80, 60);
|
||||
+ assertFade(svg, 160, 120);
|
||||
+ assertFade(svg, 60, 40);
|
||||
+ assertFade(svg, 160, 120);
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
+ // Pixel rendering needs a React context but does not send layout events to JavaScript.
|
||||
+ private static final class DrawingContext extends BridgeReactContext
|
||||
+ implements com.facebook.react.uimanager.events.EventDispatcherProvider {
|
||||
+ DrawingContext(android.content.Context context) { super(context); }
|
||||
+
|
||||
+ @Override
|
||||
+ public com.facebook.react.uimanager.events.EventDispatcher getEventDispatcher() { return null; }
|
||||
+ }
|
||||
+
|
||||
+ private static void assertFade(SvgView svg, int width, int height) {
|
||||
+ svg.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
|
||||
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));
|
||||
+ svg.layout(0, 0, width, height);
|
||||
+ Bitmap image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
||||
+ svg.draw(new Canvas(image));
|
||||
+ try {
|
||||
+ int middle = Color.alpha(image.getPixel(width / 2, height / 2));
|
||||
+ int right = Color.alpha(image.getPixel(width - 2, height / 2));
|
||||
+ assertTrue("Gradient midpoint must follow canvas height at " + width + "x" + height
|
||||
+ + "; alpha=" + middle, middle >= 120 && middle <= 135);
|
||||
+ assertEquals("Fade must reach the right edge at " + width + "x" + height, middle, right);
|
||||
+ assertTrue("Fade must end at the new bottom edge",
|
||||
+ Color.alpha(image.getPixel(width / 2, height - 2)) < 20);
|
||||
+ } finally {
|
||||
+ image.recycle();
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
|
||||
@@ -59,6 +59,8 @@ const patchedPackages = [
|
||||
nodeModulesPath: "node_modules/@gorhom/bottom-sheet",
|
||||
patchPrefix: "@gorhom+bottom-sheet+",
|
||||
},
|
||||
// Android resize: retain until upstream clears descendant geometry when the SVG canvas resizes.
|
||||
// The native pixel regression is included in the patch; preserve the separate transform hunks.
|
||||
{
|
||||
nodeModulesPath: "packages/app/node_modules/react-native-svg",
|
||||
patchPrefix: "react-native-svg+",
|
||||
|
||||
Reference in New Issue
Block a user