refactor(agent-stream): convert ScrollView to inverted FlatList for better performance
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||||
import { View, Text, ScrollView, Pressable } from "react-native";
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Pressable,
|
||||||
|
FlatList,
|
||||||
|
ListRenderItemInfo,
|
||||||
|
NativeScrollEvent,
|
||||||
|
NativeSyntheticEvent,
|
||||||
|
InteractionManager,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
@@ -34,7 +43,7 @@ export function AgentStreamView({
|
|||||||
pendingPermissions,
|
pendingPermissions,
|
||||||
onPermissionResponse,
|
onPermissionResponse,
|
||||||
}: AgentStreamViewProps) {
|
}: AgentStreamViewProps) {
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||||
const bottomSheetRef = useRef<BottomSheetModal | null>(null);
|
const bottomSheetRef = useRef<BottomSheetModal | null>(null);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [selectedToolCall, setSelectedToolCall] =
|
const [selectedToolCall, setSelectedToolCall] =
|
||||||
@@ -42,40 +51,15 @@ export function AgentStreamView({
|
|||||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||||
const hasScrolledInitially = useRef(false);
|
const hasScrolledInitially = useRef(false);
|
||||||
const hasAutoScrolledOnce = useRef(false);
|
const hasAutoScrolledOnce = useRef(false);
|
||||||
|
const isProgrammaticScrollRef = useRef(false);
|
||||||
|
const isNearBottomRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasScrolledInitially.current = false;
|
hasScrolledInitially.current = false;
|
||||||
hasAutoScrolledOnce.current = false;
|
hasAutoScrolledOnce.current = false;
|
||||||
|
isNearBottomRef.current = true;
|
||||||
}, [agentId]);
|
}, [agentId]);
|
||||||
|
|
||||||
// Scroll to bottom immediately on initial load, then animate for new messages
|
|
||||||
useEffect(() => {
|
|
||||||
if (streamItems.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollView = scrollViewRef.current;
|
|
||||||
if (!scrollView) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasAutoScrolledOnce.current) {
|
|
||||||
// First load: jump to bottom without animation and remember we did it
|
|
||||||
scrollView.scrollToEnd({ animated: false });
|
|
||||||
hasScrolledInitially.current = true;
|
|
||||||
hasAutoScrolledOnce.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isNearBottom) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldAnimate = hasScrolledInitially.current;
|
|
||||||
scrollView.scrollToEnd({ animated: shouldAnimate });
|
|
||||||
hasScrolledInitially.current = true;
|
|
||||||
}, [streamItems, isNearBottom]);
|
|
||||||
|
|
||||||
function handleOpenToolCallDetails(toolCall: SelectedToolCall) {
|
function handleOpenToolCallDetails(toolCall: SelectedToolCall) {
|
||||||
setSelectedToolCall(toolCall);
|
setSelectedToolCall(toolCall);
|
||||||
// Delay present to next frame to ensure component is mounted
|
// Delay present to next frame to ensure component is mounted
|
||||||
@@ -88,140 +72,203 @@ export function AgentStreamView({
|
|||||||
setSelectedToolCall(null);
|
setSelectedToolCall(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll(event: any) {
|
const handleScroll = useCallback(
|
||||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const distanceFromBottom =
|
const { contentOffset } = event.nativeEvent;
|
||||||
contentSize.height - contentOffset.y - layoutMeasurement.height;
|
const threshold = Math.max(insets.bottom, 32);
|
||||||
// Consider user "at bottom" if within 10px of the end
|
const nearBottom = contentOffset.y <= threshold;
|
||||||
const nearBottom = distanceFromBottom < 10;
|
isNearBottomRef.current = nearBottom;
|
||||||
setIsNearBottom(nearBottom);
|
setIsNearBottom(nearBottom);
|
||||||
}
|
},
|
||||||
|
[insets.bottom]
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollToBottomInternal = useCallback(
|
||||||
|
({ animated }: { animated: boolean }) => {
|
||||||
|
const list = flatListRef.current;
|
||||||
|
if (!list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isProgrammaticScrollRef.current = true;
|
||||||
|
list.scrollToOffset({ offset: 0, animated });
|
||||||
|
if (!animated) {
|
||||||
|
isProgrammaticScrollRef.current = false;
|
||||||
|
isNearBottomRef.current = true;
|
||||||
|
setIsNearBottom(true);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
isProgrammaticScrollRef.current = false;
|
||||||
|
isNearBottomRef.current = true;
|
||||||
|
setIsNearBottom(true);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (streamItems.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAutoScrolledOnce.current) {
|
||||||
|
const handle = InteractionManager.runAfterInteractions(() => {
|
||||||
|
scrollToBottomInternal({ animated: false });
|
||||||
|
hasAutoScrolledOnce.current = true;
|
||||||
|
hasScrolledInitially.current = true;
|
||||||
|
});
|
||||||
|
return () => handle.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNearBottomRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldAnimate = hasScrolledInitially.current;
|
||||||
|
scrollToBottomInternal({ animated: shouldAnimate });
|
||||||
|
hasScrolledInitially.current = true;
|
||||||
|
}, [streamItems, scrollToBottomInternal]);
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
scrollToBottomInternal({ animated: true });
|
||||||
|
isNearBottomRef.current = true;
|
||||||
|
setIsNearBottom(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStreamItem({ item }: ListRenderItemInfo<StreamItem>) {
|
||||||
|
switch (item.kind) {
|
||||||
|
case "user_message":
|
||||||
|
return (
|
||||||
|
<UserMessage
|
||||||
|
message={item.text}
|
||||||
|
timestamp={item.timestamp.getTime()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "assistant_message":
|
||||||
|
return (
|
||||||
|
<AssistantMessage
|
||||||
|
message={item.text}
|
||||||
|
timestamp={item.timestamp.getTime()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "thought":
|
||||||
|
return (
|
||||||
|
<ActivityLog
|
||||||
|
type="info"
|
||||||
|
message={item.text}
|
||||||
|
timestamp={item.timestamp.getTime()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "tool_call": {
|
||||||
|
const { payload } = item;
|
||||||
|
|
||||||
|
// Extract data based on source
|
||||||
|
if (payload.source === "acp") {
|
||||||
|
const data = payload.data;
|
||||||
|
// Map ACP status to display status
|
||||||
|
const toolStatus =
|
||||||
|
data.status === "pending" || data.status === "in_progress"
|
||||||
|
? ("executing" as const)
|
||||||
|
: data.status === "completed"
|
||||||
|
? ("completed" as const)
|
||||||
|
: ("failed" as const);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolCall
|
||||||
|
toolName={data.title ?? "Unknown Tool"}
|
||||||
|
kind={data.kind}
|
||||||
|
args={data.rawInput}
|
||||||
|
result={data.rawOutput}
|
||||||
|
status={toolStatus}
|
||||||
|
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orchestrator tool call
|
||||||
|
const data = payload.data;
|
||||||
|
return (
|
||||||
|
<ToolCall
|
||||||
|
toolName={data.toolName}
|
||||||
|
args={data.arguments}
|
||||||
|
result={data.result}
|
||||||
|
status={data.status}
|
||||||
|
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "plan":
|
||||||
|
case "activity_log":
|
||||||
|
case "artifact":
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingPermissionItems = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(pendingPermissions.values()).filter(
|
||||||
|
(perm) => perm.agentId === agentId
|
||||||
|
),
|
||||||
|
[pendingPermissions, agentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const flatListData = useMemo(() => {
|
||||||
|
return [...streamItems].reverse();
|
||||||
|
}, [streamItems]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.container}>
|
<View style={stylesheet.container}>
|
||||||
{/* Content list */}
|
<FlatList
|
||||||
<ScrollView
|
ref={flatListRef}
|
||||||
ref={scrollViewRef}
|
data={flatListData}
|
||||||
style={stylesheet.scrollView}
|
renderItem={renderStreamItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 24,
|
paddingVertical: 0,
|
||||||
paddingBottom: Math.max(insets.bottom, 32),
|
flexGrow: 1,
|
||||||
}}
|
}}
|
||||||
|
style={stylesheet.list}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
>
|
ListEmptyComponent={
|
||||||
{streamItems.length === 0 ? (
|
<View style={stylesheet.invertedWrapper}>
|
||||||
<View style={stylesheet.emptyState}>
|
<View style={stylesheet.emptyState}>
|
||||||
<Text style={stylesheet.emptyStateText}>
|
<Text style={stylesheet.emptyStateText}>
|
||||||
Start chatting with this agent...
|
Start chatting with this agent...
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
}
|
||||||
streamItems.map((item) => {
|
ListHeaderComponent={
|
||||||
switch (item.kind) {
|
<View style={stylesheet.invertedWrapper}>
|
||||||
case "user_message":
|
{pendingPermissionItems.length > 0 ? (
|
||||||
return (
|
<View style={stylesheet.permissionsContainer}>
|
||||||
<UserMessage
|
{pendingPermissionItems.map((permission) => (
|
||||||
key={item.id}
|
<PermissionRequestCard
|
||||||
message={item.text}
|
key={permission.requestId}
|
||||||
timestamp={item.timestamp.getTime()}
|
permission={permission}
|
||||||
|
onResponse={onPermissionResponse}
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
|
</View>
|
||||||
case "assistant_message":
|
) : null}
|
||||||
return (
|
</View>
|
||||||
<AssistantMessage
|
}
|
||||||
key={item.id}
|
extraData={pendingPermissionItems.length}
|
||||||
message={item.text}
|
maintainVisibleContentPosition={{
|
||||||
timestamp={item.timestamp.getTime()}
|
minIndexForVisible: 0,
|
||||||
/>
|
autoscrollToTopThreshold: 40,
|
||||||
);
|
}}
|
||||||
|
initialNumToRender={12}
|
||||||
case "thought":
|
windowSize={10}
|
||||||
return (
|
inverted
|
||||||
<ActivityLog
|
/>
|
||||||
key={item.id}
|
|
||||||
type="info"
|
|
||||||
message={item.text}
|
|
||||||
timestamp={item.timestamp.getTime()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "tool_call": {
|
|
||||||
const { payload } = item;
|
|
||||||
|
|
||||||
// Extract data based on source
|
|
||||||
if (payload.source === "acp") {
|
|
||||||
const data = payload.data;
|
|
||||||
// Map ACP status to display status
|
|
||||||
const toolStatus =
|
|
||||||
data.status === "pending" || data.status === "in_progress"
|
|
||||||
? ("executing" as const)
|
|
||||||
: data.status === "completed"
|
|
||||||
? ("completed" as const)
|
|
||||||
: ("failed" as const);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToolCall
|
|
||||||
key={item.id}
|
|
||||||
toolName={data.title ?? "Unknown Tool"}
|
|
||||||
kind={data.kind}
|
|
||||||
args={data.rawInput}
|
|
||||||
result={data.rawOutput}
|
|
||||||
status={toolStatus}
|
|
||||||
onOpenDetails={() =>
|
|
||||||
handleOpenToolCallDetails({ payload })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Orchestrator tool call
|
|
||||||
const data = payload.data;
|
|
||||||
return (
|
|
||||||
<ToolCall
|
|
||||||
key={item.id}
|
|
||||||
toolName={data.toolName}
|
|
||||||
args={data.arguments}
|
|
||||||
result={data.result}
|
|
||||||
status={data.status}
|
|
||||||
onOpenDetails={() =>
|
|
||||||
handleOpenToolCallDetails({ payload })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "plan":
|
|
||||||
// TODO: Render plan component
|
|
||||||
return null;
|
|
||||||
|
|
||||||
case "activity_log":
|
|
||||||
case "artifact":
|
|
||||||
// These are orchestrator-only, skip for now
|
|
||||||
return null;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Render pending permissions for this agent */}
|
|
||||||
{Array.from(pendingPermissions.values())
|
|
||||||
.filter((perm) => perm.agentId === agentId)
|
|
||||||
.map((permission) => (
|
|
||||||
<PermissionRequestCard
|
|
||||||
key={permission.requestId}
|
|
||||||
permission={permission}
|
|
||||||
onResponse={onPermissionResponse}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* Scroll to bottom button */}
|
{/* Scroll to bottom button */}
|
||||||
{!isNearBottom && (
|
{!isNearBottom && (
|
||||||
@@ -234,7 +281,10 @@ export function AgentStreamView({
|
|||||||
style={stylesheet.scrollToBottomButton}
|
style={stylesheet.scrollToBottomButton}
|
||||||
onPress={scrollToBottom}
|
onPress={scrollToBottom}
|
||||||
>
|
>
|
||||||
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
|
<ChevronDown
|
||||||
|
size={24}
|
||||||
|
color={stylesheet.scrollToBottomIcon.color}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
@@ -400,7 +450,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.background,
|
backgroundColor: theme.colors.background,
|
||||||
},
|
},
|
||||||
scrollView: {
|
list: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
},
|
},
|
||||||
@@ -410,6 +460,13 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
paddingVertical: theme.spacing[12],
|
paddingVertical: theme.spacing[12],
|
||||||
},
|
},
|
||||||
|
permissionsContainer: {
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
invertedWrapper: {
|
||||||
|
transform: [{ scaleY: -1 }],
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
emptyStateText: {
|
emptyStateText: {
|
||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export type AgentType = "claude" | "codex";
|
||||||
|
|
||||||
|
export interface AgentModeDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentTypeDefinition {
|
||||||
|
id: AgentType;
|
||||||
|
label: string;
|
||||||
|
spawn: {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
};
|
||||||
|
supportsSessionPersistence: boolean;
|
||||||
|
availableModes: AgentModeDefinition[];
|
||||||
|
defaultModeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentTypeDefinitions: Record<AgentType, AgentTypeDefinition> = {
|
||||||
|
claude: {
|
||||||
|
id: "claude",
|
||||||
|
label: "Claude Code",
|
||||||
|
spawn: {
|
||||||
|
command: "npx",
|
||||||
|
args: ["@boudra/claude-code-acp"],
|
||||||
|
},
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
availableModes: [
|
||||||
|
{
|
||||||
|
id: "plan",
|
||||||
|
name: "Plan",
|
||||||
|
description: "Plan and design before implementing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bypassPermissions",
|
||||||
|
name: "Bypass Permissions",
|
||||||
|
description: "Skip permission prompts for faster execution",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultModeId: "plan",
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
spawn: {
|
||||||
|
command: "npx",
|
||||||
|
args: ["@zed-industries/codex-acp"],
|
||||||
|
},
|
||||||
|
supportsSessionPersistence: false,
|
||||||
|
availableModes: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
name: "Default",
|
||||||
|
description: "Standard coding mode",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultModeId: "default",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getAgentTypeDefinition(type: AgentType): AgentTypeDefinition {
|
||||||
|
return agentTypeDefinitions[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentModes(type: AgentType): AgentModeDefinition[] {
|
||||||
|
return agentTypeDefinitions[type].availableModes;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user