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

View File

@@ -41,6 +41,8 @@ import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { ImportAgentModal } from "@/components/create-agent-modal";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import {
ExplorerSidebarAnimationProvider,
useExplorerSidebarAnimation,
@@ -183,6 +185,15 @@ function AgentScreenContent({
const [menuContentHeight, setMenuContentHeight] = useState(0);
const menuButtonRef = useRef<View>(null);
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 {
@@ -672,6 +683,7 @@ function AgentScreenContent({
const mainContent = (
<View style={styles.outerContainer}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing}>
<View style={styles.container}>
{/* Header */}
<MenuHeader
@@ -735,7 +747,7 @@ function AgentScreenContent({
{/* Agent Input Area */}
{!isInitializing && agent && resolvedAgentId && (
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus />
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
)}
{/* Dropdown Menu */}
@@ -899,6 +911,7 @@ function AgentScreenContent({
</View>
</Modal>
</View>
</FileDropZone>
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
{!isMobile && isExplorerOpen && resolvedAgentId && (

View File

@@ -46,6 +46,8 @@ interface AgentInputAreaProps {
onChangeText?: (text: string) => void;
/** When true, auto-focuses the text input on web. */
autoFocus?: boolean;
/** Callback to expose the addImages function to parent components */
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -68,6 +70,7 @@ export function AgentInputArea({
value,
onChangeText,
autoFocus = false,
onAddImages,
}: AgentInputAreaProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
@@ -130,6 +133,15 @@ export function AgentInputArea({
const sendAgentMessageRef = useRef(sendAgentMessage);
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(
async (text: string, images?: ImageAttachment[]) => {
if (onSubmitMessageRef.current) {

View File

@@ -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,
},
}));

View File

@@ -426,20 +426,30 @@ export function MessageInput({
{hasImages && (
<View style={styles.imagePreviewContainer}>
{images.map((image, index) => (
<View key={`${image.uri}-${index}`} style={styles.imagePill}>
<Image
source={{ uri: image.uri }}
style={styles.imageThumbnail}
/>
{onRemoveImage && (
<Pressable
onPress={() => onRemoveImage(index)}
style={styles.removeImageButton}
>
<X size={16} color={theme.colors.foreground} />
</Pressable>
<Pressable
key={`${image.uri}-${index}`}
style={styles.imagePill}
onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
>
{({ hovered }) => (
<>
<Image
source={{ uri: image.uri }}
style={styles.imageThumbnail}
/>
{onRemoveImage && (
<View
style={[
styles.removeImageButton,
(hovered || !IS_WEB) && styles.removeImageButtonVisible,
]}
>
<X size={16} color="white" />
</View>
)}
</>
)}
</View>
</Pressable>
))}
</View>
)}
@@ -591,23 +601,40 @@ const styles = StyleSheet.create(((theme: any) => ({
flexWrap: "wrap",
},
imagePill: {
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[1],
gap: theme.spacing[2],
position: "relative",
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.accentBorder,
overflow: "hidden",
...(IS_WEB
? {
cursor: "pointer",
}
: {}),
},
imageThumbnail: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.md,
width: 48,
height: 48,
},
removeImageButton: {
width: 24,
height: 24,
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: 0,
...(IS_WEB
? {
transitionProperty: "opacity",
transitionDuration: "150ms",
}
: {}),
},
removeImageButtonVisible: {
opacity: 1,
},
textInput: {
width: "100%",

View File

@@ -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,
};
}