fix drag and drop

This commit is contained in:
Zane Staggs
2025-07-08 20:50:56 -07:00
parent 2b30dc3a28
commit 5da375665e
5 changed files with 314 additions and 75 deletions
+50
View File
@@ -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[]) => {
+2 -1
View File
@@ -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()}
+167 -51
View File
@@ -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<HTMLTextAreaElement>(null);
const [processedFilePaths, setProcessedFilePaths] = useState<string[]>([]);
// 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 (
<div
@@ -773,6 +818,9 @@ export default function ChatInput({
? 'border-borderProminent hover:border-borderProminent'
: 'border-borderSubtle hover:border-borderStandard'
} bg-bgApp z-10`}
data-drop-zone="true"
onDrop={handleLocalDrop}
onDragOver={handleLocalDragOver}
>
<div className="p-2 pb-0">
<DirSwitcher hasMessages={messages.length > 0} />
@@ -813,8 +861,10 @@ export default function ChatInput({
)}
</div>
{pastedImages.length > 0 && (
{/* Combined files and images preview */}
{(pastedImages.length > 0 || allDroppedFiles.length > 0) && (
<div className="flex flex-wrap gap-2 p-2 border-t border-borderSubtle">
{/* Render pasted images first */}
{pastedImages.map((img) => (
<div key={img.id} className="relative group w-20 h-20">
{img.dataUrl && (
@@ -862,6 +912,62 @@ export default function ChatInput({
)}
</div>
))}
{/* Render dropped files after pasted images */}
{allDroppedFiles.map((file) => (
<div key={file.id} className="relative group">
{file.isImage ? (
// Image preview
<div className="w-20 h-20">
{file.dataUrl && (
<img
src={file.dataUrl}
alt={file.name}
className={`w-full h-full object-cover rounded border ${file.error ? 'border-red-500' : 'border-borderStandard'}`}
/>
)}
{file.isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded">
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-white"></div>
</div>
)}
{file.error && !file.isLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-75 rounded p-1 text-center">
<p className="text-red-400 text-[10px] leading-tight break-all">
{file.error.substring(0, 30)}
</p>
</div>
)}
</div>
) : (
// File box preview
<div className="flex items-center gap-2 px-3 py-2 bg-bgSubtle border border-borderStandard rounded-lg min-w-[120px] max-w-[200px]">
<div className="flex-shrink-0 w-8 h-8 bg-bgApp border border-borderSubtle rounded flex items-center justify-center text-xs font-mono text-textSubtle">
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-textStandard truncate" title={file.name}>
{file.name}
</p>
<p className="text-xs text-textSubtle">{file.type || 'Unknown type'}</p>
</div>
</div>
)}
{!file.isLoading && (
<Button
type="button"
shape="round"
onClick={() => handleRemoveDroppedFile(file.id)}
className="absolute -top-1 -right-1 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10"
aria-label="Remove file"
variant="outline"
size="xs"
>
<Close />
</Button>
)}
</div>
))}
</div>
)}
@@ -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'
}
>
<Send />
+6 -17
View File
@@ -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<number>(0);
const [localOutputTokens, setLocalOutputTokens] = useState<number>(0);
const [ancestorMessages, setAncestorMessages] = useState<Message[]>([]);
const [droppedFiles, setDroppedFiles] = useState<string[]>([]);
const [isParameterModalOpen, setIsParameterModalOpen] = useState(false);
const [recipeParameters, setRecipeParameters] = useState<Record<string, string> | 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 && <SessionInsights />}
@@ -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}
+89 -6
View File
@@ -1,21 +1,104 @@
import { useCallback, useState } from 'react';
export const useFileDrop = () => {
const [droppedFiles, setDroppedFiles] = useState<string[]>([]);
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<HTMLDivElement>) => {
export const useFileDrop = () => {
const [droppedFiles, setDroppedFiles] = useState<DroppedFile[]>([]);
const handleDrop = useCallback(async (e: React.DragEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
console.log('handleDragOver called in useFileDrop');
e.preventDefault();
}, []);