refactor(app): use strategy-driven stream renderer on web (#80)
This commit is contained in:
@@ -0,0 +1,285 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import {
|
||||||
|
collectAssistantTurnContentForStreamRenderStrategy,
|
||||||
|
getBottomOffsetForStreamRenderStrategy,
|
||||||
|
getStreamEdgeSlotProps,
|
||||||
|
getStreamNeighborIndex,
|
||||||
|
getStreamNeighborItem,
|
||||||
|
isNearBottomForStreamRenderStrategy,
|
||||||
|
orderHeadForStreamRenderStrategy,
|
||||||
|
orderTailForStreamRenderStrategy,
|
||||||
|
resolveStreamRenderStrategy,
|
||||||
|
} from "./agent-stream-render-strategy";
|
||||||
|
|
||||||
|
function createTimestamp(seed: number): Date {
|
||||||
|
return new Date(`2026-01-01T00:00:0${seed}.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMessage(id: string, text: string, seed: number): StreamItem {
|
||||||
|
return {
|
||||||
|
kind: "user_message",
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
timestamp: createTimestamp(seed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantMessage(id: string, text: string, seed: number): StreamItem {
|
||||||
|
return {
|
||||||
|
kind: "assistant_message",
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
timestamp: createTimestamp(seed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveStreamRenderStrategy", () => {
|
||||||
|
it("uses forward_stream on web", () => {
|
||||||
|
const strategy = resolveStreamRenderStrategy({
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(strategy.kind).toBe("forward_stream");
|
||||||
|
expect(strategy.flatListInverted).toBe(false);
|
||||||
|
expect(strategy.overlayScrollbarInverted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses inverted_stream on native", () => {
|
||||||
|
const strategy = resolveStreamRenderStrategy({
|
||||||
|
platform: "ios",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(strategy.kind).toBe("inverted_stream");
|
||||||
|
expect(strategy.flatListInverted).toBe(true);
|
||||||
|
expect(strategy.overlayScrollbarInverted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stream ordering", () => {
|
||||||
|
const streamItems: StreamItem[] = [
|
||||||
|
userMessage("u1", "user-1", 1),
|
||||||
|
assistantMessage("a1", "assistant-1", 2),
|
||||||
|
assistantMessage("a2", "assistant-2", 3),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("keeps forward_stream order unchanged for tail and head", () => {
|
||||||
|
const strategy = resolveStreamRenderStrategy({
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tail = orderTailForStreamRenderStrategy({ strategy, streamItems });
|
||||||
|
const head = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems });
|
||||||
|
|
||||||
|
expect(tail.map((item) => item.id)).toEqual(["u1", "a1", "a2"]);
|
||||||
|
expect(head.map((item) => item.id)).toEqual(["u1", "a1", "a2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverses inverted_stream order for tail and head", () => {
|
||||||
|
const strategy = resolveStreamRenderStrategy({
|
||||||
|
platform: "android",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tail = orderTailForStreamRenderStrategy({ strategy, streamItems });
|
||||||
|
const head = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems });
|
||||||
|
|
||||||
|
expect(tail.map((item) => item.id)).toEqual(["a2", "a1", "u1"]);
|
||||||
|
expect(head.map((item) => item.id)).toEqual(["a2", "a1", "u1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("neighbor and traversal semantics", () => {
|
||||||
|
it("maps above/below indices for forward and inverted streams", () => {
|
||||||
|
const forward = resolveStreamRenderStrategy({
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
const inverted = resolveStreamRenderStrategy({
|
||||||
|
platform: "ios",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getStreamNeighborIndex({ strategy: forward, index: 3, relation: "above" })).toBe(2);
|
||||||
|
expect(getStreamNeighborIndex({ strategy: forward, index: 3, relation: "below" })).toBe(4);
|
||||||
|
expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "above" })).toBe(4);
|
||||||
|
expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "below" })).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects assistant turn content with strategy traversal direction", () => {
|
||||||
|
const chronological: StreamItem[] = [
|
||||||
|
userMessage("u1", "user-1", 1),
|
||||||
|
assistantMessage("a1", "assistant-1", 2),
|
||||||
|
assistantMessage("a2", "assistant-2", 3),
|
||||||
|
userMessage("u2", "user-2", 4),
|
||||||
|
];
|
||||||
|
|
||||||
|
const forward = resolveStreamRenderStrategy({
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
const forwardStartIndex = chronological.findIndex((item) => item.id === "a2");
|
||||||
|
expect(
|
||||||
|
collectAssistantTurnContentForStreamRenderStrategy({
|
||||||
|
strategy: forward,
|
||||||
|
items: chronological,
|
||||||
|
startIndex: forwardStartIndex,
|
||||||
|
})
|
||||||
|
).toBe("assistant-1\n\nassistant-2");
|
||||||
|
|
||||||
|
const inverted = resolveStreamRenderStrategy({
|
||||||
|
platform: "android",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
const invertedItems = orderTailForStreamRenderStrategy({
|
||||||
|
strategy: inverted,
|
||||||
|
streamItems: chronological,
|
||||||
|
});
|
||||||
|
const invertedStartIndex = invertedItems.findIndex((item) => item.id === "a2");
|
||||||
|
expect(
|
||||||
|
collectAssistantTurnContentForStreamRenderStrategy({
|
||||||
|
strategy: inverted,
|
||||||
|
items: invertedItems,
|
||||||
|
startIndex: invertedStartIndex,
|
||||||
|
})
|
||||||
|
).toBe("assistant-1\n\nassistant-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined neighbor when index would be out of bounds", () => {
|
||||||
|
const forward = resolveStreamRenderStrategy({
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
const items: StreamItem[] = [userMessage("u1", "user-1", 1)];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getStreamNeighborItem({
|
||||||
|
strategy: forward,
|
||||||
|
items,
|
||||||
|
index: 0,
|
||||||
|
relation: "above",
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
getStreamNeighborItem({
|
||||||
|
strategy: forward,
|
||||||
|
items,
|
||||||
|
index: 0,
|
||||||
|
relation: "below",
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scroll/bottom calculations", () => {
|
||||||
|
it("computes near-bottom using forward_stream distance-from-bottom math", () => {
|
||||||
|
const strategy = resolveStreamRenderStrategy({
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isNearBottomForStreamRenderStrategy({
|
||||||
|
strategy,
|
||||||
|
offsetY: 680,
|
||||||
|
viewportHeight: 300,
|
||||||
|
contentHeight: 1000,
|
||||||
|
threshold: 24,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isNearBottomForStreamRenderStrategy({
|
||||||
|
strategy,
|
||||||
|
offsetY: 600,
|
||||||
|
viewportHeight: 300,
|
||||||
|
contentHeight: 1000,
|
||||||
|
threshold: 24,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes near-bottom and scroll-to-bottom offset for inverted_stream", () => {
|
||||||
|
const strategy = resolveStreamRenderStrategy({
|
||||||
|
platform: "ios",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isNearBottomForStreamRenderStrategy({
|
||||||
|
strategy,
|
||||||
|
offsetY: 12,
|
||||||
|
viewportHeight: 300,
|
||||||
|
contentHeight: 1000,
|
||||||
|
threshold: 24,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isNearBottomForStreamRenderStrategy({
|
||||||
|
strategy,
|
||||||
|
offsetY: 40,
|
||||||
|
viewportHeight: 300,
|
||||||
|
contentHeight: 1000,
|
||||||
|
threshold: 24,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
getBottomOffsetForStreamRenderStrategy({
|
||||||
|
strategy,
|
||||||
|
viewportHeight: 300,
|
||||||
|
contentHeight: 1000,
|
||||||
|
})
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps scroll-to-bottom to max offset for forward_stream", () => {
|
||||||
|
const strategy = resolveStreamRenderStrategy({
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getBottomOffsetForStreamRenderStrategy({
|
||||||
|
strategy,
|
||||||
|
viewportHeight: 320,
|
||||||
|
contentHeight: 1000,
|
||||||
|
})
|
||||||
|
).toBe(680);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge slot semantics", () => {
|
||||||
|
it("uses footer slot for forward_stream and header slot for inverted_stream", () => {
|
||||||
|
const EdgeSlot = () => null;
|
||||||
|
const forward = resolveStreamRenderStrategy({
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
const inverted = resolveStreamRenderStrategy({
|
||||||
|
platform: "android",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwardProps = getStreamEdgeSlotProps({
|
||||||
|
strategy: forward,
|
||||||
|
component: EdgeSlot,
|
||||||
|
gapSize: 4,
|
||||||
|
});
|
||||||
|
const invertedProps = getStreamEdgeSlotProps({
|
||||||
|
strategy: inverted,
|
||||||
|
component: EdgeSlot,
|
||||||
|
gapSize: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(forwardProps).toEqual({
|
||||||
|
ListFooterComponent: EdgeSlot,
|
||||||
|
ListFooterComponentStyle: { marginTop: 4 },
|
||||||
|
});
|
||||||
|
expect(invertedProps).toEqual({
|
||||||
|
ListHeaderComponent: EdgeSlot,
|
||||||
|
ListHeaderComponentStyle: { marginBottom: 4 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import type { ComponentType, ReactElement } from "react";
|
||||||
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
|
||||||
|
type EdgeSlot = "header" | "footer";
|
||||||
|
type NeighborRelation = "above" | "below";
|
||||||
|
type AssistantTurnTraversalStep = -1 | 1;
|
||||||
|
|
||||||
|
export type MaintainVisibleContentPositionConfig = Readonly<{
|
||||||
|
minIndexForVisible: number;
|
||||||
|
autoscrollToTopThreshold: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type StreamRenderStrategyBase = {
|
||||||
|
kind: "inverted_stream" | "forward_stream";
|
||||||
|
flatListInverted: boolean;
|
||||||
|
edgeSlot: EdgeSlot;
|
||||||
|
overlayScrollbarInverted: boolean;
|
||||||
|
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
|
||||||
|
assistantTurnTraversalStep: AssistantTurnTraversalStep;
|
||||||
|
disableParentScrollOnInlineDetailsExpansion: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InvertedStreamRenderStrategy = StreamRenderStrategyBase & {
|
||||||
|
kind: "inverted_stream";
|
||||||
|
flatListInverted: true;
|
||||||
|
edgeSlot: "header";
|
||||||
|
overlayScrollbarInverted: true;
|
||||||
|
maintainVisibleContentPosition: MaintainVisibleContentPositionConfig;
|
||||||
|
assistantTurnTraversalStep: 1;
|
||||||
|
disableParentScrollOnInlineDetailsExpansion: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForwardStreamRenderStrategy = StreamRenderStrategyBase & {
|
||||||
|
kind: "forward_stream";
|
||||||
|
flatListInverted: false;
|
||||||
|
edgeSlot: "footer";
|
||||||
|
overlayScrollbarInverted: false;
|
||||||
|
maintainVisibleContentPosition?: undefined;
|
||||||
|
assistantTurnTraversalStep: -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamRenderStrategy =
|
||||||
|
| InvertedStreamRenderStrategy
|
||||||
|
| ForwardStreamRenderStrategy;
|
||||||
|
|
||||||
|
export type ResolveStreamRenderStrategyInput = {
|
||||||
|
platform: string;
|
||||||
|
isMobileBreakpoint: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamViewportMetrics = {
|
||||||
|
contentHeight: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamNearBottomInput = StreamViewportMetrics & {
|
||||||
|
offsetY: number;
|
||||||
|
threshold: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamEdgeSlotProps = {
|
||||||
|
ListHeaderComponent?: ReactElement | ComponentType<any> | null;
|
||||||
|
ListHeaderComponentStyle?: StyleProp<ViewStyle>;
|
||||||
|
ListFooterComponent?: ReactElement | ComponentType<any> | null;
|
||||||
|
ListFooterComponentStyle?: StyleProp<ViewStyle>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION: MaintainVisibleContentPositionConfig =
|
||||||
|
Object.freeze({
|
||||||
|
minIndexForVisible: 0,
|
||||||
|
autoscrollToTopThreshold: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const INVERTED_STREAM_STRATEGY: InvertedStreamRenderStrategy = {
|
||||||
|
kind: "inverted_stream",
|
||||||
|
flatListInverted: true,
|
||||||
|
edgeSlot: "header",
|
||||||
|
overlayScrollbarInverted: true,
|
||||||
|
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
|
||||||
|
assistantTurnTraversalStep: 1,
|
||||||
|
disableParentScrollOnInlineDetailsExpansion: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORWARD_STREAM_STRATEGY_DESKTOP: ForwardStreamRenderStrategy = {
|
||||||
|
kind: "forward_stream",
|
||||||
|
flatListInverted: false,
|
||||||
|
edgeSlot: "footer",
|
||||||
|
overlayScrollbarInverted: false,
|
||||||
|
assistantTurnTraversalStep: -1,
|
||||||
|
disableParentScrollOnInlineDetailsExpansion: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORWARD_STREAM_STRATEGY_MOBILE: ForwardStreamRenderStrategy = {
|
||||||
|
...FORWARD_STREAM_STRATEGY_DESKTOP,
|
||||||
|
disableParentScrollOnInlineDetailsExpansion: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveStreamRenderStrategy(
|
||||||
|
input: ResolveStreamRenderStrategyInput
|
||||||
|
): StreamRenderStrategy {
|
||||||
|
if (input.platform === "web") {
|
||||||
|
return input.isMobileBreakpoint
|
||||||
|
? FORWARD_STREAM_STRATEGY_MOBILE
|
||||||
|
: FORWARD_STREAM_STRATEGY_DESKTOP;
|
||||||
|
}
|
||||||
|
return INVERTED_STREAM_STRATEGY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderTailForStreamRenderStrategy(params: {
|
||||||
|
strategy: StreamRenderStrategy;
|
||||||
|
streamItems: StreamItem[];
|
||||||
|
}): StreamItem[] {
|
||||||
|
const { strategy, streamItems } = params;
|
||||||
|
return strategy.kind === "inverted_stream"
|
||||||
|
? [...streamItems].reverse()
|
||||||
|
: streamItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderHeadForStreamRenderStrategy(params: {
|
||||||
|
strategy: StreamRenderStrategy;
|
||||||
|
streamHead: StreamItem[];
|
||||||
|
}): StreamItem[] {
|
||||||
|
const { strategy, streamHead } = params;
|
||||||
|
return strategy.kind === "inverted_stream"
|
||||||
|
? [...streamHead].reverse()
|
||||||
|
: streamHead;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStreamNeighborIndex(params: {
|
||||||
|
strategy: StreamRenderStrategy;
|
||||||
|
index: number;
|
||||||
|
relation: NeighborRelation;
|
||||||
|
}): number {
|
||||||
|
const { strategy, index, relation } = params;
|
||||||
|
if (strategy.kind === "inverted_stream") {
|
||||||
|
return relation === "above" ? index + 1 : index - 1;
|
||||||
|
}
|
||||||
|
return relation === "above" ? index - 1 : index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStreamNeighborItem(params: {
|
||||||
|
strategy: StreamRenderStrategy;
|
||||||
|
items: StreamItem[];
|
||||||
|
index: number;
|
||||||
|
relation: NeighborRelation;
|
||||||
|
}): StreamItem | undefined {
|
||||||
|
const nextIndex = getStreamNeighborIndex(params);
|
||||||
|
if (nextIndex < 0 || nextIndex >= params.items.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return params.items[nextIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
||||||
|
strategy: StreamRenderStrategy;
|
||||||
|
items: StreamItem[];
|
||||||
|
startIndex: number;
|
||||||
|
}): string {
|
||||||
|
const { strategy, items, startIndex } = params;
|
||||||
|
const messages: string[] = [];
|
||||||
|
|
||||||
|
for (
|
||||||
|
let index = startIndex;
|
||||||
|
index >= 0 && index < items.length;
|
||||||
|
index += strategy.assistantTurnTraversalStep
|
||||||
|
) {
|
||||||
|
const currentItem = items[index];
|
||||||
|
if (currentItem.kind === "user_message") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (currentItem.kind === "assistant_message") {
|
||||||
|
messages.push(currentItem.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.reverse().join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNearBottomForStreamRenderStrategy(
|
||||||
|
params: StreamNearBottomInput & { strategy: StreamRenderStrategy }
|
||||||
|
): boolean {
|
||||||
|
const { strategy, threshold, offsetY } = params;
|
||||||
|
if (strategy.kind === "inverted_stream") {
|
||||||
|
return offsetY <= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceFromBottom = Math.max(
|
||||||
|
0,
|
||||||
|
params.contentHeight - (offsetY + params.viewportHeight)
|
||||||
|
);
|
||||||
|
return distanceFromBottom <= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBottomOffsetForStreamRenderStrategy(params: StreamViewportMetrics & {
|
||||||
|
strategy: StreamRenderStrategy;
|
||||||
|
}): number {
|
||||||
|
const { strategy } = params;
|
||||||
|
if (strategy.kind === "inverted_stream") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, params.contentHeight - params.viewportHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStreamEdgeSlotProps(params: {
|
||||||
|
strategy: StreamRenderStrategy;
|
||||||
|
component: ReactElement | ComponentType<any> | null;
|
||||||
|
gapSize: number;
|
||||||
|
}): StreamEdgeSlotProps {
|
||||||
|
const { strategy, component, gapSize } = params;
|
||||||
|
if (strategy.edgeSlot === "header") {
|
||||||
|
return {
|
||||||
|
ListHeaderComponent: component,
|
||||||
|
ListHeaderComponentStyle: { marginBottom: gapSize },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ListFooterComponent: component,
|
||||||
|
ListFooterComponentStyle: { marginTop: gapSize },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
FlatList,
|
FlatList,
|
||||||
ListRenderItemInfo,
|
ListRenderItemInfo,
|
||||||
|
LayoutChangeEvent,
|
||||||
NativeScrollEvent,
|
NativeScrollEvent,
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
InteractionManager,
|
InteractionManager,
|
||||||
@@ -54,6 +55,16 @@ import {
|
|||||||
WebDesktopScrollbarOverlay,
|
WebDesktopScrollbarOverlay,
|
||||||
useWebDesktopScrollbarMetrics,
|
useWebDesktopScrollbarMetrics,
|
||||||
} from "./web-desktop-scrollbar";
|
} from "./web-desktop-scrollbar";
|
||||||
|
import {
|
||||||
|
collectAssistantTurnContentForStreamRenderStrategy,
|
||||||
|
getBottomOffsetForStreamRenderStrategy,
|
||||||
|
getStreamEdgeSlotProps,
|
||||||
|
getStreamNeighborItem,
|
||||||
|
isNearBottomForStreamRenderStrategy,
|
||||||
|
orderHeadForStreamRenderStrategy,
|
||||||
|
orderTailForStreamRenderStrategy,
|
||||||
|
resolveStreamRenderStrategy,
|
||||||
|
} from "./agent-stream-render-strategy";
|
||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||||
@@ -65,23 +76,6 @@ const isToolSequenceItem = (item?: StreamItem) =>
|
|||||||
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
|
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
|
||||||
const STREAM_ITEM_LOG_MIN_COUNT = 200;
|
const STREAM_ITEM_LOG_MIN_COUNT = 200;
|
||||||
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
|
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
|
||||||
|
|
||||||
function collectAssistantTurnContent(
|
|
||||||
flatListData: StreamItem[],
|
|
||||||
startIndex: number
|
|
||||||
): string {
|
|
||||||
const messages: string[] = [];
|
|
||||||
for (let i = startIndex; i < flatListData.length; i++) {
|
|
||||||
const currentItem = flatListData[i];
|
|
||||||
if (currentItem.kind === "user_message") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (currentItem.kind === "assistant_message") {
|
|
||||||
messages.push(currentItem.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return messages.reverse().join("\n\n");
|
|
||||||
}
|
|
||||||
export interface AgentStreamViewProps {
|
export interface AgentStreamViewProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
@@ -101,6 +95,14 @@ export function AgentStreamView({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
const streamRenderStrategy = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveStreamRenderStrategy({
|
||||||
|
platform: Platform.OS,
|
||||||
|
isMobileBreakpoint: isMobile,
|
||||||
|
}),
|
||||||
|
[isMobile]
|
||||||
|
);
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||||
@@ -110,6 +112,10 @@ export function AgentStreamView({
|
|||||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||||
const pendingAutoScrollAnimatedRef = useRef(false);
|
const pendingAutoScrollAnimatedRef = useRef(false);
|
||||||
const streamItemCountRef = useRef(0);
|
const streamItemCountRef = useRef(0);
|
||||||
|
const streamViewportMetricsRef = useRef({
|
||||||
|
contentHeight: 0,
|
||||||
|
viewportHeight: 0,
|
||||||
|
});
|
||||||
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||||
@@ -185,10 +191,19 @@ export function AgentStreamView({
|
|||||||
|
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const { contentOffset } = event.nativeEvent;
|
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||||
|
streamViewportMetricsRef.current = {
|
||||||
|
contentHeight: Math.max(0, contentSize.height),
|
||||||
|
viewportHeight: Math.max(0, layoutMeasurement.height),
|
||||||
|
};
|
||||||
const threshold = Math.max(insets.bottom, 32);
|
const threshold = Math.max(insets.bottom, 32);
|
||||||
// In inverted list: scrollTop 0 = bottom, higher values = scrolled up
|
const nearBottom = isNearBottomForStreamRenderStrategy({
|
||||||
const nearBottom = contentOffset.y <= threshold;
|
strategy: streamRenderStrategy,
|
||||||
|
offsetY: contentOffset.y,
|
||||||
|
threshold,
|
||||||
|
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
||||||
|
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||||
|
});
|
||||||
|
|
||||||
if (isNearBottomRef.current !== nearBottom) {
|
if (isNearBottomRef.current !== nearBottom) {
|
||||||
isNearBottomRef.current = nearBottom;
|
isNearBottomRef.current = nearBottom;
|
||||||
@@ -199,7 +214,38 @@ export function AgentStreamView({
|
|||||||
streamScrollbarMetrics.onScroll(event);
|
streamScrollbarMetrics.onScroll(event);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[insets.bottom, showDesktopWebScrollbar, streamScrollbarMetrics]
|
[
|
||||||
|
insets.bottom,
|
||||||
|
showDesktopWebScrollbar,
|
||||||
|
streamRenderStrategy,
|
||||||
|
streamScrollbarMetrics,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleListLayout = useCallback(
|
||||||
|
(event: LayoutChangeEvent) => {
|
||||||
|
streamViewportMetricsRef.current = {
|
||||||
|
...streamViewportMetricsRef.current,
|
||||||
|
viewportHeight: Math.max(0, event.nativeEvent.layout.height),
|
||||||
|
};
|
||||||
|
if (showDesktopWebScrollbar) {
|
||||||
|
streamScrollbarMetrics.onLayout(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showDesktopWebScrollbar, streamScrollbarMetrics]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContentSizeChange = useCallback(
|
||||||
|
(width: number, height: number) => {
|
||||||
|
streamViewportMetricsRef.current = {
|
||||||
|
...streamViewportMetricsRef.current,
|
||||||
|
contentHeight: Math.max(0, height),
|
||||||
|
};
|
||||||
|
if (showDesktopWebScrollbar) {
|
||||||
|
streamScrollbarMetrics.onContentSizeChange(width, height);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showDesktopWebScrollbar, streamScrollbarMetrics]
|
||||||
);
|
);
|
||||||
|
|
||||||
const scrollToBottomInternal = useCallback(
|
const scrollToBottomInternal = useCallback(
|
||||||
@@ -209,11 +255,16 @@ export function AgentStreamView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.scrollToOffset({ offset: 0, animated });
|
const offset = getBottomOffsetForStreamRenderStrategy({
|
||||||
|
strategy: streamRenderStrategy,
|
||||||
|
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
||||||
|
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||||
|
});
|
||||||
|
list.scrollToOffset({ offset, animated });
|
||||||
isNearBottomRef.current = true;
|
isNearBottomRef.current = true;
|
||||||
setIsNearBottom(true);
|
setIsNearBottom(true);
|
||||||
},
|
},
|
||||||
[]
|
[streamRenderStrategy]
|
||||||
);
|
);
|
||||||
|
|
||||||
const scheduleAutoScroll = useCallback(
|
const scheduleAutoScroll = useCallback(
|
||||||
@@ -275,20 +326,31 @@ export function AgentStreamView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const flatListData = useMemo(() => {
|
const flatListData = useMemo(() => {
|
||||||
return [...streamItems].reverse();
|
return orderTailForStreamRenderStrategy({
|
||||||
}, [streamItems]);
|
strategy: streamRenderStrategy,
|
||||||
|
streamItems,
|
||||||
|
});
|
||||||
|
}, [streamItems, streamRenderStrategy]);
|
||||||
|
|
||||||
|
const orderedStreamHead = useMemo(() => {
|
||||||
|
return orderHeadForStreamRenderStrategy({
|
||||||
|
strategy: streamRenderStrategy,
|
||||||
|
streamHead: streamHead ?? [],
|
||||||
|
});
|
||||||
|
}, [streamHead, streamRenderStrategy]);
|
||||||
|
|
||||||
const tightGap = theme.spacing[1]; // 4px
|
const tightGap = theme.spacing[1]; // 4px
|
||||||
const looseGap = theme.spacing[4]; // 16px
|
const looseGap = theme.spacing[4]; // 16px
|
||||||
|
|
||||||
// The FlatList is inverted, but each row is re-inverted by RN, so `marginBottom` still
|
|
||||||
// manifests as the gap *below* the row in the final visual layout. Compute spacing
|
|
||||||
// against the item visually below (index - 1 in our inverted list).
|
|
||||||
const getGapBelow = useCallback(
|
const getGapBelow = useCallback(
|
||||||
(item: StreamItem, index: number) => {
|
(item: StreamItem, index: number, items: StreamItem[]) => {
|
||||||
const belowItem = flatListData[index - 1];
|
const belowItem = getStreamNeighborItem({
|
||||||
|
strategy: streamRenderStrategy,
|
||||||
|
items,
|
||||||
|
index,
|
||||||
|
relation: "below",
|
||||||
|
});
|
||||||
if (!belowItem) {
|
if (!belowItem) {
|
||||||
// This is the bottommost (newest) item; nothing below it visually.
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,13 +389,15 @@ export function AgentStreamView({
|
|||||||
// Different types get loose gap (16px)
|
// Different types get loose gap (16px)
|
||||||
return looseGap;
|
return looseGap;
|
||||||
},
|
},
|
||||||
[flatListData, looseGap, tightGap]
|
[looseGap, streamRenderStrategy, tightGap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderStreamItemContent = useCallback(
|
const renderStreamItemContent = useCallback(
|
||||||
(item: StreamItem, index: number) => {
|
(item: StreamItem, index: number, items: StreamItem[]) => {
|
||||||
const handleInlineDetailsExpandedChange = (expanded: boolean) => {
|
const handleInlineDetailsExpandedChange = (expanded: boolean) => {
|
||||||
if (Platform.OS !== "web") {
|
if (
|
||||||
|
!streamRenderStrategy.disableParentScrollOnInlineDetailsExpansion
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setExpandedInlineToolCallIds((previous) => {
|
setExpandedInlineToolCallIds((previous) => {
|
||||||
@@ -349,11 +413,20 @@ export function AgentStreamView({
|
|||||||
|
|
||||||
switch (item.kind) {
|
switch (item.kind) {
|
||||||
case "user_message": {
|
case "user_message": {
|
||||||
// In inverted list: index+1 is the item above, index-1 is below.
|
const aboveItem = getStreamNeighborItem({
|
||||||
const prevItem = flatListData[index + 1];
|
strategy: streamRenderStrategy,
|
||||||
const nextItem = flatListData[index - 1];
|
items,
|
||||||
const isFirstInGroup = prevItem?.kind !== "user_message";
|
index,
|
||||||
const isLastInGroup = nextItem?.kind !== "user_message";
|
relation: "above",
|
||||||
|
});
|
||||||
|
const belowItem = getStreamNeighborItem({
|
||||||
|
strategy: streamRenderStrategy,
|
||||||
|
items,
|
||||||
|
index,
|
||||||
|
relation: "below",
|
||||||
|
});
|
||||||
|
const isFirstInGroup = aboveItem?.kind !== "user_message";
|
||||||
|
const isLastInGroup = belowItem?.kind !== "user_message";
|
||||||
return (
|
return (
|
||||||
<UserMessage
|
<UserMessage
|
||||||
message={item.text}
|
message={item.text}
|
||||||
@@ -375,8 +448,12 @@ export function AgentStreamView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "thought": {
|
case "thought": {
|
||||||
// In inverted list: index+1 is the item above, index-1 is below.
|
const nextItem = getStreamNeighborItem({
|
||||||
const nextItem = flatListData[index - 1];
|
strategy: streamRenderStrategy,
|
||||||
|
items,
|
||||||
|
index,
|
||||||
|
relation: "below",
|
||||||
|
});
|
||||||
const isLastInSequence =
|
const isLastInSequence =
|
||||||
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
|
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
|
||||||
return (
|
return (
|
||||||
@@ -392,8 +469,12 @@ export function AgentStreamView({
|
|||||||
|
|
||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
const { payload } = item;
|
const { payload } = item;
|
||||||
// In inverted list: index+1 is the item above, index-1 is below.
|
const nextItem = getStreamNeighborItem({
|
||||||
const nextItem = flatListData[index - 1];
|
strategy: streamRenderStrategy,
|
||||||
|
items,
|
||||||
|
index,
|
||||||
|
relation: "below",
|
||||||
|
});
|
||||||
const isLastInSequence =
|
const isLastInSequence =
|
||||||
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
|
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
|
||||||
|
|
||||||
@@ -455,23 +536,33 @@ export function AgentStreamView({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleInlinePathPress, agent.cwd, flatListData]
|
[handleInlinePathPress, agent.cwd, streamRenderStrategy]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderStreamItem = useCallback(
|
const renderStreamItem = useCallback(
|
||||||
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
||||||
const content = renderStreamItemContent(item, index);
|
const content = renderStreamItemContent(item, index, flatListData);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gapBelow = getGapBelow(item, index);
|
const gapBelow = getGapBelow(item, index, flatListData);
|
||||||
const nextItem = flatListData[index - 1];
|
const nextItem = getStreamNeighborItem({
|
||||||
|
strategy: streamRenderStrategy,
|
||||||
|
items: flatListData,
|
||||||
|
index,
|
||||||
|
relation: "below",
|
||||||
|
});
|
||||||
const isEndOfAssistantTurn =
|
const isEndOfAssistantTurn =
|
||||||
item.kind === "assistant_message" &&
|
item.kind === "assistant_message" &&
|
||||||
(nextItem?.kind === "user_message" ||
|
(nextItem?.kind === "user_message" ||
|
||||||
(nextItem === undefined && agent.status !== "running"));
|
(nextItem === undefined && agent.status !== "running"));
|
||||||
const getTurnContent = () => collectAssistantTurnContent(flatListData, index);
|
const getTurnContent = () =>
|
||||||
|
collectAssistantTurnContentForStreamRenderStrategy({
|
||||||
|
strategy: streamRenderStrategy,
|
||||||
|
items: flatListData,
|
||||||
|
startIndex: index,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
|
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
|
||||||
@@ -482,7 +573,13 @@ export function AgentStreamView({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[getGapBelow, renderStreamItemContent, flatListData, agent.status]
|
[
|
||||||
|
getGapBelow,
|
||||||
|
renderStreamItemContent,
|
||||||
|
flatListData,
|
||||||
|
agent.status,
|
||||||
|
streamRenderStrategy,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingPermissionItems = useMemo(
|
const pendingPermissionItems = useMemo(
|
||||||
@@ -564,9 +661,9 @@ export function AgentStreamView({
|
|||||||
const showWorkingIndicator = agent.status === "running";
|
const showWorkingIndicator = agent.status === "running";
|
||||||
const showBottomBar = showWorkingIndicator;
|
const showBottomBar = showWorkingIndicator;
|
||||||
|
|
||||||
const listHeaderComponent = useMemo(() => {
|
const listEdgeSlotComponent = useMemo(() => {
|
||||||
const hasPermissions = pendingPermissionItems.length > 0;
|
const hasPermissions = pendingPermissionItems.length > 0;
|
||||||
const hasHeadItems = streamHead && streamHead.length > 0;
|
const hasHeadItems = orderedStreamHead.length > 0;
|
||||||
|
|
||||||
if (!hasPermissions && !showBottomBar && !hasHeadItems) {
|
if (!hasPermissions && !showBottomBar && !hasHeadItems) {
|
||||||
return null;
|
return null;
|
||||||
@@ -579,10 +676,8 @@ export function AgentStreamView({
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
stylesheet.listHeaderContent,
|
stylesheet.listHeaderContent,
|
||||||
// In an inverted FlatList, the header is rendered at the visual bottom, so its
|
// The edge slot (header for inverted streams, footer for forward streams)
|
||||||
// top edge can sit directly against the newest timeline item. Add a small
|
// sits next to the newest timeline item.
|
||||||
// padding to prevent badges (e.g. Thinking/Setup) from butting up against
|
|
||||||
// the last user message when the streaming head is present.
|
|
||||||
hasHeadItems ? { paddingTop: tightGap } : null,
|
hasHeadItems ? { paddingTop: tightGap } : null,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -599,8 +694,12 @@ export function AgentStreamView({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{hasHeadItems
|
{hasHeadItems
|
||||||
? [...streamHead].reverse().map((item, index) => {
|
? orderedStreamHead.map((item, index) => {
|
||||||
const rendered = renderStreamItemContent(item, index);
|
const rendered = renderStreamItemContent(
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
orderedStreamHead
|
||||||
|
);
|
||||||
return rendered ? (
|
return rendered ? (
|
||||||
<View key={item.id} style={stylesheet.streamItemWrapper}>
|
<View key={item.id} style={stylesheet.streamItemWrapper}>
|
||||||
{rendered}
|
{rendered}
|
||||||
@@ -617,7 +716,7 @@ export function AgentStreamView({
|
|||||||
pendingPermissionItems,
|
pendingPermissionItems,
|
||||||
showWorkingIndicator,
|
showWorkingIndicator,
|
||||||
client,
|
client,
|
||||||
streamHead,
|
orderedStreamHead,
|
||||||
renderStreamItemContent,
|
renderStreamItemContent,
|
||||||
tightGap,
|
tightGap,
|
||||||
showBottomBar,
|
showBottomBar,
|
||||||
@@ -636,27 +735,20 @@ export function AgentStreamView({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep maintainVisibleContentPosition shape and values stable to avoid
|
const listEdgeSlotProps = useMemo(() => {
|
||||||
// reconfiguring RN's internal anchoring logic during streaming transitions.
|
if (!listEdgeSlotComponent) {
|
||||||
// We rely on our own follow-tail logic, so keep threshold at 0.
|
return {};
|
||||||
const maintainVisibleContentPosition = useMemo(
|
|
||||||
() => ({ minIndexForVisible: 0, autoscrollToTopThreshold: 0 }),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// FlatList's ListHeaderComponent renders at the *bottom* when inverted.
|
|
||||||
// Without explicit spacing, the newest "head" rows (like Thinking/Setup tool badges)
|
|
||||||
// can butt up directly against the most recent stream item.
|
|
||||||
const headerGapStyle = useMemo(() => {
|
|
||||||
if (!listHeaderComponent) {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
return { marginBottom: tightGap };
|
return getStreamEdgeSlotProps({
|
||||||
}, [listHeaderComponent, tightGap]);
|
strategy: streamRenderStrategy,
|
||||||
|
component: listEdgeSlotComponent,
|
||||||
|
gapSize: tightGap,
|
||||||
|
});
|
||||||
|
}, [listEdgeSlotComponent, streamRenderStrategy, tightGap]);
|
||||||
|
|
||||||
const listEmptyComponent = useMemo(() => {
|
const listEmptyComponent = useMemo(() => {
|
||||||
const hasPermissions = pendingPermissionItems.length > 0;
|
const hasPermissions = pendingPermissionItems.length > 0;
|
||||||
const hasHeadItems = (streamHead?.length ?? 0) > 0;
|
const hasHeadItems = orderedStreamHead.length > 0;
|
||||||
if (hasPermissions || hasHeadItems) {
|
if (hasPermissions || hasHeadItems) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -685,7 +777,7 @@ export function AgentStreamView({
|
|||||||
}, [
|
}, [
|
||||||
agent.status,
|
agent.status,
|
||||||
pendingPermissionItems.length,
|
pendingPermissionItems.length,
|
||||||
streamHead,
|
orderedStreamHead,
|
||||||
theme.colors.foregroundMuted,
|
theme.colors.foregroundMuted,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -699,36 +791,32 @@ export function AgentStreamView({
|
|||||||
renderItem={renderStreamItem}
|
renderItem={renderStreamItem}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
testID="agent-chat-scroll"
|
testID="agent-chat-scroll"
|
||||||
ListHeaderComponentStyle={headerGapStyle}
|
{...listEdgeSlotProps}
|
||||||
contentContainerStyle={stylesheet.listContentContainer}
|
contentContainerStyle={stylesheet.listContentContainer}
|
||||||
style={stylesheet.list}
|
style={stylesheet.list}
|
||||||
onLayout={
|
onLayout={handleListLayout}
|
||||||
showDesktopWebScrollbar
|
|
||||||
? streamScrollbarMetrics.onLayout
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
onContentSizeChange={
|
onContentSizeChange={handleContentSizeChange}
|
||||||
showDesktopWebScrollbar
|
|
||||||
? streamScrollbarMetrics.onContentSizeChange
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
ListEmptyComponent={listEmptyComponent}
|
ListEmptyComponent={listEmptyComponent}
|
||||||
ListHeaderComponent={listHeaderComponent}
|
|
||||||
extraData={flatListExtraData}
|
extraData={flatListExtraData}
|
||||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
maintainVisibleContentPosition={
|
||||||
|
streamRenderStrategy.maintainVisibleContentPosition
|
||||||
|
}
|
||||||
initialNumToRender={12}
|
initialNumToRender={12}
|
||||||
windowSize={10}
|
windowSize={10}
|
||||||
scrollEnabled={Platform.OS !== "web" || expandedInlineToolCallIds.size === 0}
|
scrollEnabled={
|
||||||
|
!streamRenderStrategy.disableParentScrollOnInlineDetailsExpansion ||
|
||||||
|
expandedInlineToolCallIds.size === 0
|
||||||
|
}
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
inverted
|
inverted={streamRenderStrategy.flatListInverted}
|
||||||
/>
|
/>
|
||||||
</MessageOuterSpacingProvider>
|
</MessageOuterSpacingProvider>
|
||||||
<WebDesktopScrollbarOverlay
|
<WebDesktopScrollbarOverlay
|
||||||
enabled={showDesktopWebScrollbar}
|
enabled={showDesktopWebScrollbar}
|
||||||
metrics={streamScrollbarMetrics}
|
metrics={streamScrollbarMetrics}
|
||||||
inverted
|
inverted={streamRenderStrategy.overlayScrollbarInverted}
|
||||||
onScrollToOffset={(nextOffset) => {
|
onScrollToOffset={(nextOffset) => {
|
||||||
flatListRef.current?.scrollToOffset({
|
flatListRef.current?.scrollToOffset({
|
||||||
offset: nextOffset,
|
offset: nextOffset,
|
||||||
|
|||||||
Reference in New Issue
Block a user