diff --git a/package-lock.json b/package-lock.json
index 48b53305f..be481e20b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/packages/app/package.json b/packages/app/package.json
index 3563df269..2d5424db9 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -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",
diff --git a/packages/app/src/app/+html.tsx b/packages/app/src/app/+html.tsx
deleted file mode 100644
index 95a3ea3fb..000000000
--- a/packages/app/src/app/+html.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
-
-export default function Root({ children }: PropsWithChildren) {
- return (
-
-
-
-
-
- {/* Reset scroll styles so React Native Web views behave like native. */}
-
-
-
- {children}
-
- );
-}
diff --git a/packages/app/src/components/agent-stream-render-strategy.ts b/packages/app/src/components/agent-stream-render-strategy.ts
index a4169efc4..378455bcb 100644
--- a/packages/app/src/components/agent-stream-render-strategy.ts
+++ b/packages/app/src/components/agent-stream-render-strategy.ts
@@ -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 | null;
- ListHeaderComponentStyle?: StyleProp;
- ListFooterComponent?: ReactElement | ComponentType | null;
- ListFooterComponentStyle?: StyleProp;
-};
-
-export type StreamRenderRefs = {
- flatListRef: RefObject | null>;
- scrollViewRef: RefObject;
- bottomAnchorRef: RefObject;
-};
-
-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 | 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
-): void {
- if (!(globalThis as { __DEV__?: boolean }).__DEV__) {
- return;
- }
- console.debug("[BottomAnchorTransport]", event, details);
-}
-
-function getResolvedWebScrollContainerMetrics(
- refs: StreamRenderRefs
-): Record {
- 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 | null;
- gapSize: number;
-}): StreamEdgeSlotProps {
- return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
-}
+export * from "./stream-strategy";
diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index 77fc829ad..386ad814d 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -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["separators"] = {
- highlight: () => {},
- unhighlight: () => {},
- updateProps: () => {},
-};
-
-function renderStreamEdgeComponent(
- component: ReactElement | ComponentType | 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>(null);
- const scrollViewRef = useRef(null);
- const bottomAnchorRef = useRef(null);
+ const viewportRef = useRef(null);
const { theme } = useUnistyles();
const router = useRouter();
const isMobile =
@@ -160,65 +116,11 @@ export const AgentStreamView = forwardRef(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(null);
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState>(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 {
- 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 {
- 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(
- () => 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) => {
- 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) => {
- 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
collectAssistantTurnContentForStreamRenderStrategy({
strategy: streamRenderStrategy,
- items: flatListData,
+ items,
startIndex: index,
});
@@ -879,7 +462,6 @@ export const AgentStreamView = forwardRef {
- 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) => {
- const rendered = renderIndexedStreamItem(item);
- if (!rendered) {
- return null;
- }
- return {rendered};
- },
- [renderIndexedStreamItem]
- );
const listEdgeSlotComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
@@ -1054,19 +607,6 @@ export const AgentStreamView = forwardRef ({
- pendingPermissionCount: pendingPermissionItems.length,
- showWorkingIndicator,
- showBottomBar,
- }),
- [
- pendingPermissionItems.length,
- showWorkingIndicator,
- showBottomBar,
- ]
- );
-
const listEdgeSlotProps = useMemo(() => {
if (!listEdgeSlotComponent) {
return {};
@@ -1116,171 +656,27 @@ export const AgentStreamView = forwardRef
- 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 {rendered};
- });
- }, [indexedFlatListData, renderIndexedStreamItem]);
- const webVirtualizedListHeader = useMemo(() => {
- if (!shouldUseWebPartialVirtualization || !headerEdgeContent) {
- return null;
- }
- return (
-
- {headerEdgeContent}
-
- );
- }, [
- headerEdgeContent,
- listEdgeSlotProps.ListHeaderComponentStyle,
- shouldUseWebPartialVirtualization,
- ]);
- const webVirtualizedListFooter = useMemo(() => {
- if (!shouldUseWebPartialVirtualization || !webVirtualizedHistoryWindow) {
- return null;
- }
-
- return (
- <>
- {webVirtualizedHistoryWindow.mountedEntries.map((entry) => (
-
- {renderIndexedStreamItem(entry)}
-
- ))}
- {footerEdgeContent ? (
-
- {footerEdgeContent}
-
- ) : null}
-
- >
- );
- }, [
- footerEdgeContent,
- listEdgeSlotProps.ListFooterComponentStyle,
- renderIndexedStreamItem,
- shouldUseWebPartialVirtualization,
- webVirtualizedHistoryWindow,
- ]);
return (
- {usesVirtualizedList ? (
- 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 ? (
- 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}
- />
- ) : (
-
- {headerEdgeContent ? (
-
- {headerEdgeContent}
-
- ) : null}
- {nonVirtualizedItems}
- {flatListData.length === 0 ? listEmptyComponent : null}
- {footerEdgeContent ? (
-
- {footerEdgeContent}
-
- ) : null}
-
-
- )}
+ {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,
+ })}
- {
- streamRenderStrategy.scrollToOffset({
- refs: streamRenderRefs,
- offset: nextOffset,
- animated: false,
- });
- }}
- />
-
- {/* Scroll to bottom button */}
{!isNearBottom && (
>(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(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) => {
+ 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) => {
+ const rendered = renderRow(item, index, rows);
+ return rendered ? {rendered} : null;
+ },
+ [renderRow, rows]
+ );
+
+ return (
+ 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 ? () => {listEmptyComponent} : undefined
+ }
+ maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
+ initialNumToRender={12}
+ windowSize={10}
+ scrollEnabled={scrollEnabled}
+ showsVerticalScrollIndicator
+ inverted
+ />
+ );
+}
+
+export function createNativeStreamStrategy(): StreamStrategy {
+ const strategy = createStreamStrategy({
+ render: (renderInput) => (
+
+ ),
+ 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;
+}
diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/components/stream-strategy-web.tsx
new file mode 100644
index 000000000..4d41c2400
--- /dev/null
+++ b/packages/app/src/components/stream-strategy-web.tsx
@@ -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,
+ 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
+): 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 (
+
+ {rendered}
+
+ )
+}
+
+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
+): number {
+ return scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop
+}
+
+function isScrollContainerOverscrolledPastBottom(
+ scrollContainer: Pick
+): 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(null)
+ const contentRef = useRef(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(null)
+ const pendingAutoScrollFrameRef = useRef(null)
+ const pendingAutoScrollTimeoutRef = useRef(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 (
+ <>
+
+ {
+ scrollContainerRef.current = node
+ }}
+ data-testid="agent-chat-scroll"
+ id={`agent-chat-scroll-${shouldUseVirtualizer ? 'web-dom-virtualized' : 'web-dom-scroll'}`}
+ style={scrollContainerStyle}
+ >
+
{
+ contentRef.current = node
+ }}
+ style={contentContainerStyle}
+ >
+ {headerEdgeContent}
+ {webVirtualizedHistoryWindow ? (
+
+ {virtualRows.map((virtualRow) => {
+ const entry = virtualizedEntries[virtualRow.index]
+ if (!entry) {
+ return null
+ }
+ return (
+
+ {renderRow(entry.item, entry.index, rows)}
+
+ )
+ })}
+
+ ) : null}
+ {mountedEntries.map((entry) => (
+
{renderRow(entry.item, entry.index, rows)}
+ ))}
+ {rows.length === 0 ? listEmptyComponent : null}
+ {footerEdgeContent}
+
+
+ {
+ 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) => (
+
+ ),
+ 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),
+ })
+}
diff --git a/packages/app/src/components/stream-strategy.ts b/packages/app/src/components/stream-strategy.ts
new file mode 100644
index 000000000..b801e3675
--- /dev/null
+++ b/packages/app/src/components/stream-strategy.ts
@@ -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 | null;
+ ListHeaderComponentStyle?: StyleProp;
+ ListFooterComponent?: ReactElement | ComponentType | null;
+ ListFooterComponentStyle?: StyleProp;
+};
+
+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;
+ routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
+ isAuthoritativeHistoryReady: boolean;
+ onNearBottomChange: (value: boolean) => void;
+ scrollEnabled: boolean;
+ listStyle: StyleProp;
+ baseListContentContainerStyle: StyleProp;
+ forwardListContentContainerStyle: StyleProp;
+ 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 | 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 | null;
+ gapSize: number;
+}): StreamEdgeSlotProps {
+ return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
+}
diff --git a/packages/app/src/components/use-bottom-anchor-controller.test.ts b/packages/app/src/components/use-bottom-anchor-controller.test.ts
index 75b897c75..84120424f 100644
--- a/packages/app/src/components/use-bottom-anchor-controller.test.ts
+++ b/packages/app/src/components/use-bottom-anchor-controller.test.ts
@@ -133,6 +133,7 @@ function createDriverHarness(input?: {
});
const modeChanges: BottomAnchorMode[] = [];
const warnings: Array<{ agentId: string; reason: string }> = [];
+ const logs: Array<{ event: string; details: Record }> = [];
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;
diff --git a/packages/app/src/components/use-bottom-anchor-controller.ts b/packages/app/src/components/use-bottom-anchor-controller.ts
index b7076c9d5..99867341d 100644
--- a/packages/app/src/components/use-bottom-anchor-controller.ts
+++ b/packages/app/src/components/use-bottom-anchor-controller.ts
@@ -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 {
+ 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) => {
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");
},
};
}