mirror of https://github.com/kortix-ai/suna.git
596 lines
20 KiB
TypeScript
596 lines
20 KiB
TypeScript
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<MessageItemProps>(({ message, sandboxId, onLongPress, onToolPress }) => {
|
|
const theme = useTheme();
|
|
const messageRef = useRef<View>(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 (
|
|
<View style={[styles.messageContainer, { alignSelf: 'flex-end' }]}>
|
|
<TouchableOpacity
|
|
ref={messageRef}
|
|
onLongPress={handleLongPress}
|
|
delayLongPress={500}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Animated.View style={[styles.messageBubble, {
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
}]}>
|
|
<Body style={[styles.messageText, { color: theme.userMessage }]}>
|
|
{cleanContent}
|
|
</Body>
|
|
|
|
{attachments.length > 0 && (
|
|
<AttachmentGroup
|
|
attachments={attachments}
|
|
onFilePress={handleFilePress}
|
|
layout="grid"
|
|
showPreviews={true}
|
|
maxHeight={120}
|
|
sandboxId={sandboxId}
|
|
/>
|
|
)}
|
|
</Animated.View>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
} else {
|
|
return (
|
|
<View style={styles.aiMessageContainer}>
|
|
{cleanContent && (
|
|
<Markdown style={[styles.markdownContent]}>
|
|
{cleanContent}
|
|
</Markdown>
|
|
)}
|
|
|
|
{attachments.length > 0 && (
|
|
<AttachmentGroup
|
|
attachments={attachments}
|
|
onFilePress={handleFilePress}
|
|
layout="grid"
|
|
showPreviews={true}
|
|
sandboxId={sandboxId}
|
|
/>
|
|
)}
|
|
|
|
{/* Ask tool content */}
|
|
{askToolContent && (
|
|
<>
|
|
{askToolContent.text && (
|
|
<Markdown style={[styles.markdownContent]}>
|
|
{askToolContent.text}
|
|
</Markdown>
|
|
)}
|
|
|
|
{askToolContent.attachments.length > 0 && (
|
|
<AttachmentGroup
|
|
attachments={askToolContent.attachments}
|
|
onFilePress={handleFilePress}
|
|
layout="grid"
|
|
showPreviews={true}
|
|
sandboxId={sandboxId}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{nonAskToolCalls.length > 0 && (
|
|
<ToolCallRenderer
|
|
toolCalls={nonAskToolCalls}
|
|
onToolPress={(toolCall) => {
|
|
onToolPress?.(toolCall, message.message_id);
|
|
}}
|
|
/>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
});
|
|
|
|
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 (
|
|
<Animated.View style={animatedStyle}>
|
|
<Body style={[styles.generatingText, { color }]}>
|
|
{children}
|
|
</Body>
|
|
</Animated.View>
|
|
);
|
|
});
|
|
|
|
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 (
|
|
<Animated.View style={animatedStyle}>
|
|
<Body style={[styles.toolIndicatorText, { color }]}>
|
|
{children}
|
|
</Body>
|
|
</Animated.View>
|
|
);
|
|
});
|
|
|
|
ShimmerText.displayName = 'ShimmerText';
|
|
|
|
export const MessageThread: React.FC<MessageThreadProps> = ({
|
|
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<FlatList>(null);
|
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
|
|
// Modal state
|
|
const [modalVisible, setModalVisible] = useState(false);
|
|
const [selectedMessageText, setSelectedMessageText] = useState('');
|
|
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(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 (
|
|
<MessageItem
|
|
message={item}
|
|
sandboxId={sandboxId}
|
|
onLongPress={handleLongPress}
|
|
onToolPress={handleToolPress}
|
|
/>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<View style={styles.errorContainer}>
|
|
<Body style={[styles.errorText, { color: theme.destructive }]}>
|
|
{streamError.includes('No response received')
|
|
? 'Agent completed but no response was generated. Please try again.'
|
|
: `Error: ${streamError}`
|
|
}
|
|
</Body>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (showStreamingText) {
|
|
// Process streaming content for partial tool recognition
|
|
const processedStream = processStreamContent(streamContent);
|
|
const { cleanContent, currentToolName, isStreamingTool } = processedStream;
|
|
|
|
return (
|
|
<View style={styles.streamingContainer}>
|
|
{cleanContent && (
|
|
<Markdown style={[styles.markdownContent]}>
|
|
{cleanContent}
|
|
</Markdown>
|
|
)}
|
|
|
|
{isStreamingTool && currentToolName && currentToolName.length > 2 && !currentToolName.includes('<') && (
|
|
<View style={styles.toolIndicatorContainer}>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
<Body style={[styles.toolIndicatorText, { color: theme.mutedForeground }]}>
|
|
🔧{' '}
|
|
</Body>
|
|
<ShimmerText color={theme.mutedForeground}>
|
|
{currentToolName}
|
|
</ShimmerText>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.streamingIndicator}>
|
|
<Body style={[styles.streamingIndicatorText, { color: theme.mutedForeground }]}>
|
|
●
|
|
</Body>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.thinkingContainer}>
|
|
<ThinkingText color={theme.placeholderText}>
|
|
Suna is thinking...
|
|
</ThinkingText>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
if (isLoadingMessages && messages.length === 0) {
|
|
return (
|
|
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
|
<SkeletonChatMessages />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (messages.length === 0 && !isGenerating && !isSending) {
|
|
return (
|
|
<TouchableWithoutFeedback onPress={() => Keyboard.dismiss()}>
|
|
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
|
<View style={styles.emptyContainer}>
|
|
<Body style={[styles.emptyText, { color: theme.mutedForeground }]}>
|
|
This is the beginning of your conversation.
|
|
</Body>
|
|
<Body style={[styles.emptyText, { color: theme.mutedForeground, fontSize: 14, marginTop: 8, opacity: 0.7 }]}>
|
|
Send a message to get started!
|
|
</Body>
|
|
</View>
|
|
</View>
|
|
</TouchableWithoutFeedback>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<FlatList
|
|
ref={flatListRef}
|
|
data={displayMessages}
|
|
renderItem={renderMessage}
|
|
keyExtractor={keyExtractor}
|
|
style={[styles.container, { backgroundColor: theme.background }]}
|
|
contentContainerStyle={styles.content}
|
|
showsVerticalScrollIndicator={false}
|
|
removeClippedSubviews
|
|
maxToRenderPerBatch={10}
|
|
windowSize={10}
|
|
inverted={true}
|
|
ListHeaderComponent={renderFooter}
|
|
onScroll={handleScroll}
|
|
scrollEventThrottle={16}
|
|
onContentSizeChange={handleContentSizeChange}
|
|
/>
|
|
|
|
{!isAtBottom && (
|
|
<TouchableOpacity
|
|
style={[styles.scrollToBottomButton, { backgroundColor: theme.background, borderColor: theme.border }]}
|
|
onPress={scrollToBottom}
|
|
activeOpacity={0.8}
|
|
>
|
|
<ChevronDown size={20} color={theme.foreground} strokeWidth={2} />
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
<MessageActionModal
|
|
visible={modalVisible}
|
|
onClose={handleCloseModal}
|
|
messageText={selectedMessageText}
|
|
sourceLayout={sourceLayout}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
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,
|
|
|
|
},
|
|
}); |