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 "./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 { 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
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user