Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
+10
-8
@@ -58,7 +58,8 @@ The daemon validates that the epoch is current and the exact source sequence sti
|
||||
|
||||
## Resume behavior
|
||||
|
||||
Opening, reconnecting, or revisiting after selective-delivery grace fetches the latest tail page.
|
||||
Opening, reconnecting, or revisiting after a selective-delivery coverage gap fetches the latest tail
|
||||
page.
|
||||
Focus alone does not mutate timeline state; the tail response is compared with the local
|
||||
authoritative range first.
|
||||
|
||||
@@ -106,13 +107,14 @@ replica cache.
|
||||
|
||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||
|
||||
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
||||
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
||||
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
|
||||
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
|
||||
After grace has expired, revisiting a retained timeline displays its cached state immediately and
|
||||
authoritative catch-up advances it to the current tail.
|
||||
- Selective daemons receive every agent visible in any pane plus the most recently viewed hidden
|
||||
agents, up to five subscribed agents. Visible agents always win: if more than five are visible,
|
||||
they all remain subscribed and no hidden agent does. Switching and app backgrounding preserve
|
||||
this connection-scoped hot set, so returning to an agent still covered by it needs no catch-up.
|
||||
Losing window keyboard focus does not make a selected pane invisible. Disconnecting clears hidden
|
||||
hot agents; reconnect restores the currently visible set before authoritative catch-up. Revisiting
|
||||
an evicted retained timeline displays its cached state immediately while authoritative catch-up
|
||||
advances it to the current tail.
|
||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
readScrollMetrics,
|
||||
scrollChatAwayFromBottom,
|
||||
} from "../support/helpers/agent-bottom-anchor";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
continueToNextQuestion,
|
||||
fillQuestionAnswer,
|
||||
submitQuestionAnswers,
|
||||
waitForQuestionPrompt,
|
||||
} from "../support/helpers/questions";
|
||||
import { openMobileAgentSidebar } from "../support/helpers/sidebar";
|
||||
import {
|
||||
expectTimelinePromptVisible,
|
||||
openAgentTimeline,
|
||||
seedLongMockAgentTimeline,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
|
||||
async function expectChatAtBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBeLessThanOrEqual(72);
|
||||
}
|
||||
|
||||
async function openCompactWorkspace(
|
||||
page: Page,
|
||||
workspace: Pick<MockAgentWorkspace, "workspaceId">,
|
||||
): Promise<void> {
|
||||
await openMobileAgentSidebar(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
function armDelayedScrollForHiddenChat(page: Page): Promise<void> {
|
||||
return page
|
||||
.locator('[data-testid="agent-chat-scroll"]:visible')
|
||||
.first()
|
||||
.evaluate((root) => {
|
||||
const scroll = root as HTMLElement;
|
||||
return new Promise<void>((resolve) => {
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (scroll.getClientRects().length > 0) return;
|
||||
observer.disconnect();
|
||||
scroll.dispatchEvent(new Event("scroll"));
|
||||
resolve();
|
||||
});
|
||||
observer.observe(scroll);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("pointer jitter cannot turn geometry changes into scroll intent", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
const backgroundPrompt = "Continue after a pointer is released outside the chat";
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentTimeline(page, longChat);
|
||||
await expectTimelinePromptVisible(page, longChat.newestPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const bounds = await scroll.boundingBox();
|
||||
expect(bounds, "Expected visible chat scroll bounds").not.toBeNull();
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 - 3);
|
||||
await page.setViewportSize({ width: 390, height: 1124 });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.mouse.move(410, bounds!.y + bounds!.height / 2 - 3);
|
||||
await page.mouse.up();
|
||||
|
||||
await longChat.client.sendAgentMessage(longChat.agentId, backgroundPrompt);
|
||||
await longChat.client.waitForFinish(longChat.agentId, 20_000);
|
||||
await expectTimelinePromptVisible(page, backgroundPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
} finally {
|
||||
await longChat.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a retained chat ignores delayed scroll delivery while hidden", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
const otherWorkspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "scroll-return-hidden-other-",
|
||||
title: "Other workspace",
|
||||
initialPrompt: "Open the other workspace",
|
||||
});
|
||||
const backgroundPrompt = "emit 500 coalesced agent stream updates";
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentTimeline(page, longChat);
|
||||
await expectTimelinePromptVisible(page, longChat.newestPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
|
||||
const delayedScrollDelivered = armDelayedScrollForHiddenChat(page);
|
||||
await openCompactWorkspace(page, otherWorkspace);
|
||||
await delayedScrollDelivered;
|
||||
await longChat.client.sendAgentMessage(longChat.agentId, backgroundPrompt);
|
||||
await longChat.client.waitForFinish(longChat.agentId, 20_000);
|
||||
|
||||
await openCompactWorkspace(page, longChat);
|
||||
await expectTimelinePromptVisible(page, backgroundPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
} finally {
|
||||
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a retained streaming chat returns to the bottom and continues following", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
const otherWorkspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "scroll-return-streaming-other-",
|
||||
title: "Other workspace",
|
||||
initialPrompt: "Open the other workspace",
|
||||
});
|
||||
const streamingPrompt = "Continue streaming while this workspace is hidden";
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentTimeline(page, longChat);
|
||||
await expectTimelinePromptVisible(page, longChat.newestPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
|
||||
await longChat.client.sendAgentMessage(longChat.agentId, streamingPrompt);
|
||||
await longChat.client.waitForAgentUpsert(
|
||||
longChat.agentId,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
15_000,
|
||||
);
|
||||
await openCompactWorkspace(page, otherWorkspace);
|
||||
await openCompactWorkspace(page, longChat);
|
||||
|
||||
await expectTimelinePromptVisible(page, streamingPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
await longChat.client.waitForFinish(longChat.agentId, 20_000);
|
||||
await expectChatAtBottom(page);
|
||||
} finally {
|
||||
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a retained chat preserves an intentional reading position", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
const otherWorkspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "scroll-return-reading-position-other-",
|
||||
title: "Other workspace",
|
||||
initialPrompt: "Open the other workspace",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentTimeline(page, longChat);
|
||||
await expectTimelinePromptVisible(page, longChat.newestPrompt);
|
||||
const readingPosition = await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
|
||||
await openCompactWorkspace(page, otherWorkspace);
|
||||
await openCompactWorkspace(page, longChat);
|
||||
|
||||
await expect
|
||||
.poll(async () => Math.abs((await readScrollMetrics(page)).offsetY - readingPosition.offsetY))
|
||||
.toBeLessThanOrEqual(24);
|
||||
} finally {
|
||||
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("editing keys in an embedded question keep output following", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
const questionPrompt = "Emit synthetic questions: two free-write questions.";
|
||||
const firstQuestion = "What is the GitHub private repo URL to push to?";
|
||||
const secondQuestion = "What should the first commit message be?";
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentTimeline(page, longChat);
|
||||
await expectTimelinePromptVisible(page, longChat.newestPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
|
||||
await longChat.client.sendAgentMessage(longChat.agentId, questionPrompt);
|
||||
await waitForQuestionPrompt(page, 30_000);
|
||||
await fillQuestionAnswer(page, {
|
||||
question: firstQuestion,
|
||||
answer: "git@github.com:user/private-repo.git",
|
||||
});
|
||||
const firstInput = page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("textbox", { name: firstQuestion });
|
||||
const inputBounds = await firstInput.boundingBox();
|
||||
expect(inputBounds, "Expected embedded question input bounds").not.toBeNull();
|
||||
await page.mouse.move(
|
||||
inputBounds!.x + inputBounds!.width / 2,
|
||||
inputBounds!.y + inputBounds!.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
inputBounds!.x + inputBounds!.width / 2 + 20,
|
||||
inputBounds!.y + inputBounds!.height / 2 - 3,
|
||||
);
|
||||
await page.mouse.up();
|
||||
await firstInput.press("Home");
|
||||
await firstInput.press("ArrowUp");
|
||||
await continueToNextQuestion(page);
|
||||
await fillQuestionAnswer(page, {
|
||||
question: secondQuestion,
|
||||
answer: "Initialize private repo",
|
||||
});
|
||||
await submitQuestionAnswers(page);
|
||||
await longChat.client.waitForFinish(longChat.agentId, 20_000);
|
||||
|
||||
await expectChatAtBottom(page);
|
||||
} finally {
|
||||
await longChat.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -97,7 +97,7 @@ async function expectAgentConsistentlyIdle(page: Page, title: string): Promise<v
|
||||
}
|
||||
|
||||
test.describe("Viewed agent timelines", () => {
|
||||
test("a turn that finishes while unsubscribed reopens with consistently idle chrome", async ({
|
||||
test("a turn that finishes while hidden reopens with consistently idle chrome", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
@@ -105,9 +105,9 @@ test.describe("Viewed agent timelines", () => {
|
||||
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await startVisibleTurn(page, scenario, "Finish after this chat becomes unsubscribed.");
|
||||
await startVisibleTurn(page, scenario, "Finish after this chat becomes hidden.");
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
|
||||
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
|
||||
@@ -118,33 +118,7 @@ test.describe("Viewed agent timelines", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
||||
await commitMessage(
|
||||
scenario,
|
||||
scenario.firstAgentId,
|
||||
"Committed after the first chat unsubscribed.",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expect(
|
||||
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
|
||||
test("a hidden hot chat stays current", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
@@ -25,6 +26,10 @@ vi.hoisted(() => {
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
withUnistyles: (Component: React.ComponentType) => Component,
|
||||
}));
|
||||
|
||||
function userMessage(index: number): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
@@ -533,6 +538,7 @@ describe("createWebStreamStrategy", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||
const onNearHistoryStart = vi.fn().mockReturnValue(false);
|
||||
const routeBottomAnchorRequest = {
|
||||
agentId: "agent",
|
||||
reason: "initial-entry" as const,
|
||||
@@ -555,10 +561,10 @@ describe("createWebStreamStrategy", () => {
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch-1:20",
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -595,7 +601,7 @@ describe("createWebStreamStrategy", () => {
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 520 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
@@ -611,6 +617,14 @@ describe("createWebStreamStrategy", () => {
|
||||
});
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
await act(async () => Promise.resolve());
|
||||
expect(onNearHistoryStart).toHaveBeenCalledOnce();
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
|
||||
});
|
||||
await act(async () => Promise.resolve());
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
|
||||
@@ -821,4 +835,413 @@ describe("createWebStreamStrategy", () => {
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps following output after geometry, nested-scroll, and zoom wheel events", () => {
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => root?.render(strategy.render(renderInput)));
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.getBoundingClientRect = () =>
|
||||
({
|
||||
bottom: 500,
|
||||
height: 500,
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
width: 500,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
}) as DOMRect;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
|
||||
const jitterPointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
clientX: 100,
|
||||
clientY: 400,
|
||||
});
|
||||
const jitterPointerMove = new MouseEvent("pointermove", {
|
||||
bubbles: true,
|
||||
buttons: 1,
|
||||
clientX: 100,
|
||||
clientY: 397,
|
||||
});
|
||||
for (const event of [jitterPointerDown, jitterPointerMove]) {
|
||||
Object.defineProperties(event, {
|
||||
isPrimary: { value: true },
|
||||
pointerId: { value: 1 },
|
||||
pointerType: { value: "mouse" },
|
||||
});
|
||||
}
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(jitterPointerDown);
|
||||
window.dispatchEvent(jitterPointerMove);
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 700 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 800 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
const nestedScroller = document.createElement("div");
|
||||
nestedScroller.style.overflowY = "auto";
|
||||
Object.defineProperty(nestedScroller, "clientHeight", { configurable: true, value: 100 });
|
||||
Object.defineProperty(nestedScroller, "scrollHeight", { configurable: true, value: 300 });
|
||||
Object.defineProperty(nestedScroller, "scrollTop", { configurable: true, value: 100 });
|
||||
scrollContainer.append(nestedScroller);
|
||||
act(() => {
|
||||
nestedScroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 }));
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
scrollContainer.dispatchEvent(
|
||||
new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: -100 }),
|
||||
);
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1800 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
|
||||
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("stops following output after scrollbar and middle-button upward scrolling", () => {
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef: React.createRef<StreamViewportHandle>(),
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => root?.render(strategy.render(renderInput)));
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 520 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.getBoundingClientRect = () =>
|
||||
({
|
||||
bottom: 500,
|
||||
height: 500,
|
||||
left: 0,
|
||||
right: 520,
|
||||
top: 0,
|
||||
width: 520,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
}) as DOMRect;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
|
||||
const scrollbarPointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
clientX: 515,
|
||||
clientY: 400,
|
||||
});
|
||||
Object.defineProperties(scrollbarPointerDown, {
|
||||
isPrimary: { value: true },
|
||||
pointerId: { value: 1 },
|
||||
pointerType: { value: "mouse" },
|
||||
});
|
||||
act(() => scrollContainer.dispatchEvent(scrollbarPointerDown));
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1800 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
|
||||
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1300 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
|
||||
scrollContainer.getBoundingClientRect = () =>
|
||||
({
|
||||
bottom: 500,
|
||||
height: 500,
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
width: 500,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
}) as DOMRect;
|
||||
const overlayScrollbarPointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
clientX: 495,
|
||||
clientY: 400,
|
||||
});
|
||||
Object.defineProperties(overlayScrollbarPointerDown, {
|
||||
isPrimary: { value: true },
|
||||
pointerId: { value: 2 },
|
||||
pointerType: { value: "mouse" },
|
||||
});
|
||||
act(() => scrollContainer.dispatchEvent(overlayScrollbarPointerDown));
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 900 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1900 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
...renderInput.segments,
|
||||
liveHead: [userMessage(3), userMessage(4)],
|
||||
},
|
||||
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1400 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
const autoscrollPointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 1,
|
||||
buttons: 4,
|
||||
clientX: 100,
|
||||
clientY: 400,
|
||||
});
|
||||
const autoscrollPointerUp = new MouseEvent("pointerup", {
|
||||
bubbles: true,
|
||||
button: 1,
|
||||
buttons: 0,
|
||||
clientX: 100,
|
||||
clientY: 400,
|
||||
});
|
||||
const autoscrollPointerMove = new MouseEvent("pointermove", {
|
||||
bubbles: true,
|
||||
buttons: 0,
|
||||
clientX: 100,
|
||||
clientY: 300,
|
||||
});
|
||||
for (const event of [autoscrollPointerDown, autoscrollPointerUp, autoscrollPointerMove]) {
|
||||
Object.defineProperties(event, {
|
||||
isPrimary: { value: true },
|
||||
pointerId: { value: 3 },
|
||||
pointerType: { value: "mouse" },
|
||||
});
|
||||
}
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(autoscrollPointerDown);
|
||||
window.dispatchEvent(autoscrollPointerUp);
|
||||
window.dispatchEvent(autoscrollPointerMove);
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2000 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
...renderInput.segments,
|
||||
liveHead: [userMessage(3), userMessage(4), userMessage(5)],
|
||||
},
|
||||
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the retained viewport mounted while inactive and reconciles it once on return", () => {
|
||||
const observed = new Map<Element, ReturnType<typeof vi.fn>>();
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
configurable: true,
|
||||
value: class ResizeObserver {
|
||||
readonly disconnect = vi.fn();
|
||||
|
||||
observe(target: Element) {
|
||||
observed.set(target, this.disconnect);
|
||||
}
|
||||
},
|
||||
});
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => (
|
||||
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
|
||||
);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => root?.render(renderWithActivity(true)));
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
const viewportObserverDisconnect = observed.get(scrollContainer);
|
||||
expect(viewportObserverDisconnect).toBeDefined();
|
||||
|
||||
act(() => root?.render(renderWithActivity(false)));
|
||||
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
|
||||
expect(viewportObserverDisconnect).toHaveBeenCalledOnce();
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
root?.render(
|
||||
renderWithActivity(false, {
|
||||
...renderInput,
|
||||
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
|
||||
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
renderWithActivity(true, {
|
||||
...renderInput,
|
||||
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
|
||||
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 2200, behavior: "auto" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,11 @@ import React, {
|
||||
} from "react";
|
||||
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
|
||||
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 type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { estimateStreamItemHeight } from "./web-virtualization";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
@@ -51,6 +53,7 @@ const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||
const HISTORY_START_SLOT_HEIGHT_PX = 32;
|
||||
const CONTENT_PADDING_TOP_PX = 16;
|
||||
const UPWARD_INPUT_EVIDENCE_TIMEOUT_MS = 100;
|
||||
const VIRTUALIZER_SCROLL_MARGIN_PX = HISTORY_START_SLOT_HEIGHT_PX + CONTENT_PADDING_TOP_PX;
|
||||
// A row has to clear this much of the viewport top before the next one takes over as the
|
||||
// reading position, so a row resting exactly on the edge does not flip back and forth.
|
||||
@@ -105,6 +108,77 @@ function isScrollContainerAtBottom(
|
||||
return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
function isScrollContainerMeasurable(
|
||||
scrollContainer: Pick<HTMLElement, "clientHeight" | "scrollHeight">,
|
||||
): boolean {
|
||||
return scrollContainer.clientHeight > 0 && scrollContainer.scrollHeight > 0;
|
||||
}
|
||||
|
||||
function isEditableEventTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
const editableRoot = target.closest("input, textarea, [contenteditable]");
|
||||
if (!editableRoot) {
|
||||
return false;
|
||||
}
|
||||
const tagName = editableRoot.tagName.toLowerCase();
|
||||
if (tagName === "input" || tagName === "textarea") {
|
||||
return true;
|
||||
}
|
||||
return editableRoot.getAttribute("contenteditable")?.toLowerCase() !== "false";
|
||||
}
|
||||
|
||||
function isUpwardViewportScrollKey(event: KeyboardEvent): boolean {
|
||||
if (isEditableEventTarget(event.target)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "PageUp" ||
|
||||
event.key === "Home" ||
|
||||
(event.key === " " && event.shiftKey)
|
||||
);
|
||||
}
|
||||
|
||||
function canNestedScrollerConsumeUpwardInput(
|
||||
target: EventTarget | null,
|
||||
scrollContainer: HTMLElement,
|
||||
): boolean {
|
||||
let element = target instanceof Element ? target : null;
|
||||
while (element && element !== scrollContainer) {
|
||||
if (element instanceof HTMLElement) {
|
||||
const overflowY = window.getComputedStyle(element).overflowY;
|
||||
const canScroll = overflowY === "auto" || overflowY === "scroll";
|
||||
if (canScroll && element.scrollHeight > element.clientHeight && element.scrollTop > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isVerticalScrollbarGutterPress(
|
||||
event: PointerEvent,
|
||||
scrollContainer: HTMLElement,
|
||||
): boolean {
|
||||
if (event.target !== scrollContainer) {
|
||||
return false;
|
||||
}
|
||||
if (scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
|
||||
return false;
|
||||
}
|
||||
const scrollbarWidth = Math.max(
|
||||
scrollContainer.offsetWidth - scrollContainer.clientWidth,
|
||||
WEB_SCROLLBAR_SIZE_PX,
|
||||
);
|
||||
const bounds = scrollContainer.getBoundingClientRect();
|
||||
return window.getComputedStyle(scrollContainer).direction === "rtl"
|
||||
? event.clientX <= bounds.left + scrollbarWidth
|
||||
: event.clientX >= bounds.right - scrollbarWidth;
|
||||
}
|
||||
|
||||
function scrollElementToBottom(
|
||||
scrollContainer: HTMLElement,
|
||||
behavior: ScrollBehaviorLike = "auto",
|
||||
@@ -159,6 +233,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollEnabled,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isActiveRef = useRef(isActive);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const contentRef = useRef<HTMLElement | null>(null);
|
||||
const handleScrollContainerRef = useCallback((node: HTMLElement | null) => {
|
||||
@@ -175,7 +251,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return value;
|
||||
};
|
||||
const lastKnownScrollTopRef = useRef(0);
|
||||
const pendingUserScrollUpIntentRef = useRef(false);
|
||||
const mouseScrollGestureRef = useRef<
|
||||
| { kind: "scrollbar"; pointerId: number }
|
||||
| {
|
||||
kind: "autoscroll";
|
||||
pointerId: number;
|
||||
lastClientY: number;
|
||||
hasUpwardEvidence: boolean;
|
||||
evidenceExpiryFrame: number | null;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
const upwardInputEvidenceUntilRef = useRef(0);
|
||||
const lastTouchClientYRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
@@ -196,6 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
renderLiveAuxiliary,
|
||||
} = renderers;
|
||||
|
||||
isActiveRef.current = isActive;
|
||||
followOutputRef.current = followOutput;
|
||||
|
||||
const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null;
|
||||
@@ -234,7 +322,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
||||
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
|
||||
return null;
|
||||
}
|
||||
const bottomAnchorSettled =
|
||||
@@ -371,6 +459,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
const anchor = historyStartPrependAnchorRef.current;
|
||||
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
|
||||
return;
|
||||
@@ -402,7 +493,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
pendingFrames.delete(node);
|
||||
if (node.isConnected) {
|
||||
if (isActiveRef.current && node.isConnected) {
|
||||
rowVirtualizer.measureElement(node);
|
||||
}
|
||||
});
|
||||
@@ -434,10 +525,47 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearMouseScrollGesture = useCallback(() => {
|
||||
const gesture = mouseScrollGestureRef.current;
|
||||
const evidenceExpiryFrame = gesture?.kind === "autoscroll" ? gesture.evidenceExpiryFrame : null;
|
||||
if (evidenceExpiryFrame !== null && evidenceExpiryFrame !== undefined) {
|
||||
window.cancelAnimationFrame(evidenceExpiryFrame);
|
||||
}
|
||||
mouseScrollGestureRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearUpwardInputEvidence = useCallback(() => {
|
||||
upwardInputEvidenceUntilRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const markUpwardInputEvidence = useCallback(() => {
|
||||
upwardInputEvidenceUntilRef.current =
|
||||
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isActive) {
|
||||
return;
|
||||
}
|
||||
cancelPendingStickToBottom();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
for (const frame of pendingVirtualRowMeasureFramesRef.current.values()) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
pendingVirtualRowMeasureFramesRef.current.clear();
|
||||
clearMouseScrollGesture();
|
||||
clearUpwardInputEvidence();
|
||||
lastTouchClientYRef.current = null;
|
||||
}, [cancelPendingStickToBottom, clearMouseScrollGesture, clearUpwardInputEvidence, isActive]);
|
||||
|
||||
const scrollMessagesToBottom = useCallback(
|
||||
(behavior: ScrollBehaviorLike = "auto") => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
if (
|
||||
!isActiveRef.current ||
|
||||
!scrollContainer ||
|
||||
!isScrollContainerMeasurable(scrollContainer)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||
@@ -452,6 +580,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
|
||||
const scheduleStickToBottom = useCallback(() => {
|
||||
if (!isActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||
return;
|
||||
@@ -461,7 +592,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
pendingAutoScrollFrameRef.current = null;
|
||||
if (!followOutputRef.current) {
|
||||
if (!isActiveRef.current || !followOutputRef.current) {
|
||||
return;
|
||||
}
|
||||
scrollMessagesToBottom("auto");
|
||||
@@ -477,6 +608,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
// Rows are laid out in DOM order, virtualized block first, so the first row whose bottom
|
||||
// clears the reading line is the one the reader is looking at.
|
||||
const reportReadingPosition = useStableEvent(() => {
|
||||
if (!isActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!onReadingPositionChange) {
|
||||
return;
|
||||
}
|
||||
@@ -502,6 +636,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
});
|
||||
|
||||
const updateScrollMetrics = useCallback(() => {
|
||||
if (!isActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
onNearBottomChange(true);
|
||||
@@ -512,6 +649,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [onNearBottomChange, reportReadingPosition]);
|
||||
|
||||
const { isJumpSettling, scrollToMessage } = useScrollToMessage({
|
||||
active: isActive,
|
||||
scrollContainerRef,
|
||||
rowVirtualizer,
|
||||
historyVirtualized: segments.historyVirtualized,
|
||||
@@ -520,9 +658,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
onNearBottomChange,
|
||||
});
|
||||
|
||||
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
||||
cancelPendingStickToBottom();
|
||||
if (followOutputRef.current) {
|
||||
setFollowOutput(false);
|
||||
}
|
||||
rearmHistoryStartFromUserIntent();
|
||||
});
|
||||
|
||||
const handleDomScroll = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -531,33 +677,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
||||
const scrolledDown =
|
||||
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
|
||||
const mouseGesture = mouseScrollGestureRef.current;
|
||||
const hasUpwardInputEvidence =
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current;
|
||||
|
||||
if (scrolledUp) {
|
||||
cancelPendingStickToBottom();
|
||||
if (followOutputRef.current) {
|
||||
setFollowOutput(false);
|
||||
}
|
||||
pendingUserScrollUpIntentRef.current = false;
|
||||
rearmHistoryStartFromUserIntent();
|
||||
} else if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
||||
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
||||
setFollowOutput(true);
|
||||
pendingUserScrollUpIntentRef.current = false;
|
||||
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
|
||||
if (!isAtBottom) {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
}
|
||||
pendingUserScrollUpIntentRef.current = false;
|
||||
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
|
||||
stopFollowingOutputFromUserIntent();
|
||||
}
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
evaluateHistoryStart,
|
||||
isJumpSettling,
|
||||
rearmHistoryStartFromUserIntent,
|
||||
stopFollowingOutputFromUserIntent,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
|
||||
@@ -580,7 +718,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [evaluateHistoryStart, props.agentId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActivationReady) {
|
||||
if (!isActiveRef.current || !isActivationReady) {
|
||||
return;
|
||||
}
|
||||
if (hasRouteBottomAnchorRequest && !followOutputRef.current) {
|
||||
@@ -615,13 +753,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
// Following output is a layout invariant: rows, footer, and bottom offset must
|
||||
// reach the browser in the same paint.
|
||||
useLayoutEffect(() => {
|
||||
if (!followOutputRef.current) {
|
||||
if (!isActive || !followOutputRef.current) {
|
||||
return;
|
||||
}
|
||||
cancelPendingStickToBottom();
|
||||
scrollMessagesToBottom("auto");
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
isActive,
|
||||
renderLiveAuxiliary,
|
||||
scrollMessagesToBottom,
|
||||
segments.historyMounted,
|
||||
@@ -631,6 +770,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
@@ -639,6 +781,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isActive,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
@@ -649,7 +792,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
virtualTotalSize,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const contentNode = contentRef.current;
|
||||
if (!scrollContainer || typeof ResizeObserver === "undefined") {
|
||||
@@ -682,24 +828,98 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [
|
||||
applyHistoryStartPrependAnchor,
|
||||
evaluateHistoryStart,
|
||||
isActive,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
scheduleStickToBottom,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY < 0) {
|
||||
pendingUserScrollUpIntentRef.current = true;
|
||||
cancelPendingStickToBottom();
|
||||
const markUpwardViewportInput = () => {
|
||||
markUpwardInputEvidence();
|
||||
if (scrollContainer.scrollTop <= USER_SCROLL_DELTA_EPSILON) {
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
};
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (
|
||||
!event.ctrlKey &&
|
||||
event.deltaY < 0 &&
|
||||
!canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)
|
||||
) {
|
||||
markUpwardViewportInput();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isUpwardViewportScrollKey(event)) {
|
||||
return;
|
||||
}
|
||||
if (canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)) {
|
||||
return;
|
||||
}
|
||||
markUpwardViewportInput();
|
||||
};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
clearMouseScrollGesture();
|
||||
if (
|
||||
event.pointerType !== "mouse" ||
|
||||
!event.isPrimary ||
|
||||
(event.button !== 0 && event.button !== 1) ||
|
||||
isEditableEventTarget(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (event.button === 0) {
|
||||
if (isVerticalScrollbarGutterPress(event, scrollContainer)) {
|
||||
mouseScrollGestureRef.current = { kind: "scrollbar", pointerId: event.pointerId };
|
||||
}
|
||||
return;
|
||||
}
|
||||
mouseScrollGestureRef.current = {
|
||||
kind: "autoscroll",
|
||||
pointerId: event.pointerId,
|
||||
lastClientY: event.clientY,
|
||||
hasUpwardEvidence: false,
|
||||
evidenceExpiryFrame: null,
|
||||
};
|
||||
};
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const gesture = mouseScrollGestureRef.current;
|
||||
if (!gesture || gesture.kind !== "autoscroll" || gesture.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
if (event.clientY < gesture.lastClientY - USER_SCROLL_DELTA_EPSILON) {
|
||||
gesture.hasUpwardEvidence = true;
|
||||
if (gesture.evidenceExpiryFrame !== null) {
|
||||
window.cancelAnimationFrame(gesture.evidenceExpiryFrame);
|
||||
}
|
||||
gesture.evidenceExpiryFrame = window.requestAnimationFrame(() => {
|
||||
if (mouseScrollGestureRef.current === gesture) {
|
||||
mouseScrollGestureRef.current = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
gesture.lastClientY = event.clientY;
|
||||
};
|
||||
const handlePointerEnd = (event: PointerEvent) => {
|
||||
const gesture = mouseScrollGestureRef.current;
|
||||
if (gesture?.pointerId === event.pointerId && gesture.kind === "scrollbar") {
|
||||
clearMouseScrollGesture();
|
||||
}
|
||||
};
|
||||
const handlePointerCancel = (event: PointerEvent) => {
|
||||
if (mouseScrollGestureRef.current?.pointerId === event.pointerId) {
|
||||
clearMouseScrollGesture();
|
||||
}
|
||||
};
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
const touch = event.touches[0];
|
||||
if (!touch) {
|
||||
@@ -713,10 +933,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return;
|
||||
}
|
||||
const previousTouchY = lastTouchClientYRef.current;
|
||||
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
|
||||
pendingUserScrollUpIntentRef.current = true;
|
||||
cancelPendingStickToBottom();
|
||||
rearmHistoryStartFromUserIntent();
|
||||
if (
|
||||
previousTouchY !== null &&
|
||||
touch.clientY > previousTouchY + 1 &&
|
||||
!canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)
|
||||
) {
|
||||
markUpwardViewportInput();
|
||||
}
|
||||
lastTouchClientYRef.current = touch.clientY;
|
||||
};
|
||||
@@ -726,6 +948,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
|
||||
scrollContainer.addEventListener("wheel", handleWheel, { passive: true });
|
||||
scrollContainer.addEventListener("keydown", handleKeyDown, { passive: true });
|
||||
scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true });
|
||||
window.addEventListener("pointermove", handlePointerMove, { passive: true });
|
||||
window.addEventListener("pointerup", handlePointerEnd, { passive: true });
|
||||
window.addEventListener("pointercancel", handlePointerCancel, { passive: true });
|
||||
window.addEventListener("blur", clearMouseScrollGesture);
|
||||
scrollContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||
scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true });
|
||||
scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true });
|
||||
@@ -734,12 +962,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
scrollContainer.removeEventListener("scroll", handleDomScroll);
|
||||
scrollContainer.removeEventListener("wheel", handleWheel);
|
||||
scrollContainer.removeEventListener("keydown", handleKeyDown);
|
||||
scrollContainer.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerEnd);
|
||||
window.removeEventListener("pointercancel", handlePointerCancel);
|
||||
window.removeEventListener("blur", clearMouseScrollGesture);
|
||||
scrollContainer.removeEventListener("touchstart", handleTouchStart);
|
||||
scrollContainer.removeEventListener("touchmove", handleTouchMove);
|
||||
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
||||
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
||||
clearUpwardInputEvidence();
|
||||
};
|
||||
}, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]);
|
||||
}, [
|
||||
clearMouseScrollGesture,
|
||||
clearUpwardInputEvidence,
|
||||
handleDomScroll,
|
||||
isActive,
|
||||
markUpwardInputEvidence,
|
||||
rearmHistoryStartFromUserIntent,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
|
||||
|
||||
interface UseScrollToMessageInput {
|
||||
active: boolean;
|
||||
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
||||
rowVirtualizer: Virtualizer<HTMLElement, Element>;
|
||||
historyVirtualized: readonly { id: string }[];
|
||||
@@ -23,6 +24,7 @@ const SCROLL_AFFECTING_KEYS = new Set([
|
||||
]);
|
||||
|
||||
export function useScrollToMessage({
|
||||
active,
|
||||
scrollContainerRef,
|
||||
rowVirtualizer,
|
||||
historyVirtualized,
|
||||
@@ -79,9 +81,15 @@ export function useScrollToMessage({
|
||||
);
|
||||
|
||||
useEffect(() => () => settleController.cancel(), [settleController]);
|
||||
useLayoutEffect(() => {
|
||||
if (!active) {
|
||||
settleController.cancel();
|
||||
}
|
||||
}, [active, settleController]);
|
||||
|
||||
const scrollToMessage = useCallback(
|
||||
(itemId: string) => {
|
||||
if (!active) return;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
cancelPendingStickToBottom();
|
||||
@@ -109,6 +117,7 @@ export function useScrollToMessage({
|
||||
}
|
||||
},
|
||||
[
|
||||
active,
|
||||
cancelPendingStickToBottom,
|
||||
historyVirtualized,
|
||||
onNearBottomChange,
|
||||
|
||||
@@ -269,6 +269,14 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||
];
|
||||
|
||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||
|
||||
function useRetainedValue<T>(value: T, active: boolean): T {
|
||||
const retainedRef = useRef(value);
|
||||
if (active) {
|
||||
retainedRef.current = value;
|
||||
}
|
||||
return active ? value : retainedRef.current;
|
||||
}
|
||||
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
|
||||
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
|
||||
|
||||
@@ -465,20 +473,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
});
|
||||
});
|
||||
|
||||
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
|
||||
// cell-window renders on every 48ms flush from background agents.
|
||||
// Freeze stream presentation while this tab slot is hidden to prevent offscreen
|
||||
// cell-window and turn-lifecycle renders from background agents.
|
||||
// When isActive flips back to true, the context change triggers a re-render and
|
||||
// the component reads the current (fresh) streamItems/streamHead from props.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const frozenStreamItemsRef = useRef(streamItems);
|
||||
const frozenStreamHeadRef = useRef(streamHead);
|
||||
if (isActive) {
|
||||
frozenStreamItemsRef.current = streamItems;
|
||||
frozenStreamHeadRef.current = streamHead;
|
||||
}
|
||||
const effectiveStreamItems = isActive ? streamItems : frozenStreamItemsRef.current;
|
||||
const effectiveStreamHead = isActive ? streamHead : frozenStreamHeadRef.current;
|
||||
const isTurnActive = turnPresentation.isActive;
|
||||
const effectiveStreamItems = useRetainedValue(streamItems, isActive);
|
||||
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
|
||||
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
|
||||
const isTurnActive = effectiveTurnPresentation.isActive;
|
||||
// Keep retained history outside the 48ms live-head flush path.
|
||||
const preparedToolCallHistory = useMemo(
|
||||
() => prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems),
|
||||
@@ -505,7 +508,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
isTurnActive,
|
||||
activeTurnStartedAt: turnPresentation.startedAt,
|
||||
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
|
||||
tail: projectedToolCalls.tail,
|
||||
head: projectedToolCalls.head,
|
||||
platform: isWeb ? "web" : "native",
|
||||
@@ -516,7 +519,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
isTurnActive,
|
||||
projectedToolCalls.head,
|
||||
projectedToolCalls.tail,
|
||||
turnPresentation.startedAt,
|
||||
effectiveTurnPresentation.startedAt,
|
||||
]);
|
||||
const streamLayout = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, memo, type ReactNode, useContext } from "react";
|
||||
import React, { createContext, memo, type ReactNode, useContext } from "react";
|
||||
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
const RetainedPanelActiveContext = createContext(true);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
|
||||
import {
|
||||
createViewedTimelineSync,
|
||||
VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS,
|
||||
} from "./viewed-timeline-sync";
|
||||
import { createViewedTimelineSync } from "./viewed-timeline-sync";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
@@ -129,18 +126,14 @@ class TimelineWorld {
|
||||
return new Promise((resolve) => this.retryWaiters.push(resolve));
|
||||
}
|
||||
|
||||
runUnsubscribeGrace(): void {
|
||||
const index = this.scheduled.findIndex(
|
||||
(entry) => entry.delayMs === VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS,
|
||||
elapse(elapsedMs: number): void {
|
||||
const due = this.scheduled.filter((entry) => entry.delayMs <= elapsedMs);
|
||||
this.scheduled.splice(
|
||||
0,
|
||||
this.scheduled.length,
|
||||
...this.scheduled.filter((entry) => entry.delayMs > elapsedMs),
|
||||
);
|
||||
expect(index).toBeGreaterThanOrEqual(0);
|
||||
this.scheduled.splice(index, 1)[0].task();
|
||||
}
|
||||
|
||||
expectNoPendingUnsubscribe(): void {
|
||||
expect(
|
||||
this.scheduled.filter((entry) => entry.delayMs === VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS),
|
||||
).toEqual([]);
|
||||
for (const entry of due) entry.task();
|
||||
}
|
||||
|
||||
private releaseMembershipWaiter(): void {
|
||||
@@ -161,10 +154,6 @@ class TimelineWorld {
|
||||
}
|
||||
}
|
||||
|
||||
test("keeps hidden agents subscribed for thirty seconds", () => {
|
||||
expect(VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS).toBe(30_000);
|
||||
});
|
||||
|
||||
test("uses a tail fetch when an agent becomes visible", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
@@ -279,51 +268,35 @@ test("all acknowledged agents begin catch-up independently", async () => {
|
||||
expect(membership.agentIds).toEqual(["agent-a", "agent-b"]);
|
||||
});
|
||||
|
||||
test("membership changes during acknowledgement never catch up the stale set", async () => {
|
||||
test("an eviction during acknowledgement never catches up the stale hot set", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", [
|
||||
"agent-a",
|
||||
"agent-b",
|
||||
"agent-c",
|
||||
"agent-d",
|
||||
"agent-e",
|
||||
]);
|
||||
const staleMembership = await world.nextMembership();
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.runUnsubscribeGrace();
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]);
|
||||
staleMembership.succeed();
|
||||
const currentMembership = await world.nextMembership();
|
||||
currentMembership.succeed();
|
||||
const agentB = await world.nextFetch("agent-b");
|
||||
agentB.respond({ hasNewer: false });
|
||||
const currentCatchUps = await Promise.all(
|
||||
["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"].map((agentId) =>
|
||||
world.nextFetch(agentId),
|
||||
),
|
||||
);
|
||||
for (const catchUp of currentCatchUps) catchUp.respond({ hasNewer: false });
|
||||
|
||||
expect({ stale: staleMembership.agentIds, current: currentMembership.agentIds }).toEqual({
|
||||
stale: ["agent-a"],
|
||||
current: ["agent-b"],
|
||||
stale: ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"],
|
||||
current: ["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"],
|
||||
});
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("removing one agent during paging cancels only that agent", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]);
|
||||
const initialMembership = await world.nextMembership();
|
||||
initialMembership.succeed();
|
||||
const [agentA, agentB] = await Promise.all([
|
||||
world.nextFetch("agent-a"),
|
||||
world.nextFetch("agent-b"),
|
||||
]);
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.runUnsubscribeGrace();
|
||||
const replacement = await world.nextMembership();
|
||||
agentA.respond({ hasNewer: true, seq: 4 });
|
||||
agentB.respond({ hasNewer: true, seq: 7 });
|
||||
const agentBNext = await world.nextFetch("agent-b");
|
||||
replacement.succeed();
|
||||
agentBNext.respond({ hasNewer: false });
|
||||
|
||||
expect(replacement.agentIds).toEqual(["agent-b"]);
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
@@ -347,7 +320,22 @@ test("disconnect cancels paging and reconnect restores membership before fresh c
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("overlapping sources deduplicate membership and source removal preserves remaining views", async () => {
|
||||
test("navigation while disconnected reconnects only the currently visible agent", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
|
||||
world.sync.setConnected(true);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-b");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
expect(membership.agentIds).toEqual(["agent-b"]);
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("overlapping sources deduplicate membership and retain hidden hot agents", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]);
|
||||
@@ -363,16 +351,11 @@ test("overlapping sources deduplicate membership and source removal preserves re
|
||||
|
||||
world.sync.replaceVisibleAgentIds("left-route", []);
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]);
|
||||
world.runUnsubscribeGrace();
|
||||
const remaining = await world.nextMembership();
|
||||
remaining.succeed();
|
||||
|
||||
expect({ combined: combined.agentIds, remaining: remaining.agentIds }).toEqual({
|
||||
combined: ["agent-a", "agent-b"],
|
||||
remaining: ["agent-b"],
|
||||
});
|
||||
expect(combined.agentIds).toEqual(["agent-a", "agent-b"]);
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("a failed catch-up reports once and retries through the explicit retry policy", async () => {
|
||||
@@ -474,46 +457,27 @@ test("membership failure autonomously retries without another visibility declara
|
||||
});
|
||||
});
|
||||
|
||||
test("background waits for grace before unsubscribing and catches up on return", async () => {
|
||||
test("backgrounding preserves the hot membership without catch-up on return", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const initial = await world.nextMembership();
|
||||
initial.succeed();
|
||||
const initialCatchUp = await world.nextFetch("agent-a");
|
||||
initialCatchUp.respond({ hasNewer: false });
|
||||
const agentAMembership = await world.nextMembership();
|
||||
agentAMembership.succeed();
|
||||
const agentACatchUp = await world.nextFetch("agent-a");
|
||||
agentACatchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.setActive(false);
|
||||
world.expectNoPendingMembership();
|
||||
world.runUnsubscribeGrace();
|
||||
const background = await world.nextMembership();
|
||||
background.succeed();
|
||||
world.sync.setActive(true);
|
||||
const foreground = await world.nextMembership();
|
||||
foreground.succeed();
|
||||
const resumedCatchUp = await world.nextFetch("agent-a");
|
||||
resumedCatchUp.respond({ hasNewer: false });
|
||||
|
||||
expect({ background: background.agentIds, foreground: foreground.agentIds }).toEqual({
|
||||
background: [],
|
||||
foreground: ["agent-a"],
|
||||
});
|
||||
});
|
||||
|
||||
test("foregrounding within grace preserves the live membership", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const agentBMembership = await world.nextMembership();
|
||||
agentBMembership.succeed();
|
||||
const agentBCatchUp = await world.nextFetch("agent-b");
|
||||
agentBCatchUp.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
|
||||
|
||||
world.sync.setActive(false);
|
||||
world.expectNoPendingMembership();
|
||||
world.elapse(60_000);
|
||||
world.sync.setActive(true);
|
||||
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
@@ -527,14 +491,17 @@ test("stale membership retry cannot overwrite a newer effective set", async () =
|
||||
const staleRetry = await world.nextRetry();
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.runUnsubscribeGrace();
|
||||
const current = await world.nextMembership();
|
||||
staleRetry();
|
||||
current.succeed();
|
||||
const catchUp = await world.nextFetch("agent-b");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
const [agentA, agentB] = await Promise.all([
|
||||
world.nextFetch("agent-a"),
|
||||
world.nextFetch("agent-b"),
|
||||
]);
|
||||
agentA.respond({ hasNewer: false });
|
||||
agentB.respond({ hasNewer: false });
|
||||
|
||||
expect(current.agentIds).toEqual(["agent-b"]);
|
||||
expect(current.agentIds).toEqual(["agent-a", "agent-b"]);
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
@@ -558,138 +525,114 @@ test("membership retry cannot run while disconnected", async () => {
|
||||
expect(restored.agentIds).toEqual(["agent-a"]);
|
||||
});
|
||||
|
||||
test("quickly returning to an agent cancels its pending unsubscribe without another catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const initialCatchUp = await world.nextFetch("agent-a");
|
||||
initialCatchUp.respond({ hasNewer: false });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
|
||||
});
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||
world.expectNoPendingMembership();
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
|
||||
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("unsubscribe grace expiry removes the agent exactly once", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
const readinessChanges: string[] = [];
|
||||
world.sync.subscribe(() => {
|
||||
readinessChanges.push(world.sync.getAgentTimelineStatus("agent-a"));
|
||||
});
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||
world.runUnsubscribeGrace();
|
||||
const unsubscribe = await world.nextMembership();
|
||||
unsubscribe.succeed();
|
||||
|
||||
expect(unsubscribe.agentIds).toEqual([]);
|
||||
expect(readinessChanges.at(-1)).toBe("pending");
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("a new visible agent subscribes immediately while the previous agent lingers", async () => {
|
||||
test("returning to a hot hidden agent stays live after inactivity", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const initialMembership = await world.nextMembership();
|
||||
initialMembership.succeed();
|
||||
const initialCatchUp = await world.nextFetch("agent-a");
|
||||
initialCatchUp.respond({ hasNewer: false });
|
||||
const agentACatchUp = await world.nextFetch("agent-a");
|
||||
agentACatchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const expandedMembership = await world.nextMembership();
|
||||
expandedMembership.succeed();
|
||||
const agentBCatchUp = await world.nextFetch("agent-b");
|
||||
agentBCatchUp.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
|
||||
|
||||
expect(expandedMembership.agentIds).toEqual(["agent-a", "agent-b"]);
|
||||
world.runUnsubscribeGrace();
|
||||
const settledMembership = await world.nextMembership();
|
||||
settledMembership.succeed();
|
||||
expect(settledMembership.agentIds).toEqual(["agent-b"]);
|
||||
world.elapse(60_000);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("backgrounding preserves an existing unsubscribe grace period", async () => {
|
||||
test("the hot set evicts the least-recent hidden agent and promotes revisited agents", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
for (const agentId of ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]) {
|
||||
world.sync.replaceVisibleAgentIds("workspace", [agentId]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch(agentId);
|
||||
catchUp.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus(agentId)).toBe("ready"));
|
||||
}
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]);
|
||||
const eviction = await world.nextMembership();
|
||||
eviction.succeed();
|
||||
const agentFCatchUp = await world.nextFetch("agent-f");
|
||||
agentFCatchUp.respond({ hasNewer: false });
|
||||
expect(eviction.agentIds).toEqual(["agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]);
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const restoreEvicted = await world.nextMembership();
|
||||
restoreEvicted.succeed();
|
||||
const agentACatchUp = await world.nextFetch("agent-a");
|
||||
agentACatchUp.respond({ hasNewer: false });
|
||||
expect(restoreEvicted.agentIds).toEqual(["agent-a", "agent-b", "agent-d", "agent-e", "agent-f"]);
|
||||
});
|
||||
|
||||
test("visible agents are never evicted when they exceed the hot-set limit", async () => {
|
||||
const world = new TimelineWorld();
|
||||
const visible = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f"];
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", visible);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUps = await Promise.all(visible.map((agentId) => world.nextFetch(agentId)));
|
||||
for (const catchUp of catchUps) catchUp.respond({ hasNewer: false });
|
||||
|
||||
expect(membership.agentIds).toEqual(visible);
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", visible.slice(0, 5));
|
||||
const hiddenEviction = await world.nextMembership();
|
||||
hiddenEviction.succeed();
|
||||
expect(hiddenEviction.agentIds).toEqual(visible.slice(0, 5));
|
||||
});
|
||||
|
||||
test("disconnect clears hidden hot agents before reconnecting the visible set", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
const agentAMembership = await world.nextMembership();
|
||||
agentAMembership.succeed();
|
||||
const agentACatchUp = await world.nextFetch("agent-a");
|
||||
agentACatchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||
world.sync.setActive(false);
|
||||
world.expectNoPendingMembership();
|
||||
world.runUnsubscribeGrace();
|
||||
const unsubscribe = await world.nextMembership();
|
||||
unsubscribe.succeed();
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const agentBMembership = await world.nextMembership();
|
||||
agentBMembership.succeed();
|
||||
const agentBCatchUp = await world.nextFetch("agent-b");
|
||||
agentBCatchUp.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
|
||||
|
||||
expect(unsubscribe.agentIds).toEqual([]);
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("disconnecting cancels pending unsubscribe grace without publishing on the closed socket", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const [agentA, agentB] = await Promise.all([
|
||||
world.nextFetch("agent-a"),
|
||||
world.nextFetch("agent-b"),
|
||||
]);
|
||||
agentA.respond({ hasNewer: false });
|
||||
agentB.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const agentAStatuses: string[] = [];
|
||||
world.sync.subscribe(() => {
|
||||
agentAStatuses.push(world.sync.getAgentTimelineStatus("agent-a"));
|
||||
});
|
||||
world.sync.setConnected(false);
|
||||
|
||||
expect(agentAStatuses).toEqual(["pending"]);
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("disposing cancels pending unsubscribe grace", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
const restored = await world.nextMembership();
|
||||
restored.succeed();
|
||||
const restoredCatchUp = await world.nextFetch("agent-b");
|
||||
restoredCatchUp.respond({ hasNewer: false });
|
||||
|
||||
expect(restored.agentIds).toEqual(["agent-b"]);
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||
world.sync.dispose();
|
||||
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
const reopened = await world.nextMembership();
|
||||
reopened.succeed();
|
||||
const reopenedCatchUp = await world.nextFetch("agent-a");
|
||||
reopenedCatchUp.respond({ hasNewer: false });
|
||||
expect(reopened.agentIds).toEqual(["agent-a", "agent-b"]);
|
||||
});
|
||||
|
||||
test("legacy delivery skips subscription RPCs while retaining visibility catch-up and gap recovery", async () => {
|
||||
@@ -715,6 +658,33 @@ test("legacy delivery skips subscription RPCs while retaining visibility catch-u
|
||||
});
|
||||
});
|
||||
|
||||
test("legacy delivery catches up after returning to a view or foreground", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setDeliveryMode("legacy");
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const firstAgentA = await world.nextFetch("agent-a");
|
||||
firstAgentA.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const agentB = await world.nextFetch("agent-b");
|
||||
agentB.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const secondAgentA = await world.nextFetch("agent-a");
|
||||
secondAgentA.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
world.sync.setActive(false);
|
||||
world.sync.setActive(true);
|
||||
const foregroundAgentA = await world.nextFetch("agent-a");
|
||||
foregroundAgentA.respond({ hasNewer: false });
|
||||
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("switching from legacy to selective delivery publishes membership and catches up once", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setDeliveryMode("legacy");
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
}
|
||||
|
||||
const RETRY_DELAY_MS = 1_000;
|
||||
export const VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS = 30_000;
|
||||
const VIEWED_TIMELINE_HOT_AGENT_LIMIT = 5;
|
||||
|
||||
type CatchUpStatus = "running" | "complete" | "error";
|
||||
|
||||
@@ -99,7 +99,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
// Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked.
|
||||
// Acknowledgement and tail completion are the only drain points.
|
||||
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
||||
const lingeringRemovals = new Map<string, () => void>();
|
||||
const visibilityCatchUpPending = new Set<string>();
|
||||
const visibilityCatchUpErrors = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -114,10 +113,27 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
let reconcileRequested = false;
|
||||
let membershipNeedsRetry = false;
|
||||
let cancelMembershipRetry: (() => void) | null = null;
|
||||
let recentlyViewedAgentIds: string[] = [];
|
||||
|
||||
const visibleAgentIds = () => (active ? normalizeAgentIds([...sources.values()].flat()) : []);
|
||||
const effectiveAgentIds = () =>
|
||||
normalizeAgentIds([...visibleAgentIds(), ...lingeringRemovals.keys()]);
|
||||
const visibleAgentIds = () => normalizeAgentIds([...sources.values()].flat());
|
||||
|
||||
const selectHotAgentIds = (visible: string[]) => {
|
||||
const visibleSet = new Set(visible);
|
||||
recentlyViewedAgentIds = [
|
||||
...visible,
|
||||
...recentlyViewedAgentIds.filter((agentId) => !visibleSet.has(agentId)),
|
||||
];
|
||||
const hiddenBudget = Math.max(0, VIEWED_TIMELINE_HOT_AGENT_LIMIT - visible.length);
|
||||
const desiredAgentIds = normalizeAgentIds([
|
||||
...visible,
|
||||
...recentlyViewedAgentIds
|
||||
.filter((agentId) => !visibleSet.has(agentId))
|
||||
.slice(0, hiddenBudget),
|
||||
]);
|
||||
const desiredSet = new Set(desiredAgentIds);
|
||||
recentlyViewedAgentIds = recentlyViewedAgentIds.filter((agentId) => desiredSet.has(agentId));
|
||||
return desiredAgentIds;
|
||||
};
|
||||
|
||||
const isAcknowledged = (agentId: string) => acknowledged.includes(agentId);
|
||||
const isDesired = (agentId: string) => desired.includes(agentId);
|
||||
@@ -361,32 +377,16 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
void reconcileMembership();
|
||||
};
|
||||
|
||||
const clearLingeringRemovals = () => {
|
||||
for (const cancel of lingeringRemovals.values()) cancel();
|
||||
lingeringRemovals.clear();
|
||||
};
|
||||
|
||||
const publishVisibleMembership = (allowGrace: boolean) => {
|
||||
const publishVisibleMembership = () => {
|
||||
const visible = visibleAgentIds();
|
||||
for (const agentId of visible) {
|
||||
lingeringRemovals.get(agentId)?.();
|
||||
lingeringRemovals.delete(agentId);
|
||||
if (!connected || deliveryMode !== "selective") {
|
||||
const activeVisible = active ? visible : [];
|
||||
recentlyViewedAgentIds = activeVisible;
|
||||
commitDesiredMembership(activeVisible);
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowGrace && connected && deliveryMode === "selective") {
|
||||
for (const agentId of desired) {
|
||||
if (visible.includes(agentId) || lingeringRemovals.has(agentId)) continue;
|
||||
const cancel = ports.schedule(() => {
|
||||
lingeringRemovals.delete(agentId);
|
||||
commitDesiredMembership(effectiveAgentIds());
|
||||
}, VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS);
|
||||
lingeringRemovals.set(agentId, cancel);
|
||||
}
|
||||
} else {
|
||||
clearLingeringRemovals();
|
||||
}
|
||||
|
||||
commitDesiredMembership(effectiveAgentIds());
|
||||
if (!active) return;
|
||||
commitDesiredMembership(selectHotAgentIds(visible));
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -403,19 +403,20 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const normalized = normalizeAgentIds(agentIds);
|
||||
if (normalized.length === 0) sources.delete(sourceId);
|
||||
else sources.set(sourceId, normalized);
|
||||
publishVisibleMembership(true);
|
||||
publishVisibleMembership();
|
||||
},
|
||||
setActive(nextActive) {
|
||||
if (active === nextActive) return;
|
||||
active = nextActive;
|
||||
publishVisibleMembership(true);
|
||||
publishVisibleMembership();
|
||||
},
|
||||
setConnected(nextConnected) {
|
||||
if (connected === nextConnected) return;
|
||||
connected = nextConnected;
|
||||
if (!connected) {
|
||||
clearLingeringRemovals();
|
||||
commitDesiredMembership(visibleAgentIds(), { resetCatchUpStatus: true });
|
||||
const visible = active ? visibleAgentIds() : [];
|
||||
recentlyViewedAgentIds = visible;
|
||||
commitDesiredMembership(visible, { resetCatchUpStatus: true });
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
acknowledged = [];
|
||||
@@ -434,13 +435,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
setDeliveryMode(nextMode) {
|
||||
if (deliveryMode === nextMode) return;
|
||||
deliveryMode = nextMode;
|
||||
clearLingeringRemovals();
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
membershipNeedsRetry = false;
|
||||
membershipGeneration += 1;
|
||||
for (const agentId of desired) cancelCatchUp(agentId);
|
||||
desired = visibleAgentIds();
|
||||
const visible = active ? visibleAgentIds() : [];
|
||||
recentlyViewedAgentIds = visible;
|
||||
desired = visible;
|
||||
visibilityCatchUpPending.clear();
|
||||
visibilityCatchUpErrors.clear();
|
||||
for (const agentId of desired) visibilityCatchUpPending.add(agentId);
|
||||
@@ -467,7 +469,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
clearLingeringRemovals();
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
sources.clear();
|
||||
@@ -475,6 +476,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
for (const agentId of desired) cancelCatchUp(agentId);
|
||||
desired = [];
|
||||
acknowledged = [];
|
||||
recentlyViewedAgentIds = [];
|
||||
visibilityCatchUpPending.clear();
|
||||
visibilityCatchUpErrors.clear();
|
||||
notifyListeners();
|
||||
|
||||
Reference in New Issue
Block a user