Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-08-03 23:59:39 +02:00
10 changed files with 1195 additions and 334 deletions
+10 -8
View File
@@ -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" });
});
}); });
+278 -36
View File
@@ -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,
+16 -13
View File
@@ -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();