refactor(agent-stream): convert ScrollView to inverted FlatList for better performance

This commit is contained in:
Mohamed Boudra
2025-10-26 17:04:22 +01:00
parent 2c1b5b13fe
commit 700840da5a
2 changed files with 281 additions and 155 deletions
+144 -87
View File
@@ -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,45 +72,75 @@ 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);
} }
return ( function renderStreamItem({ item }: ListRenderItemInfo<StreamItem>) {
<View style={stylesheet.container}>
{/* Content list */}
<ScrollView
ref={scrollViewRef}
style={stylesheet.scrollView}
contentContainerStyle={{
paddingTop: 24,
paddingBottom: Math.max(insets.bottom, 32),
}}
onScroll={handleScroll}
scrollEventThrottle={16}
>
{streamItems.length === 0 ? (
<View style={stylesheet.emptyState}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
) : (
streamItems.map((item) => {
switch (item.kind) { switch (item.kind) {
case "user_message": case "user_message":
return ( return (
<UserMessage <UserMessage
key={item.id}
message={item.text} message={item.text}
timestamp={item.timestamp.getTime()} timestamp={item.timestamp.getTime()}
/> />
@@ -135,7 +149,6 @@ export function AgentStreamView({
case "assistant_message": case "assistant_message":
return ( return (
<AssistantMessage <AssistantMessage
key={item.id}
message={item.text} message={item.text}
timestamp={item.timestamp.getTime()} timestamp={item.timestamp.getTime()}
/> />
@@ -144,7 +157,6 @@ export function AgentStreamView({
case "thought": case "thought":
return ( return (
<ActivityLog <ActivityLog
key={item.id}
type="info" type="info"
message={item.text} message={item.text}
timestamp={item.timestamp.getTime()} timestamp={item.timestamp.getTime()}
@@ -167,61 +179,96 @@ export function AgentStreamView({
return ( return (
<ToolCall <ToolCall
key={item.id}
toolName={data.title ?? "Unknown Tool"} toolName={data.title ?? "Unknown Tool"}
kind={data.kind} kind={data.kind}
args={data.rawInput} args={data.rawInput}
result={data.rawOutput} result={data.rawOutput}
status={toolStatus} status={toolStatus}
onOpenDetails={() => onOpenDetails={() => handleOpenToolCallDetails({ payload })}
handleOpenToolCallDetails({ payload })
}
/> />
); );
} else { }
// Orchestrator tool call // Orchestrator tool call
const data = payload.data; const data = payload.data;
return ( return (
<ToolCall <ToolCall
key={item.id}
toolName={data.toolName} toolName={data.toolName}
args={data.arguments} args={data.arguments}
result={data.result} result={data.result}
status={data.status} status={data.status}
onOpenDetails={() => onOpenDetails={() => handleOpenToolCallDetails({ payload })}
handleOpenToolCallDetails({ payload })
}
/> />
); );
} }
}
case "plan": case "plan":
// TODO: Render plan component
return null;
case "activity_log": case "activity_log":
case "artifact": case "artifact":
// These are orchestrator-only, skip for now
return null;
default: default:
return null; return null;
} }
}) }
)}
{/* Render pending permissions for this agent */} const pendingPermissionItems = useMemo(
{Array.from(pendingPermissions.values()) () =>
.filter((perm) => perm.agentId === agentId) Array.from(pendingPermissions.values()).filter(
.map((permission) => ( (perm) => perm.agentId === agentId
),
[pendingPermissions, agentId]
);
const flatListData = useMemo(() => {
return [...streamItems].reverse();
}, [streamItems]);
return (
<View style={stylesheet.container}>
<FlatList
ref={flatListRef}
data={flatListData}
renderItem={renderStreamItem}
keyExtractor={(item) => item.id}
contentContainerStyle={{
paddingVertical: 0,
flexGrow: 1,
}}
style={stylesheet.list}
onScroll={handleScroll}
scrollEventThrottle={16}
ListEmptyComponent={
<View style={stylesheet.invertedWrapper}>
<View style={stylesheet.emptyState}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
</View>
}
ListHeaderComponent={
<View style={stylesheet.invertedWrapper}>
{pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard <PermissionRequestCard
key={permission.requestId} key={permission.requestId}
permission={permission} permission={permission}
onResponse={onPermissionResponse} onResponse={onPermissionResponse}
/> />
))} ))}
</ScrollView> </View>
) : null}
</View>
}
extraData={pendingPermissionItems.length}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: 40,
}}
initialNumToRender={12}
windowSize={10}
inverted
/>
{/* 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;
}