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