refactor(app): use strategy-driven stream renderer on web (#80)

This commit is contained in:
Mohamed Boudra
2026-02-24 18:15:06 +07:00
committed by GitHub
parent 5c773371d6
commit 9d2ec0e9e1
3 changed files with 686 additions and 92 deletions
@@ -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 },
});
});
});
@@ -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<any> | null;
ListHeaderComponentStyle?: StyleProp<ViewStyle>;
ListFooterComponent?: ReactElement | ComponentType<any> | null;
ListFooterComponentStyle?: StyleProp<ViewStyle>;
};
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<any> | 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 },
};
}
+180 -92
View File
@@ -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<number | null>(null);
const pendingAutoScrollAnimatedRef = useRef(false);
const streamItemCountRef = useRef(0);
const streamViewportMetricsRef = useRef({
contentHeight: 0,
viewportHeight: 0,
});
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
@@ -185,10 +191,19 @@ export function AgentStreamView({
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
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 (
<UserMessage
message={item.text}
@@ -375,8 +448,12 @@ export function AgentStreamView({
);
case "thought": {
// In inverted list: index+1 is the item above, index-1 is below.
const nextItem = flatListData[index - 1];
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence =
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
return (
@@ -392,8 +469,12 @@ export function AgentStreamView({
case "tool_call": {
const { payload } = item;
// In inverted list: index+1 is the item above, index-1 is below.
const nextItem = flatListData[index - 1];
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence =
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
@@ -455,23 +536,33 @@ export function AgentStreamView({
return null;
}
},
[handleInlinePathPress, agent.cwd, flatListData]
[handleInlinePathPress, agent.cwd, streamRenderStrategy]
);
const renderStreamItem = useCallback(
({ item, index }: ListRenderItemInfo<StreamItem>) => {
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 (
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
@@ -482,7 +573,13 @@ export function AgentStreamView({
</View>
);
},
[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({
<View
style={[
stylesheet.listHeaderContent,
// In an inverted FlatList, the header is rendered at the visual bottom, so its
// top edge can sit directly against the newest timeline item. Add a small
// padding to prevent badges (e.g. Thinking/Setup) from butting up against
// the last user message when the streaming head is present.
// The edge slot (header for inverted streams, footer for forward streams)
// sits next to the newest timeline item.
hasHeadItems ? { paddingTop: tightGap } : null,
]}
>
@@ -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 ? (
<View key={item.id} style={stylesheet.streamItemWrapper}>
{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}
/>
</MessageOuterSpacingProvider>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics}
inverted
inverted={streamRenderStrategy.overlayScrollbarInverted}
onScrollToOffset={(nextOffset) => {
flatListRef.current?.scrollToOffset({
offset: nextOffset,