refactor(app): organize agent stream rendering
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
getBottomOffsetForStreamRenderStrategy,
|
||||
getFrameChildOrderForStreamRenderStrategy,
|
||||
getHistoryLiveBoundaryIndexForStreamRenderStrategy,
|
||||
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy,
|
||||
getStreamEdgeSlotProps,
|
||||
getStreamNeighborIndex,
|
||||
getStreamNeighborItem,
|
||||
isNearBottomForStreamRenderStrategy,
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
resolveBottomAnchorTransportBehavior,
|
||||
} from "./strategy";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout strategy edges", () => {
|
||||
const streamItems: StreamItem[] = [
|
||||
userMessage("u1", "user-1", 1),
|
||||
assistantMessage("a1", "assistant-1", 2),
|
||||
];
|
||||
|
||||
it("uses the newest history edge as the history/live boundary", () => {
|
||||
const forward = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const inverted = resolveStreamRenderStrategy({
|
||||
platform: "android",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
const forwardHistory = orderTailForStreamRenderStrategy({ strategy: forward, streamItems });
|
||||
const invertedHistory = orderTailForStreamRenderStrategy({ strategy: inverted, streamItems });
|
||||
|
||||
expect(
|
||||
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
|
||||
strategy: forward,
|
||||
history: forwardHistory,
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
|
||||
strategy: inverted,
|
||||
history: invertedHistory,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the oldest live-head edge as the live-head/history boundary", () => {
|
||||
const forward = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const inverted = resolveStreamRenderStrategy({
|
||||
platform: "ios",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
const forwardHead = orderHeadForStreamRenderStrategy({
|
||||
strategy: forward,
|
||||
streamHead: streamItems,
|
||||
});
|
||||
const invertedHead = orderHeadForStreamRenderStrategy({
|
||||
strategy: inverted,
|
||||
streamHead: streamItems,
|
||||
});
|
||||
|
||||
expect(
|
||||
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
|
||||
strategy: forward,
|
||||
liveHead: forwardHead,
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
|
||||
strategy: inverted,
|
||||
liveHead: invertedHead,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("names the frame child order needed by native inverted cells", () => {
|
||||
const forward = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const inverted = resolveStreamRenderStrategy({
|
||||
platform: "android",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: forward })).toBe(
|
||||
"content-then-footer",
|
||||
);
|
||||
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: inverted })).toBe(
|
||||
"footer-then-content",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user