mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
* fix(app): preserve composer capacity when the keyboard closes Releasing the keyboard reservation enlarged an unchanged capped draft. Retain the last editing reservation while still recalculating capacity for viewport changes and subsequent keyboard openings. * perf(app): isolate composer editing and resize updates Draft and cursor subscriptions rerendered the chat and full composer on every native edit. Give editing consumers a live text source, preserve attachment identity on text updates, and read viewport settling state without a render. Read live input text for web resize measurement so removing per-keystroke props does not collapse a draft when its width changes. * fix(app): restore current draft when the composer remounts The last replacement command does not include subsequent edits. Seed remounted editors from the live draft snapshot so switching launch modes restores the saved text without subscribing the composer to every edit. * fix(app): synchronize composer resolution state before paint Avoid an extra passive-effect delay when forwarding forge resolution state from the isolated binding. Exercise live-text width measurement through the actual composer E2E journey instead of a hook-only test component.
630 lines
22 KiB
TypeScript
630 lines
22 KiB
TypeScript
import {
|
|
Fragment,
|
|
type ReactElement,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
FlatList,
|
|
Platform,
|
|
View,
|
|
type LayoutChangeEvent,
|
|
type ListRenderItemInfo,
|
|
type NativeScrollEvent,
|
|
type NativeSyntheticEvent,
|
|
type ViewStyle,
|
|
} from "react-native";
|
|
import { withUnistyles } from "react-native-unistyles";
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import type { StreamItem } from "@/types/stream";
|
|
import type { Theme } from "@/styles/theme";
|
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
import { useSettledKeyboardShift } from "@/hooks/keyboard-shift-context";
|
|
import { resolveStreamKeyboardInset } from "@/hooks/keyboard-shift-policy";
|
|
import { useRevisedHistoryRows } from "./history-row-revision";
|
|
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
|
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
|
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
|
import {
|
|
createStreamStrategy,
|
|
isNearBottomForStreamRenderStrategy,
|
|
resolveBottomAnchorTransportBehavior,
|
|
} from "./strategy";
|
|
import {
|
|
abandonHistoryStartPaginationRequest,
|
|
createHistoryStartPaginationState,
|
|
evaluateHistoryStartPagination,
|
|
isHistoryStartLoadingOperation,
|
|
rearmHistoryStartPagination,
|
|
settleHistoryStartPagination,
|
|
type HistoryStartPaginationInput,
|
|
type HistoryStartPaginationTransition,
|
|
} from "./history-start-pagination";
|
|
import {
|
|
createHistoryStartSettleScheduler,
|
|
type HistoryStartSettleScheduler,
|
|
} from "./history-start-settle-scheduler";
|
|
|
|
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
|
minIndexForVisible: 0,
|
|
autoscrollToTopThreshold: 0,
|
|
});
|
|
|
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.foregroundMuted,
|
|
});
|
|
const historyStartSlotStyle: ViewStyle = {
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
height: 32,
|
|
flexShrink: 0,
|
|
};
|
|
const HISTORY_START_SETTLE_FRAMES = 2;
|
|
|
|
function keyExtractor(item: { id: string }): string {
|
|
return item.id;
|
|
}
|
|
|
|
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
|
|
const {
|
|
agentId,
|
|
segments,
|
|
historyRowRevision,
|
|
liveHeadRowRevision,
|
|
boundary,
|
|
renderers,
|
|
listEmptyComponent,
|
|
viewportRef,
|
|
routeBottomAnchorRequest,
|
|
isAuthoritativeHistoryReady,
|
|
onNearBottomChange,
|
|
onNearHistoryStart,
|
|
isLoadingOlderHistory,
|
|
hasOlderHistory,
|
|
olderHistoryProgressKey,
|
|
scrollEnabled,
|
|
listStyle,
|
|
baseListContentContainerStyle,
|
|
strategy,
|
|
} = props;
|
|
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
|
|
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 isUserScrollActiveRef = useRef(false);
|
|
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
|
|
const settledKeyboardShift = useSettledKeyboardShift();
|
|
const userScrollEndFrameIdRef = useRef<number | null>(null);
|
|
const programmaticScrollEventBudgetRef = useRef(0);
|
|
const isNativeViewportSettlingRef = useRef(false);
|
|
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
|
const historyStartReadyRef = useRef(false);
|
|
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
|
createHistoryStartPaginationState,
|
|
);
|
|
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
|
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
|
|
|
const historyItems = useMemo(() => {
|
|
if (segments.historyVirtualized.length === 0) {
|
|
return segments.historyMounted;
|
|
}
|
|
return [...segments.historyVirtualized, ...segments.historyMounted];
|
|
}, [segments.historyMounted, segments.historyVirtualized]);
|
|
const historyRows = useRevisedHistoryRows(historyItems, historyRowRevision);
|
|
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
|
|
const metrics = streamViewportMetricsRef.current;
|
|
const hasMeasuredViewport =
|
|
metrics.viewportMeasuredForKey === metrics.containerKey &&
|
|
metrics.contentMeasuredForKey === metrics.containerKey;
|
|
return {
|
|
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
|
|
hasOlderHistory,
|
|
isLoadingOlderHistory,
|
|
isReady: historyStartReadyRef.current && hasMeasuredViewport,
|
|
progressKey: olderHistoryProgressKey,
|
|
};
|
|
});
|
|
const applyHistoryStartPaginationTransition = useStableEvent(
|
|
(transition: HistoryStartPaginationTransition) => {
|
|
const previousState = historyStartPaginationStateRef.current;
|
|
historyStartPaginationStateRef.current = transition.state;
|
|
if (transition.state !== previousState) {
|
|
setHistoryStartPaginationState(transition.state);
|
|
}
|
|
if (transition.shouldLoad) {
|
|
const requestedProgressKey = olderHistoryProgressKey;
|
|
if (requestedProgressKey === null) {
|
|
return;
|
|
}
|
|
void (async () => {
|
|
const started = await onNearHistoryStart();
|
|
if (started === true) {
|
|
return;
|
|
}
|
|
applyHistoryStartPaginationTransition({
|
|
state: abandonHistoryStartPaginationRequest(
|
|
historyStartPaginationStateRef.current,
|
|
requestedProgressKey,
|
|
),
|
|
shouldLoad: false,
|
|
});
|
|
})();
|
|
}
|
|
},
|
|
);
|
|
const evaluateHistoryStart = useStableEvent(() => {
|
|
const transition = evaluateHistoryStartPagination(
|
|
historyStartPaginationStateRef.current,
|
|
getHistoryStartPaginationInput(),
|
|
);
|
|
applyHistoryStartPaginationTransition(transition);
|
|
});
|
|
const scheduleHistoryStartSettle = useStableEvent(() => {
|
|
let scheduler = historyStartSettleSchedulerRef.current;
|
|
if (!scheduler) {
|
|
scheduler = createHistoryStartSettleScheduler({
|
|
settleFrames: HISTORY_START_SETTLE_FRAMES,
|
|
requestFrame: requestAnimationFrame,
|
|
cancelFrame: cancelAnimationFrame,
|
|
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
|
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
|
|
onSettle: () => {
|
|
const transition = settleHistoryStartPagination(
|
|
historyStartPaginationStateRef.current,
|
|
getHistoryStartPaginationInput(),
|
|
);
|
|
applyHistoryStartPaginationTransition(transition);
|
|
},
|
|
});
|
|
historyStartSettleSchedulerRef.current = scheduler;
|
|
}
|
|
scheduler.schedule();
|
|
});
|
|
|
|
const clearNativeViewportSettling = useCallback(() => {
|
|
if (nativeViewportSettlingFrameIdRef.current !== null) {
|
|
cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current);
|
|
nativeViewportSettlingFrameIdRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const clearPendingUserScrollEnd = useCallback(() => {
|
|
if (userScrollEndFrameIdRef.current !== null) {
|
|
cancelAnimationFrame(userScrollEndFrameIdRef.current);
|
|
userScrollEndFrameIdRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const markNativeViewportSettling = useCallback(() => {
|
|
clearNativeViewportSettling();
|
|
isNativeViewportSettlingRef.current = true;
|
|
let remainingFrames = 4;
|
|
const tick = () => {
|
|
if (remainingFrames <= 0) {
|
|
nativeViewportSettlingFrameIdRef.current = null;
|
|
isNativeViewportSettlingRef.current = false;
|
|
return;
|
|
}
|
|
remainingFrames -= 1;
|
|
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
|
|
};
|
|
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
|
|
}, [clearNativeViewportSettling]);
|
|
|
|
const getBottomAnchorTransportBehavior = useCallback(
|
|
() =>
|
|
resolveBottomAnchorTransportBehavior({
|
|
strategy,
|
|
isViewportSettling: isNativeViewportSettlingRef.current,
|
|
}),
|
|
[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",
|
|
getTransportBehavior: getBottomAnchorTransportBehavior,
|
|
getMeasurementState: () => streamViewportMetricsRef.current,
|
|
isNearBottom: () => {
|
|
const metrics = streamViewportMetricsRef.current;
|
|
return isNearBottomForStreamRenderStrategy({
|
|
strategy,
|
|
offsetY: metrics.offsetY,
|
|
threshold: 32,
|
|
contentHeight: metrics.contentHeight,
|
|
viewportHeight: metrics.viewportHeight,
|
|
});
|
|
},
|
|
scrollToBottom,
|
|
});
|
|
// Android's maintainVisibleContentPosition ignores the list inversion transform and
|
|
// fights the controller's offset-zero correction while the live header grows.
|
|
const maintainVisibleContentPosition =
|
|
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
|
|
? undefined
|
|
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
|
|
const streamKeyboardInset = useMemo(
|
|
() =>
|
|
resolveStreamKeyboardInset({
|
|
platform: Platform.OS === "ios" ? "ios" : "android",
|
|
settledShift: settledKeyboardShift,
|
|
}),
|
|
[settledKeyboardShift],
|
|
);
|
|
const listContentContainerStyle = useMemo(
|
|
() => [
|
|
baseListContentContainerStyle,
|
|
{ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom },
|
|
],
|
|
[baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
|
|
);
|
|
const listInsetProps = useMemo(
|
|
() =>
|
|
streamKeyboardInset.contentInset
|
|
? {
|
|
automaticallyAdjustContentInsets: false,
|
|
automaticallyAdjustsScrollIndicatorInsets: false,
|
|
contentInsetAdjustmentBehavior: "never" as const,
|
|
contentInset: streamKeyboardInset.contentInset,
|
|
scrollIndicatorInsets: streamKeyboardInset.contentInset,
|
|
}
|
|
: {},
|
|
[streamKeyboardInset.contentInset],
|
|
);
|
|
|
|
useEffect(() => {
|
|
streamViewportMetricsRef.current = {
|
|
containerKey: "native-virtualized",
|
|
contentHeight: 0,
|
|
viewportWidth: 0,
|
|
viewportHeight: 0,
|
|
offsetY: 0,
|
|
viewportMeasuredForKey: null,
|
|
contentMeasuredForKey: null,
|
|
};
|
|
scrollOffsetYRef.current = 0;
|
|
isUserScrollActiveRef.current = false;
|
|
clearPendingUserScrollEnd();
|
|
clearNativeViewportSettling();
|
|
isNativeViewportSettlingRef.current = false;
|
|
historyStartReadyRef.current = false;
|
|
const initialHistoryStartState = createHistoryStartPaginationState();
|
|
historyStartPaginationStateRef.current = initialHistoryStartState;
|
|
setHistoryStartPaginationState(initialHistoryStartState);
|
|
const frame = requestAnimationFrame(() => {
|
|
historyStartReadyRef.current = true;
|
|
evaluateHistoryStart();
|
|
});
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
clearPendingUserScrollEnd();
|
|
historyStartSettleSchedulerRef.current?.cancel();
|
|
historyStartSettleSchedulerRef.current = null;
|
|
};
|
|
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
|
|
|
|
useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]);
|
|
|
|
useEffect(() => {
|
|
bottomAnchorController.prepareForStickyContentChange();
|
|
}, [bottomAnchorController, historyRows, segments.liveHead]);
|
|
|
|
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 isScrollEventNearBottom = useStableEvent(
|
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
|
return isNearBottomForStreamRenderStrategy({
|
|
strategy,
|
|
offsetY: contentOffset.y,
|
|
threshold: 32,
|
|
contentHeight: contentSize.height,
|
|
viewportHeight: layoutMeasurement.height,
|
|
});
|
|
},
|
|
);
|
|
|
|
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
|
const previousOffsetY = scrollOffsetYRef.current;
|
|
scrollOffsetYRef.current = contentOffset.y;
|
|
scrollKeyboardDismiss.onScroll(event);
|
|
|
|
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 = isScrollEventNearBottom(event);
|
|
onNearBottomChange(nearBottom);
|
|
|
|
evaluateHistoryStart();
|
|
|
|
if (
|
|
!isUserScrollActiveRef.current &&
|
|
programmaticScrollEventBudgetRef.current > 0 &&
|
|
contentOffset.y <= 8
|
|
) {
|
|
programmaticScrollEventBudgetRef.current -= 1;
|
|
} else {
|
|
programmaticScrollEventBudgetRef.current = 0;
|
|
bottomAnchorController.handleScrollNearBottomChange({
|
|
nextIsNearBottom: nearBottom,
|
|
scrollDelta: contentOffset.y - previousOffsetY,
|
|
});
|
|
}
|
|
});
|
|
|
|
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
clearPendingUserScrollEnd();
|
|
isUserScrollActiveRef.current = true;
|
|
scrollKeyboardDismiss.onScrollBeginDrag(event);
|
|
bottomAnchorController.beginUserScroll();
|
|
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
|
if (rearmed !== historyStartPaginationStateRef.current) {
|
|
historyStartPaginationStateRef.current = rearmed;
|
|
setHistoryStartPaginationState(rearmed);
|
|
evaluateHistoryStart();
|
|
}
|
|
});
|
|
|
|
// Defer drag end so momentum can take ownership, but capture the terminal
|
|
// gesture position now because layout may move the viewport in the meantime.
|
|
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
const isNearBottom = isScrollEventNearBottom(event);
|
|
scrollKeyboardDismiss.onScrollEndDrag(event);
|
|
|
|
clearPendingUserScrollEnd();
|
|
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
|
|
userScrollEndFrameIdRef.current = null;
|
|
isUserScrollActiveRef.current = false;
|
|
bottomAnchorController.endUserScroll({ isNearBottom });
|
|
});
|
|
});
|
|
|
|
const handleMomentumScrollBegin = useStableEvent(() => {
|
|
clearPendingUserScrollEnd();
|
|
});
|
|
|
|
const handleMomentumScrollEnd = useStableEvent(
|
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
// Android can emit momentum-end after a programmatic anchor correction.
|
|
// Only momentum that still owns the user gesture may settle scroll intent.
|
|
if (!isUserScrollActiveRef.current) {
|
|
return;
|
|
}
|
|
const isNearBottom = isScrollEventNearBottom(event);
|
|
clearPendingUserScrollEnd();
|
|
isUserScrollActiveRef.current = false;
|
|
bottomAnchorController.endUserScroll({ isNearBottom });
|
|
},
|
|
);
|
|
|
|
const handleListLayout = useStableEvent((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,
|
|
});
|
|
evaluateHistoryStart();
|
|
});
|
|
|
|
const handleContentSizeChange = useStableEvent((_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,
|
|
});
|
|
evaluateHistoryStart();
|
|
if (historyStartPaginationStateRef.current.status === "settling") {
|
|
scheduleHistoryStartSettle();
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
evaluateHistoryStart();
|
|
if (historyStartPaginationStateRef.current.status === "settling") {
|
|
scheduleHistoryStartSettle();
|
|
}
|
|
}, [
|
|
evaluateHistoryStart,
|
|
hasOlderHistory,
|
|
isLoadingOlderHistory,
|
|
olderHistoryProgressKey,
|
|
scheduleHistoryStartSettle,
|
|
]);
|
|
|
|
const renderItem = useStableEvent(
|
|
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
|
|
const rendered = renderHistoryMountedRow(item, index, historyItems);
|
|
return (rendered ?? null) as ReactElement | null;
|
|
},
|
|
);
|
|
|
|
const liveHeaderContent = useMemo(() => {
|
|
// Stable render events read the latest expansion state; this revision makes
|
|
// the memo invoke them again when that state changes.
|
|
void liveHeadRowRevision;
|
|
const liveHeadRows = segments.liveHead.map((item, index) => (
|
|
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
|
|
));
|
|
const liveAuxiliary = renderLiveAuxiliary();
|
|
if (
|
|
liveHeadRows.length === 0 &&
|
|
!liveAuxiliary &&
|
|
!boundary.hasMountedHistory &&
|
|
!boundary.hasVirtualizedHistory
|
|
) {
|
|
return (listEmptyComponent ?? null) as ReactElement | null;
|
|
}
|
|
return (
|
|
<Fragment>
|
|
{liveHeadRows}
|
|
{liveAuxiliary}
|
|
</Fragment>
|
|
);
|
|
}, [
|
|
boundary,
|
|
listEmptyComponent,
|
|
liveHeadRowRevision,
|
|
renderLiveAuxiliary,
|
|
renderLiveHeadRow,
|
|
segments.liveHead,
|
|
]);
|
|
|
|
const historyFooterContent = useMemo(() => {
|
|
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
|
|
return (
|
|
<View style={historyStartSlotStyle} testID="older-history-slot">
|
|
{isLoadingOperation ? (
|
|
<View testID="load-older-history-spinner">
|
|
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}, [historyStartPaginationState]);
|
|
|
|
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
|
|
// data or the live header changes, preserving the row identities above.
|
|
return (
|
|
<FlatList
|
|
{...listInsetProps}
|
|
ref={flatListRef}
|
|
data={historyRows}
|
|
renderItem={renderItem}
|
|
keyExtractor={keyExtractor}
|
|
strictMode
|
|
testID="agent-chat-scroll"
|
|
nativeID="agent-chat-scroll-native-virtualized"
|
|
ListHeaderComponent={liveHeaderContent ?? undefined}
|
|
ListFooterComponent={historyFooterContent ?? undefined}
|
|
contentContainerStyle={listContentContainerStyle}
|
|
style={listStyle}
|
|
onLayout={handleListLayout}
|
|
onScroll={handleScroll}
|
|
onScrollBeginDrag={handleScrollBeginDrag}
|
|
onScrollEndDrag={handleScrollEndDrag}
|
|
onMomentumScrollBegin={handleMomentumScrollBegin}
|
|
onMomentumScrollEnd={handleMomentumScrollEnd}
|
|
scrollEventThrottle={16}
|
|
onContentSizeChange={handleContentSizeChange}
|
|
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
|
initialNumToRender={12}
|
|
windowSize={10}
|
|
removeClippedSubviews={false}
|
|
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",
|
|
historyLiveBoundaryEdge: "first",
|
|
liveHeadHistoryBoundaryEdge: "last",
|
|
frameChildOrder: "footer-then-content",
|
|
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;
|
|
}
|