Split stream rendering into platform-specific strategies

This commit is contained in:
Mohamed Boudra
2026-03-09 11:13:57 +07:00
parent e3552f6365
commit 06f8722f25
10 changed files with 1580 additions and 1280 deletions

28
package-lock.json generated
View File

@@ -6115,6 +6115,23 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.21.tgz",
"integrity": "sha512-SYXFrmrbPgXBvf+HsOsKhFgqSe4M6B29VHOsX9Jih9TlNkNkDWx0hWMiMLUghMEzyUz772ndzdEeCEBx+3GIZw==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.21"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/router-core": {
"version": "1.149.3",
"license": "MIT",
@@ -6363,6 +6380,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.21",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.21.tgz",
"integrity": "sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-file-routes": {
"version": "1.145.4",
"license": "MIT",
@@ -21597,6 +21624,7 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@tauri-apps/api": "^2.9.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",

View File

@@ -51,6 +51,7 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@tauri-apps/api": "^2.9.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",

View File

@@ -1,77 +0,0 @@
import { ScrollViewStyleReset } from "expo-router/html";
import type { PropsWithChildren } from "react";
// Ensure Unistyles runs before Expo Router statically renders each page.
import "../styles/unistyles";
const webEcosystemStyles = /* css */ `
html {
touch-action: auto;
height: 100%;
background-color: #18181c;
color-scheme: dark;
}
body {
min-height: 100%;
overflow: auto;
overscroll-behavior: contain;
-webkit-user-select: text;
user-select: text;
background-color: #18181c;
}
body * {
-webkit-user-select: text;
user-select: text;
}
[data-testid="sidebar-agent-list-scroll"],
[data-testid="agent-chat-scroll"],
[data-testid="git-diff-scroll"],
[data-testid="file-explorer-tree-scroll"] {
scrollbar-width: none;
-ms-overflow-style: none;
}
[data-testid="sidebar-agent-list-scroll"]::-webkit-scrollbar,
[data-testid="agent-chat-scroll"]::-webkit-scrollbar,
[data-testid="git-diff-scroll"]::-webkit-scrollbar,
[data-testid="file-explorer-tree-scroll"]::-webkit-scrollbar {
width: 0;
height: 0;
}
#root {
min-height: 100%;
background-color: #18181c;
}
`;
function WebRespectfulStyleReset() {
return (
<style
id="paseo-web-ecosystem"
dangerouslySetInnerHTML={{ __html: webEcosystemStyles }}
/>
);
}
export default function Root({ children }: PropsWithChildren) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=yes, minimum-scale=1, maximum-scale=5"
/>
{/* Reset scroll styles so React Native Web views behave like native. */}
<ScrollViewStyleReset />
<WebRespectfulStyleReset />
</head>
<body>{children}</body>
</html>
);
}

View File

@@ -1,543 +1 @@
import type { ComponentType, ReactElement, RefObject } from "react";
import type { FlatList, ScrollView, StyleProp, View, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
type EdgeSlot = "header" | "footer";
type NeighborRelation = "above" | "below";
type AssistantTurnTraversalStep = -1 | 1;
export type MaintainVisibleContentPositionConfig = Readonly<{
minIndexForVisible: number;
autoscrollToTopThreshold: number;
}>;
export type BottomAnchorTransportBehavior = Readonly<{
verificationDelayFrames: number;
verificationRetryMode: "rescroll" | "recheck";
}>;
export type StreamViewportMetrics = {
contentHeight: number;
viewportHeight: number;
};
export type StreamNearBottomInput = StreamViewportMetrics & {
offsetY: number;
threshold: number;
};
export type StreamEdgeSlotProps = {
ListHeaderComponent?: ReactElement | ComponentType<any> | null;
ListHeaderComponentStyle?: StyleProp<ViewStyle>;
ListFooterComponent?: ReactElement | ComponentType<any> | null;
ListFooterComponentStyle?: StyleProp<ViewStyle>;
};
export type StreamRenderRefs = {
flatListRef: RefObject<FlatList<any> | null>;
scrollViewRef: RefObject<ScrollView | null>;
bottomAnchorRef: RefObject<View | null>;
};
export type ResolveStreamRenderStrategyInput = {
platform: string;
isMobileBreakpoint: boolean;
};
export interface StreamRenderStrategy {
orderTail: (streamItems: StreamItem[]) => StreamItem[];
orderHead: (streamHead: StreamItem[]) => StreamItem[];
getNeighborIndex: (index: number, relation: NeighborRelation) => number;
getNeighborItem: (
items: StreamItem[],
index: number,
relation: NeighborRelation
) => StreamItem | undefined;
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
getEdgeSlotProps: (
component: ReactElement | ComponentType<any> | null,
gapSize: number
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () =>
| MaintainVisibleContentPositionConfig
| undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
shouldAnchorBottomOnContentSizeChange: () => boolean;
shouldAnimateManualScrollToBottom: () => boolean;
shouldUseVirtualizedList: () => boolean;
scrollToBottom: (params: {
refs: StreamRenderRefs;
metrics: StreamViewportMetrics;
animated: boolean;
}) => void;
scrollToOffset: (params: {
refs: StreamRenderRefs;
offset: number;
animated: boolean;
}) => void;
}
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
type StreamRenderStrategyConfig = {
orderTailReverse: boolean;
orderHeadReverse: boolean;
assistantTurnTraversalStep: AssistantTurnTraversalStep;
edgeSlot: EdgeSlot;
flatListInverted: boolean;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
bottomAnchorTransportBehavior: BottomAnchorTransportBehavior;
disableParentScrollOnInlineDetailsExpansion: boolean;
anchorBottomOnContentSizeChange: boolean;
animateManualScrollToBottom: boolean;
useVirtualizedList: boolean;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
scrollToBottom: (params: {
refs: StreamRenderRefs;
metrics: StreamViewportMetrics;
animated: boolean;
}) => void;
scrollToOffset: (params: {
refs: StreamRenderRefs;
offset: number;
animated: boolean;
}) => void;
};
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION: MaintainVisibleContentPositionConfig =
Object.freeze({
minIndexForVisible: 0,
autoscrollToTopThreshold: 0,
});
function scrollAnchorIntoView(params: {
refs: StreamRenderRefs;
animated: boolean;
}): boolean {
const anchorHandle = params.refs.bottomAnchorRef.current as
| ({ getNativeRef?: () => unknown; scrollIntoView?: (options?: unknown) => void } &
object)
| null;
if (!anchorHandle) {
return false;
}
const maybeNative =
typeof anchorHandle.getNativeRef === "function"
? anchorHandle.getNativeRef()
: anchorHandle;
const domElement = maybeNative as { scrollIntoView?: (options?: unknown) => void };
if (typeof domElement.scrollIntoView !== "function") {
return false;
}
domElement.scrollIntoView({
block: "end",
behavior: params.animated ? "smooth" : "auto",
});
return true;
}
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
| {
getNativeScrollRef?: () => unknown;
getScrollableNode?: () => unknown;
getInnerViewNode?: () => unknown;
getNativeRef?: () => unknown;
}
| null;
const anchorHandle = refs.bottomAnchorRef.current as
| ({ getNativeRef?: () => unknown } & object)
| null;
const candidates: unknown[] = [
scrollViewHandle?.getNativeScrollRef?.(),
scrollViewHandle?.getScrollableNode?.(),
scrollViewHandle?.getInnerViewNode?.(),
scrollViewHandle?.getNativeRef?.(),
scrollViewHandle,
typeof anchorHandle?.getNativeRef === "function"
? anchorHandle.getNativeRef()
: anchorHandle,
];
let scrollNode: HTMLElement | null = null;
for (const candidate of candidates) {
scrollNode = resolvePotentialScrollNode(candidate);
if (scrollNode) {
break;
}
}
if (!scrollNode && typeof document !== "undefined") {
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;
}
const snap = () => {
scrollNode.scrollTop = Math.max(
fallbackOffset,
scrollNode.scrollHeight - scrollNode.clientHeight
);
};
snap();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(snap);
}
}
function logWebBottomAnchorTransport(
event: string,
details: Record<string, unknown>
): void {
if (!(globalThis as { __DEV__?: boolean }).__DEV__) {
return;
}
console.debug("[BottomAnchorTransport]", event, details);
}
function getResolvedWebScrollContainerMetrics(
refs: StreamRenderRefs
): Record<string, unknown> {
const scrollNode = resolveWebScrollContainerNode(refs);
if (!scrollNode) {
return { hasScrollNode: false };
}
return {
hasScrollNode: true,
scrollTop: scrollNode.scrollTop,
scrollHeight: scrollNode.scrollHeight,
clientHeight: scrollNode.clientHeight,
tagName: scrollNode.tagName,
};
}
function createStreamRenderStrategy(
config: StreamRenderStrategyConfig
): StreamRenderStrategy {
return {
orderTail: (streamItems) =>
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
orderHead: (streamHead) =>
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
getNeighborIndex: (index, relation) =>
relation === "above"
? index + config.assistantTurnTraversalStep
: index - config.assistantTurnTraversalStep,
getNeighborItem: (items, index, relation) => {
const neighborIndex =
relation === "above"
? index + config.assistantTurnTraversalStep
: index - config.assistantTurnTraversalStep;
if (neighborIndex < 0 || neighborIndex >= items.length) {
return undefined;
}
return items[neighborIndex];
},
collectAssistantTurnContent: (items, startIndex) => {
const messages: string[] = [];
for (
let index = startIndex;
index >= 0 && index < items.length;
index += config.assistantTurnTraversalStep
) {
const currentItem = items[index];
if (currentItem.kind === "user_message") {
break;
}
if (currentItem.kind === "assistant_message") {
messages.push(currentItem.text);
}
}
return messages.reverse().join("\n\n");
},
isNearBottom: (input) => config.isNearBottom(input),
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
getEdgeSlotProps: (component, gapSize) => {
if (config.edgeSlot === "header") {
return {
ListHeaderComponent: component,
ListHeaderComponentStyle: { marginBottom: gapSize },
};
}
return {
ListFooterComponent: component,
ListFooterComponentStyle: { marginTop: gapSize },
};
},
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
getFlatListInverted: () => config.flatListInverted,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
config.disableParentScrollOnInlineDetailsExpansion,
shouldAnchorBottomOnContentSizeChange: () =>
config.anchorBottomOnContentSizeChange,
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
shouldUseVirtualizedList: () => config.useVirtualizedList,
scrollToBottom: (params) => config.scrollToBottom(params),
scrollToOffset: (params) => config.scrollToOffset(params),
};
}
function createInvertedStreamStrategy(): StreamRenderStrategy {
return createStreamRenderStrategy({
orderTailReverse: true,
orderHeadReverse: true,
assistantTurnTraversalStep: 1,
edgeSlot: "header",
flatListInverted: true,
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: false,
animateManualScrollToBottom: true,
useVirtualizedList: true,
isNearBottom: (input) => input.offsetY <= input.threshold,
getBottomOffset: () => 0,
scrollToBottom: ({ refs, animated }) => {
refs.flatListRef.current?.scrollToOffset({
offset: 0,
animated,
});
},
scrollToOffset: ({ refs, offset, animated }) => {
refs.flatListRef.current?.scrollToOffset({ offset, animated });
},
});
}
function createForwardStreamStrategy(): StreamRenderStrategy {
return createStreamRenderStrategy({
orderTailReverse: false,
orderHeadReverse: false,
assistantTurnTraversalStep: -1,
edgeSlot: "footer",
flatListInverted: false,
overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
},
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: true,
animateManualScrollToBottom: false,
useVirtualizedList: false,
isNearBottom: (inputMetrics) => {
const distanceFromBottom = Math.max(
0,
inputMetrics.contentHeight -
(inputMetrics.offsetY + inputMetrics.viewportHeight)
);
return distanceFromBottom <= inputMetrics.threshold;
},
getBottomOffset: (metrics) =>
Math.max(0, metrics.contentHeight - metrics.viewportHeight),
scrollToBottom: ({ refs, metrics, animated }) => {
const bottomOffset = Math.max(
0,
metrics.contentHeight - metrics.viewportHeight
);
logWebBottomAnchorTransport("forward_scroll_to_bottom_start", {
animated,
bottomOffset,
metrics,
usedFlatList: Boolean(refs.flatListRef.current),
...getResolvedWebScrollContainerMetrics(refs),
});
if (refs.flatListRef.current) {
refs.flatListRef.current.scrollToEnd?.({ animated });
refs.flatListRef.current.scrollToOffset?.({
offset: bottomOffset,
animated,
});
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
logWebBottomAnchorTransport("forward_scroll_to_bottom_after_flatlist", {
animated,
bottomOffset,
...getResolvedWebScrollContainerMetrics(refs),
});
});
}
return;
}
const usedAnchor = scrollAnchorIntoView({ refs, animated });
if (!usedAnchor) {
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
}
// Always apply deterministic bottom offset to avoid partial anchors.
refs.scrollViewRef.current?.scrollTo?.({
y: bottomOffset,
animated,
});
forceScrollContainerToBottom(refs, bottomOffset);
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
logWebBottomAnchorTransport("forward_scroll_to_bottom_after_scrollview", {
animated,
bottomOffset,
usedAnchor,
...getResolvedWebScrollContainerMetrics(refs),
});
});
}
},
scrollToOffset: ({ refs, offset, animated }) => {
if (refs.flatListRef.current) {
refs.flatListRef.current.scrollToOffset?.({ offset, animated });
return;
}
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
},
});
}
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput
): StreamRenderStrategy {
if (input.platform === "web") {
return createForwardStreamStrategy();
}
return createInvertedStreamStrategy();
}
export function orderTailForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
streamItems: StreamItem[];
}): StreamItem[] {
return params.strategy.orderTail(params.streamItems);
}
export function orderHeadForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
streamHead: StreamItem[];
}): StreamItem[] {
return params.strategy.orderHead(params.streamHead);
}
export function getStreamNeighborIndex(params: {
strategy: StreamRenderStrategy;
index: number;
relation: NeighborRelation;
}): number {
return params.strategy.getNeighborIndex(params.index, params.relation);
}
export function getStreamNeighborItem(params: {
strategy: StreamRenderStrategy;
items: StreamItem[];
index: number;
relation: NeighborRelation;
}): StreamItem | undefined {
return params.strategy.getNeighborItem(
params.items,
params.index,
params.relation
);
}
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
items: StreamItem[];
startIndex: number;
}): string {
return params.strategy.collectAssistantTurnContent(
params.items,
params.startIndex
);
}
export function isNearBottomForStreamRenderStrategy(
params: StreamNearBottomInput & { strategy: StreamRenderStrategy }
): boolean {
return params.strategy.isNearBottom({
offsetY: params.offsetY,
threshold: params.threshold,
contentHeight: params.contentHeight,
viewportHeight: params.viewportHeight,
});
}
export function getBottomOffsetForStreamRenderStrategy(
params: StreamViewportMetrics & {
strategy: StreamRenderStrategy;
}
): number {
return params.strategy.getBottomOffset({
contentHeight: params.contentHeight,
viewportHeight: params.viewportHeight,
});
}
export function getStreamEdgeSlotProps(params: {
strategy: StreamRenderStrategy;
component: ReactElement | ComponentType<any> | null;
gapSize: number;
}): StreamEdgeSlotProps {
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
}
export * from "./stream-strategy";

View File

@@ -1,8 +1,5 @@
import {
Fragment,
createElement,
forwardRef,
isValidElement,
useCallback,
useEffect,
useImperativeHandle,
@@ -10,23 +7,14 @@ import {
useRef,
useState,
} from "react";
import type { ComponentType, ReactElement, ReactNode } from "react";
import {
View,
Text,
Pressable,
FlatList,
ScrollView,
ListRenderItemInfo,
LayoutChangeEvent,
NativeScrollEvent,
NativeSyntheticEvent,
Platform,
ActivityIndicator,
Keyboard,
} from "react-native";
import Markdown from "react-native-markdown-display";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
@@ -64,30 +52,17 @@ import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "./web-desktop-scrollbar";
import {
collectAssistantTurnContentForStreamRenderStrategy,
getStreamEdgeSlotProps,
getStreamNeighborItem,
isNearBottomForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
resolveWebScrollContainerNode,
resolveStreamRenderStrategy,
type StreamEdgeSlotProps,
type StreamViewportHandle,
} 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";
@@ -103,23 +78,6 @@ const isToolSequenceItem = (item?: StreamItem) =>
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
const STREAM_ITEM_LOG_MIN_COUNT = 200;
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
const NOOP_SEPARATORS: ListRenderItemInfo<StreamItem>["separators"] = {
highlight: () => {},
unhighlight: () => {},
updateProps: () => {},
};
function renderStreamEdgeComponent(
component: ReactElement | ComponentType<any> | null | undefined
): ReactNode {
if (!component) {
return null;
}
if (isValidElement(component)) {
return component;
}
return createElement(component);
}
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
@@ -145,9 +103,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
}, ref) {
const flatListRef = useRef<FlatList<any>>(null);
const scrollViewRef = useRef<ScrollView>(null);
const bottomAnchorRef = useRef<View>(null);
const viewportRef = useRef<StreamViewportHandle | null>(null);
const { theme } = useUnistyles();
const router = useRouter();
const isMobile =
@@ -160,65 +116,11 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
}),
[isMobile]
);
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const insets = useSafeAreaInsets();
const [isNearBottom, setIsNearBottom] = useState(true);
const isNearBottomRef = useRef(true);
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);
const streamRenderRefs = useMemo(
() => ({ 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 ?? "";
@@ -248,52 +150,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
: FadeOut.duration(200);
useEffect(() => {
isNearBottomRef.current = true;
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,
};
setIsNearBottom(true);
setExpandedInlineToolCallIds(new Set());
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,
]);
}, [agentId]);
const handleInlinePathPress = useCallback(
(target: InlinePathTarget) => {
@@ -340,299 +199,23 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
]
);
const updateNearBottom = useCallback((value: boolean) => {
if (isNearBottomRef.current === value) return;
isNearBottomRef.current = value;
setIsNearBottom(value);
}, []);
const flatListData = useMemo(() => {
const orderedStreamItems = 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;
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 threshold = Math.max(insets.bottom, 32);
const nearBottom = isNearBottomForStreamRenderStrategy({
strategy: streamRenderStrategy,
offsetY: contentOffset.y,
threshold,
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
updateNearBottom(nearBottom);
const expectedBottomOffset = streamRenderStrategy.getBottomOffset(
streamViewportMetricsRef.current
);
const isProgrammaticBottomEvent =
Math.abs(contentOffset.y - expectedBottomOffset) <= 8;
if (
programmaticScrollEventBudgetRef.current > 0 &&
isProgrammaticBottomEvent
) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
programmaticScrollEventBudgetRef.current = 0;
bottomAnchorController.handleScrollNearBottomChange({
nextIsNearBottom: nearBottom,
scrollDelta: contentOffset.y - previousOffsetY,
});
}
if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onScroll(event);
}
},
[
insets.bottom,
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,
containerKey: currentContainerKey,
viewportWidth,
viewportHeight,
viewportMeasuredForKey: currentContainerKey,
};
if (viewportChanged) {
markNativeViewportSettling();
}
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
previousViewportHeight,
viewportHeight,
});
if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onLayout(event);
}
},
[
showDesktopWebScrollbar,
streamScrollbarMetrics,
currentContainerKey,
markNativeViewportSettling,
bottomAnchorController,
]
);
useImperativeHandle(ref, () => ({
scrollToBottom(reason = "jump-to-bottom") {
bottomAnchorController.requestLocalAnchor({ agentId, reason });
viewportRef.current?.scrollToBottom(reason);
},
prepareForViewportChange() {
bottomAnchorController.prepareForStickyViewportChange();
viewportRef.current?.prepareForViewportChange();
},
}), [agentId, bottomAnchorController]);
const handleContentSizeChange = useCallback(
(width: number, height: number) => {
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
const nextContentHeight = Math.max(0, height);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: currentContainerKey,
contentHeight: nextContentHeight,
contentMeasuredForKey: currentContainerKey,
};
bottomAnchorController.handleContentSizeChange({
previousContentHeight,
contentHeight: nextContentHeight,
});
if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onContentSizeChange(width, height);
}
},
[
showDesktopWebScrollbar,
streamScrollbarMetrics,
currentContainerKey,
bottomAnchorController,
]
);
useEffect(() => {
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,
});
};
applyObservedMetrics();
const resizeObserver = new ResizeObserver(() => {
applyObservedMetrics();
});
resizeObserver.observe(scrollNode);
const contentNode = scrollNode.firstElementChild;
if (contentNode instanceof HTMLElement) {
resizeObserver.observe(contentNode);
}
return () => {
resizeObserver.disconnect();
};
}, [bottomAnchorController, currentContainerKey, streamRenderRefs]);
}), []);
function scrollToBottom() {
bottomAnchorController.requestLocalAnchor({
agentId,
reason: "jump-to-bottom",
});
viewportRef.current?.scrollToBottom("jump-to-bottom");
}
const orderedStreamHead = useMemo(() => {
@@ -843,16 +426,16 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
);
const renderStreamItem = useCallback(
({ item, index }: ListRenderItemInfo<StreamItem>) => {
const content = renderStreamItemContent(item, index, flatListData);
(item: StreamItem, index: number, items: StreamItem[]) => {
const content = renderStreamItemContent(item, index, items);
if (!content) {
return null;
}
const gapBelow = getGapBelow(item, index, flatListData);
const gapBelow = getGapBelow(item, index, items);
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items: flatListData,
items,
index,
relation: "below",
});
@@ -863,7 +446,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
const getTurnContent = () =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy: streamRenderStrategy,
items: flatListData,
items,
startIndex: index,
});
@@ -879,7 +462,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
[
getGapBelow,
renderStreamItemContent,
flatListData,
agent.status,
streamRenderStrategy,
]
@@ -963,35 +545,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
const showWorkingIndicator = agent.status === "running";
const showBottomBar = showWorkingIndicator;
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;
@@ -1054,19 +607,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
tightGap,
]);
const flatListExtraData = useMemo(
() => ({
pendingPermissionCount: pendingPermissionItems.length,
showWorkingIndicator,
showBottomBar,
}),
[
pendingPermissionItems.length,
showWorkingIndicator,
showBottomBar,
]
);
const listEdgeSlotProps = useMemo<StreamEdgeSlotProps>(() => {
if (!listEdgeSlotComponent) {
return {};
@@ -1116,171 +656,27 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const listContentContainerStyle = useMemo(
() =>
usesVirtualizedList
? stylesheet.listContentContainer
: [stylesheet.listContentContainer, stylesheet.forwardListContentContainer],
[usesVirtualizedList]
);
const headerEdgeContent = renderStreamEdgeComponent(
listEdgeSlotProps.ListHeaderComponent
);
const footerEdgeContent = renderStreamEdgeComponent(
listEdgeSlotProps.ListFooterComponent
);
const nonVirtualizedItems = useMemo(() => {
if (indexedFlatListData.length === 0) {
return null;
}
return indexedFlatListData.map((entry) => {
const rendered = renderIndexedStreamItem(entry);
if (!rendered) {
return null;
}
return <Fragment key={entry.item.id}>{rendered}</Fragment>;
});
}, [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>
<View style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
{usesVirtualizedList ? (
<FlatList
ref={flatListRef}
data={flatListData}
renderItem={renderStreamItem}
keyExtractor={(item) => item.id}
testID="agent-chat-scroll"
nativeID={`agent-chat-scroll-${currentContainerKey}`}
{...listEdgeSlotProps}
contentContainerStyle={listContentContainerStyle}
style={stylesheet.list}
onLayout={handleListLayout}
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
ListEmptyComponent={listEmptyComponent}
extraData={flatListExtraData}
maintainVisibleContentPosition={
streamRenderStrategy.getMaintainVisibleContentPosition()
}
initialNumToRender={12}
windowSize={10}
scrollEnabled={streamScrollEnabled}
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}
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
scrollEnabled={streamScrollEnabled}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
{headerEdgeContent ? (
<View style={listEdgeSlotProps.ListHeaderComponentStyle}>
{headerEdgeContent}
</View>
) : null}
{nonVirtualizedItems}
{flatListData.length === 0 ? listEmptyComponent : null}
{footerEdgeContent ? (
<View style={listEdgeSlotProps.ListFooterComponentStyle}>
{footerEdgeContent}
</View>
) : null}
<View ref={bottomAnchorRef} collapsable={false} />
</ScrollView>
)}
{streamRenderStrategy.render({
agentId,
rows: orderedStreamItems,
renderRow: renderStreamItem,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: setIsNearBottom,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
edgeSlotProps: listEdgeSlotProps,
})}
</MessageOuterSpacingProvider>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics}
inverted={streamRenderStrategy.getOverlayScrollbarInverted()}
onScrollToOffset={(nextOffset) => {
streamRenderStrategy.scrollToOffset({
refs: streamRenderRefs,
offset: nextOffset,
animated: false,
});
}}
/>
{/* Scroll to bottom button */}
{!isNearBottom && (
<Animated.View
style={stylesheet.scrollToBottomContainer}

View File

@@ -0,0 +1,336 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
FlatList,
Keyboard,
type LayoutChangeEvent,
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import type { StreamItem } from "@/types/stream";
import {
useBottomAnchorController,
} from "./use-bottom-anchor-controller";
import type {
StreamRenderInput,
StreamStrategy,
StreamViewportHandle,
} from "./stream-strategy";
import {
createStreamStrategy,
isNearBottomForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
} from "./stream-strategy";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
autoscrollToTopThreshold: 0,
});
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
const {
agentId,
rows,
renderRow,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange,
scrollEnabled,
listStyle,
baseListContentContainerStyle,
edgeSlotProps,
strategy,
} = props;
const flatListRef = useRef<FlatList<StreamItem>>(null);
const streamViewportMetricsRef = useRef({
containerKey: "native-virtualized",
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null as string | null,
contentMeasuredForKey: null as string | null,
});
const scrollOffsetYRef = useRef(0);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const clearNativeViewportSettling = useCallback(() => {
if (nativeViewportSettlingFrameIdRef.current !== null) {
cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current);
nativeViewportSettlingFrameIdRef.current = null;
}
}, []);
const markNativeViewportSettling = useCallback(() => {
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]);
const bottomAnchorTransportBehavior = useMemo(
() =>
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: isNativeViewportSettling,
}),
[isNativeViewportSettling, strategy]
);
const scrollToBottom = useCallback((animated: boolean) => {
programmaticScrollEventBudgetRef.current = 3;
flatListRef.current?.scrollToOffset({
offset: 0,
animated,
});
scrollOffsetYRef.current = 0;
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
offsetY: 0,
};
onNearBottomChange(true);
}, [onNearBottomChange]);
const bottomAnchorController = useBottomAnchorController({
agentId,
routeRequest: routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
renderStrategy: "inverted-stream",
transportBehavior: bottomAnchorTransportBehavior,
getMeasurementState: () => streamViewportMetricsRef.current,
isNearBottom: () => {
const metrics = streamViewportMetricsRef.current;
return isNearBottomForStreamRenderStrategy({
strategy,
offsetY: metrics.offsetY,
threshold: 32,
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
});
},
scrollToBottom,
});
useEffect(() => {
streamViewportMetricsRef.current = {
containerKey: "native-virtualized",
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
}, [agentId, clearNativeViewportSettling]);
useEffect(() => {
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]);
useEffect(() => {
bottomAnchorController.prepareForStickyContentChange();
}, [bottomAnchorController, rows]);
useEffect(() => {
const handle: StreamViewportHandle = {
scrollToBottom: (reason = "jump-to-bottom") => {
bottomAnchorController.requestLocalAnchor({
agentId,
reason,
});
},
prepareForViewportChange: () => {
bottomAnchorController.prepareForStickyViewportChange();
markNativeViewportSettling();
},
};
viewportRef.current = handle;
return () => {
if (viewportRef.current === handle) {
viewportRef.current = null;
}
};
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
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: "native-virtualized",
offsetY: contentOffset.y,
viewportMeasuredForKey: "native-virtualized",
contentMeasuredForKey: "native-virtualized",
};
const nearBottom = isNearBottomForStreamRenderStrategy({
strategy,
offsetY: contentOffset.y,
threshold: 32,
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
onNearBottomChange(nearBottom);
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
programmaticScrollEventBudgetRef.current = 0;
bottomAnchorController.handleScrollNearBottomChange({
nextIsNearBottom: nearBottom,
scrollDelta: contentOffset.y - previousOffsetY,
});
}
},
[bottomAnchorController, onNearBottomChange, strategy]
);
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,
containerKey: "native-virtualized",
viewportWidth,
viewportHeight,
viewportMeasuredForKey: "native-virtualized",
};
if (viewportChanged) {
markNativeViewportSettling();
}
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
previousViewportHeight,
viewportHeight,
});
},
[bottomAnchorController, markNativeViewportSettling]
);
const handleContentSizeChange = useCallback(
(_width: number, height: number) => {
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
const nextContentHeight = Math.max(0, height);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: "native-virtualized",
contentHeight: nextContentHeight,
contentMeasuredForKey: "native-virtualized",
};
bottomAnchorController.handleContentSizeChange({
previousContentHeight,
contentHeight: nextContentHeight,
});
},
[bottomAnchorController]
);
const renderItem = useCallback(
({ item, index }: ListRenderItemInfo<StreamItem>) => {
const rendered = renderRow(item, index, rows);
return rendered ? <Fragment>{rendered}</Fragment> : null;
},
[renderRow, rows]
);
return (
<FlatList
ref={flatListRef}
data={rows}
renderItem={renderItem}
keyExtractor={(item) => item.id}
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
{...edgeSlotProps}
contentContainerStyle={baseListContentContainerStyle}
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
ListEmptyComponent={
listEmptyComponent ? () => <Fragment>{listEmptyComponent}</Fragment> : undefined
}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
initialNumToRender={12}
windowSize={10}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
inverted
/>
);
}
export function createNativeStreamStrategy(): StreamStrategy {
const strategy = createStreamStrategy({
render: (renderInput) => (
<NativeStreamViewport
{...renderInput}
strategy={strategy}
/>
),
orderTailReverse: true,
orderHeadReverse: true,
assistantTurnTraversalStep: 1,
edgeSlot: "header",
flatListInverted: true,
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: false,
animateManualScrollToBottom: true,
useVirtualizedList: true,
isNearBottom: (input) => input.offsetY <= input.threshold,
getBottomOffset: () => 0,
});
return strategy;
}

View File

@@ -0,0 +1,635 @@
import {
Fragment,
type CSSProperties,
createElement,
isValidElement,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { measureElement as measureVirtualElement, useVirtualizer } from '@tanstack/react-virtual'
import { View } from 'react-native'
import {
estimateStreamItemHeight,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
} from './agent-stream-web-virtualization'
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from './stream-strategy'
import { createStreamStrategy } from './stream-strategy'
type CreateWebStreamStrategyInput = {
isMobileBreakpoint: boolean
}
type ScrollBehaviorLike = 'auto' | 'smooth'
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200
const USER_SCROLL_DELTA_EPSILON = 1
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1
const WEB_STREAM_SCROLLBAR_STYLE_ID = 'web-stream-viewport-scrollbar-style'
const WEB_STREAM_SCROLLBAR_STYLE = `
#agent-chat-scroll-web-dom-scroll,
#agent-chat-scroll-web-dom-virtualized {
scrollbar-width: none;
-ms-overflow-style: none;
}
#agent-chat-scroll-web-dom-scroll::-webkit-scrollbar,
#agent-chat-scroll-web-dom-virtualized::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
`
function isScrollContainerNearBottom(
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>,
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX
): boolean {
const threshold = Number.isFinite(thresholdPx)
? Math.max(0, thresholdPx)
: AUTO_SCROLL_BOTTOM_THRESHOLD_PX
const { scrollTop, clientHeight, scrollHeight } = scrollContainer
if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) {
return true
}
const distanceFromBottom = scrollHeight - clientHeight - scrollTop
return distanceFromBottom <= threshold
}
function isScrollContainerAtBottom(
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>
): boolean {
return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX)
}
function renderEdge(
content: StreamRenderInput['edgeSlotProps']['ListHeaderComponent'],
style?: CSSProperties
) {
if (!content) {
return null
}
const rendered = isValidElement(content) ? content : createElement(content)
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
...style,
}}
>
{rendered}
</div>
)
}
function scrollElementToBottom(
scrollContainer: HTMLElement,
behavior: ScrollBehaviorLike = 'auto'
): void {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior,
})
}
function syncNearBottom(
scrollContainer: HTMLElement | null,
onNearBottomChange: (value: boolean) => void
): boolean {
if (!scrollContainer) {
onNearBottomChange(true)
return true
}
const nextValue = isScrollContainerNearBottom(scrollContainer)
onNearBottomChange(nextValue)
return nextValue
}
function getScrollContainerDistanceFromBottom(
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>
): number {
return scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop
}
function isScrollContainerOverscrolledPastBottom(
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>
): boolean {
return getScrollContainerDistanceFromBottom(scrollContainer) < 0
}
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const {
rows,
renderRow,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange,
scrollEnabled,
listStyle,
baseListContentContainerStyle,
forwardListContentContainerStyle,
edgeSlotProps,
isMobileBreakpoint,
} = props
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
require('./web-desktop-scrollbar') as typeof import('./web-desktop-scrollbar')
const scrollContainerRef = useRef<HTMLElement | null>(null)
const contentRef = useRef<HTMLElement | null>(null)
const [followOutput, setFollowOutputr] = useState(true)
const setFollowOutput = (value: boolean) => {
console.trace('setFollowOutput', value)
setFollowOutputr(value)
return value
}
const followOutputRef = useRef(followOutput)
const lastKnownScrollTopRef = useRef(0)
const pendingUserScrollUpIntentRef = useRef(false)
const isPointerScrollActiveRef = useRef(false)
const lastTouchClientYRef = useRef<number | null>(null)
const pendingAutoScrollFrameRef = useRef<number | null>(null)
const pendingAutoScrollTimeoutRef = useRef<number | null>(null)
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics()
const showDesktopWebScrollbar = !isMobileBreakpoint
const shouldUseVirtualizer =
!isMobileBreakpoint && rows.length > getWebPartialVirtualizationThreshold()
followOutputRef.current = followOutput
const indexedRows = useMemo(() => rows.map((item, index) => ({ item, index })), [rows])
const webVirtualizedHistoryWindow = useMemo(() => {
if (!shouldUseVirtualizer) {
return null
}
return splitWebVirtualizedHistory({
entries: indexedRows,
minMountedCount: getWebMountedRecentStreamItems(),
})
}, [indexedRows, shouldUseVirtualizer])
const virtualizedEntries = webVirtualizedHistoryWindow?.virtualizedEntries ?? []
const mountedEntries = webVirtualizedHistoryWindow?.mountedEntries ?? indexedRows
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId
const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady
const rowVirtualizer = useVirtualizer({
count: virtualizedEntries.length,
getScrollElement: () => scrollContainerRef.current,
getItemKey: (index: number) => virtualizedEntries[index]?.item.id ?? index,
estimateSize: (index: number) => {
const row = virtualizedEntries[index]?.item
return row ? estimateStreamItemHeight(row) : 120
},
measureElement: measureVirtualElement,
useAnimationFrameWithResizeObserver: true,
overscan: 8,
})
useEffect(() => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
const viewportHeight = instance.scrollRect?.height ?? 0
const scrollOffset = instance.scrollOffset ?? 0
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight)
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX
}
return () => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined
}
}, [rowVirtualizer])
const virtualRows = rowVirtualizer.getVirtualItems()
const virtualTotalSize = rowVirtualizer.getTotalSize()
const cancelPendingStickToBottom = useCallback(() => {
const pendingFrame = pendingAutoScrollFrameRef.current
if (pendingFrame !== null) {
pendingAutoScrollFrameRef.current = null
window.cancelAnimationFrame(pendingFrame)
}
const pendingTimeout = pendingAutoScrollTimeoutRef.current
if (pendingTimeout !== null) {
pendingAutoScrollTimeoutRef.current = null
window.clearTimeout(pendingTimeout)
}
}, [])
const scrollMessagesToBottom = useCallback(
(behavior: ScrollBehaviorLike = 'auto', source = 'unknown') => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) {
return
}
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
return
}
scrollElementToBottom(scrollContainer, behavior)
lastKnownScrollTopRef.current = scrollContainer.scrollTop
syncNearBottom(scrollContainer, onNearBottomChange)
},
[onNearBottomChange]
)
const scheduleStickToBottom = useCallback((source = 'unknown') => {
const scrollContainer = scrollContainerRef.current
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
return
}
if (pendingAutoScrollFrameRef.current !== null) {
return
}
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
pendingAutoScrollFrameRef.current = null
if (!followOutputRef.current) {
return
}
scrollMessagesToBottom('auto', source)
})
}, [scrollMessagesToBottom])
const forceStickToBottom = useCallback(() => {
cancelPendingStickToBottom()
scrollMessagesToBottom('auto', 'force')
scheduleStickToBottom('force')
}, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom])
const updateScrollMetrics = useCallback(() => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) {
onNearBottomChange(true)
return
}
streamScrollbarMetrics.onContentSizeChange(
scrollContainer.clientWidth,
scrollContainer.scrollHeight
)
streamScrollbarMetrics.onLayout({
nativeEvent: {
layout: {
width: scrollContainer.clientWidth,
height: scrollContainer.clientHeight,
x: 0,
y: 0,
},
},
} as never)
streamScrollbarMetrics.onScroll({
nativeEvent: {
contentOffset: { x: 0, y: scrollContainer.scrollTop },
contentSize: {
width: scrollContainer.clientWidth,
height: scrollContainer.scrollHeight,
},
layoutMeasurement: {
width: scrollContainer.clientWidth,
height: scrollContainer.clientHeight,
},
},
} as never)
syncNearBottom(scrollContainer, onNearBottomChange)
}, [onNearBottomChange, streamScrollbarMetrics])
const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) {
return
}
const currentScrollTop = scrollContainer.scrollTop
const isNearBottom = syncNearBottom(scrollContainer, onNearBottomChange)
const isAtBottom = isScrollContainerAtBottom(scrollContainer)
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON
if (!followOutputRef.current && isAtBottom) {
setFollowOutput(true)
pendingUserScrollUpIntentRef.current = false
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
if (scrolledUp) {
cancelPendingStickToBottom()
setFollowOutput(false)
}
pendingUserScrollUpIntentRef.current = false
} else if (followOutputRef.current && isPointerScrollActiveRef.current) {
if (scrolledUp) {
cancelPendingStickToBottom()
setFollowOutput(false)
}
}
lastKnownScrollTopRef.current = currentScrollTop
updateScrollMetrics()
}, [cancelPendingStickToBottom, onNearBottomChange, updateScrollMetrics])
useLayoutEffect(() => {
if (!isActivationReady) {
return
}
setFollowOutput(true)
forceStickToBottom()
const timeout = window.setTimeout(() => {
console.log('timeout', {
followOutputRef: followOutputRef.current,
scrollContainerRef: scrollContainerRef.current,
isScrollContainerNearBottom: scrollContainerRef.current
? isScrollContainerNearBottom(scrollContainerRef.current)
: null,
})
if (!followOutputRef.current) {
return
}
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) {
return
}
if (isScrollContainerNearBottom(scrollContainer)) {
return
}
scheduleStickToBottom('activation-timeout')
}, WEB_BOTTOM_SETTLE_TIMEOUT_MS)
return () => {
window.clearTimeout(timeout)
}
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom])
useEffect(() => {
if (!followOutputRef.current) {
return
}
scheduleStickToBottom('rows-effect')
}, [rows, scheduleStickToBottom])
useEffect(() => {
if (!followOutputRef.current) {
return
}
if (!webVirtualizedHistoryWindow) {
return
}
scheduleStickToBottom('virtual-total-size-effect')
}, [scheduleStickToBottom, virtualTotalSize, webVirtualizedHistoryWindow])
useEffect(() => {
updateScrollMetrics()
}, [updateScrollMetrics, virtualTotalSize, rows.length])
useEffect(() => {
const scrollContainer = scrollContainerRef.current
const contentNode = contentRef.current
if (!scrollContainer || typeof ResizeObserver === 'undefined') {
return
}
updateScrollMetrics()
const observer = new ResizeObserver(() => {
updateScrollMetrics()
if (!followOutputRef.current) {
return
}
scheduleStickToBottom('resize-observer')
})
observer.observe(scrollContainer)
if (contentNode) {
observer.observe(contentNode)
}
return () => {
observer.disconnect()
}
}, [scheduleStickToBottom, updateScrollMetrics])
useEffect(() => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) {
return
}
const handleWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
pendingUserScrollUpIntentRef.current = true
cancelPendingStickToBottom()
}
}
const handlePointerDown = () => {
isPointerScrollActiveRef.current = true
}
const handlePointerUp = () => {
isPointerScrollActiveRef.current = false
}
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches[0]
if (!touch) {
return
}
lastTouchClientYRef.current = touch.clientY
}
const handleTouchMove = (event: TouchEvent) => {
const touch = event.touches[0]
if (!touch) {
return
}
const previousTouchY = lastTouchClientYRef.current
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
pendingUserScrollUpIntentRef.current = true
cancelPendingStickToBottom()
}
lastTouchClientYRef.current = touch.clientY
}
const handleTouchEnd = () => {
lastTouchClientYRef.current = null
}
scrollContainer.addEventListener('scroll', handleDomScroll, { passive: true })
scrollContainer.addEventListener('wheel', handleWheel, { passive: true })
scrollContainer.addEventListener('pointerdown', handlePointerDown, { passive: true })
scrollContainer.addEventListener('pointerup', handlePointerUp, { passive: true })
scrollContainer.addEventListener('pointercancel', handlePointerUp, { passive: true })
scrollContainer.addEventListener('touchstart', handleTouchStart, { passive: true })
scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: true })
scrollContainer.addEventListener('touchend', handleTouchEnd, { passive: true })
scrollContainer.addEventListener('touchcancel', handleTouchEnd, { passive: true })
return () => {
scrollContainer.removeEventListener('scroll', handleDomScroll)
scrollContainer.removeEventListener('wheel', handleWheel)
scrollContainer.removeEventListener('pointerdown', handlePointerDown)
scrollContainer.removeEventListener('pointerup', handlePointerUp)
scrollContainer.removeEventListener('pointercancel', handlePointerUp)
scrollContainer.removeEventListener('touchstart', handleTouchStart)
scrollContainer.removeEventListener('touchmove', handleTouchMove)
scrollContainer.removeEventListener('touchend', handleTouchEnd)
scrollContainer.removeEventListener('touchcancel', handleTouchEnd)
}
}, [cancelPendingStickToBottom, handleDomScroll])
useEffect(() => {
const handle: StreamViewportHandle = {
scrollToBottom: () => {
setFollowOutput(true)
cancelPendingStickToBottom()
forceStickToBottom()
},
prepareForViewportChange: () => {
if (!followOutputRef.current) {
return
}
scheduleStickToBottom('prepare-for-viewport-change')
},
}
viewportRef.current = handle
return () => {
if (viewportRef.current === handle) {
viewportRef.current = null
}
cancelPendingStickToBottom()
}
}, [cancelPendingStickToBottom, scheduleStickToBottom, forceStickToBottom, viewportRef])
const contentContainerStyle = useMemo(
(): CSSProperties => ({
display: 'flex',
flexDirection: 'column',
minHeight: '100%',
paddingTop: 16,
paddingBottom: 16,
paddingLeft: isMobileBreakpoint ? 8 : 16,
paddingRight: isMobileBreakpoint ? 8 : 16,
boxSizing: 'border-box',
}),
[isMobileBreakpoint]
)
const scrollContainerStyle = useMemo(
(): CSSProperties => ({
flex: 1,
minHeight: 0,
overflowX: 'hidden',
overflowY: scrollEnabled ? 'auto' : 'hidden',
overscrollBehaviorY: 'contain',
}),
[scrollEnabled]
)
const headerEdgeContent = renderEdge(edgeSlotProps.ListHeaderComponent)
const footerEdgeContent = renderEdge(
edgeSlotProps.ListFooterComponent,
edgeSlotProps.ListFooterComponentStyle as CSSProperties | undefined
)
const virtualRowsContainerStyle = useMemo(
(): CSSProperties => ({
position: 'relative',
width: '100%',
height: virtualTotalSize,
}),
[virtualTotalSize]
)
const renderVirtualRowStyle = useCallback(
(start: number): CSSProperties => ({
position: 'absolute',
top: 0,
left: 0,
display: 'flex',
flexDirection: 'column',
width: '100%',
transform: `translateY(${start}px)`,
}),
[]
)
return (
<>
<style id={WEB_STREAM_SCROLLBAR_STYLE_ID}>{WEB_STREAM_SCROLLBAR_STYLE}</style>
<div
ref={(node) => {
scrollContainerRef.current = node
}}
data-testid="agent-chat-scroll"
id={`agent-chat-scroll-${shouldUseVirtualizer ? 'web-dom-virtualized' : 'web-dom-scroll'}`}
style={scrollContainerStyle}
>
<div
ref={(node) => {
contentRef.current = node
}}
style={contentContainerStyle}
>
{headerEdgeContent}
{webVirtualizedHistoryWindow ? (
<div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => {
const entry = virtualizedEntries[virtualRow.index]
if (!entry) {
return null
}
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
{renderRow(entry.item, entry.index, rows)}
</div>
)
})}
</div>
) : null}
{mountedEntries.map((entry) => (
<Fragment key={entry.item.id}>{renderRow(entry.item, entry.index, rows)}</Fragment>
))}
{rows.length === 0 ? listEmptyComponent : null}
{footerEdgeContent}
</div>
</div>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics}
inverted={false}
onScrollToOffset={(nextOffset) => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) {
return
}
scrollContainer.scrollTo({ top: nextOffset, behavior: 'auto' })
lastKnownScrollTopRef.current = scrollContainer.scrollTop
updateScrollMetrics()
}}
/>
</>
)
}
export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy {
return createStreamStrategy({
render: (renderInput) => (
<WebStreamViewport
key={renderInput.agentId}
{...renderInput}
isMobileBreakpoint={input.isMobileBreakpoint}
/>
),
orderTailReverse: false,
orderHeadReverse: false,
assistantTurnTraversalStep: -1,
edgeSlot: 'footer',
flatListInverted: false,
overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 0,
verificationRetryMode: 'rescroll',
},
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: true,
animateManualScrollToBottom: false,
useVirtualizedList: false,
isNearBottom: (inputMetrics) => {
const distanceFromBottom = Math.max(
0,
inputMetrics.contentHeight - (inputMetrics.offsetY + inputMetrics.viewportHeight)
)
return distanceFromBottom <= inputMetrics.threshold
},
getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight),
})
}

View File

@@ -0,0 +1,286 @@
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
import type {
BottomAnchorLocalRequest,
BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { createNativeStreamStrategy } from "./stream-strategy-native";
import { createWebStreamStrategy } from "./stream-strategy-web";
type EdgeSlot = "header" | "footer";
type NeighborRelation = "above" | "below";
type AssistantTurnTraversalStep = -1 | 1;
export type MaintainVisibleContentPositionConfig = Readonly<{
minIndexForVisible: number;
autoscrollToTopThreshold: number;
}>;
export type BottomAnchorTransportBehavior = Readonly<{
verificationDelayFrames: number;
verificationRetryMode: "rescroll" | "recheck";
}>;
export type StreamViewportMetrics = {
contentHeight: number;
viewportHeight: number;
};
export type StreamNearBottomInput = StreamViewportMetrics & {
offsetY: number;
threshold: number;
};
export type StreamEdgeSlotProps = {
ListHeaderComponent?: ReactElement | ComponentType<any> | null;
ListHeaderComponentStyle?: StyleProp<ViewStyle>;
ListFooterComponent?: ReactElement | ComponentType<any> | null;
ListFooterComponentStyle?: StyleProp<ViewStyle>;
};
export type StreamViewportHandle = {
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
};
export type StreamRenderInput = {
agentId: string;
rows: StreamItem[];
renderRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
listEmptyComponent: ReactNode;
viewportRef: RefObject<StreamViewportHandle | null>;
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
onNearBottomChange: (value: boolean) => void;
scrollEnabled: boolean;
listStyle: StyleProp<ViewStyle>;
baseListContentContainerStyle: StyleProp<ViewStyle>;
forwardListContentContainerStyle: StyleProp<ViewStyle>;
edgeSlotProps: StreamEdgeSlotProps;
};
export type ResolveStreamRenderStrategyInput = {
platform: string;
isMobileBreakpoint: boolean;
};
export interface StreamStrategy {
render: (input: StreamRenderInput) => ReactNode;
orderTail: (streamItems: StreamItem[]) => StreamItem[];
orderHead: (streamHead: StreamItem[]) => StreamItem[];
getNeighborIndex: (index: number, relation: NeighborRelation) => number;
getNeighborItem: (
items: StreamItem[],
index: number,
relation: NeighborRelation
) => StreamItem | undefined;
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
getEdgeSlotProps: (
component: ReactElement | ComponentType<any> | null,
gapSize: number
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () =>
| MaintainVisibleContentPositionConfig
| undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
shouldAnchorBottomOnContentSizeChange: () => boolean;
shouldAnimateManualScrollToBottom: () => boolean;
shouldUseVirtualizedList: () => boolean;
}
type StreamStrategyConfig = {
render: StreamStrategy["render"];
orderTailReverse: boolean;
orderHeadReverse: boolean;
assistantTurnTraversalStep: AssistantTurnTraversalStep;
edgeSlot: EdgeSlot;
flatListInverted: boolean;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
bottomAnchorTransportBehavior: BottomAnchorTransportBehavior;
disableParentScrollOnInlineDetailsExpansion: boolean;
anchorBottomOnContentSizeChange: boolean;
animateManualScrollToBottom: boolean;
useVirtualizedList: boolean;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
};
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
export function createStreamStrategy(
config: StreamStrategyConfig
): StreamStrategy {
return {
render: config.render,
orderTail: (streamItems) =>
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
orderHead: (streamHead) =>
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
getNeighborIndex: (index, relation) =>
relation === "above"
? index + config.assistantTurnTraversalStep
: index - config.assistantTurnTraversalStep,
getNeighborItem: (items, index, relation) => {
const neighborIndex =
relation === "above"
? index + config.assistantTurnTraversalStep
: index - config.assistantTurnTraversalStep;
if (neighborIndex < 0 || neighborIndex >= items.length) {
return undefined;
}
return items[neighborIndex];
},
collectAssistantTurnContent: (items, startIndex) => {
const messages: string[] = [];
for (
let index = startIndex;
index >= 0 && index < items.length;
index += config.assistantTurnTraversalStep
) {
const currentItem = items[index];
if (currentItem.kind === "user_message") {
break;
}
if (currentItem.kind === "assistant_message") {
messages.push(currentItem.text);
}
}
return messages.reverse().join("\n\n");
},
isNearBottom: (input) => config.isNearBottom(input),
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
getEdgeSlotProps: (component, gapSize) => {
if (config.edgeSlot === "header") {
return {
ListHeaderComponent: component,
ListHeaderComponentStyle: { marginBottom: gapSize },
};
}
return {
ListFooterComponent: component,
ListFooterComponentStyle: { marginTop: gapSize },
};
},
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
getFlatListInverted: () => config.flatListInverted,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
config.disableParentScrollOnInlineDetailsExpansion,
shouldAnchorBottomOnContentSizeChange: () =>
config.anchorBottomOnContentSizeChange,
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
shouldUseVirtualizedList: () => config.useVirtualizedList,
};
}
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput
): StreamStrategy {
if (input.platform === "web") {
return createWebStreamStrategy({
isMobileBreakpoint: input.isMobileBreakpoint,
});
}
return createNativeStreamStrategy();
}
export function resolveBottomAnchorTransportBehavior(input: {
strategy: StreamStrategy;
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",
};
}
export function orderTailForStreamRenderStrategy(params: {
strategy: StreamStrategy;
streamItems: StreamItem[];
}): StreamItem[] {
return params.strategy.orderTail(params.streamItems);
}
export function orderHeadForStreamRenderStrategy(params: {
strategy: StreamStrategy;
streamHead: StreamItem[];
}): StreamItem[] {
return params.strategy.orderHead(params.streamHead);
}
export function getStreamNeighborIndex(params: {
strategy: StreamStrategy;
index: number;
relation: NeighborRelation;
}): number {
return params.strategy.getNeighborIndex(params.index, params.relation);
}
export function getStreamNeighborItem(params: {
strategy: StreamStrategy;
items: StreamItem[];
index: number;
relation: NeighborRelation;
}): StreamItem | undefined {
return params.strategy.getNeighborItem(
params.items,
params.index,
params.relation
);
}
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): string {
return params.strategy.collectAssistantTurnContent(
params.items,
params.startIndex
);
}
export function isNearBottomForStreamRenderStrategy(
params: StreamNearBottomInput & { strategy: StreamStrategy }
): boolean {
return params.strategy.isNearBottom({
offsetY: params.offsetY,
threshold: params.threshold,
contentHeight: params.contentHeight,
viewportHeight: params.viewportHeight,
});
}
export function getBottomOffsetForStreamRenderStrategy(
params: StreamViewportMetrics & {
strategy: StreamStrategy;
}
): number {
return params.strategy.getBottomOffset({
contentHeight: params.contentHeight,
viewportHeight: params.viewportHeight,
});
}
export function getStreamEdgeSlotProps(params: {
strategy: StreamStrategy;
component: ReactElement | ComponentType<any> | null;
gapSize: number;
}): StreamEdgeSlotProps {
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
}

View File

@@ -133,6 +133,7 @@ function createDriverHarness(input?: {
});
const modeChanges: BottomAnchorMode[] = [];
const warnings: Array<{ agentId: string; reason: string }> = [];
const logs: Array<{ event: string; details: Record<string, unknown> }> = [];
const driver = __private__.createBottomAnchorControllerDriver({
getAgentId: () => context.agentId,
getIsAuthoritativeHistoryReady: () => context.authoritativeReady,
@@ -144,7 +145,9 @@ function createDriverHarness(input?: {
onModeChange: (mode) => {
modeChanges.push(mode);
},
log: () => {},
log: (event, details) => {
logs.push({ event, details });
},
warn: (details) => warnings.push(details),
scheduleFrame: (params) => scheduler.schedule(params),
cancelFrame: (handle) => scheduler.cancel(handle),
@@ -156,6 +159,7 @@ function createDriverHarness(input?: {
scheduler,
scrollToBottom,
modeChanges,
logs,
warnings,
};
}
@@ -367,6 +371,122 @@ describe("bottom anchor controller driver", () => {
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
it("does not stay blocked on post-layout verification after a retry-scroll request", () => {
const harness = createDriverHarness({
measurementState: createMeasurementState({
containerKey: "web-partial-virtualized",
viewportWidth: 828,
viewportHeight: 846,
contentHeight: 14322,
offsetY: 0,
viewportMeasuredForKey: "web-partial-virtualized",
contentMeasuredForKey: "web-partial-virtualized",
}),
isNearBottom: false,
});
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 13476;
});
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushFrame();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.measurementState.contentHeight = 14804;
harness.context.nearBottom = false;
harness.driver.handleContentSizeChange({
previousContentHeight: 14322,
contentHeight: 14804,
});
harness.scheduler.flushFrame();
expect(harness.driver.getSnapshot()).toMatchObject({
pendingRequest: {
reason: "resume",
},
pendingVerification: {
requestId: 1,
retries: 1,
},
});
harness.scheduler.flushFrame();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_post_layout_verification",
pendingRequest: {
reason: "resume",
},
pendingVerification: {
requestId: 1,
},
});
});
it("does not fulfill a web partial-virtualized resume request before a confirmation pass", () => {
const harness = createDriverHarness({
measurementState: createMeasurementState({
containerKey: "web-partial-virtualized",
viewportWidth: 828,
viewportHeight: 846,
contentHeight: 14322,
offsetY: 0,
viewportMeasuredForKey: "web-partial-virtualized",
contentMeasuredForKey: "web-partial-virtualized",
}),
isNearBottom: false,
});
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = Math.max(
0,
harness.context.measurementState.contentHeight -
harness.context.measurementState.viewportHeight
);
harness.context.nearBottom = true;
});
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume-confirmation",
});
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.driver.getSnapshot()).toMatchObject({
pendingRequest: {
reason: "resume",
},
blockedReason: "waiting_for_post_layout_verification",
});
harness.context.measurementState.contentHeight = 16230;
harness.context.nearBottom = false;
harness.driver.handleContentSizeChange({
previousContentHeight: 14322,
contentHeight: 16230,
});
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({
reason: "resume",
});
});
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;

View File

@@ -44,6 +44,10 @@ type ControllerMeasurementState = {
type AttemptContext = {
requestId: number | null;
retries: number;
confirmationPasses?: number;
startedContentHeight?: number;
startedOffsetY?: number;
startedViewportHeight?: number;
};
type ScheduledFrameHandle = {
@@ -55,12 +59,14 @@ type ScheduledFrameHandle = {
type BottomAnchorEvent =
| "request_created"
| "evaluate_called"
| "attempt_started"
| "attempt_verified"
| "attempt_failed"
| "request_fulfilled"
| "request_cancelled"
| "detached_by_user"
| "verification_scheduled"
| "blocked_reason_changed";
type BottomAnchorControllerDriver = {
@@ -115,6 +121,7 @@ type CreateBottomAnchorControllerDriverInput = {
};
const MAX_VERIFICATION_RETRIES = 3;
const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1;
const USER_SCROLL_AWAY_DELTA_PX = 24;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
@@ -233,15 +240,41 @@ function deriveRetryDisposition(input: {
: "retry-scroll";
}
function shouldRequireRouteRequestConfirmation(input: {
request: BottomAnchorRequest | null;
measurementState: ControllerMeasurementState;
confirmationPasses: number;
}): boolean {
if (!input.request) {
return false;
}
if (
input.request.reason !== "initial-entry" &&
input.request.reason !== "resume"
) {
return false;
}
if (input.measurementState.containerKey !== "web-partial-virtualized") {
return false;
}
return input.confirmationPasses < 1;
}
function getDetailedMeasurementState(
measurementState: ControllerMeasurementState
): Record<string, unknown> {
const distanceFromBottom = Math.max(
0,
measurementState.contentHeight -
(measurementState.offsetY + measurementState.viewportHeight)
);
return {
containerKey: measurementState.containerKey,
viewportWidth: measurementState.viewportWidth,
viewportHeight: measurementState.viewportHeight,
contentHeight: measurementState.contentHeight,
offsetY: measurementState.offsetY,
distanceFromBottom,
viewportMeasuredForKey: measurementState.viewportMeasuredForKey,
contentMeasuredForKey: measurementState.contentMeasuredForKey,
};
@@ -263,6 +296,11 @@ function createBottomAnchorControllerDriver(
const getLogContext = (extra?: Record<string, unknown>) => {
const measurementState = input.getMeasurementState();
const distanceFromBottom = Math.max(
0,
measurementState.contentHeight -
(measurementState.offsetY + measurementState.viewportHeight)
);
return {
agentId: input.getAgentId(),
requestReason: pendingRequest?.reason ?? null,
@@ -270,10 +308,12 @@ function createBottomAnchorControllerDriver(
contentHeight: measurementState.contentHeight,
viewportHeight: measurementState.viewportHeight,
offset: measurementState.offsetY,
distanceFromBottom,
renderStrategy: input.getRenderStrategy(),
blockedReason,
mode,
containerKey: measurementState.containerKey,
transportBehavior: input.getTransportBehavior(),
...extra,
};
};
@@ -339,13 +379,42 @@ function createBottomAnchorControllerDriver(
setBlockedReason(null);
};
const scheduleVerification = (attemptContext: AttemptContext) => {
const deriveDriverBlockedReason = (
measurementState: ControllerMeasurementState
) =>
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
measurementState,
pendingVerificationRequestId:
verificationHandle !== null ? pendingVerification?.requestId ?? null : null,
});
const scheduleVerification = (
attemptContext: AttemptContext,
delayFramesOverride?: number
) => {
const scheduledMeasurementState = input.getMeasurementState();
if (verificationHandle) {
input.cancelFrame(verificationHandle);
}
input.log(
"verification_scheduled",
getLogContext({
retries: attemptContext.retries,
startedContentHeight: attemptContext.startedContentHeight ?? null,
startedOffsetY: attemptContext.startedOffsetY ?? null,
startedViewportHeight: attemptContext.startedViewportHeight ?? null,
scheduledMeasurementState:
getDetailedMeasurementState(scheduledMeasurementState),
verificationDelayFrames:
delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames,
})
);
verificationHandle = input.scheduleFrame({
kind: "verification",
delayFrames: input.getTransportBehavior().verificationDelayFrames,
delayFrames:
delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames,
callback: () => {
verificationHandle = null;
const currentRequest = pendingRequest;
@@ -388,11 +457,40 @@ function createBottomAnchorControllerDriver(
verifiedNearBottom,
retries: attemptContext.retries,
retryDisposition,
contentHeightDeltaSinceAttempt:
measurementState.contentHeight -
(attemptContext.startedContentHeight ?? measurementState.contentHeight),
offsetDeltaSinceAttempt:
measurementState.offsetY -
(attemptContext.startedOffsetY ?? measurementState.offsetY),
viewportHeightDeltaSinceAttempt:
measurementState.viewportHeight -
(attemptContext.startedViewportHeight ??
measurementState.viewportHeight),
measurementState: getDetailedMeasurementState(measurementState),
})
);
if (verifiedNearBottom) {
if (
isRequestAttempt &&
shouldRequireRouteRequestConfirmation({
request: currentRequest,
measurementState,
confirmationPasses: attemptContext.confirmationPasses ?? 0,
})
) {
pendingVerification = {
...attemptContext,
confirmationPasses: (attemptContext.confirmationPasses ?? 0) + 1,
};
setBlockedReason("waiting_for_post_layout_verification");
scheduleVerification(
pendingVerification,
WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES
);
return;
}
pendingVerification = null;
markStickyMeasurementVerified();
if (isRequestAttempt) {
@@ -418,7 +516,7 @@ function createBottomAnchorControllerDriver(
requestId: attemptContext.requestId,
retries: attemptContext.retries + 1,
};
evaluate(false);
evaluate(false, "retry_scroll");
return;
}
@@ -445,28 +543,51 @@ function createBottomAnchorControllerDriver(
};
const runAttempt = (animated: boolean) => {
const measurementState = input.getMeasurementState();
const attemptContext: AttemptContext = {
requestId: pendingRequest?.id ?? null,
retries: pendingVerification?.retries ?? 0,
startedContentHeight: measurementState.contentHeight,
startedOffsetY: measurementState.offsetY,
startedViewportHeight: measurementState.viewportHeight,
};
pendingVerification = attemptContext;
input.log(
"attempt_started",
getLogContext({ animated, retries: attemptContext.retries })
getLogContext({
animated,
retries: attemptContext.retries,
measurementState: getDetailedMeasurementState(measurementState),
})
);
input.scrollToBottom(animated);
scheduleVerification(attemptContext);
setBlockedReason(
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
measurementState: input.getMeasurementState(),
pendingVerificationRequestId: attemptContext.requestId,
})
);
setBlockedReason(deriveDriverBlockedReason(input.getMeasurementState()));
};
const evaluate = (animated: boolean) => {
const evaluate = (
animated: boolean,
reason:
| "request_created"
| "viewport_change"
| "content_size_change"
| "scroll_near_bottom_change"
| "history_readiness_change"
| "manual_reevaluate"
| "retry_scroll"
) => {
input.log(
"evaluate_called",
getLogContext({
evaluateReason: reason,
animated,
hasAttemptHandle: attemptHandle !== null,
hasVerificationHandle: verificationHandle !== null,
pendingVerificationRequestId: pendingVerification?.requestId ?? null,
pendingVerificationRetries: pendingVerification?.retries ?? null,
measurementState: getDetailedMeasurementState(input.getMeasurementState()),
})
);
if (attemptHandle) {
return;
}
@@ -475,12 +596,7 @@ function createBottomAnchorControllerDriver(
callback: () => {
attemptHandle = null;
const measurementState = input.getMeasurementState();
const nextBlockedReason = deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
measurementState,
pendingVerificationRequestId: pendingVerification?.requestId ?? null,
});
const nextBlockedReason = deriveDriverBlockedReason(measurementState);
setBlockedReason(nextBlockedReason);
const shouldAttemptForPendingRequest =
@@ -498,6 +614,7 @@ function createBottomAnchorControllerDriver(
"attempt_started",
getLogContext({
attemptPhase: "skipped",
evaluateReason: reason,
nextBlockedReason,
shouldAttemptForPendingRequest,
shouldAttemptForStickyVerification,
@@ -546,7 +663,7 @@ function createBottomAnchorControllerDriver(
"request_created",
getLogContext({ requestReason: request.reason })
);
evaluate(request.reason === "jump-to-bottom");
evaluate(request.reason === "jump-to-bottom", "request_created");
};
return {
@@ -610,7 +727,7 @@ function createBottomAnchorControllerDriver(
pendingVerification = { requestId: null, retries: 0 };
}
if (shouldRestick || pendingRequest) {
evaluate(false);
evaluate(false, "viewport_change");
}
},
handleContentSizeChange(params) {
@@ -626,7 +743,7 @@ function createBottomAnchorControllerDriver(
pendingVerification = { requestId: null, retries: 0 };
}
if (shouldRestick || pendingRequest) {
evaluate(false);
evaluate(false, "content_size_change");
}
},
prepareForStickyViewportChange() {
@@ -673,21 +790,21 @@ function createBottomAnchorControllerDriver(
if (!pendingRequest && !pendingVerification) {
pendingVerification = { requestId: null, retries: 0 };
}
evaluate(false);
evaluate(false, "scroll_near_bottom_change");
return;
}
if (nextIsNearBottom && pendingRequest) {
evaluate(false);
evaluate(false, "scroll_near_bottom_change");
}
},
notifyAuthoritativeHistoryMaybeChanged() {
if (!pendingVerification && !pendingRequest) {
return;
}
evaluate(false);
evaluate(false, "history_readiness_change");
},
reevaluate(animated = false) {
evaluate(animated);
evaluate(animated, "manual_reevaluate");
},
};
}