Split stream rendering into platform-specific strategies
This commit is contained in:
Generated
+28
@@ -6115,6 +6115,23 @@
|
|||||||
"react-dom": "^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/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": {
|
"node_modules/@tanstack/router-core": {
|
||||||
"version": "1.149.3",
|
"version": "1.149.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -6363,6 +6380,16 @@
|
|||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"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": {
|
"node_modules/@tanstack/virtual-file-routes": {
|
||||||
"version": "1.145.4",
|
"version": "1.145.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -21597,6 +21624,7 @@
|
|||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
"@tauri-apps/api": "^2.9.1",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-unicode11": "^0.9.0",
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
"@tauri-apps/api": "^2.9.1",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-unicode11": "^0.9.0",
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { ScrollViewStyleReset } from "expo-router/html";
|
|
||||||
import type { PropsWithChildren } from "react";
|
|
||||||
|
|
||||||
// Ensure Unistyles runs before Expo Router statically renders each page.
|
|
||||||
import "../styles/unistyles";
|
|
||||||
|
|
||||||
const webEcosystemStyles = /* css */ `
|
|
||||||
html {
|
|
||||||
touch-action: auto;
|
|
||||||
height: 100%;
|
|
||||||
background-color: #18181c;
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
-webkit-user-select: text;
|
|
||||||
user-select: text;
|
|
||||||
background-color: #18181c;
|
|
||||||
}
|
|
||||||
|
|
||||||
body * {
|
|
||||||
-webkit-user-select: text;
|
|
||||||
user-select: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-testid="sidebar-agent-list-scroll"],
|
|
||||||
[data-testid="agent-chat-scroll"],
|
|
||||||
[data-testid="git-diff-scroll"],
|
|
||||||
[data-testid="file-explorer-tree-scroll"] {
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-testid="sidebar-agent-list-scroll"]::-webkit-scrollbar,
|
|
||||||
[data-testid="agent-chat-scroll"]::-webkit-scrollbar,
|
|
||||||
[data-testid="git-diff-scroll"]::-webkit-scrollbar,
|
|
||||||
[data-testid="file-explorer-tree-scroll"]::-webkit-scrollbar {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
min-height: 100%;
|
|
||||||
background-color: #18181c;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function WebRespectfulStyleReset() {
|
|
||||||
return (
|
|
||||||
<style
|
|
||||||
id="paseo-web-ecosystem"
|
|
||||||
dangerouslySetInnerHTML={{ __html: webEcosystemStyles }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Root({ children }: PropsWithChildren) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charSet="utf-8" />
|
|
||||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=yes, minimum-scale=1, maximum-scale=5"
|
|
||||||
/>
|
|
||||||
{/* Reset scroll styles so React Native Web views behave like native. */}
|
|
||||||
<ScrollViewStyleReset />
|
|
||||||
<WebRespectfulStyleReset />
|
|
||||||
</head>
|
|
||||||
<body>{children}</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,543 +1 @@
|
|||||||
import type { ComponentType, ReactElement, RefObject } from "react";
|
export * from "./stream-strategy";
|
||||||
import type { FlatList, ScrollView, StyleProp, View, ViewStyle } from "react-native";
|
|
||||||
import type { StreamItem } from "@/types/stream";
|
|
||||||
|
|
||||||
type EdgeSlot = "header" | "footer";
|
|
||||||
type NeighborRelation = "above" | "below";
|
|
||||||
type AssistantTurnTraversalStep = -1 | 1;
|
|
||||||
|
|
||||||
export type MaintainVisibleContentPositionConfig = Readonly<{
|
|
||||||
minIndexForVisible: number;
|
|
||||||
autoscrollToTopThreshold: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type BottomAnchorTransportBehavior = Readonly<{
|
|
||||||
verificationDelayFrames: number;
|
|
||||||
verificationRetryMode: "rescroll" | "recheck";
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type StreamViewportMetrics = {
|
|
||||||
contentHeight: number;
|
|
||||||
viewportHeight: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StreamNearBottomInput = StreamViewportMetrics & {
|
|
||||||
offsetY: number;
|
|
||||||
threshold: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StreamEdgeSlotProps = {
|
|
||||||
ListHeaderComponent?: ReactElement | ComponentType<any> | null;
|
|
||||||
ListHeaderComponentStyle?: StyleProp<ViewStyle>;
|
|
||||||
ListFooterComponent?: ReactElement | ComponentType<any> | null;
|
|
||||||
ListFooterComponentStyle?: StyleProp<ViewStyle>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StreamRenderRefs = {
|
|
||||||
flatListRef: RefObject<FlatList<any> | null>;
|
|
||||||
scrollViewRef: RefObject<ScrollView | null>;
|
|
||||||
bottomAnchorRef: RefObject<View | null>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ResolveStreamRenderStrategyInput = {
|
|
||||||
platform: string;
|
|
||||||
isMobileBreakpoint: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface StreamRenderStrategy {
|
|
||||||
orderTail: (streamItems: StreamItem[]) => StreamItem[];
|
|
||||||
orderHead: (streamHead: StreamItem[]) => StreamItem[];
|
|
||||||
getNeighborIndex: (index: number, relation: NeighborRelation) => number;
|
|
||||||
getNeighborItem: (
|
|
||||||
items: StreamItem[],
|
|
||||||
index: number,
|
|
||||||
relation: NeighborRelation
|
|
||||||
) => StreamItem | undefined;
|
|
||||||
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
|
|
||||||
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
|
||||||
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
|
||||||
getEdgeSlotProps: (
|
|
||||||
component: ReactElement | ComponentType<any> | null,
|
|
||||||
gapSize: number
|
|
||||||
) => StreamEdgeSlotProps;
|
|
||||||
getMaintainVisibleContentPosition: () =>
|
|
||||||
| MaintainVisibleContentPositionConfig
|
|
||||||
| undefined;
|
|
||||||
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
|
|
||||||
getFlatListInverted: () => boolean;
|
|
||||||
getOverlayScrollbarInverted: () => boolean;
|
|
||||||
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
|
|
||||||
shouldAnchorBottomOnContentSizeChange: () => boolean;
|
|
||||||
shouldAnimateManualScrollToBottom: () => boolean;
|
|
||||||
shouldUseVirtualizedList: () => boolean;
|
|
||||||
scrollToBottom: (params: {
|
|
||||||
refs: StreamRenderRefs;
|
|
||||||
metrics: StreamViewportMetrics;
|
|
||||||
animated: boolean;
|
|
||||||
}) => void;
|
|
||||||
scrollToOffset: (params: {
|
|
||||||
refs: StreamRenderRefs;
|
|
||||||
offset: number;
|
|
||||||
animated: boolean;
|
|
||||||
}) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
|
|
||||||
|
|
||||||
type StreamRenderStrategyConfig = {
|
|
||||||
orderTailReverse: boolean;
|
|
||||||
orderHeadReverse: boolean;
|
|
||||||
assistantTurnTraversalStep: AssistantTurnTraversalStep;
|
|
||||||
edgeSlot: EdgeSlot;
|
|
||||||
flatListInverted: boolean;
|
|
||||||
overlayScrollbarInverted: boolean;
|
|
||||||
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
|
|
||||||
bottomAnchorTransportBehavior: BottomAnchorTransportBehavior;
|
|
||||||
disableParentScrollOnInlineDetailsExpansion: boolean;
|
|
||||||
anchorBottomOnContentSizeChange: boolean;
|
|
||||||
animateManualScrollToBottom: boolean;
|
|
||||||
useVirtualizedList: boolean;
|
|
||||||
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
|
||||||
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
|
||||||
scrollToBottom: (params: {
|
|
||||||
refs: StreamRenderRefs;
|
|
||||||
metrics: StreamViewportMetrics;
|
|
||||||
animated: boolean;
|
|
||||||
}) => void;
|
|
||||||
scrollToOffset: (params: {
|
|
||||||
refs: StreamRenderRefs;
|
|
||||||
offset: number;
|
|
||||||
animated: boolean;
|
|
||||||
}) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION: MaintainVisibleContentPositionConfig =
|
|
||||||
Object.freeze({
|
|
||||||
minIndexForVisible: 0,
|
|
||||||
autoscrollToTopThreshold: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
function scrollAnchorIntoView(params: {
|
|
||||||
refs: StreamRenderRefs;
|
|
||||||
animated: boolean;
|
|
||||||
}): boolean {
|
|
||||||
const anchorHandle = params.refs.bottomAnchorRef.current as
|
|
||||||
| ({ getNativeRef?: () => unknown; scrollIntoView?: (options?: unknown) => void } &
|
|
||||||
object)
|
|
||||||
| null;
|
|
||||||
if (!anchorHandle) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeNative =
|
|
||||||
typeof anchorHandle.getNativeRef === "function"
|
|
||||||
? anchorHandle.getNativeRef()
|
|
||||||
: anchorHandle;
|
|
||||||
|
|
||||||
const domElement = maybeNative as { scrollIntoView?: (options?: unknown) => void };
|
|
||||||
if (typeof domElement.scrollIntoView !== "function") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
domElement.scrollIntoView({
|
|
||||||
block: "end",
|
|
||||||
behavior: params.animated ? "smooth" : "auto",
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePotentialScrollNode(input: unknown): HTMLElement | null {
|
|
||||||
if (!(input instanceof HTMLElement)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (input.scrollHeight - input.clientHeight > 1) {
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
let node: HTMLElement | null = input.parentElement;
|
|
||||||
while (node) {
|
|
||||||
if (node.scrollHeight - node.clientHeight > 1) {
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
node = node.parentElement;
|
|
||||||
}
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveWebScrollContainerNode(
|
|
||||||
refs: StreamRenderRefs
|
|
||||||
): HTMLElement | null {
|
|
||||||
|
|
||||||
const scrollViewHandle = refs.scrollViewRef.current as
|
|
||||||
| {
|
|
||||||
getNativeScrollRef?: () => unknown;
|
|
||||||
getScrollableNode?: () => unknown;
|
|
||||||
getInnerViewNode?: () => unknown;
|
|
||||||
getNativeRef?: () => unknown;
|
|
||||||
}
|
|
||||||
| null;
|
|
||||||
const anchorHandle = refs.bottomAnchorRef.current as
|
|
||||||
| ({ getNativeRef?: () => unknown } & object)
|
|
||||||
| null;
|
|
||||||
|
|
||||||
const candidates: unknown[] = [
|
|
||||||
scrollViewHandle?.getNativeScrollRef?.(),
|
|
||||||
scrollViewHandle?.getScrollableNode?.(),
|
|
||||||
scrollViewHandle?.getInnerViewNode?.(),
|
|
||||||
scrollViewHandle?.getNativeRef?.(),
|
|
||||||
scrollViewHandle,
|
|
||||||
typeof anchorHandle?.getNativeRef === "function"
|
|
||||||
? anchorHandle.getNativeRef()
|
|
||||||
: anchorHandle,
|
|
||||||
];
|
|
||||||
|
|
||||||
let scrollNode: HTMLElement | null = null;
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
scrollNode = resolvePotentialScrollNode(candidate);
|
|
||||||
if (scrollNode) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!scrollNode && typeof document !== "undefined") {
|
|
||||||
scrollNode = resolvePotentialScrollNode(
|
|
||||||
document.querySelector("[data-testid='agent-chat-scroll']")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return scrollNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveBottomAnchorTransportBehavior(input: {
|
|
||||||
strategy: StreamRenderStrategy;
|
|
||||||
isViewportSettling: boolean;
|
|
||||||
}): BottomAnchorTransportBehavior {
|
|
||||||
const baseBehavior = input.strategy.getBottomAnchorTransportBehavior();
|
|
||||||
if (!input.isViewportSettling || !input.strategy.getFlatListInverted()) {
|
|
||||||
return baseBehavior;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
verificationDelayFrames: Math.max(
|
|
||||||
baseBehavior.verificationDelayFrames,
|
|
||||||
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES
|
|
||||||
),
|
|
||||||
verificationRetryMode: "recheck",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function forceScrollContainerToBottom(
|
|
||||||
refs: StreamRenderRefs,
|
|
||||||
fallbackOffset: number
|
|
||||||
): void {
|
|
||||||
const scrollNode = resolveWebScrollContainerNode(refs);
|
|
||||||
if (!scrollNode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snap = () => {
|
|
||||||
scrollNode.scrollTop = Math.max(
|
|
||||||
fallbackOffset,
|
|
||||||
scrollNode.scrollHeight - scrollNode.clientHeight
|
|
||||||
);
|
|
||||||
};
|
|
||||||
snap();
|
|
||||||
if (typeof requestAnimationFrame === "function") {
|
|
||||||
requestAnimationFrame(snap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logWebBottomAnchorTransport(
|
|
||||||
event: string,
|
|
||||||
details: Record<string, unknown>
|
|
||||||
): void {
|
|
||||||
if (!(globalThis as { __DEV__?: boolean }).__DEV__) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.debug("[BottomAnchorTransport]", event, details);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResolvedWebScrollContainerMetrics(
|
|
||||||
refs: StreamRenderRefs
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const scrollNode = resolveWebScrollContainerNode(refs);
|
|
||||||
if (!scrollNode) {
|
|
||||||
return { hasScrollNode: false };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
hasScrollNode: true,
|
|
||||||
scrollTop: scrollNode.scrollTop,
|
|
||||||
scrollHeight: scrollNode.scrollHeight,
|
|
||||||
clientHeight: scrollNode.clientHeight,
|
|
||||||
tagName: scrollNode.tagName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStreamRenderStrategy(
|
|
||||||
config: StreamRenderStrategyConfig
|
|
||||||
): StreamRenderStrategy {
|
|
||||||
return {
|
|
||||||
orderTail: (streamItems) =>
|
|
||||||
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
|
|
||||||
orderHead: (streamHead) =>
|
|
||||||
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
|
|
||||||
getNeighborIndex: (index, relation) =>
|
|
||||||
relation === "above"
|
|
||||||
? index + config.assistantTurnTraversalStep
|
|
||||||
: index - config.assistantTurnTraversalStep,
|
|
||||||
getNeighborItem: (items, index, relation) => {
|
|
||||||
const neighborIndex =
|
|
||||||
relation === "above"
|
|
||||||
? index + config.assistantTurnTraversalStep
|
|
||||||
: index - config.assistantTurnTraversalStep;
|
|
||||||
if (neighborIndex < 0 || neighborIndex >= items.length) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return items[neighborIndex];
|
|
||||||
},
|
|
||||||
collectAssistantTurnContent: (items, startIndex) => {
|
|
||||||
const messages: string[] = [];
|
|
||||||
for (
|
|
||||||
let index = startIndex;
|
|
||||||
index >= 0 && index < items.length;
|
|
||||||
index += config.assistantTurnTraversalStep
|
|
||||||
) {
|
|
||||||
const currentItem = items[index];
|
|
||||||
if (currentItem.kind === "user_message") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (currentItem.kind === "assistant_message") {
|
|
||||||
messages.push(currentItem.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return messages.reverse().join("\n\n");
|
|
||||||
},
|
|
||||||
isNearBottom: (input) => config.isNearBottom(input),
|
|
||||||
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
|
|
||||||
getEdgeSlotProps: (component, gapSize) => {
|
|
||||||
if (config.edgeSlot === "header") {
|
|
||||||
return {
|
|
||||||
ListHeaderComponent: component,
|
|
||||||
ListHeaderComponentStyle: { marginBottom: gapSize },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ListFooterComponent: component,
|
|
||||||
ListFooterComponentStyle: { marginTop: gapSize },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
|
|
||||||
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
|
|
||||||
getFlatListInverted: () => config.flatListInverted,
|
|
||||||
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
|
|
||||||
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
|
|
||||||
config.disableParentScrollOnInlineDetailsExpansion,
|
|
||||||
shouldAnchorBottomOnContentSizeChange: () =>
|
|
||||||
config.anchorBottomOnContentSizeChange,
|
|
||||||
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
|
|
||||||
shouldUseVirtualizedList: () => config.useVirtualizedList,
|
|
||||||
scrollToBottom: (params) => config.scrollToBottom(params),
|
|
||||||
scrollToOffset: (params) => config.scrollToOffset(params),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createInvertedStreamStrategy(): StreamRenderStrategy {
|
|
||||||
return createStreamRenderStrategy({
|
|
||||||
orderTailReverse: true,
|
|
||||||
orderHeadReverse: true,
|
|
||||||
assistantTurnTraversalStep: 1,
|
|
||||||
edgeSlot: "header",
|
|
||||||
flatListInverted: true,
|
|
||||||
overlayScrollbarInverted: true,
|
|
||||||
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
|
|
||||||
bottomAnchorTransportBehavior: {
|
|
||||||
verificationDelayFrames: 2,
|
|
||||||
verificationRetryMode: "recheck",
|
|
||||||
},
|
|
||||||
disableParentScrollOnInlineDetailsExpansion: false,
|
|
||||||
anchorBottomOnContentSizeChange: false,
|
|
||||||
animateManualScrollToBottom: true,
|
|
||||||
useVirtualizedList: true,
|
|
||||||
isNearBottom: (input) => input.offsetY <= input.threshold,
|
|
||||||
getBottomOffset: () => 0,
|
|
||||||
scrollToBottom: ({ refs, animated }) => {
|
|
||||||
refs.flatListRef.current?.scrollToOffset({
|
|
||||||
offset: 0,
|
|
||||||
animated,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
scrollToOffset: ({ refs, offset, animated }) => {
|
|
||||||
refs.flatListRef.current?.scrollToOffset({ offset, animated });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createForwardStreamStrategy(): StreamRenderStrategy {
|
|
||||||
return createStreamRenderStrategy({
|
|
||||||
orderTailReverse: false,
|
|
||||||
orderHeadReverse: false,
|
|
||||||
assistantTurnTraversalStep: -1,
|
|
||||||
edgeSlot: "footer",
|
|
||||||
flatListInverted: false,
|
|
||||||
overlayScrollbarInverted: false,
|
|
||||||
maintainVisibleContentPosition: undefined,
|
|
||||||
bottomAnchorTransportBehavior: {
|
|
||||||
verificationDelayFrames: 0,
|
|
||||||
verificationRetryMode: "rescroll",
|
|
||||||
},
|
|
||||||
disableParentScrollOnInlineDetailsExpansion: false,
|
|
||||||
anchorBottomOnContentSizeChange: true,
|
|
||||||
animateManualScrollToBottom: false,
|
|
||||||
useVirtualizedList: false,
|
|
||||||
isNearBottom: (inputMetrics) => {
|
|
||||||
const distanceFromBottom = Math.max(
|
|
||||||
0,
|
|
||||||
inputMetrics.contentHeight -
|
|
||||||
(inputMetrics.offsetY + inputMetrics.viewportHeight)
|
|
||||||
);
|
|
||||||
return distanceFromBottom <= inputMetrics.threshold;
|
|
||||||
},
|
|
||||||
getBottomOffset: (metrics) =>
|
|
||||||
Math.max(0, metrics.contentHeight - metrics.viewportHeight),
|
|
||||||
scrollToBottom: ({ refs, metrics, animated }) => {
|
|
||||||
const bottomOffset = Math.max(
|
|
||||||
0,
|
|
||||||
metrics.contentHeight - metrics.viewportHeight
|
|
||||||
);
|
|
||||||
logWebBottomAnchorTransport("forward_scroll_to_bottom_start", {
|
|
||||||
animated,
|
|
||||||
bottomOffset,
|
|
||||||
metrics,
|
|
||||||
usedFlatList: Boolean(refs.flatListRef.current),
|
|
||||||
...getResolvedWebScrollContainerMetrics(refs),
|
|
||||||
});
|
|
||||||
if (refs.flatListRef.current) {
|
|
||||||
refs.flatListRef.current.scrollToEnd?.({ animated });
|
|
||||||
refs.flatListRef.current.scrollToOffset?.({
|
|
||||||
offset: bottomOffset,
|
|
||||||
animated,
|
|
||||||
});
|
|
||||||
if (typeof requestAnimationFrame === "function") {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
logWebBottomAnchorTransport("forward_scroll_to_bottom_after_flatlist", {
|
|
||||||
animated,
|
|
||||||
bottomOffset,
|
|
||||||
...getResolvedWebScrollContainerMetrics(refs),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const usedAnchor = scrollAnchorIntoView({ refs, animated });
|
|
||||||
if (!usedAnchor) {
|
|
||||||
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
|
|
||||||
}
|
|
||||||
// Always apply deterministic bottom offset to avoid partial anchors.
|
|
||||||
refs.scrollViewRef.current?.scrollTo?.({
|
|
||||||
y: bottomOffset,
|
|
||||||
animated,
|
|
||||||
});
|
|
||||||
forceScrollContainerToBottom(refs, bottomOffset);
|
|
||||||
if (typeof requestAnimationFrame === "function") {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
logWebBottomAnchorTransport("forward_scroll_to_bottom_after_scrollview", {
|
|
||||||
animated,
|
|
||||||
bottomOffset,
|
|
||||||
usedAnchor,
|
|
||||||
...getResolvedWebScrollContainerMetrics(refs),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollToOffset: ({ refs, offset, animated }) => {
|
|
||||||
if (refs.flatListRef.current) {
|
|
||||||
refs.flatListRef.current.scrollToOffset?.({ offset, animated });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveStreamRenderStrategy(
|
|
||||||
input: ResolveStreamRenderStrategyInput
|
|
||||||
): StreamRenderStrategy {
|
|
||||||
if (input.platform === "web") {
|
|
||||||
return createForwardStreamStrategy();
|
|
||||||
}
|
|
||||||
return createInvertedStreamStrategy();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function orderTailForStreamRenderStrategy(params: {
|
|
||||||
strategy: StreamRenderStrategy;
|
|
||||||
streamItems: StreamItem[];
|
|
||||||
}): StreamItem[] {
|
|
||||||
return params.strategy.orderTail(params.streamItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function orderHeadForStreamRenderStrategy(params: {
|
|
||||||
strategy: StreamRenderStrategy;
|
|
||||||
streamHead: StreamItem[];
|
|
||||||
}): StreamItem[] {
|
|
||||||
return params.strategy.orderHead(params.streamHead);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStreamNeighborIndex(params: {
|
|
||||||
strategy: StreamRenderStrategy;
|
|
||||||
index: number;
|
|
||||||
relation: NeighborRelation;
|
|
||||||
}): number {
|
|
||||||
return params.strategy.getNeighborIndex(params.index, params.relation);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStreamNeighborItem(params: {
|
|
||||||
strategy: StreamRenderStrategy;
|
|
||||||
items: StreamItem[];
|
|
||||||
index: number;
|
|
||||||
relation: NeighborRelation;
|
|
||||||
}): StreamItem | undefined {
|
|
||||||
return params.strategy.getNeighborItem(
|
|
||||||
params.items,
|
|
||||||
params.index,
|
|
||||||
params.relation
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
|
||||||
strategy: StreamRenderStrategy;
|
|
||||||
items: StreamItem[];
|
|
||||||
startIndex: number;
|
|
||||||
}): string {
|
|
||||||
return params.strategy.collectAssistantTurnContent(
|
|
||||||
params.items,
|
|
||||||
params.startIndex
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isNearBottomForStreamRenderStrategy(
|
|
||||||
params: StreamNearBottomInput & { strategy: StreamRenderStrategy }
|
|
||||||
): boolean {
|
|
||||||
return params.strategy.isNearBottom({
|
|
||||||
offsetY: params.offsetY,
|
|
||||||
threshold: params.threshold,
|
|
||||||
contentHeight: params.contentHeight,
|
|
||||||
viewportHeight: params.viewportHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBottomOffsetForStreamRenderStrategy(
|
|
||||||
params: StreamViewportMetrics & {
|
|
||||||
strategy: StreamRenderStrategy;
|
|
||||||
}
|
|
||||||
): number {
|
|
||||||
return params.strategy.getBottomOffset({
|
|
||||||
contentHeight: params.contentHeight,
|
|
||||||
viewportHeight: params.viewportHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStreamEdgeSlotProps(params: {
|
|
||||||
strategy: StreamRenderStrategy;
|
|
||||||
component: ReactElement | ComponentType<any> | null;
|
|
||||||
gapSize: number;
|
|
||||||
}): StreamEdgeSlotProps {
|
|
||||||
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Fragment,
|
|
||||||
createElement,
|
|
||||||
forwardRef,
|
forwardRef,
|
||||||
isValidElement,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
@@ -10,23 +7,14 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { ComponentType, ReactElement, ReactNode } from "react";
|
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
FlatList,
|
|
||||||
ScrollView,
|
|
||||||
ListRenderItemInfo,
|
|
||||||
LayoutChangeEvent,
|
|
||||||
NativeScrollEvent,
|
|
||||||
NativeSyntheticEvent,
|
|
||||||
Platform,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Keyboard,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import Markdown from "react-native-markdown-display";
|
import Markdown from "react-native-markdown-display";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
@@ -64,30 +52,17 @@ import type { DaemonClient } from "@server/client/daemon-client";
|
|||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
import { QuestionFormCard } from "./question-form-card";
|
import { QuestionFormCard } from "./question-form-card";
|
||||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||||
import {
|
|
||||||
WebDesktopScrollbarOverlay,
|
|
||||||
useWebDesktopScrollbarMetrics,
|
|
||||||
} from "./web-desktop-scrollbar";
|
|
||||||
import {
|
import {
|
||||||
collectAssistantTurnContentForStreamRenderStrategy,
|
collectAssistantTurnContentForStreamRenderStrategy,
|
||||||
getStreamEdgeSlotProps,
|
getStreamEdgeSlotProps,
|
||||||
getStreamNeighborItem,
|
getStreamNeighborItem,
|
||||||
isNearBottomForStreamRenderStrategy,
|
|
||||||
orderHeadForStreamRenderStrategy,
|
orderHeadForStreamRenderStrategy,
|
||||||
orderTailForStreamRenderStrategy,
|
orderTailForStreamRenderStrategy,
|
||||||
resolveBottomAnchorTransportBehavior,
|
|
||||||
resolveWebScrollContainerNode,
|
|
||||||
resolveStreamRenderStrategy,
|
resolveStreamRenderStrategy,
|
||||||
type StreamEdgeSlotProps,
|
type StreamEdgeSlotProps,
|
||||||
|
type StreamViewportHandle,
|
||||||
} from "./agent-stream-render-strategy";
|
} from "./agent-stream-render-strategy";
|
||||||
import {
|
import {
|
||||||
getWebMountedRecentStreamItems,
|
|
||||||
getWebPartialVirtualizationThreshold,
|
|
||||||
splitWebVirtualizedHistory,
|
|
||||||
type IndexedStreamItem,
|
|
||||||
} from "./agent-stream-web-virtualization";
|
|
||||||
import {
|
|
||||||
useBottomAnchorController,
|
|
||||||
type BottomAnchorLocalRequest,
|
type BottomAnchorLocalRequest,
|
||||||
type BottomAnchorRouteRequest,
|
type BottomAnchorRouteRequest,
|
||||||
} from "./use-bottom-anchor-controller";
|
} from "./use-bottom-anchor-controller";
|
||||||
@@ -103,23 +78,6 @@ const isToolSequenceItem = (item?: StreamItem) =>
|
|||||||
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
|
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
|
||||||
const STREAM_ITEM_LOG_MIN_COUNT = 200;
|
const STREAM_ITEM_LOG_MIN_COUNT = 200;
|
||||||
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
|
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
|
||||||
const NOOP_SEPARATORS: ListRenderItemInfo<StreamItem>["separators"] = {
|
|
||||||
highlight: () => {},
|
|
||||||
unhighlight: () => {},
|
|
||||||
updateProps: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderStreamEdgeComponent(
|
|
||||||
component: ReactElement | ComponentType<any> | null | undefined
|
|
||||||
): ReactNode {
|
|
||||||
if (!component) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (isValidElement(component)) {
|
|
||||||
return component;
|
|
||||||
}
|
|
||||||
return createElement(component);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentStreamViewHandle {
|
export interface AgentStreamViewHandle {
|
||||||
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
||||||
@@ -145,9 +103,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
routeBottomAnchorRequest = null,
|
routeBottomAnchorRequest = null,
|
||||||
isAuthoritativeHistoryReady = true,
|
isAuthoritativeHistoryReady = true,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const flatListRef = useRef<FlatList<any>>(null);
|
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
|
||||||
const bottomAnchorRef = useRef<View>(null);
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
@@ -160,65 +116,11 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
}),
|
}),
|
||||||
[isMobile]
|
[isMobile]
|
||||||
);
|
);
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||||
const isNearBottomRef = useRef(true);
|
|
||||||
const scrollOffsetYRef = useRef(0);
|
|
||||||
const programmaticScrollEventBudgetRef = useRef(0);
|
|
||||||
const streamItemCountRef = useRef(0);
|
const streamItemCountRef = useRef(0);
|
||||||
const previousStreamItemsRef = useRef<StreamItem[] | null>(null);
|
|
||||||
const streamViewportMetricsRef = useRef({
|
|
||||||
containerKey: "unknown",
|
|
||||||
contentHeight: 0,
|
|
||||||
viewportWidth: 0,
|
|
||||||
viewportHeight: 0,
|
|
||||||
offsetY: 0,
|
|
||||||
viewportMeasuredForKey: null as string | null,
|
|
||||||
contentMeasuredForKey: null as string | null,
|
|
||||||
});
|
|
||||||
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
|
||||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
|
||||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
|
||||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
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)
|
// Get serverId (fallback to agent's serverId if not provided)
|
||||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||||
@@ -248,52 +150,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
: FadeOut.duration(200);
|
: FadeOut.duration(200);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isNearBottomRef.current = true;
|
setIsNearBottom(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,
|
|
||||||
};
|
|
||||||
setExpandedInlineToolCallIds(new Set());
|
setExpandedInlineToolCallIds(new Set());
|
||||||
clearNativeViewportSettling();
|
}, [agentId]);
|
||||||
setIsNativeViewportSettling(false);
|
|
||||||
}, [agentId, clearNativeViewportSettling]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS === "web" || !streamRenderStrategy.getFlatListInverted()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const keyboardEvents = [
|
|
||||||
"keyboardWillShow",
|
|
||||||
"keyboardWillHide",
|
|
||||||
"keyboardDidShow",
|
|
||||||
"keyboardDidHide",
|
|
||||||
"keyboardWillChangeFrame",
|
|
||||||
"keyboardDidChangeFrame",
|
|
||||||
] as const;
|
|
||||||
const subscriptions = keyboardEvents.map((eventName) =>
|
|
||||||
Keyboard.addListener(eventName, () => {
|
|
||||||
markNativeViewportSettling();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
for (const subscription of subscriptions) {
|
|
||||||
subscription.remove();
|
|
||||||
}
|
|
||||||
clearNativeViewportSettling();
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
clearNativeViewportSettling,
|
|
||||||
markNativeViewportSettling,
|
|
||||||
streamRenderStrategy,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleInlinePathPress = useCallback(
|
const handleInlinePathPress = useCallback(
|
||||||
(target: InlinePathTarget) => {
|
(target: InlinePathTarget) => {
|
||||||
@@ -340,299 +199,23 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateNearBottom = useCallback((value: boolean) => {
|
const orderedStreamItems = useMemo(() => {
|
||||||
if (isNearBottomRef.current === value) return;
|
|
||||||
isNearBottomRef.current = value;
|
|
||||||
setIsNearBottom(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const flatListData = useMemo(() => {
|
|
||||||
return orderTailForStreamRenderStrategy({
|
return orderTailForStreamRenderStrategy({
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
streamItems,
|
streamItems,
|
||||||
});
|
});
|
||||||
}, [streamItems, streamRenderStrategy]);
|
}, [streamItems, streamRenderStrategy]);
|
||||||
const indexedFlatListData = useMemo<IndexedStreamItem[]>(
|
|
||||||
() => flatListData.map((item, index) => ({ item, index })),
|
|
||||||
[flatListData]
|
|
||||||
);
|
|
||||||
const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList();
|
|
||||||
const shouldUseWebPartialVirtualization =
|
|
||||||
Platform.OS === "web" &&
|
|
||||||
!isMobile &&
|
|
||||||
!usesVirtualizedList &&
|
|
||||||
indexedFlatListData.length > getWebPartialVirtualizationThreshold();
|
|
||||||
const currentContainerKey = usesVirtualizedList
|
|
||||||
? "native-virtualized"
|
|
||||||
: shouldUseWebPartialVirtualization
|
|
||||||
? "web-partial-virtualized"
|
|
||||||
: "scroll-view";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const current = streamViewportMetricsRef.current;
|
|
||||||
if (current.containerKey === currentContainerKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
streamViewportMetricsRef.current = {
|
|
||||||
containerKey: currentContainerKey,
|
|
||||||
contentHeight: 0,
|
|
||||||
viewportWidth: 0,
|
|
||||||
viewportHeight: 0,
|
|
||||||
offsetY: 0,
|
|
||||||
viewportMeasuredForKey: null,
|
|
||||||
contentMeasuredForKey: null,
|
|
||||||
};
|
|
||||||
scrollOffsetYRef.current = 0;
|
|
||||||
}, [currentContainerKey]);
|
|
||||||
|
|
||||||
const scrollToBottomInternal = useCallback(
|
|
||||||
({ animated }: { animated: boolean }) => {
|
|
||||||
const targetOffset = streamRenderStrategy.getBottomOffset(
|
|
||||||
streamViewportMetricsRef.current
|
|
||||||
);
|
|
||||||
programmaticScrollEventBudgetRef.current = 3;
|
|
||||||
streamRenderStrategy.scrollToBottom({
|
|
||||||
refs: streamRenderRefs,
|
|
||||||
metrics: streamViewportMetricsRef.current,
|
|
||||||
animated,
|
|
||||||
});
|
|
||||||
scrollOffsetYRef.current = targetOffset;
|
|
||||||
streamViewportMetricsRef.current = {
|
|
||||||
...streamViewportMetricsRef.current,
|
|
||||||
offsetY: targetOffset,
|
|
||||||
};
|
|
||||||
updateNearBottom(true);
|
|
||||||
},
|
|
||||||
[updateNearBottom, streamRenderRefs, streamRenderStrategy]
|
|
||||||
);
|
|
||||||
|
|
||||||
const bottomAnchorController = useBottomAnchorController({
|
|
||||||
agentId,
|
|
||||||
routeRequest: routeBottomAnchorRequest,
|
|
||||||
isAuthoritativeHistoryReady,
|
|
||||||
renderStrategy:
|
|
||||||
Platform.OS === "web" ? "forward-stream" : "inverted-stream",
|
|
||||||
transportBehavior: bottomAnchorTransportBehavior,
|
|
||||||
getMeasurementState: () => streamViewportMetricsRef.current,
|
|
||||||
isNearBottom: () => {
|
|
||||||
const metrics = streamViewportMetricsRef.current;
|
|
||||||
return isNearBottomForStreamRenderStrategy({
|
|
||||||
strategy: streamRenderStrategy,
|
|
||||||
offsetY: metrics.offsetY,
|
|
||||||
threshold: Math.max(insets.bottom, 32),
|
|
||||||
contentHeight: metrics.contentHeight,
|
|
||||||
viewportHeight: metrics.viewportHeight,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
scrollToBottom: (animated) => {
|
|
||||||
scrollToBottomInternal({ animated });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (previousStreamItemsRef.current === null) {
|
|
||||||
previousStreamItemsRef.current = streamItems;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
previousStreamItemsRef.current = streamItems;
|
|
||||||
bottomAnchorController.prepareForStickyContentChange();
|
|
||||||
}, [bottomAnchorController, streamItems]);
|
|
||||||
|
|
||||||
const handleScroll = useCallback(
|
|
||||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
||||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
|
||||||
const previousOffsetY = scrollOffsetYRef.current;
|
|
||||||
scrollOffsetYRef.current = contentOffset.y;
|
|
||||||
streamViewportMetricsRef.current = {
|
|
||||||
contentHeight: Math.max(0, contentSize.height),
|
|
||||||
viewportWidth: Math.max(0, layoutMeasurement.width),
|
|
||||||
viewportHeight: Math.max(0, layoutMeasurement.height),
|
|
||||||
containerKey: currentContainerKey,
|
|
||||||
offsetY: contentOffset.y,
|
|
||||||
viewportMeasuredForKey: currentContainerKey,
|
|
||||||
contentMeasuredForKey: currentContainerKey,
|
|
||||||
};
|
|
||||||
const threshold = Math.max(insets.bottom, 32);
|
|
||||||
const nearBottom = isNearBottomForStreamRenderStrategy({
|
|
||||||
strategy: streamRenderStrategy,
|
|
||||||
offsetY: contentOffset.y,
|
|
||||||
threshold,
|
|
||||||
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
|
||||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
|
||||||
});
|
|
||||||
updateNearBottom(nearBottom);
|
|
||||||
const expectedBottomOffset = streamRenderStrategy.getBottomOffset(
|
|
||||||
streamViewportMetricsRef.current
|
|
||||||
);
|
|
||||||
const isProgrammaticBottomEvent =
|
|
||||||
Math.abs(contentOffset.y - expectedBottomOffset) <= 8;
|
|
||||||
|
|
||||||
if (
|
|
||||||
programmaticScrollEventBudgetRef.current > 0 &&
|
|
||||||
isProgrammaticBottomEvent
|
|
||||||
) {
|
|
||||||
programmaticScrollEventBudgetRef.current -= 1;
|
|
||||||
} else {
|
|
||||||
programmaticScrollEventBudgetRef.current = 0;
|
|
||||||
bottomAnchorController.handleScrollNearBottomChange({
|
|
||||||
nextIsNearBottom: nearBottom,
|
|
||||||
scrollDelta: contentOffset.y - previousOffsetY,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDesktopWebScrollbar) {
|
|
||||||
streamScrollbarMetrics.onScroll(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
insets.bottom,
|
|
||||||
showDesktopWebScrollbar,
|
|
||||||
streamRenderStrategy,
|
|
||||||
streamScrollbarMetrics,
|
|
||||||
currentContainerKey,
|
|
||||||
bottomAnchorController,
|
|
||||||
updateNearBottom,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleListLayout = useCallback(
|
|
||||||
(event: LayoutChangeEvent) => {
|
|
||||||
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
|
|
||||||
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
|
|
||||||
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
|
|
||||||
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
|
|
||||||
const viewportChanged =
|
|
||||||
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
|
|
||||||
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
|
|
||||||
streamViewportMetricsRef.current = {
|
|
||||||
...streamViewportMetricsRef.current,
|
|
||||||
containerKey: currentContainerKey,
|
|
||||||
viewportWidth,
|
|
||||||
viewportHeight,
|
|
||||||
viewportMeasuredForKey: currentContainerKey,
|
|
||||||
};
|
|
||||||
if (viewportChanged) {
|
|
||||||
markNativeViewportSettling();
|
|
||||||
}
|
|
||||||
bottomAnchorController.handleViewportMetricsChange({
|
|
||||||
previousViewportWidth,
|
|
||||||
viewportWidth,
|
|
||||||
previousViewportHeight,
|
|
||||||
viewportHeight,
|
|
||||||
});
|
|
||||||
if (showDesktopWebScrollbar) {
|
|
||||||
streamScrollbarMetrics.onLayout(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
showDesktopWebScrollbar,
|
|
||||||
streamScrollbarMetrics,
|
|
||||||
currentContainerKey,
|
|
||||||
markNativeViewportSettling,
|
|
||||||
bottomAnchorController,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
scrollToBottom(reason = "jump-to-bottom") {
|
scrollToBottom(reason = "jump-to-bottom") {
|
||||||
bottomAnchorController.requestLocalAnchor({ agentId, reason });
|
viewportRef.current?.scrollToBottom(reason);
|
||||||
},
|
},
|
||||||
prepareForViewportChange() {
|
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() {
|
function scrollToBottom() {
|
||||||
bottomAnchorController.requestLocalAnchor({
|
viewportRef.current?.scrollToBottom("jump-to-bottom");
|
||||||
agentId,
|
|
||||||
reason: "jump-to-bottom",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedStreamHead = useMemo(() => {
|
const orderedStreamHead = useMemo(() => {
|
||||||
@@ -843,16 +426,16 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderStreamItem = useCallback(
|
const renderStreamItem = useCallback(
|
||||||
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
(item: StreamItem, index: number, items: StreamItem[]) => {
|
||||||
const content = renderStreamItemContent(item, index, flatListData);
|
const content = renderStreamItemContent(item, index, items);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gapBelow = getGapBelow(item, index, flatListData);
|
const gapBelow = getGapBelow(item, index, items);
|
||||||
const nextItem = getStreamNeighborItem({
|
const nextItem = getStreamNeighborItem({
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
items: flatListData,
|
items,
|
||||||
index,
|
index,
|
||||||
relation: "below",
|
relation: "below",
|
||||||
});
|
});
|
||||||
@@ -863,7 +446,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
const getTurnContent = () =>
|
const getTurnContent = () =>
|
||||||
collectAssistantTurnContentForStreamRenderStrategy({
|
collectAssistantTurnContentForStreamRenderStrategy({
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
items: flatListData,
|
items,
|
||||||
startIndex: index,
|
startIndex: index,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -879,7 +462,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
[
|
[
|
||||||
getGapBelow,
|
getGapBelow,
|
||||||
renderStreamItemContent,
|
renderStreamItemContent,
|
||||||
flatListData,
|
|
||||||
agent.status,
|
agent.status,
|
||||||
streamRenderStrategy,
|
streamRenderStrategy,
|
||||||
]
|
]
|
||||||
@@ -963,35 +545,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
|
|
||||||
const showWorkingIndicator = agent.status === "running";
|
const showWorkingIndicator = agent.status === "running";
|
||||||
const showBottomBar = showWorkingIndicator;
|
const showBottomBar = showWorkingIndicator;
|
||||||
const webVirtualizedHistoryWindow = useMemo(() => {
|
|
||||||
if (!shouldUseWebPartialVirtualization) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return splitWebVirtualizedHistory({
|
|
||||||
entries: indexedFlatListData,
|
|
||||||
minMountedCount: getWebMountedRecentStreamItems(),
|
|
||||||
});
|
|
||||||
}, [indexedFlatListData, shouldUseWebPartialVirtualization]);
|
|
||||||
|
|
||||||
const renderIndexedStreamItem = useCallback(
|
|
||||||
(entry: IndexedStreamItem) =>
|
|
||||||
renderStreamItem({
|
|
||||||
item: entry.item,
|
|
||||||
index: entry.index,
|
|
||||||
separators: NOOP_SEPARATORS,
|
|
||||||
}),
|
|
||||||
[renderStreamItem]
|
|
||||||
);
|
|
||||||
const renderWebVirtualizedStreamItem = useCallback(
|
|
||||||
({ item }: ListRenderItemInfo<IndexedStreamItem>) => {
|
|
||||||
const rendered = renderIndexedStreamItem(item);
|
|
||||||
if (!rendered) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return <Fragment key={item.item.id}>{rendered}</Fragment>;
|
|
||||||
},
|
|
||||||
[renderIndexedStreamItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
const listEdgeSlotComponent = useMemo(() => {
|
const listEdgeSlotComponent = useMemo(() => {
|
||||||
const hasPermissions = pendingPermissionItems.length > 0;
|
const hasPermissions = pendingPermissionItems.length > 0;
|
||||||
@@ -1054,19 +607,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
tightGap,
|
tightGap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const flatListExtraData = useMemo(
|
|
||||||
() => ({
|
|
||||||
pendingPermissionCount: pendingPermissionItems.length,
|
|
||||||
showWorkingIndicator,
|
|
||||||
showBottomBar,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
pendingPermissionItems.length,
|
|
||||||
showWorkingIndicator,
|
|
||||||
showBottomBar,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const listEdgeSlotProps = useMemo<StreamEdgeSlotProps>(() => {
|
const listEdgeSlotProps = useMemo<StreamEdgeSlotProps>(() => {
|
||||||
if (!listEdgeSlotComponent) {
|
if (!listEdgeSlotComponent) {
|
||||||
return {};
|
return {};
|
||||||
@@ -1116,171 +656,27 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
const streamScrollEnabled =
|
const streamScrollEnabled =
|
||||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||||
expandedInlineToolCallIds.size === 0;
|
expandedInlineToolCallIds.size === 0;
|
||||||
const listContentContainerStyle = useMemo(
|
|
||||||
() =>
|
|
||||||
usesVirtualizedList
|
|
||||||
? stylesheet.listContentContainer
|
|
||||||
: [stylesheet.listContentContainer, stylesheet.forwardListContentContainer],
|
|
||||||
[usesVirtualizedList]
|
|
||||||
);
|
|
||||||
const headerEdgeContent = renderStreamEdgeComponent(
|
|
||||||
listEdgeSlotProps.ListHeaderComponent
|
|
||||||
);
|
|
||||||
const footerEdgeContent = renderStreamEdgeComponent(
|
|
||||||
listEdgeSlotProps.ListFooterComponent
|
|
||||||
);
|
|
||||||
const nonVirtualizedItems = useMemo(() => {
|
|
||||||
if (indexedFlatListData.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return indexedFlatListData.map((entry) => {
|
|
||||||
const rendered = renderIndexedStreamItem(entry);
|
|
||||||
if (!rendered) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return <Fragment key={entry.item.id}>{rendered}</Fragment>;
|
|
||||||
});
|
|
||||||
}, [indexedFlatListData, renderIndexedStreamItem]);
|
|
||||||
const webVirtualizedListHeader = useMemo(() => {
|
|
||||||
if (!shouldUseWebPartialVirtualization || !headerEdgeContent) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View style={listEdgeSlotProps.ListHeaderComponentStyle}>
|
|
||||||
{headerEdgeContent}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
headerEdgeContent,
|
|
||||||
listEdgeSlotProps.ListHeaderComponentStyle,
|
|
||||||
shouldUseWebPartialVirtualization,
|
|
||||||
]);
|
|
||||||
const webVirtualizedListFooter = useMemo(() => {
|
|
||||||
if (!shouldUseWebPartialVirtualization || !webVirtualizedHistoryWindow) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{webVirtualizedHistoryWindow.mountedEntries.map((entry) => (
|
|
||||||
<Fragment key={entry.item.id}>
|
|
||||||
{renderIndexedStreamItem(entry)}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
{footerEdgeContent ? (
|
|
||||||
<View style={listEdgeSlotProps.ListFooterComponentStyle}>
|
|
||||||
{footerEdgeContent}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<View ref={bottomAnchorRef} collapsable={false} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
footerEdgeContent,
|
|
||||||
listEdgeSlotProps.ListFooterComponentStyle,
|
|
||||||
renderIndexedStreamItem,
|
|
||||||
shouldUseWebPartialVirtualization,
|
|
||||||
webVirtualizedHistoryWindow,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCallSheetProvider>
|
<ToolCallSheetProvider>
|
||||||
<View style={stylesheet.container}>
|
<View style={stylesheet.container}>
|
||||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||||
{usesVirtualizedList ? (
|
{streamRenderStrategy.render({
|
||||||
<FlatList
|
agentId,
|
||||||
ref={flatListRef}
|
rows: orderedStreamItems,
|
||||||
data={flatListData}
|
renderRow: renderStreamItem,
|
||||||
renderItem={renderStreamItem}
|
listEmptyComponent,
|
||||||
keyExtractor={(item) => item.id}
|
viewportRef,
|
||||||
testID="agent-chat-scroll"
|
routeBottomAnchorRequest,
|
||||||
nativeID={`agent-chat-scroll-${currentContainerKey}`}
|
isAuthoritativeHistoryReady,
|
||||||
{...listEdgeSlotProps}
|
onNearBottomChange: setIsNearBottom,
|
||||||
contentContainerStyle={listContentContainerStyle}
|
scrollEnabled: streamScrollEnabled,
|
||||||
style={stylesheet.list}
|
listStyle: stylesheet.list,
|
||||||
onLayout={handleListLayout}
|
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||||
onScroll={handleScroll}
|
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||||
scrollEventThrottle={16}
|
edgeSlotProps: listEdgeSlotProps,
|
||||||
onContentSizeChange={handleContentSizeChange}
|
})}
|
||||||
ListEmptyComponent={listEmptyComponent}
|
|
||||||
extraData={flatListExtraData}
|
|
||||||
maintainVisibleContentPosition={
|
|
||||||
streamRenderStrategy.getMaintainVisibleContentPosition()
|
|
||||||
}
|
|
||||||
initialNumToRender={12}
|
|
||||||
windowSize={10}
|
|
||||||
scrollEnabled={streamScrollEnabled}
|
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
|
||||||
inverted={streamRenderStrategy.getFlatListInverted()}
|
|
||||||
/>
|
|
||||||
) : shouldUseWebPartialVirtualization && webVirtualizedHistoryWindow ? (
|
|
||||||
<FlatList
|
|
||||||
ref={flatListRef}
|
|
||||||
data={webVirtualizedHistoryWindow.virtualizedEntries}
|
|
||||||
renderItem={renderWebVirtualizedStreamItem}
|
|
||||||
keyExtractor={(entry) => entry.item.id}
|
|
||||||
testID="agent-chat-scroll"
|
|
||||||
nativeID={`agent-chat-scroll-${currentContainerKey}`}
|
|
||||||
ListHeaderComponent={webVirtualizedListHeader}
|
|
||||||
ListFooterComponent={webVirtualizedListFooter}
|
|
||||||
contentContainerStyle={listContentContainerStyle}
|
|
||||||
style={stylesheet.list}
|
|
||||||
onLayout={handleListLayout}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
onContentSizeChange={handleContentSizeChange}
|
|
||||||
extraData={flatListExtraData}
|
|
||||||
initialNumToRender={12}
|
|
||||||
windowSize={10}
|
|
||||||
scrollEnabled={streamScrollEnabled}
|
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
|
||||||
inverted={false}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ScrollView
|
|
||||||
ref={scrollViewRef}
|
|
||||||
testID="agent-chat-scroll"
|
|
||||||
nativeID={`agent-chat-scroll-${currentContainerKey}`}
|
|
||||||
contentContainerStyle={listContentContainerStyle}
|
|
||||||
style={stylesheet.list}
|
|
||||||
onLayout={handleListLayout}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
onContentSizeChange={handleContentSizeChange}
|
|
||||||
scrollEnabled={streamScrollEnabled}
|
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
|
||||||
>
|
|
||||||
{headerEdgeContent ? (
|
|
||||||
<View style={listEdgeSlotProps.ListHeaderComponentStyle}>
|
|
||||||
{headerEdgeContent}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{nonVirtualizedItems}
|
|
||||||
{flatListData.length === 0 ? listEmptyComponent : null}
|
|
||||||
{footerEdgeContent ? (
|
|
||||||
<View style={listEdgeSlotProps.ListFooterComponentStyle}>
|
|
||||||
{footerEdgeContent}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<View ref={bottomAnchorRef} collapsable={false} />
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</MessageOuterSpacingProvider>
|
</MessageOuterSpacingProvider>
|
||||||
<WebDesktopScrollbarOverlay
|
|
||||||
enabled={showDesktopWebScrollbar}
|
|
||||||
metrics={streamScrollbarMetrics}
|
|
||||||
inverted={streamRenderStrategy.getOverlayScrollbarInverted()}
|
|
||||||
onScrollToOffset={(nextOffset) => {
|
|
||||||
streamRenderStrategy.scrollToOffset({
|
|
||||||
refs: streamRenderRefs,
|
|
||||||
offset: nextOffset,
|
|
||||||
animated: false,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scroll to bottom button */}
|
|
||||||
{!isNearBottom && (
|
{!isNearBottom && (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={stylesheet.scrollToBottomContainer}
|
style={stylesheet.scrollToBottomContainer}
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
FlatList,
|
||||||
|
Keyboard,
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
type ListRenderItemInfo,
|
||||||
|
type NativeScrollEvent,
|
||||||
|
type NativeSyntheticEvent,
|
||||||
|
} from "react-native";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import {
|
||||||
|
useBottomAnchorController,
|
||||||
|
} from "./use-bottom-anchor-controller";
|
||||||
|
import type {
|
||||||
|
StreamRenderInput,
|
||||||
|
StreamStrategy,
|
||||||
|
StreamViewportHandle,
|
||||||
|
} from "./stream-strategy";
|
||||||
|
import {
|
||||||
|
createStreamStrategy,
|
||||||
|
isNearBottomForStreamRenderStrategy,
|
||||||
|
resolveBottomAnchorTransportBehavior,
|
||||||
|
} from "./stream-strategy";
|
||||||
|
|
||||||
|
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
||||||
|
minIndexForVisible: 0,
|
||||||
|
autoscrollToTopThreshold: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
|
||||||
|
const {
|
||||||
|
agentId,
|
||||||
|
rows,
|
||||||
|
renderRow,
|
||||||
|
listEmptyComponent,
|
||||||
|
viewportRef,
|
||||||
|
routeBottomAnchorRequest,
|
||||||
|
isAuthoritativeHistoryReady,
|
||||||
|
onNearBottomChange,
|
||||||
|
scrollEnabled,
|
||||||
|
listStyle,
|
||||||
|
baseListContentContainerStyle,
|
||||||
|
edgeSlotProps,
|
||||||
|
strategy,
|
||||||
|
} = props;
|
||||||
|
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||||
|
const streamViewportMetricsRef = useRef({
|
||||||
|
containerKey: "native-virtualized",
|
||||||
|
contentHeight: 0,
|
||||||
|
viewportWidth: 0,
|
||||||
|
viewportHeight: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
viewportMeasuredForKey: null as string | null,
|
||||||
|
contentMeasuredForKey: null as string | null,
|
||||||
|
});
|
||||||
|
const scrollOffsetYRef = useRef(0);
|
||||||
|
const programmaticScrollEventBudgetRef = useRef(0);
|
||||||
|
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||||
|
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const clearNativeViewportSettling = useCallback(() => {
|
||||||
|
if (nativeViewportSettlingFrameIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current);
|
||||||
|
nativeViewportSettlingFrameIdRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markNativeViewportSettling = useCallback(() => {
|
||||||
|
clearNativeViewportSettling();
|
||||||
|
setIsNativeViewportSettling(true);
|
||||||
|
let remainingFrames = 4;
|
||||||
|
const tick = () => {
|
||||||
|
if (remainingFrames <= 0) {
|
||||||
|
nativeViewportSettlingFrameIdRef.current = null;
|
||||||
|
setIsNativeViewportSettling(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remainingFrames -= 1;
|
||||||
|
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
|
||||||
|
}, [clearNativeViewportSettling]);
|
||||||
|
|
||||||
|
const bottomAnchorTransportBehavior = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveBottomAnchorTransportBehavior({
|
||||||
|
strategy,
|
||||||
|
isViewportSettling: isNativeViewportSettling,
|
||||||
|
}),
|
||||||
|
[isNativeViewportSettling, strategy]
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback((animated: boolean) => {
|
||||||
|
programmaticScrollEventBudgetRef.current = 3;
|
||||||
|
flatListRef.current?.scrollToOffset({
|
||||||
|
offset: 0,
|
||||||
|
animated,
|
||||||
|
});
|
||||||
|
scrollOffsetYRef.current = 0;
|
||||||
|
streamViewportMetricsRef.current = {
|
||||||
|
...streamViewportMetricsRef.current,
|
||||||
|
offsetY: 0,
|
||||||
|
};
|
||||||
|
onNearBottomChange(true);
|
||||||
|
}, [onNearBottomChange]);
|
||||||
|
|
||||||
|
const bottomAnchorController = useBottomAnchorController({
|
||||||
|
agentId,
|
||||||
|
routeRequest: routeBottomAnchorRequest,
|
||||||
|
isAuthoritativeHistoryReady,
|
||||||
|
renderStrategy: "inverted-stream",
|
||||||
|
transportBehavior: bottomAnchorTransportBehavior,
|
||||||
|
getMeasurementState: () => streamViewportMetricsRef.current,
|
||||||
|
isNearBottom: () => {
|
||||||
|
const metrics = streamViewportMetricsRef.current;
|
||||||
|
return isNearBottomForStreamRenderStrategy({
|
||||||
|
strategy,
|
||||||
|
offsetY: metrics.offsetY,
|
||||||
|
threshold: 32,
|
||||||
|
contentHeight: metrics.contentHeight,
|
||||||
|
viewportHeight: metrics.viewportHeight,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
scrollToBottom,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
streamViewportMetricsRef.current = {
|
||||||
|
containerKey: "native-virtualized",
|
||||||
|
contentHeight: 0,
|
||||||
|
viewportWidth: 0,
|
||||||
|
viewportHeight: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
viewportMeasuredForKey: null,
|
||||||
|
contentMeasuredForKey: null,
|
||||||
|
};
|
||||||
|
scrollOffsetYRef.current = 0;
|
||||||
|
clearNativeViewportSettling();
|
||||||
|
setIsNativeViewportSettling(false);
|
||||||
|
}, [agentId, clearNativeViewportSettling]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const keyboardEvents = [
|
||||||
|
"keyboardWillShow",
|
||||||
|
"keyboardWillHide",
|
||||||
|
"keyboardDidShow",
|
||||||
|
"keyboardDidHide",
|
||||||
|
"keyboardWillChangeFrame",
|
||||||
|
"keyboardDidChangeFrame",
|
||||||
|
] as const;
|
||||||
|
const subscriptions = keyboardEvents.map((eventName) =>
|
||||||
|
Keyboard.addListener(eventName, () => {
|
||||||
|
markNativeViewportSettling();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
subscription.remove();
|
||||||
|
}
|
||||||
|
clearNativeViewportSettling();
|
||||||
|
};
|
||||||
|
}, [clearNativeViewportSettling, markNativeViewportSettling]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomAnchorController.prepareForStickyContentChange();
|
||||||
|
}, [bottomAnchorController, rows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle: StreamViewportHandle = {
|
||||||
|
scrollToBottom: (reason = "jump-to-bottom") => {
|
||||||
|
bottomAnchorController.requestLocalAnchor({
|
||||||
|
agentId,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
prepareForViewportChange: () => {
|
||||||
|
bottomAnchorController.prepareForStickyViewportChange();
|
||||||
|
markNativeViewportSettling();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
viewportRef.current = handle;
|
||||||
|
return () => {
|
||||||
|
if (viewportRef.current === handle) {
|
||||||
|
viewportRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(
|
||||||
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||||
|
const previousOffsetY = scrollOffsetYRef.current;
|
||||||
|
scrollOffsetYRef.current = contentOffset.y;
|
||||||
|
streamViewportMetricsRef.current = {
|
||||||
|
contentHeight: Math.max(0, contentSize.height),
|
||||||
|
viewportWidth: Math.max(0, layoutMeasurement.width),
|
||||||
|
viewportHeight: Math.max(0, layoutMeasurement.height),
|
||||||
|
containerKey: "native-virtualized",
|
||||||
|
offsetY: contentOffset.y,
|
||||||
|
viewportMeasuredForKey: "native-virtualized",
|
||||||
|
contentMeasuredForKey: "native-virtualized",
|
||||||
|
};
|
||||||
|
|
||||||
|
const nearBottom = isNearBottomForStreamRenderStrategy({
|
||||||
|
strategy,
|
||||||
|
offsetY: contentOffset.y,
|
||||||
|
threshold: 32,
|
||||||
|
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
||||||
|
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||||
|
});
|
||||||
|
onNearBottomChange(nearBottom);
|
||||||
|
|
||||||
|
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
|
||||||
|
programmaticScrollEventBudgetRef.current -= 1;
|
||||||
|
} else {
|
||||||
|
programmaticScrollEventBudgetRef.current = 0;
|
||||||
|
bottomAnchorController.handleScrollNearBottomChange({
|
||||||
|
nextIsNearBottom: nearBottom,
|
||||||
|
scrollDelta: contentOffset.y - previousOffsetY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[bottomAnchorController, onNearBottomChange, strategy]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleListLayout = useCallback(
|
||||||
|
(event: LayoutChangeEvent) => {
|
||||||
|
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
|
||||||
|
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
|
||||||
|
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
|
||||||
|
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
|
||||||
|
const viewportChanged =
|
||||||
|
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
|
||||||
|
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
|
||||||
|
streamViewportMetricsRef.current = {
|
||||||
|
...streamViewportMetricsRef.current,
|
||||||
|
containerKey: "native-virtualized",
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
viewportMeasuredForKey: "native-virtualized",
|
||||||
|
};
|
||||||
|
if (viewportChanged) {
|
||||||
|
markNativeViewportSettling();
|
||||||
|
}
|
||||||
|
bottomAnchorController.handleViewportMetricsChange({
|
||||||
|
previousViewportWidth,
|
||||||
|
viewportWidth,
|
||||||
|
previousViewportHeight,
|
||||||
|
viewportHeight,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[bottomAnchorController, markNativeViewportSettling]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContentSizeChange = useCallback(
|
||||||
|
(_width: number, height: number) => {
|
||||||
|
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
|
||||||
|
const nextContentHeight = Math.max(0, height);
|
||||||
|
streamViewportMetricsRef.current = {
|
||||||
|
...streamViewportMetricsRef.current,
|
||||||
|
containerKey: "native-virtualized",
|
||||||
|
contentHeight: nextContentHeight,
|
||||||
|
contentMeasuredForKey: "native-virtualized",
|
||||||
|
};
|
||||||
|
bottomAnchorController.handleContentSizeChange({
|
||||||
|
previousContentHeight,
|
||||||
|
contentHeight: nextContentHeight,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[bottomAnchorController]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
||||||
|
const rendered = renderRow(item, index, rows);
|
||||||
|
return rendered ? <Fragment>{rendered}</Fragment> : null;
|
||||||
|
},
|
||||||
|
[renderRow, rows]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
|
data={rows}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
testID="agent-chat-scroll"
|
||||||
|
nativeID="agent-chat-scroll-native-virtualized"
|
||||||
|
{...edgeSlotProps}
|
||||||
|
contentContainerStyle={baseListContentContainerStyle}
|
||||||
|
style={listStyle}
|
||||||
|
onLayout={handleListLayout}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
onContentSizeChange={handleContentSizeChange}
|
||||||
|
ListEmptyComponent={
|
||||||
|
listEmptyComponent ? () => <Fragment>{listEmptyComponent}</Fragment> : undefined
|
||||||
|
}
|
||||||
|
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
||||||
|
initialNumToRender={12}
|
||||||
|
windowSize={10}
|
||||||
|
scrollEnabled={scrollEnabled}
|
||||||
|
showsVerticalScrollIndicator
|
||||||
|
inverted
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNativeStreamStrategy(): StreamStrategy {
|
||||||
|
const strategy = createStreamStrategy({
|
||||||
|
render: (renderInput) => (
|
||||||
|
<NativeStreamViewport
|
||||||
|
{...renderInput}
|
||||||
|
strategy={strategy}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
orderTailReverse: true,
|
||||||
|
orderHeadReverse: true,
|
||||||
|
assistantTurnTraversalStep: 1,
|
||||||
|
edgeSlot: "header",
|
||||||
|
flatListInverted: true,
|
||||||
|
overlayScrollbarInverted: true,
|
||||||
|
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
|
||||||
|
bottomAnchorTransportBehavior: {
|
||||||
|
verificationDelayFrames: 2,
|
||||||
|
verificationRetryMode: "recheck",
|
||||||
|
},
|
||||||
|
disableParentScrollOnInlineDetailsExpansion: false,
|
||||||
|
anchorBottomOnContentSizeChange: false,
|
||||||
|
animateManualScrollToBottom: true,
|
||||||
|
useVirtualizedList: true,
|
||||||
|
isNearBottom: (input) => input.offsetY <= input.threshold,
|
||||||
|
getBottomOffset: () => 0,
|
||||||
|
});
|
||||||
|
return strategy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
import {
|
||||||
|
Fragment,
|
||||||
|
type CSSProperties,
|
||||||
|
createElement,
|
||||||
|
isValidElement,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { measureElement as measureVirtualElement, useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
import { View } from 'react-native'
|
||||||
|
import {
|
||||||
|
estimateStreamItemHeight,
|
||||||
|
getWebMountedRecentStreamItems,
|
||||||
|
getWebPartialVirtualizationThreshold,
|
||||||
|
splitWebVirtualizedHistory,
|
||||||
|
} from './agent-stream-web-virtualization'
|
||||||
|
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from './stream-strategy'
|
||||||
|
import { createStreamStrategy } from './stream-strategy'
|
||||||
|
|
||||||
|
type CreateWebStreamStrategyInput = {
|
||||||
|
isMobileBreakpoint: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScrollBehaviorLike = 'auto' | 'smooth'
|
||||||
|
|
||||||
|
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200
|
||||||
|
const USER_SCROLL_DELTA_EPSILON = 1
|
||||||
|
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64
|
||||||
|
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1
|
||||||
|
const WEB_STREAM_SCROLLBAR_STYLE_ID = 'web-stream-viewport-scrollbar-style'
|
||||||
|
const WEB_STREAM_SCROLLBAR_STYLE = `
|
||||||
|
#agent-chat-scroll-web-dom-scroll,
|
||||||
|
#agent-chat-scroll-web-dom-virtualized {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#agent-chat-scroll-web-dom-scroll::-webkit-scrollbar,
|
||||||
|
#agent-chat-scroll-web-dom-virtualized::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
function isScrollContainerNearBottom(
|
||||||
|
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>,
|
||||||
|
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX
|
||||||
|
): boolean {
|
||||||
|
const threshold = Number.isFinite(thresholdPx)
|
||||||
|
? Math.max(0, thresholdPx)
|
||||||
|
: AUTO_SCROLL_BOTTOM_THRESHOLD_PX
|
||||||
|
const { scrollTop, clientHeight, scrollHeight } = scrollContainer
|
||||||
|
if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const distanceFromBottom = scrollHeight - clientHeight - scrollTop
|
||||||
|
return distanceFromBottom <= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScrollContainerAtBottom(
|
||||||
|
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>
|
||||||
|
): boolean {
|
||||||
|
return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEdge(
|
||||||
|
content: StreamRenderInput['edgeSlotProps']['ListHeaderComponent'],
|
||||||
|
style?: CSSProperties
|
||||||
|
) {
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const rendered = isValidElement(content) ? content : createElement(content)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rendered}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollElementToBottom(
|
||||||
|
scrollContainer: HTMLElement,
|
||||||
|
behavior: ScrollBehaviorLike = 'auto'
|
||||||
|
): void {
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: scrollContainer.scrollHeight,
|
||||||
|
behavior,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncNearBottom(
|
||||||
|
scrollContainer: HTMLElement | null,
|
||||||
|
onNearBottomChange: (value: boolean) => void
|
||||||
|
): boolean {
|
||||||
|
if (!scrollContainer) {
|
||||||
|
onNearBottomChange(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const nextValue = isScrollContainerNearBottom(scrollContainer)
|
||||||
|
onNearBottomChange(nextValue)
|
||||||
|
return nextValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScrollContainerDistanceFromBottom(
|
||||||
|
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>
|
||||||
|
): number {
|
||||||
|
return scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScrollContainerOverscrolledPastBottom(
|
||||||
|
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>
|
||||||
|
): boolean {
|
||||||
|
return getScrollContainerDistanceFromBottom(scrollContainer) < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||||
|
const {
|
||||||
|
rows,
|
||||||
|
renderRow,
|
||||||
|
listEmptyComponent,
|
||||||
|
viewportRef,
|
||||||
|
routeBottomAnchorRequest,
|
||||||
|
isAuthoritativeHistoryReady,
|
||||||
|
onNearBottomChange,
|
||||||
|
scrollEnabled,
|
||||||
|
listStyle,
|
||||||
|
baseListContentContainerStyle,
|
||||||
|
forwardListContentContainerStyle,
|
||||||
|
edgeSlotProps,
|
||||||
|
isMobileBreakpoint,
|
||||||
|
} = props
|
||||||
|
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
|
||||||
|
require('./web-desktop-scrollbar') as typeof import('./web-desktop-scrollbar')
|
||||||
|
const scrollContainerRef = useRef<HTMLElement | null>(null)
|
||||||
|
const contentRef = useRef<HTMLElement | null>(null)
|
||||||
|
const [followOutput, setFollowOutputr] = useState(true)
|
||||||
|
const setFollowOutput = (value: boolean) => {
|
||||||
|
console.trace('setFollowOutput', value)
|
||||||
|
setFollowOutputr(value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const followOutputRef = useRef(followOutput)
|
||||||
|
const lastKnownScrollTopRef = useRef(0)
|
||||||
|
const pendingUserScrollUpIntentRef = useRef(false)
|
||||||
|
const isPointerScrollActiveRef = useRef(false)
|
||||||
|
const lastTouchClientYRef = useRef<number | null>(null)
|
||||||
|
const pendingAutoScrollFrameRef = useRef<number | null>(null)
|
||||||
|
const pendingAutoScrollTimeoutRef = useRef<number | null>(null)
|
||||||
|
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics()
|
||||||
|
const showDesktopWebScrollbar = !isMobileBreakpoint
|
||||||
|
const shouldUseVirtualizer =
|
||||||
|
!isMobileBreakpoint && rows.length > getWebPartialVirtualizationThreshold()
|
||||||
|
|
||||||
|
followOutputRef.current = followOutput
|
||||||
|
|
||||||
|
const indexedRows = useMemo(() => rows.map((item, index) => ({ item, index })), [rows])
|
||||||
|
const webVirtualizedHistoryWindow = useMemo(() => {
|
||||||
|
if (!shouldUseVirtualizer) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return splitWebVirtualizedHistory({
|
||||||
|
entries: indexedRows,
|
||||||
|
minMountedCount: getWebMountedRecentStreamItems(),
|
||||||
|
})
|
||||||
|
}, [indexedRows, shouldUseVirtualizer])
|
||||||
|
const virtualizedEntries = webVirtualizedHistoryWindow?.virtualizedEntries ?? []
|
||||||
|
const mountedEntries = webVirtualizedHistoryWindow?.mountedEntries ?? indexedRows
|
||||||
|
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId
|
||||||
|
const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: virtualizedEntries.length,
|
||||||
|
getScrollElement: () => scrollContainerRef.current,
|
||||||
|
getItemKey: (index: number) => virtualizedEntries[index]?.item.id ?? index,
|
||||||
|
estimateSize: (index: number) => {
|
||||||
|
const row = virtualizedEntries[index]?.item
|
||||||
|
return row ? estimateStreamItemHeight(row) : 120
|
||||||
|
},
|
||||||
|
measureElement: measureVirtualElement,
|
||||||
|
useAnimationFrameWithResizeObserver: true,
|
||||||
|
overscan: 8,
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
|
||||||
|
const viewportHeight = instance.scrollRect?.height ?? 0
|
||||||
|
const scrollOffset = instance.scrollOffset ?? 0
|
||||||
|
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight)
|
||||||
|
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined
|
||||||
|
}
|
||||||
|
}, [rowVirtualizer])
|
||||||
|
const virtualRows = rowVirtualizer.getVirtualItems()
|
||||||
|
const virtualTotalSize = rowVirtualizer.getTotalSize()
|
||||||
|
|
||||||
|
const cancelPendingStickToBottom = useCallback(() => {
|
||||||
|
const pendingFrame = pendingAutoScrollFrameRef.current
|
||||||
|
if (pendingFrame !== null) {
|
||||||
|
pendingAutoScrollFrameRef.current = null
|
||||||
|
window.cancelAnimationFrame(pendingFrame)
|
||||||
|
}
|
||||||
|
const pendingTimeout = pendingAutoScrollTimeoutRef.current
|
||||||
|
if (pendingTimeout !== null) {
|
||||||
|
pendingAutoScrollTimeoutRef.current = null
|
||||||
|
window.clearTimeout(pendingTimeout)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollMessagesToBottom = useCallback(
|
||||||
|
(behavior: ScrollBehaviorLike = 'auto', source = 'unknown') => {
|
||||||
|
const scrollContainer = scrollContainerRef.current
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollElementToBottom(scrollContainer, behavior)
|
||||||
|
lastKnownScrollTopRef.current = scrollContainer.scrollTop
|
||||||
|
syncNearBottom(scrollContainer, onNearBottomChange)
|
||||||
|
},
|
||||||
|
[onNearBottomChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const scheduleStickToBottom = useCallback((source = 'unknown') => {
|
||||||
|
const scrollContainer = scrollContainerRef.current
|
||||||
|
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pendingAutoScrollFrameRef.current !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
pendingAutoScrollFrameRef.current = null
|
||||||
|
if (!followOutputRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollMessagesToBottom('auto', source)
|
||||||
|
})
|
||||||
|
}, [scrollMessagesToBottom])
|
||||||
|
const forceStickToBottom = useCallback(() => {
|
||||||
|
cancelPendingStickToBottom()
|
||||||
|
scrollMessagesToBottom('auto', 'force')
|
||||||
|
scheduleStickToBottom('force')
|
||||||
|
}, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom])
|
||||||
|
|
||||||
|
const updateScrollMetrics = useCallback(() => {
|
||||||
|
const scrollContainer = scrollContainerRef.current
|
||||||
|
if (!scrollContainer) {
|
||||||
|
onNearBottomChange(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
streamScrollbarMetrics.onContentSizeChange(
|
||||||
|
scrollContainer.clientWidth,
|
||||||
|
scrollContainer.scrollHeight
|
||||||
|
)
|
||||||
|
streamScrollbarMetrics.onLayout({
|
||||||
|
nativeEvent: {
|
||||||
|
layout: {
|
||||||
|
width: scrollContainer.clientWidth,
|
||||||
|
height: scrollContainer.clientHeight,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
streamScrollbarMetrics.onScroll({
|
||||||
|
nativeEvent: {
|
||||||
|
contentOffset: { x: 0, y: scrollContainer.scrollTop },
|
||||||
|
contentSize: {
|
||||||
|
width: scrollContainer.clientWidth,
|
||||||
|
height: scrollContainer.scrollHeight,
|
||||||
|
},
|
||||||
|
layoutMeasurement: {
|
||||||
|
width: scrollContainer.clientWidth,
|
||||||
|
height: scrollContainer.clientHeight,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
syncNearBottom(scrollContainer, onNearBottomChange)
|
||||||
|
}, [onNearBottomChange, streamScrollbarMetrics])
|
||||||
|
|
||||||
|
const handleDomScroll = useCallback(() => {
|
||||||
|
const scrollContainer = scrollContainerRef.current
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentScrollTop = scrollContainer.scrollTop
|
||||||
|
const isNearBottom = syncNearBottom(scrollContainer, onNearBottomChange)
|
||||||
|
const isAtBottom = isScrollContainerAtBottom(scrollContainer)
|
||||||
|
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON
|
||||||
|
|
||||||
|
if (!followOutputRef.current && isAtBottom) {
|
||||||
|
setFollowOutput(true)
|
||||||
|
pendingUserScrollUpIntentRef.current = false
|
||||||
|
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
|
||||||
|
if (scrolledUp) {
|
||||||
|
cancelPendingStickToBottom()
|
||||||
|
setFollowOutput(false)
|
||||||
|
}
|
||||||
|
pendingUserScrollUpIntentRef.current = false
|
||||||
|
} else if (followOutputRef.current && isPointerScrollActiveRef.current) {
|
||||||
|
if (scrolledUp) {
|
||||||
|
cancelPendingStickToBottom()
|
||||||
|
setFollowOutput(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastKnownScrollTopRef.current = currentScrollTop
|
||||||
|
updateScrollMetrics()
|
||||||
|
}, [cancelPendingStickToBottom, onNearBottomChange, updateScrollMetrics])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isActivationReady) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFollowOutput(true)
|
||||||
|
forceStickToBottom()
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
console.log('timeout', {
|
||||||
|
followOutputRef: followOutputRef.current,
|
||||||
|
scrollContainerRef: scrollContainerRef.current,
|
||||||
|
isScrollContainerNearBottom: scrollContainerRef.current
|
||||||
|
? isScrollContainerNearBottom(scrollContainerRef.current)
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
if (!followOutputRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const scrollContainer = scrollContainerRef.current
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isScrollContainerNearBottom(scrollContainer)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleStickToBottom('activation-timeout')
|
||||||
|
}, WEB_BOTTOM_SETTLE_TIMEOUT_MS)
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!followOutputRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleStickToBottom('rows-effect')
|
||||||
|
}, [rows, scheduleStickToBottom])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!followOutputRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!webVirtualizedHistoryWindow) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleStickToBottom('virtual-total-size-effect')
|
||||||
|
}, [scheduleStickToBottom, virtualTotalSize, webVirtualizedHistoryWindow])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateScrollMetrics()
|
||||||
|
}, [updateScrollMetrics, virtualTotalSize, rows.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollContainer = scrollContainerRef.current
|
||||||
|
const contentNode = contentRef.current
|
||||||
|
if (!scrollContainer || typeof ResizeObserver === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScrollMetrics()
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
updateScrollMetrics()
|
||||||
|
if (!followOutputRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleStickToBottom('resize-observer')
|
||||||
|
})
|
||||||
|
observer.observe(scrollContainer)
|
||||||
|
if (contentNode) {
|
||||||
|
observer.observe(contentNode)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [scheduleStickToBottom, updateScrollMetrics])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollContainer = scrollContainerRef.current
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
if (event.deltaY < 0) {
|
||||||
|
pendingUserScrollUpIntentRef.current = true
|
||||||
|
cancelPendingStickToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handlePointerDown = () => {
|
||||||
|
isPointerScrollActiveRef.current = true
|
||||||
|
}
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
isPointerScrollActiveRef.current = false
|
||||||
|
}
|
||||||
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastTouchClientYRef.current = touch.clientY
|
||||||
|
}
|
||||||
|
const handleTouchMove = (event: TouchEvent) => {
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const previousTouchY = lastTouchClientYRef.current
|
||||||
|
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
|
||||||
|
pendingUserScrollUpIntentRef.current = true
|
||||||
|
cancelPendingStickToBottom()
|
||||||
|
}
|
||||||
|
lastTouchClientYRef.current = touch.clientY
|
||||||
|
}
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
lastTouchClientYRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollContainer.addEventListener('scroll', handleDomScroll, { passive: true })
|
||||||
|
scrollContainer.addEventListener('wheel', handleWheel, { passive: true })
|
||||||
|
scrollContainer.addEventListener('pointerdown', handlePointerDown, { passive: true })
|
||||||
|
scrollContainer.addEventListener('pointerup', handlePointerUp, { passive: true })
|
||||||
|
scrollContainer.addEventListener('pointercancel', handlePointerUp, { passive: true })
|
||||||
|
scrollContainer.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||||
|
scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: true })
|
||||||
|
scrollContainer.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||||
|
scrollContainer.addEventListener('touchcancel', handleTouchEnd, { passive: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollContainer.removeEventListener('scroll', handleDomScroll)
|
||||||
|
scrollContainer.removeEventListener('wheel', handleWheel)
|
||||||
|
scrollContainer.removeEventListener('pointerdown', handlePointerDown)
|
||||||
|
scrollContainer.removeEventListener('pointerup', handlePointerUp)
|
||||||
|
scrollContainer.removeEventListener('pointercancel', handlePointerUp)
|
||||||
|
scrollContainer.removeEventListener('touchstart', handleTouchStart)
|
||||||
|
scrollContainer.removeEventListener('touchmove', handleTouchMove)
|
||||||
|
scrollContainer.removeEventListener('touchend', handleTouchEnd)
|
||||||
|
scrollContainer.removeEventListener('touchcancel', handleTouchEnd)
|
||||||
|
}
|
||||||
|
}, [cancelPendingStickToBottom, handleDomScroll])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle: StreamViewportHandle = {
|
||||||
|
scrollToBottom: () => {
|
||||||
|
setFollowOutput(true)
|
||||||
|
cancelPendingStickToBottom()
|
||||||
|
forceStickToBottom()
|
||||||
|
},
|
||||||
|
prepareForViewportChange: () => {
|
||||||
|
if (!followOutputRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleStickToBottom('prepare-for-viewport-change')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
viewportRef.current = handle
|
||||||
|
return () => {
|
||||||
|
if (viewportRef.current === handle) {
|
||||||
|
viewportRef.current = null
|
||||||
|
}
|
||||||
|
cancelPendingStickToBottom()
|
||||||
|
}
|
||||||
|
}, [cancelPendingStickToBottom, scheduleStickToBottom, forceStickToBottom, viewportRef])
|
||||||
|
|
||||||
|
const contentContainerStyle = useMemo(
|
||||||
|
(): CSSProperties => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: '100%',
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 16,
|
||||||
|
paddingLeft: isMobileBreakpoint ? 8 : 16,
|
||||||
|
paddingRight: isMobileBreakpoint ? 8 : 16,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}),
|
||||||
|
[isMobileBreakpoint]
|
||||||
|
)
|
||||||
|
const scrollContainerStyle = useMemo(
|
||||||
|
(): CSSProperties => ({
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowX: 'hidden',
|
||||||
|
overflowY: scrollEnabled ? 'auto' : 'hidden',
|
||||||
|
overscrollBehaviorY: 'contain',
|
||||||
|
}),
|
||||||
|
[scrollEnabled]
|
||||||
|
)
|
||||||
|
const headerEdgeContent = renderEdge(edgeSlotProps.ListHeaderComponent)
|
||||||
|
const footerEdgeContent = renderEdge(
|
||||||
|
edgeSlotProps.ListFooterComponent,
|
||||||
|
edgeSlotProps.ListFooterComponentStyle as CSSProperties | undefined
|
||||||
|
)
|
||||||
|
const virtualRowsContainerStyle = useMemo(
|
||||||
|
(): CSSProperties => ({
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: virtualTotalSize,
|
||||||
|
}),
|
||||||
|
[virtualTotalSize]
|
||||||
|
)
|
||||||
|
const renderVirtualRowStyle = useCallback(
|
||||||
|
(start: number): CSSProperties => ({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${start}px)`,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style id={WEB_STREAM_SCROLLBAR_STYLE_ID}>{WEB_STREAM_SCROLLBAR_STYLE}</style>
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
scrollContainerRef.current = node
|
||||||
|
}}
|
||||||
|
data-testid="agent-chat-scroll"
|
||||||
|
id={`agent-chat-scroll-${shouldUseVirtualizer ? 'web-dom-virtualized' : 'web-dom-scroll'}`}
|
||||||
|
style={scrollContainerStyle}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
contentRef.current = node
|
||||||
|
}}
|
||||||
|
style={contentContainerStyle}
|
||||||
|
>
|
||||||
|
{headerEdgeContent}
|
||||||
|
{webVirtualizedHistoryWindow ? (
|
||||||
|
<div style={virtualRowsContainerStyle}>
|
||||||
|
{virtualRows.map((virtualRow) => {
|
||||||
|
const entry = virtualizedEntries[virtualRow.index]
|
||||||
|
if (!entry) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
style={renderVirtualRowStyle(virtualRow.start)}
|
||||||
|
>
|
||||||
|
{renderRow(entry.item, entry.index, rows)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{mountedEntries.map((entry) => (
|
||||||
|
<Fragment key={entry.item.id}>{renderRow(entry.item, entry.index, rows)}</Fragment>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 ? listEmptyComponent : null}
|
||||||
|
{footerEdgeContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<WebDesktopScrollbarOverlay
|
||||||
|
enabled={showDesktopWebScrollbar}
|
||||||
|
metrics={streamScrollbarMetrics}
|
||||||
|
inverted={false}
|
||||||
|
onScrollToOffset={(nextOffset) => {
|
||||||
|
const scrollContainer = scrollContainerRef.current
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollContainer.scrollTo({ top: nextOffset, behavior: 'auto' })
|
||||||
|
lastKnownScrollTopRef.current = scrollContainer.scrollTop
|
||||||
|
updateScrollMetrics()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy {
|
||||||
|
return createStreamStrategy({
|
||||||
|
render: (renderInput) => (
|
||||||
|
<WebStreamViewport
|
||||||
|
key={renderInput.agentId}
|
||||||
|
{...renderInput}
|
||||||
|
isMobileBreakpoint={input.isMobileBreakpoint}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
orderTailReverse: false,
|
||||||
|
orderHeadReverse: false,
|
||||||
|
assistantTurnTraversalStep: -1,
|
||||||
|
edgeSlot: 'footer',
|
||||||
|
flatListInverted: false,
|
||||||
|
overlayScrollbarInverted: false,
|
||||||
|
maintainVisibleContentPosition: undefined,
|
||||||
|
bottomAnchorTransportBehavior: {
|
||||||
|
verificationDelayFrames: 0,
|
||||||
|
verificationRetryMode: 'rescroll',
|
||||||
|
},
|
||||||
|
disableParentScrollOnInlineDetailsExpansion: false,
|
||||||
|
anchorBottomOnContentSizeChange: true,
|
||||||
|
animateManualScrollToBottom: false,
|
||||||
|
useVirtualizedList: false,
|
||||||
|
isNearBottom: (inputMetrics) => {
|
||||||
|
const distanceFromBottom = Math.max(
|
||||||
|
0,
|
||||||
|
inputMetrics.contentHeight - (inputMetrics.offsetY + inputMetrics.viewportHeight)
|
||||||
|
)
|
||||||
|
return distanceFromBottom <= inputMetrics.threshold
|
||||||
|
},
|
||||||
|
getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||||
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import type {
|
||||||
|
BottomAnchorLocalRequest,
|
||||||
|
BottomAnchorRouteRequest,
|
||||||
|
} from "./use-bottom-anchor-controller";
|
||||||
|
import { createNativeStreamStrategy } from "./stream-strategy-native";
|
||||||
|
import { createWebStreamStrategy } from "./stream-strategy-web";
|
||||||
|
|
||||||
|
type EdgeSlot = "header" | "footer";
|
||||||
|
type NeighborRelation = "above" | "below";
|
||||||
|
type AssistantTurnTraversalStep = -1 | 1;
|
||||||
|
|
||||||
|
export type MaintainVisibleContentPositionConfig = Readonly<{
|
||||||
|
minIndexForVisible: number;
|
||||||
|
autoscrollToTopThreshold: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type BottomAnchorTransportBehavior = Readonly<{
|
||||||
|
verificationDelayFrames: number;
|
||||||
|
verificationRetryMode: "rescroll" | "recheck";
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type StreamViewportMetrics = {
|
||||||
|
contentHeight: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamNearBottomInput = StreamViewportMetrics & {
|
||||||
|
offsetY: number;
|
||||||
|
threshold: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamEdgeSlotProps = {
|
||||||
|
ListHeaderComponent?: ReactElement | ComponentType<any> | null;
|
||||||
|
ListHeaderComponentStyle?: StyleProp<ViewStyle>;
|
||||||
|
ListFooterComponent?: ReactElement | ComponentType<any> | null;
|
||||||
|
ListFooterComponentStyle?: StyleProp<ViewStyle>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamViewportHandle = {
|
||||||
|
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||||
|
prepareForViewportChange: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamRenderInput = {
|
||||||
|
agentId: string;
|
||||||
|
rows: StreamItem[];
|
||||||
|
renderRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
|
||||||
|
listEmptyComponent: ReactNode;
|
||||||
|
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||||
|
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
|
||||||
|
isAuthoritativeHistoryReady: boolean;
|
||||||
|
onNearBottomChange: (value: boolean) => void;
|
||||||
|
scrollEnabled: boolean;
|
||||||
|
listStyle: StyleProp<ViewStyle>;
|
||||||
|
baseListContentContainerStyle: StyleProp<ViewStyle>;
|
||||||
|
forwardListContentContainerStyle: StyleProp<ViewStyle>;
|
||||||
|
edgeSlotProps: StreamEdgeSlotProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveStreamRenderStrategyInput = {
|
||||||
|
platform: string;
|
||||||
|
isMobileBreakpoint: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StreamStrategy {
|
||||||
|
render: (input: StreamRenderInput) => ReactNode;
|
||||||
|
orderTail: (streamItems: StreamItem[]) => StreamItem[];
|
||||||
|
orderHead: (streamHead: StreamItem[]) => StreamItem[];
|
||||||
|
getNeighborIndex: (index: number, relation: NeighborRelation) => number;
|
||||||
|
getNeighborItem: (
|
||||||
|
items: StreamItem[],
|
||||||
|
index: number,
|
||||||
|
relation: NeighborRelation
|
||||||
|
) => StreamItem | undefined;
|
||||||
|
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
|
||||||
|
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
||||||
|
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
||||||
|
getEdgeSlotProps: (
|
||||||
|
component: ReactElement | ComponentType<any> | null,
|
||||||
|
gapSize: number
|
||||||
|
) => StreamEdgeSlotProps;
|
||||||
|
getMaintainVisibleContentPosition: () =>
|
||||||
|
| MaintainVisibleContentPositionConfig
|
||||||
|
| undefined;
|
||||||
|
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
|
||||||
|
getFlatListInverted: () => boolean;
|
||||||
|
getOverlayScrollbarInverted: () => boolean;
|
||||||
|
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
|
||||||
|
shouldAnchorBottomOnContentSizeChange: () => boolean;
|
||||||
|
shouldAnimateManualScrollToBottom: () => boolean;
|
||||||
|
shouldUseVirtualizedList: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamStrategyConfig = {
|
||||||
|
render: StreamStrategy["render"];
|
||||||
|
orderTailReverse: boolean;
|
||||||
|
orderHeadReverse: boolean;
|
||||||
|
assistantTurnTraversalStep: AssistantTurnTraversalStep;
|
||||||
|
edgeSlot: EdgeSlot;
|
||||||
|
flatListInverted: boolean;
|
||||||
|
overlayScrollbarInverted: boolean;
|
||||||
|
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
|
||||||
|
bottomAnchorTransportBehavior: BottomAnchorTransportBehavior;
|
||||||
|
disableParentScrollOnInlineDetailsExpansion: boolean;
|
||||||
|
anchorBottomOnContentSizeChange: boolean;
|
||||||
|
animateManualScrollToBottom: boolean;
|
||||||
|
useVirtualizedList: boolean;
|
||||||
|
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
||||||
|
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
|
||||||
|
|
||||||
|
export function createStreamStrategy(
|
||||||
|
config: StreamStrategyConfig
|
||||||
|
): StreamStrategy {
|
||||||
|
return {
|
||||||
|
render: config.render,
|
||||||
|
orderTail: (streamItems) =>
|
||||||
|
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
|
||||||
|
orderHead: (streamHead) =>
|
||||||
|
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
|
||||||
|
getNeighborIndex: (index, relation) =>
|
||||||
|
relation === "above"
|
||||||
|
? index + config.assistantTurnTraversalStep
|
||||||
|
: index - config.assistantTurnTraversalStep,
|
||||||
|
getNeighborItem: (items, index, relation) => {
|
||||||
|
const neighborIndex =
|
||||||
|
relation === "above"
|
||||||
|
? index + config.assistantTurnTraversalStep
|
||||||
|
: index - config.assistantTurnTraversalStep;
|
||||||
|
if (neighborIndex < 0 || neighborIndex >= items.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return items[neighborIndex];
|
||||||
|
},
|
||||||
|
collectAssistantTurnContent: (items, startIndex) => {
|
||||||
|
const messages: string[] = [];
|
||||||
|
for (
|
||||||
|
let index = startIndex;
|
||||||
|
index >= 0 && index < items.length;
|
||||||
|
index += config.assistantTurnTraversalStep
|
||||||
|
) {
|
||||||
|
const currentItem = items[index];
|
||||||
|
if (currentItem.kind === "user_message") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (currentItem.kind === "assistant_message") {
|
||||||
|
messages.push(currentItem.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages.reverse().join("\n\n");
|
||||||
|
},
|
||||||
|
isNearBottom: (input) => config.isNearBottom(input),
|
||||||
|
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
|
||||||
|
getEdgeSlotProps: (component, gapSize) => {
|
||||||
|
if (config.edgeSlot === "header") {
|
||||||
|
return {
|
||||||
|
ListHeaderComponent: component,
|
||||||
|
ListHeaderComponentStyle: { marginBottom: gapSize },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ListFooterComponent: component,
|
||||||
|
ListFooterComponentStyle: { marginTop: gapSize },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
|
||||||
|
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
|
||||||
|
getFlatListInverted: () => config.flatListInverted,
|
||||||
|
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
|
||||||
|
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
|
||||||
|
config.disableParentScrollOnInlineDetailsExpansion,
|
||||||
|
shouldAnchorBottomOnContentSizeChange: () =>
|
||||||
|
config.anchorBottomOnContentSizeChange,
|
||||||
|
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
|
||||||
|
shouldUseVirtualizedList: () => config.useVirtualizedList,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStreamRenderStrategy(
|
||||||
|
input: ResolveStreamRenderStrategyInput
|
||||||
|
): StreamStrategy {
|
||||||
|
if (input.platform === "web") {
|
||||||
|
return createWebStreamStrategy({
|
||||||
|
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createNativeStreamStrategy();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBottomAnchorTransportBehavior(input: {
|
||||||
|
strategy: StreamStrategy;
|
||||||
|
isViewportSettling: boolean;
|
||||||
|
}): BottomAnchorTransportBehavior {
|
||||||
|
const baseBehavior = input.strategy.getBottomAnchorTransportBehavior();
|
||||||
|
if (!input.isViewportSettling || !input.strategy.getFlatListInverted()) {
|
||||||
|
return baseBehavior;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
verificationDelayFrames: Math.max(
|
||||||
|
baseBehavior.verificationDelayFrames,
|
||||||
|
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES
|
||||||
|
),
|
||||||
|
verificationRetryMode: "recheck",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderTailForStreamRenderStrategy(params: {
|
||||||
|
strategy: StreamStrategy;
|
||||||
|
streamItems: StreamItem[];
|
||||||
|
}): StreamItem[] {
|
||||||
|
return params.strategy.orderTail(params.streamItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderHeadForStreamRenderStrategy(params: {
|
||||||
|
strategy: StreamStrategy;
|
||||||
|
streamHead: StreamItem[];
|
||||||
|
}): StreamItem[] {
|
||||||
|
return params.strategy.orderHead(params.streamHead);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStreamNeighborIndex(params: {
|
||||||
|
strategy: StreamStrategy;
|
||||||
|
index: number;
|
||||||
|
relation: NeighborRelation;
|
||||||
|
}): number {
|
||||||
|
return params.strategy.getNeighborIndex(params.index, params.relation);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStreamNeighborItem(params: {
|
||||||
|
strategy: StreamStrategy;
|
||||||
|
items: StreamItem[];
|
||||||
|
index: number;
|
||||||
|
relation: NeighborRelation;
|
||||||
|
}): StreamItem | undefined {
|
||||||
|
return params.strategy.getNeighborItem(
|
||||||
|
params.items,
|
||||||
|
params.index,
|
||||||
|
params.relation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
||||||
|
strategy: StreamStrategy;
|
||||||
|
items: StreamItem[];
|
||||||
|
startIndex: number;
|
||||||
|
}): string {
|
||||||
|
return params.strategy.collectAssistantTurnContent(
|
||||||
|
params.items,
|
||||||
|
params.startIndex
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNearBottomForStreamRenderStrategy(
|
||||||
|
params: StreamNearBottomInput & { strategy: StreamStrategy }
|
||||||
|
): boolean {
|
||||||
|
return params.strategy.isNearBottom({
|
||||||
|
offsetY: params.offsetY,
|
||||||
|
threshold: params.threshold,
|
||||||
|
contentHeight: params.contentHeight,
|
||||||
|
viewportHeight: params.viewportHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBottomOffsetForStreamRenderStrategy(
|
||||||
|
params: StreamViewportMetrics & {
|
||||||
|
strategy: StreamStrategy;
|
||||||
|
}
|
||||||
|
): number {
|
||||||
|
return params.strategy.getBottomOffset({
|
||||||
|
contentHeight: params.contentHeight,
|
||||||
|
viewportHeight: params.viewportHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStreamEdgeSlotProps(params: {
|
||||||
|
strategy: StreamStrategy;
|
||||||
|
component: ReactElement | ComponentType<any> | null;
|
||||||
|
gapSize: number;
|
||||||
|
}): StreamEdgeSlotProps {
|
||||||
|
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
|
||||||
|
}
|
||||||
@@ -133,6 +133,7 @@ function createDriverHarness(input?: {
|
|||||||
});
|
});
|
||||||
const modeChanges: BottomAnchorMode[] = [];
|
const modeChanges: BottomAnchorMode[] = [];
|
||||||
const warnings: Array<{ agentId: string; reason: string }> = [];
|
const warnings: Array<{ agentId: string; reason: string }> = [];
|
||||||
|
const logs: Array<{ event: string; details: Record<string, unknown> }> = [];
|
||||||
const driver = __private__.createBottomAnchorControllerDriver({
|
const driver = __private__.createBottomAnchorControllerDriver({
|
||||||
getAgentId: () => context.agentId,
|
getAgentId: () => context.agentId,
|
||||||
getIsAuthoritativeHistoryReady: () => context.authoritativeReady,
|
getIsAuthoritativeHistoryReady: () => context.authoritativeReady,
|
||||||
@@ -144,7 +145,9 @@ function createDriverHarness(input?: {
|
|||||||
onModeChange: (mode) => {
|
onModeChange: (mode) => {
|
||||||
modeChanges.push(mode);
|
modeChanges.push(mode);
|
||||||
},
|
},
|
||||||
log: () => {},
|
log: (event, details) => {
|
||||||
|
logs.push({ event, details });
|
||||||
|
},
|
||||||
warn: (details) => warnings.push(details),
|
warn: (details) => warnings.push(details),
|
||||||
scheduleFrame: (params) => scheduler.schedule(params),
|
scheduleFrame: (params) => scheduler.schedule(params),
|
||||||
cancelFrame: (handle) => scheduler.cancel(handle),
|
cancelFrame: (handle) => scheduler.cancel(handle),
|
||||||
@@ -156,6 +159,7 @@ function createDriverHarness(input?: {
|
|||||||
scheduler,
|
scheduler,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
modeChanges,
|
modeChanges,
|
||||||
|
logs,
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -367,6 +371,122 @@ describe("bottom anchor controller driver", () => {
|
|||||||
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
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", () => {
|
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
|
||||||
const harness = createDriverHarness();
|
const harness = createDriverHarness();
|
||||||
harness.context.nearBottom = false;
|
harness.context.nearBottom = false;
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ type ControllerMeasurementState = {
|
|||||||
type AttemptContext = {
|
type AttemptContext = {
|
||||||
requestId: number | null;
|
requestId: number | null;
|
||||||
retries: number;
|
retries: number;
|
||||||
|
confirmationPasses?: number;
|
||||||
|
startedContentHeight?: number;
|
||||||
|
startedOffsetY?: number;
|
||||||
|
startedViewportHeight?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ScheduledFrameHandle = {
|
type ScheduledFrameHandle = {
|
||||||
@@ -55,12 +59,14 @@ type ScheduledFrameHandle = {
|
|||||||
|
|
||||||
type BottomAnchorEvent =
|
type BottomAnchorEvent =
|
||||||
| "request_created"
|
| "request_created"
|
||||||
|
| "evaluate_called"
|
||||||
| "attempt_started"
|
| "attempt_started"
|
||||||
| "attempt_verified"
|
| "attempt_verified"
|
||||||
| "attempt_failed"
|
| "attempt_failed"
|
||||||
| "request_fulfilled"
|
| "request_fulfilled"
|
||||||
| "request_cancelled"
|
| "request_cancelled"
|
||||||
| "detached_by_user"
|
| "detached_by_user"
|
||||||
|
| "verification_scheduled"
|
||||||
| "blocked_reason_changed";
|
| "blocked_reason_changed";
|
||||||
|
|
||||||
type BottomAnchorControllerDriver = {
|
type BottomAnchorControllerDriver = {
|
||||||
@@ -115,6 +121,7 @@ type CreateBottomAnchorControllerDriverInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MAX_VERIFICATION_RETRIES = 3;
|
const MAX_VERIFICATION_RETRIES = 3;
|
||||||
|
const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1;
|
||||||
const USER_SCROLL_AWAY_DELTA_PX = 24;
|
const USER_SCROLL_AWAY_DELTA_PX = 24;
|
||||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||||
|
|
||||||
@@ -233,15 +240,41 @@ function deriveRetryDisposition(input: {
|
|||||||
: "retry-scroll";
|
: "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(
|
function getDetailedMeasurementState(
|
||||||
measurementState: ControllerMeasurementState
|
measurementState: ControllerMeasurementState
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
|
const distanceFromBottom = Math.max(
|
||||||
|
0,
|
||||||
|
measurementState.contentHeight -
|
||||||
|
(measurementState.offsetY + measurementState.viewportHeight)
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
containerKey: measurementState.containerKey,
|
containerKey: measurementState.containerKey,
|
||||||
viewportWidth: measurementState.viewportWidth,
|
viewportWidth: measurementState.viewportWidth,
|
||||||
viewportHeight: measurementState.viewportHeight,
|
viewportHeight: measurementState.viewportHeight,
|
||||||
contentHeight: measurementState.contentHeight,
|
contentHeight: measurementState.contentHeight,
|
||||||
offsetY: measurementState.offsetY,
|
offsetY: measurementState.offsetY,
|
||||||
|
distanceFromBottom,
|
||||||
viewportMeasuredForKey: measurementState.viewportMeasuredForKey,
|
viewportMeasuredForKey: measurementState.viewportMeasuredForKey,
|
||||||
contentMeasuredForKey: measurementState.contentMeasuredForKey,
|
contentMeasuredForKey: measurementState.contentMeasuredForKey,
|
||||||
};
|
};
|
||||||
@@ -263,6 +296,11 @@ function createBottomAnchorControllerDriver(
|
|||||||
|
|
||||||
const getLogContext = (extra?: Record<string, unknown>) => {
|
const getLogContext = (extra?: Record<string, unknown>) => {
|
||||||
const measurementState = input.getMeasurementState();
|
const measurementState = input.getMeasurementState();
|
||||||
|
const distanceFromBottom = Math.max(
|
||||||
|
0,
|
||||||
|
measurementState.contentHeight -
|
||||||
|
(measurementState.offsetY + measurementState.viewportHeight)
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
agentId: input.getAgentId(),
|
agentId: input.getAgentId(),
|
||||||
requestReason: pendingRequest?.reason ?? null,
|
requestReason: pendingRequest?.reason ?? null,
|
||||||
@@ -270,10 +308,12 @@ function createBottomAnchorControllerDriver(
|
|||||||
contentHeight: measurementState.contentHeight,
|
contentHeight: measurementState.contentHeight,
|
||||||
viewportHeight: measurementState.viewportHeight,
|
viewportHeight: measurementState.viewportHeight,
|
||||||
offset: measurementState.offsetY,
|
offset: measurementState.offsetY,
|
||||||
|
distanceFromBottom,
|
||||||
renderStrategy: input.getRenderStrategy(),
|
renderStrategy: input.getRenderStrategy(),
|
||||||
blockedReason,
|
blockedReason,
|
||||||
mode,
|
mode,
|
||||||
containerKey: measurementState.containerKey,
|
containerKey: measurementState.containerKey,
|
||||||
|
transportBehavior: input.getTransportBehavior(),
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -339,13 +379,42 @@ function createBottomAnchorControllerDriver(
|
|||||||
setBlockedReason(null);
|
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) {
|
if (verificationHandle) {
|
||||||
input.cancelFrame(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({
|
verificationHandle = input.scheduleFrame({
|
||||||
kind: "verification",
|
kind: "verification",
|
||||||
delayFrames: input.getTransportBehavior().verificationDelayFrames,
|
delayFrames:
|
||||||
|
delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames,
|
||||||
callback: () => {
|
callback: () => {
|
||||||
verificationHandle = null;
|
verificationHandle = null;
|
||||||
const currentRequest = pendingRequest;
|
const currentRequest = pendingRequest;
|
||||||
@@ -388,11 +457,40 @@ function createBottomAnchorControllerDriver(
|
|||||||
verifiedNearBottom,
|
verifiedNearBottom,
|
||||||
retries: attemptContext.retries,
|
retries: attemptContext.retries,
|
||||||
retryDisposition,
|
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),
|
measurementState: getDetailedMeasurementState(measurementState),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
if (verifiedNearBottom) {
|
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;
|
pendingVerification = null;
|
||||||
markStickyMeasurementVerified();
|
markStickyMeasurementVerified();
|
||||||
if (isRequestAttempt) {
|
if (isRequestAttempt) {
|
||||||
@@ -418,7 +516,7 @@ function createBottomAnchorControllerDriver(
|
|||||||
requestId: attemptContext.requestId,
|
requestId: attemptContext.requestId,
|
||||||
retries: attemptContext.retries + 1,
|
retries: attemptContext.retries + 1,
|
||||||
};
|
};
|
||||||
evaluate(false);
|
evaluate(false, "retry_scroll");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,28 +543,51 @@ function createBottomAnchorControllerDriver(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const runAttempt = (animated: boolean) => {
|
const runAttempt = (animated: boolean) => {
|
||||||
|
const measurementState = input.getMeasurementState();
|
||||||
const attemptContext: AttemptContext = {
|
const attemptContext: AttemptContext = {
|
||||||
requestId: pendingRequest?.id ?? null,
|
requestId: pendingRequest?.id ?? null,
|
||||||
retries: pendingVerification?.retries ?? 0,
|
retries: pendingVerification?.retries ?? 0,
|
||||||
|
startedContentHeight: measurementState.contentHeight,
|
||||||
|
startedOffsetY: measurementState.offsetY,
|
||||||
|
startedViewportHeight: measurementState.viewportHeight,
|
||||||
};
|
};
|
||||||
pendingVerification = attemptContext;
|
pendingVerification = attemptContext;
|
||||||
input.log(
|
input.log(
|
||||||
"attempt_started",
|
"attempt_started",
|
||||||
getLogContext({ animated, retries: attemptContext.retries })
|
getLogContext({
|
||||||
|
animated,
|
||||||
|
retries: attemptContext.retries,
|
||||||
|
measurementState: getDetailedMeasurementState(measurementState),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
input.scrollToBottom(animated);
|
input.scrollToBottom(animated);
|
||||||
scheduleVerification(attemptContext);
|
scheduleVerification(attemptContext);
|
||||||
setBlockedReason(
|
setBlockedReason(deriveDriverBlockedReason(input.getMeasurementState()));
|
||||||
deriveBottomAnchorBlockedReason({
|
|
||||||
pendingRequest,
|
|
||||||
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
|
||||||
measurementState: input.getMeasurementState(),
|
|
||||||
pendingVerificationRequestId: attemptContext.requestId,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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) {
|
if (attemptHandle) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -475,12 +596,7 @@ function createBottomAnchorControllerDriver(
|
|||||||
callback: () => {
|
callback: () => {
|
||||||
attemptHandle = null;
|
attemptHandle = null;
|
||||||
const measurementState = input.getMeasurementState();
|
const measurementState = input.getMeasurementState();
|
||||||
const nextBlockedReason = deriveBottomAnchorBlockedReason({
|
const nextBlockedReason = deriveDriverBlockedReason(measurementState);
|
||||||
pendingRequest,
|
|
||||||
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
|
||||||
measurementState,
|
|
||||||
pendingVerificationRequestId: pendingVerification?.requestId ?? null,
|
|
||||||
});
|
|
||||||
setBlockedReason(nextBlockedReason);
|
setBlockedReason(nextBlockedReason);
|
||||||
|
|
||||||
const shouldAttemptForPendingRequest =
|
const shouldAttemptForPendingRequest =
|
||||||
@@ -498,6 +614,7 @@ function createBottomAnchorControllerDriver(
|
|||||||
"attempt_started",
|
"attempt_started",
|
||||||
getLogContext({
|
getLogContext({
|
||||||
attemptPhase: "skipped",
|
attemptPhase: "skipped",
|
||||||
|
evaluateReason: reason,
|
||||||
nextBlockedReason,
|
nextBlockedReason,
|
||||||
shouldAttemptForPendingRequest,
|
shouldAttemptForPendingRequest,
|
||||||
shouldAttemptForStickyVerification,
|
shouldAttemptForStickyVerification,
|
||||||
@@ -546,7 +663,7 @@ function createBottomAnchorControllerDriver(
|
|||||||
"request_created",
|
"request_created",
|
||||||
getLogContext({ requestReason: request.reason })
|
getLogContext({ requestReason: request.reason })
|
||||||
);
|
);
|
||||||
evaluate(request.reason === "jump-to-bottom");
|
evaluate(request.reason === "jump-to-bottom", "request_created");
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -610,7 +727,7 @@ function createBottomAnchorControllerDriver(
|
|||||||
pendingVerification = { requestId: null, retries: 0 };
|
pendingVerification = { requestId: null, retries: 0 };
|
||||||
}
|
}
|
||||||
if (shouldRestick || pendingRequest) {
|
if (shouldRestick || pendingRequest) {
|
||||||
evaluate(false);
|
evaluate(false, "viewport_change");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleContentSizeChange(params) {
|
handleContentSizeChange(params) {
|
||||||
@@ -626,7 +743,7 @@ function createBottomAnchorControllerDriver(
|
|||||||
pendingVerification = { requestId: null, retries: 0 };
|
pendingVerification = { requestId: null, retries: 0 };
|
||||||
}
|
}
|
||||||
if (shouldRestick || pendingRequest) {
|
if (shouldRestick || pendingRequest) {
|
||||||
evaluate(false);
|
evaluate(false, "content_size_change");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
prepareForStickyViewportChange() {
|
prepareForStickyViewportChange() {
|
||||||
@@ -673,21 +790,21 @@ function createBottomAnchorControllerDriver(
|
|||||||
if (!pendingRequest && !pendingVerification) {
|
if (!pendingRequest && !pendingVerification) {
|
||||||
pendingVerification = { requestId: null, retries: 0 };
|
pendingVerification = { requestId: null, retries: 0 };
|
||||||
}
|
}
|
||||||
evaluate(false);
|
evaluate(false, "scroll_near_bottom_change");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nextIsNearBottom && pendingRequest) {
|
if (nextIsNearBottom && pendingRequest) {
|
||||||
evaluate(false);
|
evaluate(false, "scroll_near_bottom_change");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
notifyAuthoritativeHistoryMaybeChanged() {
|
notifyAuthoritativeHistoryMaybeChanged() {
|
||||||
if (!pendingVerification && !pendingRequest) {
|
if (!pendingVerification && !pendingRequest) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
evaluate(false);
|
evaluate(false, "history_readiness_change");
|
||||||
},
|
},
|
||||||
reevaluate(animated = false) {
|
reevaluate(animated = false) {
|
||||||
evaluate(animated);
|
evaluate(animated, "manual_reevaluate");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user