diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 55905f1cb..68b8027c6 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -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(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 = ( + {/* Header */} + )} {/* Dropdown Menu */} @@ -899,6 +911,7 @@ function AgentScreenContent({ + {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} {!isMobile && isExplorerOpen && resolvedAgentId && ( diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index ee8baa417..1b705a3a1 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -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) { diff --git a/packages/app/src/components/file-drop-zone.tsx b/packages/app/src/components/file-drop-zone.tsx new file mode 100644 index 000000000..09c4b441d --- /dev/null +++ b/packages/app/src/components/file-drop-zone.tsx @@ -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 ( + } + style={styles.container} + > + {children} + + {/* Drop overlay */} + + {/* Backdrop */} + + {/* Content */} + + + Drop images here + + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index e2ecff4a6..23b98cbbb 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -426,20 +426,30 @@ export function MessageInput({ {hasImages && ( {images.map((image, index) => ( - - - {onRemoveImage && ( - onRemoveImage(index)} - style={styles.removeImageButton} - > - - + onRemoveImage(index) : undefined} + > + {({ hovered }) => ( + <> + + {onRemoveImage && ( + + + + )} + )} - + ))} )} @@ -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%", diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts new file mode 100644 index 000000000..dbfd68b3b --- /dev/null +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -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; +} + +const IS_WEB = Platform.OS === "web"; + +function isImageFile(file: File): boolean { + return file.type.startsWith("image/"); +} + +async function fileToImageAttachment(file: File): Promise { + 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(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, + }; +}