suna/apps/mobile/components/ChatInput.tsx

318 lines
11 KiB
TypeScript
Raw Normal View History

2025-06-26 05:41:40 +08:00
import { AttachmentGroup } from '@/components/AttachmentGroup';
2025-06-17 04:21:36 +08:00
import { useTheme } from '@/hooks/useThemeColor';
2025-06-26 05:41:40 +08:00
import { useSelectedProject } from '@/stores/ui-store';
import { handleLocalFiles, pickFiles, UploadedFile, uploadFilesToSandbox } from '@/utils/file-upload';
2025-06-25 04:43:44 +08:00
import { ArrowUp, Mic, Paperclip, Square } from 'lucide-react-native';
2025-06-17 04:39:00 +08:00
import React, { useEffect, useState } from 'react';
import { Keyboard, KeyboardEvent, Platform, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native';
import Animated, {
Easing,
Extrapolate,
interpolate,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
2025-06-17 04:21:36 +08:00
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface ChatInputProps {
2025-06-27 04:45:38 +08:00
onSendMessage: (message: string, files?: UploadedFile[]) => void;
2025-06-17 04:21:36 +08:00
onAttachPress?: () => void;
onMicPress?: () => void;
2025-06-25 04:43:44 +08:00
onCancelStream?: () => void;
2025-06-17 04:21:36 +08:00
placeholder?: string;
2025-06-21 05:26:22 +08:00
isAtBottomOfChat?: boolean;
2025-06-25 04:43:44 +08:00
isGenerating?: boolean;
isSending?: boolean;
2025-06-17 04:21:36 +08:00
}
export const ChatInput: React.FC<ChatInputProps> = ({
onSendMessage,
onAttachPress,
onMicPress,
2025-06-25 04:43:44 +08:00
onCancelStream,
2025-06-17 04:21:36 +08:00
placeholder = 'Ask Suna anything...',
2025-06-21 05:26:22 +08:00
isAtBottomOfChat = true,
2025-06-25 04:43:44 +08:00
isGenerating = false,
isSending = false,
2025-06-17 04:21:36 +08:00
}) => {
const [message, setMessage] = useState('');
2025-06-26 05:41:40 +08:00
const [attachedFiles, setAttachedFiles] = useState<UploadedFile[]>([]);
const selectedProject = useSelectedProject();
2025-06-17 04:21:36 +08:00
const theme = useTheme();
const insets = useSafeAreaInsets();
2025-06-26 05:41:40 +08:00
// Get sandboxId from selected project
const sandboxId = selectedProject?.sandbox?.id;
2025-06-17 04:39:00 +08:00
const keyboardHeight = useSharedValue(0);
useEffect(() => {
const handleKeyboardShow = (event: KeyboardEvent) => {
keyboardHeight.value = withTiming(event.endCoordinates.height, {
duration: 250,
easing: Easing.out(Easing.quad),
});
};
const handleKeyboardHide = () => {
keyboardHeight.value = withTiming(0, {
duration: 250,
easing: Easing.out(Easing.quad),
});
};
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSubscription = Keyboard.addListener(showEvent, handleKeyboardShow);
const hideSubscription = Keyboard.addListener(hideEvent, handleKeyboardHide);
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, []);
2025-06-17 04:21:36 +08:00
const handleSend = () => {
2025-06-26 05:41:40 +08:00
if (message.trim() || attachedFiles.length > 0) {
let finalMessage = message.trim();
2025-06-27 04:45:38 +08:00
// For existing projects with sandboxId, add file references to message
// For new chat mode, let server handle file references to avoid duplicates
if (attachedFiles.length > 0 && sandboxId) {
2025-06-26 05:41:40 +08:00
const fileInfo = attachedFiles
.map(file => `[Uploaded File: ${file.path}]`)
.join('\n');
finalMessage = finalMessage ? `${finalMessage}\n\n${fileInfo}` : fileInfo;
}
2025-06-27 04:45:38 +08:00
// Pass the message and files separately to the handler
onSendMessage(finalMessage, attachedFiles);
2025-06-17 04:21:36 +08:00
setMessage('');
2025-06-26 05:41:40 +08:00
setAttachedFiles([]);
}
};
const handleAttachPress = async () => {
try {
const result = await pickFiles();
if (result.cancelled || !result.files?.length) {
return;
}
if (sandboxId) {
// Upload to sandbox - files shown immediately with loading state
await uploadFilesToSandbox(
result.files,
sandboxId,
(files: UploadedFile[]) => setAttachedFiles(prev => [...prev, ...files]),
(filePath: string, status: { isUploading?: boolean; uploadError?: string }) => {
setAttachedFiles(prev => prev.map(file =>
file.path === filePath
? { ...file, ...status }
: file
));
}
);
} else {
// Store locally - files shown immediately
2025-06-27 03:14:39 +08:00
await handleLocalFiles(
2025-06-26 05:41:40 +08:00
result.files,
() => { }, // We don't need pending files state here
(files: UploadedFile[]) => setAttachedFiles(prev => [...prev, ...files])
);
}
onAttachPress?.();
} catch (error) {
console.error('File attach error:', error);
2025-06-17 04:21:36 +08:00
}
};
2025-06-26 05:41:40 +08:00
const removeFile = (index: number) => {
setAttachedFiles(prev => prev.filter((_, i) => i !== index));
};
2025-06-21 05:26:22 +08:00
const containerStyle = useAnimatedStyle(() => {
2025-06-17 04:39:00 +08:00
const paddingBottom = interpolate(
2025-06-21 05:26:22 +08:00
keyboardHeight.value,
[0, 300],
[Math.max(insets.bottom, 20), 10],
2025-06-17 04:39:00 +08:00
Extrapolate.CLAMP
);
return {
paddingBottom,
};
});
2025-06-21 05:26:22 +08:00
const fakeViewStyle = useAnimatedStyle(() => {
return {
height: keyboardHeight.value,
};
});
2025-06-25 04:43:44 +08:00
const shouldShowCancel = isSending || isGenerating;
2025-06-17 04:39:00 +08:00
return (
2025-06-21 05:26:22 +08:00
<>
<Animated.View style={[
styles.container,
{
backgroundColor: theme.sidebar,
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
shadowColor: theme.border,
shadowOffset: { width: 0, height: -1 },
shadowOpacity: 1,
shadowRadius: 0,
2025-06-27 03:14:39 +08:00
paddingVertical: attachedFiles.length > 0 ? 0 : 12,
2025-06-21 05:26:22 +08:00
},
containerStyle
]}>
<View style={[styles.inputContainer, { backgroundColor: theme.sidebar }]}>
2025-06-26 05:41:40 +08:00
{/* File attachments preview */}
{attachedFiles.length > 0 && (
<AttachmentGroup
attachments={attachedFiles}
layout="inline"
showPreviews={true}
maxHeight={100}
sandboxId={sandboxId}
onFilePress={(filepath) => {
2025-06-27 03:14:39 +08:00
// Don't remove on file press in inline mode, let X button handle it
console.log('File pressed:', filepath);
2025-06-26 05:41:40 +08:00
}}
2025-06-27 03:14:39 +08:00
onRemove={removeFile}
2025-06-26 05:41:40 +08:00
/>
)}
2025-06-21 05:26:22 +08:00
<TextInput
style={[styles.textInput, { color: theme.foreground }]}
value={message}
onChangeText={setMessage}
placeholder={placeholder}
placeholderTextColor={theme.placeholderText}
multiline
maxLength={2000}
returnKeyType="send"
onSubmitEditing={handleSend}
blurOnSubmit={false}
/>
<View style={styles.buttonContainer}>
2025-06-26 05:41:40 +08:00
<TouchableOpacity
style={styles.actionButton}
onPress={handleAttachPress}
>
2025-06-21 05:26:22 +08:00
<Paperclip size={20} strokeWidth={2} color={theme.placeholderText} />
2025-06-17 04:21:36 +08:00
</TouchableOpacity>
2025-06-21 05:26:22 +08:00
<View style={styles.rightButtons}>
<TouchableOpacity style={styles.actionButton} onPress={onMicPress}>
<Mic size={20} strokeWidth={2} color={theme.placeholderText} style={{ marginRight: 10 }} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.sendButton, {
2025-06-26 05:41:40 +08:00
backgroundColor: shouldShowCancel || message.trim() || attachedFiles.length > 0
2025-06-25 04:43:44 +08:00
? theme.activeButton
: theme.inactiveButton
2025-06-21 05:26:22 +08:00
}]}
2025-06-25 04:43:44 +08:00
onPress={shouldShowCancel ? onCancelStream : handleSend}
2025-06-26 05:41:40 +08:00
disabled={!shouldShowCancel && !message.trim() && attachedFiles.length === 0}
2025-06-21 05:26:22 +08:00
>
2025-06-25 04:43:44 +08:00
{shouldShowCancel ? (
<Square
size={16}
strokeWidth={2}
color={theme.background}
fill={theme.background}
/>
) : (
<ArrowUp
size={19}
strokeWidth={3}
2025-06-26 05:41:40 +08:00
color={message.trim() || attachedFiles.length > 0 ? theme.background : theme.disabledText}
2025-06-25 04:43:44 +08:00
/>
)}
2025-06-21 05:26:22 +08:00
</TouchableOpacity>
</View>
2025-06-17 04:21:36 +08:00
</View>
</View>
2025-06-21 05:26:22 +08:00
</Animated.View>
{/* Fake view that ALWAYS pushes content up */}
<Animated.View style={fakeViewStyle} />
</>
2025-06-17 04:21:36 +08:00
);
};
const styles = StyleSheet.create({
container: {
2025-06-18 04:48:06 +08:00
paddingHorizontal: 10,
2025-06-27 03:14:39 +08:00
paddingVertical: 0,
2025-06-17 04:21:36 +08:00
},
inputContainer: {
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 12,
},
textInput: {
fontSize: 16,
maxHeight: 100,
backgroundColor: 'transparent',
marginBottom: 8,
...Platform.select({
ios: {
paddingTop: 12,
},
}),
},
buttonContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
rightButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
actionButton: {
2025-06-18 04:48:06 +08:00
width: 22,
2025-06-17 04:21:36 +08:00
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
sendButton: {
width: 36,
height: 36,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
attachIcon: {
width: 16,
height: 16,
borderRadius: 2,
},
micIcon: {
width: 14,
height: 14,
borderRadius: 7,
},
sendIcon: {
width: 0,
height: 0,
borderStyle: 'solid',
borderLeftWidth: 14,
borderRightWidth: 0,
borderBottomWidth: 7,
borderTopWidth: 7,
borderTopColor: 'transparent',
borderBottomColor: 'transparent',
},
});