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
@@ -5,6 +5,7 @@ import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RetainedPanelActivity } from "@/components/retained-panel";
import type { StreamItem } from "@/types/stream";
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
import { createWebStreamStrategy } from "./strategy-web";
@@ -25,6 +26,10 @@ vi.hoisted(() => {
});
});
vi.mock("react-native-unistyles", () => ({
withUnistyles: (Component: React.ComponentType) => Component,
}));
function userMessage(index: number): StreamItem {
return {
kind: "user_message",
@@ -533,6 +538,7 @@ describe("createWebStreamStrategy", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
const onNearHistoryStart = vi.fn().mockReturnValue(false);
const routeBottomAnchorRequest = {
agentId: "agent",
reason: "initial-entry" as const,
@@ -555,10 +561,10 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
hasOlderHistory: true,
olderHistoryProgressKey: "epoch-1:20",
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -595,7 +601,7 @@ describe("createWebStreamStrategy", () => {
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 520 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
@@ -611,6 +617,14 @@ describe("createWebStreamStrategy", () => {
});
expect(scrollTo).not.toHaveBeenCalled();
await act(async () => Promise.resolve());
expect(onNearHistoryStart).toHaveBeenCalledOnce();
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
});
await act(async () => Promise.resolve());
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
});
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
@@ -821,4 +835,413 @@ describe("createWebStreamStrategy", () => {
expect(scrollTo).toHaveBeenCalled();
});
it("keeps following output after geometry, nested-scroll, and zoom wheel events", () => {
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(strategy.render(renderInput)));
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
scrollContainer.getBoundingClientRect = () =>
({
bottom: 500,
height: 500,
left: 0,
right: 500,
top: 0,
width: 500,
x: 0,
y: 0,
toJSON: () => {},
}) as DOMRect;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const jitterPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
button: 0,
buttons: 1,
clientX: 100,
clientY: 400,
});
const jitterPointerMove = new MouseEvent("pointermove", {
bubbles: true,
buttons: 1,
clientX: 100,
clientY: 397,
});
for (const event of [jitterPointerDown, jitterPointerMove]) {
Object.defineProperties(event, {
isPrimary: { value: true },
pointerId: { value: 1 },
pointerType: { value: "mouse" },
});
}
act(() => {
scrollContainer.dispatchEvent(jitterPointerDown);
window.dispatchEvent(jitterPointerMove);
});
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 700 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 800 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
const nestedScroller = document.createElement("div");
nestedScroller.style.overflowY = "auto";
Object.defineProperty(nestedScroller, "clientHeight", { configurable: true, value: 100 });
Object.defineProperty(nestedScroller, "scrollHeight", { configurable: true, value: 300 });
Object.defineProperty(nestedScroller, "scrollTop", { configurable: true, value: 100 });
scrollContainer.append(nestedScroller);
act(() => {
nestedScroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 }));
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
scrollContainer.dispatchEvent(new Event("scroll"));
scrollContainer.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: -100 }),
);
});
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1800 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: "auto" });
});
it("stops following output after scrollbar and middle-button upward scrolling", () => {
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef: React.createRef<StreamViewportHandle>(),
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(strategy.render(renderInput)));
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 520 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
scrollContainer.getBoundingClientRect = () =>
({
bottom: 500,
height: 500,
left: 0,
right: 520,
top: 0,
width: 520,
x: 0,
y: 0,
toJSON: () => {},
}) as DOMRect;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const scrollbarPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
button: 0,
buttons: 1,
clientX: 515,
clientY: 400,
});
Object.defineProperties(scrollbarPointerDown, {
isPrimary: { value: true },
pointerId: { value: 1 },
pointerType: { value: "mouse" },
});
act(() => scrollContainer.dispatchEvent(scrollbarPointerDown));
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1800 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1300 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
scrollContainer.getBoundingClientRect = () =>
({
bottom: 500,
height: 500,
left: 0,
right: 500,
top: 0,
width: 500,
x: 0,
y: 0,
toJSON: () => {},
}) as DOMRect;
const overlayScrollbarPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
button: 0,
buttons: 1,
clientX: 495,
clientY: 400,
});
Object.defineProperties(overlayScrollbarPointerDown, {
isPrimary: { value: true },
pointerId: { value: 2 },
pointerType: { value: "mouse" },
});
act(() => scrollContainer.dispatchEvent(overlayScrollbarPointerDown));
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 900 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1900 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
...renderInput.segments,
liveHead: [userMessage(3), userMessage(4)],
},
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1400 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const autoscrollPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
button: 1,
buttons: 4,
clientX: 100,
clientY: 400,
});
const autoscrollPointerUp = new MouseEvent("pointerup", {
bubbles: true,
button: 1,
buttons: 0,
clientX: 100,
clientY: 400,
});
const autoscrollPointerMove = new MouseEvent("pointermove", {
bubbles: true,
buttons: 0,
clientX: 100,
clientY: 300,
});
for (const event of [autoscrollPointerDown, autoscrollPointerUp, autoscrollPointerMove]) {
Object.defineProperties(event, {
isPrimary: { value: true },
pointerId: { value: 3 },
pointerType: { value: "mouse" },
});
}
act(() => {
scrollContainer.dispatchEvent(autoscrollPointerDown);
window.dispatchEvent(autoscrollPointerUp);
window.dispatchEvent(autoscrollPointerMove);
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2000 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
...renderInput.segments,
liveHead: [userMessage(3), userMessage(4), userMessage(5)],
},
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
});
it("keeps the retained viewport mounted while inactive and reconciles it once on return", () => {
const observed = new Map<Element, ReturnType<typeof vi.fn>>();
Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true,
value: class ResizeObserver {
readonly disconnect = vi.fn();
observe(target: Element) {
observed.set(target, this.disconnect);
}
},
});
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => (
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(renderWithActivity(true)));
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const viewportObserverDisconnect = observed.get(scrollContainer);
expect(viewportObserverDisconnect).toBeDefined();
act(() => root?.render(renderWithActivity(false)));
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
expect(viewportObserverDisconnect).toHaveBeenCalledOnce();
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
scrollContainer.dispatchEvent(new Event("scroll"));
root?.render(
renderWithActivity(false, {
...renderInput,
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
act(() => {
root?.render(
renderWithActivity(true, {
...renderInput,
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
boundary: { ...renderInput.boundary, hasLiveHead: true },
}),
);
});
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
expect(scrollTo).toHaveBeenCalledWith({ top: 2200, behavior: "auto" });
});
});
+278 -36
View File
@@ -9,9 +9,11 @@ import React, {
} from "react";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
@@ -51,6 +53,7 @@ const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_SETTLE_FRAMES = 2;
const HISTORY_START_SLOT_HEIGHT_PX = 32;
const CONTENT_PADDING_TOP_PX = 16;
const UPWARD_INPUT_EVIDENCE_TIMEOUT_MS = 100;
const VIRTUALIZER_SCROLL_MARGIN_PX = HISTORY_START_SLOT_HEIGHT_PX + CONTENT_PADDING_TOP_PX;
// A row has to clear this much of the viewport top before the next one takes over as the
// reading position, so a row resting exactly on the edge does not flip back and forth.
@@ -105,6 +108,77 @@ function isScrollContainerAtBottom(
return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX);
}
function isScrollContainerMeasurable(
scrollContainer: Pick<HTMLElement, "clientHeight" | "scrollHeight">,
): boolean {
return scrollContainer.clientHeight > 0 && scrollContainer.scrollHeight > 0;
}
function isEditableEventTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false;
}
const editableRoot = target.closest("input, textarea, [contenteditable]");
if (!editableRoot) {
return false;
}
const tagName = editableRoot.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea") {
return true;
}
return editableRoot.getAttribute("contenteditable")?.toLowerCase() !== "false";
}
function isUpwardViewportScrollKey(event: KeyboardEvent): boolean {
if (isEditableEventTarget(event.target)) {
return false;
}
return (
event.key === "ArrowUp" ||
event.key === "PageUp" ||
event.key === "Home" ||
(event.key === " " && event.shiftKey)
);
}
function canNestedScrollerConsumeUpwardInput(
target: EventTarget | null,
scrollContainer: HTMLElement,
): boolean {
let element = target instanceof Element ? target : null;
while (element && element !== scrollContainer) {
if (element instanceof HTMLElement) {
const overflowY = window.getComputedStyle(element).overflowY;
const canScroll = overflowY === "auto" || overflowY === "scroll";
if (canScroll && element.scrollHeight > element.clientHeight && element.scrollTop > 0) {
return true;
}
}
element = element.parentElement;
}
return false;
}
function isVerticalScrollbarGutterPress(
event: PointerEvent,
scrollContainer: HTMLElement,
): boolean {
if (event.target !== scrollContainer) {
return false;
}
if (scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
return false;
}
const scrollbarWidth = Math.max(
scrollContainer.offsetWidth - scrollContainer.clientWidth,
WEB_SCROLLBAR_SIZE_PX,
);
const bounds = scrollContainer.getBoundingClientRect();
return window.getComputedStyle(scrollContainer).direction === "rtl"
? event.clientX <= bounds.left + scrollbarWidth
: event.clientX >= bounds.right - scrollbarWidth;
}
function scrollElementToBottom(
scrollContainer: HTMLElement,
behavior: ScrollBehaviorLike = "auto",
@@ -159,6 +233,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled,
isMobileBreakpoint,
} = props;
const isActive = useRetainedPanelActive();
const isActiveRef = useRef(isActive);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
const handleScrollContainerRef = useCallback((node: HTMLElement | null) => {
@@ -175,7 +251,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return value;
};
const lastKnownScrollTopRef = useRef(0);
const pendingUserScrollUpIntentRef = useRef(false);
const mouseScrollGestureRef = useRef<
| { kind: "scrollbar"; pointerId: number }
| {
kind: "autoscroll";
pointerId: number;
lastClientY: number;
hasUpwardEvidence: boolean;
evidenceExpiryFrame: number | null;
}
| null
>(null);
const upwardInputEvidenceUntilRef = useRef(0);
const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
@@ -196,6 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
renderLiveAuxiliary,
} = renderers;
isActiveRef.current = isActive;
followOutputRef.current = followOutput;
const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null;
@@ -234,7 +322,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const virtualTotalSize = rowVirtualizer.getTotalSize();
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
return null;
}
const bottomAnchorSettled =
@@ -371,6 +459,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
});
useLayoutEffect(() => {
if (!isActiveRef.current) {
return;
}
const anchor = historyStartPrependAnchorRef.current;
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
return;
@@ -402,7 +493,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
const frame = window.requestAnimationFrame(() => {
pendingFrames.delete(node);
if (node.isConnected) {
if (isActiveRef.current && node.isConnected) {
rowVirtualizer.measureElement(node);
}
});
@@ -434,10 +525,47 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
}, []);
const clearMouseScrollGesture = useCallback(() => {
const gesture = mouseScrollGestureRef.current;
const evidenceExpiryFrame = gesture?.kind === "autoscroll" ? gesture.evidenceExpiryFrame : null;
if (evidenceExpiryFrame !== null && evidenceExpiryFrame !== undefined) {
window.cancelAnimationFrame(evidenceExpiryFrame);
}
mouseScrollGestureRef.current = null;
}, []);
const clearUpwardInputEvidence = useCallback(() => {
upwardInputEvidenceUntilRef.current = 0;
}, []);
const markUpwardInputEvidence = useCallback(() => {
upwardInputEvidenceUntilRef.current =
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
}, []);
useLayoutEffect(() => {
if (isActive) {
return;
}
cancelPendingStickToBottom();
historyStartSettleSchedulerRef.current?.cancel();
for (const frame of pendingVirtualRowMeasureFramesRef.current.values()) {
window.cancelAnimationFrame(frame);
}
pendingVirtualRowMeasureFramesRef.current.clear();
clearMouseScrollGesture();
clearUpwardInputEvidence();
lastTouchClientYRef.current = null;
}, [cancelPendingStickToBottom, clearMouseScrollGesture, clearUpwardInputEvidence, isActive]);
const scrollMessagesToBottom = useCallback(
(behavior: ScrollBehaviorLike = "auto") => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
if (
!isActiveRef.current ||
!scrollContainer ||
!isScrollContainerMeasurable(scrollContainer)
) {
return;
}
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
@@ -452,6 +580,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const scheduleStickToBottom = useCallback(() => {
if (!isActiveRef.current) {
return;
}
const scrollContainer = scrollContainerRef.current;
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
return;
@@ -461,7 +592,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
pendingAutoScrollFrameRef.current = null;
if (!followOutputRef.current) {
if (!isActiveRef.current || !followOutputRef.current) {
return;
}
scrollMessagesToBottom("auto");
@@ -477,6 +608,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Rows are laid out in DOM order, virtualized block first, so the first row whose bottom
// clears the reading line is the one the reader is looking at.
const reportReadingPosition = useStableEvent(() => {
if (!isActiveRef.current) {
return;
}
if (!onReadingPositionChange) {
return;
}
@@ -502,6 +636,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
});
const updateScrollMetrics = useCallback(() => {
if (!isActiveRef.current) {
return;
}
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
onNearBottomChange(true);
@@ -512,6 +649,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [onNearBottomChange, reportReadingPosition]);
const { isJumpSettling, scrollToMessage } = useScrollToMessage({
active: isActive,
scrollContainerRef,
rowVirtualizer,
historyVirtualized: segments.historyVirtualized,
@@ -520,9 +658,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearBottomChange,
});
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
cancelPendingStickToBottom();
if (followOutputRef.current) {
setFollowOutput(false);
}
rearmHistoryStartFromUserIntent();
});
const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
return;
}
@@ -531,33 +677,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
const scrolledDown =
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
const mouseGesture = mouseScrollGestureRef.current;
const hasUpwardInputEvidence =
mouseGesture?.kind === "scrollbar" ||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
window.performance.now() < upwardInputEvidenceUntilRef.current;
if (scrolledUp) {
cancelPendingStickToBottom();
if (followOutputRef.current) {
setFollowOutput(false);
}
pendingUserScrollUpIntentRef.current = false;
rearmHistoryStartFromUserIntent();
} else if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
setFollowOutput(true);
pendingUserScrollUpIntentRef.current = false;
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
if (!isAtBottom) {
cancelPendingStickToBottom();
setFollowOutput(false);
}
pendingUserScrollUpIntentRef.current = false;
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
stopFollowingOutputFromUserIntent();
}
lastKnownScrollTopRef.current = currentScrollTop;
updateScrollMetrics();
evaluateHistoryStart();
}, [
cancelPendingStickToBottom,
evaluateHistoryStart,
isJumpSettling,
rearmHistoryStartFromUserIntent,
stopFollowingOutputFromUserIntent,
updateScrollMetrics,
]);
@@ -580,7 +718,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [evaluateHistoryStart, props.agentId]);
useLayoutEffect(() => {
if (!isActivationReady) {
if (!isActiveRef.current || !isActivationReady) {
return;
}
if (hasRouteBottomAnchorRequest && !followOutputRef.current) {
@@ -615,13 +753,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Following output is a layout invariant: rows, footer, and bottom offset must
// reach the browser in the same paint.
useLayoutEffect(() => {
if (!followOutputRef.current) {
if (!isActive || !followOutputRef.current) {
return;
}
cancelPendingStickToBottom();
scrollMessagesToBottom("auto");
}, [
cancelPendingStickToBottom,
isActive,
renderLiveAuxiliary,
scrollMessagesToBottom,
segments.historyMounted,
@@ -631,6 +770,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
useEffect(() => {
if (!isActive) {
return;
}
updateScrollMetrics();
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
@@ -639,6 +781,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
@@ -649,7 +792,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
virtualTotalSize,
]);
useEffect(() => {
useLayoutEffect(() => {
if (!isActive) {
return;
}
const scrollContainer = scrollContainerRef.current;
const contentNode = contentRef.current;
if (!scrollContainer || typeof ResizeObserver === "undefined") {
@@ -682,24 +828,98 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [
applyHistoryStartPrependAnchor,
evaluateHistoryStart,
isActive,
scheduleHistoryStartPrependSettle,
scheduleStickToBottom,
updateScrollMetrics,
]);
useEffect(() => {
useLayoutEffect(() => {
if (!isActive) {
return;
}
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
const handleWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
const markUpwardViewportInput = () => {
markUpwardInputEvidence();
if (scrollContainer.scrollTop <= USER_SCROLL_DELTA_EPSILON) {
rearmHistoryStartFromUserIntent();
}
};
const handleWheel = (event: WheelEvent) => {
if (
!event.ctrlKey &&
event.deltaY < 0 &&
!canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)
) {
markUpwardViewportInput();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!isUpwardViewportScrollKey(event)) {
return;
}
if (canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)) {
return;
}
markUpwardViewportInput();
};
const handlePointerDown = (event: PointerEvent) => {
clearMouseScrollGesture();
if (
event.pointerType !== "mouse" ||
!event.isPrimary ||
(event.button !== 0 && event.button !== 1) ||
isEditableEventTarget(event.target)
) {
return;
}
if (event.button === 0) {
if (isVerticalScrollbarGutterPress(event, scrollContainer)) {
mouseScrollGestureRef.current = { kind: "scrollbar", pointerId: event.pointerId };
}
return;
}
mouseScrollGestureRef.current = {
kind: "autoscroll",
pointerId: event.pointerId,
lastClientY: event.clientY,
hasUpwardEvidence: false,
evidenceExpiryFrame: null,
};
};
const handlePointerMove = (event: PointerEvent) => {
const gesture = mouseScrollGestureRef.current;
if (!gesture || gesture.kind !== "autoscroll" || gesture.pointerId !== event.pointerId) {
return;
}
if (event.clientY < gesture.lastClientY - USER_SCROLL_DELTA_EPSILON) {
gesture.hasUpwardEvidence = true;
if (gesture.evidenceExpiryFrame !== null) {
window.cancelAnimationFrame(gesture.evidenceExpiryFrame);
}
gesture.evidenceExpiryFrame = window.requestAnimationFrame(() => {
if (mouseScrollGestureRef.current === gesture) {
mouseScrollGestureRef.current = null;
}
});
}
gesture.lastClientY = event.clientY;
};
const handlePointerEnd = (event: PointerEvent) => {
const gesture = mouseScrollGestureRef.current;
if (gesture?.pointerId === event.pointerId && gesture.kind === "scrollbar") {
clearMouseScrollGesture();
}
};
const handlePointerCancel = (event: PointerEvent) => {
if (mouseScrollGestureRef.current?.pointerId === event.pointerId) {
clearMouseScrollGesture();
}
};
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) {
@@ -713,10 +933,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return;
}
const previousTouchY = lastTouchClientYRef.current;
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
rearmHistoryStartFromUserIntent();
if (
previousTouchY !== null &&
touch.clientY > previousTouchY + 1 &&
!canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)
) {
markUpwardViewportInput();
}
lastTouchClientYRef.current = touch.clientY;
};
@@ -726,6 +948,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
scrollContainer.addEventListener("wheel", handleWheel, { passive: true });
scrollContainer.addEventListener("keydown", handleKeyDown, { passive: true });
scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true });
window.addEventListener("pointermove", handlePointerMove, { passive: true });
window.addEventListener("pointerup", handlePointerEnd, { passive: true });
window.addEventListener("pointercancel", handlePointerCancel, { passive: true });
window.addEventListener("blur", clearMouseScrollGesture);
scrollContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true });
scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true });
@@ -734,12 +962,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
scrollContainer.removeEventListener("scroll", handleDomScroll);
scrollContainer.removeEventListener("wheel", handleWheel);
scrollContainer.removeEventListener("keydown", handleKeyDown);
scrollContainer.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerEnd);
window.removeEventListener("pointercancel", handlePointerCancel);
window.removeEventListener("blur", clearMouseScrollGesture);
scrollContainer.removeEventListener("touchstart", handleTouchStart);
scrollContainer.removeEventListener("touchmove", handleTouchMove);
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
clearUpwardInputEvidence();
};
}, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]);
}, [
clearMouseScrollGesture,
clearUpwardInputEvidence,
handleDomScroll,
isActive,
markUpwardInputEvidence,
rearmHistoryStartFromUserIntent,
]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -1,9 +1,10 @@
import type React from "react";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
interface UseScrollToMessageInput {
active: boolean;
scrollContainerRef: React.RefObject<HTMLElement | null>;
rowVirtualizer: Virtualizer<HTMLElement, Element>;
historyVirtualized: readonly { id: string }[];
@@ -23,6 +24,7 @@ const SCROLL_AFFECTING_KEYS = new Set([
]);
export function useScrollToMessage({
active,
scrollContainerRef,
rowVirtualizer,
historyVirtualized,
@@ -79,9 +81,15 @@ export function useScrollToMessage({
);
useEffect(() => () => settleController.cancel(), [settleController]);
useLayoutEffect(() => {
if (!active) {
settleController.cancel();
}
}, [active, settleController]);
const scrollToMessage = useCallback(
(itemId: string) => {
if (!active) return;
const container = scrollContainerRef.current;
if (!container) return;
cancelPendingStickToBottom();
@@ -109,6 +117,7 @@ export function useScrollToMessage({
}
},
[
active,
cancelPendingStickToBottom,
historyVirtualized,
onNearBottomChange,
+16 -13
View File
@@ -269,6 +269,14 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
];
const EMPTY_STREAM_HEAD: StreamItem[] = [];
function useRetainedValue<T>(value: T, active: boolean): T {
const retainedRef = useRef(value);
if (active) {
retainedRef.current = value;
}
return active ? value : retainedRef.current;
}
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
@@ -465,20 +473,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
});
});
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
// cell-window renders on every 48ms flush from background agents.
// Freeze stream presentation while this tab slot is hidden to prevent offscreen
// cell-window and turn-lifecycle renders from background agents.
// When isActive flips back to true, the context change triggers a re-render and
// the component reads the current (fresh) streamItems/streamHead from props.
const isActive = useRetainedPanelActive();
const frozenStreamItemsRef = useRef(streamItems);
const frozenStreamHeadRef = useRef(streamHead);
if (isActive) {
frozenStreamItemsRef.current = streamItems;
frozenStreamHeadRef.current = streamHead;
}
const effectiveStreamItems = isActive ? streamItems : frozenStreamItemsRef.current;
const effectiveStreamHead = isActive ? streamHead : frozenStreamHeadRef.current;
const isTurnActive = turnPresentation.isActive;
const effectiveStreamItems = useRetainedValue(streamItems, isActive);
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
const isTurnActive = effectiveTurnPresentation.isActive;
// Keep retained history outside the 48ms live-head flush path.
const preparedToolCallHistory = useMemo(
() => prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems),
@@ -505,7 +508,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
isTurnActive,
activeTurnStartedAt: turnPresentation.startedAt,
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
tail: projectedToolCalls.tail,
head: projectedToolCalls.head,
platform: isWeb ? "web" : "native",
@@ -516,7 +519,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isTurnActive,
projectedToolCalls.head,
projectedToolCalls.tail,
turnPresentation.startedAt,
effectiveTurnPresentation.startedAt,
]);
const streamLayout = useMemo(
() =>
@@ -1,4 +1,4 @@
import { createContext, memo, type ReactNode, useContext } from "react";
import React, { createContext, memo, type ReactNode, useContext } from "react";
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
const RetainedPanelActiveContext = createContext(true);
@@ -1,9 +1,6 @@
import { expect, test, vi } from "vitest";
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
import {
createViewedTimelineSync,
VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS,
} from "./viewed-timeline-sync";
import { createViewedTimelineSync } from "./viewed-timeline-sync";
interface Deferred<T> {
promise: Promise<T>;
@@ -129,18 +126,14 @@ class TimelineWorld {
return new Promise((resolve) => this.retryWaiters.push(resolve));
}
runUnsubscribeGrace(): void {
const index = this.scheduled.findIndex(
(entry) => entry.delayMs === VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS,
elapse(elapsedMs: number): void {
const due = this.scheduled.filter((entry) => entry.delayMs <= elapsedMs);
this.scheduled.splice(
0,
this.scheduled.length,
...this.scheduled.filter((entry) => entry.delayMs > elapsedMs),
);
expect(index).toBeGreaterThanOrEqual(0);
this.scheduled.splice(index, 1)[0].task();
}
expectNoPendingUnsubscribe(): void {
expect(
this.scheduled.filter((entry) => entry.delayMs === VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS),
).toEqual([]);
for (const entry of due) entry.task();
}
private releaseMembershipWaiter(): void {
@@ -161,10 +154,6 @@ class TimelineWorld {
}
}
test("keeps hidden agents subscribed for thirty seconds", () => {
expect(VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS).toBe(30_000);
});
test("uses a tail fetch when an agent becomes visible", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
@@ -279,51 +268,35 @@ test("all acknowledged agents begin catch-up independently", async () => {
expect(membership.agentIds).toEqual(["agent-a", "agent-b"]);
});
test("membership changes during acknowledgement never catch up the stale set", async () => {
test("an eviction during acknowledgement never catches up the stale hot set", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", [
"agent-a",
"agent-b",
"agent-c",
"agent-d",
"agent-e",
]);
const staleMembership = await world.nextMembership();
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
world.runUnsubscribeGrace();
world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]);
staleMembership.succeed();
const currentMembership = await world.nextMembership();
currentMembership.succeed();
const agentB = await world.nextFetch("agent-b");
agentB.respond({ hasNewer: false });
const currentCatchUps = await Promise.all(
["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"].map((agentId) =>
world.nextFetch(agentId),
),
);
for (const catchUp of currentCatchUps) catchUp.respond({ hasNewer: false });
expect({ stale: staleMembership.agentIds, current: currentMembership.agentIds }).toEqual({
stale: ["agent-a"],
current: ["agent-b"],
stale: ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"],
current: ["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"],
});
world.expectNoPendingFetch();
});
test("removing one agent during paging cancels only that agent", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]);
const initialMembership = await world.nextMembership();
initialMembership.succeed();
const [agentA, agentB] = await Promise.all([
world.nextFetch("agent-a"),
world.nextFetch("agent-b"),
]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
world.runUnsubscribeGrace();
const replacement = await world.nextMembership();
agentA.respond({ hasNewer: true, seq: 4 });
agentB.respond({ hasNewer: true, seq: 7 });
const agentBNext = await world.nextFetch("agent-b");
replacement.succeed();
agentBNext.respond({ hasNewer: false });
expect(replacement.agentIds).toEqual(["agent-b"]);
world.expectNoPendingFetch();
});
test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
@@ -347,7 +320,22 @@ test("disconnect cancels paging and reconnect restores membership before fresh c
world.expectNoPendingFetch();
});
test("overlapping sources deduplicate membership and source removal preserves remaining views", async () => {
test("navigation while disconnected reconnects only the currently visible agent", async () => {
const world = new TimelineWorld();
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
world.sync.setConnected(true);
const membership = await world.nextMembership();
membership.succeed();
const catchUp = await world.nextFetch("agent-b");
catchUp.respond({ hasNewer: false });
expect(membership.agentIds).toEqual(["agent-b"]);
world.expectNoPendingFetch();
});
test("overlapping sources deduplicate membership and retain hidden hot agents", async () => {
const world = new TimelineWorld();
world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]);
world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]);
@@ -363,16 +351,11 @@ test("overlapping sources deduplicate membership and source removal preserves re
world.sync.replaceVisibleAgentIds("left-route", []);
world.expectNoPendingMembership();
world.expectNoPendingUnsubscribe();
world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]);
world.runUnsubscribeGrace();
const remaining = await world.nextMembership();
remaining.succeed();
expect({ combined: combined.agentIds, remaining: remaining.agentIds }).toEqual({
combined: ["agent-a", "agent-b"],
remaining: ["agent-b"],
});
expect(combined.agentIds).toEqual(["agent-a", "agent-b"]);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
});
test("a failed catch-up reports once and retries through the explicit retry policy", async () => {
@@ -474,46 +457,27 @@ test("membership failure autonomously retries without another visibility declara
});
});
test("background waits for grace before unsubscribing and catches up on return", async () => {
test("backgrounding preserves the hot membership without catch-up on return", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const initial = await world.nextMembership();
initial.succeed();
const initialCatchUp = await world.nextFetch("agent-a");
initialCatchUp.respond({ hasNewer: false });
const agentAMembership = await world.nextMembership();
agentAMembership.succeed();
const agentACatchUp = await world.nextFetch("agent-a");
agentACatchUp.respond({ hasNewer: false });
world.sync.setActive(false);
world.expectNoPendingMembership();
world.runUnsubscribeGrace();
const background = await world.nextMembership();
background.succeed();
world.sync.setActive(true);
const foreground = await world.nextMembership();
foreground.succeed();
const resumedCatchUp = await world.nextFetch("agent-a");
resumedCatchUp.respond({ hasNewer: false });
expect({ background: background.agentIds, foreground: foreground.agentIds }).toEqual({
background: [],
foreground: ["agent-a"],
});
});
test("foregrounding within grace preserves the live membership", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const catchUp = await world.nextFetch("agent-a");
catchUp.respond({ hasNewer: false });
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
const agentBMembership = await world.nextMembership();
agentBMembership.succeed();
const agentBCatchUp = await world.nextFetch("agent-b");
agentBCatchUp.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
world.sync.setActive(false);
world.expectNoPendingMembership();
world.elapse(60_000);
world.sync.setActive(true);
world.expectNoPendingUnsubscribe();
world.expectNoPendingMembership();
world.expectNoPendingFetch();
});
@@ -527,14 +491,17 @@ test("stale membership retry cannot overwrite a newer effective set", async () =
const staleRetry = await world.nextRetry();
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
world.runUnsubscribeGrace();
const current = await world.nextMembership();
staleRetry();
current.succeed();
const catchUp = await world.nextFetch("agent-b");
catchUp.respond({ hasNewer: false });
const [agentA, agentB] = await Promise.all([
world.nextFetch("agent-a"),
world.nextFetch("agent-b"),
]);
agentA.respond({ hasNewer: false });
agentB.respond({ hasNewer: false });
expect(current.agentIds).toEqual(["agent-b"]);
expect(current.agentIds).toEqual(["agent-a", "agent-b"]);
world.expectNoPendingMembership();
});
@@ -558,138 +525,114 @@ test("membership retry cannot run while disconnected", async () => {
expect(restored.agentIds).toEqual(["agent-a"]);
});
test("quickly returning to an agent cancels its pending unsubscribe without another catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const initialCatchUp = await world.nextFetch("agent-a");
initialCatchUp.respond({ hasNewer: false });
await vi.waitFor(() => {
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
});
world.sync.replaceVisibleAgentIds("workspace", []);
world.expectNoPendingMembership();
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
world.expectNoPendingUnsubscribe();
world.expectNoPendingMembership();
world.expectNoPendingFetch();
});
test("unsubscribe grace expiry removes the agent exactly once", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const catchUp = await world.nextFetch("agent-a");
catchUp.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
const readinessChanges: string[] = [];
world.sync.subscribe(() => {
readinessChanges.push(world.sync.getAgentTimelineStatus("agent-a"));
});
world.sync.replaceVisibleAgentIds("workspace", []);
world.runUnsubscribeGrace();
const unsubscribe = await world.nextMembership();
unsubscribe.succeed();
expect(unsubscribe.agentIds).toEqual([]);
expect(readinessChanges.at(-1)).toBe("pending");
world.expectNoPendingUnsubscribe();
world.expectNoPendingMembership();
});
test("a new visible agent subscribes immediately while the previous agent lingers", async () => {
test("returning to a hot hidden agent stays live after inactivity", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const initialMembership = await world.nextMembership();
initialMembership.succeed();
const initialCatchUp = await world.nextFetch("agent-a");
initialCatchUp.respond({ hasNewer: false });
const agentACatchUp = await world.nextFetch("agent-a");
agentACatchUp.respond({ hasNewer: false });
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
const expandedMembership = await world.nextMembership();
expandedMembership.succeed();
const agentBCatchUp = await world.nextFetch("agent-b");
agentBCatchUp.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
expect(expandedMembership.agentIds).toEqual(["agent-a", "agent-b"]);
world.runUnsubscribeGrace();
const settledMembership = await world.nextMembership();
settledMembership.succeed();
expect(settledMembership.agentIds).toEqual(["agent-b"]);
world.elapse(60_000);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
});
test("backgrounding preserves an existing unsubscribe grace period", async () => {
test("the hot set evicts the least-recent hidden agent and promotes revisited agents", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
for (const agentId of ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]) {
world.sync.replaceVisibleAgentIds("workspace", [agentId]);
const membership = await world.nextMembership();
membership.succeed();
const catchUp = await world.nextFetch(agentId);
catchUp.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus(agentId)).toBe("ready"));
}
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]);
const eviction = await world.nextMembership();
eviction.succeed();
const agentFCatchUp = await world.nextFetch("agent-f");
agentFCatchUp.respond({ hasNewer: false });
expect(eviction.agentIds).toEqual(["agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const restoreEvicted = await world.nextMembership();
restoreEvicted.succeed();
const agentACatchUp = await world.nextFetch("agent-a");
agentACatchUp.respond({ hasNewer: false });
expect(restoreEvicted.agentIds).toEqual(["agent-a", "agent-b", "agent-d", "agent-e", "agent-f"]);
});
test("visible agents are never evicted when they exceed the hot-set limit", async () => {
const world = new TimelineWorld();
const visible = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f"];
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", visible);
const membership = await world.nextMembership();
membership.succeed();
const catchUps = await Promise.all(visible.map((agentId) => world.nextFetch(agentId)));
for (const catchUp of catchUps) catchUp.respond({ hasNewer: false });
expect(membership.agentIds).toEqual(visible);
world.sync.replaceVisibleAgentIds("workspace", visible.slice(0, 5));
const hiddenEviction = await world.nextMembership();
hiddenEviction.succeed();
expect(hiddenEviction.agentIds).toEqual(visible.slice(0, 5));
});
test("disconnect clears hidden hot agents before reconnecting the visible set", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const catchUp = await world.nextFetch("agent-a");
catchUp.respond({ hasNewer: false });
const agentAMembership = await world.nextMembership();
agentAMembership.succeed();
const agentACatchUp = await world.nextFetch("agent-a");
agentACatchUp.respond({ hasNewer: false });
world.sync.replaceVisibleAgentIds("workspace", []);
world.sync.setActive(false);
world.expectNoPendingMembership();
world.runUnsubscribeGrace();
const unsubscribe = await world.nextMembership();
unsubscribe.succeed();
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
const agentBMembership = await world.nextMembership();
agentBMembership.succeed();
const agentBCatchUp = await world.nextFetch("agent-b");
agentBCatchUp.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
expect(unsubscribe.agentIds).toEqual([]);
world.expectNoPendingMembership();
});
test("disconnecting cancels pending unsubscribe grace without publishing on the closed socket", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]);
const membership = await world.nextMembership();
membership.succeed();
const [agentA, agentB] = await Promise.all([
world.nextFetch("agent-a"),
world.nextFetch("agent-b"),
]);
agentA.respond({ hasNewer: false });
agentB.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const agentAStatuses: string[] = [];
world.sync.subscribe(() => {
agentAStatuses.push(world.sync.getAgentTimelineStatus("agent-a"));
});
world.sync.setConnected(false);
expect(agentAStatuses).toEqual(["pending"]);
world.expectNoPendingUnsubscribe();
world.expectNoPendingMembership();
});
test("disposing cancels pending unsubscribe grace", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
const restored = await world.nextMembership();
restored.succeed();
const restoredCatchUp = await world.nextFetch("agent-b");
restoredCatchUp.respond({ hasNewer: false });
expect(restored.agentIds).toEqual(["agent-b"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const catchUp = await world.nextFetch("agent-a");
catchUp.respond({ hasNewer: false });
world.sync.replaceVisibleAgentIds("workspace", []);
world.sync.dispose();
world.expectNoPendingUnsubscribe();
world.expectNoPendingMembership();
const reopened = await world.nextMembership();
reopened.succeed();
const reopenedCatchUp = await world.nextFetch("agent-a");
reopenedCatchUp.respond({ hasNewer: false });
expect(reopened.agentIds).toEqual(["agent-a", "agent-b"]);
});
test("legacy delivery skips subscription RPCs while retaining visibility catch-up and gap recovery", async () => {
@@ -715,6 +658,33 @@ test("legacy delivery skips subscription RPCs while retaining visibility catch-u
});
});
test("legacy delivery catches up after returning to a view or foreground", async () => {
const world = new TimelineWorld();
world.sync.setDeliveryMode("legacy");
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const firstAgentA = await world.nextFetch("agent-a");
firstAgentA.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
const agentB = await world.nextFetch("agent-b");
agentB.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const secondAgentA = await world.nextFetch("agent-a");
secondAgentA.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.setActive(false);
world.sync.setActive(true);
const foregroundAgentA = await world.nextFetch("agent-a");
foregroundAgentA.respond({ hasNewer: false });
world.expectNoPendingMembership();
});
test("switching from legacy to selective delivery publishes membership and catches up once", async () => {
const world = new TimelineWorld();
world.sync.setDeliveryMode("legacy");
@@ -39,7 +39,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
}
const RETRY_DELAY_MS = 1_000;
export const VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS = 30_000;
const VIEWED_TIMELINE_HOT_AGENT_LIMIT = 5;
type CatchUpStatus = "running" | "complete" | "error";
@@ -99,7 +99,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
// Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked.
// Acknowledgement and tail completion are the only drain points.
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
const lingeringRemovals = new Map<string, () => void>();
const visibilityCatchUpPending = new Set<string>();
const visibilityCatchUpErrors = new Set<string>();
const listeners = new Set<() => void>();
@@ -114,10 +113,27 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
let reconcileRequested = false;
let membershipNeedsRetry = false;
let cancelMembershipRetry: (() => void) | null = null;
let recentlyViewedAgentIds: string[] = [];
const visibleAgentIds = () => (active ? normalizeAgentIds([...sources.values()].flat()) : []);
const effectiveAgentIds = () =>
normalizeAgentIds([...visibleAgentIds(), ...lingeringRemovals.keys()]);
const visibleAgentIds = () => normalizeAgentIds([...sources.values()].flat());
const selectHotAgentIds = (visible: string[]) => {
const visibleSet = new Set(visible);
recentlyViewedAgentIds = [
...visible,
...recentlyViewedAgentIds.filter((agentId) => !visibleSet.has(agentId)),
];
const hiddenBudget = Math.max(0, VIEWED_TIMELINE_HOT_AGENT_LIMIT - visible.length);
const desiredAgentIds = normalizeAgentIds([
...visible,
...recentlyViewedAgentIds
.filter((agentId) => !visibleSet.has(agentId))
.slice(0, hiddenBudget),
]);
const desiredSet = new Set(desiredAgentIds);
recentlyViewedAgentIds = recentlyViewedAgentIds.filter((agentId) => desiredSet.has(agentId));
return desiredAgentIds;
};
const isAcknowledged = (agentId: string) => acknowledged.includes(agentId);
const isDesired = (agentId: string) => desired.includes(agentId);
@@ -361,32 +377,16 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
void reconcileMembership();
};
const clearLingeringRemovals = () => {
for (const cancel of lingeringRemovals.values()) cancel();
lingeringRemovals.clear();
};
const publishVisibleMembership = (allowGrace: boolean) => {
const publishVisibleMembership = () => {
const visible = visibleAgentIds();
for (const agentId of visible) {
lingeringRemovals.get(agentId)?.();
lingeringRemovals.delete(agentId);
if (!connected || deliveryMode !== "selective") {
const activeVisible = active ? visible : [];
recentlyViewedAgentIds = activeVisible;
commitDesiredMembership(activeVisible);
return;
}
if (allowGrace && connected && deliveryMode === "selective") {
for (const agentId of desired) {
if (visible.includes(agentId) || lingeringRemovals.has(agentId)) continue;
const cancel = ports.schedule(() => {
lingeringRemovals.delete(agentId);
commitDesiredMembership(effectiveAgentIds());
}, VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS);
lingeringRemovals.set(agentId, cancel);
}
} else {
clearLingeringRemovals();
}
commitDesiredMembership(effectiveAgentIds());
if (!active) return;
commitDesiredMembership(selectHotAgentIds(visible));
};
return {
@@ -403,19 +403,20 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const normalized = normalizeAgentIds(agentIds);
if (normalized.length === 0) sources.delete(sourceId);
else sources.set(sourceId, normalized);
publishVisibleMembership(true);
publishVisibleMembership();
},
setActive(nextActive) {
if (active === nextActive) return;
active = nextActive;
publishVisibleMembership(true);
publishVisibleMembership();
},
setConnected(nextConnected) {
if (connected === nextConnected) return;
connected = nextConnected;
if (!connected) {
clearLingeringRemovals();
commitDesiredMembership(visibleAgentIds(), { resetCatchUpStatus: true });
const visible = active ? visibleAgentIds() : [];
recentlyViewedAgentIds = visible;
commitDesiredMembership(visible, { resetCatchUpStatus: true });
cancelMembershipRetry?.();
cancelMembershipRetry = null;
acknowledged = [];
@@ -434,13 +435,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
setDeliveryMode(nextMode) {
if (deliveryMode === nextMode) return;
deliveryMode = nextMode;
clearLingeringRemovals();
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipNeedsRetry = false;
membershipGeneration += 1;
for (const agentId of desired) cancelCatchUp(agentId);
desired = visibleAgentIds();
const visible = active ? visibleAgentIds() : [];
recentlyViewedAgentIds = visible;
desired = visible;
visibilityCatchUpPending.clear();
visibilityCatchUpErrors.clear();
for (const agentId of desired) visibilityCatchUpPending.add(agentId);
@@ -467,7 +469,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
},
dispose() {
disposed = true;
clearLingeringRemovals();
cancelMembershipRetry?.();
cancelMembershipRetry = null;
sources.clear();
@@ -475,6 +476,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
for (const agentId of desired) cancelCatchUp(agentId);
desired = [];
acknowledged = [];
recentlyViewedAgentIds = [];
visibilityCatchUpPending.clear();
visibilityCatchUpErrors.clear();
notifyListeners();