mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
Long agent turns bury the answer under intermediate messages, thoughts and tool calls. A new Chat display preference (Full / Collapse tool calls / Collapse turns) replaces the tool call detail level. Collapse turns folds everything a finished turn did before its final message into one summary line under the prompt; opening it shows those rows inline behind the same indent rail that expanded tool call groups now use. Expanded turns stay real timeline rows so virtualization, chat find and scroll-to-message keep working; find and outline jumps open the turn that holds their target. Opening or closing a turn stops following output so the summary stays under the pointer. Collapse turns is the default for new installs only. Existing settings were materialized on first launch, so stored blobs without the new field map from the legacy tool call detail level and otherwise stay on Full. Co-authored-by: Ready <127541578+akaready@users.noreply.github.com> Co-authored-by: Waishnav <86405648+Waishnav@users.noreply.github.com> Co-authored-by: Shreyan Dey <90114886+Dey11@users.noreply.github.com> Co-authored-by: Sebastian Parfitt <7753735+Sebeb@users.noreply.github.com>
642 lines
22 KiB
TypeScript
642 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 { useKeyboardStreamInset } from "@/keyboard/shift";
|
|
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
|
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 { useStreamTopOverlayInset } from "./top-overlay";
|
|
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;
|
|
const NATIVE_STREAM_WINDOW_SIZE = 10;
|
|
|
|
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 streamKeyboardInset = useKeyboardStreamInset();
|
|
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:
|
|
animated &&
|
|
shouldAnimateNativeScrollToBottom({
|
|
offsetY: scrollOffsetYRef.current,
|
|
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
|
windowSize: NATIVE_STREAM_WINDOW_SIZE,
|
|
}),
|
|
});
|
|
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 topOverlayInset = useStreamTopOverlayInset();
|
|
// The list is inverted, so its content's bottom padding is the visual top.
|
|
const listContentContainerStyle = useMemo(
|
|
() => [
|
|
baseListContentContainerStyle,
|
|
{
|
|
paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset,
|
|
},
|
|
],
|
|
[
|
|
baseListContentContainerStyle,
|
|
streamKeyboardInset.contentContainerPaddingBottom,
|
|
topOverlayInset,
|
|
],
|
|
);
|
|
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();
|
|
},
|
|
holdReadingPosition: () => bottomAnchorController.detachByUser(),
|
|
};
|
|
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}
|
|
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
|
|
// render small, then refill a deeper window in batches large enough to keep up.
|
|
initialNumToRender={2}
|
|
maxToRenderPerBatch={10}
|
|
windowSize={NATIVE_STREAM_WINDOW_SIZE}
|
|
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;
|
|
}
|