import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import type { StreamItem } from "@/types/stream"; import type { StreamHistoryBoundary, StreamRenderSegments } from "./model"; import type { BottomAnchorLocalRequest, BottomAnchorRouteRequest, } from "./bottom-anchor-controller"; type EdgeSlot = "header" | "footer"; type NeighborRelation = "above" | "below"; type AssistantTurnTraversalStep = -1 | 1; export type StreamFrameChildOrder = "content-then-footer" | "footer-then-content"; export type MaintainVisibleContentPositionConfig = Readonly<{ minIndexForVisible: number; autoscrollToTopThreshold: number; }>; export type BottomAnchorTransportBehavior = Readonly<{ verificationDelayFrames: number; verificationRetryMode: "rescroll" | "recheck"; }>; export interface StreamViewportMetrics { contentHeight: number; viewportHeight: number; } export type StreamNearBottomInput = StreamViewportMetrics & { offsetY: number; threshold: number; }; export interface StreamEdgeSlotProps { ListHeaderComponent?: ReactElement | ComponentType | null; ListHeaderComponentStyle?: StyleProp; ListFooterComponent?: ReactElement | ComponentType | null; ListFooterComponentStyle?: StyleProp; } export interface StreamViewportHandle { scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; } export interface StreamSegmentRenderers { renderHistoryVirtualizedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode; renderHistoryMountedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode; renderLiveHeadRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode; renderLiveAuxiliary: () => ReactNode; } export interface StreamRenderInput { agentId: string; segments: StreamRenderSegments; boundary: StreamHistoryBoundary; renderers: StreamSegmentRenderers; listEmptyComponent: ReactNode; viewportRef: RefObject; routeBottomAnchorRequest: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady: boolean; onNearBottomChange: (value: boolean) => void; onNearHistoryStart: () => void; isLoadingOlderHistory: boolean; hasOlderHistory: boolean; scrollEnabled: boolean; listStyle: StyleProp; baseListContentContainerStyle: StyleProp; forwardListContentContainerStyle: StyleProp; } export interface 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 | null, gapSize: number, ) => StreamEdgeSlotProps; getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined; getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior; getHistoryLiveBoundaryIndex: (history: StreamItem[]) => number | null; getLiveHeadHistoryBoundaryIndex: (liveHead: StreamItem[]) => number | null; getLatestItemIndex: (items: StreamItem[]) => number | null; getFrameChildOrder: () => StreamFrameChildOrder; getFlatListInverted: () => boolean; getOverlayScrollbarInverted: () => boolean; shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean; shouldAnchorBottomOnContentSizeChange: () => boolean; shouldAnimateManualScrollToBottom: () => boolean; shouldUseVirtualizedList: () => boolean; } interface StreamStrategyConfig { render: StreamStrategy["render"]; orderTailReverse: boolean; orderHeadReverse: boolean; assistantTurnTraversalStep: AssistantTurnTraversalStep; edgeSlot: EdgeSlot; historyLiveBoundaryEdge: "first" | "last"; liveHeadHistoryBoundaryEdge: "first" | "last"; frameChildOrder: StreamFrameChildOrder; 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].toReversed() : streamItems, orderHead: (streamHead) => config.orderHeadReverse ? [...streamHead].toReversed() : 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.toReversed().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, getHistoryLiveBoundaryIndex: (history) => { if (history.length === 0) { return null; } return config.historyLiveBoundaryEdge === "first" ? 0 : history.length - 1; }, getLiveHeadHistoryBoundaryIndex: (liveHead) => { if (liveHead.length === 0) { return null; } return config.liveHeadHistoryBoundaryEdge === "first" ? 0 : liveHead.length - 1; }, getLatestItemIndex: (items) => { if (items.length === 0) { return null; } return config.historyLiveBoundaryEdge === "first" ? 0 : items.length - 1; }, getFrameChildOrder: () => config.frameChildOrder, getFlatListInverted: () => config.flatListInverted, getOverlayScrollbarInverted: () => config.overlayScrollbarInverted, shouldDisableParentScrollOnInlineDetailsExpansion: () => config.disableParentScrollOnInlineDetailsExpansion, shouldAnchorBottomOnContentSizeChange: () => config.anchorBottomOnContentSizeChange, shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom, shouldUseVirtualizedList: () => config.useVirtualizedList, }; } 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 | null; gapSize: number; }): StreamEdgeSlotProps { return params.strategy.getEdgeSlotProps(params.component, params.gapSize); } export function getHistoryLiveBoundaryIndexForStreamRenderStrategy(params: { strategy: StreamStrategy; history: StreamItem[]; }): number | null { return params.strategy.getHistoryLiveBoundaryIndex(params.history); } export function getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy(params: { strategy: StreamStrategy; liveHead: StreamItem[]; }): number | null { return params.strategy.getLiveHeadHistoryBoundaryIndex(params.liveHead); } export function getFrameChildOrderForStreamRenderStrategy(params: { strategy: StreamStrategy; }): StreamFrameChildOrder { return params.strategy.getFrameChildOrder(); }