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
|
## 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
|
Focus alone does not mutate timeline state; the tail response is compared with the local
|
||||||
authoritative range first.
|
authoritative range first.
|
||||||
|
|
||||||
@@ -106,13 +107,14 @@ replica cache.
|
|||||||
|
|
||||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
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
|
- Selective daemons receive every agent visible in any pane plus the most recently viewed hidden
|
||||||
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
agents, up to five subscribed agents. Visible agents always win: if more than five are visible,
|
||||||
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
|
they all remain subscribed and no hidden agent does. Switching and app backgrounding preserve
|
||||||
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
|
this connection-scoped hot set, so returning to an agent still covered by it needs no catch-up.
|
||||||
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
|
Losing window keyboard focus does not make a selected pane invisible. Disconnecting clears hidden
|
||||||
After grace has expired, revisiting a retained timeline displays its cached state immediately and
|
hot agents; reconnect restores the currently visible set before authoritative catch-up. Revisiting
|
||||||
authoritative catch-up advances it to the current tail.
|
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
|
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
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.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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
test.setTimeout(150_000);
|
test.setTimeout(150_000);
|
||||||
@@ -105,9 +105,9 @@ test.describe("Viewed agent timelines", () => {
|
|||||||
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
|
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
|
||||||
try {
|
try {
|
||||||
await openAgent(page, scenario, scenario.firstAgentId);
|
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 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);
|
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
|
||||||
expect(finish.status).toBe("idle");
|
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("a hidden hot chat stays current", 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.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
const subscriptions = observeTimelineSubscriptions(page);
|
const subscriptions = observeTimelineSubscriptions(page);
|
||||||
const scenario = await seedViewedTimelineScenario();
|
const scenario = await seedViewedTimelineScenario();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import React from "react";
|
|||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||||
import { createWebStreamStrategy } from "./strategy-web";
|
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 {
|
function userMessage(index: number): StreamItem {
|
||||||
return {
|
return {
|
||||||
kind: "user_message",
|
kind: "user_message",
|
||||||
@@ -533,6 +538,7 @@ describe("createWebStreamStrategy", () => {
|
|||||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||||
|
const onNearHistoryStart = vi.fn().mockReturnValue(false);
|
||||||
const routeBottomAnchorRequest = {
|
const routeBottomAnchorRequest = {
|
||||||
agentId: "agent",
|
agentId: "agent",
|
||||||
reason: "initial-entry" as const,
|
reason: "initial-entry" as const,
|
||||||
@@ -555,10 +561,10 @@ describe("createWebStreamStrategy", () => {
|
|||||||
viewportRef,
|
viewportRef,
|
||||||
routeBottomAnchorRequest,
|
routeBottomAnchorRequest,
|
||||||
onNearBottomChange: vi.fn(),
|
onNearBottomChange: vi.fn(),
|
||||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
onNearHistoryStart,
|
||||||
isLoadingOlderHistory: false,
|
isLoadingOlderHistory: false,
|
||||||
hasOlderHistory: false,
|
hasOlderHistory: true,
|
||||||
olderHistoryProgressKey: null,
|
olderHistoryProgressKey: "epoch-1:20",
|
||||||
scrollEnabled: true,
|
scrollEnabled: true,
|
||||||
listStyle: null,
|
listStyle: null,
|
||||||
baseListContentContainerStyle: null,
|
baseListContentContainerStyle: null,
|
||||||
@@ -595,7 +601,7 @@ describe("createWebStreamStrategy", () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
|
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
|
||||||
});
|
});
|
||||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 520 });
|
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||||
act(() => {
|
act(() => {
|
||||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||||
});
|
});
|
||||||
@@ -611,6 +617,14 @@ describe("createWebStreamStrategy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(scrollTo).not.toHaveBeenCalled();
|
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 () => {
|
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
|
||||||
@@ -821,4 +835,413 @@ describe("createWebStreamStrategy", () => {
|
|||||||
|
|
||||||
expect(scrollTo).toHaveBeenCalled();
|
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";
|
} from "react";
|
||||||
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
|
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||||
import { estimateStreamItemHeight } from "./web-virtualization";
|
import { estimateStreamItemHeight } from "./web-virtualization";
|
||||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||||
import { createStreamStrategy } 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_SETTLE_FRAMES = 2;
|
||||||
const HISTORY_START_SLOT_HEIGHT_PX = 32;
|
const HISTORY_START_SLOT_HEIGHT_PX = 32;
|
||||||
const CONTENT_PADDING_TOP_PX = 16;
|
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;
|
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
|
// 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.
|
// 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);
|
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(
|
function scrollElementToBottom(
|
||||||
scrollContainer: HTMLElement,
|
scrollContainer: HTMLElement,
|
||||||
behavior: ScrollBehaviorLike = "auto",
|
behavior: ScrollBehaviorLike = "auto",
|
||||||
@@ -159,6 +233,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
scrollEnabled,
|
scrollEnabled,
|
||||||
isMobileBreakpoint,
|
isMobileBreakpoint,
|
||||||
} = props;
|
} = props;
|
||||||
|
const isActive = useRetainedPanelActive();
|
||||||
|
const isActiveRef = useRef(isActive);
|
||||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||||
const contentRef = useRef<HTMLElement | null>(null);
|
const contentRef = useRef<HTMLElement | null>(null);
|
||||||
const handleScrollContainerRef = useCallback((node: HTMLElement | null) => {
|
const handleScrollContainerRef = useCallback((node: HTMLElement | null) => {
|
||||||
@@ -175,7 +251,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
const lastKnownScrollTopRef = useRef(0);
|
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 lastTouchClientYRef = useRef<number | null>(null);
|
||||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||||
@@ -196,6 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
renderLiveAuxiliary,
|
renderLiveAuxiliary,
|
||||||
} = renderers;
|
} = renderers;
|
||||||
|
|
||||||
|
isActiveRef.current = isActive;
|
||||||
followOutputRef.current = followOutput;
|
followOutputRef.current = followOutput;
|
||||||
|
|
||||||
const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null;
|
const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null;
|
||||||
@@ -234,7 +322,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
||||||
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
|
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
|
||||||
const scrollContainer = scrollContainerRef.current;
|
const scrollContainer = scrollContainerRef.current;
|
||||||
if (!scrollContainer) {
|
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const bottomAnchorSettled =
|
const bottomAnchorSettled =
|
||||||
@@ -371,6 +459,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
});
|
});
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
if (!isActiveRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const anchor = historyStartPrependAnchorRef.current;
|
const anchor = historyStartPrependAnchorRef.current;
|
||||||
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
|
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
|
||||||
return;
|
return;
|
||||||
@@ -402,7 +493,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
}
|
}
|
||||||
const frame = window.requestAnimationFrame(() => {
|
const frame = window.requestAnimationFrame(() => {
|
||||||
pendingFrames.delete(node);
|
pendingFrames.delete(node);
|
||||||
if (node.isConnected) {
|
if (isActiveRef.current && node.isConnected) {
|
||||||
rowVirtualizer.measureElement(node);
|
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(
|
const scrollMessagesToBottom = useCallback(
|
||||||
(behavior: ScrollBehaviorLike = "auto") => {
|
(behavior: ScrollBehaviorLike = "auto") => {
|
||||||
const scrollContainer = scrollContainerRef.current;
|
const scrollContainer = scrollContainerRef.current;
|
||||||
if (!scrollContainer) {
|
if (
|
||||||
|
!isActiveRef.current ||
|
||||||
|
!scrollContainer ||
|
||||||
|
!isScrollContainerMeasurable(scrollContainer)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||||
@@ -452,6 +580,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
);
|
);
|
||||||
|
|
||||||
const scheduleStickToBottom = useCallback(() => {
|
const scheduleStickToBottom = useCallback(() => {
|
||||||
|
if (!isActiveRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const scrollContainer = scrollContainerRef.current;
|
const scrollContainer = scrollContainerRef.current;
|
||||||
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||||
return;
|
return;
|
||||||
@@ -461,7 +592,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
}
|
}
|
||||||
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
pendingAutoScrollFrameRef.current = null;
|
pendingAutoScrollFrameRef.current = null;
|
||||||
if (!followOutputRef.current) {
|
if (!isActiveRef.current || !followOutputRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scrollMessagesToBottom("auto");
|
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
|
// 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.
|
// clears the reading line is the one the reader is looking at.
|
||||||
const reportReadingPosition = useStableEvent(() => {
|
const reportReadingPosition = useStableEvent(() => {
|
||||||
|
if (!isActiveRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!onReadingPositionChange) {
|
if (!onReadingPositionChange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -502,6 +636,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateScrollMetrics = useCallback(() => {
|
const updateScrollMetrics = useCallback(() => {
|
||||||
|
if (!isActiveRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const scrollContainer = scrollContainerRef.current;
|
const scrollContainer = scrollContainerRef.current;
|
||||||
if (!scrollContainer) {
|
if (!scrollContainer) {
|
||||||
onNearBottomChange(true);
|
onNearBottomChange(true);
|
||||||
@@ -512,6 +649,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
}, [onNearBottomChange, reportReadingPosition]);
|
}, [onNearBottomChange, reportReadingPosition]);
|
||||||
|
|
||||||
const { isJumpSettling, scrollToMessage } = useScrollToMessage({
|
const { isJumpSettling, scrollToMessage } = useScrollToMessage({
|
||||||
|
active: isActive,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
rowVirtualizer,
|
rowVirtualizer,
|
||||||
historyVirtualized: segments.historyVirtualized,
|
historyVirtualized: segments.historyVirtualized,
|
||||||
@@ -520,9 +658,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
onNearBottomChange,
|
onNearBottomChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
||||||
|
cancelPendingStickToBottom();
|
||||||
|
if (followOutputRef.current) {
|
||||||
|
setFollowOutput(false);
|
||||||
|
}
|
||||||
|
rearmHistoryStartFromUserIntent();
|
||||||
|
});
|
||||||
|
|
||||||
const handleDomScroll = useCallback(() => {
|
const handleDomScroll = useCallback(() => {
|
||||||
const scrollContainer = scrollContainerRef.current;
|
const scrollContainer = scrollContainerRef.current;
|
||||||
if (!scrollContainer) {
|
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,33 +677,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
||||||
const scrolledDown =
|
const scrolledDown =
|
||||||
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
|
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) {
|
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
||||||
cancelPendingStickToBottom();
|
|
||||||
if (followOutputRef.current) {
|
|
||||||
setFollowOutput(false);
|
|
||||||
}
|
|
||||||
pendingUserScrollUpIntentRef.current = false;
|
|
||||||
rearmHistoryStartFromUserIntent();
|
|
||||||
} else if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
|
||||||
setFollowOutput(true);
|
setFollowOutput(true);
|
||||||
pendingUserScrollUpIntentRef.current = false;
|
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
|
||||||
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
|
stopFollowingOutputFromUserIntent();
|
||||||
if (!isAtBottom) {
|
|
||||||
cancelPendingStickToBottom();
|
|
||||||
setFollowOutput(false);
|
|
||||||
}
|
|
||||||
pendingUserScrollUpIntentRef.current = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lastKnownScrollTopRef.current = currentScrollTop;
|
lastKnownScrollTopRef.current = currentScrollTop;
|
||||||
updateScrollMetrics();
|
updateScrollMetrics();
|
||||||
evaluateHistoryStart();
|
evaluateHistoryStart();
|
||||||
}, [
|
}, [
|
||||||
cancelPendingStickToBottom,
|
|
||||||
evaluateHistoryStart,
|
evaluateHistoryStart,
|
||||||
isJumpSettling,
|
isJumpSettling,
|
||||||
rearmHistoryStartFromUserIntent,
|
stopFollowingOutputFromUserIntent,
|
||||||
updateScrollMetrics,
|
updateScrollMetrics,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -580,7 +718,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
}, [evaluateHistoryStart, props.agentId]);
|
}, [evaluateHistoryStart, props.agentId]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isActivationReady) {
|
if (!isActiveRef.current || !isActivationReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasRouteBottomAnchorRequest && !followOutputRef.current) {
|
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
|
// Following output is a layout invariant: rows, footer, and bottom offset must
|
||||||
// reach the browser in the same paint.
|
// reach the browser in the same paint.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!followOutputRef.current) {
|
if (!isActive || !followOutputRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cancelPendingStickToBottom();
|
cancelPendingStickToBottom();
|
||||||
scrollMessagesToBottom("auto");
|
scrollMessagesToBottom("auto");
|
||||||
}, [
|
}, [
|
||||||
cancelPendingStickToBottom,
|
cancelPendingStickToBottom,
|
||||||
|
isActive,
|
||||||
renderLiveAuxiliary,
|
renderLiveAuxiliary,
|
||||||
scrollMessagesToBottom,
|
scrollMessagesToBottom,
|
||||||
segments.historyMounted,
|
segments.historyMounted,
|
||||||
@@ -631,6 +770,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateScrollMetrics();
|
updateScrollMetrics();
|
||||||
evaluateHistoryStart();
|
evaluateHistoryStart();
|
||||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||||
@@ -639,6 +781,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
}, [
|
}, [
|
||||||
evaluateHistoryStart,
|
evaluateHistoryStart,
|
||||||
hasOlderHistory,
|
hasOlderHistory,
|
||||||
|
isActive,
|
||||||
isLoadingOlderHistory,
|
isLoadingOlderHistory,
|
||||||
olderHistoryProgressKey,
|
olderHistoryProgressKey,
|
||||||
scheduleHistoryStartPrependSettle,
|
scheduleHistoryStartPrependSettle,
|
||||||
@@ -649,7 +792,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
virtualTotalSize,
|
virtualTotalSize,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const scrollContainer = scrollContainerRef.current;
|
const scrollContainer = scrollContainerRef.current;
|
||||||
const contentNode = contentRef.current;
|
const contentNode = contentRef.current;
|
||||||
if (!scrollContainer || typeof ResizeObserver === "undefined") {
|
if (!scrollContainer || typeof ResizeObserver === "undefined") {
|
||||||
@@ -682,24 +828,98 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
}, [
|
}, [
|
||||||
applyHistoryStartPrependAnchor,
|
applyHistoryStartPrependAnchor,
|
||||||
evaluateHistoryStart,
|
evaluateHistoryStart,
|
||||||
|
isActive,
|
||||||
scheduleHistoryStartPrependSettle,
|
scheduleHistoryStartPrependSettle,
|
||||||
scheduleStickToBottom,
|
scheduleStickToBottom,
|
||||||
updateScrollMetrics,
|
updateScrollMetrics,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const scrollContainer = scrollContainerRef.current;
|
const scrollContainer = scrollContainerRef.current;
|
||||||
if (!scrollContainer) {
|
if (!scrollContainer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleWheel = (event: WheelEvent) => {
|
const markUpwardViewportInput = () => {
|
||||||
if (event.deltaY < 0) {
|
markUpwardInputEvidence();
|
||||||
pendingUserScrollUpIntentRef.current = true;
|
if (scrollContainer.scrollTop <= USER_SCROLL_DELTA_EPSILON) {
|
||||||
cancelPendingStickToBottom();
|
|
||||||
rearmHistoryStartFromUserIntent();
|
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 handleTouchStart = (event: TouchEvent) => {
|
||||||
const touch = event.touches[0];
|
const touch = event.touches[0];
|
||||||
if (!touch) {
|
if (!touch) {
|
||||||
@@ -713,10 +933,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const previousTouchY = lastTouchClientYRef.current;
|
const previousTouchY = lastTouchClientYRef.current;
|
||||||
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
|
if (
|
||||||
pendingUserScrollUpIntentRef.current = true;
|
previousTouchY !== null &&
|
||||||
cancelPendingStickToBottom();
|
touch.clientY > previousTouchY + 1 &&
|
||||||
rearmHistoryStartFromUserIntent();
|
!canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)
|
||||||
|
) {
|
||||||
|
markUpwardViewportInput();
|
||||||
}
|
}
|
||||||
lastTouchClientYRef.current = touch.clientY;
|
lastTouchClientYRef.current = touch.clientY;
|
||||||
};
|
};
|
||||||
@@ -726,6 +948,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
|
|
||||||
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
|
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
|
||||||
scrollContainer.addEventListener("wheel", handleWheel, { 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("touchstart", handleTouchStart, { passive: true });
|
||||||
scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true });
|
scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true });
|
||||||
scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true });
|
scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true });
|
||||||
@@ -734,12 +962,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
return () => {
|
return () => {
|
||||||
scrollContainer.removeEventListener("scroll", handleDomScroll);
|
scrollContainer.removeEventListener("scroll", handleDomScroll);
|
||||||
scrollContainer.removeEventListener("wheel", handleWheel);
|
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("touchstart", handleTouchStart);
|
||||||
scrollContainer.removeEventListener("touchmove", handleTouchMove);
|
scrollContainer.removeEventListener("touchmove", handleTouchMove);
|
||||||
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
||||||
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
||||||
|
clearUpwardInputEvidence();
|
||||||
};
|
};
|
||||||
}, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]);
|
}, [
|
||||||
|
clearMouseScrollGesture,
|
||||||
|
clearUpwardInputEvidence,
|
||||||
|
handleDomScroll,
|
||||||
|
isActive,
|
||||||
|
markUpwardInputEvidence,
|
||||||
|
rearmHistoryStartFromUserIntent,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handle: StreamViewportHandle = {
|
const handle: StreamViewportHandle = {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type React from "react";
|
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 type { Virtualizer } from "@tanstack/react-virtual";
|
||||||
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
|
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
|
||||||
|
|
||||||
interface UseScrollToMessageInput {
|
interface UseScrollToMessageInput {
|
||||||
|
active: boolean;
|
||||||
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
||||||
rowVirtualizer: Virtualizer<HTMLElement, Element>;
|
rowVirtualizer: Virtualizer<HTMLElement, Element>;
|
||||||
historyVirtualized: readonly { id: string }[];
|
historyVirtualized: readonly { id: string }[];
|
||||||
@@ -23,6 +24,7 @@ const SCROLL_AFFECTING_KEYS = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export function useScrollToMessage({
|
export function useScrollToMessage({
|
||||||
|
active,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
rowVirtualizer,
|
rowVirtualizer,
|
||||||
historyVirtualized,
|
historyVirtualized,
|
||||||
@@ -79,9 +81,15 @@ export function useScrollToMessage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => () => settleController.cancel(), [settleController]);
|
useEffect(() => () => settleController.cancel(), [settleController]);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
settleController.cancel();
|
||||||
|
}
|
||||||
|
}, [active, settleController]);
|
||||||
|
|
||||||
const scrollToMessage = useCallback(
|
const scrollToMessage = useCallback(
|
||||||
(itemId: string) => {
|
(itemId: string) => {
|
||||||
|
if (!active) return;
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
cancelPendingStickToBottom();
|
cancelPendingStickToBottom();
|
||||||
@@ -109,6 +117,7 @@ export function useScrollToMessage({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
active,
|
||||||
cancelPendingStickToBottom,
|
cancelPendingStickToBottom,
|
||||||
historyVirtualized,
|
historyVirtualized,
|
||||||
onNearBottomChange,
|
onNearBottomChange,
|
||||||
|
|||||||
@@ -269,6 +269,14 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
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 EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
|
||||||
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
|
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
|
// Freeze stream presentation while this tab slot is hidden to prevent offscreen
|
||||||
// cell-window renders on every 48ms flush from background agents.
|
// cell-window and turn-lifecycle renders from background agents.
|
||||||
// When isActive flips back to true, the context change triggers a re-render and
|
// When isActive flips back to true, the context change triggers a re-render and
|
||||||
// the component reads the current (fresh) streamItems/streamHead from props.
|
// the component reads the current (fresh) streamItems/streamHead from props.
|
||||||
const isActive = useRetainedPanelActive();
|
const isActive = useRetainedPanelActive();
|
||||||
const frozenStreamItemsRef = useRef(streamItems);
|
const effectiveStreamItems = useRetainedValue(streamItems, isActive);
|
||||||
const frozenStreamHeadRef = useRef(streamHead);
|
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
|
||||||
if (isActive) {
|
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
|
||||||
frozenStreamItemsRef.current = streamItems;
|
const isTurnActive = effectiveTurnPresentation.isActive;
|
||||||
frozenStreamHeadRef.current = streamHead;
|
|
||||||
}
|
|
||||||
const effectiveStreamItems = isActive ? streamItems : frozenStreamItemsRef.current;
|
|
||||||
const effectiveStreamHead = isActive ? streamHead : frozenStreamHeadRef.current;
|
|
||||||
const isTurnActive = turnPresentation.isActive;
|
|
||||||
// Keep retained history outside the 48ms live-head flush path.
|
// Keep retained history outside the 48ms live-head flush path.
|
||||||
const preparedToolCallHistory = useMemo(
|
const preparedToolCallHistory = useMemo(
|
||||||
() => prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems),
|
() => prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems),
|
||||||
@@ -505,7 +508,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const baseRenderModel = useMemo(() => {
|
const baseRenderModel = useMemo(() => {
|
||||||
return buildAgentStreamRenderModel({
|
return buildAgentStreamRenderModel({
|
||||||
isTurnActive,
|
isTurnActive,
|
||||||
activeTurnStartedAt: turnPresentation.startedAt,
|
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
|
||||||
tail: projectedToolCalls.tail,
|
tail: projectedToolCalls.tail,
|
||||||
head: projectedToolCalls.head,
|
head: projectedToolCalls.head,
|
||||||
platform: isWeb ? "web" : "native",
|
platform: isWeb ? "web" : "native",
|
||||||
@@ -516,7 +519,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
isTurnActive,
|
isTurnActive,
|
||||||
projectedToolCalls.head,
|
projectedToolCalls.head,
|
||||||
projectedToolCalls.tail,
|
projectedToolCalls.tail,
|
||||||
turnPresentation.startedAt,
|
effectiveTurnPresentation.startedAt,
|
||||||
]);
|
]);
|
||||||
const streamLayout = useMemo(
|
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";
|
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
|
||||||
const RetainedPanelActiveContext = createContext(true);
|
const RetainedPanelActiveContext = createContext(true);
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
|
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
|
||||||
import {
|
import { createViewedTimelineSync } from "./viewed-timeline-sync";
|
||||||
createViewedTimelineSync,
|
|
||||||
VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS,
|
|
||||||
} from "./viewed-timeline-sync";
|
|
||||||
|
|
||||||
interface Deferred<T> {
|
interface Deferred<T> {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
@@ -129,18 +126,14 @@ class TimelineWorld {
|
|||||||
return new Promise((resolve) => this.retryWaiters.push(resolve));
|
return new Promise((resolve) => this.retryWaiters.push(resolve));
|
||||||
}
|
}
|
||||||
|
|
||||||
runUnsubscribeGrace(): void {
|
elapse(elapsedMs: number): void {
|
||||||
const index = this.scheduled.findIndex(
|
const due = this.scheduled.filter((entry) => entry.delayMs <= elapsedMs);
|
||||||
(entry) => entry.delayMs === VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS,
|
this.scheduled.splice(
|
||||||
|
0,
|
||||||
|
this.scheduled.length,
|
||||||
|
...this.scheduled.filter((entry) => entry.delayMs > elapsedMs),
|
||||||
);
|
);
|
||||||
expect(index).toBeGreaterThanOrEqual(0);
|
for (const entry of due) entry.task();
|
||||||
this.scheduled.splice(index, 1)[0].task();
|
|
||||||
}
|
|
||||||
|
|
||||||
expectNoPendingUnsubscribe(): void {
|
|
||||||
expect(
|
|
||||||
this.scheduled.filter((entry) => entry.delayMs === VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS),
|
|
||||||
).toEqual([]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private releaseMembershipWaiter(): void {
|
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 () => {
|
test("uses a tail fetch when an agent becomes visible", async () => {
|
||||||
const world = new TimelineWorld();
|
const world = new TimelineWorld();
|
||||||
world.sync.setConnected(true);
|
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"]);
|
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();
|
const world = new TimelineWorld();
|
||||||
world.sync.setConnected(true);
|
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();
|
const staleMembership = await world.nextMembership();
|
||||||
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]);
|
||||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
|
||||||
world.runUnsubscribeGrace();
|
|
||||||
staleMembership.succeed();
|
staleMembership.succeed();
|
||||||
const currentMembership = await world.nextMembership();
|
const currentMembership = await world.nextMembership();
|
||||||
currentMembership.succeed();
|
currentMembership.succeed();
|
||||||
const agentB = await world.nextFetch("agent-b");
|
const currentCatchUps = await Promise.all(
|
||||||
agentB.respond({ hasNewer: false });
|
["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({
|
expect({ stale: staleMembership.agentIds, current: currentMembership.agentIds }).toEqual({
|
||||||
stale: ["agent-a"],
|
stale: ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"],
|
||||||
current: ["agent-b"],
|
current: ["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"],
|
||||||
});
|
});
|
||||||
world.expectNoPendingFetch();
|
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 () => {
|
test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => {
|
||||||
const world = new TimelineWorld();
|
const world = new TimelineWorld();
|
||||||
world.sync.setConnected(true);
|
world.sync.setConnected(true);
|
||||||
@@ -347,7 +320,22 @@ test("disconnect cancels paging and reconnect restores membership before fresh c
|
|||||||
world.expectNoPendingFetch();
|
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();
|
const world = new TimelineWorld();
|
||||||
world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]);
|
world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]);
|
||||||
world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]);
|
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.sync.replaceVisibleAgentIds("left-route", []);
|
||||||
world.expectNoPendingMembership();
|
world.expectNoPendingMembership();
|
||||||
world.expectNoPendingUnsubscribe();
|
|
||||||
world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]);
|
world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]);
|
||||||
world.runUnsubscribeGrace();
|
|
||||||
const remaining = await world.nextMembership();
|
|
||||||
remaining.succeed();
|
|
||||||
|
|
||||||
expect({ combined: combined.agentIds, remaining: remaining.agentIds }).toEqual({
|
expect(combined.agentIds).toEqual(["agent-a", "agent-b"]);
|
||||||
combined: ["agent-a", "agent-b"],
|
world.expectNoPendingMembership();
|
||||||
remaining: ["agent-b"],
|
world.expectNoPendingFetch();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a failed catch-up reports once and retries through the explicit retry policy", async () => {
|
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();
|
const world = new TimelineWorld();
|
||||||
world.sync.setConnected(true);
|
world.sync.setConnected(true);
|
||||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||||
const initial = await world.nextMembership();
|
const agentAMembership = await world.nextMembership();
|
||||||
initial.succeed();
|
agentAMembership.succeed();
|
||||||
const initialCatchUp = await world.nextFetch("agent-a");
|
const agentACatchUp = await world.nextFetch("agent-a");
|
||||||
initialCatchUp.respond({ hasNewer: false });
|
agentACatchUp.respond({ hasNewer: false });
|
||||||
|
|
||||||
world.sync.setActive(false);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||||
world.expectNoPendingMembership();
|
const agentBMembership = await world.nextMembership();
|
||||||
world.runUnsubscribeGrace();
|
agentBMembership.succeed();
|
||||||
const background = await world.nextMembership();
|
const agentBCatchUp = await world.nextFetch("agent-b");
|
||||||
background.succeed();
|
agentBCatchUp.respond({ hasNewer: false });
|
||||||
world.sync.setActive(true);
|
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
|
||||||
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.setActive(false);
|
world.sync.setActive(false);
|
||||||
world.expectNoPendingMembership();
|
world.expectNoPendingMembership();
|
||||||
|
world.elapse(60_000);
|
||||||
world.sync.setActive(true);
|
world.sync.setActive(true);
|
||||||
|
|
||||||
world.expectNoPendingUnsubscribe();
|
|
||||||
world.expectNoPendingMembership();
|
world.expectNoPendingMembership();
|
||||||
world.expectNoPendingFetch();
|
world.expectNoPendingFetch();
|
||||||
});
|
});
|
||||||
@@ -527,14 +491,17 @@ test("stale membership retry cannot overwrite a newer effective set", async () =
|
|||||||
const staleRetry = await world.nextRetry();
|
const staleRetry = await world.nextRetry();
|
||||||
|
|
||||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||||
world.runUnsubscribeGrace();
|
|
||||||
const current = await world.nextMembership();
|
const current = await world.nextMembership();
|
||||||
staleRetry();
|
staleRetry();
|
||||||
current.succeed();
|
current.succeed();
|
||||||
const catchUp = await world.nextFetch("agent-b");
|
const [agentA, agentB] = await Promise.all([
|
||||||
catchUp.respond({ hasNewer: false });
|
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();
|
world.expectNoPendingMembership();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -558,138 +525,114 @@ test("membership retry cannot run while disconnected", async () => {
|
|||||||
expect(restored.agentIds).toEqual(["agent-a"]);
|
expect(restored.agentIds).toEqual(["agent-a"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("quickly returning to an agent cancels its pending unsubscribe without another catch-up", 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 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 () => {
|
|
||||||
const world = new TimelineWorld();
|
const world = new TimelineWorld();
|
||||||
world.sync.setConnected(true);
|
world.sync.setConnected(true);
|
||||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||||
const initialMembership = await world.nextMembership();
|
const initialMembership = await world.nextMembership();
|
||||||
initialMembership.succeed();
|
initialMembership.succeed();
|
||||||
const initialCatchUp = await world.nextFetch("agent-a");
|
const agentACatchUp = await world.nextFetch("agent-a");
|
||||||
initialCatchUp.respond({ hasNewer: false });
|
agentACatchUp.respond({ hasNewer: false });
|
||||||
|
|
||||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||||
const expandedMembership = await world.nextMembership();
|
const expandedMembership = await world.nextMembership();
|
||||||
expandedMembership.succeed();
|
expandedMembership.succeed();
|
||||||
const agentBCatchUp = await world.nextFetch("agent-b");
|
const agentBCatchUp = await world.nextFetch("agent-b");
|
||||||
agentBCatchUp.respond({ hasNewer: false });
|
agentBCatchUp.respond({ hasNewer: false });
|
||||||
|
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
|
||||||
|
|
||||||
expect(expandedMembership.agentIds).toEqual(["agent-a", "agent-b"]);
|
world.elapse(60_000);
|
||||||
world.runUnsubscribeGrace();
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||||
const settledMembership = await world.nextMembership();
|
|
||||||
settledMembership.succeed();
|
world.expectNoPendingMembership();
|
||||||
expect(settledMembership.agentIds).toEqual(["agent-b"]);
|
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();
|
const world = new TimelineWorld();
|
||||||
world.sync.setConnected(true);
|
world.sync.setConnected(true);
|
||||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||||
const membership = await world.nextMembership();
|
const agentAMembership = await world.nextMembership();
|
||||||
membership.succeed();
|
agentAMembership.succeed();
|
||||||
const catchUp = await world.nextFetch("agent-a");
|
const agentACatchUp = await world.nextFetch("agent-a");
|
||||||
catchUp.respond({ hasNewer: false });
|
agentACatchUp.respond({ hasNewer: false });
|
||||||
|
|
||||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||||
world.sync.setActive(false);
|
const agentBMembership = await world.nextMembership();
|
||||||
world.expectNoPendingMembership();
|
agentBMembership.succeed();
|
||||||
world.runUnsubscribeGrace();
|
const agentBCatchUp = await world.nextFetch("agent-b");
|
||||||
const unsubscribe = await world.nextMembership();
|
agentBCatchUp.respond({ hasNewer: false });
|
||||||
unsubscribe.succeed();
|
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);
|
world.sync.setConnected(false);
|
||||||
|
|
||||||
expect(agentAStatuses).toEqual(["pending"]);
|
|
||||||
world.expectNoPendingUnsubscribe();
|
|
||||||
world.expectNoPendingMembership();
|
world.expectNoPendingMembership();
|
||||||
});
|
|
||||||
|
|
||||||
test("disposing cancels pending unsubscribe grace", async () => {
|
|
||||||
const world = new TimelineWorld();
|
|
||||||
world.sync.setConnected(true);
|
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"]);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||||
const membership = await world.nextMembership();
|
const reopened = await world.nextMembership();
|
||||||
membership.succeed();
|
reopened.succeed();
|
||||||
const catchUp = await world.nextFetch("agent-a");
|
const reopenedCatchUp = await world.nextFetch("agent-a");
|
||||||
catchUp.respond({ hasNewer: false });
|
reopenedCatchUp.respond({ hasNewer: false });
|
||||||
|
expect(reopened.agentIds).toEqual(["agent-a", "agent-b"]);
|
||||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
|
||||||
world.sync.dispose();
|
|
||||||
|
|
||||||
world.expectNoPendingUnsubscribe();
|
|
||||||
world.expectNoPendingMembership();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("legacy delivery skips subscription RPCs while retaining visibility catch-up and gap recovery", async () => {
|
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 () => {
|
test("switching from legacy to selective delivery publishes membership and catches up once", async () => {
|
||||||
const world = new TimelineWorld();
|
const world = new TimelineWorld();
|
||||||
world.sync.setDeliveryMode("legacy");
|
world.sync.setDeliveryMode("legacy");
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RETRY_DELAY_MS = 1_000;
|
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";
|
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.
|
// Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked.
|
||||||
// Acknowledgement and tail completion are the only drain points.
|
// Acknowledgement and tail completion are the only drain points.
|
||||||
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
||||||
const lingeringRemovals = new Map<string, () => void>();
|
|
||||||
const visibilityCatchUpPending = new Set<string>();
|
const visibilityCatchUpPending = new Set<string>();
|
||||||
const visibilityCatchUpErrors = new Set<string>();
|
const visibilityCatchUpErrors = new Set<string>();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
@@ -114,10 +113,27 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
let reconcileRequested = false;
|
let reconcileRequested = false;
|
||||||
let membershipNeedsRetry = false;
|
let membershipNeedsRetry = false;
|
||||||
let cancelMembershipRetry: (() => void) | null = null;
|
let cancelMembershipRetry: (() => void) | null = null;
|
||||||
|
let recentlyViewedAgentIds: string[] = [];
|
||||||
|
|
||||||
const visibleAgentIds = () => (active ? normalizeAgentIds([...sources.values()].flat()) : []);
|
const visibleAgentIds = () => normalizeAgentIds([...sources.values()].flat());
|
||||||
const effectiveAgentIds = () =>
|
|
||||||
normalizeAgentIds([...visibleAgentIds(), ...lingeringRemovals.keys()]);
|
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 isAcknowledged = (agentId: string) => acknowledged.includes(agentId);
|
||||||
const isDesired = (agentId: string) => desired.includes(agentId);
|
const isDesired = (agentId: string) => desired.includes(agentId);
|
||||||
@@ -361,32 +377,16 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
void reconcileMembership();
|
void reconcileMembership();
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearLingeringRemovals = () => {
|
const publishVisibleMembership = () => {
|
||||||
for (const cancel of lingeringRemovals.values()) cancel();
|
|
||||||
lingeringRemovals.clear();
|
|
||||||
};
|
|
||||||
|
|
||||||
const publishVisibleMembership = (allowGrace: boolean) => {
|
|
||||||
const visible = visibleAgentIds();
|
const visible = visibleAgentIds();
|
||||||
for (const agentId of visible) {
|
if (!connected || deliveryMode !== "selective") {
|
||||||
lingeringRemovals.get(agentId)?.();
|
const activeVisible = active ? visible : [];
|
||||||
lingeringRemovals.delete(agentId);
|
recentlyViewedAgentIds = activeVisible;
|
||||||
|
commitDesiredMembership(activeVisible);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (!active) return;
|
||||||
if (allowGrace && connected && deliveryMode === "selective") {
|
commitDesiredMembership(selectHotAgentIds(visible));
|
||||||
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());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -403,19 +403,20 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
const normalized = normalizeAgentIds(agentIds);
|
const normalized = normalizeAgentIds(agentIds);
|
||||||
if (normalized.length === 0) sources.delete(sourceId);
|
if (normalized.length === 0) sources.delete(sourceId);
|
||||||
else sources.set(sourceId, normalized);
|
else sources.set(sourceId, normalized);
|
||||||
publishVisibleMembership(true);
|
publishVisibleMembership();
|
||||||
},
|
},
|
||||||
setActive(nextActive) {
|
setActive(nextActive) {
|
||||||
if (active === nextActive) return;
|
if (active === nextActive) return;
|
||||||
active = nextActive;
|
active = nextActive;
|
||||||
publishVisibleMembership(true);
|
publishVisibleMembership();
|
||||||
},
|
},
|
||||||
setConnected(nextConnected) {
|
setConnected(nextConnected) {
|
||||||
if (connected === nextConnected) return;
|
if (connected === nextConnected) return;
|
||||||
connected = nextConnected;
|
connected = nextConnected;
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
clearLingeringRemovals();
|
const visible = active ? visibleAgentIds() : [];
|
||||||
commitDesiredMembership(visibleAgentIds(), { resetCatchUpStatus: true });
|
recentlyViewedAgentIds = visible;
|
||||||
|
commitDesiredMembership(visible, { resetCatchUpStatus: true });
|
||||||
cancelMembershipRetry?.();
|
cancelMembershipRetry?.();
|
||||||
cancelMembershipRetry = null;
|
cancelMembershipRetry = null;
|
||||||
acknowledged = [];
|
acknowledged = [];
|
||||||
@@ -434,13 +435,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
setDeliveryMode(nextMode) {
|
setDeliveryMode(nextMode) {
|
||||||
if (deliveryMode === nextMode) return;
|
if (deliveryMode === nextMode) return;
|
||||||
deliveryMode = nextMode;
|
deliveryMode = nextMode;
|
||||||
clearLingeringRemovals();
|
|
||||||
cancelMembershipRetry?.();
|
cancelMembershipRetry?.();
|
||||||
cancelMembershipRetry = null;
|
cancelMembershipRetry = null;
|
||||||
membershipNeedsRetry = false;
|
membershipNeedsRetry = false;
|
||||||
membershipGeneration += 1;
|
membershipGeneration += 1;
|
||||||
for (const agentId of desired) cancelCatchUp(agentId);
|
for (const agentId of desired) cancelCatchUp(agentId);
|
||||||
desired = visibleAgentIds();
|
const visible = active ? visibleAgentIds() : [];
|
||||||
|
recentlyViewedAgentIds = visible;
|
||||||
|
desired = visible;
|
||||||
visibilityCatchUpPending.clear();
|
visibilityCatchUpPending.clear();
|
||||||
visibilityCatchUpErrors.clear();
|
visibilityCatchUpErrors.clear();
|
||||||
for (const agentId of desired) visibilityCatchUpPending.add(agentId);
|
for (const agentId of desired) visibilityCatchUpPending.add(agentId);
|
||||||
@@ -467,7 +469,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
clearLingeringRemovals();
|
|
||||||
cancelMembershipRetry?.();
|
cancelMembershipRetry?.();
|
||||||
cancelMembershipRetry = null;
|
cancelMembershipRetry = null;
|
||||||
sources.clear();
|
sources.clear();
|
||||||
@@ -475,6 +476,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
for (const agentId of desired) cancelCatchUp(agentId);
|
for (const agentId of desired) cancelCatchUp(agentId);
|
||||||
desired = [];
|
desired = [];
|
||||||
acknowledged = [];
|
acknowledged = [];
|
||||||
|
recentlyViewedAgentIds = [];
|
||||||
visibilityCatchUpPending.clear();
|
visibilityCatchUpPending.clear();
|
||||||
visibilityCatchUpErrors.clear();
|
visibilityCatchUpErrors.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
Reference in New Issue
Block a user