refactor: extract stream render model and segment-based rendering
This commit is contained in:
@@ -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 "./agent-stream-render-model";
|
||||
|
||||
@@ -53,13 +53,12 @@ import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { QuestionFormCard } from "./question-form-card";
|
||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||
import {
|
||||
buildAgentStreamRenderModel,
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
getStreamEdgeSlotProps,
|
||||
getStreamNeighborItem,
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
resolveStreamRenderStrategy,
|
||||
type StreamEdgeSlotProps,
|
||||
type AgentStreamRenderModel,
|
||||
type StreamSegmentRenderers,
|
||||
type StreamViewportHandle,
|
||||
} from "./agent-stream-render-strategy";
|
||||
import {
|
||||
@@ -199,12 +198,14 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
]
|
||||
);
|
||||
|
||||
const orderedStreamItems = useMemo(() => {
|
||||
return orderTailForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
streamItems,
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
tail: streamItems,
|
||||
head: streamHead ?? [],
|
||||
platform: Platform.OS === "web" ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [streamItems, streamRenderStrategy]);
|
||||
}, [isMobile, streamHead, streamItems]);
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToBottom(reason = "jump-to-bottom") {
|
||||
viewportRef.current?.scrollToBottom(reason);
|
||||
@@ -218,68 +219,48 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
viewportRef.current?.scrollToBottom("jump-to-bottom");
|
||||
}
|
||||
|
||||
const orderedStreamHead = useMemo(() => {
|
||||
return orderHeadForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
streamHead: streamHead ?? [],
|
||||
});
|
||||
}, [streamHead, streamRenderStrategy]);
|
||||
|
||||
const tightGap = theme.spacing[1]; // 4px
|
||||
const looseGap = theme.spacing[4]; // 16px
|
||||
|
||||
const getGapBelow = useCallback(
|
||||
(item: StreamItem, index: number, items: StreamItem[]) => {
|
||||
const belowItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
if (!belowItem) {
|
||||
const getGapBetween = useCallback(
|
||||
(item: StreamItem | null, belowItem: StreamItem | null) => {
|
||||
if (!item || !belowItem) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Same type groups get tight gap (4px)
|
||||
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
|
||||
return tightGap;
|
||||
}
|
||||
|
||||
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
||||
return tightGap;
|
||||
}
|
||||
|
||||
// Give user messages more breathing room before tool sequences.
|
||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||
return looseGap;
|
||||
}
|
||||
|
||||
// Keep tool sequences visually connected to the preceding user/assistant message.
|
||||
if (
|
||||
(item.kind === "user_message" || item.kind === "assistant_message") &&
|
||||
isToolSequenceItem(belowItem)
|
||||
) {
|
||||
return tightGap;
|
||||
}
|
||||
|
||||
// Keep todo lists visually connected to the following tool sequence (symmetry).
|
||||
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
|
||||
return tightGap;
|
||||
}
|
||||
|
||||
// Keep tool sequences visually connected to the assistant response (symmetry).
|
||||
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
||||
return tightGap;
|
||||
}
|
||||
|
||||
// Different types get loose gap (16px)
|
||||
return looseGap;
|
||||
},
|
||||
[looseGap, streamRenderStrategy, tightGap]
|
||||
[looseGap, tightGap]
|
||||
);
|
||||
|
||||
const renderStreamItemContent = useCallback(
|
||||
(item: StreamItem, index: number, items: StreamItem[]) => {
|
||||
(
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[],
|
||||
seamAboveItem: StreamItem | null = null
|
||||
) => {
|
||||
const handleInlineDetailsExpandedChange = (expanded: boolean) => {
|
||||
if (
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()
|
||||
@@ -299,12 +280,13 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
|
||||
switch (item.kind) {
|
||||
case "user_message": {
|
||||
const aboveItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "above",
|
||||
});
|
||||
const aboveItem =
|
||||
getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "above",
|
||||
}) ?? seamAboveItem ?? undefined;
|
||||
const belowItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
@@ -426,19 +408,24 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
);
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gapBelow = getGapBelow(item, index, items);
|
||||
const nextItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
const gapBelow = getGapBetween(item, nextItem ?? null);
|
||||
const isEndOfAssistantTurn =
|
||||
item.kind === "assistant_message" &&
|
||||
(nextItem?.kind === "user_message" ||
|
||||
@@ -460,7 +447,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
);
|
||||
},
|
||||
[
|
||||
getGapBelow,
|
||||
getGapBetween,
|
||||
renderStreamItemContent,
|
||||
agent.status,
|
||||
streamRenderStrategy,
|
||||
@@ -544,101 +531,58 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
|
||||
|
||||
const showWorkingIndicator = agent.status === "running";
|
||||
const showBottomBar = showWorkingIndicator;
|
||||
|
||||
const listEdgeSlotComponent = useMemo(() => {
|
||||
const hasPermissions = pendingPermissionItems.length > 0;
|
||||
const hasHeadItems = orderedStreamHead.length > 0;
|
||||
|
||||
if (!hasPermissions && !showBottomBar && !hasHeadItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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}
|
||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||
const pendingPermissionsNode =
|
||||
pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.key}
|
||||
permission={permission}
|
||||
client={client}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null;
|
||||
const workingIndicatorNode = showWorkingIndicator ? (
|
||||
<View style={stylesheet.bottomBarWrapper}>
|
||||
<WorkingIndicator />
|
||||
</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,
|
||||
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 hasPermissions = pendingPermissionItems.length > 0;
|
||||
const hasHeadItems = orderedStreamHead.length > 0;
|
||||
if (hasPermissions || hasHeadItems) {
|
||||
if (
|
||||
renderModel.boundary.hasVirtualizedHistory ||
|
||||
renderModel.boundary.hasMountedHistory ||
|
||||
renderModel.boundary.hasLiveHead ||
|
||||
renderModel.auxiliary.pendingPermissions ||
|
||||
renderModel.auxiliary.workingIndicator
|
||||
) {
|
||||
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 (
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
@@ -646,12 +590,86 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, [
|
||||
agent.status,
|
||||
pendingPermissionItems.length,
|
||||
orderedStreamHead,
|
||||
theme.colors.foregroundMuted,
|
||||
]);
|
||||
}, [renderModel]);
|
||||
|
||||
const historyItems = renderModel.history;
|
||||
const liveHeadItems = renderModel.segments.liveHead;
|
||||
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 =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
@@ -663,8 +681,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
{streamRenderStrategy.render({
|
||||
agentId,
|
||||
rows: orderedStreamItems,
|
||||
renderRow: renderStreamItem,
|
||||
segments: renderModel.segments,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
@@ -674,7 +693,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
edgeSlotProps: listEdgeSlotProps,
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
{!isNearBottom && (
|
||||
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
useBottomAnchorController,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
|
||||
import type {
|
||||
StreamRenderInput,
|
||||
StreamStrategy,
|
||||
@@ -30,8 +28,9 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
||||
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
|
||||
const {
|
||||
agentId,
|
||||
rows,
|
||||
renderRow,
|
||||
segments,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
@@ -40,7 +39,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
scrollEnabled,
|
||||
listStyle,
|
||||
baseListContentContainerStyle,
|
||||
edgeSlotProps,
|
||||
strategy,
|
||||
} = props;
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
@@ -58,6 +56,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
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(() => {
|
||||
if (nativeViewportSettlingFrameIdRef.current !== null) {
|
||||
cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current);
|
||||
@@ -90,19 +95,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
[isNativeViewportSettling, strategy]
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback((animated: boolean) => {
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated,
|
||||
});
|
||||
scrollOffsetYRef.current = 0;
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
offsetY: 0,
|
||||
};
|
||||
onNearBottomChange(true);
|
||||
}, [onNearBottomChange]);
|
||||
const scrollToBottom = useCallback(
|
||||
(animated: boolean) => {
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated,
|
||||
});
|
||||
scrollOffsetYRef.current = 0;
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
offsetY: 0,
|
||||
};
|
||||
onNearBottomChange(true);
|
||||
},
|
||||
[onNearBottomChange]
|
||||
);
|
||||
|
||||
const bottomAnchorController = useBottomAnchorController({
|
||||
agentId,
|
||||
@@ -163,7 +171,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
|
||||
useEffect(() => {
|
||||
bottomAnchorController.prepareForStickyContentChange();
|
||||
}, [bottomAnchorController, rows]);
|
||||
}, [bottomAnchorController, historyRows, segments.liveHead]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -272,30 +280,50 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
||||
const rendered = renderRow(item, index, rows);
|
||||
const rendered = renderers.renderHistoryMountedRow(item, index, historyRows);
|
||||
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 (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={rows}
|
||||
data={historyRows}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
{...edgeSlotProps}
|
||||
ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined}
|
||||
contentContainerStyle={baseListContentContainerStyle}
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
ListEmptyComponent={
|
||||
listEmptyComponent ? () => <Fragment>{listEmptyComponent}</Fragment> : undefined
|
||||
}
|
||||
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
Fragment,
|
||||
type CSSProperties,
|
||||
createElement,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -11,13 +9,7 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { measureElement as measureVirtualElement, useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { View } from 'react-native'
|
||||
import {
|
||||
estimateStreamItemHeight,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
splitWebVirtualizedHistory,
|
||||
} from './agent-stream-web-virtualization'
|
||||
import { estimateStreamItemHeight } from './agent-stream-web-virtualization'
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from './stream-strategy'
|
||||
import { createStreamStrategy } from './stream-strategy'
|
||||
|
||||
@@ -68,28 +60,6 @@ function isScrollContainerAtBottom(
|
||||
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(
|
||||
scrollContainer: HTMLElement,
|
||||
behavior: ScrollBehaviorLike = 'auto'
|
||||
@@ -127,18 +97,15 @@ function isScrollContainerOverscrolledPastBottom(
|
||||
|
||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||
const {
|
||||
rows,
|
||||
renderRow,
|
||||
segments,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange,
|
||||
scrollEnabled,
|
||||
listStyle,
|
||||
baseListContentContainerStyle,
|
||||
forwardListContentContainerStyle,
|
||||
edgeSlotProps,
|
||||
isMobileBreakpoint,
|
||||
} = props
|
||||
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
|
||||
@@ -147,7 +114,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const contentRef = useRef<HTMLElement | null>(null)
|
||||
const [followOutput, setFollowOutputr] = useState(true)
|
||||
const setFollowOutput = (value: boolean) => {
|
||||
console.trace('setFollowOutput', value)
|
||||
setFollowOutputr(value)
|
||||
return value
|
||||
}
|
||||
@@ -160,32 +126,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null)
|
||||
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics()
|
||||
const showDesktopWebScrollbar = !isMobileBreakpoint
|
||||
const shouldUseVirtualizer =
|
||||
!isMobileBreakpoint && rows.length > getWebPartialVirtualizationThreshold()
|
||||
const shouldUseVirtualizer = segments.historyVirtualized.length > 0
|
||||
const {
|
||||
renderHistoryVirtualizedRow,
|
||||
renderHistoryMountedRow,
|
||||
renderLiveHeadRow,
|
||||
renderLiveAuxiliary,
|
||||
} = renderers
|
||||
|
||||
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 isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: virtualizedEntries.length,
|
||||
count: segments.historyVirtualized.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
getItemKey: (index: number) => virtualizedEntries[index]?.item.id ?? index,
|
||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||
estimateSize: (index: number) => {
|
||||
const row = virtualizedEntries[index]?.item
|
||||
const row = segments.historyVirtualized[index]
|
||||
return row ? estimateStreamItemHeight(row) : 120
|
||||
},
|
||||
measureElement: measureVirtualElement,
|
||||
@@ -220,7 +179,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [])
|
||||
|
||||
const scrollMessagesToBottom = useCallback(
|
||||
(behavior: ScrollBehaviorLike = 'auto', source = 'unknown') => {
|
||||
(behavior: ScrollBehaviorLike = 'auto') => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
@@ -236,7 +195,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
)
|
||||
|
||||
const scheduleStickToBottom = useCallback(
|
||||
(source = 'unknown') => {
|
||||
() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||
return
|
||||
@@ -249,15 +208,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
scrollMessagesToBottom('auto', source)
|
||||
scrollMessagesToBottom('auto')
|
||||
})
|
||||
},
|
||||
[scrollMessagesToBottom]
|
||||
)
|
||||
|
||||
const forceStickToBottom = useCallback(() => {
|
||||
cancelPendingStickToBottom()
|
||||
scrollMessagesToBottom('auto', 'force')
|
||||
scheduleStickToBottom('force')
|
||||
scrollMessagesToBottom('auto')
|
||||
scheduleStickToBottom()
|
||||
}, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom])
|
||||
|
||||
const updateScrollMetrics = useCallback(() => {
|
||||
@@ -303,7 +263,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
|
||||
const currentScrollTop = scrollContainer.scrollTop
|
||||
const isNearBottom = syncNearBottom(scrollContainer, onNearBottomChange)
|
||||
const isAtBottom = isScrollContainerAtBottom(scrollContainer)
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON
|
||||
|
||||
@@ -325,7 +284,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop
|
||||
updateScrollMetrics()
|
||||
}, [cancelPendingStickToBottom, onNearBottomChange, updateScrollMetrics])
|
||||
}, [cancelPendingStickToBottom, updateScrollMetrics])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActivationReady) {
|
||||
@@ -334,13 +293,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
setFollowOutput(true)
|
||||
forceStickToBottom()
|
||||
const timeout = window.setTimeout(() => {
|
||||
console.log('timeout', {
|
||||
followOutputRef: followOutputRef.current,
|
||||
scrollContainerRef: scrollContainerRef.current,
|
||||
isScrollContainerNearBottom: scrollContainerRef.current
|
||||
? isScrollContainerNearBottom(scrollContainerRef.current)
|
||||
: null,
|
||||
})
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -351,7 +303,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (isScrollContainerNearBottom(scrollContainer)) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom('activation-timeout')
|
||||
scheduleStickToBottom()
|
||||
}, WEB_BOTTOM_SETTLE_TIMEOUT_MS)
|
||||
return () => {
|
||||
window.clearTimeout(timeout)
|
||||
@@ -362,22 +314,30 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom('rows-effect')
|
||||
}, [rows, scheduleStickToBottom])
|
||||
scheduleStickToBottom()
|
||||
}, [
|
||||
scheduleStickToBottom,
|
||||
segments.historyMounted,
|
||||
segments.historyVirtualized,
|
||||
segments.liveHead,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!followOutputRef.current) {
|
||||
if (!followOutputRef.current || !shouldUseVirtualizer) {
|
||||
return
|
||||
}
|
||||
if (!webVirtualizedHistoryWindow) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom('virtual-total-size-effect')
|
||||
}, [scheduleStickToBottom, virtualTotalSize, webVirtualizedHistoryWindow])
|
||||
scheduleStickToBottom()
|
||||
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize])
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollMetrics()
|
||||
}, [updateScrollMetrics, virtualTotalSize, rows.length])
|
||||
}, [
|
||||
segments.historyMounted.length,
|
||||
segments.historyVirtualized.length,
|
||||
segments.liveHead.length,
|
||||
updateScrollMetrics,
|
||||
virtualTotalSize,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
@@ -392,7 +352,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom('resize-observer')
|
||||
scheduleStickToBottom()
|
||||
})
|
||||
observer.observe(scrollContainer)
|
||||
if (contentNode) {
|
||||
@@ -478,7 +438,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom('prepare-for-viewport-change')
|
||||
scheduleStickToBottom()
|
||||
},
|
||||
}
|
||||
viewportRef.current = handle
|
||||
@@ -488,7 +448,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
cancelPendingStickToBottom()
|
||||
}
|
||||
}, [cancelPendingStickToBottom, scheduleStickToBottom, forceStickToBottom, viewportRef])
|
||||
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef])
|
||||
|
||||
const contentContainerStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
@@ -513,11 +473,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}),
|
||||
[scrollEnabled]
|
||||
)
|
||||
const headerEdgeContent = renderEdge(edgeSlotProps.ListHeaderComponent)
|
||||
const footerEdgeContent = renderEdge(
|
||||
edgeSlotProps.ListFooterComponent,
|
||||
edgeSlotProps.ListFooterComponentStyle as CSSProperties | undefined
|
||||
)
|
||||
const virtualRowsContainerStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
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 (
|
||||
<>
|
||||
@@ -556,12 +535,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}}
|
||||
style={contentContainerStyle}
|
||||
>
|
||||
{headerEdgeContent}
|
||||
{webVirtualizedHistoryWindow ? (
|
||||
{shouldUseVirtualizer ? (
|
||||
<div style={virtualRowsContainerStyle}>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const entry = virtualizedEntries[virtualRow.index]
|
||||
if (!entry) {
|
||||
const item = segments.historyVirtualized[virtualRow.index]
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
@@ -571,17 +549,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
{renderRow(entry.item, entry.index, rows)}
|
||||
{renderHistoryVirtualizedRow(
|
||||
item,
|
||||
virtualRow.index,
|
||||
segments.historyVirtualized
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{mountedEntries.map((entry) => (
|
||||
<Fragment key={entry.item.id}>{renderRow(entry.item, entry.index, rows)}</Fragment>
|
||||
))}
|
||||
{rows.length === 0 ? listEmptyComponent : null}
|
||||
{footerEdgeContent}
|
||||
{mountedHistoryRows}
|
||||
{boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? (
|
||||
<div style={{ height: boundary.historyToHeadGap, width: '100%' }} />
|
||||
) : null}
|
||||
{liveHeadRows}
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
</div>
|
||||
<WebDesktopScrollbarOverlay
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type {
|
||||
StreamHistoryBoundary,
|
||||
StreamRenderSegments,
|
||||
} from "./agent-stream-render-model";
|
||||
import type {
|
||||
BottomAnchorLocalRequest,
|
||||
BottomAnchorRouteRequest,
|
||||
@@ -44,10 +48,30 @@ export type StreamViewportHandle = {
|
||||
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 = {
|
||||
agentId: string;
|
||||
rows: StreamItem[];
|
||||
renderRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
|
||||
segments: StreamRenderSegments;
|
||||
boundary: StreamHistoryBoundary;
|
||||
renderers: StreamSegmentRenderers;
|
||||
listEmptyComponent: ReactNode;
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
|
||||
@@ -57,7 +81,6 @@ export type StreamRenderInput = {
|
||||
listStyle: StyleProp<ViewStyle>;
|
||||
baseListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
forwardListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
edgeSlotProps: StreamEdgeSlotProps;
|
||||
};
|
||||
|
||||
export type ResolveStreamRenderStrategyInput = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 QRCode from "qrcode";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
@@ -336,14 +336,17 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Built-in daemon</Text>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={styles.sectionLinkText}
|
||||
style={styles.sectionLink}
|
||||
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
|
||||
accessibilityLabel="Open advanced daemon settings"
|
||||
>
|
||||
<Text style={styles.sectionLinkText}>Advanced settings</Text>
|
||||
<ArrowUpRight size={14} color={styles.sectionLinkText.color} />
|
||||
</Pressable>
|
||||
Advanced settings
|
||||
</Button>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={styles.row}>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
Alert,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
@@ -221,17 +220,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
hostSettingsButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
paddingVertical: 0,
|
||||
paddingHorizontal: 0,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "transparent",
|
||||
backgroundColor: "transparent",
|
||||
gap: 0,
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
hostSettingsButtonActive: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
advancedTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -256,20 +250,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
// Add host button
|
||||
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",
|
||||
},
|
||||
addButtonText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
// Add/Edit form
|
||||
formCard: {
|
||||
@@ -712,16 +696,19 @@ export default function SettingsScreen() {
|
||||
})
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
style={styles.addButton}
|
||||
textStyle={styles.addButtonText}
|
||||
onPress={() => {
|
||||
setAddConnectionTargetServerId(null);
|
||||
setPendingEditReopenServerId(null);
|
||||
setIsAddHostMethodVisible(true);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.addButtonText}>+ Add connection</Text>
|
||||
</Pressable>
|
||||
+ Add connection
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<AddHostMethodModal
|
||||
@@ -1147,12 +1134,15 @@ function HostDetailModal({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Pressable
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
style={styles.addButton}
|
||||
textStyle={styles.addButtonText}
|
||||
onPress={onAddConnection}
|
||||
>
|
||||
<Text style={styles.addButtonText}>+ Add connection</Text>
|
||||
</Pressable>
|
||||
+ Add connection
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -1312,11 +1302,14 @@ function ConnectionRow({
|
||||
<Text style={{ color: latencyColor, fontSize: 11 }}>
|
||||
{latencyText}
|
||||
</Text>
|
||||
<Pressable onPress={onRemove}>
|
||||
<Text style={{ color: theme.colors.destructive, fontSize: 12, fontWeight: "500" }}>
|
||||
Remove
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
textStyle={{ color: theme.colors.destructive }}
|
||||
onPress={onRemove}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1403,23 +1396,18 @@ function DaemonCard({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.hostSettingsButton,
|
||||
(pressed || hovered) && styles.hostSettingsButtonActive,
|
||||
]}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
style={styles.hostSettingsButton}
|
||||
textStyle={{ fontSize: 0, lineHeight: 0 }}
|
||||
leftIcon={<Settings size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
onPress={() => onOpenSettings(daemon)}
|
||||
testID={`daemon-card-settings-${daemon.serverId}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open settings for ${daemon.label}`}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Settings
|
||||
size={theme.iconSize.md}
|
||||
color={pressed || hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
{" "}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
|
||||
|
||||
@@ -133,8 +133,6 @@ const lightSemanticColors = {
|
||||
|
||||
// Legacy aliases (for gradual migration)
|
||||
background: "#ffffff",
|
||||
card: "#ffffff",
|
||||
cardForeground: "#09090b",
|
||||
popover: "#ffffff",
|
||||
popoverForeground: "#09090b",
|
||||
primary: "#18181b",
|
||||
@@ -204,8 +202,6 @@ const darkSemanticColors = {
|
||||
|
||||
// Legacy aliases (for gradual migration)
|
||||
background: "#18181c",
|
||||
card: "#27272a",
|
||||
cardForeground: "#fafafa",
|
||||
popover: "#27272a",
|
||||
popoverForeground: "#fafafa",
|
||||
primary: "#fafafa",
|
||||
|
||||
@@ -110,7 +110,7 @@ function Home() {
|
||||
<div className="bg-black">
|
||||
<main className="p-6 md:p-20 md:pt-8 max-w-3xl mx-auto">
|
||||
<Features />
|
||||
<Story />
|
||||
<CLISection />
|
||||
<FAQ />
|
||||
</main>
|
||||
<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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-60px' }}
|
||||
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-4 text-sm text-white/60">
|
||||
<p>
|
||||
I started using Claude Code soon after it launched, often on my phone while going on walks
|
||||
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.
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-medium">CLI</h2>
|
||||
<p className="text-sm text-white/60 max-w-lg">
|
||||
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.
|
||||
</p>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -736,14 +853,6 @@ function FAQ() {
|
||||
own tunnel.
|
||||
</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>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user