refactor(app): organize agent stream rendering

This commit is contained in:
Mohamed Boudra
2026-05-25 12:15:20 +07:00
parent ba724956df
commit 62780448dc
23 changed files with 772 additions and 334 deletions
@@ -1,161 +0,0 @@
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({
agentStatus: "running",
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({
agentStatus: "running",
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({
agentStatus: "running",
tail,
head: firstHead,
platform: "native",
isMobileBreakpoint: false,
});
const second = buildAgentStreamRenderModel({
agentStatus: "running",
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"]);
});
it("derives running turn timing across committed history and live head", () => {
const tail = [userMessage("u1", 1)];
const head = [assistantMessage("live-a", 4)];
const model = buildAgentStreamRenderModel({
agentStatus: "running",
tail,
head,
platform: "web",
isMobileBreakpoint: false,
});
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false);
});
it("maps completed turn timing to assistant ids across committed history and live head", () => {
const tail = [userMessage("u1", 1)];
const head = [assistantMessage("live-a", 4)];
const model = buildAgentStreamRenderModel({
agentStatus: "idle",
tail,
head,
platform: "web",
isMobileBreakpoint: false,
});
expect(model.turnTiming.runningStartedAt).toBe(null);
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
startedAt: tail[0]?.timestamp,
completedAt: head[0]?.timestamp,
durationMs: 3000,
});
});
it("derives the same timing for native inverted rendering", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 4)];
const model = buildAgentStreamRenderModel({
agentStatus: "idle",
tail,
head: [],
platform: "native",
isMobileBreakpoint: false,
});
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
startedAt: tail[0]?.timestamp,
completedAt: tail[1]?.timestamp,
durationMs: 3000,
});
});
it("does not create completed timing for adjacent user messages", () => {
const tail = [userMessage("u1", 1), userMessage("u2", 4)];
const model = buildAgentStreamRenderModel({
agentStatus: "idle",
tail,
head: [],
platform: "web",
isMobileBreakpoint: false,
});
expect(model.turnTiming.byAssistantId.size).toBe(0);
});
});
@@ -1,215 +0,0 @@
import type { ReactNode } from "react";
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "./agent-stream-web-virtualization";
import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
} from "./stream-strategy";
import { resolveStreamRenderStrategy } from "./stream-strategy-resolver";
export interface StreamRenderSegments {
historyVirtualized: StreamItem[];
historyMounted: StreamItem[];
liveHead: StreamItem[];
}
export interface StreamHistoryBoundary {
hasVirtualizedHistory: boolean;
hasMountedHistory: boolean;
hasLiveHead: boolean;
historyToHeadGap: number;
}
export interface StreamRenderAuxiliary {
pendingPermissions: ReactNode;
turnFooter: ReactNode;
}
export interface AgentStreamRenderModel {
history: StreamItem[];
segments: StreamRenderSegments;
turnTiming: StreamTurnTiming;
boundary: StreamHistoryBoundary;
auxiliary: StreamRenderAuxiliary;
}
export interface BuildAgentStreamRenderModelInput {
agentStatus: string;
tail: StreamItem[];
head: StreamItem[];
platform: "web" | "native";
isMobileBreakpoint: boolean;
}
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_AUXILIARY: StreamRenderAuxiliary = {
pendingPermissions: null,
turnFooter: 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">>
>();
const turnTimingCache = new WeakMap<
StreamItem[],
WeakMap<StreamItem[], Map<string, StreamTurnTiming>>
>();
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;
}
function getTurnTiming(params: {
agentStatus: string;
tail: StreamItem[];
head: StreamItem[];
}): StreamTurnTiming {
let cachedByHead = turnTimingCache.get(params.tail);
if (!cachedByHead) {
cachedByHead = new WeakMap();
turnTimingCache.set(params.tail, cachedByHead);
}
let cachedByStatus = cachedByHead.get(params.head);
if (!cachedByStatus) {
cachedByStatus = new Map();
cachedByHead.set(params.head, cachedByStatus);
}
const cached = cachedByStatus.get(params.agentStatus);
if (cached) {
return cached;
}
const timing = deriveStreamTurnTiming(params);
cachedByStatus.set(params.agentStatus, timing);
return timing;
}
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,
});
const turnTiming = getTurnTiming({
agentStatus: input.agentStatus,
tail: input.tail,
head: input.head,
});
return {
history: splitHistory.history,
segments: {
...splitHistory.segments,
liveHead: orderedHead,
},
turnTiming,
boundary: {
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
hasLiveHead: orderedHead.length > 0,
historyToHeadGap: 0,
},
auxiliary: EMPTY_AUXILIARY,
};
}
@@ -1,330 +0,0 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
getBottomOffsetForStreamRenderStrategy,
getStreamEdgeSlotProps,
getStreamNeighborIndex,
getStreamNeighborItem,
isNearBottomForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
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.shouldUseVirtualizedList()).toBe(false);
expect(strategy.getFlatListInverted()).toBe(false);
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
});
});
it("uses inverted_stream on native", () => {
const strategy = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(strategy.shouldUseVirtualizedList()).toBe(true);
expect(strategy.getFlatListInverted()).toBe(true);
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
});
});
it("delays native verification while viewport settling is in flight", () => {
const strategy = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
}),
).toEqual({
verificationDelayFrames: 4,
verificationRetryMode: "recheck",
});
});
it("does not inflate forward-stream verification delays during web resize", () => {
const strategy = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
expect(
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
}),
).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
});
});
});
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 },
});
});
});
@@ -1,3 +0,0 @@
export * from "./stream-strategy";
export * from "./stream-strategy-resolver";
export * from "./agent-stream-render-model";
@@ -1,222 +0,0 @@
import React, { memo, useCallback, useMemo, type ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy,
} from "./agent-stream-render-strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
import { SyncedLoader } from "./synced-loader";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({
color:
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500],
});
export type TurnContentStrategy = StreamStrategy;
export interface TurnFooterHost {
itemId: string;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}
export function resolveBottomTurnFooterHost(input: {
agentStatus: string;
history: StreamItem[];
liveHead: StreamItem[];
isInverted: boolean;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost | null {
if (input.agentStatus === "running") {
return null;
}
const usesLiveHead = input.liveHead.length > 0;
const footerItems = usesLiveHead ? input.liveHead : input.history;
const startIndex = input.isInverted ? 0 : footerItems.length - 1;
const item = footerItems[startIndex];
if (!item || item.kind !== "assistant_message") {
return null;
}
return {
itemId: item.id,
items: footerItems,
timing: input.timingByAssistantId.get(item.id),
startIndex,
};
}
export function shouldRenderCompletedTurnFooter(input: {
item: StreamItem;
belowItem: StreamItem | undefined;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
}): boolean {
return (
input.item.kind === "assistant_message" &&
!input.suppressTurnFooter &&
(input.belowItem?.kind === "user_message" ||
(input.belowItem === undefined && input.agentStatus !== "running"))
);
}
export const TurnFooter = memo(function TurnFooter({
isRunning,
inFlightTurnStartedAt,
host,
strategy,
}: {
isRunning: boolean;
inFlightTurnStartedAt: Date | null;
host: TurnFooterHost | null;
strategy: TurnContentStrategy;
}) {
if (isRunning) {
return (
<TurnFooterRow>
<RunningTurnFooter inFlightTurnStartedAt={inFlightTurnStartedAt} />
</TurnFooterRow>
);
}
if (!host) {
return null;
}
return (
<CompletedTurnFooterRow
strategy={strategy}
items={host.items}
timing={host.timing}
startIndex={host.startIndex}
/>
);
});
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
strategy,
items,
timing,
startIndex,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}) {
return (
<TurnFooterRow>
<CompletedTurnFooter
strategy={strategy}
items={items}
timing={timing}
startIndex={startIndex}
/>
</TurnFooterRow>
);
});
const WorkingIndicator = memo(function WorkingIndicator({
inFlightTurnStartedAt = null,
}: {
inFlightTurnStartedAt?: Date | null;
}) {
return (
<View style={stylesheet.turnFooterContent}>
<View style={stylesheet.workingLoader}>
<ThemedSyncedLoader size={14} uniProps={workingIndicatorColorMapping} />
</View>
{inFlightTurnStartedAt ? (
<LiveElapsed
startedAt={inFlightTurnStartedAt}
style={stylesheet.workingElapsed}
testID="turn-working-elapsed"
/>
) : null}
</View>
);
});
function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: Date | null }) {
return (
<View style={stylesheet.turnFooterSlot} testID="turn-working-indicator">
<WorkingIndicator inFlightTurnStartedAt={inFlightTurnStartedAt} />
</View>
);
}
function CompletedTurnFooter({
strategy,
items,
timing,
startIndex,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}) {
const getContent = useCallback(
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items,
startIndex,
}),
[strategy, items, startIndex],
);
return (
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
completedAt={timing?.completedAt}
durationMs={timing?.durationMs}
/>
</View>
);
}
function TurnFooterRow({ children }: { children: ReactNode }) {
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
return <View style={rowStyle}>{children}</View>;
}
const stylesheet = StyleSheet.create((theme) => ({
streamItemWrapper: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
turnFooterRow: {
marginTop: theme.spacing[4],
},
turnFooterSlot: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
minHeight: 24,
paddingBottom: theme.spacing[6],
},
turnFooterContent: {
height: 24,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[3],
},
workingElapsed: {
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
fontVariant: ["tabular-nums"],
},
workingLoader: {
marginLeft: -2,
},
}));
@@ -1,123 +0,0 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./agent-stream-view-data";
function assistantBlock(params: {
id: string;
blockGroupId: string;
blockIndex: number;
text?: string;
}): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id: params.id,
blockGroupId: params.blockGroupId,
blockIndex: params.blockIndex,
text: params.text ?? "",
timestamp: new Date("2026-05-01T00:00:00.000Z"),
};
}
function toolCallBlock(id: string): Extract<StreamItem, { kind: "tool_call" }> {
return {
kind: "tool_call",
id,
timestamp: new Date("2026-05-01T00:00:00.000Z"),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "bash",
arguments: "cmd",
result: null,
status: "executing",
},
},
};
}
describe("isSameAssistantBlockGroup", () => {
it("returns true for two assistant blocks with the same blockGroupId", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
const b = assistantBlock({ id: "b", blockGroupId: "group-1", blockIndex: 1 });
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(true);
});
it("returns false for blocks from different groups", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
const b = assistantBlock({ id: "b", blockGroupId: "group-2", blockIndex: 0 });
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(false);
});
it("returns false when one item is not an assistant_message", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
const tc = toolCallBlock("tc-1");
expect(isSameAssistantBlockGroup({ item: a, other: tc })).toBe(false);
});
it("returns false for null neighbors", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(isSameAssistantBlockGroup({ item: a, other: null })).toBe(false);
});
});
describe("getAssistantBlockSpacing", () => {
it("returns default for non-assistant items", () => {
const tc = toolCallBlock("tc-1");
expect(getAssistantBlockSpacing({ item: tc, aboveItem: null, belowItem: null })).toBe(
"default",
);
});
it("returns default when no same-group neighbors exist", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
});
it("returns compactTop when the item above is in the same block group", () => {
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: null })).toBe(
"compactTop",
);
});
it("returns compactBottom when the item below is in the same block group", () => {
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 0 });
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 1 });
expect(getAssistantBlockSpacing({ item, aboveItem: null, belowItem: below })).toBe(
"compactBottom",
);
});
it("returns compactBoth when both neighbors are in the same block group", () => {
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 2 });
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: below })).toBe(
"compactBoth",
);
});
it("spans the history/live-head boundary: tail gets compactBottom, head gets compactTop", () => {
const tailBlock = assistantBlock({
id: "group-1:block:0",
blockGroupId: "group-1",
blockIndex: 0,
text: "First paragraph",
});
const headBlock = assistantBlock({
id: "group-1:head",
blockGroupId: "group-1",
blockIndex: 1,
text: "Second paragraph",
});
expect(
getAssistantBlockSpacing({ item: tailBlock, aboveItem: null, belowItem: headBlock }),
).toBe("compactBottom");
expect(
getAssistantBlockSpacing({ item: headBlock, aboveItem: tailBlock, belowItem: null }),
).toBe("compactTop");
});
});
@@ -1,63 +0,0 @@
import type { StreamItem } from "@/types/stream";
import { SPACING } from "@/styles/theme";
export function isSameAssistantBlockGroup(params: {
item: StreamItem | null | undefined;
other: StreamItem | null | undefined;
}): boolean {
return (
params.item?.kind === "assistant_message" &&
params.other?.kind === "assistant_message" &&
params.item.blockGroupId !== undefined &&
params.item.blockGroupId === params.other.blockGroupId
);
}
export function getAssistantBlockSpacing(params: {
item: StreamItem;
aboveItem: StreamItem | null | undefined;
belowItem: StreamItem | null | undefined;
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
if (params.item.kind !== "assistant_message") {
return "default";
}
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
const compactBottom = isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
if (compactTop && compactBottom) return "compactBoth";
if (compactTop) return "compactTop";
if (compactBottom) return "compactBottom";
return "default";
}
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem | null) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
export function getGapBetweenStreamItems(
item: StreamItem | null,
belowItem: StreamItem | null,
): number {
if (!item || !belowItem) {
return 0;
}
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return SPACING[1];
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return SPACING[4];
}
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
return SPACING[1];
}
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return SPACING[1];
}
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
return SPACING[3];
}
return SPACING[4];
}
File diff suppressed because it is too large Load Diff
@@ -1,205 +0,0 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import {
clearAssistantImageMetadataCache,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import {
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
estimateStreamItemHeight,
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./agent-stream-web-virtualization";
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),
};
}
function toolCall(id: string, seed: number): StreamItem {
return {
kind: "tool_call",
id,
timestamp: createTimestamp(seed),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "test_tool",
arguments: {},
status: "completed",
},
},
};
}
function thought(id: string, seed: number): StreamItem {
return {
kind: "thought",
id,
text: id,
status: "ready",
timestamp: createTimestamp(seed),
};
}
function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
return items.map((item, index) => ({ item, index }));
}
describe("findMountedWindowStart", () => {
it("keeps all items mounted when the chat is below the threshold", () => {
const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
expect(
findMountedWindowStart({
items,
minMountedCount: 50,
}),
).toBe(0);
});
it("rewinds to the previous user boundary when the cutoff lands inside a turn", () => {
const items: StreamItem[] = [];
for (let index = 0; index < 30; index += 1) {
const seed = index * 3;
items.push(userMessage(`u${index}`, seed + 1));
items.push(toolCall(`t${index}`, seed + 2));
items.push(assistantMessage(`a${index}`, seed + 3));
}
expect(
findMountedWindowStart({
items,
minMountedCount: 50,
}),
).toBe(39);
});
});
describe("splitWebVirtualizedHistory", () => {
it("splits older entries into the virtualized section and keeps the recent window mounted", () => {
const items: StreamItem[] = [];
for (let index = 0; index < 30; index += 1) {
const seed = index * 2;
items.push(userMessage(`u${index}`, seed + 1));
items.push(assistantMessage(`a${index}`, seed + 2));
}
const window = splitWebVirtualizedHistory({
entries: indexEntries(items),
minMountedCount: 50,
});
expect(window.virtualizedEntries).toHaveLength(10);
expect(window.virtualizedEntries[0]?.item.id).toBe("u0");
expect(window.virtualizedEntries.at(-1)?.item.id).toBe("a4");
expect(window.mountedEntries[0]?.item.id).toBe("u5");
expect(window.mountedEntries).toHaveLength(50);
});
});
describe("estimateStreamItemHeight", () => {
it("uses compact estimates for collapsed tool sequence rows", () => {
expect(estimateStreamItemHeight(toolCall("tool", 1))).toBe(40);
expect(estimateStreamItemHeight(thought("thought", 2))).toBe(40);
});
it("uses a larger estimate for user messages with image attachments", () => {
const item: StreamItem = {
kind: "user_message",
id: "u-image",
text: "image",
timestamp: createTimestamp(1),
images: [
{
id: "att-1",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/tmp/screenshot.png",
fileName: "screenshot.png",
byteSize: 1024,
createdAt: Date.now(),
},
],
};
expect(estimateStreamItemHeight(item)).toBe(220);
});
it("uses cached assistant image metadata when available", () => {
clearAssistantImageMetadataCache();
setAssistantImageMetadata(
{
source: "https://example.com/tall.png",
},
{ width: 800, height: 1600 },
);
const item: StreamItem = {
kind: "assistant_message",
id: "a-image",
text: "Look at this\n\n![Screenshot](https://example.com/tall.png)",
timestamp: createTimestamp(2),
};
expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
});
});
describe("web virtualization test overrides", () => {
it("uses defaults unless explicit positive integer overrides are present", () => {
const globalWithOverrides = globalThis as typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
const previousThreshold = globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
const previousMounted = globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
try {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
expect(getWebPartialVirtualizationThreshold()).toBe(
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
);
expect(getWebMountedRecentStreamItems()).toBe(DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS);
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
expect(getWebPartialVirtualizationThreshold()).toBe(6);
expect(getWebMountedRecentStreamItems()).toBe(4);
} finally {
if (previousThreshold === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
} else {
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = previousThreshold;
}
if (previousMounted === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
} else {
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = previousMounted;
}
}
});
});
@@ -1,94 +0,0 @@
import type { StreamItem } from "@/types/stream";
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate";
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
function readPositiveIntegerOverride(value: unknown): number | null {
if (!Number.isFinite(value)) {
return null;
}
const normalized = Math.trunc(value as number);
return normalized > 0 ? normalized : null;
}
export function getWebPartialVirtualizationThreshold(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
);
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
}
export function getWebMountedRecentStreamItems(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
);
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
}
export interface IndexedStreamItem {
item: StreamItem;
index: number;
}
export interface WebVirtualizedHistoryWindow {
virtualizedEntries: IndexedStreamItem[];
mountedEntries: IndexedStreamItem[];
}
export function estimateStreamItemHeight(item: StreamItem): number {
switch (item.kind) {
case "user_message":
return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message":
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
case "tool_call":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "thought":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "todo_list":
return 144;
case "activity_log":
return 88;
case "compaction":
return 72;
default:
return 120;
}
}
export function findMountedWindowStart(input: {
items: StreamItem[];
minMountedCount: number;
}): number {
const { items, minMountedCount } = input;
if (items.length <= minMountedCount) {
return 0;
}
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
startIndex -= 1;
}
return startIndex;
}
export function splitWebVirtualizedHistory(input: {
entries: IndexedStreamItem[];
minMountedCount: number;
}): WebVirtualizedHistoryWindow {
const startIndex = findMountedWindowStart({
items: input.entries.map((entry) => entry.item),
minMountedCount: input.minMountedCount,
});
return {
virtualizedEntries: input.entries.slice(0, startIndex),
mountedEntries: input.entries.slice(startIndex),
};
}
@@ -1,398 +0,0 @@
import {
Fragment,
type ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
FlatList,
ActivityIndicator,
Keyboard,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import type { StreamItem } from "@/types/stream";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
import {
createStreamStrategy,
isNearBottomForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
} from "./stream-strategy";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
autoscrollToTopThreshold: 0,
});
const HISTORY_START_THRESHOLD_PX = 96;
function keyExtractor(item: { id: string }): string {
return item.id;
}
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
const {
agentId,
segments,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange,
onNearHistoryStart,
isLoadingOlderHistory,
hasOlderHistory,
scrollEnabled,
listStyle,
baseListContentContainerStyle,
strategy,
} = props;
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const flatListRef = useRef<FlatList<StreamItem>>(null);
const streamViewportMetricsRef = useRef({
containerKey: "native-virtualized",
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null as string | null,
contentMeasuredForKey: null as string | null,
});
const scrollOffsetYRef = useRef(0);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
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);
nativeViewportSettlingFrameIdRef.current = null;
}
}, []);
const markNativeViewportSettling = useCallback(() => {
clearNativeViewportSettling();
setIsNativeViewportSettling(true);
let remainingFrames = 4;
const tick = () => {
if (remainingFrames <= 0) {
nativeViewportSettlingFrameIdRef.current = null;
setIsNativeViewportSettling(false);
return;
}
remainingFrames -= 1;
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
};
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
}, [clearNativeViewportSettling]);
const bottomAnchorTransportBehavior = useMemo(
() =>
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: isNativeViewportSettling,
}),
[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 bottomAnchorController = useBottomAnchorController({
agentId,
routeRequest: routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
renderStrategy: "inverted-stream",
transportBehavior: bottomAnchorTransportBehavior,
getMeasurementState: () => streamViewportMetricsRef.current,
isNearBottom: () => {
const metrics = streamViewportMetricsRef.current;
return isNearBottomForStreamRenderStrategy({
strategy,
offsetY: metrics.offsetY,
threshold: 32,
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
});
},
scrollToBottom,
});
useEffect(() => {
streamViewportMetricsRef.current = {
containerKey: "native-virtualized",
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true;
});
return () => {
cancelAnimationFrame(frame);
};
}, [agentId, clearNativeViewportSettling]);
useEffect(() => {
const keyboardEvents = [
"keyboardWillShow",
"keyboardWillHide",
"keyboardDidShow",
"keyboardDidHide",
"keyboardWillChangeFrame",
"keyboardDidChangeFrame",
] as const;
const subscriptions = keyboardEvents.map((eventName) =>
Keyboard.addListener(eventName, () => {
markNativeViewportSettling();
}),
);
return () => {
for (const subscription of subscriptions) {
subscription.remove();
}
clearNativeViewportSettling();
};
}, [clearNativeViewportSettling, markNativeViewportSettling]);
useEffect(() => {
bottomAnchorController.prepareForStickyContentChange();
}, [bottomAnchorController, historyRows, segments.liveHead]);
useEffect(() => {
const handle: StreamViewportHandle = {
scrollToBottom: (reason = "jump-to-bottom") => {
bottomAnchorController.requestLocalAnchor({
agentId,
reason,
});
},
prepareForViewportChange: () => {
bottomAnchorController.prepareForStickyViewportChange();
markNativeViewportSettling();
},
};
viewportRef.current = handle;
return () => {
if (viewportRef.current === handle) {
viewportRef.current = null;
}
};
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
scrollOffsetYRef.current = contentOffset.y;
streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height),
viewportWidth: Math.max(0, layoutMeasurement.width),
viewportHeight: Math.max(0, layoutMeasurement.height),
containerKey: "native-virtualized",
offsetY: contentOffset.y,
viewportMeasuredForKey: "native-virtualized",
contentMeasuredForKey: "native-virtualized",
};
const nearBottom = isNearBottomForStreamRenderStrategy({
strategy,
offsetY: contentOffset.y,
threshold: 32,
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
onNearBottomChange(nearBottom);
const distanceFromOldestEdge =
streamViewportMetricsRef.current.contentHeight -
streamViewportMetricsRef.current.viewportHeight -
contentOffset.y;
if (
historyStartReadyRef.current &&
hasOlderHistory &&
distanceFromOldestEdge <= HISTORY_START_THRESHOLD_PX
) {
onNearHistoryStart();
}
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
programmaticScrollEventBudgetRef.current = 0;
bottomAnchorController.handleScrollNearBottomChange({
nextIsNearBottom: nearBottom,
scrollDelta: contentOffset.y - previousOffsetY,
});
}
});
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
const viewportChanged =
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: "native-virtualized",
viewportWidth,
viewportHeight,
viewportMeasuredForKey: "native-virtualized",
};
if (viewportChanged) {
markNativeViewportSettling();
}
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
previousViewportHeight,
viewportHeight,
});
});
const handleContentSizeChange = useStableEvent((_width: number, height: number) => {
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
const nextContentHeight = Math.max(0, height);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: "native-virtualized",
contentHeight: nextContentHeight,
contentMeasuredForKey: "native-virtualized",
};
bottomAnchorController.handleContentSizeChange({
previousContentHeight,
contentHeight: nextContentHeight,
});
});
const renderItem = useStableEvent(
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
const rendered = renderHistoryMountedRow(item, index, historyRows);
return (rendered ?? null) as ReactElement | null;
},
);
const liveHeaderContent = useMemo(() => {
const liveHeadRows = segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
));
const liveAuxiliary = renderLiveAuxiliary();
if (
liveHeadRows.length === 0 &&
!liveAuxiliary &&
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory
) {
return (listEmptyComponent ?? null) as ReactElement | null;
}
return (
<Fragment>
{liveHeadRows}
{liveAuxiliary}
</Fragment>
);
}, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]);
const historyFooterContent = useMemo(() => {
if (!isLoadingOlderHistory) {
return null;
}
return (
<View testID="load-older-history-spinner">
<ActivityIndicator size="small" />
</View>
);
}, [isLoadingOlderHistory]);
return (
<FlatList
ref={flatListRef}
data={historyRows}
renderItem={renderItem}
keyExtractor={keyExtractor}
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
contentContainerStyle={baseListContentContainerStyle}
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
initialNumToRender={40}
maxToRenderPerBatch={40}
updateCellsBatchingPeriod={0}
windowSize={21}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
inverted
/>
);
}
export function createNativeStreamStrategy(): StreamStrategy {
const strategy = createStreamStrategy({
render: (renderInput) => <NativeStreamViewport {...renderInput} strategy={strategy} />,
orderTailReverse: true,
orderHeadReverse: true,
assistantTurnTraversalStep: 1,
edgeSlot: "header",
flatListInverted: true,
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: false,
animateManualScrollToBottom: true,
useVirtualizedList: true,
isNearBottom: (input) => input.offsetY <= input.threshold,
getBottomOffset: () => 0,
});
return strategy;
}
@@ -1,14 +0,0 @@
import type { ResolveStreamRenderStrategyInput, StreamStrategy } from "./stream-strategy";
import { createNativeStreamStrategy } from "./stream-strategy-native";
import { createWebStreamStrategy } from "./stream-strategy-web";
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput,
): StreamStrategy {
if (input.platform === "web") {
return createWebStreamStrategy({
isMobileBreakpoint: input.isMobileBreakpoint,
});
}
return createNativeStreamStrategy();
}
@@ -1,210 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy";
import { createWebStreamStrategy } from "./stream-strategy-web";
vi.hoisted(() => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({
addEventListener: () => {},
addListener: () => {},
dispatchEvent: () => false,
matches: false,
media: "",
onchange: null,
removeEventListener: () => {},
removeListener: () => {},
}),
});
});
vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
function userMessage(index: number): StreamItem {
return {
kind: "user_message",
id: `message-${index}`,
text: `Message ${index}`,
timestamp: new Date(`2026-04-20T00:00:${String(index % 60).padStart(2, "0")}.000Z`),
};
}
const VIRTUAL_ROW_STYLE = { height: 24 };
function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
return {
renderHistoryVirtualizedRow: (item) => {
onRowRender();
return <div style={VIRTUAL_ROW_STYLE}>{item.id}</div>;
},
renderHistoryMountedRow: (item) => <div>{item.id}</div>,
renderLiveHeadRow: (item) => <div>{item.id}</div>,
renderLiveAuxiliary: () => null,
};
}
describe("createWebStreamStrategy", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let originalScrollTo: HTMLElement["scrollTo"] | undefined;
let originalOffsetHeight: PropertyDescriptor | undefined;
beforeEach(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
Object.defineProperty(globalThis, "ResizeObserver", {
value: class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
configurable: true,
});
originalScrollTo = HTMLElement.prototype.scrollTo;
HTMLElement.prototype.scrollTo = vi.fn();
originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get() {
return 24;
},
});
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
if (originalScrollTo) {
HTMLElement.prototype.scrollTo = originalScrollTo;
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
}
if (originalOffsetHeight) {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight);
} else {
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
}
vi.restoreAllMocks();
});
it("mounts virtualized history without recursive row measurement updates", () => {
const rowRenderCount = vi.fn();
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const historyVirtualized = Array.from({ length: 16 }, (_, index) => userMessage(index));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<>
{strategy.render({
agentId: "agent",
segments: {
historyVirtualized,
historyMounted: [],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: true,
hasMountedHistory: false,
hasLiveHead: false,
historyToHeadGap: 0,
},
renderers: createRenderers(rowRenderCount),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
})}
</>,
);
});
expect(rowRenderCount.mock.calls.length).toBeGreaterThan(0);
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
});
it("fires near-history-start when the user scrolls near the top", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<>
{strategy.render({
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
historyToHeadGap: 0,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: true,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
})}
</>,
);
});
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
expect(scrollContainer).toBeInstanceOf(HTMLElement);
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
act(() => {
scrollContainer?.dispatchEvent(new Event("scroll"));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
});
});
@@ -1,642 +0,0 @@
import React, {
Fragment,
type CSSProperties,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { ActivityIndicator } from "react-native";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import { estimateStreamItemHeight } from "./agent-stream-web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
import { createStreamStrategy } from "./stream-strategy";
interface CreateWebStreamStrategyInput {
isMobileBreakpoint: boolean;
}
type ScrollBehaviorLike = "auto" | "smooth";
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
const USER_SCROLL_DELTA_EPSILON = 1;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_THRESHOLD_PX = 96;
import { useWebElementScrollbar } from "./use-web-scrollbar";
const historyStartSlotStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 32,
paddingTop: 4,
paddingBottom: 8,
};
function isScrollContainerNearBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
): boolean {
const threshold = Number.isFinite(thresholdPx)
? Math.max(0, thresholdPx)
: AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) {
return true;
}
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
return distanceFromBottom <= threshold;
}
function isScrollContainerAtBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
): boolean {
return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX);
}
function scrollElementToBottom(
scrollContainer: HTMLElement,
behavior: ScrollBehaviorLike = "auto",
): void {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior,
});
}
function syncNearBottom(
scrollContainer: HTMLElement | null,
onNearBottomChange: (value: boolean) => void,
): boolean {
if (!scrollContainer) {
onNearBottomChange(true);
return true;
}
const nextValue = isScrollContainerNearBottom(scrollContainer);
onNearBottomChange(nextValue);
return nextValue;
}
function getScrollContainerDistanceFromBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
): number {
return scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop;
}
function isScrollContainerOverscrolledPastBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
): boolean {
return getScrollContainerDistanceFromBottom(scrollContainer) < 0;
}
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const {
segments,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange,
onNearHistoryStart,
isLoadingOlderHistory,
hasOlderHistory,
scrollEnabled,
isMobileBreakpoint,
} = props;
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
const handleScrollContainerRef = useCallback((node: HTMLElement | null) => {
scrollContainerRef.current = node;
}, []);
const handleContentRef = useCallback((node: HTMLElement | null) => {
contentRef.current = node;
}, []);
const [followOutput, setFollowOutputr] = useState(true);
const setFollowOutput = (value: boolean) => {
setFollowOutputr(value);
return value;
};
const followOutputRef = useRef(followOutput);
const lastKnownScrollTopRef = useRef(0);
const pendingUserScrollUpIntentRef = useRef(false);
const isPointerScrollActiveRef = useRef(false);
const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const historyStartReadyRef = useRef(false);
const showDesktopWebScrollbar = !isMobileBreakpoint;
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
enabled: showDesktopWebScrollbar,
contentRef,
});
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const {
renderHistoryVirtualizedRow,
renderHistoryMountedRow,
renderLiveHeadRow,
renderLiveAuxiliary,
} = renderers;
followOutputRef.current = followOutput;
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId;
const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady;
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
getScrollElement: () => scrollContainerRef.current,
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
estimateSize: (index: number) => {
const row = segments.historyVirtualized[index];
return row ? estimateStreamItemHeight(row) : 120;
},
measureElement: measureVirtualElement,
useAnimationFrameWithResizeObserver: true,
overscan: 8,
});
useEffect(() => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
};
return () => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
};
}, [rowVirtualizer]);
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualTotalSize = rowVirtualizer.getTotalSize();
const measureVirtualizedRowElement = useCallback(
(node: HTMLDivElement | null) => {
if (!node) {
rowVirtualizer.measureElement(null);
return;
}
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
const existingFrame = pendingFrames.get(node);
if (existingFrame !== undefined) {
window.cancelAnimationFrame(existingFrame);
}
const frame = window.requestAnimationFrame(() => {
pendingFrames.delete(node);
if (node.isConnected) {
rowVirtualizer.measureElement(node);
}
});
pendingFrames.set(node, frame);
},
[rowVirtualizer],
);
useEffect(() => {
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
return () => {
for (const frame of pendingFrames.values()) {
window.cancelAnimationFrame(frame);
}
pendingFrames.clear();
};
}, []);
const cancelPendingStickToBottom = useCallback(() => {
const pendingFrame = pendingAutoScrollFrameRef.current;
if (pendingFrame !== null) {
pendingAutoScrollFrameRef.current = null;
window.cancelAnimationFrame(pendingFrame);
}
const pendingTimeout = pendingAutoScrollTimeoutRef.current;
if (pendingTimeout !== null) {
pendingAutoScrollTimeoutRef.current = null;
window.clearTimeout(pendingTimeout);
}
}, []);
const scrollMessagesToBottom = useCallback(
(behavior: ScrollBehaviorLike = "auto") => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
return;
}
scrollElementToBottom(scrollContainer, behavior);
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
syncNearBottom(scrollContainer, onNearBottomChange);
},
[onNearBottomChange],
);
const scheduleStickToBottom = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
return;
}
if (pendingAutoScrollFrameRef.current !== null) {
return;
}
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
pendingAutoScrollFrameRef.current = null;
if (!followOutputRef.current) {
return;
}
scrollMessagesToBottom("auto");
});
}, [scrollMessagesToBottom]);
const forceStickToBottom = useCallback(() => {
cancelPendingStickToBottom();
scrollMessagesToBottom("auto");
scheduleStickToBottom();
}, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]);
const updateScrollMetrics = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
onNearBottomChange(true);
return;
}
syncNearBottom(scrollContainer, onNearBottomChange);
}, [onNearBottomChange]);
const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
const currentScrollTop = scrollContainer.scrollTop;
const isAtBottom = isScrollContainerAtBottom(scrollContainer);
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
if (!followOutputRef.current && isAtBottom) {
setFollowOutput(true);
pendingUserScrollUpIntentRef.current = false;
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
if (scrolledUp) {
cancelPendingStickToBottom();
setFollowOutput(false);
}
pendingUserScrollUpIntentRef.current = false;
} else if (followOutputRef.current && isPointerScrollActiveRef.current) {
if (scrolledUp) {
cancelPendingStickToBottom();
setFollowOutput(false);
}
}
lastKnownScrollTopRef.current = currentScrollTop;
updateScrollMetrics();
if (
historyStartReadyRef.current &&
hasOlderHistory &&
currentScrollTop <= HISTORY_START_THRESHOLD_PX
) {
onNearHistoryStart();
}
}, [cancelPendingStickToBottom, hasOlderHistory, onNearHistoryStart, updateScrollMetrics]);
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
});
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
};
}, [props.agentId]);
useLayoutEffect(() => {
if (!isActivationReady) {
return;
}
setFollowOutput(true);
forceStickToBottom();
const timeout = window.setTimeout(() => {
if (!followOutputRef.current) {
return;
}
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
if (isScrollContainerNearBottom(scrollContainer)) {
return;
}
scheduleStickToBottom();
}, WEB_BOTTOM_SETTLE_TIMEOUT_MS);
return () => {
window.clearTimeout(timeout);
};
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]);
useEffect(() => {
if (!followOutputRef.current) {
return;
}
scheduleStickToBottom();
}, [
scheduleStickToBottom,
segments.historyMounted,
segments.historyVirtualized,
segments.liveHead,
]);
useEffect(() => {
if (!followOutputRef.current || !shouldUseVirtualizer) {
return;
}
scheduleStickToBottom();
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]);
useEffect(() => {
updateScrollMetrics();
}, [
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
updateScrollMetrics,
virtualTotalSize,
]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
const contentNode = contentRef.current;
if (!scrollContainer || typeof ResizeObserver === "undefined") {
return;
}
updateScrollMetrics();
const observer = new ResizeObserver(() => {
updateScrollMetrics();
if (!followOutputRef.current) {
return;
}
scheduleStickToBottom();
});
observer.observe(scrollContainer);
if (contentNode) {
observer.observe(contentNode);
}
return () => {
observer.disconnect();
};
}, [scheduleStickToBottom, updateScrollMetrics]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
const handleWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
}
};
const handlePointerDown = () => {
isPointerScrollActiveRef.current = true;
};
const handlePointerUp = () => {
isPointerScrollActiveRef.current = false;
};
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) {
return;
}
lastTouchClientYRef.current = touch.clientY;
};
const handleTouchMove = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) {
return;
}
const previousTouchY = lastTouchClientYRef.current;
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
}
lastTouchClientYRef.current = touch.clientY;
};
const handleTouchEnd = () => {
lastTouchClientYRef.current = null;
};
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
scrollContainer.addEventListener("wheel", handleWheel, { passive: true });
scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true });
scrollContainer.addEventListener("pointerup", handlePointerUp, { passive: true });
scrollContainer.addEventListener("pointercancel", handlePointerUp, { passive: true });
scrollContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true });
scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true });
scrollContainer.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => {
scrollContainer.removeEventListener("scroll", handleDomScroll);
scrollContainer.removeEventListener("wheel", handleWheel);
scrollContainer.removeEventListener("pointerdown", handlePointerDown);
scrollContainer.removeEventListener("pointerup", handlePointerUp);
scrollContainer.removeEventListener("pointercancel", handlePointerUp);
scrollContainer.removeEventListener("touchstart", handleTouchStart);
scrollContainer.removeEventListener("touchmove", handleTouchMove);
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
};
}, [cancelPendingStickToBottom, handleDomScroll]);
useEffect(() => {
const handle: StreamViewportHandle = {
scrollToBottom: () => {
setFollowOutput(true);
cancelPendingStickToBottom();
forceStickToBottom();
},
prepareForViewportChange: () => {
if (!followOutputRef.current) {
return;
}
scheduleStickToBottom();
},
};
viewportRef.current = handle;
return () => {
if (viewportRef.current === handle) {
viewportRef.current = null;
}
cancelPendingStickToBottom();
};
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]);
const contentContainerStyle = useMemo((): CSSProperties => {
return {
display: "flex",
flexDirection: "column",
minHeight: "100%",
paddingTop: 16,
paddingBottom: 16,
paddingLeft: isMobileBreakpoint ? 8 : 16,
paddingRight: isMobileBreakpoint ? 8 : 16,
boxSizing: "border-box",
};
}, [isMobileBreakpoint]);
const scrollContainerStyle = useMemo((): CSSProperties => {
return {
flex: 1,
minHeight: 0,
overflowX: "hidden",
overflowY: scrollEnabled ? "auto" : "hidden",
overscrollBehaviorY: "contain",
};
}, [scrollEnabled]);
const virtualRowsContainerStyle = useMemo((): CSSProperties => {
return {
position: "relative",
width: "100%",
height: virtualTotalSize,
};
}, [virtualTotalSize]);
const renderVirtualRowStyle = useCallback(
(start: number): CSSProperties => ({
position: "absolute",
top: 0,
left: 0,
display: "flex",
flexDirection: "column",
width: "100%",
transform: `translateY(${start}px)`,
}),
[],
);
const mountedHistoryRows = useMemo(() => {
return segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
));
}, [renderHistoryMountedRow, segments.historyMounted]);
const liveHeadRows = useMemo(() => {
return segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
));
}, [renderLiveHeadRow, segments.liveHead]);
const liveAuxiliary = useMemo(() => {
return renderLiveAuxiliary();
}, [renderLiveAuxiliary]);
const historyStartSlot = useMemo(() => {
if (!isLoadingOlderHistory) {
return null;
}
return (
<div style={historyStartSlotStyle} data-testid="load-older-history-spinner">
<ActivityIndicator size="small" />
</div>
);
}, [isLoadingOlderHistory]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
!boundary.hasLiveHead &&
!liveAuxiliary;
return (
<>
<div
ref={handleScrollContainerRef}
data-testid="agent-chat-scroll"
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
style={scrollContainerStyle}
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
{shouldUseVirtualizer ? (
<div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => {
const item = segments.historyVirtualized[virtualRow.index];
if (!item) {
return null;
}
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
{renderHistoryVirtualizedRow(
item,
virtualRow.index,
segments.historyVirtualized,
)}
</div>
);
})}
</div>
) : null}
{mountedHistoryRows}
{boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? (
<HistoryToHeadSpacer height={boundary.historyToHeadGap} />
) : null}
{liveHeadRows}
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
</div>
{scrollbarOverlay}
</>
);
}
export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy {
return createStreamStrategy({
render: (renderInput) => (
<WebStreamViewport
key={renderInput.agentId}
{...renderInput}
isMobileBreakpoint={input.isMobileBreakpoint}
/>
),
orderTailReverse: false,
orderHeadReverse: false,
assistantTurnTraversalStep: -1,
edgeSlot: "footer",
flatListInverted: false,
overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
},
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: true,
animateManualScrollToBottom: false,
useVirtualizedList: false,
isNearBottom: (inputMetrics) => {
const distanceFromBottom = Math.max(
0,
inputMetrics.contentHeight - (inputMetrics.offsetY + inputMetrics.viewportHeight),
);
return distanceFromBottom <= inputMetrics.threshold;
},
getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight),
});
}
interface HistoryToHeadSpacerProps {
height: number;
}
function HistoryToHeadSpacer({ height }: HistoryToHeadSpacerProps) {
const spacerStyle = useMemo(() => ({ height, width: "100%" as const }), [height]);
return <div style={spacerStyle} />;
}
@@ -1,272 +0,0 @@
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,
} from "./use-bottom-anchor-controller";
type EdgeSlot = "header" | "footer";
type NeighborRelation = "above" | "below";
type AssistantTurnTraversalStep = -1 | 1;
export type MaintainVisibleContentPositionConfig = Readonly<{
minIndexForVisible: number;
autoscrollToTopThreshold: number;
}>;
export type BottomAnchorTransportBehavior = Readonly<{
verificationDelayFrames: number;
verificationRetryMode: "rescroll" | "recheck";
}>;
export interface StreamViewportMetrics {
contentHeight: number;
viewportHeight: number;
}
export type StreamNearBottomInput = StreamViewportMetrics & {
offsetY: number;
threshold: number;
};
export interface StreamEdgeSlotProps {
ListHeaderComponent?: ReactElement | ComponentType<unknown> | null;
ListHeaderComponentStyle?: StyleProp<ViewStyle>;
ListFooterComponent?: ReactElement | ComponentType<unknown> | null;
ListFooterComponentStyle?: StyleProp<ViewStyle>;
}
export interface StreamViewportHandle {
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
}
export interface StreamSegmentRenderers {
renderHistoryVirtualizedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
renderHistoryMountedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
renderLiveHeadRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
renderLiveAuxiliary: () => ReactNode;
}
export interface StreamRenderInput {
agentId: string;
segments: StreamRenderSegments;
boundary: StreamHistoryBoundary;
renderers: StreamSegmentRenderers;
listEmptyComponent: ReactNode;
viewportRef: RefObject<StreamViewportHandle | null>;
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
onNearBottomChange: (value: boolean) => void;
onNearHistoryStart: () => void;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
scrollEnabled: boolean;
listStyle: StyleProp<ViewStyle>;
baseListContentContainerStyle: StyleProp<ViewStyle>;
forwardListContentContainerStyle: StyleProp<ViewStyle>;
}
export interface ResolveStreamRenderStrategyInput {
platform: string;
isMobileBreakpoint: boolean;
}
export interface StreamStrategy {
render: (input: StreamRenderInput) => ReactNode;
orderTail: (streamItems: StreamItem[]) => StreamItem[];
orderHead: (streamHead: StreamItem[]) => StreamItem[];
getNeighborIndex: (index: number, relation: NeighborRelation) => number;
getNeighborItem: (
items: StreamItem[],
index: number,
relation: NeighborRelation,
) => StreamItem | undefined;
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
getEdgeSlotProps: (
component: ReactElement | ComponentType<unknown> | null,
gapSize: number,
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
shouldAnchorBottomOnContentSizeChange: () => boolean;
shouldAnimateManualScrollToBottom: () => boolean;
shouldUseVirtualizedList: () => boolean;
}
interface StreamStrategyConfig {
render: StreamStrategy["render"];
orderTailReverse: boolean;
orderHeadReverse: boolean;
assistantTurnTraversalStep: AssistantTurnTraversalStep;
edgeSlot: EdgeSlot;
flatListInverted: boolean;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
bottomAnchorTransportBehavior: BottomAnchorTransportBehavior;
disableParentScrollOnInlineDetailsExpansion: boolean;
anchorBottomOnContentSizeChange: boolean;
animateManualScrollToBottom: boolean;
useVirtualizedList: boolean;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
}
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
export function createStreamStrategy(config: StreamStrategyConfig): StreamStrategy {
return {
render: config.render,
orderTail: (streamItems) =>
config.orderTailReverse ? [...streamItems].toReversed() : streamItems,
orderHead: (streamHead) =>
config.orderHeadReverse ? [...streamHead].toReversed() : streamHead,
getNeighborIndex: (index, relation) =>
relation === "above"
? index + config.assistantTurnTraversalStep
: index - config.assistantTurnTraversalStep,
getNeighborItem: (items, index, relation) => {
const neighborIndex =
relation === "above"
? index + config.assistantTurnTraversalStep
: index - config.assistantTurnTraversalStep;
if (neighborIndex < 0 || neighborIndex >= items.length) {
return undefined;
}
return items[neighborIndex];
},
collectAssistantTurnContent: (items, startIndex) => {
const messages: string[] = [];
for (
let index = startIndex;
index >= 0 && index < items.length;
index += config.assistantTurnTraversalStep
) {
const currentItem = items[index];
if (currentItem.kind === "user_message") {
break;
}
if (currentItem.kind === "assistant_message") {
messages.push(currentItem.text);
}
}
return messages.toReversed().join("\n\n");
},
isNearBottom: (input) => config.isNearBottom(input),
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
getEdgeSlotProps: (component, gapSize) => {
if (config.edgeSlot === "header") {
return {
ListHeaderComponent: component,
ListHeaderComponentStyle: { marginBottom: gapSize },
};
}
return {
ListFooterComponent: component,
ListFooterComponentStyle: { marginTop: gapSize },
};
},
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
getFlatListInverted: () => config.flatListInverted,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
config.disableParentScrollOnInlineDetailsExpansion,
shouldAnchorBottomOnContentSizeChange: () => config.anchorBottomOnContentSizeChange,
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
shouldUseVirtualizedList: () => config.useVirtualizedList,
};
}
export function resolveBottomAnchorTransportBehavior(input: {
strategy: StreamStrategy;
isViewportSettling: boolean;
}): BottomAnchorTransportBehavior {
const baseBehavior = input.strategy.getBottomAnchorTransportBehavior();
if (!input.isViewportSettling || !input.strategy.getFlatListInverted()) {
return baseBehavior;
}
return {
verificationDelayFrames: Math.max(
baseBehavior.verificationDelayFrames,
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES,
),
verificationRetryMode: "recheck",
};
}
export function orderTailForStreamRenderStrategy(params: {
strategy: StreamStrategy;
streamItems: StreamItem[];
}): StreamItem[] {
return params.strategy.orderTail(params.streamItems);
}
export function orderHeadForStreamRenderStrategy(params: {
strategy: StreamStrategy;
streamHead: StreamItem[];
}): StreamItem[] {
return params.strategy.orderHead(params.streamHead);
}
export function getStreamNeighborIndex(params: {
strategy: StreamStrategy;
index: number;
relation: NeighborRelation;
}): number {
return params.strategy.getNeighborIndex(params.index, params.relation);
}
export function getStreamNeighborItem(params: {
strategy: StreamStrategy;
items: StreamItem[];
index: number;
relation: NeighborRelation;
}): StreamItem | undefined {
return params.strategy.getNeighborItem(params.items, params.index, params.relation);
}
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): string {
return params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
}
export function isNearBottomForStreamRenderStrategy(
params: StreamNearBottomInput & { strategy: StreamStrategy },
): boolean {
return params.strategy.isNearBottom({
offsetY: params.offsetY,
threshold: params.threshold,
contentHeight: params.contentHeight,
viewportHeight: params.viewportHeight,
});
}
export function getBottomOffsetForStreamRenderStrategy(
params: StreamViewportMetrics & {
strategy: StreamStrategy;
},
): number {
return params.strategy.getBottomOffset({
contentHeight: params.contentHeight,
viewportHeight: params.viewportHeight,
});
}
export function getStreamEdgeSlotProps(params: {
strategy: StreamStrategy;
component: ReactElement | ComponentType<unknown> | null;
gapSize: number;
}): StreamEdgeSlotProps {
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
}
@@ -1,688 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
__private__,
deriveBottomAnchorBlockedReason,
type BottomAnchorMode,
} from "./use-bottom-anchor-controller";
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
type MeasurementState = ReturnType<typeof createMeasurementState>;
function createMeasurementState(
overrides?: Partial<{
containerKey: string;
viewportWidth: number;
viewportHeight: number;
contentHeight: number;
offsetY: number;
viewportMeasuredForKey: string | null;
contentMeasuredForKey: string | null;
}>,
) {
return {
containerKey: "scroll-view",
viewportWidth: 0,
viewportHeight: 0,
contentHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
...overrides,
};
}
function createPendingRequest() {
return {
id: 1,
agentId: "agent-1",
reason: "initial-entry" as const,
requestKey: "route:agent-1",
};
}
function createFrameScheduler() {
let sequence = 0;
const tasks = new Map<
number,
{
cancelled: boolean;
remainingFrames: number;
callback: () => void;
kind: "attempt" | "verification";
}
>();
return {
schedule(params: {
kind: "attempt" | "verification";
callback: () => void;
delayFrames?: number;
}) {
const id = ++sequence;
tasks.set(id, {
cancelled: false,
remainingFrames: Math.max(0, params.delayFrames ?? 0),
callback: params.callback,
kind: params.kind,
});
return id;
},
cancel(handle: unknown) {
const task = tasks.get(handle as number);
if (task) {
task.cancelled = true;
}
},
flushFrame() {
const due: Array<() => void> = [];
for (const [id, task] of Array.from(tasks.entries())) {
if (task.cancelled) {
tasks.delete(id);
continue;
}
if (task.remainingFrames > 0) {
task.remainingFrames -= 1;
continue;
}
tasks.delete(id);
due.push(task.callback);
}
for (const callback of due) {
callback();
}
},
flushAll(limit = 20) {
for (let index = 0; index < limit && tasks.size > 0; index += 1) {
this.flushFrame();
}
},
};
}
function createDriverHarness(input?: {
transportBehavior?: BottomAnchorTransportBehavior;
isNearBottom?: boolean;
measurementState?: MeasurementState;
authoritativeReady?: boolean;
}) {
const scheduler = createFrameScheduler();
const measurementState =
input?.measurementState ??
createMeasurementState({
viewportWidth: 800,
viewportHeight: 480,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
});
const context = {
agentId: "agent-1",
authoritativeReady: input?.authoritativeReady ?? true,
renderStrategy: "forward-stream",
transportBehavior: input?.transportBehavior ?? {
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
},
measurementState,
nearBottom: input?.isNearBottom ?? true,
};
const scrollToBottom = vi.fn(() => {
context.nearBottom = true;
context.measurementState.offsetY = 720;
});
const modeChanges: BottomAnchorMode[] = [];
const driver = __private__.createBottomAnchorControllerDriver({
getAgentId: () => context.agentId,
getIsAuthoritativeHistoryReady: () => context.authoritativeReady,
getRenderStrategy: () => context.renderStrategy,
getTransportBehavior: () => context.transportBehavior,
getMeasurementState: () => context.measurementState,
isNearBottom: () => context.nearBottom,
scrollToBottom,
onModeChange: (mode) => {
modeChanges.push(mode);
},
scheduleFrame: (params) => scheduler.schedule(params),
cancelFrame: (handle) => scheduler.cancel(handle),
});
return {
context,
driver,
scheduler,
scrollToBottom,
modeChanges,
};
}
describe("deriveBottomAnchorBlockedReason", () => {
it("keeps initial-entry pending until history is ready and geometry is measurable", () => {
const pendingRequest = createPendingRequest();
expect(
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: false,
measurementState: createMeasurementState(),
pendingVerificationRequestId: null,
}),
).toBe("waiting_for_history_readiness");
expect(
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: true,
measurementState: createMeasurementState({
viewportHeight: 480,
viewportMeasuredForKey: "scroll-view",
}),
pendingVerificationRequestId: null,
}),
).toBe("waiting_for_measurable_content");
expect(
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: true,
measurementState: createMeasurementState({
viewportHeight: 480,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
pendingVerificationRequestId: pendingRequest.id,
}),
).toBe("waiting_for_post_layout_verification");
});
});
describe("bottom anchor controller driver", () => {
it("keeps initial-entry pending until authoritative history and current geometry exist", () => {
const harness = createDriverHarness({
authoritativeReady: false,
measurementState: createMeasurementState(),
});
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "initial-entry",
requestKey: "route:agent-1:initial-entry",
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: "waiting_for_history_readiness",
pendingRequest: {
reason: "initial-entry",
},
});
harness.context.authoritativeReady = true;
harness.context.measurementState.viewportHeight = 480;
harness.context.measurementState.contentHeight = 1200;
harness.context.measurementState.viewportMeasuredForKey = "scroll-view";
harness.context.measurementState.contentMeasuredForKey = "scroll-view";
harness.context.nearBottom = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("suppresses sticky maintenance while detached", () => {
const harness = createDriverHarness();
harness.driver.detachByUser();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1500,
});
harness.driver.handleViewportMetricsChange({
previousViewportWidth: 800,
viewportWidth: 640,
previousViewportHeight: 480,
viewportHeight: 420,
});
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollToBottom).not.toHaveBeenCalled();
});
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
const harness = createDriverHarness({
isNearBottom: false,
});
harness.driver.detachByUser();
harness.driver.requestLocalAnchor({
agentId: "agent-1",
reason: "jump-to-bottom",
});
harness.scheduler.flushAll();
expect(harness.modeChanges).toContain("detached");
expect(harness.modeChanges).toContain("sticky-bottom");
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
it("schedules sticky maintenance on viewport and content growth", () => {
const harness = createDriverHarness();
harness.driver.handleViewportMetricsChange({
previousViewportWidth: 800,
viewportWidth: 640,
previousViewportHeight: 480,
viewportHeight: 420,
});
harness.scheduler.flushAll();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1600,
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
});
it("keeps a pending request blocked when stale container measurements arrive", () => {
const harness = createDriverHarness({
measurementState: createMeasurementState({
containerKey: "web-partial-virtualized",
viewportHeight: 420,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
});
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_measurable_viewport",
pendingRequest: {
reason: "resume",
},
});
harness.context.measurementState.viewportMeasuredForKey = "web-partial-virtualized";
harness.context.measurementState.contentMeasuredForKey = "web-partial-virtualized";
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
it("uses delayed rechecks instead of repeated rescroll loops for native transport", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
isNearBottom: false,
});
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
});
harness.driver.requestLocalAnchor({
agentId: "agent-1",
reason: "jump-to-bottom",
});
harness.scheduler.flushFrame();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.nearBottom = true;
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
it("does not stay blocked on post-layout verification after a retry-scroll request", () => {
const harness = createDriverHarness({
measurementState: createMeasurementState({
containerKey: "web-partial-virtualized",
viewportWidth: 828,
viewportHeight: 846,
contentHeight: 14322,
offsetY: 0,
viewportMeasuredForKey: "web-partial-virtualized",
contentMeasuredForKey: "web-partial-virtualized",
}),
isNearBottom: false,
});
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 13476;
});
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushFrame();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.measurementState.contentHeight = 14804;
harness.context.nearBottom = false;
harness.driver.handleContentSizeChange({
previousContentHeight: 14322,
contentHeight: 14804,
});
harness.scheduler.flushFrame();
expect(harness.driver.getSnapshot()).toMatchObject({
pendingRequest: {
reason: "resume",
},
pendingVerification: {
requestId: 1,
retries: 1,
},
});
harness.scheduler.flushFrame();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_post_layout_verification",
pendingRequest: {
reason: "resume",
},
pendingVerification: {
requestId: 1,
},
});
});
it("does not fulfill a web partial-virtualized resume request before a confirmation pass", () => {
const harness = createDriverHarness({
measurementState: createMeasurementState({
containerKey: "web-partial-virtualized",
viewportWidth: 828,
viewportHeight: 846,
contentHeight: 14322,
offsetY: 0,
viewportMeasuredForKey: "web-partial-virtualized",
contentMeasuredForKey: "web-partial-virtualized",
}),
isNearBottom: false,
});
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = Math.max(
0,
harness.context.measurementState.contentHeight -
harness.context.measurementState.viewportHeight,
);
harness.context.nearBottom = true;
});
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume-confirmation",
});
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.driver.getSnapshot()).toMatchObject({
pendingRequest: {
reason: "resume",
},
blockedReason: "waiting_for_post_layout_verification",
});
harness.context.measurementState.contentHeight = 16230;
harness.context.nearBottom = false;
harness.driver.handleContentSizeChange({
previousContentHeight: 14322,
contentHeight: 16230,
});
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({
reason: "resume",
});
});
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 720;
});
harness.driver.handleViewportMetricsChange({
previousViewportWidth: 800,
viewportWidth: 800,
previousViewportHeight: 480,
viewportHeight: 420,
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 0,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: 0,
});
harness.scheduler.flushAll();
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 64,
});
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 900;
});
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 0,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: 0,
});
harness.scheduler.flushAll();
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 64,
});
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
});
describe("controller helper predicates", () => {
it("rejects stale container measurements during post-scroll verification", () => {
expect(
__private__.deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: true,
measurementState: createMeasurementState({
containerKey: "web-partial-virtualized",
viewportHeight: 420,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
}),
).toBe("waiting_for_measurable_viewport");
});
it("allows verification only after authoritative readiness and current geometry exist", () => {
expect(
__private__.deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: false,
measurementState: createMeasurementState({
containerKey: "scroll-view",
viewportHeight: 420,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
}),
).toBe("waiting_for_history_readiness");
expect(
__private__.deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: true,
measurementState: createMeasurementState({
containerKey: "scroll-view",
viewportHeight: 420,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
}),
).toBeNull();
});
it("suppresses auto-anchor helpers while detached", () => {
const mode: BottomAnchorMode = "detached";
expect(
__private__.shouldRestickOnContentChange({
mode,
previousContentHeight: 1000,
contentHeight: 1100,
}),
).toBe(false);
expect(
__private__.shouldRestickOnViewportChange({
mode,
previousViewportWidth: 800,
viewportWidth: 640,
previousViewportHeight: 400,
viewportHeight: 360,
}),
).toBe(false);
});
it("does not detach from sticky while a restick request is still pending", () => {
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 0,
hasPendingRequest: true,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: false,
}),
).toBe(false);
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 0,
hasPendingRequest: false,
hasPendingVerification: true,
hasUnverifiedStickyMeasurementChange: false,
}),
).toBe(false);
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 0,
hasPendingRequest: false,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: true,
}),
).toBe(false);
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 0,
hasPendingRequest: false,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: false,
}),
).toBe(true);
});
it("treats a large scroll delta as user detach even during an unverified sticky change", () => {
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 48,
hasPendingRequest: false,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: true,
}),
).toBe(true);
});
});
@@ -1,780 +0,0 @@
import { useEffect, useRef, useState } from "react";
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
export type BottomAnchorMode = "sticky-bottom" | "detached";
export interface BottomAnchorRouteRequest {
reason: "initial-entry" | "resume";
agentId: string;
requestKey: string;
}
export interface BottomAnchorLocalRequest {
reason: "jump-to-bottom" | "message-sent";
agentId: string;
}
export type BottomAnchorBlockedReason =
| "waiting_for_history_readiness"
| "waiting_for_measurable_viewport"
| "waiting_for_measurable_content"
| "waiting_for_post_layout_verification";
type BottomAnchorRequestReason =
| BottomAnchorRouteRequest["reason"]
| BottomAnchorLocalRequest["reason"];
interface BottomAnchorRequest {
id: number;
agentId: string;
reason: BottomAnchorRequestReason;
requestKey: string;
}
interface ControllerMeasurementState {
containerKey: string;
viewportWidth: number;
viewportHeight: number;
contentHeight: number;
offsetY: number;
viewportMeasuredForKey: string | null;
contentMeasuredForKey: string | null;
}
interface AttemptContext {
requestId: number | null;
retries: number;
confirmationPasses?: number;
startedContentHeight?: number;
startedOffsetY?: number;
startedViewportHeight?: number;
}
interface ScheduledFrameHandle {
cancelled: boolean;
rafId: number | null;
remainingFrames: number;
callback: () => void;
}
interface BottomAnchorControllerDriver {
destroy: () => void;
getSnapshot: () => {
mode: BottomAnchorMode;
pendingRequest: BottomAnchorRequest | null;
pendingVerification: AttemptContext | null;
blockedReason: BottomAnchorBlockedReason | null;
};
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
detachByUser: () => void;
handleViewportMetricsChange: (params: {
previousViewportWidth: number;
viewportWidth: number;
previousViewportHeight: number;
viewportHeight: number;
}) => void;
handleContentSizeChange: (params: {
previousContentHeight: number;
contentHeight: number;
}) => void;
prepareForStickyViewportChange: () => void;
prepareForStickyContentChange: () => void;
handleScrollNearBottomChange: (params: {
nextIsNearBottom: boolean;
scrollDelta: number;
}) => void;
notifyAuthoritativeHistoryMaybeChanged: () => void;
reevaluate: (animated?: boolean) => void;
}
interface CreateBottomAnchorControllerDriverInput {
getAgentId: () => string;
getIsAuthoritativeHistoryReady: () => boolean;
getRenderStrategy: () => string;
getTransportBehavior: () => BottomAnchorTransportBehavior;
getMeasurementState: () => ControllerMeasurementState;
isNearBottom: () => boolean;
scrollToBottom: (animated: boolean) => void;
onModeChange: (mode: BottomAnchorMode) => void;
scheduleFrame: (params: {
kind: "attempt" | "verification";
callback: () => void;
delayFrames?: number;
}) => unknown;
cancelFrame: (handle: unknown) => void;
}
const MAX_VERIFICATION_RETRIES = 3;
const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1;
const USER_SCROLL_AWAY_DELTA_PX = 24;
function scheduleAnimationFrameWithDelay(input: {
callback: () => void;
delayFrames?: number;
}): ScheduledFrameHandle {
const handle: ScheduledFrameHandle = {
cancelled: false,
rafId: null,
remainingFrames: Math.max(0, input.delayFrames ?? 0),
callback: input.callback,
};
const tick = () => {
if (handle.cancelled) {
return;
}
if (handle.remainingFrames > 0) {
handle.remainingFrames -= 1;
handle.rafId = requestAnimationFrame(tick);
return;
}
handle.rafId = null;
input.callback();
};
handle.rafId = requestAnimationFrame(tick);
return handle;
}
function cancelScheduledAnimationFrame(handle: unknown): void {
const scheduled = handle as ScheduledFrameHandle | null;
if (!scheduled) {
return;
}
scheduled.cancelled = true;
if (scheduled.rafId !== null) {
cancelAnimationFrame(scheduled.rafId);
scheduled.rafId = null;
}
}
function deriveVerificationBlockedReason(input: {
isAuthoritativeHistoryReady: boolean;
measurementState: ControllerMeasurementState;
}): Exclude<BottomAnchorBlockedReason, "waiting_for_post_layout_verification"> | null {
if (!input.isAuthoritativeHistoryReady) {
return "waiting_for_history_readiness";
}
if (
input.measurementState.viewportHeight <= 0 ||
input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey
) {
return "waiting_for_measurable_viewport";
}
if (
input.measurementState.contentHeight <= 0 ||
input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey
) {
return "waiting_for_measurable_content";
}
return null;
}
export function deriveBottomAnchorBlockedReason(input: {
pendingRequest: BottomAnchorRequest | null;
isAuthoritativeHistoryReady: boolean;
measurementState: ControllerMeasurementState;
pendingVerificationRequestId: number | null;
}): BottomAnchorBlockedReason | null {
if (!input.pendingRequest) {
return null;
}
if (!input.isAuthoritativeHistoryReady) {
return "waiting_for_history_readiness";
}
if (
input.measurementState.viewportHeight <= 0 ||
input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey
) {
return "waiting_for_measurable_viewport";
}
if (
input.measurementState.contentHeight <= 0 ||
input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey
) {
return "waiting_for_measurable_content";
}
if (input.pendingVerificationRequestId === input.pendingRequest.id) {
return "waiting_for_post_layout_verification";
}
return null;
}
function deriveRetryDisposition(input: {
mode: BottomAnchorMode;
retries: number;
verificationRetryMode: BottomAnchorTransportBehavior["verificationRetryMode"];
}): "retry-scroll" | "retry-verify" | "fail" {
if (input.mode !== "sticky-bottom" || input.retries >= MAX_VERIFICATION_RETRIES) {
return "fail";
}
return input.verificationRetryMode === "recheck" ? "retry-verify" : "retry-scroll";
}
function shouldRequireRouteRequestConfirmation(input: {
request: BottomAnchorRequest | null;
measurementState: ControllerMeasurementState;
confirmationPasses: number;
}): boolean {
if (!input.request) {
return false;
}
if (input.request.reason !== "initial-entry" && input.request.reason !== "resume") {
return false;
}
if (input.measurementState.containerKey !== "web-partial-virtualized") {
return false;
}
return input.confirmationPasses < 1;
}
function createBottomAnchorControllerDriver(
input: CreateBottomAnchorControllerDriverInput,
): BottomAnchorControllerDriver {
let requestSequence = 0;
let mode: BottomAnchorMode = "sticky-bottom";
let pendingRequest: BottomAnchorRequest | null = null;
let pendingVerification: AttemptContext | null = null;
let blockedReason: BottomAnchorBlockedReason | null = null;
let attemptHandle: unknown = null;
let verificationHandle: unknown = null;
let lastRouteRequestKey: string | null = null;
let stickyMeasurementRevision = 0;
let lastVerifiedStickyMeasurementRevision = 0;
const _getLogContext = (extra?: Record<string, unknown>) => {
const measurementState = input.getMeasurementState();
const distanceFromBottom = Math.max(
0,
measurementState.contentHeight - (measurementState.offsetY + measurementState.viewportHeight),
);
return {
agentId: input.getAgentId(),
requestReason: pendingRequest?.reason ?? null,
authoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
contentHeight: measurementState.contentHeight,
viewportHeight: measurementState.viewportHeight,
offset: measurementState.offsetY,
distanceFromBottom,
renderStrategy: input.getRenderStrategy(),
blockedReason,
mode,
containerKey: measurementState.containerKey,
transportBehavior: input.getTransportBehavior(),
...extra,
};
};
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
if (blockedReason === nextBlockedReason) {
return;
}
blockedReason = nextBlockedReason;
};
const setModeInternal = (nextMode: BottomAnchorMode) => {
if (mode === nextMode) {
return;
}
mode = nextMode;
input.onModeChange(nextMode);
if (nextMode === "detached") {
lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision;
}
};
const markStickyMeasurementChanged = () => {
stickyMeasurementRevision += 1;
};
const markStickyMeasurementVerified = () => {
lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision;
};
const cancelPendingAttempt = () => {
if (attemptHandle) {
input.cancelFrame(attemptHandle);
attemptHandle = null;
}
if (verificationHandle) {
input.cancelFrame(verificationHandle);
verificationHandle = null;
}
pendingVerification = null;
};
const cancelPendingRequest = (_reason: string) => {
const currentRequest = pendingRequest;
if (!currentRequest) {
cancelPendingAttempt();
setBlockedReason(null);
return;
}
pendingRequest = null;
cancelPendingAttempt();
setBlockedReason(null);
};
const deriveDriverBlockedReason = (measurementState: ControllerMeasurementState) =>
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
measurementState,
pendingVerificationRequestId:
verificationHandle !== null ? (pendingVerification?.requestId ?? null) : null,
});
const scheduleVerification = (attemptContext: AttemptContext, delayFramesOverride?: number) => {
const _scheduledMeasurementState = input.getMeasurementState();
if (verificationHandle) {
input.cancelFrame(verificationHandle);
}
verificationHandle = input.scheduleFrame({
kind: "verification",
delayFrames: delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames,
callback: () => {
verificationHandle = null;
const currentRequest = pendingRequest;
const isRequestAttempt = currentRequest && attemptContext.requestId === currentRequest.id;
const measurementState = input.getMeasurementState();
const verificationBlockedReason = deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
measurementState,
});
if (verificationBlockedReason) {
pendingVerification = attemptContext;
setBlockedReason(verificationBlockedReason);
return;
}
const verifiedNearBottom = input.isNearBottom();
const retryDisposition = verifiedNearBottom
? null
: deriveRetryDisposition({
mode,
retries: attemptContext.retries,
verificationRetryMode: input.getTransportBehavior().verificationRetryMode,
});
if (verifiedNearBottom) {
if (
isRequestAttempt &&
shouldRequireRouteRequestConfirmation({
request: currentRequest,
measurementState,
confirmationPasses: attemptContext.confirmationPasses ?? 0,
})
) {
pendingVerification = {
...attemptContext,
confirmationPasses: (attemptContext.confirmationPasses ?? 0) + 1,
};
setBlockedReason("waiting_for_post_layout_verification");
scheduleVerification(
pendingVerification,
WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES,
);
return;
}
pendingVerification = null;
markStickyMeasurementVerified();
if (isRequestAttempt) {
pendingRequest = null;
}
setBlockedReason(null);
return;
}
if (retryDisposition === "retry-verify") {
pendingVerification = {
requestId: attemptContext.requestId,
retries: attemptContext.retries + 1,
};
setBlockedReason("waiting_for_post_layout_verification");
scheduleVerification(pendingVerification);
return;
}
if (retryDisposition === "retry-scroll") {
pendingVerification = {
requestId: attemptContext.requestId,
retries: attemptContext.retries + 1,
};
evaluate(false, "retry_scroll");
return;
}
pendingVerification = null;
setBlockedReason(isRequestAttempt ? "waiting_for_post_layout_verification" : null);
},
});
};
const runAttempt = (animated: boolean) => {
const measurementState = input.getMeasurementState();
const attemptContext: AttemptContext = {
requestId: pendingRequest?.id ?? null,
retries: pendingVerification?.retries ?? 0,
startedContentHeight: measurementState.contentHeight,
startedOffsetY: measurementState.offsetY,
startedViewportHeight: measurementState.viewportHeight,
};
pendingVerification = attemptContext;
input.scrollToBottom(animated);
scheduleVerification(attemptContext);
setBlockedReason(deriveDriverBlockedReason(input.getMeasurementState()));
};
const evaluate = (
animated: boolean,
_reason:
| "request_created"
| "viewport_change"
| "content_size_change"
| "scroll_near_bottom_change"
| "history_readiness_change"
| "manual_reevaluate"
| "retry_scroll",
) => {
if (attemptHandle) {
return;
}
attemptHandle = input.scheduleFrame({
kind: "attempt",
callback: () => {
attemptHandle = null;
const measurementState = input.getMeasurementState();
const nextBlockedReason = deriveDriverBlockedReason(measurementState);
setBlockedReason(nextBlockedReason);
const shouldAttemptForPendingRequest =
pendingRequest !== null && nextBlockedReason === null;
const shouldAttemptForStickyVerification =
mode === "sticky-bottom" && pendingVerification !== null && nextBlockedReason === null;
if (!shouldAttemptForPendingRequest && !shouldAttemptForStickyVerification) {
return;
}
runAttempt(animated);
},
});
};
const createRequest = (request: BottomAnchorRouteRequest | BottomAnchorLocalRequest) => {
cancelPendingAttempt();
const nextRequest: BottomAnchorRequest = {
id: requestSequence + 1,
agentId: request.agentId,
reason: request.reason,
requestKey:
"requestKey" in request
? request.requestKey
: `${request.agentId}:${request.reason}:${requestSequence + 1}`,
};
requestSequence = nextRequest.id;
pendingRequest = nextRequest;
pendingVerification = null;
setModeInternal(
"requestKey" in request
? "sticky-bottom"
: __private__.deriveModeForLocalRequest({ reason: request.reason }),
);
evaluate(request.reason === "jump-to-bottom", "request_created");
};
return {
destroy() {
cancelPendingAttempt();
},
getSnapshot() {
return {
mode,
pendingRequest,
pendingVerification,
blockedReason,
};
},
resetForAgent() {
lastRouteRequestKey = null;
pendingRequest = null;
blockedReason = null;
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
applyRouteRequest(request) {
if (!request) {
return;
}
if (lastRouteRequestKey === request.requestKey) {
return;
}
lastRouteRequestKey = request.requestKey;
createRequest(request);
},
requestLocalAnchor(request) {
createRequest(request);
},
detachByUser() {
if (mode === "detached") {
return;
}
cancelPendingRequest("user_scrolled_away");
setModeInternal("detached");
},
handleViewportMetricsChange(params) {
if (
params.previousViewportWidth !== params.viewportWidth ||
params.previousViewportHeight !== params.viewportHeight
) {
markStickyMeasurementChanged();
}
const shouldRestick = __private__.shouldRestickOnViewportChange({
mode,
previousViewportWidth: params.previousViewportWidth,
viewportWidth: params.viewportWidth,
previousViewportHeight: params.previousViewportHeight,
viewportHeight: params.viewportHeight,
});
if (shouldRestick && !pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
}
if (shouldRestick || pendingRequest) {
evaluate(false, "viewport_change");
}
},
handleContentSizeChange(params) {
if (params.previousContentHeight !== params.contentHeight) {
markStickyMeasurementChanged();
}
const shouldRestick = __private__.shouldRestickOnContentChange({
mode,
previousContentHeight: params.previousContentHeight,
contentHeight: params.contentHeight,
});
if (shouldRestick && !pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
}
if (shouldRestick || pendingRequest) {
evaluate(false, "content_size_change");
}
},
prepareForStickyViewportChange() {
if (mode !== "sticky-bottom") {
return;
}
markStickyMeasurementChanged();
},
prepareForStickyContentChange() {
if (mode !== "sticky-bottom") {
return;
}
markStickyMeasurementChanged();
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
if (
nextIsNearBottom &&
mode === "sticky-bottom" &&
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
) {
markStickyMeasurementVerified();
}
const hasUnverifiedStickyMeasurementChange =
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision;
if (
__private__.shouldDetachFromScrollAway({
mode,
nextIsNearBottom,
scrollDelta,
hasPendingRequest: pendingRequest !== null,
hasPendingVerification: pendingVerification !== null,
hasUnverifiedStickyMeasurementChange,
})
) {
this.detachByUser();
return;
}
if (mode === "sticky-bottom" && !nextIsNearBottom && hasUnverifiedStickyMeasurementChange) {
if (!pendingRequest && !pendingVerification) {
pendingVerification = { requestId: null, retries: 0 };
}
evaluate(false, "scroll_near_bottom_change");
return;
}
if (nextIsNearBottom && pendingRequest) {
evaluate(false, "scroll_near_bottom_change");
}
},
notifyAuthoritativeHistoryMaybeChanged() {
if (!pendingVerification && !pendingRequest) {
return;
}
evaluate(false, "history_readiness_change");
},
reevaluate(animated = false) {
evaluate(animated, "manual_reevaluate");
},
};
}
export const __private__ = {
createBottomAnchorControllerDriver,
deriveBottomAnchorBlockedReason,
deriveVerificationBlockedReason,
deriveRetryDisposition,
deriveModeForLocalRequest(_input: {
reason: BottomAnchorLocalRequest["reason"];
}): BottomAnchorMode {
return "sticky-bottom";
},
shouldRestickOnViewportChange(input: {
mode: BottomAnchorMode;
previousViewportWidth: number;
viewportWidth: number;
previousViewportHeight: number;
viewportHeight: number;
}): boolean {
return (
input.mode === "sticky-bottom" &&
((input.previousViewportHeight > 0 &&
input.viewportHeight > 0 &&
input.previousViewportHeight !== input.viewportHeight) ||
(input.previousViewportWidth > 0 &&
input.viewportWidth > 0 &&
input.previousViewportWidth !== input.viewportWidth))
);
},
shouldRestickOnContentChange(input: {
mode: BottomAnchorMode;
previousContentHeight: number;
contentHeight: number;
}): boolean {
return (
input.mode === "sticky-bottom" &&
input.previousContentHeight > 0 &&
input.contentHeight > input.previousContentHeight
);
},
shouldDetachFromScrollAway(input: {
mode: BottomAnchorMode;
nextIsNearBottom: boolean;
scrollDelta: number;
hasPendingRequest: boolean;
hasPendingVerification: boolean;
hasUnverifiedStickyMeasurementChange: boolean;
}): boolean {
const scrolledAwayIntentionally = Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX;
return (
input.mode === "sticky-bottom" &&
!input.nextIsNearBottom &&
!input.hasPendingRequest &&
!input.hasPendingVerification &&
(!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally)
);
},
};
export function useBottomAnchorController(input: {
agentId: string;
routeRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
renderStrategy: string;
transportBehavior: BottomAnchorTransportBehavior;
getMeasurementState: () => ControllerMeasurementState;
isNearBottom: () => boolean;
scrollToBottom: (animated: boolean) => void;
}) {
const [mode, setMode] = useState<BottomAnchorMode>("sticky-bottom");
const agentIdRef = useRef(input.agentId);
const readinessRef = useRef(input.isAuthoritativeHistoryReady);
const renderStrategyRef = useRef(input.renderStrategy);
const transportBehaviorRef = useRef(input.transportBehavior);
const getMeasurementStateRef = useRef(input.getMeasurementState);
const isNearBottomRef = useRef(input.isNearBottom);
const scrollToBottomRef = useRef(input.scrollToBottom);
const driverRef = useRef<BottomAnchorControllerDriver | null>(null);
agentIdRef.current = input.agentId;
readinessRef.current = input.isAuthoritativeHistoryReady;
renderStrategyRef.current = input.renderStrategy;
transportBehaviorRef.current = input.transportBehavior;
getMeasurementStateRef.current = input.getMeasurementState;
isNearBottomRef.current = input.isNearBottom;
scrollToBottomRef.current = input.scrollToBottom;
if (!driverRef.current) {
driverRef.current = __private__.createBottomAnchorControllerDriver({
getAgentId: () => agentIdRef.current,
getIsAuthoritativeHistoryReady: () => readinessRef.current,
getRenderStrategy: () => renderStrategyRef.current,
getTransportBehavior: () => transportBehaviorRef.current,
getMeasurementState: () => getMeasurementStateRef.current(),
isNearBottom: () => isNearBottomRef.current(),
scrollToBottom: (animated) => scrollToBottomRef.current(animated),
onModeChange: (nextMode) => setMode(nextMode),
scheduleFrame: ({ callback, delayFrames }) =>
scheduleAnimationFrameWithDelay({ callback, delayFrames }),
cancelFrame: (handle) => cancelScheduledAnimationFrame(handle),
});
}
useEffect(() => {
driverRef.current?.resetForAgent();
}, [input.agentId]);
useEffect(() => {
driverRef.current?.applyRouteRequest(input.routeRequest);
}, [input.routeRequest]);
useEffect(() => {
driverRef.current?.notifyAuthoritativeHistoryMaybeChanged();
}, [input.isAuthoritativeHistoryReady]);
useEffect(() => {
return () => {
driverRef.current?.destroy();
driverRef.current = null;
};
}, []);
return {
mode,
requestLocalAnchor(request: BottomAnchorLocalRequest) {
driverRef.current?.requestLocalAnchor(request);
},
detachByUser() {
driverRef.current?.detachByUser();
},
handleViewportLayout() {},
handleViewportMetricsChange(params: {
previousViewportWidth: number;
viewportWidth: number;
previousViewportHeight: number;
viewportHeight: number;
}) {
driverRef.current?.handleViewportMetricsChange(params);
},
handleContentSizeChange(params: { previousContentHeight: number; contentHeight: number }) {
driverRef.current?.handleContentSizeChange(params);
},
prepareForStickyViewportChange() {
driverRef.current?.prepareForStickyViewportChange();
},
prepareForStickyContentChange() {
driverRef.current?.prepareForStickyContentChange();
},
handleScrollNearBottomChange(params: { nextIsNearBottom: boolean; scrollDelta: number }) {
driverRef.current?.handleScrollNearBottomChange(params);
},
reevaluate(animated = false) {
driverRef.current?.reevaluate(animated);
},
};
}