import { Message } from '@/api/chat-api'; import { commonStyles } from '@/constants/CommonStyles'; import { fontWeights } from '@/constants/Fonts'; import { useFileBrowser } from '@/hooks/useFileBrowser'; import { useTheme } from '@/hooks/useThemeColor'; import { useOpenToolView } from '@/stores/ui-store'; import { parseFileAttachments } from '@/utils/file-parser'; import { Markdown } from '@/utils/markdown-renderer'; import { parseMessage, processStreamContent } from '@/utils/message-parser'; import { ChevronDown } from 'lucide-react-native'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FlatList, Keyboard, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withRepeat, withSequence, withTiming } from 'react-native-reanimated'; import { AttachmentGroup } from './AttachmentGroup'; import { MessageActionModal } from './MessageActionModal'; import { SkeletonChatMessages } from './Skeleton'; import { ToolCallRenderer } from './ToolCallRenderer'; import { Body } from './Typography'; interface MessageItemProps { message: Message; sandboxId?: string; onLongPress?: (messageText: string, layout: { x: number; y: number; width: number; height: number }, messageId: string) => void; onToolPress?: (toolCall: any, messageId: string) => void; } const MessageItem = memo(({ message, sandboxId, onLongPress, onToolPress }) => { const theme = useTheme(); const messageRef = useRef(null); const { openFileBrowser } = useFileBrowser(); const parsedMessage = useMemo(() => parseMessage(message), [message]); // Apply file parsing to the content const { attachments, cleanContent } = useMemo(() => parseFileAttachments(parsedMessage.cleanContent, message.metadata?.cached_files), [parsedMessage.cleanContent, message.metadata?.cached_files] ); // Extract ask tool content and attachments const askToolContent = useMemo(() => { if (!parsedMessage.hasTools) return null; const askTools = parsedMessage.toolCalls.filter(tool => tool.functionName === 'ask'); if (askTools.length === 0) return null; // Handle the first ask tool (there should typically be only one) const askTool = askTools[0]; const text = askTool.parameters?.text || ''; const attachments = askTool.parameters?.attachments || ''; // Parse attachments (could be comma-separated) const attachmentList = attachments ? attachments.split(',').map((a: string) => a.trim()).filter(Boolean) : []; return { text, attachments: attachmentList }; }, [parsedMessage.toolCalls, parsedMessage.hasTools]); // Filter out ask tools from regular tool rendering const nonAskToolCalls = useMemo(() => { if (!parsedMessage.hasTools) return []; return parsedMessage.toolCalls.filter(tool => tool.functionName !== 'ask'); }, [parsedMessage.toolCalls, parsedMessage.hasTools]); const handleLongPress = () => { if (messageRef.current) { messageRef.current.measure((x, y, width, height, pageX, pageY) => { onLongPress?.(cleanContent, { x: pageX, y: pageY, width, height }, message.message_id); }); } }; const handleFilePress = (filepath: string) => { console.log('File pressed:', filepath); // Open file browser to view the file if (sandboxId) { openFileBrowser(sandboxId, filepath); } }; const isUser = message.type === 'user'; if (isUser) { return ( {cleanContent} {attachments.length > 0 && ( )} ); } else { return ( {cleanContent && ( {cleanContent} )} {attachments.length > 0 && ( )} {/* Ask tool content */} {askToolContent && ( <> {askToolContent.text && ( {askToolContent.text} )} {askToolContent.attachments.length > 0 && ( )} )} {nonAskToolCalls.length > 0 && ( { onToolPress?.(toolCall, message.message_id); }} /> )} ); } }); MessageItem.displayName = 'MessageItem'; interface MessageThreadProps { messages: Message[]; isGenerating?: boolean; isSending?: boolean; streamContent?: string; streamError?: string | null; isLoadingMessages?: boolean; onScrollPositionChange?: (isAtBottom: boolean) => void; keyboardHeight?: number; sandboxId?: string; } // EXACT FRONTEND PATTERN - Simple thinking animation const ThinkingText = memo<{ children: string; color: string }>(({ children, color }) => { const opacity = useSharedValue(0.3); useEffect(() => { opacity.value = withRepeat( withSequence( withTiming(1, { duration: 800 }), withTiming(0.3, { duration: 800 }) ), -1, false ); }, [opacity]); const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value, })); return ( {children} ); }); ThinkingText.displayName = 'ThinkingText'; // Shimmer effect for tool name const ShimmerText = memo<{ children: string; color: string }>(({ children, color }) => { const shimmerPosition = useSharedValue(-1); useEffect(() => { shimmerPosition.value = withRepeat( withTiming(1, { duration: 1500 }), -1, false ); }, [shimmerPosition]); const animatedStyle = useAnimatedStyle(() => { const inputRange = [-1, 0, 1]; const outputRange = [0.4, 1, 0.4]; return { opacity: shimmerPosition.value >= -0.5 && shimmerPosition.value <= 0.5 ? 1 : 0.7, }; }); return ( {children} ); }); ShimmerText.displayName = 'ShimmerText'; export const MessageThread: React.FC = ({ messages, isGenerating = false, isSending = false, streamContent = '', streamError, isLoadingMessages = false, onScrollPositionChange, keyboardHeight = 0, sandboxId, }) => { const theme = useTheme(); const openToolView = useOpenToolView(); // Log sandboxId for debugging console.log(`[MessageThread] sandboxId: ${sandboxId || 'undefined'}`); const flatListRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); // Modal state const [modalVisible, setModalVisible] = useState(false); const [selectedMessageText, setSelectedMessageText] = useState(''); const [selectedMessageId, setSelectedMessageId] = useState(null); const [sourceLayout, setSourceLayout] = useState<{ x: number; y: number; width: number; height: number } | undefined>(); // EXACT FRONTEND PATTERN - Simple message display const displayMessages = useMemo(() => { // Simple reverse for inverted list - no complex logic return messages.slice().reverse(); }, [messages]); // EXACT FRONTEND PATTERN - Show streaming content as text const showStreamingText = isGenerating && streamContent; // Simple auto-scroll on content change const handleContentSizeChange = useCallback(() => { if ((isGenerating || isSending) && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } }, [isGenerating, isSending]); // Auto-scroll when generating starts useEffect(() => { if (isGenerating || isSending) { Keyboard.dismiss(); setTimeout(() => { if (flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } }, 50); } }, [isGenerating, isSending]); // Keyboard handling useEffect(() => { if (keyboardHeight > 0 && flatListRef.current) { setTimeout(() => { flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); }, 100); } }, [keyboardHeight]); const handleScroll = useCallback((event: any) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; // For inverted FlatList, we're at bottom when offset is near 0 // But also check if content fits in container (no scrolling needed) const contentFitsInContainer = contentSize.height <= layoutMeasurement.height; const isScrolledToBottom = contentOffset.y <= 20 || contentFitsInContainer; if (isScrolledToBottom !== isAtBottom) { setIsAtBottom(isScrolledToBottom); onScrollPositionChange?.(isScrolledToBottom); } }, [isAtBottom, onScrollPositionChange]); const scrollToBottom = useCallback(() => { if (flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } }, []); const handleLongPress = useCallback((messageText: string, layout: { x: number; y: number; width: number; height: number }, messageId: string) => { setSelectedMessageText(messageText); setSourceLayout(layout); setSelectedMessageId(messageId); setModalVisible(true); }, []); const handleCloseModal = useCallback(() => { setModalVisible(false); setSelectedMessageText(''); setSourceLayout(undefined); setSelectedMessageId(null); }, []); const handleToolPress = useCallback((toolCall: any, messageId: string) => { openToolView(toolCall, messageId); }, [openToolView]); const renderMessage = ({ item }: { item: Message }) => { // Skip rendering pure tool result messages const parsed = parseMessage(item); if (parsed.isToolResultMessage && !parsed.cleanContent.trim()) { return null; } return ( ); }; const keyExtractor = (item: Message) => item.message_id; // EXACT FRONTEND PATTERN - Simple footer with partial tool recognition const renderFooter = () => { if (!isGenerating && !isSending) return null; if (streamError) { return ( {streamError.includes('No response received') ? 'Agent completed but no response was generated. Please try again.' : `Error: ${streamError}` } ); } if (showStreamingText) { // Process streaming content for partial tool recognition const processedStream = processStreamContent(streamContent); const { cleanContent, currentToolName, isStreamingTool } = processedStream; return ( {cleanContent && ( {cleanContent} )} {isStreamingTool && currentToolName && currentToolName.length > 2 && !currentToolName.includes('<') && ( 🔧{' '} {currentToolName} )} ); } return ( Suna is thinking... ); }; if (isLoadingMessages && messages.length === 0) { return ( ); } if (messages.length === 0 && !isGenerating && !isSending) { return ( Keyboard.dismiss()}> This is the beginning of your conversation. Send a message to get started! ); } return ( <> {!isAtBottom && ( )} ); }; const styles = StyleSheet.create({ container: { flex: 1, paddingBottom: 20, }, content: { padding: 16, paddingBottom: 0, flexGrow: 1, }, messageContainer: { marginVertical: 4, maxWidth: '85%', }, messageBubble: { paddingHorizontal: 16, paddingVertical: 12, borderRadius: 24, borderBottomRightRadius: 8, borderWidth: 1, overflow: 'hidden', ...commonStyles.shadow, }, messageText: { lineHeight: 20, }, aiMessageContainer: { marginVertical: 4, width: '100%', }, streamingContainer: { paddingVertical: 8, width: '100%', }, streamingIndicator: { paddingTop: 4, alignItems: 'flex-start', }, streamingIndicatorText: { fontSize: 12, opacity: 0.7, }, generatingText: { fontStyle: 'italic', fontSize: 15, fontFamily: fontWeights[500], }, errorContainer: { paddingVertical: 12, paddingHorizontal: 16, ...commonStyles.centerContainer, }, errorText: { fontSize: 14, textAlign: 'center', }, emptyContainer: { ...commonStyles.flexCenter, paddingHorizontal: 32, }, emptyText: { fontSize: 16, textAlign: 'center', fontStyle: 'italic', }, thinkingContainer: { paddingTop: 16, paddingBottom: 12, paddingHorizontal: 0, alignItems: 'flex-start', }, scrollToBottomButton: { position: 'absolute', bottom: 20, right: 20, width: 40, height: 40, borderRadius: 20, justifyContent: 'center', alignItems: 'center', borderWidth: 1, ...commonStyles.shadow, }, markdownContent: { flex: 1, }, toolIndicatorContainer: { paddingVertical: 4, alignItems: 'flex-start', }, toolIndicatorText: { fontSize: 13, fontStyle: 'italic', opacity: 0.8, }, });