import { type ComponentType, type FC, type ForwardedRef, type ReactNode, type RefObject, forwardRef, memo, useCallback, useEffect, useRef, } from "react"; import { FlatList, type FlatListProps, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, } from "react-native"; import type { MessageState, ThreadMessage } from "@assistant-ui/core"; import { RenderChildrenWithAccessor, useAuiEvent, useAuiState, } from "@assistant-ui/store"; import { MessageByIndexProvider } from "@assistant-ui/core/react"; type MessageComponents = | { Message: ComponentType; EditComposer?: ComponentType | undefined; UserEditComposer?: ComponentType | undefined; AssistantEditComposer?: ComponentType | undefined; SystemEditComposer?: ComponentType | undefined; UserMessage?: ComponentType | undefined; AssistantMessage?: ComponentType | undefined; SystemMessage?: ComponentType | undefined; } | { Message?: ComponentType | undefined; EditComposer?: ComponentType | undefined; UserEditComposer?: ComponentType | undefined; AssistantEditComposer?: ComponentType | undefined; SystemEditComposer?: ComponentType | undefined; UserMessage: ComponentType; AssistantMessage: ComponentType; SystemMessage?: ComponentType | undefined; }; type MessagesContent = | { /** @deprecated Use the children render function instead. */ components: MessageComponents; children?: never; } | { children: (value: { message: MessageState }) => ReactNode; components?: never; }; export type ThreadMessagesFlatListProps = Omit< FlatListProps, "data" | "renderItem" | "children" > & MessagesContent & { autoScroll?: boolean | undefined; scrollToBottomOnRunStart?: boolean | undefined; scrollToBottomOnInitialize?: boolean | undefined; scrollToBottomOnThreadSwitch?: boolean | undefined; }; /** @deprecated Use ThreadMessagesFlatListProps instead. */ export type ThreadMessagesProps = ThreadMessagesFlatListProps; const DEFAULT_SYSTEM_MESSAGE = () => null; const AT_BOTTOM_THRESHOLD = 4; const getComponent = ( components: MessageComponents, role: ThreadMessage["role"], isEditing: boolean, ) => { switch (role) { case "user": if (isEditing) { return ( components.UserEditComposer ?? components.EditComposer ?? components.UserMessage ?? (components.Message as ComponentType) ); } else { return components.UserMessage ?? (components.Message as ComponentType); } case "assistant": if (isEditing) { return ( components.AssistantEditComposer ?? components.EditComposer ?? components.AssistantMessage ?? (components.Message as ComponentType) ); } else { return ( components.AssistantMessage ?? (components.Message as ComponentType) ); } case "system": if (isEditing) { return ( components.SystemEditComposer ?? components.EditComposer ?? components.SystemMessage ?? (components.Message as ComponentType) ?? DEFAULT_SYSTEM_MESSAGE ); } else { return ( components.SystemMessage ?? (components.Message as ComponentType) ?? DEFAULT_SYSTEM_MESSAGE ); } default: { const _exhaustiveCheck: never = role; throw new Error(`Unknown message role: ${_exhaustiveCheck}`); } } }; const ThreadMessageComponent: FC<{ components: MessageComponents }> = ({ components, }) => { const role = useAuiState((s) => s.message.role); const isEditing = useAuiState((s) => s.message.composer.isEditing); const Component = getComponent(components, role, isEditing); return ; }; const ThreadMessageByIndex = memo( ({ index, components }: { index: number; components: MessageComponents }) => { return ( ); }, (prev, next) => prev.index === next.index && prev.components === next.components, ); ThreadMessageByIndex.displayName = "ThreadPrimitive.MessageByIndex"; const ThreadMessageByChildren = memo( ({ index, children, }: { index: number; children: (value: { message: MessageState }) => ReactNode; }) => { return ( aui.thread.message({ index }).getState()} > {(getItem) => children({ get message() { return getItem(); }, }) } ); }, (prev, next) => prev.index === next.index && prev.children === next.children, ); ThreadMessageByChildren.displayName = "ThreadPrimitive.MessageByChildren"; const setForwardedRef = (ref: ForwardedRef, value: T | null) => { if (typeof ref === "function") { ref(value); } else if (ref) { ref.current = value; } }; const useComposedFlatListRef = ( forwardedRef: ForwardedRef>, ) => { const flatListRef = useRef | null>(null); const setFlatListRef = useCallback( (node: FlatList | null) => { flatListRef.current = node; setForwardedRef(forwardedRef, node); }, [forwardedRef], ); return [flatListRef, setFlatListRef] as const; }; const useThreadMessagesFlatListAutoScroll = ({ flatListRef, hasMessages, autoScroll = true, scrollToBottomOnRunStart = true, scrollToBottomOnInitialize = true, scrollToBottomOnThreadSwitch = true, }: { flatListRef: RefObject | null>; hasMessages: boolean; autoScroll?: boolean | undefined; scrollToBottomOnRunStart?: boolean | undefined; scrollToBottomOnInitialize?: boolean | undefined; scrollToBottomOnThreadSwitch?: boolean | undefined; }) => { const metricsRef = useRef({ contentHeight: 0, viewportHeight: 0, scrollY: 0, }); const isAtBottomRef = useRef(true); const lastScrollEventYRef = useRef(0); const initializeScrollRequestedRef = useRef(false); const pendingScrollToBottomRef = useRef(false); const updateIsAtBottom = useCallback(() => { const { contentHeight, scrollY, viewportHeight } = metricsRef.current; isAtBottomRef.current = contentHeight <= viewportHeight || contentHeight - scrollY - viewportHeight <= AT_BOTTOM_THRESHOLD; }, []); // Commanding a scroll records the intended position immediately; the // native scroll echo is bridged and throttled, so waiting for it lets a // fast stream observe stale metrics and drop out of following. const scrollToBottom = useCallback( (animated: boolean) => { const { contentHeight, viewportHeight } = metricsRef.current; metricsRef.current.scrollY = Math.max(0, contentHeight - viewportHeight); isAtBottomRef.current = true; flatListRef.current?.scrollToEnd({ animated }); }, [flatListRef], ); const handleLayout = useCallback( (event: LayoutChangeEvent) => { const wasAtBottom = isAtBottomRef.current; const previousViewportHeight = metricsRef.current.viewportHeight; const viewportHeight = event.nativeEvent.layout.height; metricsRef.current.viewportHeight = viewportHeight; updateIsAtBottom(); if (!wasAtBottom) return; // Layout changes are never user gestures, so they must not unpin. Past // the first measurement, a viewport change while pinned re-commands the // bottom position, since no content-size event follows a bare keyboard // open or close. if ( autoScroll && previousViewportHeight !== 0 && viewportHeight !== previousViewportHeight ) { const pending = pendingScrollToBottomRef.current; scrollToBottom(pending ? pending.animated : false); } else { isAtBottomRef.current = true; } }, [autoScroll, scrollToBottom, updateIsAtBottom], ); const handleScroll = useCallback( (event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const previousEventY = lastScrollEventYRef.current; const wasPinnedToBottom = isAtBottomRef.current; lastScrollEventYRef.current = contentOffset.y; metricsRef.current = { contentHeight: contentSize.height, viewportHeight: layoutMeasurement.height, scrollY: contentOffset.y, }; updateIsAtBottom(); const upwardMove = contentOffset.y < previousEventY; // Only a deliberate upward move unpins or cancels a pending scroll. // Gestures are detected echo-to-echo because a commanded scroll // optimistically moves the tracked position ahead of its ascending // animation echoes, and those echoes must not unpin mid-flight. if (wasPinnedToBottom && !upwardMove) { isAtBottomRef.current = true; } if (!isAtBottomRef.current && upwardMove) { pendingScrollToBottomRef.current = false; } }, [updateIsAtBottom], ); const handleContentSizeChange = useCallback( (_width: number, height: number) => { const metrics = metricsRef.current; const previousContentHeight = metrics.contentHeight; const wasAtBottom = isAtBottomRef.current; metrics.contentHeight = height; updateIsAtBottom(); // FlatList.scrollToEnd is a no-op before the list has measured, so the // initialize and thread-switch scrolls land on the next content-size // event, once real metrics exist. const pendingScroll = pendingScrollToBottomRef.current; if (pendingScroll) { pendingScrollToBottomRef.current = false; scrollToBottom(pendingScroll.animated); return; } if (!autoScroll) return; if (!wasAtBottom) return; if (previousContentHeight === 0) return; if (height <= previousContentHeight) return; scrollToBottom(false); }, [autoScroll, scrollToBottom, updateIsAtBottom], ); useEffect(() => { if (!scrollToBottomOnInitialize) return; if (!hasMessages) { initializeScrollRequestedRef.current = false; return; } if (initializeScrollRequestedRef.current) return; initializeScrollRequestedRef.current = true; pendingScrollToBottomRef.current = { animated: false }; scrollToBottom(false); }, [hasMessages, scrollToBottom, scrollToBottomOnInitialize]); useAuiEvent("thread.runStart", () => { if (!scrollToBottomOnRunStart) return; pendingScrollToBottomRef.current = { animated: true }; scrollToBottom(true); }); useAuiEvent("threads.selectionChanged", () => { if (!scrollToBottomOnThreadSwitch) return; initializeScrollRequestedRef.current = false; lastScrollEventYRef.current = 0; pendingScrollToBottomRef.current = { animated: false }; scrollToBottom(false); }); return { handleLayout, handleScroll, handleContentSizeChange, }; }; export const ThreadMessagesFlatList = forwardRef< FlatList, ThreadMessagesFlatListProps >( ( { autoScroll, components, children, onContentSizeChange, onLayout, onScroll, scrollEventThrottle, scrollToBottomOnInitialize, scrollToBottomOnRunStart, scrollToBottomOnThreadSwitch, ...flatListProps }, forwardedRef, ) => { const messages = useAuiState((s) => s.thread.messages); const [flatListRef, setFlatListRef] = useComposedFlatListRef(forwardedRef); const { handleContentSizeChange: handleAutoScrollContentSizeChange, handleLayout: handleAutoScrollLayout, handleScroll: handleAutoScrollScroll, } = useThreadMessagesFlatListAutoScroll({ flatListRef, hasMessages: messages.length > 0, autoScroll, scrollToBottomOnInitialize, scrollToBottomOnRunStart, scrollToBottomOnThreadSwitch, }); const renderItem = useCallback( ({ index }: { item: ThreadMessage; index: number }) => { if (children) { return ( {children} ); } return ; }, [components, children], ); const keyExtractor = useCallback((item: ThreadMessage) => item.id, []); const scrollTracking = (autoScroll ?? true) || (scrollToBottomOnInitialize ?? true) || (scrollToBottomOnRunStart ?? true) || (scrollToBottomOnThreadSwitch ?? true); const handleLayout = useCallback( (event: LayoutChangeEvent) => { handleAutoScrollLayout(event); onLayout?.(event); }, [handleAutoScrollLayout, onLayout], ); const handleScroll = useCallback( (event: NativeSyntheticEvent) => { handleAutoScrollScroll(event); onScroll?.(event); }, [handleAutoScrollScroll, onScroll], ); const handleContentSizeChange = useCallback( (width: number, height: number) => { handleAutoScrollContentSizeChange(width, height); onContentSizeChange?.(width, height); }, [handleAutoScrollContentSizeChange, onContentSizeChange], ); return ( ); }, ); ThreadMessagesFlatList.displayName = "ThreadPrimitive.MessagesFlatList"; /** @deprecated Use ThreadPrimitive.MessagesFlatList instead. */ export const ThreadMessages = forwardRef< FlatList, ThreadMessagesProps >( ( { autoScroll = false, scrollToBottomOnInitialize = false, scrollToBottomOnRunStart = false, scrollToBottomOnThreadSwitch = false, ...props }, ref, ) => ( ), ); ThreadMessages.displayName = "ThreadPrimitive.Messages";