fix: file attachments

This commit is contained in:
Vukasin 2025-06-26 21:14:39 +02:00
parent c9058aa61c
commit 4d0a937331
5 changed files with 279 additions and 137 deletions

View File

@ -1,12 +1,15 @@
import { useTheme } from '@/hooks/useThemeColor';
import { UploadedFile } from '@/utils/file-upload';
import { X } from 'lucide-react-native';
import React from 'react';
import { ScrollView, StyleSheet, View } from 'react-native';
import { ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import { FileAttachment } from './FileAttachment';
interface AttachmentGroupProps {
attachments: string[] | UploadedFile[];
sandboxId?: string;
onFilePress?: (path: string) => void;
onRemove?: (index: number) => void;
layout?: 'inline' | 'grid';
showPreviews?: boolean;
maxHeight?: number;
@ -16,10 +19,60 @@ export const AttachmentGroup: React.FC<AttachmentGroupProps> = ({
attachments,
sandboxId,
onFilePress,
onRemove,
layout = 'grid',
showPreviews = true,
maxHeight = 200
}) => {
const theme = useTheme();
// Create styles inside component to access theme
const styles = StyleSheet.create({
inlineContainer: {
},
inlineContent: {
paddingRight: 12,
paddingVertical: 8,
gap: 8,
},
inlineItem: {
marginRight: 8,
},
fileWrapper: {
position: 'relative',
paddingRight: 8,
marginRight: -8,
},
removeButton: {
position: 'absolute',
top: -5,
right: 0,
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: theme.sidebar,
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
removeButtonInner: {
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: theme.foreground,
justifyContent: 'center',
alignItems: 'center',
},
gridContainer: {
gap: 8,
marginTop: 8,
},
gridItem: {
width: '100%',
},
});
if (!attachments || attachments.length === 0) {
return null;
}
@ -40,15 +93,29 @@ export const AttachmentGroup: React.FC<AttachmentGroupProps> = ({
return (
<View key={index} style={styles.inlineItem}>
<FileAttachment
filepath={filepath}
sandboxId={sandboxId}
onPress={onFilePress}
layout="inline"
showPreview={showPreviews}
isUploading={isUploadedFile ? attachment.isUploading : false}
uploadError={isUploadedFile ? attachment.uploadError : undefined}
/>
<View style={styles.fileWrapper}>
<FileAttachment
filepath={filepath}
sandboxId={sandboxId}
onPress={onFilePress}
layout="inline"
showPreview={showPreviews}
isUploading={isUploadedFile ? attachment.isUploading : false}
uploadError={isUploadedFile ? attachment.uploadError : undefined}
uploadedBlob={isUploadedFile ? attachment.cachedBlob : undefined}
/>
{onRemove && (
<TouchableOpacity
style={styles.removeButton}
onPress={() => onRemove(index)}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
>
<View style={styles.removeButtonInner}>
<X size={12} color={theme.background} strokeWidth={3} />
</View>
</TouchableOpacity>
)}
</View>
</View>
);
})}
@ -73,30 +140,11 @@ export const AttachmentGroup: React.FC<AttachmentGroupProps> = ({
showPreview={showPreviews}
isUploading={isUploadedFile ? attachment.isUploading : false}
uploadError={isUploadedFile ? attachment.uploadError : undefined}
uploadedBlob={isUploadedFile ? attachment.cachedBlob : undefined}
/>
</View>
);
})}
</View>
);
};
const styles = StyleSheet.create({
inlineContainer: {
marginVertical: 8,
},
inlineContent: {
paddingHorizontal: 4,
gap: 8,
},
inlineItem: {
marginRight: 8,
},
gridContainer: {
gap: 8,
marginTop: 8,
},
gridItem: {
width: '100%',
},
});
};

View File

@ -117,7 +117,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
);
} else {
// Store locally - files shown immediately
handleLocalFiles(
await handleLocalFiles(
result.files,
() => { }, // We don't need pending files state here
(files: UploadedFile[]) => setAttachedFiles(prev => [...prev, ...files])
@ -167,6 +167,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
shadowOffset: { width: 0, height: -1 },
shadowOpacity: 1,
shadowRadius: 0,
paddingVertical: attachedFiles.length > 0 ? 0 : 12,
},
containerStyle
]}>
@ -180,9 +181,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({
maxHeight={100}
sandboxId={sandboxId}
onFilePress={(filepath) => {
const index = attachedFiles.findIndex(file => file.path === filepath);
if (index > -1) removeFile(index);
// Don't remove on file press in inline mode, let X button handle it
console.log('File pressed:', filepath);
}}
onRemove={removeFile}
/>
)}
@ -249,7 +251,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const styles = StyleSheet.create({
container: {
paddingHorizontal: 10,
paddingVertical: 12,
paddingVertical: 0,
},
inputContainer: {
borderRadius: 20,

View File

@ -13,6 +13,7 @@ interface FileAttachmentProps {
layout?: 'inline' | 'grid';
isUploading?: boolean;
uploadError?: string;
uploadedBlob?: Blob; // For optimistic caching
}
const getFileIcon = (type: FileType) => {
@ -53,7 +54,8 @@ export const FileAttachment: React.FC<FileAttachmentProps> = ({
showPreview = true,
layout = 'inline',
isUploading = false,
uploadError
uploadError,
uploadedBlob
}) => {
const theme = useTheme();
const filename = filepath.split('/').pop() || 'file';
@ -72,8 +74,13 @@ export const FileAttachment: React.FC<FileAttachmentProps> = ({
const {
data: imageUrl,
isLoading: imageLoading,
error: imageError
} = useImageContent(isImage && sandboxId ? sandboxId : undefined, isImage ? filepath : undefined);
error: imageError,
isProcessing
} = useImageContent(
isImage && sandboxId ? sandboxId : undefined,
isImage ? filepath : undefined,
uploadedBlob
);
const handlePress = () => {
onPress?.(filepath);
@ -110,12 +117,17 @@ export const FileAttachment: React.FC<FileAttachmentProps> = ({
activeOpacity={0.8}
>
<ActivityIndicator size="small" color={theme.mutedForeground} />
{isProcessing && (
<Text style={[styles.metadataText, { color: theme.mutedForeground, marginTop: 4 }]}>
Processing...
</Text>
)}
</TouchableOpacity>
);
}
// Error state
if (imageError || !imageUrl) {
// Error state (but not if processing)
if (imageError && !isProcessing) {
return (
<TouchableOpacity
style={[containerStyle, { height: minHeight }]}
@ -162,7 +174,7 @@ export const FileAttachment: React.FC<FileAttachmentProps> = ({
activeOpacity={0.8}
>
<Image
source={{ uri: imageUrl }}
source={imageUrl ? { uri: imageUrl } : undefined}
style={styles.imageGridPreview}
resizeMode="cover"
onError={(error) => {
@ -177,12 +189,19 @@ export const FileAttachment: React.FC<FileAttachmentProps> = ({
// Inline mode: Fixed height with contain
return (
<TouchableOpacity
style={[containerStyle, styles.imageInlineContainer]}
style={[
styles.container,
{
backgroundColor: theme.sidebar,
borderColor: theme.border,
},
styles.imageInlineContainer
]}
onPress={handlePress}
activeOpacity={0.8}
>
<Image
source={{ uri: imageUrl }}
source={imageUrl ? { uri: imageUrl } : undefined}
style={styles.imageInlinePreview}
resizeMode="contain"
onError={(error) => {
@ -253,8 +272,8 @@ const styles = StyleSheet.create({
},
inlineContainer: {
height: 54,
minWidth: 54,
maxWidth: 54,
minWidth: 170,
maxWidth: 300,
paddingRight: 12,
},
gridContainer: {
@ -262,9 +281,12 @@ const styles = StyleSheet.create({
},
imageInlineContainer: {
height: 54,
minWidth: 170,
maxWidth: 300,
width: 54,
minWidth: 54,
maxWidth: 54,
padding: 0,
justifyContent: 'center',
alignItems: 'center',
},
iconContainer: {
width: 54,
@ -293,7 +315,7 @@ const styles = StyleSheet.create({
marginRight: 4,
},
imageInlinePreview: {
width: '100%',
width: 54,
height: 54,
},
imageGridPreview: {

View File

@ -1,15 +1,17 @@
import { SERVER_URL } from '@/constants/Server';
import { supabase } from '@/constants/SupabaseConfig';
import { createSupabaseClient } from '@/constants/SupabaseConfig';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
interface ImageContentResult {
data: string | null;
isLoading: boolean;
error: Error | null;
isProcessing: boolean; // New state for server processing
}
// Cache for loaded image URLs
const imageCache = new Map<string, string>();
// Optimistic cache for uploaded files
const uploadCache = new Map<string, Blob>();
// Convert blob to base64 data URL for React Native
const blobToDataURL = (blob: Blob): Promise<string> => {
@ -21,95 +23,120 @@ const blobToDataURL = (blob: Blob): Promise<string> => {
});
};
export function useImageContent(sandboxId?: string, filePath?: string): ImageContentResult {
export function useImageContent(
sandboxId?: string,
filePath?: string,
uploadedBlob?: Blob // For optimistic caching
): ImageContentResult {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Normalize path
const normalizedPath = filePath && !filePath.startsWith('/workspace')
? `/workspace/${filePath.startsWith('/') ? filePath.substring(1) : filePath}`
: filePath;
const cacheKey = `${sandboxId}:${normalizedPath}`;
// Optimistic caching for uploads
useEffect(() => {
if (!sandboxId || !filePath) {
setImageUrl(null);
setError(null);
setIsLoading(false);
return;
if (uploadedBlob && cacheKey) {
uploadCache.set(cacheKey, uploadedBlob);
// Auto-expire after 30 seconds
setTimeout(() => uploadCache.delete(cacheKey), 30000);
}
}, [uploadedBlob, cacheKey]);
// Normalize path to have /workspace prefix
let normalizedPath = filePath;
if (!normalizedPath.startsWith('/workspace')) {
normalizedPath = `/workspace/${normalizedPath.startsWith('/') ? normalizedPath.substring(1) : normalizedPath}`;
}
// Check cache first
const cacheKey = `${sandboxId}:${normalizedPath}`;
const cached = imageCache.get(cacheKey);
if (cached) {
setImageUrl(cached);
setIsLoading(false);
setError(null);
return;
}
// Load image with authentication
const loadImage = async () => {
setIsLoading(true);
setError(null);
try {
// Get session for auth token
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
if (sessionError) {
throw new Error(`Authentication error: ${sessionError.message}`);
}
// Construct API URL
const url = new URL(`${SERVER_URL}/sandboxes/${sandboxId}/files/content`);
url.searchParams.append('path', normalizedPath);
// Prepare headers with auth
const headers: Record<string, string> = {};
if (session?.access_token) {
headers['Authorization'] = `Bearer ${session.access_token}`;
}
console.log('[useImageContent] Fetching image:', url.toString());
// Fetch the image
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
throw new Error(`Failed to load image: ${response.status} ${response.statusText}`);
}
// Convert to blob then to base64 data URL for React Native
const blob = await response.blob();
const dataUrl = await blobToDataURL(blob);
// Cache the result
imageCache.set(cacheKey, dataUrl);
setImageUrl(dataUrl);
setIsLoading(false);
} catch (err) {
console.error('[useImageContent] Error loading image:', err);
setError(err instanceof Error ? err : new Error('Failed to load image'));
setIsLoading(false);
// Smart fetch with retry logic
const {
data: blobData,
isLoading,
error,
} = useQuery({
queryKey: ['image', sandboxId, normalizedPath],
queryFn: async () => {
// Check optimistic cache first
const cachedBlob = uploadCache.get(cacheKey);
if (cachedBlob) {
console.log(`[IMAGE] Using optimistic cache for ${filePath}`);
return cachedBlob;
}
};
loadImage();
// Fetch from server
const supabase = createSupabaseClient();
const { data: { session } } = await supabase.auth.getSession();
// Cleanup function
return () => {
// No cleanup needed for data URLs
};
}, [sandboxId, filePath]);
if (!session?.access_token) {
throw new Error('No auth token');
}
const url = new URL(`${SERVER_URL}/sandboxes/${sandboxId}/files/content`);
url.searchParams.append('path', normalizedPath!);
const response = await fetch(url.toString(), {
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to load image: ${response.status}`);
}
return await response.blob();
},
enabled: Boolean(sandboxId && normalizedPath),
staleTime: 5 * 60 * 1000, // 5 minutes
retry: (failureCount, error: any) => {
// Smart retry for 404s (server processing)
if (error?.message?.includes('404')) {
return failureCount < 3;
}
// Don't retry auth errors
if (error?.message?.includes('401') || error?.message?.includes('403')) {
return false;
}
return failureCount < 2;
},
retryDelay: (attemptIndex) => {
// Exponential backoff: 1s, 2s, 4s
return Math.min(1000 * (2 ** attemptIndex), 4000);
},
});
// Convert blob to data URL
useEffect(() => {
if (blobData instanceof Blob) {
blobToDataURL(blobData)
.then(setImageUrl)
.catch(console.error);
} else {
setImageUrl(null);
}
}, [blobData]);
const isProcessing = error?.message?.includes('404') && isLoading;
return {
data: imageUrl,
isLoading,
error
error,
isProcessing,
};
}
// Utility for optimistic caching of uploaded files
export function cacheUploadedFile(sandboxId: string, filePath: string, blob: Blob) {
const normalizedPath = !filePath.startsWith('/workspace')
? `/workspace/${filePath.startsWith('/') ? filePath.substring(1) : filePath}`
: filePath;
const cacheKey = `${sandboxId}:${normalizedPath}`;
uploadCache.set(cacheKey, blob);
console.log(`[IMAGE] Cached uploaded file: ${filePath}`);
// Auto-expire after 30 seconds
setTimeout(() => {
uploadCache.delete(cacheKey);
console.log(`[IMAGE] Expired cache for: ${filePath}`);
}, 30000);
}

View File

@ -1,5 +1,6 @@
import { SERVER_URL } from '@/constants/Server';
import { supabase } from '@/constants/SupabaseConfig';
import { createSupabaseClient } from '@/constants/SupabaseConfig';
import { cacheUploadedFile } from '@/hooks/useImageContent';
import * as DocumentPicker from 'expo-document-picker';
import * as ImagePicker from 'expo-image-picker';
import { Alert } from 'react-native';
@ -12,6 +13,7 @@ export interface UploadedFile {
localUri?: string;
isUploading?: boolean;
uploadError?: string;
cachedBlob?: Blob;
}
export interface FileUploadResult {
@ -123,12 +125,30 @@ const pickFromDocuments = async (): Promise<{ cancelled: boolean; files?: any[]
return { cancelled: false, files: result.assets };
};
// Helper function to create blob from file URI
const createBlobFromUri = async (uri: string, mimeType: string): Promise<Blob | null> => {
try {
const response = await fetch(uri);
const blob = await response.blob();
// Ensure correct MIME type
if (blob.type !== mimeType) {
return new Blob([blob], { type: mimeType });
}
return blob;
} catch (error) {
console.warn('[FILE_UPLOAD] Failed to create blob from URI:', error);
return null;
}
};
// Handle local files (when no sandbox) - show immediately
export const handleLocalFiles = (
export const handleLocalFiles = async (
files: any[],
setPendingFiles: (files: File[]) => void,
addUploadedFiles: (files: UploadedFile[]) => void
): UploadedFile[] => {
): Promise<UploadedFile[]> => {
const validFiles: UploadedFile[] = [];
for (const file of files) {
@ -139,14 +159,22 @@ export const handleLocalFiles = (
const fileName = file.name || file.fileName || 'unknown_file';
const normalizedName = normalizeFilenameToNFC(fileName);
const mimeType = file.mimeType || file.type || 'application/octet-stream';
// Create blob for optimistic caching (images only)
let cachedBlob: Blob | undefined;
if (file.uri && mimeType.startsWith('image/')) {
cachedBlob = await createBlobFromUri(file.uri, mimeType) || undefined;
}
const uploadedFile: UploadedFile = {
name: normalizedName,
path: `/workspace/${normalizedName}`,
size: file.size || 0,
type: file.mimeType || file.type || 'application/octet-stream',
type: mimeType,
localUri: file.uri,
isUploading: false // Local files don't need uploading
isUploading: false, // Local files don't need uploading
cachedBlob,
};
validFiles.push(uploadedFile);
@ -169,7 +197,7 @@ export const uploadFilesToSandbox = async (
const uploadedFiles: UploadedFile[] = [];
const filesToShow: UploadedFile[] = [];
// First, show all files immediately with loading state
// First, show all files immediately with loading state + optimistic caching
for (const file of files) {
if (file.size && file.size > MAX_FILE_SIZE) {
Alert.alert('File Too Large', `File size exceeds 50MB limit: ${file.name || 'Unknown file'}`);
@ -179,14 +207,28 @@ export const uploadFilesToSandbox = async (
const fileName = file.name || file.fileName || 'unknown_file';
const normalizedName = normalizeFilenameToNFC(fileName);
const uploadPath = `/workspace/${normalizedName}`;
const mimeType = file.mimeType || file.type || 'application/octet-stream';
// Create blob for optimistic caching (images only)
let cachedBlob: Blob | undefined;
if (file.uri && mimeType.startsWith('image/')) {
cachedBlob = await createBlobFromUri(file.uri, mimeType) || undefined;
// Cache optimistically
if (cachedBlob) {
cacheUploadedFile(sandboxId, uploadPath, cachedBlob);
console.log(`[FILE_UPLOAD] Cached image optimistically: ${uploadPath}`);
}
}
const uploadedFile: UploadedFile = {
name: normalizedName,
path: uploadPath,
size: file.size || 0,
type: file.mimeType || file.type || 'application/octet-stream',
type: mimeType,
localUri: file.uri,
isUploading: true // Show loading immediately
isUploading: true, // Show loading immediately
cachedBlob,
};
filesToShow.push(uploadedFile);
@ -221,6 +263,7 @@ export const uploadFilesToSandbox = async (
formData.append('path', uploadPath);
// Get auth token
const supabase = createSupabaseClient();
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
if (sessionError || !session?.access_token) {