fix(app): settle padded and stalled chat history

Measure the native history edge using the same content area as viewport filling, and bound child history requests so retry can start after a stalled fetch.
This commit is contained in:
Mohamed Boudra
2026-10-31 09:44:06 +01:00
parent d57777ff3b
commit b5ef3d6bd2
5 changed files with 120 additions and 8 deletions
@@ -1,3 +1,4 @@
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
import { describe, expect, it, vi } from "vitest";
import {
createHistoryStartPagination,
@@ -202,3 +203,36 @@ it("keeps cached history visible after synchronization and subsequent pagination
expect(h.requests).toHaveLength(2);
h.pagination.dispose();
});
it.each([
[true, 123, 0],
[false, 123, 0],
[true, 0, 123],
[false, 0, 123],
])(
"handles native padding when short history has older=%s",
(hasOlderHistory, topOverlayInset, bottomPadding) => {
const geometry = nativeHistoryStartGeometry({
contentHeight: 825,
viewportHeight: 700,
offsetY: 0,
topOverlayInset,
bottomPadding,
});
let requests = 0;
const pagination = createHistoryStartPagination({
initiallyVisible: false,
read: () => ({ ...initial, ...geometry, hasOlderHistory }),
load: () => {
requests++;
return new Promise<boolean>(() => {});
},
changed: () => {},
scheduleSettle: () => {},
});
pagination.update();
expect(requests).toBe(hasOlderHistory ? 1 : 0);
expect(pagination.state.visible).toBe(!hasOlderHistory);
pagination.dispose();
},
);
@@ -0,0 +1,13 @@
export function nativeHistoryStartGeometry(input: {
contentHeight: number;
viewportHeight: number;
offsetY: number;
topOverlayInset: number;
bottomPadding: number;
}) {
const contentHeight = input.contentHeight - 32 - input.topOverlayInset - input.bottomPadding;
return {
distanceFromHistoryStart: contentHeight - input.viewportHeight - input.offsetY,
isUnderfilled: contentHeight < input.viewportHeight,
};
}
@@ -33,6 +33,7 @@ import {
type HistoryStartPaginationInput,
} 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,
@@ -132,13 +133,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
return {
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
isUnderfilled:
metrics.contentHeight -
32 -
topOverlayInset -
streamKeyboardInset.contentContainerPaddingBottom <
metrics.viewportHeight,
...nativeHistoryStartGeometry({
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
offsetY: metrics.offsetY,
topOverlayInset,
bottomPadding: streamKeyboardInset.contentContainerPaddingBottom,
}),
hasOlderHistory,
isLoadingOlderHistory,
isReady:
@@ -1,5 +1,6 @@
import { createHistoryStartPagination } from "@/agent-stream/history-start-pagination";
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
import {
loadOlderProviderSubagentTimeline,
@@ -593,6 +594,68 @@ describe("projected child history", () => {
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");
@@ -377,6 +377,7 @@ export async function loadOlderProviderSubagentTimeline({
direction: "before",
cursor: { epoch: timeline.epoch, seq },
limit,
timeout: 10_000,
});
if (payload.error) return false;
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);