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:
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user