Fix bottom anchoring and Claude wake routing

This commit is contained in:
Mohamed Boudra
2026-03-08 12:54:08 +07:00
parent 0c44e7db80
commit b339c5e61d
27 changed files with 3801 additions and 326 deletions
@@ -66,6 +66,7 @@ interface AgentInputAreaProps {
commandDraftConfig?: DraftCommandConfig
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
onMessageSent?: () => void
onComposerHeightChange?: (height: number) => void
/** Controlled status controls rendered in input area (draft flows). */
statusControls?: DraftAgentStatusBarProps
}
@@ -85,6 +86,7 @@ export function AgentInputArea({
onAddImages,
commandDraftConfig,
onMessageSent,
onComposerHeightChange,
statusControls,
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
@@ -823,6 +825,7 @@ export function AgentInputArea({
setCursorIndex(selection.start)
}}
onFocusChange={setIsMessageInputFocused}
onHeightChange={onComposerHeightChange}
/>
</View>
</View>
@@ -9,6 +9,7 @@ import {
isNearBottomForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
resolveStreamRenderStrategy,
} from "./agent-stream-render-strategy";
@@ -45,6 +46,10 @@ describe("resolveStreamRenderStrategy", () => {
expect(strategy.getFlatListInverted()).toBe(false);
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
});
});
it("uses inverted_stream on native", () => {
@@ -57,6 +62,44 @@ describe("resolveStreamRenderStrategy", () => {
expect(strategy.getFlatListInverted()).toBe(true);
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
});
});
it("delays native verification while viewport settling is in flight", () => {
const strategy = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
).toEqual({
verificationDelayFrames: 4,
verificationRetryMode: "recheck",
});
});
it("does not inflate forward-stream verification delays during web resize", () => {
const strategy = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
expect(
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
});
});
});
@@ -11,6 +11,11 @@ export type MaintainVisibleContentPositionConfig = Readonly<{
autoscrollToTopThreshold: number;
}>;
export type BottomAnchorTransportBehavior = Readonly<{
verificationDelayFrames: number;
verificationRetryMode: "rescroll" | "recheck";
}>;
export type StreamViewportMetrics = {
contentHeight: number;
viewportHeight: number;
@@ -29,7 +34,7 @@ export type StreamEdgeSlotProps = {
};
export type StreamRenderRefs = {
flatListRef: RefObject<FlatList<StreamItem> | null>;
flatListRef: RefObject<FlatList<any> | null>;
scrollViewRef: RefObject<ScrollView | null>;
bottomAnchorRef: RefObject<View | null>;
};
@@ -58,6 +63,7 @@ export interface StreamRenderStrategy {
getMaintainVisibleContentPosition: () =>
| MaintainVisibleContentPositionConfig
| undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
@@ -76,6 +82,8 @@ export interface StreamRenderStrategy {
}) => void;
}
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
type StreamRenderStrategyConfig = {
orderTailReverse: boolean;
orderHeadReverse: boolean;
@@ -84,6 +92,7 @@ type StreamRenderStrategyConfig = {
flatListInverted: boolean;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
bottomAnchorTransportBehavior: BottomAnchorTransportBehavior;
disableParentScrollOnInlineDetailsExpansion: boolean;
anchorBottomOnContentSizeChange: boolean;
animateManualScrollToBottom: boolean;
@@ -137,28 +146,26 @@ function scrollAnchorIntoView(params: {
return true;
}
function forceScrollContainerToBottom(
refs: StreamRenderRefs,
fallbackOffset: number
): void {
const resolveNode = (
input: unknown
): HTMLElement | null => {
if (!(input instanceof HTMLElement)) {
return null;
}
if (input.scrollHeight - input.clientHeight > 1) {
return input;
}
let node: HTMLElement | null = input.parentElement;
while (node) {
if (node.scrollHeight - node.clientHeight > 1) {
return node;
}
node = node.parentElement;
}
function resolvePotentialScrollNode(input: unknown): HTMLElement | null {
if (!(input instanceof HTMLElement)) {
return null;
};
}
if (input.scrollHeight - input.clientHeight > 1) {
return input;
}
let node: HTMLElement | null = input.parentElement;
while (node) {
if (node.scrollHeight - node.clientHeight > 1) {
return node;
}
node = node.parentElement;
}
return input;
}
export function resolveWebScrollContainerNode(
refs: StreamRenderRefs
): HTMLElement | null {
const scrollViewHandle = refs.scrollViewRef.current as
| {
@@ -185,18 +192,43 @@ function forceScrollContainerToBottom(
let scrollNode: HTMLElement | null = null;
for (const candidate of candidates) {
scrollNode = resolveNode(candidate);
scrollNode = resolvePotentialScrollNode(candidate);
if (scrollNode) {
break;
}
}
if (!scrollNode && typeof document !== "undefined") {
scrollNode = resolveNode(
scrollNode = resolvePotentialScrollNode(
document.querySelector("[data-testid='agent-chat-scroll']")
);
}
return scrollNode;
}
export function resolveBottomAnchorTransportBehavior(input: {
strategy: StreamRenderStrategy;
isViewportSettling: boolean;
}): BottomAnchorTransportBehavior {
const baseBehavior = input.strategy.getBottomAnchorTransportBehavior();
if (!input.isViewportSettling || !input.strategy.getFlatListInverted()) {
return baseBehavior;
}
return {
verificationDelayFrames: Math.max(
baseBehavior.verificationDelayFrames,
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES
),
verificationRetryMode: "recheck",
};
}
function forceScrollContainerToBottom(
refs: StreamRenderRefs,
fallbackOffset: number
): void {
const scrollNode = resolveWebScrollContainerNode(refs);
if (!scrollNode) {
return;
}
@@ -267,6 +299,7 @@ function createStreamRenderStrategy(
};
},
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
getFlatListInverted: () => config.flatListInverted,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
@@ -289,6 +322,10 @@ function createInvertedStreamStrategy(): StreamRenderStrategy {
flatListInverted: true,
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: false,
animateManualScrollToBottom: true,
@@ -316,6 +353,10 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
flatListInverted: false,
overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
},
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: true,
animateManualScrollToBottom: false,
@@ -335,6 +376,14 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
0,
metrics.contentHeight - metrics.viewportHeight
);
if (refs.flatListRef.current) {
refs.flatListRef.current.scrollToEnd?.({ animated });
refs.flatListRef.current.scrollToOffset?.({
offset: bottomOffset,
animated,
});
return;
}
const usedAnchor = scrollAnchorIntoView({ refs, animated });
if (!usedAnchor) {
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
@@ -347,6 +396,10 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
forceScrollContainerToBottom(refs, bottomOffset);
},
scrollToOffset: ({ refs, offset, animated }) => {
if (refs.flatListRef.current) {
refs.flatListRef.current.scrollToOffset?.({ offset, animated });
return;
}
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
},
});
+424 -158
View File
@@ -21,9 +21,9 @@ import {
LayoutChangeEvent,
NativeScrollEvent,
NativeSyntheticEvent,
InteractionManager,
Platform,
ActivityIndicator,
Keyboard,
} from "react-native";
import Markdown from "react-native-markdown-display";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -75,9 +75,22 @@ import {
isNearBottomForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
resolveWebScrollContainerNode,
resolveStreamRenderStrategy,
type StreamEdgeSlotProps,
} from "./agent-stream-render-strategy";
import {
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./agent-stream-web-virtualization";
import {
useBottomAnchorController,
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
@@ -109,7 +122,8 @@ function renderStreamEdgeComponent(
}
export interface AgentStreamViewHandle {
scrollToBottom(): void;
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
}
export interface AgentStreamViewProps {
@@ -118,6 +132,8 @@ export interface AgentStreamViewProps {
agent: Agent;
streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
}
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(function AgentStreamView({
@@ -126,8 +142,10 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
agent,
streamItems,
pendingPermissions,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
}, ref) {
const flatListRef = useRef<FlatList<StreamItem>>(null);
const flatListRef = useRef<FlatList<any>>(null);
const scrollViewRef = useRef<ScrollView>(null);
const bottomAnchorRef = useRef<View>(null);
const { theme } = useUnistyles();
@@ -145,19 +163,23 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const insets = useSafeAreaInsets();
const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false);
const hasAutoScrolledOnce = useRef(false);
const isNearBottomRef = useRef(true);
const pendingAnchorRequestRef = useRef(false);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollAnimatedRef = useRef(false);
const scrollOffsetYRef = useRef(0);
const programmaticScrollEventBudgetRef = useRef(0);
const streamItemCountRef = useRef(0);
const previousStreamItemsRef = useRef<StreamItem[] | null>(null);
const streamViewportMetricsRef = useRef({
containerKey: "unknown",
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null as string | null,
contentMeasuredForKey: null as string | null,
});
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
@@ -165,6 +187,38 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
() => ({ flatListRef, scrollViewRef, bottomAnchorRef }),
[]
);
const clearNativeViewportSettling = useCallback(() => {
if (nativeViewportSettlingFrameIdRef.current !== null) {
cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current);
nativeViewportSettlingFrameIdRef.current = null;
}
}, []);
const markNativeViewportSettling = useCallback(() => {
if (Platform.OS === "web" || !streamRenderStrategy.getFlatListInverted()) {
return;
}
clearNativeViewportSettling();
setIsNativeViewportSettling(true);
let remainingFrames = 4;
const tick = () => {
if (remainingFrames <= 0) {
nativeViewportSettlingFrameIdRef.current = null;
setIsNativeViewportSettling(false);
return;
}
remainingFrames -= 1;
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
};
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
}, [clearNativeViewportSettling, streamRenderStrategy]);
const bottomAnchorTransportBehavior = useMemo(
() =>
resolveBottomAnchorTransportBehavior({
strategy: streamRenderStrategy,
isViewportSettling: isNativeViewportSettling,
}),
[isNativeViewportSettling, streamRenderStrategy]
);
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? agent.serverId ?? "";
@@ -194,12 +248,52 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
: FadeOut.duration(200);
useEffect(() => {
hasScrolledInitially.current = false;
hasAutoScrolledOnce.current = false;
isNearBottomRef.current = true;
pendingAnchorRequestRef.current = false;
scrollOffsetYRef.current = 0;
programmaticScrollEventBudgetRef.current = 0;
previousStreamItemsRef.current = null;
streamViewportMetricsRef.current = {
containerKey: "unknown",
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
};
setExpandedInlineToolCallIds(new Set());
}, [agentId]);
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
}, [agentId, clearNativeViewportSettling]);
useEffect(() => {
if (Platform.OS === "web" || !streamRenderStrategy.getFlatListInverted()) {
return;
}
const keyboardEvents = [
"keyboardWillShow",
"keyboardWillHide",
"keyboardDidShow",
"keyboardDidHide",
"keyboardWillChangeFrame",
"keyboardDidChangeFrame",
] as const;
const subscriptions = keyboardEvents.map((eventName) =>
Keyboard.addListener(eventName, () => {
markNativeViewportSettling();
})
);
return () => {
for (const subscription of subscriptions) {
subscription.remove();
}
clearNativeViewportSettling();
};
}, [
clearNativeViewportSettling,
markNativeViewportSettling,
streamRenderStrategy,
]);
const handleInlinePathPress = useCallback(
(target: InlinePathTarget) => {
@@ -252,23 +346,112 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
setIsNearBottom(value);
}, []);
const requestAnchorToBottom = useCallback(() => {
pendingAnchorRequestRef.current = true;
}, []);
const flatListData = useMemo(() => {
return orderTailForStreamRenderStrategy({
strategy: streamRenderStrategy,
streamItems,
});
}, [streamItems, streamRenderStrategy]);
const indexedFlatListData = useMemo<IndexedStreamItem[]>(
() => flatListData.map((item, index) => ({ item, index })),
[flatListData]
);
const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList();
const shouldUseWebPartialVirtualization =
Platform.OS === "web" &&
!isMobile &&
!usesVirtualizedList &&
indexedFlatListData.length > getWebPartialVirtualizationThreshold();
const currentContainerKey = usesVirtualizedList
? "native-virtualized"
: shouldUseWebPartialVirtualization
? "web-partial-virtualized"
: "scroll-view";
useEffect(() => {
const current = streamViewportMetricsRef.current;
if (current.containerKey === currentContainerKey) {
return;
}
streamViewportMetricsRef.current = {
containerKey: currentContainerKey,
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
}, [currentContainerKey]);
const scrollToBottomInternal = useCallback(
({ animated }: { animated: boolean }) => {
const targetOffset = streamRenderStrategy.getBottomOffset(
streamViewportMetricsRef.current
);
programmaticScrollEventBudgetRef.current = 3;
streamRenderStrategy.scrollToBottom({
refs: streamRenderRefs,
metrics: streamViewportMetricsRef.current,
animated,
});
scrollOffsetYRef.current = targetOffset;
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
offsetY: targetOffset,
};
updateNearBottom(true);
},
[updateNearBottom, streamRenderRefs, streamRenderStrategy]
);
const bottomAnchorController = useBottomAnchorController({
agentId,
routeRequest: routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
renderStrategy:
Platform.OS === "web" ? "forward-stream" : "inverted-stream",
transportBehavior: bottomAnchorTransportBehavior,
getMeasurementState: () => streamViewportMetricsRef.current,
isNearBottom: () => {
const metrics = streamViewportMetricsRef.current;
return isNearBottomForStreamRenderStrategy({
strategy: streamRenderStrategy,
offsetY: metrics.offsetY,
threshold: Math.max(insets.bottom, 32),
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
});
},
scrollToBottom: (animated) => {
scrollToBottomInternal({ animated });
},
});
useEffect(() => {
if (previousStreamItemsRef.current === null) {
previousStreamItemsRef.current = streamItems;
return;
}
previousStreamItemsRef.current = streamItems;
bottomAnchorController.prepareForStickyContentChange();
}, [bottomAnchorController, streamItems]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
scrollOffsetYRef.current = contentOffset.y;
streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height),
viewportWidth: Math.max(0, layoutMeasurement.width),
viewportHeight: Math.max(0, layoutMeasurement.height),
containerKey: currentContainerKey,
offsetY: contentOffset.y,
viewportMeasuredForKey: currentContainerKey,
contentMeasuredForKey: currentContainerKey,
};
const offsetDelta = contentOffset.y - previousOffsetY;
const contentHeightDelta =
streamViewportMetricsRef.current.contentHeight - previousContentHeight;
const threshold = Math.max(insets.bottom, 32);
const nearBottom = isNearBottomForStreamRenderStrategy({
strategy: streamRenderStrategy,
@@ -277,23 +460,24 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
updateNearBottom(nearBottom);
const expectedBottomOffset = streamRenderStrategy.getBottomOffset(
streamViewportMetricsRef.current
);
const isProgrammaticBottomEvent =
Math.abs(contentOffset.y - expectedBottomOffset) <= 8;
const pendingAnchorBefore = pendingAnchorRequestRef.current;
const shouldSuppressFalseNearBottom =
pendingAnchorBefore &&
!nearBottom &&
Math.abs(offsetDelta) <= 1 &&
contentHeightDelta > 0;
if (shouldSuppressFalseNearBottom) {
updateNearBottom(true);
if (
programmaticScrollEventBudgetRef.current > 0 &&
isProgrammaticBottomEvent
) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
updateNearBottom(nearBottom);
}
const shouldClearPendingAnchor =
pendingAnchorBefore && !nearBottom && Math.abs(offsetDelta) > 1;
if (shouldClearPendingAnchor) {
pendingAnchorRequestRef.current = false;
programmaticScrollEventBudgetRef.current = 0;
bottomAnchorController.handleScrollNearBottomChange({
nextIsNearBottom: nearBottom,
scrollDelta: contentOffset.y - previousOffsetY,
});
}
if (showDesktopWebScrollbar) {
@@ -305,162 +489,151 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
showDesktopWebScrollbar,
streamRenderStrategy,
streamScrollbarMetrics,
currentContainerKey,
bottomAnchorController,
updateNearBottom,
]
);
const handleListLayout = useCallback(
(event: LayoutChangeEvent) => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
const viewportChanged =
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
viewportHeight: Math.max(0, event.nativeEvent.layout.height),
containerKey: currentContainerKey,
viewportWidth,
viewportHeight,
viewportMeasuredForKey: currentContainerKey,
};
if (viewportChanged) {
markNativeViewportSettling();
}
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
previousViewportHeight,
viewportHeight,
});
if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onLayout(event);
}
},
[showDesktopWebScrollbar, streamScrollbarMetrics]
);
const scrollToBottomInternal = useCallback(
({ animated }: { animated: boolean }) => {
const targetOffset = streamRenderStrategy.getBottomOffset(
streamViewportMetricsRef.current
);
streamRenderStrategy.scrollToBottom({
refs: streamRenderRefs,
metrics: streamViewportMetricsRef.current,
animated,
});
scrollOffsetYRef.current = targetOffset;
updateNearBottom(true);
},
[updateNearBottom, streamRenderRefs, streamRenderStrategy]
[
showDesktopWebScrollbar,
streamScrollbarMetrics,
currentContainerKey,
markNativeViewportSettling,
bottomAnchorController,
]
);
useImperativeHandle(ref, () => ({
scrollToBottom() {
requestAnchorToBottom();
scrollToBottom(reason = "jump-to-bottom") {
bottomAnchorController.requestLocalAnchor({ agentId, reason });
},
}), [requestAnchorToBottom]);
prepareForViewportChange() {
bottomAnchorController.prepareForStickyViewportChange();
},
}), [agentId, bottomAnchorController]);
const handleContentSizeChange = useCallback(
(width: number, height: number) => {
const previousMetrics = streamViewportMetricsRef.current;
const threshold = Math.max(insets.bottom, 32);
const wasNearBottom = isNearBottomForStreamRenderStrategy({
strategy: streamRenderStrategy,
offsetY: scrollOffsetYRef.current,
threshold,
contentHeight: previousMetrics.contentHeight,
viewportHeight: previousMetrics.viewportHeight,
});
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
const nextContentHeight = Math.max(0, height);
streamViewportMetricsRef.current = {
...previousMetrics,
contentHeight: Math.max(0, height),
...streamViewportMetricsRef.current,
containerKey: currentContainerKey,
contentHeight: nextContentHeight,
contentMeasuredForKey: currentContainerKey,
};
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
if (!hasAutoScrolledOnce.current) {
scrollToBottomInternal({ animated: false });
hasAutoScrolledOnce.current = true;
hasScrolledInitially.current = true;
} else if (
wasNearBottom ||
isNearBottomRef.current ||
pendingAnchorRequestRef.current
) {
scrollToBottomInternal({ animated: false });
}
}
bottomAnchorController.handleContentSizeChange({
previousContentHeight,
contentHeight: nextContentHeight,
});
if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onContentSizeChange(width, height);
}
},
[
insets.bottom,
scrollToBottomInternal,
showDesktopWebScrollbar,
streamRenderStrategy,
streamScrollbarMetrics,
currentContainerKey,
bottomAnchorController,
]
);
const scheduleAutoScroll = useCallback(
({ animated }: { animated: boolean }) => {
pendingAutoScrollAnimatedRef.current =
pendingAutoScrollAnimatedRef.current || animated;
if (pendingAutoScrollFrameRef.current !== null) {
return;
}
pendingAutoScrollFrameRef.current = requestAnimationFrame(() => {
pendingAutoScrollFrameRef.current = null;
const shouldAnimate = pendingAutoScrollAnimatedRef.current;
pendingAutoScrollAnimatedRef.current = false;
scrollToBottomInternal({ animated: shouldAnimate });
});
},
[scrollToBottomInternal]
);
useEffect(() => {
return () => {
if (pendingAutoScrollFrameRef.current !== null) {
cancelAnimationFrame(pendingAutoScrollFrameRef.current);
pendingAutoScrollFrameRef.current = null;
}
pendingAutoScrollAnimatedRef.current = false;
if (
Platform.OS !== "web" ||
typeof ResizeObserver === "undefined"
) {
return;
}
const scrollNode = resolveWebScrollContainerNode(streamRenderRefs);
if (!scrollNode) {
return;
}
const applyObservedMetrics = () => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
const viewportWidth = Math.max(0, scrollNode.clientWidth);
const viewportHeight = Math.max(0, scrollNode.clientHeight);
const contentHeight = Math.max(0, scrollNode.scrollHeight);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: currentContainerKey,
viewportWidth,
viewportHeight,
contentHeight,
viewportMeasuredForKey: currentContainerKey,
contentMeasuredForKey: currentContainerKey,
};
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
previousViewportHeight,
viewportHeight,
});
bottomAnchorController.handleContentSizeChange({
previousContentHeight,
contentHeight,
});
};
}, []);
useEffect(() => {
if (streamItems.length === 0) {
return;
applyObservedMetrics();
const resizeObserver = new ResizeObserver(() => {
applyObservedMetrics();
});
resizeObserver.observe(scrollNode);
const contentNode = scrollNode.firstElementChild;
if (contentNode instanceof HTMLElement) {
resizeObserver.observe(contentNode);
}
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
// Forward streams anchor from measurement updates in handleContentSizeChange.
return;
}
if (!hasAutoScrolledOnce.current) {
const handle = InteractionManager.runAfterInteractions(() => {
scrollToBottomInternal({ animated: false });
hasAutoScrolledOnce.current = true;
hasScrolledInitially.current = true;
});
return () => handle.cancel();
}
if (!isNearBottomRef.current && !pendingAnchorRequestRef.current) {
return;
}
const shouldAnimate = hasScrolledInitially.current;
scheduleAutoScroll({ animated: shouldAnimate });
hasScrolledInitially.current = true;
}, [
scheduleAutoScroll,
scrollToBottomInternal,
streamItems,
streamRenderStrategy,
]);
return () => {
resizeObserver.disconnect();
};
}, [bottomAnchorController, currentContainerKey, streamRenderRefs]);
function scrollToBottom() {
const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom();
scrollToBottomInternal({ animated });
}
const flatListData = useMemo(() => {
return orderTailForStreamRenderStrategy({
strategy: streamRenderStrategy,
streamItems,
bottomAnchorController.requestLocalAnchor({
agentId,
reason: "jump-to-bottom",
});
}, [streamItems, streamRenderStrategy]);
}
const orderedStreamHead = useMemo(() => {
return orderHeadForStreamRenderStrategy({
@@ -790,7 +963,35 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
const showWorkingIndicator = agent.status === "running";
const showBottomBar = showWorkingIndicator;
const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList();
const webVirtualizedHistoryWindow = useMemo(() => {
if (!shouldUseWebPartialVirtualization) {
return null;
}
return splitWebVirtualizedHistory({
entries: indexedFlatListData,
minMountedCount: getWebMountedRecentStreamItems(),
});
}, [indexedFlatListData, shouldUseWebPartialVirtualization]);
const renderIndexedStreamItem = useCallback(
(entry: IndexedStreamItem) =>
renderStreamItem({
item: entry.item,
index: entry.index,
separators: NOOP_SEPARATORS,
}),
[renderStreamItem]
);
const renderWebVirtualizedStreamItem = useCallback(
({ item }: ListRenderItemInfo<IndexedStreamItem>) => {
const rendered = renderIndexedStreamItem(item);
if (!rendered) {
return null;
}
return <Fragment key={item.item.id}>{rendered}</Fragment>;
},
[renderIndexedStreamItem]
);
const listEdgeSlotComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
@@ -929,22 +1130,59 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
listEdgeSlotProps.ListFooterComponent
);
const nonVirtualizedItems = useMemo(() => {
if (flatListData.length === 0) {
if (indexedFlatListData.length === 0) {
return null;
}
return flatListData.map((item, index) => {
const rendered = renderStreamItem({
item,
index,
separators: NOOP_SEPARATORS,
});
return indexedFlatListData.map((entry) => {
const rendered = renderIndexedStreamItem(entry);
if (!rendered) {
return null;
}
return <Fragment key={item.id}>{rendered}</Fragment>;
return <Fragment key={entry.item.id}>{rendered}</Fragment>;
});
}, [flatListData, renderStreamItem]);
}, [indexedFlatListData, renderIndexedStreamItem]);
const webVirtualizedListHeader = useMemo(() => {
if (!shouldUseWebPartialVirtualization || !headerEdgeContent) {
return null;
}
return (
<View style={listEdgeSlotProps.ListHeaderComponentStyle}>
{headerEdgeContent}
</View>
);
}, [
headerEdgeContent,
listEdgeSlotProps.ListHeaderComponentStyle,
shouldUseWebPartialVirtualization,
]);
const webVirtualizedListFooter = useMemo(() => {
if (!shouldUseWebPartialVirtualization || !webVirtualizedHistoryWindow) {
return null;
}
return (
<>
{webVirtualizedHistoryWindow.mountedEntries.map((entry) => (
<Fragment key={entry.item.id}>
{renderIndexedStreamItem(entry)}
</Fragment>
))}
{footerEdgeContent ? (
<View style={listEdgeSlotProps.ListFooterComponentStyle}>
{footerEdgeContent}
</View>
) : null}
<View ref={bottomAnchorRef} collapsable={false} />
</>
);
}, [
footerEdgeContent,
listEdgeSlotProps.ListFooterComponentStyle,
renderIndexedStreamItem,
shouldUseWebPartialVirtualization,
webVirtualizedHistoryWindow,
]);
return (
<ToolCallSheetProvider>
@@ -957,6 +1195,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
renderItem={renderStreamItem}
keyExtractor={(item) => item.id}
testID="agent-chat-scroll"
nativeID={`agent-chat-scroll-${currentContainerKey}`}
{...listEdgeSlotProps}
contentContainerStyle={listContentContainerStyle}
style={stylesheet.list}
@@ -975,10 +1214,34 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
inverted={streamRenderStrategy.getFlatListInverted()}
/>
) : shouldUseWebPartialVirtualization && webVirtualizedHistoryWindow ? (
<FlatList
ref={flatListRef}
data={webVirtualizedHistoryWindow.virtualizedEntries}
renderItem={renderWebVirtualizedStreamItem}
keyExtractor={(entry) => entry.item.id}
testID="agent-chat-scroll"
nativeID={`agent-chat-scroll-${currentContainerKey}`}
ListHeaderComponent={webVirtualizedListHeader}
ListFooterComponent={webVirtualizedListFooter}
contentContainerStyle={listContentContainerStyle}
style={stylesheet.list}
onLayout={handleListLayout}
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
extraData={flatListExtraData}
initialNumToRender={12}
windowSize={10}
scrollEnabled={streamScrollEnabled}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
inverted={false}
/>
) : (
<ScrollView
ref={scrollViewRef}
testID="agent-chat-scroll"
nativeID={`agent-chat-scroll-${currentContainerKey}`}
contentContainerStyle={listContentContainerStyle}
style={stylesheet.list}
onLayout={handleListLayout}
@@ -1028,6 +1291,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
accessibilityRole="button"
accessibilityLabel="Scroll to bottom"
testID="scroll-to-bottom-button"
>
<ChevronDown
size={24}
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import {
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
estimateStreamItemHeight,
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./agent-stream-web-virtualization";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): StreamItem {
return {
kind: "user_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
function assistantMessage(id: string, seed: number): StreamItem {
return {
kind: "assistant_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
function toolCall(id: string, seed: number): StreamItem {
return {
kind: "tool_call",
id,
timestamp: createTimestamp(seed),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "test_tool",
arguments: {},
status: "completed",
},
},
};
}
function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
return items.map((item, index) => ({ item, index }));
}
describe("findMountedWindowStart", () => {
it("keeps all items mounted when the chat is below the threshold", () => {
const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
expect(
findMountedWindowStart({
items,
minMountedCount: 50,
})
).toBe(0);
});
it("rewinds to the previous user boundary when the cutoff lands inside a turn", () => {
const items: StreamItem[] = [];
for (let index = 0; index < 30; index += 1) {
const seed = index * 3;
items.push(userMessage(`u${index}`, seed + 1));
items.push(toolCall(`t${index}`, seed + 2));
items.push(assistantMessage(`a${index}`, seed + 3));
}
expect(
findMountedWindowStart({
items,
minMountedCount: 50,
})
).toBe(39);
});
});
describe("splitWebVirtualizedHistory", () => {
it("splits older entries into the virtualized section and keeps the recent window mounted", () => {
const items: StreamItem[] = [];
for (let index = 0; index < 30; index += 1) {
const seed = index * 2;
items.push(userMessage(`u${index}`, seed + 1));
items.push(assistantMessage(`a${index}`, seed + 2));
}
const window = splitWebVirtualizedHistory({
entries: indexEntries(items),
minMountedCount: 50,
});
expect(window.virtualizedEntries).toHaveLength(10);
expect(window.virtualizedEntries[0]?.item.id).toBe("u0");
expect(window.virtualizedEntries.at(-1)?.item.id).toBe("a4");
expect(window.mountedEntries[0]?.item.id).toBe("u5");
expect(window.mountedEntries).toHaveLength(50);
});
});
describe("estimateStreamItemHeight", () => {
it("uses a larger estimate for user messages with image attachments", () => {
const item: StreamItem = {
kind: "user_message",
id: "u-image",
text: "image",
timestamp: createTimestamp(1),
images: [
{
id: "att-1",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/tmp/screenshot.png",
fileName: "screenshot.png",
byteSize: 1024,
createdAt: Date.now(),
},
],
};
expect(estimateStreamItemHeight(item)).toBe(220);
});
});
describe("web virtualization test overrides", () => {
it("uses defaults unless explicit positive integer overrides are present", () => {
const globalWithOverrides = globalThis as typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
const previousThreshold =
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
const previousMounted =
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
try {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
expect(getWebPartialVirtualizationThreshold()).toBe(
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
);
expect(getWebMountedRecentStreamItems()).toBe(
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS
);
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
expect(getWebPartialVirtualizationThreshold()).toBe(6);
expect(getWebMountedRecentStreamItems()).toBe(4);
} finally {
if (previousThreshold === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
} else {
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD =
previousThreshold;
}
if (previousMounted === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
} else {
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS =
previousMounted;
}
}
});
});
@@ -0,0 +1,94 @@
import type { StreamItem } from "@/types/stream";
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
function readPositiveIntegerOverride(value: unknown): number | null {
if (!Number.isFinite(value)) {
return null;
}
const normalized = Math.trunc(value as number);
return normalized > 0 ? normalized : null;
}
export function getWebPartialVirtualizationThreshold(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
);
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
}
export function getWebMountedRecentStreamItems(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS
);
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
}
export type IndexedStreamItem = {
item: StreamItem;
index: number;
};
export type WebVirtualizedHistoryWindow = {
virtualizedEntries: IndexedStreamItem[];
mountedEntries: IndexedStreamItem[];
};
export function estimateStreamItemHeight(item: StreamItem): number {
switch (item.kind) {
case "user_message":
return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message":
return 220;
case "tool_call":
return 136;
case "thought":
return 112;
case "todo_list":
return 144;
case "activity_log":
return 88;
case "compaction":
return 72;
default:
return 120;
}
}
export function findMountedWindowStart(input: {
items: StreamItem[];
minMountedCount: number;
}): number {
const { items, minMountedCount } = input;
if (items.length <= minMountedCount) {
return 0;
}
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
startIndex -= 1;
}
return startIndex;
}
export function splitWebVirtualizedHistory(input: {
entries: IndexedStreamItem[];
minMountedCount: number;
}): WebVirtualizedHistoryWindow {
const startIndex = findMountedWindowStart({
items: input.entries.map((entry) => entry.item),
minMountedCount: input.minMountedCount,
});
return {
virtualizedEntries: input.entries.slice(0, startIndex),
mountedEntries: input.entries.slice(startIndex),
};
}
@@ -84,6 +84,7 @@ export interface MessageInputProps {
/** Reports cursor selection updates from the underlying input. */
onSelectionChange?: (selection: { start: number; end: number }) => void
onFocusChange?: (focused: boolean) => void
onHeightChange?: (height: number) => void
}
export interface MessageInputRef {
@@ -153,6 +154,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onKeyPress: onKeyPressCallback,
onSelectionChange: onSelectionChangeCallback,
onFocusChange,
onHeightChange,
},
ref
) {
@@ -473,7 +475,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSubmit(payload)
inputHeightRef.current = MIN_INPUT_HEIGHT
setInputHeight(MIN_INPUT_HEIGHT)
}, [value, images, onSubmit, isAgentRunning])
onHeightChange?.(MIN_INPUT_HEIGHT)
}, [value, images, onSubmit, isAgentRunning, onHeightChange])
const handleQueueMessage = useCallback(() => {
if (!onQueue) return
@@ -487,7 +490,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onChangeText('')
inputHeightRef.current = MIN_INPUT_HEIGHT
setInputHeight(MIN_INPUT_HEIGHT)
}, [value, images, onQueue, onChangeText])
onHeightChange?.(MIN_INPUT_HEIGHT)
}, [value, images, onQueue, onChangeText, onHeightChange])
// Web input height measurement
function isTextAreaLike(v: unknown): v is TextAreaHandle {
@@ -587,6 +591,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (Math.abs(inputHeightRef.current - bounded) >= 1) {
inputHeightRef.current = bounded
setInputHeight(bounded)
onHeightChange?.(bounded)
return true
}
return false
@@ -597,6 +602,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (Math.abs(inputHeightRef.current - bounded) < 1) return
inputHeightRef.current = bounded
setInputHeight(bounded)
onHeightChange?.(bounded)
}
function handleContentSizeChange(
@@ -0,0 +1,578 @@
import { describe, expect, it, vi } from "vitest";
import {
__private__,
deriveBottomAnchorBlockedReason,
type BottomAnchorMode,
} from "./use-bottom-anchor-controller";
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
type MeasurementState = ReturnType<typeof createMeasurementState>;
function createMeasurementState(
overrides?: Partial<{
containerKey: string;
viewportWidth: number;
viewportHeight: number;
contentHeight: number;
offsetY: number;
viewportMeasuredForKey: string | null;
contentMeasuredForKey: string | null;
}>
) {
return {
containerKey: "scroll-view",
viewportWidth: 0,
viewportHeight: 0,
contentHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
...overrides,
};
}
function createPendingRequest() {
return {
id: 1,
agentId: "agent-1",
reason: "initial-entry" as const,
requestKey: "route:agent-1",
};
}
function createFrameScheduler() {
let sequence = 0;
const tasks = new Map<
number,
{
cancelled: boolean;
remainingFrames: number;
callback: () => void;
kind: "attempt" | "verification";
}
>();
return {
schedule(params: {
kind: "attempt" | "verification";
callback: () => void;
delayFrames?: number;
}) {
const id = ++sequence;
tasks.set(id, {
cancelled: false,
remainingFrames: Math.max(0, params.delayFrames ?? 0),
callback: params.callback,
kind: params.kind,
});
return id;
},
cancel(handle: unknown) {
const task = tasks.get(handle as number);
if (task) {
task.cancelled = true;
}
},
flushFrame() {
const due: Array<() => void> = [];
for (const [id, task] of Array.from(tasks.entries())) {
if (task.cancelled) {
tasks.delete(id);
continue;
}
if (task.remainingFrames > 0) {
task.remainingFrames -= 1;
continue;
}
tasks.delete(id);
due.push(task.callback);
}
for (const callback of due) {
callback();
}
},
flushAll(limit = 20) {
for (let index = 0; index < limit && tasks.size > 0; index += 1) {
this.flushFrame();
}
},
};
}
function createDriverHarness(input?: {
transportBehavior?: BottomAnchorTransportBehavior;
isNearBottom?: boolean;
measurementState?: MeasurementState;
authoritativeReady?: boolean;
}) {
const scheduler = createFrameScheduler();
const measurementState =
input?.measurementState ??
createMeasurementState({
viewportWidth: 800,
viewportHeight: 480,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
});
const context = {
agentId: "agent-1",
authoritativeReady: input?.authoritativeReady ?? true,
renderStrategy: "forward-stream",
transportBehavior:
input?.transportBehavior ?? {
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
},
measurementState,
nearBottom: input?.isNearBottom ?? true,
};
const scrollToBottom = vi.fn(() => {
context.nearBottom = true;
context.measurementState.offsetY = 720;
});
const modeChanges: BottomAnchorMode[] = [];
const warnings: Array<{ agentId: string; reason: string }> = [];
const driver = __private__.createBottomAnchorControllerDriver({
getAgentId: () => context.agentId,
getIsAuthoritativeHistoryReady: () => context.authoritativeReady,
getRenderStrategy: () => context.renderStrategy,
getTransportBehavior: () => context.transportBehavior,
getMeasurementState: () => context.measurementState,
isNearBottom: () => context.nearBottom,
scrollToBottom,
onModeChange: (mode) => {
modeChanges.push(mode);
},
log: () => {},
warn: (details) => warnings.push(details),
scheduleFrame: (params) => scheduler.schedule(params),
cancelFrame: (handle) => scheduler.cancel(handle),
});
return {
context,
driver,
scheduler,
scrollToBottom,
modeChanges,
warnings,
};
}
describe("deriveBottomAnchorBlockedReason", () => {
it("keeps initial-entry pending until history is ready and geometry is measurable", () => {
const pendingRequest = createPendingRequest();
expect(
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: false,
measurementState: createMeasurementState(),
pendingVerificationRequestId: null,
})
).toBe("waiting_for_history_readiness");
expect(
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: true,
measurementState: createMeasurementState({
viewportHeight: 480,
viewportMeasuredForKey: "scroll-view",
}),
pendingVerificationRequestId: null,
})
).toBe("waiting_for_measurable_content");
expect(
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: true,
measurementState: createMeasurementState({
viewportHeight: 480,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
pendingVerificationRequestId: pendingRequest.id,
})
).toBe("waiting_for_post_layout_verification");
});
});
describe("bottom anchor controller driver", () => {
it("keeps initial-entry pending until authoritative history and current geometry exist", () => {
const harness = createDriverHarness({
authoritativeReady: false,
measurementState: createMeasurementState(),
});
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "initial-entry",
requestKey: "route:agent-1:initial-entry",
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: "waiting_for_history_readiness",
pendingRequest: {
reason: "initial-entry",
},
});
harness.context.authoritativeReady = true;
harness.context.measurementState.viewportHeight = 480;
harness.context.measurementState.contentHeight = 1200;
harness.context.measurementState.viewportMeasuredForKey = "scroll-view";
harness.context.measurementState.contentMeasuredForKey = "scroll-view";
harness.context.nearBottom = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("suppresses sticky maintenance while detached", () => {
const harness = createDriverHarness();
harness.driver.detachByUser();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1500,
});
harness.driver.handleViewportMetricsChange({
previousViewportWidth: 800,
viewportWidth: 640,
previousViewportHeight: 480,
viewportHeight: 420,
});
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollToBottom).not.toHaveBeenCalled();
});
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
const harness = createDriverHarness({
isNearBottom: false,
});
harness.driver.detachByUser();
harness.driver.requestLocalAnchor({
agentId: "agent-1",
reason: "jump-to-bottom",
});
harness.scheduler.flushAll();
expect(harness.modeChanges).toContain("detached");
expect(harness.modeChanges).toContain("sticky-bottom");
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
it("schedules sticky maintenance on viewport and content growth", () => {
const harness = createDriverHarness();
harness.driver.handleViewportMetricsChange({
previousViewportWidth: 800,
viewportWidth: 640,
previousViewportHeight: 480,
viewportHeight: 420,
});
harness.scheduler.flushAll();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1600,
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
});
it("keeps a pending request blocked when stale container measurements arrive", () => {
const harness = createDriverHarness({
measurementState: createMeasurementState({
containerKey: "web-partial-virtualized",
viewportHeight: 420,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
});
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_measurable_viewport",
pendingRequest: {
reason: "resume",
},
});
harness.context.measurementState.viewportMeasuredForKey = "web-partial-virtualized";
harness.context.measurementState.contentMeasuredForKey = "web-partial-virtualized";
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
it("uses delayed rechecks instead of repeated rescroll loops for native transport", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
isNearBottom: false,
});
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
});
harness.driver.requestLocalAnchor({
agentId: "agent-1",
reason: "jump-to-bottom",
});
harness.scheduler.flushFrame();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.nearBottom = true;
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.warnings).toEqual([]);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 720;
});
harness.driver.handleViewportMetricsChange({
previousViewportWidth: 800,
viewportWidth: 800,
previousViewportHeight: 480,
viewportHeight: 420,
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 0,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: 0,
});
harness.scheduler.flushAll();
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 64,
});
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 900;
});
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 0,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: 0,
});
harness.scheduler.flushAll();
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 64,
});
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
});
describe("controller helper predicates", () => {
it("rejects stale container measurements during post-scroll verification", () => {
expect(
__private__.deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: true,
measurementState: createMeasurementState({
containerKey: "web-partial-virtualized",
viewportHeight: 420,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
})
).toBe("waiting_for_measurable_viewport");
});
it("allows verification only after authoritative readiness and current geometry exist", () => {
expect(
__private__.deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: false,
measurementState: createMeasurementState({
containerKey: "scroll-view",
viewportHeight: 420,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
})
).toBe("waiting_for_history_readiness");
expect(
__private__.deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: true,
measurementState: createMeasurementState({
containerKey: "scroll-view",
viewportHeight: 420,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
})
).toBeNull();
});
it("suppresses auto-anchor helpers while detached", () => {
const mode: BottomAnchorMode = "detached";
expect(
__private__.shouldRestickOnContentChange({
mode,
previousContentHeight: 1000,
contentHeight: 1100,
})
).toBe(false);
expect(
__private__.shouldRestickOnViewportChange({
mode,
previousViewportWidth: 800,
viewportWidth: 640,
previousViewportHeight: 400,
viewportHeight: 360,
})
).toBe(false);
});
it("does not detach from sticky while a restick request is still pending", () => {
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 0,
hasPendingRequest: true,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: false,
})
).toBe(false);
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 0,
hasPendingRequest: false,
hasPendingVerification: true,
hasUnverifiedStickyMeasurementChange: false,
})
).toBe(false);
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 0,
hasPendingRequest: false,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: true,
})
).toBe(false);
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 0,
hasPendingRequest: false,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: false,
})
).toBe(true);
});
it("treats a large scroll delta as user detach even during an unverified sticky change", () => {
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 48,
hasPendingRequest: false,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: true,
})
).toBe(true);
});
});
@@ -0,0 +1,817 @@
import { useEffect, useRef, useState } from "react";
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
export type BottomAnchorMode = "sticky-bottom" | "detached";
export type BottomAnchorRouteRequest = {
reason: "initial-entry" | "resume";
agentId: string;
requestKey: string;
};
export type BottomAnchorLocalRequest = {
reason: "jump-to-bottom" | "message-sent";
agentId: string;
};
export type BottomAnchorBlockedReason =
| "waiting_for_history_readiness"
| "waiting_for_measurable_viewport"
| "waiting_for_measurable_content"
| "waiting_for_post_layout_verification";
type BottomAnchorRequestReason =
| BottomAnchorRouteRequest["reason"]
| BottomAnchorLocalRequest["reason"];
type BottomAnchorRequest = {
id: number;
agentId: string;
reason: BottomAnchorRequestReason;
requestKey: string;
};
type ControllerMeasurementState = {
containerKey: string;
viewportWidth: number;
viewportHeight: number;
contentHeight: number;
offsetY: number;
viewportMeasuredForKey: string | null;
contentMeasuredForKey: string | null;
};
type AttemptContext = {
requestId: number | null;
retries: number;
};
type ScheduledFrameHandle = {
cancelled: boolean;
rafId: number | null;
remainingFrames: number;
callback: () => void;
};
type BottomAnchorEvent =
| "request_created"
| "attempt_started"
| "attempt_verified"
| "attempt_failed"
| "request_fulfilled"
| "request_cancelled"
| "detached_by_user"
| "blocked_reason_changed";
type BottomAnchorControllerDriver = {
destroy: () => void;
getSnapshot: () => {
mode: BottomAnchorMode;
pendingRequest: BottomAnchorRequest | null;
pendingVerification: AttemptContext | null;
blockedReason: BottomAnchorBlockedReason | null;
};
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
detachByUser: () => void;
handleViewportMetricsChange: (params: {
previousViewportWidth: number;
viewportWidth: number;
previousViewportHeight: number;
viewportHeight: number;
}) => void;
handleContentSizeChange: (params: {
previousContentHeight: number;
contentHeight: number;
}) => void;
prepareForStickyViewportChange: () => void;
prepareForStickyContentChange: () => void;
handleScrollNearBottomChange: (params: {
nextIsNearBottom: boolean;
scrollDelta: number;
}) => void;
notifyAuthoritativeHistoryMaybeChanged: () => void;
reevaluate: (animated?: boolean) => void;
};
type CreateBottomAnchorControllerDriverInput = {
getAgentId: () => string;
getIsAuthoritativeHistoryReady: () => boolean;
getRenderStrategy: () => string;
getTransportBehavior: () => BottomAnchorTransportBehavior;
getMeasurementState: () => ControllerMeasurementState;
isNearBottom: () => boolean;
scrollToBottom: (animated: boolean) => void;
onModeChange: (mode: BottomAnchorMode) => void;
log: (event: BottomAnchorEvent, details: Record<string, unknown>) => void;
warn: (details: { agentId: string; reason: BottomAnchorRequestReason }) => void;
scheduleFrame: (params: {
kind: "attempt" | "verification";
callback: () => void;
delayFrames?: number;
}) => unknown;
cancelFrame: (handle: unknown) => void;
};
const MAX_VERIFICATION_RETRIES = 3;
const USER_SCROLL_AWAY_DELTA_PX = 24;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logBottomAnchorEvent(
event: BottomAnchorEvent,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.debug("[BottomAnchor]", event, details);
}
function scheduleAnimationFrameWithDelay(input: {
callback: () => void;
delayFrames?: number;
}): ScheduledFrameHandle {
const handle: ScheduledFrameHandle = {
cancelled: false,
rafId: null,
remainingFrames: Math.max(0, input.delayFrames ?? 0),
callback: input.callback,
};
const tick = () => {
if (handle.cancelled) {
return;
}
if (handle.remainingFrames > 0) {
handle.remainingFrames -= 1;
handle.rafId = requestAnimationFrame(tick);
return;
}
handle.rafId = null;
input.callback();
};
handle.rafId = requestAnimationFrame(tick);
return handle;
}
function cancelScheduledAnimationFrame(handle: unknown): void {
const scheduled = handle as ScheduledFrameHandle | null;
if (!scheduled) {
return;
}
scheduled.cancelled = true;
if (scheduled.rafId !== null) {
cancelAnimationFrame(scheduled.rafId);
scheduled.rafId = null;
}
}
function deriveVerificationBlockedReason(input: {
isAuthoritativeHistoryReady: boolean;
measurementState: ControllerMeasurementState;
}): Exclude<BottomAnchorBlockedReason, "waiting_for_post_layout_verification"> | null {
if (!input.isAuthoritativeHistoryReady) {
return "waiting_for_history_readiness";
}
if (
input.measurementState.viewportHeight <= 0 ||
input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey
) {
return "waiting_for_measurable_viewport";
}
if (
input.measurementState.contentHeight <= 0 ||
input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey
) {
return "waiting_for_measurable_content";
}
return null;
}
export function deriveBottomAnchorBlockedReason(input: {
pendingRequest: BottomAnchorRequest | null;
isAuthoritativeHistoryReady: boolean;
measurementState: ControllerMeasurementState;
pendingVerificationRequestId: number | null;
}): BottomAnchorBlockedReason | null {
if (!input.pendingRequest) {
return null;
}
if (!input.isAuthoritativeHistoryReady) {
return "waiting_for_history_readiness";
}
if (
input.measurementState.viewportHeight <= 0 ||
input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey
) {
return "waiting_for_measurable_viewport";
}
if (
input.measurementState.contentHeight <= 0 ||
input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey
) {
return "waiting_for_measurable_content";
}
if (input.pendingVerificationRequestId === input.pendingRequest.id) {
return "waiting_for_post_layout_verification";
}
return null;
}
function deriveRetryDisposition(input: {
mode: BottomAnchorMode;
retries: number;
verificationRetryMode: BottomAnchorTransportBehavior["verificationRetryMode"];
}): "retry-scroll" | "retry-verify" | "fail" {
if (input.mode !== "sticky-bottom" || input.retries >= MAX_VERIFICATION_RETRIES) {
return "fail";
}
return input.verificationRetryMode === "recheck"
? "retry-verify"
: "retry-scroll";
}
function createBottomAnchorControllerDriver(
input: CreateBottomAnchorControllerDriverInput
): BottomAnchorControllerDriver {
let requestSequence = 0;
let mode: BottomAnchorMode = "sticky-bottom";
let pendingRequest: BottomAnchorRequest | null = null;
let pendingVerification: AttemptContext | null = null;
let blockedReason: BottomAnchorBlockedReason | null = null;
let attemptHandle: unknown = null;
let verificationHandle: unknown = null;
let lastRouteRequestKey: string | null = null;
let stickyMeasurementRevision = 0;
let lastVerifiedStickyMeasurementRevision = 0;
const getLogContext = (extra?: Record<string, unknown>) => {
const measurementState = input.getMeasurementState();
return {
agentId: input.getAgentId(),
requestReason: pendingRequest?.reason ?? null,
authoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
contentHeight: measurementState.contentHeight,
viewportHeight: measurementState.viewportHeight,
offset: measurementState.offsetY,
renderStrategy: input.getRenderStrategy(),
blockedReason,
mode,
containerKey: measurementState.containerKey,
...extra,
};
};
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
if (blockedReason === nextBlockedReason) {
return;
}
blockedReason = nextBlockedReason;
input.log(
"blocked_reason_changed",
getLogContext({ nextBlockedReason })
);
};
const setModeInternal = (nextMode: BottomAnchorMode) => {
if (mode === nextMode) {
return;
}
mode = nextMode;
input.onModeChange(nextMode);
if (nextMode === "detached") {
lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision;
}
};
const markStickyMeasurementChanged = () => {
stickyMeasurementRevision += 1;
};
const markStickyMeasurementVerified = () => {
lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision;
};
const cancelPendingAttempt = () => {
if (attemptHandle) {
input.cancelFrame(attemptHandle);
attemptHandle = null;
}
if (verificationHandle) {
input.cancelFrame(verificationHandle);
verificationHandle = null;
}
pendingVerification = null;
};
const cancelPendingRequest = (reason: string) => {
const currentRequest = pendingRequest;
if (!currentRequest) {
cancelPendingAttempt();
setBlockedReason(null);
return;
}
input.log(
"request_cancelled",
getLogContext({
cancelledRequestReason: currentRequest.reason,
cancelReason: reason,
})
);
pendingRequest = null;
cancelPendingAttempt();
setBlockedReason(null);
};
const scheduleVerification = (attemptContext: AttemptContext) => {
if (verificationHandle) {
input.cancelFrame(verificationHandle);
}
verificationHandle = input.scheduleFrame({
kind: "verification",
delayFrames: input.getTransportBehavior().verificationDelayFrames,
callback: () => {
verificationHandle = null;
const currentRequest = pendingRequest;
const isRequestAttempt =
currentRequest && attemptContext.requestId === currentRequest.id;
const measurementState = input.getMeasurementState();
const verificationBlockedReason = deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
measurementState,
});
if (verificationBlockedReason) {
pendingVerification = attemptContext;
setBlockedReason(verificationBlockedReason);
return;
}
const verifiedNearBottom = input.isNearBottom();
input.log(
"attempt_verified",
getLogContext({
verifiedNearBottom,
retries: attemptContext.retries,
})
);
if (verifiedNearBottom) {
pendingVerification = null;
markStickyMeasurementVerified();
if (isRequestAttempt) {
input.log("request_fulfilled", getLogContext());
pendingRequest = null;
}
setBlockedReason(null);
return;
}
const retryDisposition = deriveRetryDisposition({
mode,
retries: attemptContext.retries,
verificationRetryMode: input.getTransportBehavior().verificationRetryMode,
});
if (retryDisposition === "retry-verify") {
pendingVerification = {
requestId: attemptContext.requestId,
retries: attemptContext.retries + 1,
};
setBlockedReason("waiting_for_post_layout_verification");
scheduleVerification(pendingVerification);
return;
}
if (retryDisposition === "retry-scroll") {
pendingVerification = {
requestId: attemptContext.requestId,
retries: attemptContext.retries + 1,
};
evaluate(false);
return;
}
input.log(
"attempt_failed",
getLogContext({ retries: attemptContext.retries })
);
pendingVerification = null;
if (isRequestAttempt && currentRequest) {
input.warn({
agentId: input.getAgentId(),
reason: currentRequest.reason,
});
}
setBlockedReason(
isRequestAttempt ? "waiting_for_post_layout_verification" : null
);
},
});
};
const runAttempt = (animated: boolean) => {
const attemptContext: AttemptContext = {
requestId: pendingRequest?.id ?? null,
retries: pendingVerification?.retries ?? 0,
};
pendingVerification = attemptContext;
input.log(
"attempt_started",
getLogContext({ animated, retries: attemptContext.retries })
);
input.scrollToBottom(animated);
scheduleVerification(attemptContext);
setBlockedReason(
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
measurementState: input.getMeasurementState(),
pendingVerificationRequestId: attemptContext.requestId,
})
);
};
const evaluate = (animated: boolean) => {
if (attemptHandle) {
return;
}
attemptHandle = input.scheduleFrame({
kind: "attempt",
callback: () => {
attemptHandle = null;
const nextBlockedReason = deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
measurementState: input.getMeasurementState(),
pendingVerificationRequestId: pendingVerification?.requestId ?? null,
});
setBlockedReason(nextBlockedReason);
const shouldAttemptForPendingRequest =
pendingRequest !== null && nextBlockedReason === null;
const shouldAttemptForStickyVerification =
mode === "sticky-bottom" &&
pendingVerification !== null &&
nextBlockedReason === null;
if (
!shouldAttemptForPendingRequest &&
!shouldAttemptForStickyVerification
) {
return;
}
runAttempt(animated);
},
});
};
const createRequest = (request: BottomAnchorRouteRequest | BottomAnchorLocalRequest) => {
const existing = pendingRequest;
if (existing) {
input.log(
"request_cancelled",
getLogContext({
cancelledRequestReason: existing.reason,
cancelReason: "replaced_by_new_request",
})
);
}
cancelPendingAttempt();
const nextRequest: BottomAnchorRequest = {
id: requestSequence + 1,
agentId: request.agentId,
reason: request.reason,
requestKey:
"requestKey" in request
? request.requestKey
: `${request.agentId}:${request.reason}:${requestSequence + 1}`,
};
requestSequence = nextRequest.id;
pendingRequest = nextRequest;
pendingVerification = null;
setModeInternal(
"requestKey" in request
? "sticky-bottom"
: __private__.deriveModeForLocalRequest({ reason: request.reason })
);
input.log(
"request_created",
getLogContext({ requestReason: request.reason })
);
evaluate(request.reason === "jump-to-bottom");
};
return {
destroy() {
cancelPendingAttempt();
},
getSnapshot() {
return {
mode,
pendingRequest,
pendingVerification,
blockedReason,
};
},
resetForAgent() {
lastRouteRequestKey = null;
pendingRequest = null;
blockedReason = null;
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
applyRouteRequest(request) {
if (!request) {
return;
}
if (lastRouteRequestKey === request.requestKey) {
return;
}
lastRouteRequestKey = request.requestKey;
createRequest(request);
},
requestLocalAnchor(request) {
createRequest(request);
},
detachByUser() {
if (mode === "detached") {
return;
}
cancelPendingRequest("user_scrolled_away");
setModeInternal("detached");
input.log("detached_by_user", getLogContext());
},
handleViewportMetricsChange(params) {
if (
params.previousViewportWidth !== params.viewportWidth ||
params.previousViewportHeight !== params.viewportHeight
) {
markStickyMeasurementChanged();
}
const shouldRestick = __private__.shouldRestickOnViewportChange({
mode,
previousViewportWidth: params.previousViewportWidth,
viewportWidth: params.viewportWidth,
previousViewportHeight: params.previousViewportHeight,
viewportHeight: params.viewportHeight,
});
if (shouldRestick && !pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
}
if (shouldRestick || pendingRequest) {
evaluate(false);
}
},
handleContentSizeChange(params) {
if (params.previousContentHeight !== params.contentHeight) {
markStickyMeasurementChanged();
}
const shouldRestick = __private__.shouldRestickOnContentChange({
mode,
previousContentHeight: params.previousContentHeight,
contentHeight: params.contentHeight,
});
if (shouldRestick && !pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
}
if (shouldRestick || pendingRequest) {
evaluate(false);
}
},
prepareForStickyViewportChange() {
if (mode !== "sticky-bottom") {
return;
}
markStickyMeasurementChanged();
},
prepareForStickyContentChange() {
if (mode !== "sticky-bottom") {
return;
}
markStickyMeasurementChanged();
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
if (
nextIsNearBottom &&
mode === "sticky-bottom" &&
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
) {
markStickyMeasurementVerified();
}
const hasUnverifiedStickyMeasurementChange =
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision;
if (
__private__.shouldDetachFromScrollAway({
mode,
nextIsNearBottom,
scrollDelta,
hasPendingRequest: pendingRequest !== null,
hasPendingVerification: pendingVerification !== null,
hasUnverifiedStickyMeasurementChange,
})
) {
this.detachByUser();
return;
}
if (
mode === "sticky-bottom" &&
!nextIsNearBottom &&
hasUnverifiedStickyMeasurementChange
) {
if (!pendingRequest && !pendingVerification) {
pendingVerification = { requestId: null, retries: 0 };
}
evaluate(false);
return;
}
if (nextIsNearBottom && pendingRequest) {
evaluate(false);
}
},
notifyAuthoritativeHistoryMaybeChanged() {
if (!pendingVerification && !pendingRequest) {
return;
}
evaluate(false);
},
reevaluate(animated = false) {
evaluate(animated);
},
};
}
export const __private__ = {
createBottomAnchorControllerDriver,
deriveBottomAnchorBlockedReason,
deriveVerificationBlockedReason,
deriveRetryDisposition,
deriveModeForLocalRequest(input: {
reason: BottomAnchorLocalRequest["reason"];
}): BottomAnchorMode {
return "sticky-bottom";
},
shouldRestickOnViewportChange(input: {
mode: BottomAnchorMode;
previousViewportWidth: number;
viewportWidth: number;
previousViewportHeight: number;
viewportHeight: number;
}): boolean {
return (
input.mode === "sticky-bottom" &&
((input.previousViewportHeight > 0 &&
input.viewportHeight > 0 &&
input.previousViewportHeight !== input.viewportHeight) ||
(input.previousViewportWidth > 0 &&
input.viewportWidth > 0 &&
input.previousViewportWidth !== input.viewportWidth))
);
},
shouldRestickOnContentChange(input: {
mode: BottomAnchorMode;
previousContentHeight: number;
contentHeight: number;
}): boolean {
return (
input.mode === "sticky-bottom" &&
input.previousContentHeight > 0 &&
input.contentHeight > input.previousContentHeight
);
},
shouldDetachFromScrollAway(input: {
mode: BottomAnchorMode;
nextIsNearBottom: boolean;
scrollDelta: number;
hasPendingRequest: boolean;
hasPendingVerification: boolean;
hasUnverifiedStickyMeasurementChange: boolean;
}): boolean {
const scrolledAwayIntentionally =
Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX;
return (
input.mode === "sticky-bottom" &&
!input.nextIsNearBottom &&
!input.hasPendingRequest &&
!input.hasPendingVerification &&
(!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally)
);
},
};
export function useBottomAnchorController(input: {
agentId: string;
routeRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
renderStrategy: string;
transportBehavior: BottomAnchorTransportBehavior;
getMeasurementState: () => ControllerMeasurementState;
isNearBottom: () => boolean;
scrollToBottom: (animated: boolean) => void;
}) {
const [mode, setMode] = useState<BottomAnchorMode>("sticky-bottom");
const agentIdRef = useRef(input.agentId);
const readinessRef = useRef(input.isAuthoritativeHistoryReady);
const renderStrategyRef = useRef(input.renderStrategy);
const transportBehaviorRef = useRef(input.transportBehavior);
const getMeasurementStateRef = useRef(input.getMeasurementState);
const isNearBottomRef = useRef(input.isNearBottom);
const scrollToBottomRef = useRef(input.scrollToBottom);
const driverRef = useRef<BottomAnchorControllerDriver | null>(null);
agentIdRef.current = input.agentId;
readinessRef.current = input.isAuthoritativeHistoryReady;
renderStrategyRef.current = input.renderStrategy;
transportBehaviorRef.current = input.transportBehavior;
getMeasurementStateRef.current = input.getMeasurementState;
isNearBottomRef.current = input.isNearBottom;
scrollToBottomRef.current = input.scrollToBottom;
if (!driverRef.current) {
driverRef.current = __private__.createBottomAnchorControllerDriver({
getAgentId: () => agentIdRef.current,
getIsAuthoritativeHistoryReady: () => readinessRef.current,
getRenderStrategy: () => renderStrategyRef.current,
getTransportBehavior: () => transportBehaviorRef.current,
getMeasurementState: () => getMeasurementStateRef.current(),
isNearBottom: () => isNearBottomRef.current(),
scrollToBottom: (animated) => scrollToBottomRef.current(animated),
onModeChange: (nextMode) => setMode(nextMode),
log: (event, details) => logBottomAnchorEvent(event, details),
warn: (details) => {
console.warn("[BottomAnchor] request could not be fulfilled", details);
},
scheduleFrame: ({ callback, delayFrames }) =>
scheduleAnimationFrameWithDelay({ callback, delayFrames }),
cancelFrame: (handle) => cancelScheduledAnimationFrame(handle),
});
}
useEffect(() => {
driverRef.current?.resetForAgent();
}, [input.agentId]);
useEffect(() => {
driverRef.current?.applyRouteRequest(input.routeRequest);
}, [input.routeRequest]);
useEffect(() => {
driverRef.current?.notifyAuthoritativeHistoryMaybeChanged();
}, [input.isAuthoritativeHistoryReady]);
useEffect(() => {
return () => {
driverRef.current?.destroy();
driverRef.current = null;
};
}, []);
return {
mode,
requestLocalAnchor(request: BottomAnchorLocalRequest) {
driverRef.current?.requestLocalAnchor(request);
},
detachByUser() {
driverRef.current?.detachByUser();
},
handleViewportLayout() {},
handleViewportMetricsChange(params: {
previousViewportWidth: number;
viewportWidth: number;
previousViewportHeight: number;
viewportHeight: number;
}) {
driverRef.current?.handleViewportMetricsChange(params);
},
handleContentSizeChange(params: {
previousContentHeight: number;
contentHeight: number;
}) {
driverRef.current?.handleContentSizeChange(params);
},
prepareForStickyViewportChange() {
driverRef.current?.prepareForStickyViewportChange();
},
prepareForStickyContentChange() {
driverRef.current?.prepareForStickyContentChange();
},
handleScrollNearBottomChange(params: {
nextIsNearBottom: boolean;
scrollDelta: number;
}) {
driverRef.current?.handleScrollNearBottomChange(params);
},
reevaluate(animated = false) {
driverRef.current?.reevaluate(animated);
},
};
}