import React, { useRef, memo } from 'react'; import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine } from '@remixicon/react'; import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import type { ToolPopupContent } from './message/types'; export const FileAttachmentButton = memo(() => { const fileInputRef = useRef(null); const { addAttachedFile } = useSessionStore(); const { isMobile } = useUIStore(); const isVSCodeRuntime = useIsVSCodeRuntime(); const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7'; const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]'; const attachFiles = async (files: FileList | File[]) => { let attachedCount = 0; for (let i = 0; i < files.length; i++) { const file = files[i]; const sizeBefore = useSessionStore.getState().attachedFiles.length; try { await addAttachedFile(file); const sizeAfter = useSessionStore.getState().attachedFiles.length; if (sizeAfter > sizeBefore) { attachedCount++; } } catch (error) { console.error('File attach failed', error); toast.error(error instanceof Error ? error.message : 'Failed to attach file'); } } if (attachedCount > 0) { toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`); } }; const handleFileSelect = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files) return; await attachFiles(files); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const handleVSCodePick = async () => { try { const response = await fetch('/api/vscode/pick-files'); const data = await response.json(); const picked = Array.isArray(data?.files) ? data.files : []; const skipped = Array.isArray(data?.skipped) ? data.skipped : []; if (skipped.length > 0) { const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`).join('\n'); toast.error(`Some files were skipped:\n${summary}`); } const asFiles = picked .map((file: { name: string; mimeType?: string; dataUrl?: string }) => { if (!file?.dataUrl) return null; try { const [meta, base64] = file.dataUrl.split(','); const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream'); if (!base64) return null; const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } const blob = new Blob([bytes], { type: mime }); return new File([blob], file.name || 'file', { type: mime }); } catch (err) { console.error('Failed to decode VS Code picked file', err); return null; } }) .filter(Boolean) as File[]; if (asFiles.length > 0) { await attachFiles(asFiles); } } catch (error) { console.error('VS Code file pick failed', error); toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code'); } }; return ( <>

Attach files

); }); FileAttachmentButton.displayName = 'FileAttachmentButton'; interface ImagePreviewProps { file: AttachedFile; onRemove: () => void; } const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => { const isLocalImagePreview = file.source !== 'server' && file.mimeType.startsWith('image/') && typeof file.dataUrl === 'string' && file.dataUrl.startsWith('data:image/'); const imageUrl = isLocalImagePreview ? file.dataUrl : (file.serverPath || ''); const extractFilename = (path: string): string => { const normalized = path.replace(/\\/g, '/'); const parts = normalized.split('/'); return parts[parts.length - 1] || path; }; const getFileExtension = (filename: string): string => { const parts = filename.split('.'); return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''; }; const displayName = extractFilename(file.filename); const extension = getFileExtension(file.filename); if (!imageUrl) { // Fallback to text-only for server images without preview return ( ); } return (
{displayName}
); }); ImagePreview.displayName = 'ImagePreview'; interface FileChipProps { file: AttachedFile; onRemove: () => void; } const FileChip = memo(({ file, onRemove }: FileChipProps) => { const getFileExtension = (filename: string): string => { const parts = filename.split('.'); return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''; }; const formatFileSize = (bytes: number) => { if (!Number.isFinite(bytes) || bytes <= 0) return ''; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }; const extractFilename = (path: string): string => { const normalized = path.replace(/\\/g, '/'); const parts = normalized.split('/'); const filename = parts[parts.length - 1]; return filename || path; }; const displayName = extractFilename(file.filename); const fileSize = formatFileSize(file.size); const extension = getFileExtension(file.filename); return ( ); }); FileChip.displayName = 'FileChip'; export const AttachedFilesList = memo(() => { const { attachedFiles, removeAttachedFile } = useSessionStore(); if (attachedFiles.length === 0) return null; const images = attachedFiles.filter(f => f.mimeType.startsWith('image/')); const otherFiles = attachedFiles.filter(f => !f.mimeType.startsWith('image/')); return (
{/* Images row - inline with previews */} {images.length > 0 && (
{images.map((file) => ( removeAttachedFile(file.id)} /> ))}
)} {/* Other files row - inline text-only */} {otherFiles.length > 0 && (
{otherFiles.map((file) => ( removeAttachedFile(file.id)} /> ))}
)}
); }); AttachedFilesList.displayName = 'AttachedFilesList'; interface FilePart { type: string; mime?: string; url?: string; filename?: string; size?: number; } interface MessageFilesDisplayProps { files: FilePart[]; onShowPopup?: (content: ToolPopupContent) => void; compact?: boolean; } export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }: MessageFilesDisplayProps) => { const fileItems = files.filter(f => f.type === 'file' && (f.mime || f.url)); const extractFilename = (path?: string): string => { if (!path) return 'Unnamed file'; const normalized = path.replace(/\\/g, '/'); const parts = normalized.split('/'); const filename = parts[parts.length - 1]; return filename || path; }; const formatFileSize = (bytes?: number) => { if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return ''; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; if (fileItems.length === 0) return null; return (
{fileItems.map((file, index) => { const fileName = extractFilename(file.filename || file.url); const isImage = file.mime?.startsWith('image/'); const sizeText = formatFileSize(file.size); if (isImage && file.url) { return (
{fileName}

{fileName}

{sizeText &&

{sizeText}

}
); } return (

{fileName}{sizeText ? ` (${sizeText})` : ''}

); })}
); }); MessageFilesDisplay.displayName = 'MessageFilesDisplay'; interface ImageGalleryProps { urls: string[]; caption?: string; onShowPopup?: (content: ToolPopupContent) => void; } export const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryProps) => { if (urls.length === 0) return null; const getGridCols = () => { if (urls.length === 1) return 'grid-cols-1'; if (urls.length === 2) return 'grid-cols-2'; if (urls.length <= 4) return 'grid-cols-2'; return 'grid-cols-3'; }; return (
{urls.map((url, index) => ( ))}
{caption && (

{caption}

)}
); }); ImageGallery.displayName = 'ImageGallery';