feat: add drag-and-drop image upload and improve image previews

- Add FileDropZone component with overlay for web drag-and-drop
- Add useFileDropZone hook using native addEventListener for web
- Wire up drag-drop to agent screen to add images to input
- Improve image preview styling with visible border
- Show X button on hover with dark overlay for removal

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Mohamed Boudra
2026-01-07 18:05:02 +07:00
parent 0d9a69d00f
commit a515f1fd03
5 changed files with 316 additions and 25 deletions
@@ -41,6 +41,8 @@ import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area"; import { AgentInputArea } from "@/components/agent-input-area";
import { ImportAgentModal } from "@/components/create-agent-modal"; import { ImportAgentModal } from "@/components/create-agent-modal";
import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import { import {
ExplorerSidebarAnimationProvider, ExplorerSidebarAnimationProvider,
useExplorerSidebarAnimation, useExplorerSidebarAnimation,
@@ -183,6 +185,15 @@ function AgentScreenContent({
const [menuContentHeight, setMenuContentHeight] = useState(0); const [menuContentHeight, setMenuContentHeight] = useState(0);
const menuButtonRef = useRef<View>(null); const menuButtonRef = useRef<View>(null);
const [showImportAgentModal, setShowImportAgentModal] = useState(false); const [showImportAgentModal, setShowImportAgentModal] = useState(false);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const { isOpen: isExplorerOpen, toggle: toggleExplorer, open: openExplorer, close: closeExplorer } = useExplorerSidebarStore(); const { isOpen: isExplorerOpen, toggle: toggleExplorer, open: openExplorer, close: closeExplorer } = useExplorerSidebarStore();
const { const {
@@ -672,6 +683,7 @@ function AgentScreenContent({
const mainContent = ( const mainContent = (
<View style={styles.outerContainer}> <View style={styles.outerContainer}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing}>
<View style={styles.container}> <View style={styles.container}>
{/* Header */} {/* Header */}
<MenuHeader <MenuHeader
@@ -735,7 +747,7 @@ function AgentScreenContent({
{/* Agent Input Area */} {/* Agent Input Area */}
{!isInitializing && agent && resolvedAgentId && ( {!isInitializing && agent && resolvedAgentId && (
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus /> <AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
)} )}
{/* Dropdown Menu */} {/* Dropdown Menu */}
@@ -899,6 +911,7 @@ function AgentScreenContent({
</View> </View>
</Modal> </Modal>
</View> </View>
</FileDropZone>
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
{!isMobile && isExplorerOpen && resolvedAgentId && ( {!isMobile && isExplorerOpen && resolvedAgentId && (
@@ -46,6 +46,8 @@ interface AgentInputAreaProps {
onChangeText?: (text: string) => void; onChangeText?: (text: string) => void;
/** When true, auto-focuses the text input on web. */ /** When true, auto-focuses the text input on web. */
autoFocus?: boolean; autoFocus?: boolean;
/** Callback to expose the addImages function to parent components */
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
} }
const EMPTY_ARRAY: readonly QueuedMessage[] = []; const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -68,6 +70,7 @@ export function AgentInputArea({
value, value,
onChangeText, onChangeText,
autoFocus = false, autoFocus = false,
onAddImages,
}: AgentInputAreaProps) { }: AgentInputAreaProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -130,6 +133,15 @@ export function AgentInputArea({
const sendAgentMessageRef = useRef(sendAgentMessage); const sendAgentMessageRef = useRef(sendAgentMessage);
const onSubmitMessageRef = useRef(onSubmitMessage); const onSubmitMessageRef = useRef(onSubmitMessage);
// Expose addImages function to parent for drag-and-drop support
const addImages = useCallback((images: ImageAttachment[]) => {
setSelectedImages((prev) => [...prev, ...images]);
}, []);
useEffect(() => {
onAddImages?.(addImages);
}, [addImages, onAddImages]);
const submitMessage = useCallback( const submitMessage = useCallback(
async (text: string, images?: ImageAttachment[]) => { async (text: string, images?: ImageAttachment[]) => {
if (onSubmitMessageRef.current) { if (onSubmitMessageRef.current) {
@@ -0,0 +1,95 @@
import { View, Text, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, {
useAnimatedStyle,
withTiming,
useSharedValue,
} from "react-native-reanimated";
import { useEffect } from "react";
import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "./message-input";
interface FileDropZoneProps {
children: React.ReactNode;
onFilesDropped: (files: ImageAttachment[]) => void;
disabled?: boolean;
}
const IS_WEB = Platform.OS === "web";
export function FileDropZone({
children,
onFilesDropped,
disabled = false,
}: FileDropZoneProps) {
const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({
onFilesDropped,
disabled,
});
const overlayOpacity = useSharedValue(0);
useEffect(() => {
overlayOpacity.value = withTiming(isDragging ? 1 : 0, { duration: 150 });
}, [isDragging, overlayOpacity]);
const overlayAnimatedStyle = useAnimatedStyle(() => ({
opacity: overlayOpacity.value,
pointerEvents: overlayOpacity.value > 0 ? "auto" : "none",
}));
// On non-web platforms, just render children
if (!IS_WEB) {
return <>{children}</>;
}
return (
<View
// Cast ref for web - View renders as div on web
ref={containerRef as unknown as React.RefObject<View>}
style={styles.container}
>
{children}
{/* Drop overlay */}
<Animated.View style={[styles.overlay, overlayAnimatedStyle]}>
{/* Backdrop */}
<View style={styles.backdrop} />
{/* Content */}
<View style={styles.overlayContent}>
<Upload size={32} color={theme.colors.primary} />
<Text style={styles.overlayText}>Drop images here</Text>
</View>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
position: "relative",
},
overlay: {
...StyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: theme.colors.background,
opacity: 0.7,
},
overlayContent: {
alignItems: "center",
gap: theme.spacing[2],
},
overlayText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
}));
+51 -24
View File
@@ -426,20 +426,30 @@ export function MessageInput({
{hasImages && ( {hasImages && (
<View style={styles.imagePreviewContainer}> <View style={styles.imagePreviewContainer}>
{images.map((image, index) => ( {images.map((image, index) => (
<View key={`${image.uri}-${index}`} style={styles.imagePill}> <Pressable
<Image key={`${image.uri}-${index}`}
source={{ uri: image.uri }} style={styles.imagePill}
style={styles.imageThumbnail} onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
/> >
{onRemoveImage && ( {({ hovered }) => (
<Pressable <>
onPress={() => onRemoveImage(index)} <Image
style={styles.removeImageButton} source={{ uri: image.uri }}
> style={styles.imageThumbnail}
<X size={16} color={theme.colors.foreground} /> />
</Pressable> {onRemoveImage && (
<View
style={[
styles.removeImageButton,
(hovered || !IS_WEB) && styles.removeImageButtonVisible,
]}
>
<X size={16} color="white" />
</View>
)}
</>
)} )}
</View> </Pressable>
))} ))}
</View> </View>
)} )}
@@ -591,23 +601,40 @@ const styles = StyleSheet.create(((theme: any) => ({
flexWrap: "wrap", flexWrap: "wrap",
}, },
imagePill: { imagePill: {
flexDirection: "row", position: "relative",
alignItems: "center", borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.muted, borderWidth: 1,
borderRadius: theme.borderRadius.lg, borderColor: theme.colors.accentBorder,
padding: theme.spacing[1], overflow: "hidden",
gap: theme.spacing[2], ...(IS_WEB
? {
cursor: "pointer",
}
: {}),
}, },
imageThumbnail: { imageThumbnail: {
width: 40, width: 48,
height: 40, height: 48,
borderRadius: theme.borderRadius.md,
}, },
removeImageButton: { removeImageButton: {
width: 24, position: "absolute",
height: 24, top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: 0,
...(IS_WEB
? {
transitionProperty: "opacity",
transitionDuration: "150ms",
}
: {}),
},
removeImageButtonVisible: {
opacity: 1,
}, },
textInput: { textInput: {
width: "100%", width: "100%",
@@ -0,0 +1,144 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input";
interface UseFileDropZoneOptions {
onFilesDropped: (files: ImageAttachment[]) => void;
disabled?: boolean;
}
interface UseFileDropZoneReturn {
isDragging: boolean;
containerRef: React.RefObject<HTMLElement | null>;
}
const IS_WEB = Platform.OS === "web";
function isImageFile(file: File): boolean {
return file.type.startsWith("image/");
}
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve({
uri: reader.result,
mimeType: file.type || "image/jpeg",
});
} else {
reject(new Error("Failed to read file as data URL"));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export function useFileDropZone({
onFilesDropped,
disabled = false,
}: UseFileDropZoneOptions): UseFileDropZoneReturn {
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLElement | null>(null);
const dragCounterRef = useRef(0);
const onFilesDroppedRef = useRef(onFilesDropped);
// Keep callback ref up to date
useEffect(() => {
onFilesDroppedRef.current = onFilesDropped;
}, [onFilesDropped]);
// Reset drag state when disabled changes
useEffect(() => {
if (disabled) {
setIsDragging(false);
dragCounterRef.current = 0;
}
}, [disabled]);
// Set up event listeners on web
useEffect(() => {
if (!IS_WEB) return;
const element = containerRef.current;
if (!element) return;
function handleDragEnter(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current++;
if (e.dataTransfer?.types.includes("Files")) {
setIsDragging(true);
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "copy";
}
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragging(false);
}
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounterRef.current = 0;
if (disabled) return;
const files = Array.from(e.dataTransfer?.files ?? []);
const imageFiles = files.filter(isImageFile);
if (imageFiles.length === 0) return;
try {
const attachments = await Promise.all(
imageFiles.map(fileToImageAttachment)
);
onFilesDroppedRef.current(attachments);
} catch (error) {
console.error("[useFileDropZone] Failed to process dropped files:", error);
}
}
element.addEventListener("dragenter", handleDragEnter);
element.addEventListener("dragover", handleDragOver);
element.addEventListener("dragleave", handleDragLeave);
element.addEventListener("drop", handleDrop);
return () => {
element.removeEventListener("dragenter", handleDragEnter);
element.removeEventListener("dragover", handleDragOver);
element.removeEventListener("dragleave", handleDragLeave);
element.removeEventListener("drop", handleDrop);
};
}, [disabled]);
return {
isDragging,
containerRef,
};
}