* fix(app): refine compact composer controls and native scrolling Consolidate responsive agent controls and model browsing inside the compact sheet. Preserve manual native scrolling by suspending sticky bottom maintenance while the user owns the viewport. * fix(app): suppress recoverable resume refresh errors Resume revalidation can race host reconnection and reject while the host is offline. Treat the refresh as deferred so development LogBox does not surface a recoverable disconnect. * fix(app): stabilize compact model sheet loading Let the model list consume the sheet space above persistent controls as the sheet expands. Keep unresolved defaults in a loading state so Select model only represents a genuinely empty selection. * fix(app): preserve route anchors during native scroll * fix(app): preserve native scroll intent and sheet dismissal * fix(app): keep compact sheet styles composable
521 lines
17 KiB
TypeScript
521 lines
17 KiB
TypeScript
import {
|
|
Fragment,
|
|
type ReactElement,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
FlatList,
|
|
ActivityIndicator,
|
|
Keyboard,
|
|
Platform,
|
|
View,
|
|
type LayoutChangeEvent,
|
|
type ListRenderItemInfo,
|
|
type NativeScrollEvent,
|
|
type NativeSyntheticEvent,
|
|
} from "react-native";
|
|
import type { StreamItem } from "@/types/stream";
|
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
|
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
|
import {
|
|
createStreamStrategy,
|
|
isNearBottomForStreamRenderStrategy,
|
|
resolveBottomAnchorTransportBehavior,
|
|
} from "./strategy";
|
|
|
|
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
|
minIndexForVisible: 0,
|
|
autoscrollToTopThreshold: 0,
|
|
});
|
|
const HISTORY_START_THRESHOLD_PX = 96;
|
|
|
|
interface HistoryRowDisplayVariants {
|
|
regular?: StreamItem;
|
|
compact?: StreamItem;
|
|
}
|
|
|
|
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
|
|
|
|
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
|
|
let variants = historyRowDisplayVariants.get(item);
|
|
if (!variants) {
|
|
variants = {};
|
|
historyRowDisplayVariants.set(item, variants);
|
|
}
|
|
const key = compact ? "compact" : "regular";
|
|
variants[key] ??= { ...item };
|
|
return variants[key];
|
|
}
|
|
|
|
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,
|
|
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 userScrollEndFrameIdRef = useRef<number | null>(null);
|
|
const programmaticScrollEventBudgetRef = useRef(0);
|
|
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
|
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
|
const historyStartReadyRef = useRef(false);
|
|
|
|
const historyItems = useMemo(() => {
|
|
if (segments.historyVirtualized.length === 0) {
|
|
return segments.historyMounted;
|
|
}
|
|
return [...segments.historyVirtualized, ...segments.historyMounted];
|
|
}, [segments.historyMounted, segments.historyVirtualized]);
|
|
// Keep unchanged item identities intact so live updates only rerender rows
|
|
// whose projected content or local display state actually changed. A rare
|
|
// breakpoint change intentionally refreshes the whole history window.
|
|
const globallyRevisedHistoryRows = useMemo(() => {
|
|
const globalDisplayState = historyRowRevision?.globalDisplayState ?? false;
|
|
return historyItems.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState));
|
|
}, [historyItems, historyRowRevision?.globalDisplayState]);
|
|
const displayStateHistoryRows = useMemo(
|
|
() =>
|
|
globallyRevisedHistoryRows.map((item) =>
|
|
historyRowRevision?.displayStateById.has(item.id) ? { ...item } : item,
|
|
),
|
|
[globallyRevisedHistoryRows, historyRowRevision?.displayStateById],
|
|
);
|
|
const historyRows = useMemo(
|
|
() =>
|
|
displayStateHistoryRows.map((item) =>
|
|
historyRowRevision?.contentById.has(item.id) ? { ...item } : item,
|
|
),
|
|
[displayStateHistoryRows, historyRowRevision?.contentById],
|
|
);
|
|
|
|
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();
|
|
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,
|
|
});
|
|
// 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;
|
|
|
|
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();
|
|
setIsNativeViewportSettling(false);
|
|
historyStartReadyRef.current = false;
|
|
const frame = requestAnimationFrame(() => {
|
|
historyStartReadyRef.current = true;
|
|
});
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
clearPendingUserScrollEnd();
|
|
};
|
|
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);
|
|
|
|
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, 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;
|
|
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);
|
|
|
|
const distanceFromOldestEdge =
|
|
streamViewportMetricsRef.current.contentHeight -
|
|
streamViewportMetricsRef.current.viewportHeight -
|
|
contentOffset.y;
|
|
if (
|
|
historyStartReadyRef.current &&
|
|
hasOlderHistory &&
|
|
distanceFromOldestEdge <= HISTORY_START_THRESHOLD_PX
|
|
) {
|
|
onNearHistoryStart();
|
|
}
|
|
|
|
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(() => {
|
|
clearPendingUserScrollEnd();
|
|
isUserScrollActiveRef.current = true;
|
|
bottomAnchorController.beginUserScroll();
|
|
});
|
|
|
|
// 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);
|
|
clearPendingUserScrollEnd();
|
|
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
|
|
userScrollEndFrameIdRef.current = null;
|
|
isUserScrollActiveRef.current = false;
|
|
bottomAnchorController.endUserScroll({ isNearBottom });
|
|
});
|
|
});
|
|
|
|
const handleMomentumScrollBegin = useStableEvent(() => {
|
|
clearPendingUserScrollEnd();
|
|
});
|
|
|
|
const handleMomentumScrollEnd = useStableEvent(
|
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
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,
|
|
});
|
|
});
|
|
|
|
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,
|
|
});
|
|
});
|
|
|
|
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(() => {
|
|
if (!isLoadingOlderHistory) {
|
|
return null;
|
|
}
|
|
return (
|
|
<View testID="load-older-history-spinner">
|
|
<ActivityIndicator size="small" />
|
|
</View>
|
|
);
|
|
}, [isLoadingOlderHistory]);
|
|
|
|
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
|
|
// data or the live header changes, preserving the row identities above.
|
|
return (
|
|
<FlatList
|
|
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={baseListContentContainerStyle}
|
|
style={listStyle}
|
|
onLayout={handleListLayout}
|
|
onScroll={handleScroll}
|
|
onScrollBeginDrag={handleScrollBeginDrag}
|
|
onScrollEndDrag={handleScrollEndDrag}
|
|
onMomentumScrollBegin={handleMomentumScrollBegin}
|
|
onMomentumScrollEnd={handleMomentumScrollEnd}
|
|
scrollEventThrottle={16}
|
|
onContentSizeChange={handleContentSizeChange}
|
|
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
|
initialNumToRender={40}
|
|
maxToRenderPerBatch={40}
|
|
updateCellsBatchingPeriod={0}
|
|
windowSize={21}
|
|
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;
|
|
}
|