diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index fed945e0b1..b3666c7d36 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -834,6 +834,56 @@ export default function App() { }; }, []); + // Prevent default drag and drop behavior globally to avoid opening files in new windows + // but allow our React components to handle drops in designated areas + useEffect(() => { + console.log('Setting up global drag and drop prevention'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const preventDefaults = (e: any) => { + // Only prevent default if we're not over a designated drop zone + const target = e.target as HTMLElement; + const isOverDropZone = target.closest('[data-drop-zone="true"]') !== null; + + if (!isOverDropZone) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleDragOver = (e: any) => { + // Always prevent default for dragover to allow dropping + e.preventDefault(); + e.stopPropagation(); + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleDrop = (e: any) => { + // Only prevent default if we're not over a designated drop zone + const target = e.target as HTMLElement; + const isOverDropZone = target.closest('[data-drop-zone="true"]') !== null; + + if (!isOverDropZone) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + // Add event listeners to document to catch drag events + document.addEventListener('dragenter', preventDefaults, false); + document.addEventListener('dragleave', preventDefaults, false); + document.addEventListener('dragover', handleDragOver, false); + document.addEventListener('drop', handleDrop, false); + + return () => { + document.removeEventListener('dragenter', preventDefaults, false); + document.removeEventListener('dragleave', preventDefaults, false); + document.removeEventListener('dragover', handleDragOver, false); + document.removeEventListener('drop', handleDrop, false); + }; + }, []); + useEffect(() => { console.log('Setting up fatal error handler'); const handleFatalError = (_event: IpcRendererEvent, ...args: unknown[]) => { diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 25c38f2ea0..b462f3e5d5 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -55,7 +55,7 @@ function BaseChatContent({ renderAfterMessages, customChatInputProps = {}, customMainLayoutProps = {}, - contentClassName = "", + contentClassName = '', }: BaseChatProps) { const location = useLocation(); @@ -169,6 +169,7 @@ function BaseChatContent({ className={`flex flex-col min-w-0 flex-1 overflow-y-scroll relative ${contentClassName}`} onDrop={handleDrop} onDragOver={handleDragOver} + data-drop-zone="true" > {/* Custom content before messages */} {renderBeforeMessages && renderBeforeMessages()} diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index f68982af42..28676ad3a0 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -20,6 +20,7 @@ import { toastError } from '../toasts'; import MentionPopover, { FileItemWithMatch } from './MentionPopover'; import { COST_TRACKING_ENABLED } from '../updates'; import { CostTracker } from './bottom_menu/CostTracker'; +import { DroppedFile, useFileDrop } from '../hooks/useFileDrop'; interface PastedImage { id: string; @@ -49,7 +50,7 @@ interface ChatInputProps { onStop?: () => void; commandHistory?: string[]; // Current chat's message history initialValue?: string; - droppedFiles?: string[]; + droppedFiles?: DroppedFile[]; onFilesProcessed?: () => void; // Callback to clear dropped files after processing setView: (view: View) => void; numTokens?: number; @@ -175,7 +176,27 @@ export default function ChatInput({ const [isInGlobalHistory, setIsInGlobalHistory] = useState(false); const [hasUserTyped, setHasUserTyped] = useState(false); const textAreaRef = useRef(null); - const [processedFilePaths, setProcessedFilePaths] = useState([]); + + // Use shared file drop hook for ChatInput + const { + droppedFiles: localDroppedFiles, + setDroppedFiles: setLocalDroppedFiles, + handleDrop: handleLocalDrop, + handleDragOver: handleLocalDragOver, + } = useFileDrop(); + + // Merge local dropped files with parent dropped files + const allDroppedFiles = [...droppedFiles, ...localDroppedFiles]; + + const handleRemoveDroppedFile = (idToRemove: string) => { + // Remove from local dropped files + setLocalDroppedFiles((prev) => prev.filter((file) => file.id !== idToRemove)); + + // If it's from parent, call the parent's callback + if (onFilesProcessed && droppedFiles.some((file) => file.id === idToRemove)) { + onFilesProcessed(); + } + }; const handleRemovePastedImage = (idToRemove: string) => { const imageToRemove = pastedImages.find((img) => img.id === idToRemove); @@ -350,22 +371,6 @@ export default function ChatInput({ const minHeight = '1rem'; const maxHeight = 10 * 24; - // If we have dropped files, add them to the input and update our state. - useEffect(() => { - if (processedFilePaths !== droppedFiles && droppedFiles.length > 0) { - // Append file paths that aren't in displayValue. - const currentText = displayValue || ''; - const joinedPaths = currentText.trim() - ? `${currentText.trim()} ${droppedFiles.filter((path) => !currentText.includes(path)).join(' ')}` - : droppedFiles.join(' '); - - setDisplayValue(joinedPaths); - setValue(joinedPaths); - textAreaRef.current?.focus(); - setProcessedFilePaths(droppedFiles); - } - }, [droppedFiles, processedFilePaths, displayValue]); - // Debounced function to update actual value const debouncedSetValue = useMemo( () => @@ -455,48 +460,60 @@ export default function ChatInput({ id: `error-${Date.now()}`, dataUrl: '', isLoading: false, - error: `Cannot paste ${imageFiles.length} image(s). Maximum ${MAX_IMAGES_PER_MESSAGE} images per message allowed.`, + error: `Cannot paste ${imageFiles.length} image(s). Maximum ${MAX_IMAGES_PER_MESSAGE} images per message allowed. Currently have ${pastedImages.length}.`, }, ]); - // Remove the error message after 3 seconds + // Remove the error message after 5 seconds setTimeout(() => { setPastedImages((prev) => prev.filter((img) => !img.id.startsWith('error-'))); - }, 3000); + }, 5000); return; } evt.preventDefault(); + // Process each image file + const newImages: PastedImage[] = []; + for (const file of imageFiles) { // Check individual file size before processing if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) { const errorId = `error-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - setPastedImages((prev) => [ - ...prev, - { - id: errorId, - dataUrl: '', - isLoading: false, - error: `Image too large (${Math.round(file.size / (1024 * 1024))}MB). Maximum ${MAX_IMAGE_SIZE_MB}MB allowed.`, - }, - ]); + newImages.push({ + id: errorId, + dataUrl: '', + isLoading: false, + error: `Image too large (${Math.round(file.size / (1024 * 1024))}MB). Maximum ${MAX_IMAGE_SIZE_MB}MB allowed.`, + }); - // Remove the error message after 3 seconds + // Remove the error message after 5 seconds setTimeout(() => { setPastedImages((prev) => prev.filter((img) => img.id !== errorId)); - }, 3000); + }, 5000); continue; } + const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + // Add the image with loading state + newImages.push({ + id: imageId, + dataUrl: '', + isLoading: true, + }); + + // Process the image asynchronously const reader = new FileReader(); reader.onload = async (e) => { const dataUrl = e.target?.result as string; if (dataUrl) { - const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - setPastedImages((prev) => [...prev, { id: imageId, dataUrl, isLoading: true }]); + // Update the image with the data URL + setPastedImages((prev) => + prev.map((img) => (img.id === imageId ? { ...img, dataUrl, isLoading: true } : img)) + ); try { const result = await window.electron.saveDataUrlToTemp(dataUrl, imageId); @@ -519,8 +536,21 @@ export default function ChatInput({ } } }; + reader.onerror = () => { + console.error('Failed to read image file:', file.name); + setPastedImages((prev) => + prev.map((img) => + img.id === imageId + ? { ...img, error: 'Failed to read image file.', isLoading: false } + : img + ) + ); + }; reader.readAsDataURL(file); } + + // Add all new images to the existing list + setPastedImages((prev) => [...prev, ...newImages]); }; // Cleanup debounced functions on unmount @@ -623,18 +653,25 @@ export default function ChatInput({ .filter((img) => img.filePath && !img.error && !img.isLoading) .map((img) => img.filePath as string); + // Get paths from all dropped files (both parent and local) + const droppedFilePaths = allDroppedFiles + .filter((file) => !file.error && !file.isLoading) + .map((file) => file.path); + let textToSend = displayValue.trim(); - if (validPastedImageFilesPaths.length > 0) { - const pathsString = validPastedImageFilesPaths.join(' '); + // Combine pasted images and dropped files + const allFilePaths = [...validPastedImageFilesPaths, ...droppedFilePaths]; + if (allFilePaths.length > 0) { + const pathsString = allFilePaths.join(' '); textToSend = textToSend ? `${textToSend} ${pathsString}` : pathsString; } if (textToSend) { if (displayValue.trim()) { LocalMessageStorage.addMessage(displayValue); - } else if (validPastedImageFilesPaths.length > 0) { - LocalMessageStorage.addMessage(validPastedImageFilesPaths.join(' ')); + } else if (allFilePaths.length > 0) { + LocalMessageStorage.addMessage(allFilePaths.join(' ')); } handleSubmit( @@ -648,11 +685,14 @@ export default function ChatInput({ setSavedInput(''); setIsInGlobalHistory(false); setHasUserTyped(false); - - // Clear dropped files after processing + + // Clear both parent and local dropped files after processing if (onFilesProcessed && droppedFiles.length > 0) { onFilesProcessed(); } + if (localDroppedFiles.length > 0) { + setLocalDroppedFiles([]); + } } }; @@ -710,7 +750,8 @@ export default function ChatInput({ const canSubmit = !isLoading && (displayValue.trim() || - pastedImages.some((img) => img.filePath && !img.error && !img.isLoading)); + pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || + allDroppedFiles.some((file) => !file.error && !file.isLoading)); if (canSubmit) { performSubmit(); } @@ -722,7 +763,8 @@ export default function ChatInput({ const canSubmit = !isLoading && (displayValue.trim() || - pastedImages.some((img) => img.filePath && !img.error && !img.isLoading)); + pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || + allDroppedFiles.some((file) => !file.error && !file.isLoading)); if (canSubmit) { performSubmit(); } @@ -761,8 +803,11 @@ export default function ChatInput({ }; const hasSubmittableContent = - displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading); + displayValue.trim() || + pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || + allDroppedFiles.some((file) => !file.error && !file.isLoading); const isAnyImageLoading = pastedImages.some((img) => img.isLoading); + const isAnyDroppedFileLoading = allDroppedFiles.some((file) => file.isLoading); return (
0} /> @@ -813,8 +861,10 @@ export default function ChatInput({ )}
- {pastedImages.length > 0 && ( + {/* Combined files and images preview */} + {(pastedImages.length > 0 || allDroppedFiles.length > 0) && (
+ {/* Render pasted images first */} {pastedImages.map((img) => (
{img.dataUrl && ( @@ -862,6 +912,62 @@ export default function ChatInput({ )}
))} + + {/* Render dropped files after pasted images */} + {allDroppedFiles.map((file) => ( +
+ {file.isImage ? ( + // Image preview +
+ {file.dataUrl && ( + {file.name} + )} + {file.isLoading && ( +
+
+
+ )} + {file.error && !file.isLoading && ( +
+

+ {file.error.substring(0, 30)} +

+
+ )} +
+ ) : ( + // File box preview +
+
+ {file.name.split('.').pop()?.toUpperCase() || 'FILE'} +
+
+

+ {file.name} +

+

{file.type || 'Unknown type'}

+
+
+ )} + {!file.isLoading && ( + + )} +
+ ))}
)} @@ -948,21 +1054,31 @@ export default function ChatInput({ size="xs" variant="outline" disabled={ - !hasSubmittableContent || isAnyImageLoading || isRecording || isTranscribing + !hasSubmittableContent || + isAnyImageLoading || + isAnyDroppedFileLoading || + isRecording || + isTranscribing } className={`text-text-muted ${ - !hasSubmittableContent || isAnyImageLoading || isRecording || isTranscribing + !hasSubmittableContent || + isAnyImageLoading || + isAnyDroppedFileLoading || + isRecording || + isTranscribing ? 'text-textSubtle cursor-not-allowed' : 'bg-bgAppInverse text-textProminentInverse hover:cursor-pointer' }`} title={ isAnyImageLoading ? 'Waiting for images to save...' - : isRecording - ? 'Recording...' - : isTranscribing - ? 'Transcribing...' - : 'Send' + : isAnyDroppedFileLoading + ? 'Processing dropped files...' + : isRecording + ? 'Recording...' + : isTranscribing + ? 'Transcribing...' + : 'Send' } > diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index fef2063045..e0b4e05e91 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -48,6 +48,7 @@ import { import { SidebarTrigger } from './ui/sidebar'; import { SessionInsights } from './sessions/SessionsInsights'; import { useSidebar } from './ui/sidebar'; +import { useFileDrop } from '../hooks/useFileDrop'; import { Button } from './ui/button'; import { Idea } from './icons'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip'; @@ -161,7 +162,6 @@ function ChatContentWithSidebar({ const [localInputTokens, setLocalInputTokens] = useState(0); const [localOutputTokens, setLocalOutputTokens] = useState(0); const [ancestorMessages, setAncestorMessages] = useState([]); - const [droppedFiles, setDroppedFiles] = useState([]); const [isParameterModalOpen, setIsParameterModalOpen] = useState(false); const [recipeParameters, setRecipeParameters] = useState | null>(null); const [_sessionCosts, setSessionCosts] = useState<{ @@ -189,6 +189,9 @@ function ChatContentWithSidebar({ getContextHandlerType, } = useChatContextManager(); + // Use shared file drop + const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop(); + useEffect(() => { // Log all messages when the component first mounts window.electron.logInfo( @@ -764,22 +767,6 @@ function ChatContentWithSidebar({ sessionMetadata, ]); - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - const files = e.dataTransfer.files; - if (files.length > 0) { - const paths: string[] = []; - for (let i = 0; i < files.length; i++) { - paths.push(window.electron.getPathForFile(files[i])); - } - setDroppedFiles(paths); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - }; - const toolCallNotifications = notifications.reduce((map, item) => { const key = item.request_id; if (!map.has(key)) { @@ -920,6 +907,7 @@ function ChatContentWithSidebar({ className="flex flex-col min-w-0 flex-1 overflow-y-scroll relative pl-6 pr-4 pb-16 pt-2" onDrop={handleDrop} onDragOver={handleDragOver} + data-drop-zone="true" > {/* Session Insights at the top */} {messages.length === 0 && } @@ -1054,6 +1042,7 @@ function ChatContentWithSidebar({ // inputTokens={sessionInputTokens || localInputTokens} // outputTokens={sessionOutputTokens || localOutputTokens} droppedFiles={droppedFiles} + onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing messages={messages} setMessages={setMessages} // sessionCosts={sessionCosts} diff --git a/ui/desktop/src/hooks/useFileDrop.ts b/ui/desktop/src/hooks/useFileDrop.ts index 6431f3d0f9..3636b5e921 100644 --- a/ui/desktop/src/hooks/useFileDrop.ts +++ b/ui/desktop/src/hooks/useFileDrop.ts @@ -1,21 +1,104 @@ import { useCallback, useState } from 'react'; -export const useFileDrop = () => { - const [droppedFiles, setDroppedFiles] = useState([]); +export interface DroppedFile { + id: string; + path: string; + name: string; + type: string; + isImage: boolean; + dataUrl?: string; // For image previews + isLoading?: boolean; + error?: string; +} - const handleDrop = useCallback((e: React.DragEvent) => { +export const useFileDrop = () => { + const [droppedFiles, setDroppedFiles] = useState([]); + + const handleDrop = useCallback(async (e: React.DragEvent) => { + console.log('handleDrop called', e); e.preventDefault(); const files = e.dataTransfer.files; + console.log('Files dropped:', files.length, files); if (files.length > 0) { - const paths: string[] = []; + const droppedFileObjects: DroppedFile[] = []; + for (let i = 0; i < files.length; i++) { - paths.push(window.electron.getPathForFile(files[i])); + const file = files[i]; + console.log('Processing file:', file.name, file.type, file.size); + + let droppedFile: DroppedFile; + + try { + const path = window.electron.getPathForFile(file); + console.log('File path obtained:', path); + const isImage = file.type.startsWith('image/'); + + droppedFile = { + id: `dropped-${Date.now()}-${i}`, + path, + name: file.name, + type: file.type, + isImage, + isLoading: isImage, // Only images need loading state for preview generation + }; + + console.log('Created DroppedFile object:', droppedFile); + } catch (error) { + console.error('Error processing file:', file.name, error); + // Create an error file object + droppedFile = { + id: `dropped-error-${Date.now()}-${i}`, + path: '', + name: file.name, + type: file.type, + isImage: false, + isLoading: false, + error: `Failed to get file path: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + + droppedFileObjects.push(droppedFile); + + // For images, generate a preview (only if successfully processed) + if (droppedFile.isImage && !droppedFile.error) { + console.log('Generating preview for image:', file.name); + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result as string; + console.log('Image preview generated for:', file.name); + setDroppedFiles((prev) => + prev.map((f) => (f.id === droppedFile.id ? { ...f, dataUrl, isLoading: false } : f)) + ); + }; + reader.onerror = () => { + console.error('Failed to generate preview for:', file.name); + setDroppedFiles((prev) => + prev.map((f) => + f.id === droppedFile.id + ? { ...f, error: 'Failed to load image preview', isLoading: false } + : f + ) + ); + }; + reader.readAsDataURL(file); + } } - setDroppedFiles(paths); + + console.log('Adding dropped files to existing list. New files:', droppedFileObjects.length); + setDroppedFiles((prev) => { + console.log( + 'Current dropped files count:', + prev.length, + 'Adding:', + droppedFileObjects.length + ); + return [...prev, ...droppedFileObjects]; + }); } }, []); const handleDragOver = useCallback((e: React.DragEvent) => { + console.log('handleDragOver called in useFileDrop'); e.preventDefault(); }, []);