import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import type { StreamItem } from "@/types/stream"; import type { ReadingRowReplacements } from "./reading-anchor"; import { continuesResponse } from "./turn-membership"; 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 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; } /** * A caller that landed on one occurrence inside the message owns where the viewport * settles. It resolved the occurrence against a specific row already, so it takes no * row here and returns null once that row is gone. */ export interface ScrollToMessageOccurrence { signal: AbortSignal; targetTop(): number | null; } export interface StreamViewportHandle { /** Give keyboard navigation back to the scrolling surface on web. */ focus?: () => void; scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; /** * Run a disclosure's state change so the row that owns its trigger keeps its visual top. * Following output stops; it resumes if the change leaves the list at its end. */ holdRow: (rowId: string, apply: () => void) => void; scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => 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 StreamHistoryRowRevision { contentById: { has(id: string): boolean }; displayStateById: { has(id: string): boolean }; globalDisplayState: boolean; } export interface StreamRenderInput { agentId: string; segments: StreamRenderSegments; historyRowRevision?: StreamHistoryRowRevision; liveHeadRowRevision?: unknown; /** Presentation rows replaced by another row after history is folded. */ readingRowReplacements?: ReadingRowReplacements; boundary: StreamHistoryBoundary; renderers: StreamSegmentRenderers; listEmptyComponent: ReactNode; viewportRef: RefObject; routeBottomAnchorRequest: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady: boolean; onNearBottomChange: (value: boolean) => void; // The history row under the top of the viewport, for surfaces that mark where the reader // is in the transcript. Only the web viewport measures it today. onReadingPositionChange?: (rowId: string | null) => void; onNearHistoryStart: () => boolean | Promise; isLoadingOlderHistory: boolean; hasOlderHistory: boolean; olderHistoryProgressKey: string | null; scrollEnabled: boolean; listStyle: StyleProp; baseListContentContainerStyle: StyleProp; forwardListContentContainerStyle: StyleProp; // The chat column's max width. The web viewport estimates unmeasured row heights with it. contentMaxWidth: number; } 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; collectAssistantResponseContent: (items: StreamItem[], startIndex: number) => string; isNearBottom: (input: StreamNearBottomInput) => boolean; getBottomOffset: (metrics: StreamViewportMetrics) => number; getEdgeSlotProps: ( component: ReactElement | ComponentType | null, gapSize: number, ) => StreamEdgeSlotProps; getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior; getHistoryLiveBoundaryIndex: (history: StreamItem[]) => number | null; getLiveHeadHistoryBoundaryIndex: (liveHead: StreamItem[]) => number | null; getLatestItemIndex: (items: StreamItem[]) => number | null; getFrameChildOrder: () => StreamFrameChildOrder; 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; bottomAnchorTransportBehavior: BottomAnchorTransportBehavior; disableParentScrollOnInlineDetailsExpansion: boolean; anchorBottomOnContentSizeChange: boolean; animateManualScrollToBottom: boolean; useVirtualizedList: boolean; isNearBottom: (input: StreamNearBottomInput) => boolean; getBottomOffset: (metrics: StreamViewportMetrics) => number; } 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]; }, collectAssistantResponseContent: (items, startIndex) => { const messages: string[] = []; let laterItem: StreamItem | null = null; for ( let index = startIndex; index >= 0 && index < items.length; index += config.assistantTurnTraversalStep ) { const currentItem = items[index]; if (!currentItem || (laterItem && !continuesResponse(currentItem, laterItem))) { break; } if (currentItem.kind === "assistant_message") { messages.push(currentItem.text); } laterItem = currentItem; } 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 }, }; }, 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, shouldDisableParentScrollOnInlineDetailsExpansion: () => config.disableParentScrollOnInlineDetailsExpansion, shouldAnchorBottomOnContentSizeChange: () => config.anchorBottomOnContentSizeChange, shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom, shouldUseVirtualizedList: () => config.useVirtualizedList, }; } 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 collectAssistantResponseContentForStreamRenderStrategy(params: { strategy: StreamStrategy; items: StreamItem[]; startIndex: number; }): string { return params.strategy.collectAssistantResponseContent(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(); }