From 700840da5a69b592279a818b7eeb55a10d6c732c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 26 Oct 2025 17:04:22 +0100 Subject: [PATCH] refactor(agent-stream): convert ScrollView to inverted FlatList for better performance --- .../app/src/components/agent-stream-view.tsx | 367 ++++++++++-------- .../server/agents/agent-type-definitions.ts | 69 ++++ 2 files changed, 281 insertions(+), 155 deletions(-) create mode 100644 packages/app/src/server/agents/agent-type-definitions.ts diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 5aa41e7e9..6c8ac90e2 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -1,5 +1,14 @@ -import { useEffect, useRef, useState } from "react"; -import { View, Text, ScrollView, Pressable } from "react-native"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; +import { + View, + Text, + Pressable, + FlatList, + ListRenderItemInfo, + NativeScrollEvent, + NativeSyntheticEvent, + InteractionManager, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { BottomSheetModal } from "@gorhom/bottom-sheet"; @@ -34,7 +43,7 @@ export function AgentStreamView({ pendingPermissions, onPermissionResponse, }: AgentStreamViewProps) { - const scrollViewRef = useRef(null); + const flatListRef = useRef>(null); const bottomSheetRef = useRef(null); const insets = useSafeAreaInsets(); const [selectedToolCall, setSelectedToolCall] = @@ -42,40 +51,15 @@ export function AgentStreamView({ const [isNearBottom, setIsNearBottom] = useState(true); const hasScrolledInitially = useRef(false); const hasAutoScrolledOnce = useRef(false); + const isProgrammaticScrollRef = useRef(false); + const isNearBottomRef = useRef(true); useEffect(() => { hasScrolledInitially.current = false; hasAutoScrolledOnce.current = false; + isNearBottomRef.current = true; }, [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) { setSelectedToolCall(toolCall); // Delay present to next frame to ensure component is mounted @@ -88,140 +72,203 @@ export function AgentStreamView({ setSelectedToolCall(null); } - function handleScroll(event: any) { - const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - const distanceFromBottom = - contentSize.height - contentOffset.y - layoutMeasurement.height; - // Consider user "at bottom" if within 10px of the end - const nearBottom = distanceFromBottom < 10; - setIsNearBottom(nearBottom); - } + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { contentOffset } = event.nativeEvent; + const threshold = Math.max(insets.bottom, 32); + const nearBottom = contentOffset.y <= threshold; + isNearBottomRef.current = 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() { - scrollViewRef.current?.scrollToEnd({ animated: true }); + scrollToBottomInternal({ animated: true }); + isNearBottomRef.current = true; + setIsNearBottom(true); } + function renderStreamItem({ item }: ListRenderItemInfo) { + switch (item.kind) { + case "user_message": + return ( + + ); + + case "assistant_message": + return ( + + ); + + case "thought": + return ( + + ); + + 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 ( + handleOpenToolCallDetails({ payload })} + /> + ); + } + + // Orchestrator tool call + const data = payload.data; + return ( + 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 ( - {/* Content list */} - item.id} contentContainerStyle={{ - paddingTop: 24, - paddingBottom: Math.max(insets.bottom, 32), + paddingVertical: 0, + flexGrow: 1, }} + style={stylesheet.list} onScroll={handleScroll} scrollEventThrottle={16} - > - {streamItems.length === 0 ? ( - - - Start chatting with this agent... - + ListEmptyComponent={ + + + + Start chatting with this agent... + + - ) : ( - streamItems.map((item) => { - switch (item.kind) { - case "user_message": - return ( - + {pendingPermissionItems.length > 0 ? ( + + {pendingPermissionItems.map((permission) => ( + - ); - - case "assistant_message": - return ( - - ); - - case "thought": - return ( - - ); - - 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 ( - - handleOpenToolCallDetails({ payload }) - } - /> - ); - } else { - // Orchestrator tool call - const data = payload.data; - return ( - - 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) => ( - - ))} - + ))} + + ) : null} + + } + extraData={pendingPermissionItems.length} + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + autoscrollToTopThreshold: 40, + }} + initialNumToRender={12} + windowSize={10} + inverted + /> {/* Scroll to bottom button */} {!isNearBottom && ( @@ -234,7 +281,10 @@ export function AgentStreamView({ style={stylesheet.scrollToBottomButton} onPress={scrollToBottom} > - + )} @@ -400,7 +450,7 @@ const stylesheet = StyleSheet.create((theme) => ({ flex: 1, backgroundColor: theme.colors.background, }, - scrollView: { + list: { flex: 1, paddingHorizontal: theme.spacing[2], }, @@ -410,6 +460,13 @@ const stylesheet = StyleSheet.create((theme) => ({ justifyContent: "center", paddingVertical: theme.spacing[12], }, + permissionsContainer: { + gap: theme.spacing[2], + }, + invertedWrapper: { + transform: [{ scaleY: -1 }], + width: "100%", + }, emptyStateText: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/server/agents/agent-type-definitions.ts b/packages/app/src/server/agents/agent-type-definitions.ts new file mode 100644 index 000000000..f2e7340b9 --- /dev/null +++ b/packages/app/src/server/agents/agent-type-definitions.ts @@ -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 = { + 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; +}