refactor(app): organize agent stream rendering
This commit is contained in:
@@ -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",
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user