mirror of https://github.com/kortix-ai/suna.git
fix: file attachments
This commit is contained in:
parent
c9058aa61c
commit
4d0a937331
|
@ -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%',
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in New Issue