fix(app): paginate canonical timeline catch-up to avoid relay 1009 disconnect loop (#657)

@fireblue diagnosed the production failure mode: long canonical timeline catch-up could exceed the relay WebSocket frame cap, close with 1009, and send clients into a reconnect loop. Their seatbelt fix also removed the `limit: 0` footgun that let app callers request an unbounded frame.

This maintainer pass keeps that diagnosis and redirects the UX around bounded projected pages:

- initial agent load fetches the latest canonical tail
- app resume/reconnect re-fetches the latest tail instead of chaining after-cursor pages
- in-stream gaps still use bounded after-cursor catch-up
- older history loads explicitly when the user scrolls to the oldest edge
- shared page size is TIMELINE_FETCH_PAGE_SIZE = 100 projected timeline items, matching the daemon's canonical projection semantics rather than raw delta chunks

Original diagnosis and fix by @fireblue.

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
fireblue
2026-05-02 19:53:50 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 906205f97a
commit 654439005a
18 changed files with 728 additions and 185 deletions
@@ -133,6 +133,9 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -145,4 +148,63 @@ describe("createWebStreamStrategy", () => {
expect(rowRenderCount.mock.calls.length).toBeGreaterThan(0);
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
});
it("fires near-history-start when the user scrolls near the top", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<>
{strategy.render({
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
historyToHeadGap: 0,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: true,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
})}
</>,
);
});
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
expect(scrollContainer).toBeInstanceOf(HTMLElement);
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
act(() => {
scrollContainer?.dispatchEvent(new Event("scroll"));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
});
});