refactor: extract stream render model and segment-based rendering

This commit is contained in:
Mohamed Boudra
2026-03-10 12:56:18 +07:00
parent 483dd7cb6d
commit 15e9569157
11 changed files with 766 additions and 350 deletions
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { buildAgentStreamRenderModel } from "./agent-stream-render-model";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): StreamItem {
return {
kind: "user_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
function assistantMessage(id: string, seed: number): StreamItem {
return {
kind: "assistant_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
describe("buildAgentStreamRenderModel", () => {
it("keeps head separate from committed history on desktop web", () => {
const tail: StreamItem[] = [];
for (let index = 0; index < 60; index += 1) {
const seed = index * 2;
tail.push(userMessage(`u${index}`, seed + 1));
tail.push(assistantMessage(`a${index}`, seed + 2));
}
const head = [assistantMessage("live-a", 121)];
const model = buildAgentStreamRenderModel({
tail,
head,
platform: "web",
isMobileBreakpoint: false,
});
expect(model.segments.historyVirtualized.length).toBeGreaterThan(0);
expect(model.segments.historyMounted.length).toBeGreaterThan(0);
expect(model.segments.liveHead.map((item) => item.id)).toEqual(["live-a"]);
expect(model.history).not.toContain(head[0]);
});
it("keeps the full committed tail mounted on mobile web", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
const head = [assistantMessage("live-a", 3)];
const model = buildAgentStreamRenderModel({
tail,
head,
platform: "web",
isMobileBreakpoint: true,
});
expect(model.segments.historyVirtualized).toHaveLength(0);
expect(model.segments.historyMounted).toBe(tail);
expect(model.segments.liveHead).toBe(head);
});
it("reuses ordered committed history when only the live head changes", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
const firstHead = [assistantMessage("live-a", 3)];
const secondHead = [assistantMessage("live-b", 4)];
const first = buildAgentStreamRenderModel({
tail,
head: firstHead,
platform: "native",
isMobileBreakpoint: false,
});
const second = buildAgentStreamRenderModel({
tail,
head: secondHead,
platform: "native",
isMobileBreakpoint: false,
});
expect(first.history).toBe(second.history);
expect(first.segments.historyMounted).toBe(second.segments.historyMounted);
expect(second.segments.liveHead.map((item) => item.id)).toEqual(["live-b"]);
});
});
@@ -0,0 +1,178 @@
import type { ReactNode } from "react";
import type { StreamItem } from "@/types/stream";
import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "./agent-stream-web-virtualization";
import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveStreamRenderStrategy,
} from "./stream-strategy";
export type StreamRenderSegments = {
historyVirtualized: StreamItem[];
historyMounted: StreamItem[];
liveHead: StreamItem[];
};
export type StreamHistoryBoundary = {
hasVirtualizedHistory: boolean;
hasMountedHistory: boolean;
hasLiveHead: boolean;
historyToHeadGap: number;
};
export type StreamRenderAuxiliary = {
pendingPermissions: ReactNode;
workingIndicator: ReactNode;
};
export type AgentStreamRenderModel = {
history: StreamItem[];
segments: StreamRenderSegments;
boundary: StreamHistoryBoundary;
auxiliary: StreamRenderAuxiliary;
};
export type BuildAgentStreamRenderModelInput = {
tail: StreamItem[];
head: StreamItem[];
platform: "web" | "native";
isMobileBreakpoint: boolean;
};
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_AUXILIARY: StreamRenderAuxiliary = {
pendingPermissions: null,
workingIndicator: null,
};
const orderedTailCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
const orderedHeadCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
const splitHistoryCache = new WeakMap<
StreamItem[],
Map<string, Pick<AgentStreamRenderModel, "history" | "segments">>
>();
function getOrderedItems(params: {
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
source: StreamItem[];
cacheKey: string;
order: (items: StreamItem[]) => StreamItem[];
}): StreamItem[] {
const { cache, source, cacheKey, order } = params;
let cachedByKey = cache.get(source);
if (!cachedByKey) {
cachedByKey = new Map();
cache.set(source, cachedByKey);
}
const cached = cachedByKey.get(cacheKey);
if (cached) {
return cached;
}
const ordered = order(source);
cachedByKey.set(cacheKey, ordered);
return ordered;
}
function splitOrderedTail(params: {
orderedTail: StreamItem[];
platform: "web" | "native";
isMobileBreakpoint: boolean;
}): Pick<AgentStreamRenderModel, "history" | "segments"> {
const { orderedTail, platform, isMobileBreakpoint } = params;
const shouldSplitHistory =
platform === "web" &&
!isMobileBreakpoint &&
orderedTail.length > getWebPartialVirtualizationThreshold();
const cacheKey = `${platform}:${isMobileBreakpoint}:${getWebMountedRecentStreamItems()}:${shouldSplitHistory}`;
let cachedByKey = splitHistoryCache.get(orderedTail);
if (!cachedByKey) {
cachedByKey = new Map();
splitHistoryCache.set(orderedTail, cachedByKey);
}
const cached = cachedByKey.get(cacheKey);
if (cached) {
return cached;
}
if (!shouldSplitHistory) {
const unsplit = {
history: orderedTail,
segments: {
historyVirtualized: EMPTY_STREAM_ITEMS,
historyMounted: orderedTail,
liveHead: EMPTY_STREAM_ITEMS,
},
} satisfies Pick<AgentStreamRenderModel, "history" | "segments">;
cachedByKey.set(cacheKey, unsplit);
return unsplit;
}
const mountedWindowStart = findMountedWindowStart({
items: orderedTail,
minMountedCount: getWebMountedRecentStreamItems(),
});
const split = {
history: orderedTail,
segments: {
historyVirtualized: orderedTail.slice(0, mountedWindowStart),
historyMounted: orderedTail.slice(mountedWindowStart),
liveHead: EMPTY_STREAM_ITEMS,
},
} satisfies Pick<AgentStreamRenderModel, "history" | "segments">;
cachedByKey.set(cacheKey, split);
return split;
}
export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput
): AgentStreamRenderModel {
const strategy = resolveStreamRenderStrategy({
platform: input.platform === "web" ? "web" : "native",
isMobileBreakpoint: input.isMobileBreakpoint,
});
const orderingCacheKey = `${input.platform}:${input.isMobileBreakpoint}`;
const orderedTail = getOrderedItems({
cache: orderedTailCache,
source: input.tail,
cacheKey: orderingCacheKey,
order: (items) =>
orderTailForStreamRenderStrategy({
strategy,
streamItems: items,
}),
});
const orderedHead = getOrderedItems({
cache: orderedHeadCache,
source: input.head,
cacheKey: orderingCacheKey,
order: (items) =>
orderHeadForStreamRenderStrategy({
strategy,
streamHead: items,
}),
});
const splitHistory = splitOrderedTail({
orderedTail,
platform: input.platform,
isMobileBreakpoint: input.isMobileBreakpoint,
});
return {
history: splitHistory.history,
segments: {
...splitHistory.segments,
liveHead: orderedHead,
},
boundary: {
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
hasLiveHead: orderedHead.length > 0,
historyToHeadGap: 0,
},
auxiliary: EMPTY_AUXILIARY,
};
}
@@ -1 +1,2 @@
export * from "./stream-strategy";
export * from "./agent-stream-render-model";
+160 -142
View File
@@ -53,13 +53,12 @@ import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import {
buildAgentStreamRenderModel,
collectAssistantTurnContentForStreamRenderStrategy,
getStreamEdgeSlotProps,
getStreamNeighborItem,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveStreamRenderStrategy,
type StreamEdgeSlotProps,
type AgentStreamRenderModel,
type StreamSegmentRenderers,
type StreamViewportHandle,
} from "./agent-stream-render-strategy";
import {
@@ -199,12 +198,14 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
]
);
const orderedStreamItems = useMemo(() => {
return orderTailForStreamRenderStrategy({
strategy: streamRenderStrategy,
streamItems,
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
tail: streamItems,
head: streamHead ?? [],
platform: Platform.OS === "web" ? "web" : "native",
isMobileBreakpoint: isMobile,
});
}, [streamItems, streamRenderStrategy]);
}, [isMobile, streamHead, streamItems]);
useImperativeHandle(ref, () => ({
scrollToBottom(reason = "jump-to-bottom") {
viewportRef.current?.scrollToBottom(reason);
@@ -218,68 +219,48 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
viewportRef.current?.scrollToBottom("jump-to-bottom");
}
const orderedStreamHead = useMemo(() => {
return orderHeadForStreamRenderStrategy({
strategy: streamRenderStrategy,
streamHead: streamHead ?? [],
});
}, [streamHead, streamRenderStrategy]);
const tightGap = theme.spacing[1]; // 4px
const looseGap = theme.spacing[4]; // 16px
const getGapBelow = useCallback(
(item: StreamItem, index: number, items: StreamItem[]) => {
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
if (!belowItem) {
const getGapBetween = useCallback(
(item: StreamItem | null, belowItem: StreamItem | null) => {
if (!item || !belowItem) {
return 0;
}
// Same type groups get tight gap (4px)
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return tightGap;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return tightGap;
}
// Give user messages more breathing room before tool sequences.
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap;
}
// Keep tool sequences visually connected to the preceding user/assistant message.
if (
(item.kind === "user_message" || item.kind === "assistant_message") &&
isToolSequenceItem(belowItem)
) {
return tightGap;
}
// Keep todo lists visually connected to the following tool sequence (symmetry).
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
return tightGap;
}
// Keep tool sequences visually connected to the assistant response (symmetry).
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return tightGap;
}
// Different types get loose gap (16px)
return looseGap;
},
[looseGap, streamRenderStrategy, tightGap]
[looseGap, tightGap]
);
const renderStreamItemContent = useCallback(
(item: StreamItem, index: number, items: StreamItem[]) => {
(
item: StreamItem,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null = null
) => {
const handleInlineDetailsExpandedChange = (expanded: boolean) => {
if (
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()
@@ -299,12 +280,13 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
switch (item.kind) {
case "user_message": {
const aboveItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
});
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ?? seamAboveItem ?? undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
@@ -426,19 +408,24 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
);
const renderStreamItem = useCallback(
(item: StreamItem, index: number, items: StreamItem[]) => {
const content = renderStreamItemContent(item, index, items);
(
item: StreamItem,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null = null
) => {
const content = renderStreamItemContent(item, index, items, seamAboveItem);
if (!content) {
return null;
}
const gapBelow = getGapBelow(item, index, items);
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const gapBelow = getGapBetween(item, nextItem ?? null);
const isEndOfAssistantTurn =
item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" ||
@@ -460,7 +447,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
);
},
[
getGapBelow,
getGapBetween,
renderStreamItemContent,
agent.status,
streamRenderStrategy,
@@ -544,101 +531,58 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
const showWorkingIndicator = agent.status === "running";
const showBottomBar = showWorkingIndicator;
const listEdgeSlotComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
const hasHeadItems = orderedStreamHead.length > 0;
if (!hasPermissions && !showBottomBar && !hasHeadItems) {
return null;
}
const leftContent = showWorkingIndicator ? <WorkingIndicator /> : null;
return (
<View style={stylesheet.contentWrapper}>
<View
style={[
stylesheet.listHeaderContent,
// The edge slot (header for inverted streams, footer for forward streams)
// sits next to the newest timeline item.
hasHeadItems ? { paddingTop: tightGap } : null,
]}
>
{hasPermissions ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard
key={permission.key}
permission={permission}
client={client}
/>
))}
</View>
) : null}
{hasHeadItems
? orderedStreamHead.map((item, index) => {
const rendered = renderStreamItemContent(
item,
index,
orderedStreamHead
);
return rendered ? (
<View key={item.id} style={stylesheet.streamItemWrapper}>
{rendered}
</View>
) : null;
})
: null}
{showBottomBar ? <View style={stylesheet.bottomBarWrapper}>{leftContent}</View> : null}
const renderModel = useMemo<AgentStreamRenderModel>(() => {
const pendingPermissionsNode =
pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard
key={permission.key}
permission={permission}
client={client}
/>
))}
</View>
) : null;
const workingIndicatorNode = showWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper}>
<WorkingIndicator />
</View>
);
) : null;
return {
...baseRenderModel,
boundary: {
...baseRenderModel.boundary,
historyToHeadGap: getGapBetween(
baseRenderModel.history.at(-1) ?? null,
baseRenderModel.segments.liveHead[0] ?? null
),
},
auxiliary: {
pendingPermissions: pendingPermissionsNode,
workingIndicator: workingIndicatorNode,
},
};
}, [
baseRenderModel,
client,
getGapBetween,
pendingPermissionItems,
showWorkingIndicator,
client,
orderedStreamHead,
renderStreamItemContent,
showBottomBar,
tightGap,
]);
const listEdgeSlotProps = useMemo<StreamEdgeSlotProps>(() => {
if (!listEdgeSlotComponent) {
return {};
}
return getStreamEdgeSlotProps({
strategy: streamRenderStrategy,
component: listEdgeSlotComponent,
gapSize: tightGap,
});
}, [listEdgeSlotComponent, streamRenderStrategy, tightGap]);
const listEmptyComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
const hasHeadItems = orderedStreamHead.length > 0;
if (hasPermissions || hasHeadItems) {
if (
renderModel.boundary.hasVirtualizedHistory ||
renderModel.boundary.hasMountedHistory ||
renderModel.boundary.hasLiveHead ||
renderModel.auxiliary.pendingPermissions ||
renderModel.auxiliary.workingIndicator
) {
return null;
}
const shouldShowWorking = agent.status === "running";
if (shouldShowWorking) {
return (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<Text style={stylesheet.emptyStateText}>Working…</Text>
</View>
);
}
return (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>
@@ -646,12 +590,86 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
</Text>
</View>
);
}, [
agent.status,
pendingPermissionItems.length,
orderedStreamHead,
theme.colors.foregroundMuted,
]);
}, [renderModel]);
const historyItems = renderModel.history;
const liveHeadItems = renderModel.segments.liveHead;
const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null;
const historyIndexById = useMemo(() => {
const indexById = new Map<string, number>();
historyItems.forEach((item, index) => {
indexById.set(item.id, index);
});
return indexById;
}, [historyItems]);
const renderHistoryRow = useCallback(
(item: StreamItem) => {
const historyIndex = historyIndexById.get(item.id);
if (historyIndex === undefined) {
return null;
}
return renderStreamItem(item, historyIndex, historyItems);
},
[historyIndexById, historyItems, renderStreamItem]
);
const renderHistoryVirtualizedRow = useCallback<StreamSegmentRenderers["renderHistoryVirtualizedRow"]>(
(item) => renderHistoryRow(item),
[renderHistoryRow]
);
const renderHistoryMountedRow = useCallback<StreamSegmentRenderers["renderHistoryMountedRow"]>(
(item) => renderHistoryRow(item),
[renderHistoryRow]
);
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item, index, items) =>
renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null),
[lastHistoryItem, renderStreamItem]
);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(
() => {
if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) {
return null;
}
return (
<View style={stylesheet.contentWrapper}>
<View
style={[
stylesheet.listHeaderContent,
boundary.hasLiveHead ? { paddingTop: tightGap } : null,
]}
>
{auxiliary.pendingPermissions}
{auxiliary.workingIndicator}
</View>
</View>
);
},
[
auxiliary.pendingPermissions,
auxiliary.workingIndicator,
boundary.hasLiveHead,
tightGap,
]
);
const renderers = useMemo<StreamSegmentRenderers>(
() => ({
renderHistoryVirtualizedRow,
renderHistoryMountedRow,
renderLiveHeadRow,
renderLiveAuxiliary,
}),
[
renderHistoryVirtualizedRow,
renderHistoryMountedRow,
renderLiveHeadRow,
renderLiveAuxiliary,
]
);
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
@@ -663,8 +681,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
<MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({
agentId,
rows: orderedStreamItems,
renderRow: renderStreamItem,
segments: renderModel.segments,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
@@ -674,7 +693,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
edgeSlotProps: listEdgeSlotProps,
})}
</MessageOuterSpacingProvider>
{!isNearBottom && (
@@ -8,9 +8,7 @@ import {
type NativeSyntheticEvent,
} from "react-native";
import type { StreamItem } from "@/types/stream";
import {
useBottomAnchorController,
} from "./use-bottom-anchor-controller";
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
import type {
StreamRenderInput,
StreamStrategy,
@@ -30,8 +28,9 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
const {
agentId,
rows,
renderRow,
segments,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
@@ -40,7 +39,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollEnabled,
listStyle,
baseListContentContainerStyle,
edgeSlotProps,
strategy,
} = props;
const flatListRef = useRef<FlatList<StreamItem>>(null);
@@ -58,6 +56,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyRows = useMemo(() => {
if (segments.historyVirtualized.length === 0) {
return segments.historyMounted;
}
return [...segments.historyVirtualized, ...segments.historyMounted];
}, [segments.historyMounted, segments.historyVirtualized]);
const clearNativeViewportSettling = useCallback(() => {
if (nativeViewportSettlingFrameIdRef.current !== null) {
cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current);
@@ -90,19 +95,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
[isNativeViewportSettling, strategy]
);
const scrollToBottom = useCallback((animated: boolean) => {
programmaticScrollEventBudgetRef.current = 3;
flatListRef.current?.scrollToOffset({
offset: 0,
animated,
});
scrollOffsetYRef.current = 0;
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
offsetY: 0,
};
onNearBottomChange(true);
}, [onNearBottomChange]);
const scrollToBottom = useCallback(
(animated: boolean) => {
programmaticScrollEventBudgetRef.current = 3;
flatListRef.current?.scrollToOffset({
offset: 0,
animated,
});
scrollOffsetYRef.current = 0;
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
offsetY: 0,
};
onNearBottomChange(true);
},
[onNearBottomChange]
);
const bottomAnchorController = useBottomAnchorController({
agentId,
@@ -163,7 +171,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
useEffect(() => {
bottomAnchorController.prepareForStickyContentChange();
}, [bottomAnchorController, rows]);
}, [bottomAnchorController, historyRows, segments.liveHead]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -272,30 +280,50 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const renderItem = useCallback(
({ item, index }: ListRenderItemInfo<StreamItem>) => {
const rendered = renderRow(item, index, rows);
const rendered = renderers.renderHistoryMountedRow(item, index, historyRows);
return rendered ? <Fragment>{rendered}</Fragment> : null;
},
[renderRow, rows]
[historyRows, renderers]
);
const liveHeaderContent = useMemo(() => {
const liveHeadRows = segments.liveHead.map((item, index) => (
<Fragment key={item.id}>
{renderers.renderLiveHeadRow(item, index, segments.liveHead)}
</Fragment>
));
const liveAuxiliary = renderers.renderLiveAuxiliary();
if (
liveHeadRows.length === 0 &&
!liveAuxiliary &&
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory
) {
return listEmptyComponent ? <Fragment>{listEmptyComponent}</Fragment> : null;
}
return (
<Fragment>
{liveHeadRows}
{liveAuxiliary}
</Fragment>
);
}, [boundary, listEmptyComponent, renderers, segments.liveHead]);
return (
<FlatList
ref={flatListRef}
data={rows}
data={historyRows}
renderItem={renderItem}
keyExtractor={(item) => item.id}
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
{...edgeSlotProps}
ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined}
contentContainerStyle={baseListContentContainerStyle}
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
ListEmptyComponent={
listEmptyComponent ? () => <Fragment>{listEmptyComponent}</Fragment> : undefined
}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
initialNumToRender={12}
windowSize={10}
@@ -1,8 +1,6 @@
import {
Fragment,
type CSSProperties,
createElement,
isValidElement,
useCallback,
useEffect,
useLayoutEffect,
@@ -11,13 +9,7 @@ import {
useState,
} from 'react'
import { measureElement as measureVirtualElement, useVirtualizer } from '@tanstack/react-virtual'
import { View } from 'react-native'
import {
estimateStreamItemHeight,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
} from './agent-stream-web-virtualization'
import { estimateStreamItemHeight } from './agent-stream-web-virtualization'
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from './stream-strategy'
import { createStreamStrategy } from './stream-strategy'
@@ -68,28 +60,6 @@ function isScrollContainerAtBottom(
return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX)
}
function renderEdge(
content: StreamRenderInput['edgeSlotProps']['ListHeaderComponent'],
style?: CSSProperties
) {
if (!content) {
return null
}
const rendered = isValidElement(content) ? content : createElement(content)
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
...style,
}}
>
{rendered}
</div>
)
}
function scrollElementToBottom(
scrollContainer: HTMLElement,
behavior: ScrollBehaviorLike = 'auto'
@@ -127,18 +97,15 @@ function isScrollContainerOverscrolledPastBottom(
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const {
rows,
renderRow,
segments,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange,
scrollEnabled,
listStyle,
baseListContentContainerStyle,
forwardListContentContainerStyle,
edgeSlotProps,
isMobileBreakpoint,
} = props
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
@@ -147,7 +114,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const contentRef = useRef<HTMLElement | null>(null)
const [followOutput, setFollowOutputr] = useState(true)
const setFollowOutput = (value: boolean) => {
console.trace('setFollowOutput', value)
setFollowOutputr(value)
return value
}
@@ -160,32 +126,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollTimeoutRef = useRef<number | null>(null)
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics()
const showDesktopWebScrollbar = !isMobileBreakpoint
const shouldUseVirtualizer =
!isMobileBreakpoint && rows.length > getWebPartialVirtualizationThreshold()
const shouldUseVirtualizer = segments.historyVirtualized.length > 0
const {
renderHistoryVirtualizedRow,
renderHistoryMountedRow,
renderLiveHeadRow,
renderLiveAuxiliary,
} = renderers
followOutputRef.current = followOutput
const indexedRows = useMemo(() => rows.map((item, index) => ({ item, index })), [rows])
const webVirtualizedHistoryWindow = useMemo(() => {
if (!shouldUseVirtualizer) {
return null
}
return splitWebVirtualizedHistory({
entries: indexedRows,
minMountedCount: getWebMountedRecentStreamItems(),
})
}, [indexedRows, shouldUseVirtualizer])
const virtualizedEntries = webVirtualizedHistoryWindow?.virtualizedEntries ?? []
const mountedEntries = webVirtualizedHistoryWindow?.mountedEntries ?? indexedRows
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId
const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady
const rowVirtualizer = useVirtualizer({
count: virtualizedEntries.length,
count: segments.historyVirtualized.length,
getScrollElement: () => scrollContainerRef.current,
getItemKey: (index: number) => virtualizedEntries[index]?.item.id ?? index,
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
estimateSize: (index: number) => {
const row = virtualizedEntries[index]?.item
const row = segments.historyVirtualized[index]
return row ? estimateStreamItemHeight(row) : 120
},
measureElement: measureVirtualElement,
@@ -220,7 +179,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [])
const scrollMessagesToBottom = useCallback(
(behavior: ScrollBehaviorLike = 'auto', source = 'unknown') => {
(behavior: ScrollBehaviorLike = 'auto') => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) {
return
@@ -236,7 +195,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
)
const scheduleStickToBottom = useCallback(
(source = 'unknown') => {
() => {
const scrollContainer = scrollContainerRef.current
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
return
@@ -249,15 +208,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) {
return
}
scrollMessagesToBottom('auto', source)
scrollMessagesToBottom('auto')
})
},
[scrollMessagesToBottom]
)
const forceStickToBottom = useCallback(() => {
cancelPendingStickToBottom()
scrollMessagesToBottom('auto', 'force')
scheduleStickToBottom('force')
scrollMessagesToBottom('auto')
scheduleStickToBottom()
}, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom])
const updateScrollMetrics = useCallback(() => {
@@ -303,7 +263,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
const currentScrollTop = scrollContainer.scrollTop
const isNearBottom = syncNearBottom(scrollContainer, onNearBottomChange)
const isAtBottom = isScrollContainerAtBottom(scrollContainer)
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON
@@ -325,7 +284,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
lastKnownScrollTopRef.current = currentScrollTop
updateScrollMetrics()
}, [cancelPendingStickToBottom, onNearBottomChange, updateScrollMetrics])
}, [cancelPendingStickToBottom, updateScrollMetrics])
useLayoutEffect(() => {
if (!isActivationReady) {
@@ -334,13 +293,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
setFollowOutput(true)
forceStickToBottom()
const timeout = window.setTimeout(() => {
console.log('timeout', {
followOutputRef: followOutputRef.current,
scrollContainerRef: scrollContainerRef.current,
isScrollContainerNearBottom: scrollContainerRef.current
? isScrollContainerNearBottom(scrollContainerRef.current)
: null,
})
if (!followOutputRef.current) {
return
}
@@ -351,7 +303,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (isScrollContainerNearBottom(scrollContainer)) {
return
}
scheduleStickToBottom('activation-timeout')
scheduleStickToBottom()
}, WEB_BOTTOM_SETTLE_TIMEOUT_MS)
return () => {
window.clearTimeout(timeout)
@@ -362,22 +314,30 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) {
return
}
scheduleStickToBottom('rows-effect')
}, [rows, scheduleStickToBottom])
scheduleStickToBottom()
}, [
scheduleStickToBottom,
segments.historyMounted,
segments.historyVirtualized,
segments.liveHead,
])
useEffect(() => {
if (!followOutputRef.current) {
if (!followOutputRef.current || !shouldUseVirtualizer) {
return
}
if (!webVirtualizedHistoryWindow) {
return
}
scheduleStickToBottom('virtual-total-size-effect')
}, [scheduleStickToBottom, virtualTotalSize, webVirtualizedHistoryWindow])
scheduleStickToBottom()
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize])
useEffect(() => {
updateScrollMetrics()
}, [updateScrollMetrics, virtualTotalSize, rows.length])
}, [
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
updateScrollMetrics,
virtualTotalSize,
])
useEffect(() => {
const scrollContainer = scrollContainerRef.current
@@ -392,7 +352,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) {
return
}
scheduleStickToBottom('resize-observer')
scheduleStickToBottom()
})
observer.observe(scrollContainer)
if (contentNode) {
@@ -478,7 +438,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) {
return
}
scheduleStickToBottom('prepare-for-viewport-change')
scheduleStickToBottom()
},
}
viewportRef.current = handle
@@ -488,7 +448,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
cancelPendingStickToBottom()
}
}, [cancelPendingStickToBottom, scheduleStickToBottom, forceStickToBottom, viewportRef])
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef])
const contentContainerStyle = useMemo(
(): CSSProperties => ({
@@ -513,11 +473,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}),
[scrollEnabled]
)
const headerEdgeContent = renderEdge(edgeSlotProps.ListHeaderComponent)
const footerEdgeContent = renderEdge(
edgeSlotProps.ListFooterComponent,
edgeSlotProps.ListFooterComponentStyle as CSSProperties | undefined
)
const virtualRowsContainerStyle = useMemo(
(): CSSProperties => ({
position: 'relative',
@@ -538,6 +493,30 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}),
[]
)
const mountedHistoryRows = useMemo(
() =>
segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
)),
[renderHistoryMountedRow, segments.historyMounted]
)
const liveHeadRows = useMemo(
() =>
segments.liveHead.map((item, index) => (
<Fragment key={item.id}>
{renderLiveHeadRow(item, index, segments.liveHead)}
</Fragment>
)),
[renderLiveHeadRow, segments.liveHead]
)
const liveAuxiliary = useMemo(() => renderLiveAuxiliary(), [renderLiveAuxiliary])
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
!boundary.hasLiveHead &&
!liveAuxiliary
return (
<>
@@ -556,12 +535,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}}
style={contentContainerStyle}
>
{headerEdgeContent}
{webVirtualizedHistoryWindow ? (
{shouldUseVirtualizer ? (
<div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => {
const entry = virtualizedEntries[virtualRow.index]
if (!entry) {
const item = segments.historyVirtualized[virtualRow.index]
if (!item) {
return null
}
return (
@@ -571,17 +549,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
ref={rowVirtualizer.measureElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
{renderRow(entry.item, entry.index, rows)}
{renderHistoryVirtualizedRow(
item,
virtualRow.index,
segments.historyVirtualized
)}
</div>
)
})}
</div>
) : null}
{mountedEntries.map((entry) => (
<Fragment key={entry.item.id}>{renderRow(entry.item, entry.index, rows)}</Fragment>
))}
{rows.length === 0 ? listEmptyComponent : null}
{footerEdgeContent}
{mountedHistoryRows}
{boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? (
<div style={{ height: boundary.historyToHeadGap, width: '100%' }} />
) : null}
{liveHeadRows}
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
</div>
<WebDesktopScrollbarOverlay
+26 -3
View File
@@ -1,6 +1,10 @@
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 "./agent-stream-render-model";
import type {
BottomAnchorLocalRequest,
BottomAnchorRouteRequest,
@@ -44,10 +48,30 @@ export type StreamViewportHandle = {
prepareForViewportChange: () => void;
};
export type 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 type StreamRenderInput = {
agentId: string;
rows: StreamItem[];
renderRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
segments: StreamRenderSegments;
boundary: StreamHistoryBoundary;
renderers: StreamSegmentRenderers;
listEmptyComponent: ReactNode;
viewportRef: RefObject<StreamViewportHandle | null>;
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
@@ -57,7 +81,6 @@ export type StreamRenderInput = {
listStyle: StyleProp<ViewStyle>;
baseListContentContainerStyle: StyleProp<ViewStyle>;
forwardListContentContainerStyle: StyleProp<ViewStyle>;
edgeSlotProps: StreamEdgeSlotProps;
};
export type ResolveStreamRenderStrategyInput = {