From 9d2ec0e9e10676bf921388089cc733e0f4c7adca Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 24 Feb 2026 18:15:06 +0700 Subject: [PATCH] refactor(app): use strategy-driven stream renderer on web (#80) --- .../agent-stream-render-strategy.test.ts | 285 ++++++++++++++++++ .../agent-stream-render-strategy.ts | 221 ++++++++++++++ .../app/src/components/agent-stream-view.tsx | 272 +++++++++++------ 3 files changed, 686 insertions(+), 92 deletions(-) create mode 100644 packages/app/src/components/agent-stream-render-strategy.test.ts create mode 100644 packages/app/src/components/agent-stream-render-strategy.ts diff --git a/packages/app/src/components/agent-stream-render-strategy.test.ts b/packages/app/src/components/agent-stream-render-strategy.test.ts new file mode 100644 index 000000000..c81a67fcc --- /dev/null +++ b/packages/app/src/components/agent-stream-render-strategy.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from "vitest"; +import type { StreamItem } from "@/types/stream"; +import { + collectAssistantTurnContentForStreamRenderStrategy, + getBottomOffsetForStreamRenderStrategy, + getStreamEdgeSlotProps, + getStreamNeighborIndex, + getStreamNeighborItem, + isNearBottomForStreamRenderStrategy, + orderHeadForStreamRenderStrategy, + orderTailForStreamRenderStrategy, + resolveStreamRenderStrategy, +} from "./agent-stream-render-strategy"; + +function createTimestamp(seed: number): Date { + return new Date(`2026-01-01T00:00:0${seed}.000Z`); +} + +function userMessage(id: string, text: string, seed: number): StreamItem { + return { + kind: "user_message", + id, + text, + timestamp: createTimestamp(seed), + }; +} + +function assistantMessage(id: string, text: string, seed: number): StreamItem { + return { + kind: "assistant_message", + id, + text, + timestamp: createTimestamp(seed), + }; +} + +describe("resolveStreamRenderStrategy", () => { + it("uses forward_stream on web", () => { + const strategy = resolveStreamRenderStrategy({ + platform: "web", + isMobileBreakpoint: false, + }); + + expect(strategy.kind).toBe("forward_stream"); + expect(strategy.flatListInverted).toBe(false); + expect(strategy.overlayScrollbarInverted).toBe(false); + }); + + it("uses inverted_stream on native", () => { + const strategy = resolveStreamRenderStrategy({ + platform: "ios", + isMobileBreakpoint: false, + }); + + expect(strategy.kind).toBe("inverted_stream"); + expect(strategy.flatListInverted).toBe(true); + expect(strategy.overlayScrollbarInverted).toBe(true); + }); +}); + +describe("stream ordering", () => { + const streamItems: StreamItem[] = [ + userMessage("u1", "user-1", 1), + assistantMessage("a1", "assistant-1", 2), + assistantMessage("a2", "assistant-2", 3), + ]; + + it("keeps forward_stream order unchanged for tail and head", () => { + const strategy = resolveStreamRenderStrategy({ + platform: "web", + isMobileBreakpoint: false, + }); + + const tail = orderTailForStreamRenderStrategy({ strategy, streamItems }); + const head = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems }); + + expect(tail.map((item) => item.id)).toEqual(["u1", "a1", "a2"]); + expect(head.map((item) => item.id)).toEqual(["u1", "a1", "a2"]); + }); + + it("reverses inverted_stream order for tail and head", () => { + const strategy = resolveStreamRenderStrategy({ + platform: "android", + isMobileBreakpoint: false, + }); + + const tail = orderTailForStreamRenderStrategy({ strategy, streamItems }); + const head = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems }); + + expect(tail.map((item) => item.id)).toEqual(["a2", "a1", "u1"]); + expect(head.map((item) => item.id)).toEqual(["a2", "a1", "u1"]); + }); +}); + +describe("neighbor and traversal semantics", () => { + it("maps above/below indices for forward and inverted streams", () => { + const forward = resolveStreamRenderStrategy({ + platform: "web", + isMobileBreakpoint: false, + }); + const inverted = resolveStreamRenderStrategy({ + platform: "ios", + isMobileBreakpoint: false, + }); + + expect(getStreamNeighborIndex({ strategy: forward, index: 3, relation: "above" })).toBe(2); + expect(getStreamNeighborIndex({ strategy: forward, index: 3, relation: "below" })).toBe(4); + expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "above" })).toBe(4); + expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "below" })).toBe(2); + }); + + it("collects assistant turn content with strategy traversal direction", () => { + const chronological: StreamItem[] = [ + userMessage("u1", "user-1", 1), + assistantMessage("a1", "assistant-1", 2), + assistantMessage("a2", "assistant-2", 3), + userMessage("u2", "user-2", 4), + ]; + + const forward = resolveStreamRenderStrategy({ + platform: "web", + isMobileBreakpoint: false, + }); + const forwardStartIndex = chronological.findIndex((item) => item.id === "a2"); + expect( + collectAssistantTurnContentForStreamRenderStrategy({ + strategy: forward, + items: chronological, + startIndex: forwardStartIndex, + }) + ).toBe("assistant-1\n\nassistant-2"); + + const inverted = resolveStreamRenderStrategy({ + platform: "android", + isMobileBreakpoint: false, + }); + const invertedItems = orderTailForStreamRenderStrategy({ + strategy: inverted, + streamItems: chronological, + }); + const invertedStartIndex = invertedItems.findIndex((item) => item.id === "a2"); + expect( + collectAssistantTurnContentForStreamRenderStrategy({ + strategy: inverted, + items: invertedItems, + startIndex: invertedStartIndex, + }) + ).toBe("assistant-1\n\nassistant-2"); + }); + + it("returns undefined neighbor when index would be out of bounds", () => { + const forward = resolveStreamRenderStrategy({ + platform: "web", + isMobileBreakpoint: false, + }); + const items: StreamItem[] = [userMessage("u1", "user-1", 1)]; + + expect( + getStreamNeighborItem({ + strategy: forward, + items, + index: 0, + relation: "above", + }) + ).toBeUndefined(); + expect( + getStreamNeighborItem({ + strategy: forward, + items, + index: 0, + relation: "below", + }) + ).toBeUndefined(); + }); +}); + +describe("scroll/bottom calculations", () => { + it("computes near-bottom using forward_stream distance-from-bottom math", () => { + const strategy = resolveStreamRenderStrategy({ + platform: "web", + isMobileBreakpoint: false, + }); + + expect( + isNearBottomForStreamRenderStrategy({ + strategy, + offsetY: 680, + viewportHeight: 300, + contentHeight: 1000, + threshold: 24, + }) + ).toBe(true); + expect( + isNearBottomForStreamRenderStrategy({ + strategy, + offsetY: 600, + viewportHeight: 300, + contentHeight: 1000, + threshold: 24, + }) + ).toBe(false); + }); + + it("computes near-bottom and scroll-to-bottom offset for inverted_stream", () => { + const strategy = resolveStreamRenderStrategy({ + platform: "ios", + isMobileBreakpoint: false, + }); + + expect( + isNearBottomForStreamRenderStrategy({ + strategy, + offsetY: 12, + viewportHeight: 300, + contentHeight: 1000, + threshold: 24, + }) + ).toBe(true); + expect( + isNearBottomForStreamRenderStrategy({ + strategy, + offsetY: 40, + viewportHeight: 300, + contentHeight: 1000, + threshold: 24, + }) + ).toBe(false); + expect( + getBottomOffsetForStreamRenderStrategy({ + strategy, + viewportHeight: 300, + contentHeight: 1000, + }) + ).toBe(0); + }); + + it("maps scroll-to-bottom to max offset for forward_stream", () => { + const strategy = resolveStreamRenderStrategy({ + platform: "web", + isMobileBreakpoint: false, + }); + + expect( + getBottomOffsetForStreamRenderStrategy({ + strategy, + viewportHeight: 320, + contentHeight: 1000, + }) + ).toBe(680); + }); +}); + +describe("edge slot semantics", () => { + it("uses footer slot for forward_stream and header slot for inverted_stream", () => { + const EdgeSlot = () => null; + const forward = resolveStreamRenderStrategy({ + platform: "web", + isMobileBreakpoint: false, + }); + const inverted = resolveStreamRenderStrategy({ + platform: "android", + isMobileBreakpoint: false, + }); + + const forwardProps = getStreamEdgeSlotProps({ + strategy: forward, + component: EdgeSlot, + gapSize: 4, + }); + const invertedProps = getStreamEdgeSlotProps({ + strategy: inverted, + component: EdgeSlot, + gapSize: 4, + }); + + expect(forwardProps).toEqual({ + ListFooterComponent: EdgeSlot, + ListFooterComponentStyle: { marginTop: 4 }, + }); + expect(invertedProps).toEqual({ + ListHeaderComponent: EdgeSlot, + ListHeaderComponentStyle: { marginBottom: 4 }, + }); + }); +}); diff --git a/packages/app/src/components/agent-stream-render-strategy.ts b/packages/app/src/components/agent-stream-render-strategy.ts new file mode 100644 index 000000000..70f7bd219 --- /dev/null +++ b/packages/app/src/components/agent-stream-render-strategy.ts @@ -0,0 +1,221 @@ +import type { ComponentType, ReactElement } from "react"; +import type { StyleProp, ViewStyle } from "react-native"; +import type { StreamItem } from "@/types/stream"; + +type EdgeSlot = "header" | "footer"; +type NeighborRelation = "above" | "below"; +type AssistantTurnTraversalStep = -1 | 1; + +export type MaintainVisibleContentPositionConfig = Readonly<{ + minIndexForVisible: number; + autoscrollToTopThreshold: number; +}>; + +type StreamRenderStrategyBase = { + kind: "inverted_stream" | "forward_stream"; + flatListInverted: boolean; + edgeSlot: EdgeSlot; + overlayScrollbarInverted: boolean; + maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig; + assistantTurnTraversalStep: AssistantTurnTraversalStep; + disableParentScrollOnInlineDetailsExpansion: boolean; +}; + +export type InvertedStreamRenderStrategy = StreamRenderStrategyBase & { + kind: "inverted_stream"; + flatListInverted: true; + edgeSlot: "header"; + overlayScrollbarInverted: true; + maintainVisibleContentPosition: MaintainVisibleContentPositionConfig; + assistantTurnTraversalStep: 1; + disableParentScrollOnInlineDetailsExpansion: false; +}; + +export type ForwardStreamRenderStrategy = StreamRenderStrategyBase & { + kind: "forward_stream"; + flatListInverted: false; + edgeSlot: "footer"; + overlayScrollbarInverted: false; + maintainVisibleContentPosition?: undefined; + assistantTurnTraversalStep: -1; +}; + +export type StreamRenderStrategy = + | InvertedStreamRenderStrategy + | ForwardStreamRenderStrategy; + +export type ResolveStreamRenderStrategyInput = { + platform: string; + isMobileBreakpoint: boolean; +}; + +export type StreamViewportMetrics = { + contentHeight: number; + viewportHeight: number; +}; + +export type StreamNearBottomInput = StreamViewportMetrics & { + offsetY: number; + threshold: number; +}; + +export type StreamEdgeSlotProps = { + ListHeaderComponent?: ReactElement | ComponentType | null; + ListHeaderComponentStyle?: StyleProp; + ListFooterComponent?: ReactElement | ComponentType | null; + ListFooterComponentStyle?: StyleProp; +}; + +const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION: MaintainVisibleContentPositionConfig = + Object.freeze({ + minIndexForVisible: 0, + autoscrollToTopThreshold: 0, + }); + +const INVERTED_STREAM_STRATEGY: InvertedStreamRenderStrategy = { + kind: "inverted_stream", + flatListInverted: true, + edgeSlot: "header", + overlayScrollbarInverted: true, + maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION, + assistantTurnTraversalStep: 1, + disableParentScrollOnInlineDetailsExpansion: false, +}; + +const FORWARD_STREAM_STRATEGY_DESKTOP: ForwardStreamRenderStrategy = { + kind: "forward_stream", + flatListInverted: false, + edgeSlot: "footer", + overlayScrollbarInverted: false, + assistantTurnTraversalStep: -1, + disableParentScrollOnInlineDetailsExpansion: true, +}; + +const FORWARD_STREAM_STRATEGY_MOBILE: ForwardStreamRenderStrategy = { + ...FORWARD_STREAM_STRATEGY_DESKTOP, + disableParentScrollOnInlineDetailsExpansion: false, +}; + +export function resolveStreamRenderStrategy( + input: ResolveStreamRenderStrategyInput +): StreamRenderStrategy { + if (input.platform === "web") { + return input.isMobileBreakpoint + ? FORWARD_STREAM_STRATEGY_MOBILE + : FORWARD_STREAM_STRATEGY_DESKTOP; + } + return INVERTED_STREAM_STRATEGY; +} + +export function orderTailForStreamRenderStrategy(params: { + strategy: StreamRenderStrategy; + streamItems: StreamItem[]; +}): StreamItem[] { + const { strategy, streamItems } = params; + return strategy.kind === "inverted_stream" + ? [...streamItems].reverse() + : streamItems; +} + +export function orderHeadForStreamRenderStrategy(params: { + strategy: StreamRenderStrategy; + streamHead: StreamItem[]; +}): StreamItem[] { + const { strategy, streamHead } = params; + return strategy.kind === "inverted_stream" + ? [...streamHead].reverse() + : streamHead; +} + +export function getStreamNeighborIndex(params: { + strategy: StreamRenderStrategy; + index: number; + relation: NeighborRelation; +}): number { + const { strategy, index, relation } = params; + if (strategy.kind === "inverted_stream") { + return relation === "above" ? index + 1 : index - 1; + } + return relation === "above" ? index - 1 : index + 1; +} + +export function getStreamNeighborItem(params: { + strategy: StreamRenderStrategy; + items: StreamItem[]; + index: number; + relation: NeighborRelation; +}): StreamItem | undefined { + const nextIndex = getStreamNeighborIndex(params); + if (nextIndex < 0 || nextIndex >= params.items.length) { + return undefined; + } + return params.items[nextIndex]; +} + +export function collectAssistantTurnContentForStreamRenderStrategy(params: { + strategy: StreamRenderStrategy; + items: StreamItem[]; + startIndex: number; +}): string { + const { strategy, items, startIndex } = params; + const messages: string[] = []; + + for ( + let index = startIndex; + index >= 0 && index < items.length; + index += strategy.assistantTurnTraversalStep + ) { + const currentItem = items[index]; + if (currentItem.kind === "user_message") { + break; + } + if (currentItem.kind === "assistant_message") { + messages.push(currentItem.text); + } + } + + return messages.reverse().join("\n\n"); +} + +export function isNearBottomForStreamRenderStrategy( + params: StreamNearBottomInput & { strategy: StreamRenderStrategy } +): boolean { + const { strategy, threshold, offsetY } = params; + if (strategy.kind === "inverted_stream") { + return offsetY <= threshold; + } + + const distanceFromBottom = Math.max( + 0, + params.contentHeight - (offsetY + params.viewportHeight) + ); + return distanceFromBottom <= threshold; +} + +export function getBottomOffsetForStreamRenderStrategy(params: StreamViewportMetrics & { + strategy: StreamRenderStrategy; +}): number { + const { strategy } = params; + if (strategy.kind === "inverted_stream") { + return 0; + } + return Math.max(0, params.contentHeight - params.viewportHeight); +} + +export function getStreamEdgeSlotProps(params: { + strategy: StreamRenderStrategy; + component: ReactElement | ComponentType | null; + gapSize: number; +}): StreamEdgeSlotProps { + const { strategy, component, gapSize } = params; + if (strategy.edgeSlot === "header") { + return { + ListHeaderComponent: component, + ListHeaderComponentStyle: { marginBottom: gapSize }, + }; + } + return { + ListFooterComponent: component, + ListFooterComponentStyle: { marginTop: gapSize }, + }; +} diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index d88021437..538f0a94f 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -6,6 +6,7 @@ import { Pressable, FlatList, ListRenderItemInfo, + LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, InteractionManager, @@ -54,6 +55,16 @@ import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics, } from "./web-desktop-scrollbar"; +import { + collectAssistantTurnContentForStreamRenderStrategy, + getBottomOffsetForStreamRenderStrategy, + getStreamEdgeSlotProps, + getStreamNeighborItem, + isNearBottomForStreamRenderStrategy, + orderHeadForStreamRenderStrategy, + orderTailForStreamRenderStrategy, + resolveStreamRenderStrategy, +} from "./agent-stream-render-strategy"; import { createMarkdownStyles } from "@/styles/markdown-styles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; @@ -65,23 +76,6 @@ const isToolSequenceItem = (item?: StreamItem) => const AGENT_STREAM_LOG_TAG = "[AgentStreamView]"; const STREAM_ITEM_LOG_MIN_COUNT = 200; const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50; - -function collectAssistantTurnContent( - flatListData: StreamItem[], - startIndex: number -): string { - const messages: string[] = []; - for (let i = startIndex; i < flatListData.length; i++) { - const currentItem = flatListData[i]; - if (currentItem.kind === "user_message") { - break; - } - if (currentItem.kind === "assistant_message") { - messages.push(currentItem.text); - } - } - return messages.reverse().join("\n\n"); -} export interface AgentStreamViewProps { agentId: string; serverId?: string; @@ -101,6 +95,14 @@ export function AgentStreamView({ const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const streamRenderStrategy = useMemo( + () => + resolveStreamRenderStrategy({ + platform: Platform.OS, + isMobileBreakpoint: isMobile, + }), + [isMobile] + ); const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const insets = useSafeAreaInsets(); const [isNearBottom, setIsNearBottom] = useState(true); @@ -110,6 +112,10 @@ export function AgentStreamView({ const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollAnimatedRef = useRef(false); const streamItemCountRef = useRef(0); + const streamViewportMetricsRef = useRef({ + contentHeight: 0, + viewportHeight: 0, + }); const streamScrollbarMetrics = useWebDesktopScrollbarMetrics(); const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState>(new Set()); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); @@ -185,10 +191,19 @@ export function AgentStreamView({ const handleScroll = useCallback( (event: NativeSyntheticEvent) => { - const { contentOffset } = event.nativeEvent; + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + streamViewportMetricsRef.current = { + contentHeight: Math.max(0, contentSize.height), + viewportHeight: Math.max(0, layoutMeasurement.height), + }; const threshold = Math.max(insets.bottom, 32); - // In inverted list: scrollTop 0 = bottom, higher values = scrolled up - const nearBottom = contentOffset.y <= threshold; + const nearBottom = isNearBottomForStreamRenderStrategy({ + strategy: streamRenderStrategy, + offsetY: contentOffset.y, + threshold, + contentHeight: streamViewportMetricsRef.current.contentHeight, + viewportHeight: streamViewportMetricsRef.current.viewportHeight, + }); if (isNearBottomRef.current !== nearBottom) { isNearBottomRef.current = nearBottom; @@ -199,7 +214,38 @@ export function AgentStreamView({ streamScrollbarMetrics.onScroll(event); } }, - [insets.bottom, showDesktopWebScrollbar, streamScrollbarMetrics] + [ + insets.bottom, + showDesktopWebScrollbar, + streamRenderStrategy, + streamScrollbarMetrics, + ] + ); + + const handleListLayout = useCallback( + (event: LayoutChangeEvent) => { + streamViewportMetricsRef.current = { + ...streamViewportMetricsRef.current, + viewportHeight: Math.max(0, event.nativeEvent.layout.height), + }; + if (showDesktopWebScrollbar) { + streamScrollbarMetrics.onLayout(event); + } + }, + [showDesktopWebScrollbar, streamScrollbarMetrics] + ); + + const handleContentSizeChange = useCallback( + (width: number, height: number) => { + streamViewportMetricsRef.current = { + ...streamViewportMetricsRef.current, + contentHeight: Math.max(0, height), + }; + if (showDesktopWebScrollbar) { + streamScrollbarMetrics.onContentSizeChange(width, height); + } + }, + [showDesktopWebScrollbar, streamScrollbarMetrics] ); const scrollToBottomInternal = useCallback( @@ -209,11 +255,16 @@ export function AgentStreamView({ return; } - list.scrollToOffset({ offset: 0, animated }); + const offset = getBottomOffsetForStreamRenderStrategy({ + strategy: streamRenderStrategy, + contentHeight: streamViewportMetricsRef.current.contentHeight, + viewportHeight: streamViewportMetricsRef.current.viewportHeight, + }); + list.scrollToOffset({ offset, animated }); isNearBottomRef.current = true; setIsNearBottom(true); }, - [] + [streamRenderStrategy] ); const scheduleAutoScroll = useCallback( @@ -275,20 +326,31 @@ export function AgentStreamView({ } const flatListData = useMemo(() => { - return [...streamItems].reverse(); - }, [streamItems]); + return orderTailForStreamRenderStrategy({ + strategy: streamRenderStrategy, + streamItems, + }); + }, [streamItems, streamRenderStrategy]); + + const orderedStreamHead = useMemo(() => { + return orderHeadForStreamRenderStrategy({ + strategy: streamRenderStrategy, + streamHead: streamHead ?? [], + }); + }, [streamHead, streamRenderStrategy]); const tightGap = theme.spacing[1]; // 4px const looseGap = theme.spacing[4]; // 16px - // The FlatList is inverted, but each row is re-inverted by RN, so `marginBottom` still - // manifests as the gap *below* the row in the final visual layout. Compute spacing - // against the item visually below (index - 1 in our inverted list). const getGapBelow = useCallback( - (item: StreamItem, index: number) => { - const belowItem = flatListData[index - 1]; + (item: StreamItem, index: number, items: StreamItem[]) => { + const belowItem = getStreamNeighborItem({ + strategy: streamRenderStrategy, + items, + index, + relation: "below", + }); if (!belowItem) { - // This is the bottommost (newest) item; nothing below it visually. return 0; } @@ -327,13 +389,15 @@ export function AgentStreamView({ // Different types get loose gap (16px) return looseGap; }, - [flatListData, looseGap, tightGap] + [looseGap, streamRenderStrategy, tightGap] ); const renderStreamItemContent = useCallback( - (item: StreamItem, index: number) => { + (item: StreamItem, index: number, items: StreamItem[]) => { const handleInlineDetailsExpandedChange = (expanded: boolean) => { - if (Platform.OS !== "web") { + if ( + !streamRenderStrategy.disableParentScrollOnInlineDetailsExpansion + ) { return; } setExpandedInlineToolCallIds((previous) => { @@ -349,11 +413,20 @@ export function AgentStreamView({ switch (item.kind) { case "user_message": { - // In inverted list: index+1 is the item above, index-1 is below. - const prevItem = flatListData[index + 1]; - const nextItem = flatListData[index - 1]; - const isFirstInGroup = prevItem?.kind !== "user_message"; - const isLastInGroup = nextItem?.kind !== "user_message"; + const aboveItem = getStreamNeighborItem({ + strategy: streamRenderStrategy, + items, + index, + relation: "above", + }); + const belowItem = getStreamNeighborItem({ + strategy: streamRenderStrategy, + items, + index, + relation: "below", + }); + const isFirstInGroup = aboveItem?.kind !== "user_message"; + const isLastInGroup = belowItem?.kind !== "user_message"; return ( ) => { - const content = renderStreamItemContent(item, index); + const content = renderStreamItemContent(item, index, flatListData); if (!content) { return null; } - const gapBelow = getGapBelow(item, index); - const nextItem = flatListData[index - 1]; + const gapBelow = getGapBelow(item, index, flatListData); + const nextItem = getStreamNeighborItem({ + strategy: streamRenderStrategy, + items: flatListData, + index, + relation: "below", + }); const isEndOfAssistantTurn = item.kind === "assistant_message" && (nextItem?.kind === "user_message" || (nextItem === undefined && agent.status !== "running")); - const getTurnContent = () => collectAssistantTurnContent(flatListData, index); + const getTurnContent = () => + collectAssistantTurnContentForStreamRenderStrategy({ + strategy: streamRenderStrategy, + items: flatListData, + startIndex: index, + }); return ( @@ -482,7 +573,13 @@ export function AgentStreamView({ ); }, - [getGapBelow, renderStreamItemContent, flatListData, agent.status] + [ + getGapBelow, + renderStreamItemContent, + flatListData, + agent.status, + streamRenderStrategy, + ] ); const pendingPermissionItems = useMemo( @@ -564,9 +661,9 @@ export function AgentStreamView({ const showWorkingIndicator = agent.status === "running"; const showBottomBar = showWorkingIndicator; - const listHeaderComponent = useMemo(() => { + const listEdgeSlotComponent = useMemo(() => { const hasPermissions = pendingPermissionItems.length > 0; - const hasHeadItems = streamHead && streamHead.length > 0; + const hasHeadItems = orderedStreamHead.length > 0; if (!hasPermissions && !showBottomBar && !hasHeadItems) { return null; @@ -579,10 +676,8 @@ export function AgentStreamView({ @@ -599,8 +694,12 @@ export function AgentStreamView({ ) : null} {hasHeadItems - ? [...streamHead].reverse().map((item, index) => { - const rendered = renderStreamItemContent(item, index); + ? orderedStreamHead.map((item, index) => { + const rendered = renderStreamItemContent( + item, + index, + orderedStreamHead + ); return rendered ? ( {rendered} @@ -617,7 +716,7 @@ export function AgentStreamView({ pendingPermissionItems, showWorkingIndicator, client, - streamHead, + orderedStreamHead, renderStreamItemContent, tightGap, showBottomBar, @@ -636,27 +735,20 @@ export function AgentStreamView({ ] ); - // Keep maintainVisibleContentPosition shape and values stable to avoid - // reconfiguring RN's internal anchoring logic during streaming transitions. - // We rely on our own follow-tail logic, so keep threshold at 0. - const maintainVisibleContentPosition = useMemo( - () => ({ minIndexForVisible: 0, autoscrollToTopThreshold: 0 }), - [] - ); - - // FlatList's ListHeaderComponent renders at the *bottom* when inverted. - // Without explicit spacing, the newest "head" rows (like Thinking/Setup tool badges) - // can butt up directly against the most recent stream item. - const headerGapStyle = useMemo(() => { - if (!listHeaderComponent) { - return undefined; + const listEdgeSlotProps = useMemo(() => { + if (!listEdgeSlotComponent) { + return {}; } - return { marginBottom: tightGap }; - }, [listHeaderComponent, tightGap]); + return getStreamEdgeSlotProps({ + strategy: streamRenderStrategy, + component: listEdgeSlotComponent, + gapSize: tightGap, + }); + }, [listEdgeSlotComponent, streamRenderStrategy, tightGap]); const listEmptyComponent = useMemo(() => { const hasPermissions = pendingPermissionItems.length > 0; - const hasHeadItems = (streamHead?.length ?? 0) > 0; + const hasHeadItems = orderedStreamHead.length > 0; if (hasPermissions || hasHeadItems) { return null; } @@ -685,7 +777,7 @@ export function AgentStreamView({ }, [ agent.status, pendingPermissionItems.length, - streamHead, + orderedStreamHead, theme.colors.foregroundMuted, ]); @@ -699,36 +791,32 @@ export function AgentStreamView({ renderItem={renderStreamItem} keyExtractor={(item) => item.id} testID="agent-chat-scroll" - ListHeaderComponentStyle={headerGapStyle} + {...listEdgeSlotProps} contentContainerStyle={stylesheet.listContentContainer} style={stylesheet.list} - onLayout={ - showDesktopWebScrollbar - ? streamScrollbarMetrics.onLayout - : undefined - } + onLayout={handleListLayout} onScroll={handleScroll} scrollEventThrottle={16} - onContentSizeChange={ - showDesktopWebScrollbar - ? streamScrollbarMetrics.onContentSizeChange - : undefined - } + onContentSizeChange={handleContentSizeChange} ListEmptyComponent={listEmptyComponent} - ListHeaderComponent={listHeaderComponent} extraData={flatListExtraData} - maintainVisibleContentPosition={maintainVisibleContentPosition} + maintainVisibleContentPosition={ + streamRenderStrategy.maintainVisibleContentPosition + } initialNumToRender={12} windowSize={10} - scrollEnabled={Platform.OS !== "web" || expandedInlineToolCallIds.size === 0} + scrollEnabled={ + !streamRenderStrategy.disableParentScrollOnInlineDetailsExpansion || + expandedInlineToolCallIds.size === 0 + } showsVerticalScrollIndicator={!showDesktopWebScrollbar} - inverted + inverted={streamRenderStrategy.flatListInverted} /> { flatListRef.current?.scrollToOffset({ offset: nextOffset,