refactor: extract stream render model and segment-based rendering

This commit is contained in:
Mohamed Boudra
2026-03-10 12:56:18 +07:00
parent 483dd7cb6d
commit 15e9569157
11 changed files with 766 additions and 350 deletions
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { buildAgentStreamRenderModel } from "./agent-stream-render-model";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): StreamItem {
return {
kind: "user_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
function assistantMessage(id: string, seed: number): StreamItem {
return {
kind: "assistant_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
describe("buildAgentStreamRenderModel", () => {
it("keeps head separate from committed history on desktop web", () => {
const tail: StreamItem[] = [];
for (let index = 0; index < 60; index += 1) {
const seed = index * 2;
tail.push(userMessage(`u${index}`, seed + 1));
tail.push(assistantMessage(`a${index}`, seed + 2));
}
const head = [assistantMessage("live-a", 121)];
const model = buildAgentStreamRenderModel({
tail,
head,
platform: "web",
isMobileBreakpoint: false,
});
expect(model.segments.historyVirtualized.length).toBeGreaterThan(0);
expect(model.segments.historyMounted.length).toBeGreaterThan(0);
expect(model.segments.liveHead.map((item) => item.id)).toEqual(["live-a"]);
expect(model.history).not.toContain(head[0]);
});
it("keeps the full committed tail mounted on mobile web", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
const head = [assistantMessage("live-a", 3)];
const model = buildAgentStreamRenderModel({
tail,
head,
platform: "web",
isMobileBreakpoint: true,
});
expect(model.segments.historyVirtualized).toHaveLength(0);
expect(model.segments.historyMounted).toBe(tail);
expect(model.segments.liveHead).toBe(head);
});
it("reuses ordered committed history when only the live head changes", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
const firstHead = [assistantMessage("live-a", 3)];
const secondHead = [assistantMessage("live-b", 4)];
const first = buildAgentStreamRenderModel({
tail,
head: firstHead,
platform: "native",
isMobileBreakpoint: false,
});
const second = buildAgentStreamRenderModel({
tail,
head: secondHead,
platform: "native",
isMobileBreakpoint: false,
});
expect(first.history).toBe(second.history);
expect(first.segments.historyMounted).toBe(second.segments.historyMounted);
expect(second.segments.liveHead.map((item) => item.id)).toEqual(["live-b"]);
});
});
@@ -0,0 +1,178 @@
import type { ReactNode } from "react";
import type { StreamItem } from "@/types/stream";
import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "./agent-stream-web-virtualization";
import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveStreamRenderStrategy,
} from "./stream-strategy";
export type StreamRenderSegments = {
historyVirtualized: StreamItem[];
historyMounted: StreamItem[];
liveHead: StreamItem[];
};
export type StreamHistoryBoundary = {
hasVirtualizedHistory: boolean;
hasMountedHistory: boolean;
hasLiveHead: boolean;
historyToHeadGap: number;
};
export type StreamRenderAuxiliary = {
pendingPermissions: ReactNode;
workingIndicator: ReactNode;
};
export type AgentStreamRenderModel = {
history: StreamItem[];
segments: StreamRenderSegments;
boundary: StreamHistoryBoundary;
auxiliary: StreamRenderAuxiliary;
};
export type BuildAgentStreamRenderModelInput = {
tail: StreamItem[];
head: StreamItem[];
platform: "web" | "native";
isMobileBreakpoint: boolean;
};
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_AUXILIARY: StreamRenderAuxiliary = {
pendingPermissions: null,
workingIndicator: null,
};
const orderedTailCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
const orderedHeadCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
const splitHistoryCache = new WeakMap<
StreamItem[],
Map<string, Pick<AgentStreamRenderModel, "history" | "segments">>
>();
function getOrderedItems(params: {
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
source: StreamItem[];
cacheKey: string;
order: (items: StreamItem[]) => StreamItem[];
}): StreamItem[] {
const { cache, source, cacheKey, order } = params;
let cachedByKey = cache.get(source);
if (!cachedByKey) {
cachedByKey = new Map();
cache.set(source, cachedByKey);
}
const cached = cachedByKey.get(cacheKey);
if (cached) {
return cached;
}
const ordered = order(source);
cachedByKey.set(cacheKey, ordered);
return ordered;
}
function splitOrderedTail(params: {
orderedTail: StreamItem[];
platform: "web" | "native";
isMobileBreakpoint: boolean;
}): Pick<AgentStreamRenderModel, "history" | "segments"> {
const { orderedTail, platform, isMobileBreakpoint } = params;
const shouldSplitHistory =
platform === "web" &&
!isMobileBreakpoint &&
orderedTail.length > getWebPartialVirtualizationThreshold();
const cacheKey = `${platform}:${isMobileBreakpoint}:${getWebMountedRecentStreamItems()}:${shouldSplitHistory}`;
let cachedByKey = splitHistoryCache.get(orderedTail);
if (!cachedByKey) {
cachedByKey = new Map();
splitHistoryCache.set(orderedTail, cachedByKey);
}
const cached = cachedByKey.get(cacheKey);
if (cached) {
return cached;
}
if (!shouldSplitHistory) {
const unsplit = {
history: orderedTail,
segments: {
historyVirtualized: EMPTY_STREAM_ITEMS,
historyMounted: orderedTail,
liveHead: EMPTY_STREAM_ITEMS,
},
} satisfies Pick<AgentStreamRenderModel, "history" | "segments">;
cachedByKey.set(cacheKey, unsplit);
return unsplit;
}
const mountedWindowStart = findMountedWindowStart({
items: orderedTail,
minMountedCount: getWebMountedRecentStreamItems(),
});
const split = {
history: orderedTail,
segments: {
historyVirtualized: orderedTail.slice(0, mountedWindowStart),
historyMounted: orderedTail.slice(mountedWindowStart),
liveHead: EMPTY_STREAM_ITEMS,
},
} satisfies Pick<AgentStreamRenderModel, "history" | "segments">;
cachedByKey.set(cacheKey, split);
return split;
}
export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput
): AgentStreamRenderModel {
const strategy = resolveStreamRenderStrategy({
platform: input.platform === "web" ? "web" : "native",
isMobileBreakpoint: input.isMobileBreakpoint,
});
const orderingCacheKey = `${input.platform}:${input.isMobileBreakpoint}`;
const orderedTail = getOrderedItems({
cache: orderedTailCache,
source: input.tail,
cacheKey: orderingCacheKey,
order: (items) =>
orderTailForStreamRenderStrategy({
strategy,
streamItems: items,
}),
});
const orderedHead = getOrderedItems({
cache: orderedHeadCache,
source: input.head,
cacheKey: orderingCacheKey,
order: (items) =>
orderHeadForStreamRenderStrategy({
strategy,
streamHead: items,
}),
});
const splitHistory = splitOrderedTail({
orderedTail,
platform: input.platform,
isMobileBreakpoint: input.isMobileBreakpoint,
});
return {
history: splitHistory.history,
segments: {
...splitHistory.segments,
liveHead: orderedHead,
},
boundary: {
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
hasLiveHead: orderedHead.length > 0,
historyToHeadGap: 0,
},
auxiliary: EMPTY_AUXILIARY,
};
}
@@ -1 +1,2 @@
export * from "./stream-strategy"; export * from "./stream-strategy";
export * from "./agent-stream-render-model";
+160 -142
View File
@@ -53,13 +53,12 @@ import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card"; import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet"; import { ToolCallSheetProvider } from "./tool-call-sheet";
import { import {
buildAgentStreamRenderModel,
collectAssistantTurnContentForStreamRenderStrategy, collectAssistantTurnContentForStreamRenderStrategy,
getStreamEdgeSlotProps,
getStreamNeighborItem, getStreamNeighborItem,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveStreamRenderStrategy, resolveStreamRenderStrategy,
type StreamEdgeSlotProps, type AgentStreamRenderModel,
type StreamSegmentRenderers,
type StreamViewportHandle, type StreamViewportHandle,
} from "./agent-stream-render-strategy"; } from "./agent-stream-render-strategy";
import { import {
@@ -199,12 +198,14 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
] ]
); );
const orderedStreamItems = useMemo(() => { const baseRenderModel = useMemo(() => {
return orderTailForStreamRenderStrategy({ return buildAgentStreamRenderModel({
strategy: streamRenderStrategy, tail: streamItems,
streamItems, head: streamHead ?? [],
platform: Platform.OS === "web" ? "web" : "native",
isMobileBreakpoint: isMobile,
}); });
}, [streamItems, streamRenderStrategy]); }, [isMobile, streamHead, streamItems]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
scrollToBottom(reason = "jump-to-bottom") { scrollToBottom(reason = "jump-to-bottom") {
viewportRef.current?.scrollToBottom(reason); viewportRef.current?.scrollToBottom(reason);
@@ -218,68 +219,48 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
viewportRef.current?.scrollToBottom("jump-to-bottom"); viewportRef.current?.scrollToBottom("jump-to-bottom");
} }
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
const getGapBelow = useCallback( const getGapBetween = useCallback(
(item: StreamItem, index: number, items: StreamItem[]) => { (item: StreamItem | null, belowItem: StreamItem | null) => {
const belowItem = getStreamNeighborItem({ if (!item || !belowItem) {
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
if (!belowItem) {
return 0; return 0;
} }
// Same type groups get tight gap (4px)
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return tightGap; return tightGap;
} }
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return tightGap; return tightGap;
} }
// Give user messages more breathing room before tool sequences.
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) { if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap; return looseGap;
} }
// Keep tool sequences visually connected to the preceding user/assistant message.
if ( if (
(item.kind === "user_message" || item.kind === "assistant_message") && (item.kind === "user_message" || item.kind === "assistant_message") &&
isToolSequenceItem(belowItem) isToolSequenceItem(belowItem)
) { ) {
return tightGap; return tightGap;
} }
// Keep todo lists visually connected to the following tool sequence (symmetry).
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) { if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
return tightGap; return tightGap;
} }
// Keep tool sequences visually connected to the assistant response (symmetry).
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") { if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return tightGap; return tightGap;
} }
// Different types get loose gap (16px)
return looseGap; return looseGap;
}, },
[looseGap, streamRenderStrategy, tightGap] [looseGap, tightGap]
); );
const renderStreamItemContent = useCallback( const renderStreamItemContent = useCallback(
(item: StreamItem, index: number, items: StreamItem[]) => { (
item: StreamItem,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null = null
) => {
const handleInlineDetailsExpandedChange = (expanded: boolean) => { const handleInlineDetailsExpandedChange = (expanded: boolean) => {
if ( if (
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()
@@ -299,12 +280,13 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
switch (item.kind) { switch (item.kind) {
case "user_message": { case "user_message": {
const aboveItem = getStreamNeighborItem({ const aboveItem =
strategy: streamRenderStrategy, getStreamNeighborItem({
items, strategy: streamRenderStrategy,
index, items,
relation: "above", index,
}); relation: "above",
}) ?? seamAboveItem ?? undefined;
const belowItem = getStreamNeighborItem({ const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy, strategy: streamRenderStrategy,
items, items,
@@ -426,19 +408,24 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
); );
const renderStreamItem = useCallback( const renderStreamItem = useCallback(
(item: StreamItem, index: number, items: StreamItem[]) => { (
const content = renderStreamItemContent(item, index, items); item: StreamItem,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null = null
) => {
const content = renderStreamItemContent(item, index, items, seamAboveItem);
if (!content) { if (!content) {
return null; return null;
} }
const gapBelow = getGapBelow(item, index, items);
const nextItem = getStreamNeighborItem({ const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy, strategy: streamRenderStrategy,
items, items,
index, index,
relation: "below", relation: "below",
}); });
const gapBelow = getGapBetween(item, nextItem ?? null);
const isEndOfAssistantTurn = const isEndOfAssistantTurn =
item.kind === "assistant_message" && item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" || (nextItem?.kind === "user_message" ||
@@ -460,7 +447,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
); );
}, },
[ [
getGapBelow, getGapBetween,
renderStreamItemContent, renderStreamItemContent,
agent.status, agent.status,
streamRenderStrategy, streamRenderStrategy,
@@ -544,101 +531,58 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]); }, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
const showWorkingIndicator = agent.status === "running"; const showWorkingIndicator = agent.status === "running";
const showBottomBar = showWorkingIndicator; const renderModel = useMemo<AgentStreamRenderModel>(() => {
const pendingPermissionsNode =
const listEdgeSlotComponent = useMemo(() => { pendingPermissionItems.length > 0 ? (
const hasPermissions = pendingPermissionItems.length > 0; <View style={stylesheet.permissionsContainer}>
const hasHeadItems = orderedStreamHead.length > 0; {pendingPermissionItems.map((permission) => (
<PermissionRequestCard
if (!hasPermissions && !showBottomBar && !hasHeadItems) { key={permission.key}
return null; permission={permission}
} client={client}
/>
const leftContent = showWorkingIndicator ? <WorkingIndicator /> : null; ))}
return (
<View style={stylesheet.contentWrapper}>
<View
style={[
stylesheet.listHeaderContent,
// The edge slot (header for inverted streams, footer for forward streams)
// sits next to the newest timeline item.
hasHeadItems ? { paddingTop: tightGap } : null,
]}
>
{hasPermissions ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard
key={permission.key}
permission={permission}
client={client}
/>
))}
</View>
) : null}
{hasHeadItems
? orderedStreamHead.map((item, index) => {
const rendered = renderStreamItemContent(
item,
index,
orderedStreamHead
);
return rendered ? (
<View key={item.id} style={stylesheet.streamItemWrapper}>
{rendered}
</View>
) : null;
})
: null}
{showBottomBar ? <View style={stylesheet.bottomBarWrapper}>{leftContent}</View> : null}
</View> </View>
) : null;
const workingIndicatorNode = showWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper}>
<WorkingIndicator />
</View> </View>
); ) : null;
return {
...baseRenderModel,
boundary: {
...baseRenderModel.boundary,
historyToHeadGap: getGapBetween(
baseRenderModel.history.at(-1) ?? null,
baseRenderModel.segments.liveHead[0] ?? null
),
},
auxiliary: {
pendingPermissions: pendingPermissionsNode,
workingIndicator: workingIndicatorNode,
},
};
}, [ }, [
baseRenderModel,
client,
getGapBetween,
pendingPermissionItems, pendingPermissionItems,
showWorkingIndicator, showWorkingIndicator,
client,
orderedStreamHead,
renderStreamItemContent,
showBottomBar,
tightGap,
]); ]);
const listEdgeSlotProps = useMemo<StreamEdgeSlotProps>(() => {
if (!listEdgeSlotComponent) {
return {};
}
return getStreamEdgeSlotProps({
strategy: streamRenderStrategy,
component: listEdgeSlotComponent,
gapSize: tightGap,
});
}, [listEdgeSlotComponent, streamRenderStrategy, tightGap]);
const listEmptyComponent = useMemo(() => { const listEmptyComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0; if (
const hasHeadItems = orderedStreamHead.length > 0; renderModel.boundary.hasVirtualizedHistory ||
if (hasPermissions || hasHeadItems) { renderModel.boundary.hasMountedHistory ||
renderModel.boundary.hasLiveHead ||
renderModel.auxiliary.pendingPermissions ||
renderModel.auxiliary.workingIndicator
) {
return null; return null;
} }
const shouldShowWorking = agent.status === "running";
if (shouldShowWorking) {
return (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<Text style={stylesheet.emptyStateText}>Working…</Text>
</View>
);
}
return ( return (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}> <View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}> <Text style={stylesheet.emptyStateText}>
@@ -646,12 +590,86 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
</Text> </Text>
</View> </View>
); );
}, [ }, [renderModel]);
agent.status,
pendingPermissionItems.length, const historyItems = renderModel.history;
orderedStreamHead, const liveHeadItems = renderModel.segments.liveHead;
theme.colors.foregroundMuted, const { boundary, auxiliary } = renderModel;
]); const lastHistoryItem = historyItems.at(-1) ?? null;
const historyIndexById = useMemo(() => {
const indexById = new Map<string, number>();
historyItems.forEach((item, index) => {
indexById.set(item.id, index);
});
return indexById;
}, [historyItems]);
const renderHistoryRow = useCallback(
(item: StreamItem) => {
const historyIndex = historyIndexById.get(item.id);
if (historyIndex === undefined) {
return null;
}
return renderStreamItem(item, historyIndex, historyItems);
},
[historyIndexById, historyItems, renderStreamItem]
);
const renderHistoryVirtualizedRow = useCallback<StreamSegmentRenderers["renderHistoryVirtualizedRow"]>(
(item) => renderHistoryRow(item),
[renderHistoryRow]
);
const renderHistoryMountedRow = useCallback<StreamSegmentRenderers["renderHistoryMountedRow"]>(
(item) => renderHistoryRow(item),
[renderHistoryRow]
);
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item, index, items) =>
renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null),
[lastHistoryItem, renderStreamItem]
);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(
() => {
if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) {
return null;
}
return (
<View style={stylesheet.contentWrapper}>
<View
style={[
stylesheet.listHeaderContent,
boundary.hasLiveHead ? { paddingTop: tightGap } : null,
]}
>
{auxiliary.pendingPermissions}
{auxiliary.workingIndicator}
</View>
</View>
);
},
[
auxiliary.pendingPermissions,
auxiliary.workingIndicator,
boundary.hasLiveHead,
tightGap,
]
);
const renderers = useMemo<StreamSegmentRenderers>(
() => ({
renderHistoryVirtualizedRow,
renderHistoryMountedRow,
renderLiveHeadRow,
renderLiveAuxiliary,
}),
[
renderHistoryVirtualizedRow,
renderHistoryMountedRow,
renderLiveHeadRow,
renderLiveAuxiliary,
]
);
const streamScrollEnabled = const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() || !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
@@ -663,8 +681,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
<MessageOuterSpacingProvider disableOuterSpacing> <MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({ {streamRenderStrategy.render({
agentId, agentId,
rows: orderedStreamItems, segments: renderModel.segments,
renderRow: renderStreamItem, boundary,
renderers,
listEmptyComponent, listEmptyComponent,
viewportRef, viewportRef,
routeBottomAnchorRequest, routeBottomAnchorRequest,
@@ -674,7 +693,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
listStyle: stylesheet.list, listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer, baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer, forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
edgeSlotProps: listEdgeSlotProps,
})} })}
</MessageOuterSpacingProvider> </MessageOuterSpacingProvider>
{!isNearBottom && ( {!isNearBottom && (
@@ -8,9 +8,7 @@ import {
type NativeSyntheticEvent, type NativeSyntheticEvent,
} from "react-native"; } from "react-native";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { import { useBottomAnchorController } from "./use-bottom-anchor-controller";
useBottomAnchorController,
} from "./use-bottom-anchor-controller";
import type { import type {
StreamRenderInput, StreamRenderInput,
StreamStrategy, StreamStrategy,
@@ -30,8 +28,9 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) { function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
const { const {
agentId, agentId,
rows, segments,
renderRow, boundary,
renderers,
listEmptyComponent, listEmptyComponent,
viewportRef, viewportRef,
routeBottomAnchorRequest, routeBottomAnchorRequest,
@@ -40,7 +39,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollEnabled, scrollEnabled,
listStyle, listStyle,
baseListContentContainerStyle, baseListContentContainerStyle,
edgeSlotProps,
strategy, strategy,
} = props; } = props;
const flatListRef = useRef<FlatList<StreamItem>>(null); const flatListRef = useRef<FlatList<StreamItem>>(null);
@@ -58,6 +56,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null); const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyRows = useMemo(() => {
if (segments.historyVirtualized.length === 0) {
return segments.historyMounted;
}
return [...segments.historyVirtualized, ...segments.historyMounted];
}, [segments.historyMounted, segments.historyVirtualized]);
const clearNativeViewportSettling = useCallback(() => { const clearNativeViewportSettling = useCallback(() => {
if (nativeViewportSettlingFrameIdRef.current !== null) { if (nativeViewportSettlingFrameIdRef.current !== null) {
cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current); cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current);
@@ -90,19 +95,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
[isNativeViewportSettling, strategy] [isNativeViewportSettling, strategy]
); );
const scrollToBottom = useCallback((animated: boolean) => { const scrollToBottom = useCallback(
programmaticScrollEventBudgetRef.current = 3; (animated: boolean) => {
flatListRef.current?.scrollToOffset({ programmaticScrollEventBudgetRef.current = 3;
offset: 0, flatListRef.current?.scrollToOffset({
animated, offset: 0,
}); animated,
scrollOffsetYRef.current = 0; });
streamViewportMetricsRef.current = { scrollOffsetYRef.current = 0;
...streamViewportMetricsRef.current, streamViewportMetricsRef.current = {
offsetY: 0, ...streamViewportMetricsRef.current,
}; offsetY: 0,
onNearBottomChange(true); };
}, [onNearBottomChange]); onNearBottomChange(true);
},
[onNearBottomChange]
);
const bottomAnchorController = useBottomAnchorController({ const bottomAnchorController = useBottomAnchorController({
agentId, agentId,
@@ -163,7 +171,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
useEffect(() => { useEffect(() => {
bottomAnchorController.prepareForStickyContentChange(); bottomAnchorController.prepareForStickyContentChange();
}, [bottomAnchorController, rows]); }, [bottomAnchorController, historyRows, segments.liveHead]);
useEffect(() => { useEffect(() => {
const handle: StreamViewportHandle = { const handle: StreamViewportHandle = {
@@ -272,30 +280,50 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: ListRenderItemInfo<StreamItem>) => { ({ item, index }: ListRenderItemInfo<StreamItem>) => {
const rendered = renderRow(item, index, rows); const rendered = renderers.renderHistoryMountedRow(item, index, historyRows);
return rendered ? <Fragment>{rendered}</Fragment> : null; return rendered ? <Fragment>{rendered}</Fragment> : null;
}, },
[renderRow, rows] [historyRows, renderers]
); );
const liveHeaderContent = useMemo(() => {
const liveHeadRows = segments.liveHead.map((item, index) => (
<Fragment key={item.id}>
{renderers.renderLiveHeadRow(item, index, segments.liveHead)}
</Fragment>
));
const liveAuxiliary = renderers.renderLiveAuxiliary();
if (
liveHeadRows.length === 0 &&
!liveAuxiliary &&
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory
) {
return listEmptyComponent ? <Fragment>{listEmptyComponent}</Fragment> : null;
}
return (
<Fragment>
{liveHeadRows}
{liveAuxiliary}
</Fragment>
);
}, [boundary, listEmptyComponent, renderers, segments.liveHead]);
return ( return (
<FlatList <FlatList
ref={flatListRef} ref={flatListRef}
data={rows} data={historyRows}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
testID="agent-chat-scroll" testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized" nativeID="agent-chat-scroll-native-virtualized"
{...edgeSlotProps} ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined}
contentContainerStyle={baseListContentContainerStyle} contentContainerStyle={baseListContentContainerStyle}
style={listStyle} style={listStyle}
onLayout={handleListLayout} onLayout={handleListLayout}
onScroll={handleScroll} onScroll={handleScroll}
scrollEventThrottle={16} scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
ListEmptyComponent={
listEmptyComponent ? () => <Fragment>{listEmptyComponent}</Fragment> : undefined
}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION} maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
initialNumToRender={12} initialNumToRender={12}
windowSize={10} windowSize={10}
@@ -1,8 +1,6 @@
import { import {
Fragment, Fragment,
type CSSProperties, type CSSProperties,
createElement,
isValidElement,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
@@ -11,13 +9,7 @@ import {
useState, useState,
} from 'react' } from 'react'
import { measureElement as measureVirtualElement, useVirtualizer } from '@tanstack/react-virtual' import { measureElement as measureVirtualElement, useVirtualizer } from '@tanstack/react-virtual'
import { View } from 'react-native' import { estimateStreamItemHeight } from './agent-stream-web-virtualization'
import {
estimateStreamItemHeight,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
} from './agent-stream-web-virtualization'
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from './stream-strategy' import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from './stream-strategy'
import { createStreamStrategy } from './stream-strategy' import { createStreamStrategy } from './stream-strategy'
@@ -68,28 +60,6 @@ function isScrollContainerAtBottom(
return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX) return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX)
} }
function renderEdge(
content: StreamRenderInput['edgeSlotProps']['ListHeaderComponent'],
style?: CSSProperties
) {
if (!content) {
return null
}
const rendered = isValidElement(content) ? content : createElement(content)
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
...style,
}}
>
{rendered}
</div>
)
}
function scrollElementToBottom( function scrollElementToBottom(
scrollContainer: HTMLElement, scrollContainer: HTMLElement,
behavior: ScrollBehaviorLike = 'auto' behavior: ScrollBehaviorLike = 'auto'
@@ -127,18 +97,15 @@ function isScrollContainerOverscrolledPastBottom(
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const { const {
rows, segments,
renderRow, boundary,
renderers,
listEmptyComponent, listEmptyComponent,
viewportRef, viewportRef,
routeBottomAnchorRequest, routeBottomAnchorRequest,
isAuthoritativeHistoryReady, isAuthoritativeHistoryReady,
onNearBottomChange, onNearBottomChange,
scrollEnabled, scrollEnabled,
listStyle,
baseListContentContainerStyle,
forwardListContentContainerStyle,
edgeSlotProps,
isMobileBreakpoint, isMobileBreakpoint,
} = props } = props
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } = const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
@@ -147,7 +114,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const contentRef = useRef<HTMLElement | null>(null) const contentRef = useRef<HTMLElement | null>(null)
const [followOutput, setFollowOutputr] = useState(true) const [followOutput, setFollowOutputr] = useState(true)
const setFollowOutput = (value: boolean) => { const setFollowOutput = (value: boolean) => {
console.trace('setFollowOutput', value)
setFollowOutputr(value) setFollowOutputr(value)
return value return value
} }
@@ -160,32 +126,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollTimeoutRef = useRef<number | null>(null) const pendingAutoScrollTimeoutRef = useRef<number | null>(null)
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics() const streamScrollbarMetrics = useWebDesktopScrollbarMetrics()
const showDesktopWebScrollbar = !isMobileBreakpoint const showDesktopWebScrollbar = !isMobileBreakpoint
const shouldUseVirtualizer = const shouldUseVirtualizer = segments.historyVirtualized.length > 0
!isMobileBreakpoint && rows.length > getWebPartialVirtualizationThreshold() const {
renderHistoryVirtualizedRow,
renderHistoryMountedRow,
renderLiveHeadRow,
renderLiveAuxiliary,
} = renderers
followOutputRef.current = followOutput followOutputRef.current = followOutput
const indexedRows = useMemo(() => rows.map((item, index) => ({ item, index })), [rows])
const webVirtualizedHistoryWindow = useMemo(() => {
if (!shouldUseVirtualizer) {
return null
}
return splitWebVirtualizedHistory({
entries: indexedRows,
minMountedCount: getWebMountedRecentStreamItems(),
})
}, [indexedRows, shouldUseVirtualizer])
const virtualizedEntries = webVirtualizedHistoryWindow?.virtualizedEntries ?? []
const mountedEntries = webVirtualizedHistoryWindow?.mountedEntries ?? indexedRows
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId
const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: virtualizedEntries.length, count: segments.historyVirtualized.length,
getScrollElement: () => scrollContainerRef.current, getScrollElement: () => scrollContainerRef.current,
getItemKey: (index: number) => virtualizedEntries[index]?.item.id ?? index, getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
estimateSize: (index: number) => { estimateSize: (index: number) => {
const row = virtualizedEntries[index]?.item const row = segments.historyVirtualized[index]
return row ? estimateStreamItemHeight(row) : 120 return row ? estimateStreamItemHeight(row) : 120
}, },
measureElement: measureVirtualElement, measureElement: measureVirtualElement,
@@ -220,7 +179,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, []) }, [])
const scrollMessagesToBottom = useCallback( const scrollMessagesToBottom = useCallback(
(behavior: ScrollBehaviorLike = 'auto', source = 'unknown') => { (behavior: ScrollBehaviorLike = 'auto') => {
const scrollContainer = scrollContainerRef.current const scrollContainer = scrollContainerRef.current
if (!scrollContainer) { if (!scrollContainer) {
return return
@@ -236,7 +195,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
) )
const scheduleStickToBottom = useCallback( const scheduleStickToBottom = useCallback(
(source = 'unknown') => { () => {
const scrollContainer = scrollContainerRef.current const scrollContainer = scrollContainerRef.current
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) { if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
return return
@@ -249,15 +208,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) { if (!followOutputRef.current) {
return return
} }
scrollMessagesToBottom('auto', source) scrollMessagesToBottom('auto')
}) })
}, },
[scrollMessagesToBottom] [scrollMessagesToBottom]
) )
const forceStickToBottom = useCallback(() => { const forceStickToBottom = useCallback(() => {
cancelPendingStickToBottom() cancelPendingStickToBottom()
scrollMessagesToBottom('auto', 'force') scrollMessagesToBottom('auto')
scheduleStickToBottom('force') scheduleStickToBottom()
}, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]) }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom])
const updateScrollMetrics = useCallback(() => { const updateScrollMetrics = useCallback(() => {
@@ -303,7 +263,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
} }
const currentScrollTop = scrollContainer.scrollTop const currentScrollTop = scrollContainer.scrollTop
const isNearBottom = syncNearBottom(scrollContainer, onNearBottomChange)
const isAtBottom = isScrollContainerAtBottom(scrollContainer) const isAtBottom = isScrollContainerAtBottom(scrollContainer)
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON
@@ -325,7 +284,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
lastKnownScrollTopRef.current = currentScrollTop lastKnownScrollTopRef.current = currentScrollTop
updateScrollMetrics() updateScrollMetrics()
}, [cancelPendingStickToBottom, onNearBottomChange, updateScrollMetrics]) }, [cancelPendingStickToBottom, updateScrollMetrics])
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isActivationReady) { if (!isActivationReady) {
@@ -334,13 +293,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
setFollowOutput(true) setFollowOutput(true)
forceStickToBottom() forceStickToBottom()
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
console.log('timeout', {
followOutputRef: followOutputRef.current,
scrollContainerRef: scrollContainerRef.current,
isScrollContainerNearBottom: scrollContainerRef.current
? isScrollContainerNearBottom(scrollContainerRef.current)
: null,
})
if (!followOutputRef.current) { if (!followOutputRef.current) {
return return
} }
@@ -351,7 +303,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (isScrollContainerNearBottom(scrollContainer)) { if (isScrollContainerNearBottom(scrollContainer)) {
return return
} }
scheduleStickToBottom('activation-timeout') scheduleStickToBottom()
}, WEB_BOTTOM_SETTLE_TIMEOUT_MS) }, WEB_BOTTOM_SETTLE_TIMEOUT_MS)
return () => { return () => {
window.clearTimeout(timeout) window.clearTimeout(timeout)
@@ -362,22 +314,30 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) { if (!followOutputRef.current) {
return return
} }
scheduleStickToBottom('rows-effect') scheduleStickToBottom()
}, [rows, scheduleStickToBottom]) }, [
scheduleStickToBottom,
segments.historyMounted,
segments.historyVirtualized,
segments.liveHead,
])
useEffect(() => { useEffect(() => {
if (!followOutputRef.current) { if (!followOutputRef.current || !shouldUseVirtualizer) {
return return
} }
if (!webVirtualizedHistoryWindow) { scheduleStickToBottom()
return }, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize])
}
scheduleStickToBottom('virtual-total-size-effect')
}, [scheduleStickToBottom, virtualTotalSize, webVirtualizedHistoryWindow])
useEffect(() => { useEffect(() => {
updateScrollMetrics() updateScrollMetrics()
}, [updateScrollMetrics, virtualTotalSize, rows.length]) }, [
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
updateScrollMetrics,
virtualTotalSize,
])
useEffect(() => { useEffect(() => {
const scrollContainer = scrollContainerRef.current const scrollContainer = scrollContainerRef.current
@@ -392,7 +352,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) { if (!followOutputRef.current) {
return return
} }
scheduleStickToBottom('resize-observer') scheduleStickToBottom()
}) })
observer.observe(scrollContainer) observer.observe(scrollContainer)
if (contentNode) { if (contentNode) {
@@ -478,7 +438,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) { if (!followOutputRef.current) {
return return
} }
scheduleStickToBottom('prepare-for-viewport-change') scheduleStickToBottom()
}, },
} }
viewportRef.current = handle viewportRef.current = handle
@@ -488,7 +448,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
} }
cancelPendingStickToBottom() cancelPendingStickToBottom()
} }
}, [cancelPendingStickToBottom, scheduleStickToBottom, forceStickToBottom, viewportRef]) }, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef])
const contentContainerStyle = useMemo( const contentContainerStyle = useMemo(
(): CSSProperties => ({ (): CSSProperties => ({
@@ -513,11 +473,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}), }),
[scrollEnabled] [scrollEnabled]
) )
const headerEdgeContent = renderEdge(edgeSlotProps.ListHeaderComponent)
const footerEdgeContent = renderEdge(
edgeSlotProps.ListFooterComponent,
edgeSlotProps.ListFooterComponentStyle as CSSProperties | undefined
)
const virtualRowsContainerStyle = useMemo( const virtualRowsContainerStyle = useMemo(
(): CSSProperties => ({ (): CSSProperties => ({
position: 'relative', position: 'relative',
@@ -538,6 +493,30 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}), }),
[] []
) )
const mountedHistoryRows = useMemo(
() =>
segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
)),
[renderHistoryMountedRow, segments.historyMounted]
)
const liveHeadRows = useMemo(
() =>
segments.liveHead.map((item, index) => (
<Fragment key={item.id}>
{renderLiveHeadRow(item, index, segments.liveHead)}
</Fragment>
)),
[renderLiveHeadRow, segments.liveHead]
)
const liveAuxiliary = useMemo(() => renderLiveAuxiliary(), [renderLiveAuxiliary])
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
!boundary.hasLiveHead &&
!liveAuxiliary
return ( return (
<> <>
@@ -556,12 +535,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}} }}
style={contentContainerStyle} style={contentContainerStyle}
> >
{headerEdgeContent} {shouldUseVirtualizer ? (
{webVirtualizedHistoryWindow ? (
<div style={virtualRowsContainerStyle}> <div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => { {virtualRows.map((virtualRow) => {
const entry = virtualizedEntries[virtualRow.index] const item = segments.historyVirtualized[virtualRow.index]
if (!entry) { if (!item) {
return null return null
} }
return ( return (
@@ -571,17 +549,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
ref={rowVirtualizer.measureElement} ref={rowVirtualizer.measureElement}
style={renderVirtualRowStyle(virtualRow.start)} style={renderVirtualRowStyle(virtualRow.start)}
> >
{renderRow(entry.item, entry.index, rows)} {renderHistoryVirtualizedRow(
item,
virtualRow.index,
segments.historyVirtualized
)}
</div> </div>
) )
})} })}
</div> </div>
) : null} ) : null}
{mountedEntries.map((entry) => ( {mountedHistoryRows}
<Fragment key={entry.item.id}>{renderRow(entry.item, entry.index, rows)}</Fragment> {boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? (
))} <div style={{ height: boundary.historyToHeadGap, width: '100%' }} />
{rows.length === 0 ? listEmptyComponent : null} ) : null}
{footerEdgeContent} {liveHeadRows}
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
</div> </div>
</div> </div>
<WebDesktopScrollbarOverlay <WebDesktopScrollbarOverlay
+26 -3
View File
@@ -1,6 +1,10 @@
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native"; import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import type {
StreamHistoryBoundary,
StreamRenderSegments,
} from "./agent-stream-render-model";
import type { import type {
BottomAnchorLocalRequest, BottomAnchorLocalRequest,
BottomAnchorRouteRequest, BottomAnchorRouteRequest,
@@ -44,10 +48,30 @@ export type StreamViewportHandle = {
prepareForViewportChange: () => void; prepareForViewportChange: () => void;
}; };
export type StreamSegmentRenderers = {
renderHistoryVirtualizedRow: (
item: StreamItem,
index: number,
items: StreamItem[]
) => ReactNode;
renderHistoryMountedRow: (
item: StreamItem,
index: number,
items: StreamItem[]
) => ReactNode;
renderLiveHeadRow: (
item: StreamItem,
index: number,
items: StreamItem[]
) => ReactNode;
renderLiveAuxiliary: () => ReactNode;
};
export type StreamRenderInput = { export type StreamRenderInput = {
agentId: string; agentId: string;
rows: StreamItem[]; segments: StreamRenderSegments;
renderRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode; boundary: StreamHistoryBoundary;
renderers: StreamSegmentRenderers;
listEmptyComponent: ReactNode; listEmptyComponent: ReactNode;
viewportRef: RefObject<StreamViewportHandle | null>; viewportRef: RefObject<StreamViewportHandle | null>;
routeBottomAnchorRequest: BottomAnchorRouteRequest | null; routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
@@ -57,7 +81,6 @@ export type StreamRenderInput = {
listStyle: StyleProp<ViewStyle>; listStyle: StyleProp<ViewStyle>;
baseListContentContainerStyle: StyleProp<ViewStyle>; baseListContentContainerStyle: StyleProp<ViewStyle>;
forwardListContentContainerStyle: StyleProp<ViewStyle>; forwardListContentContainerStyle: StyleProp<ViewStyle>;
edgeSlotProps: StreamEdgeSlotProps;
}; };
export type ResolveStreamRenderStrategyInput = { export type ResolveStreamRenderStrategyInput = {
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Image, Pressable, Text, View } from "react-native"; import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode"; import * as QRCode from "qrcode";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
@@ -336,14 +336,17 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
<View style={settingsStyles.section}> <View style={settingsStyles.section}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={settingsStyles.sectionTitle}>Built-in daemon</Text> <Text style={settingsStyles.sectionTitle}>Built-in daemon</Text>
<Pressable <Button
accessibilityRole="link" variant="ghost"
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)} size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={styles.sectionLinkText}
style={styles.sectionLink} style={styles.sectionLink}
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
accessibilityLabel="Open advanced daemon settings"
> >
<Text style={styles.sectionLinkText}>Advanced settings</Text> Advanced settings
<ArrowUpRight size={14} color={styles.sectionLinkText.color} /> </Button>
</Pressable>
</View> </View>
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
<View style={styles.row}> <View style={styles.row}>
+31 -43
View File
@@ -4,7 +4,6 @@ import {
View, View,
Text, Text,
ScrollView, ScrollView,
Pressable,
Alert, Alert,
Platform, Platform,
} from "react-native"; } from "react-native";
@@ -221,17 +220,12 @@ const styles = StyleSheet.create((theme) => ({
hostSettingsButton: { hostSettingsButton: {
width: 28, width: 28,
height: 28, height: 28,
paddingVertical: 0,
paddingHorizontal: 0,
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
alignItems: "center", gap: 0,
justifyContent: "center",
borderWidth: 1,
borderColor: "transparent",
backgroundColor: "transparent",
marginLeft: theme.spacing[2], marginLeft: theme.spacing[2],
}, },
hostSettingsButtonActive: {
backgroundColor: theme.colors.surface3,
},
advancedTrigger: { advancedTrigger: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -256,20 +250,10 @@ const styles = StyleSheet.create((theme) => ({
}, },
// Add host button // Add host button
addButton: { addButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
borderStyle: "dashed", borderStyle: "dashed",
}, },
addButtonText: { addButtonText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
}, },
// Add/Edit form // Add/Edit form
formCard: { formCard: {
@@ -712,16 +696,19 @@ export default function SettingsScreen() {
}) })
)} )}
<Pressable <Button
variant="outline"
size="md"
style={styles.addButton} style={styles.addButton}
textStyle={styles.addButtonText}
onPress={() => { onPress={() => {
setAddConnectionTargetServerId(null); setAddConnectionTargetServerId(null);
setPendingEditReopenServerId(null); setPendingEditReopenServerId(null);
setIsAddHostMethodVisible(true); setIsAddHostMethodVisible(true);
}} }}
> >
<Text style={styles.addButtonText}>+ Add connection</Text> + Add connection
</Pressable> </Button>
</View> </View>
<AddHostMethodModal <AddHostMethodModal
@@ -1147,12 +1134,15 @@ function HostDetailModal({
/> />
); );
})} })}
<Pressable <Button
variant="outline"
size="md"
style={styles.addButton} style={styles.addButton}
textStyle={styles.addButtonText}
onPress={onAddConnection} onPress={onAddConnection}
> >
<Text style={styles.addButtonText}>+ Add connection</Text> + Add connection
</Pressable> </Button>
</View> </View>
</View> </View>
) : null} ) : null}
@@ -1312,11 +1302,14 @@ function ConnectionRow({
<Text style={{ color: latencyColor, fontSize: 11 }}> <Text style={{ color: latencyColor, fontSize: 11 }}>
{latencyText} {latencyText}
</Text> </Text>
<Pressable onPress={onRemove}> <Button
<Text style={{ color: theme.colors.destructive, fontSize: 12, fontWeight: "500" }}> variant="ghost"
Remove size="sm"
</Text> textStyle={{ color: theme.colors.destructive }}
</Pressable> onPress={onRemove}
>
Remove
</Button>
</View> </View>
); );
} }
@@ -1403,23 +1396,18 @@ function DaemonCard({
</View> </View>
) : null} ) : null}
<Pressable <Button
style={({ pressed, hovered }) => [ variant="ghost"
styles.hostSettingsButton, size="sm"
(pressed || hovered) && styles.hostSettingsButtonActive, style={styles.hostSettingsButton}
]} textStyle={{ fontSize: 0, lineHeight: 0 }}
leftIcon={<Settings size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
onPress={() => onOpenSettings(daemon)} onPress={() => onOpenSettings(daemon)}
testID={`daemon-card-settings-${daemon.serverId}`} testID={`daemon-card-settings-${daemon.serverId}`}
accessibilityRole="button"
accessibilityLabel={`Open settings for ${daemon.label}`} accessibilityLabel={`Open settings for ${daemon.label}`}
> >
{({ pressed, hovered }) => ( {" "}
<Settings </Button>
size={theme.iconSize.md}
color={pressed || hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View> </View>
</View> </View>
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null} {connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
-4
View File
@@ -133,8 +133,6 @@ const lightSemanticColors = {
// Legacy aliases (for gradual migration) // Legacy aliases (for gradual migration)
background: "#ffffff", background: "#ffffff",
card: "#ffffff",
cardForeground: "#09090b",
popover: "#ffffff", popover: "#ffffff",
popoverForeground: "#09090b", popoverForeground: "#09090b",
primary: "#18181b", primary: "#18181b",
@@ -204,8 +202,6 @@ const darkSemanticColors = {
// Legacy aliases (for gradual migration) // Legacy aliases (for gradual migration)
background: "#18181c", background: "#18181c",
card: "#27272a",
cardForeground: "#fafafa",
popover: "#27272a", popover: "#27272a",
popoverForeground: "#fafafa", popoverForeground: "#fafafa",
primary: "#fafafa", primary: "#fafafa",
+136 -27
View File
@@ -110,7 +110,7 @@ function Home() {
<div className="bg-black"> <div className="bg-black">
<main className="p-6 md:p-20 md:pt-8 max-w-3xl mx-auto"> <main className="p-6 md:p-20 md:pt-8 max-w-3xl mx-auto">
<Features /> <Features />
<Story /> <CLISection />
<FAQ /> <FAQ />
</main> </main>
<footer className="p-6 md:p-20 md:pt-0 max-w-3xl mx-auto"> <footer className="p-6 md:p-20 md:pt-0 max-w-3xl mx-auto">
@@ -682,33 +682,150 @@ function CodeBlock({ children }: { children: React.ReactNode }) {
) )
} }
function Story() { function CLICodeBlock({ children }: { children: string }) {
const [copied, setCopied] = React.useState(false)
function handleCopy() {
navigator.clipboard.writeText(children)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="relative bg-white/5 rounded-lg overflow-hidden">
<button
onClick={handleCopy}
className="absolute top-3 right-3 text-white/30 hover:text-white/70 transition-colors p-1"
title="Copy to clipboard"
>
{copied ? (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256">
<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256">
<path d="M216,28H88A20,20,0,0,0,68,48V76H40A20,20,0,0,0,20,96V216a20,20,0,0,0,20,20H168a20,20,0,0,0,20-20V188h28a20,20,0,0,0,20-20V48A20,20,0,0,0,216,28ZM164,212H44V100H164Zm48-48H188V96a20,20,0,0,0-20-20H92V52H212Z" />
</svg>
)}
</button>
<pre className="p-4 pr-10 text-xs leading-relaxed overflow-x-auto text-white/70 font-mono whitespace-pre">{children}</pre>
</div>
)
}
interface CLIExample {
title: string
description: string
code: string
}
const cliExamples: CLIExample[] = [
{
title: 'Launch and monitor agents',
description: 'Start agents with a task. Use --worktree to run in an isolated git branch — parallel feature work without conflicts.',
code:
`paseo run "implement user authentication"
paseo run --worktree feature-x "implement feature X"
paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task`,
},
{
title: 'Multi-agent orchestration',
description: 'The CLI is designed to be used by agents themselves. One agent can spawn others, coordinate parallel work, and synthesize results.',
code:
`# Agent A spawns parallel workers and waits for both
paseo run --detach "implement the frontend" --name frontend
paseo run --detach "implement the API layer" --name api
paseo wait frontend api
paseo run "review both branches and write an integration plan"`,
},
{
title: 'Structured output',
description: 'Get typed JSON back from any agent run. Pass a JSON schema and only the matching output is returned — no parsing required.',
code:
`result=$(paseo run \\
--output-schema '{
"type": "object",
"properties": {
"severity": { "type": "string", "enum": ["low", "medium", "high"] },
"issues": { "type": "array", "items": { "type": "string" } }
},
"required": ["severity", "issues"]
}' \\
"audit this codebase for security issues")
echo $result | jq '.severity' # "high"
echo $result | jq '.issues[0]' # "SQL injection on line 42"`,
},
{
title: 'Worker–judge loops',
description: 'Combine structured output with a loop to keep iterating until acceptance criteria are met.',
code:
`while true; do
paseo run "make all tests pass"
verdict=$(paseo run \\
--output-schema '{"type":"object","properties":{"passed":{"type":"boolean"}},"required":["passed"]}' \\
"verify tests pass and the code is production-ready")
echo "$verdict" | jq -e '.passed' && break
done`,
},
]
function CLISection() {
const [activeIndex, setActiveIndex] = React.useState(0)
const active = cliExamples[activeIndex]
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }} viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.5, ease: 'easeOut' }} transition={{ duration: 0.5, ease: 'easeOut' }}
className="pt-16 space-y-4" className="pt-16 space-y-6"
> >
<h2 className="text-2xl font-medium">Background</h2> <div className="space-y-2">
<div className="space-y-4 text-sm text-white/60"> <h2 className="text-2xl font-medium">CLI</h2>
<p> <p className="text-sm text-white/60 max-w-lg">
I started using Claude Code soon after it launched, often on my phone while going on walks Everything in the app, from your terminal. The CLI is ergonomic enough for agents to use — one agent can spawn others, wait for results, and get structured output back.
to spend less time at my desk. I'd SSH into Tmux from my phone. It worked, but the UX was
rough. Dictation was bad, the virtual keyboard was awkward, and the TUI would randomly
start flickering, which forced me to start over very often.
</p>
<p>
I started building a simple app to manage agents via voice. I continued adding features as
I needed them, and it slowly turned into what Paseo is today.
</p>
<p>
Anthropic and OpenAI added coding agents to their mobile apps since I started working on
this, but they force you into cloud sandboxes where you lose your whole setup. I also like
testing different agents, so locking myself to a single harness or model wasn't an option.
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2">
{cliExamples.map((example, i) => (
<button
key={example.title}
onClick={() => setActiveIndex(i)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
i === activeIndex
? 'border-white/40 text-white bg-white/10'
: 'border-white/15 text-white/50 hover:text-white/80 hover:border-white/30'
}`}
>
{example.title}
</button>
))}
</div>
<div className="space-y-3">
<p className="text-sm text-white/50">{active.description}</p>
<CLICodeBlock>{active.code}</CLICodeBlock>
</div>
<a
href="/docs/cli"
className="inline-flex items-center gap-1.5 text-xs text-white/40 hover:text-white/70 transition-colors"
>
Full CLI reference
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</a>
</motion.div> </motion.div>
) )
} }
@@ -736,14 +853,6 @@ function FAQ() {
own tunnel. own tunnel.
</FAQItem> </FAQItem>
<FAQItem question="What agents does it support?">Claude Code, Codex, and OpenCode.</FAQItem> <FAQItem question="What agents does it support?">Claude Code, Codex, and OpenCode.</FAQItem>
<FAQItem question="What's the business model?">There isn't one.</FAQItem>
<FAQItem question="Isn't this just more screen time?">
I won't pretend this can't be misused to squeeze every minute of your day into work. But
for me it means less time at my desk, not more. I brainstorm whole features with voice. I
kick off work at my desk, then check in from my phone during a walk. I see what an agent
needs, send a voice reply, and put my phone away.
</FAQItem>
<FAQItem question="What does Paseo mean?">Stroll, in Spanish. 🚶‍♂️</FAQItem>
</div> </div>
</motion.div> </motion.div>
) )