Restore the "Drop files here" backdrop when dragging files (#1801)

Decouples file drag-and-drop into a FileDropZone provider (drag target + backdrop) and a useFileDrop consumer hook, with drag state on a Reanimated shared value so dragging triggers no React re-renders.

Fixes the collapsed/invisible backdrop (the drop zone had been moved into the composer with flex:0, collapsing the overlay to zero height) and restores whole-surface coverage across the agent panel, New Workspace screen, draft workspace tab, and workspace-setup dialog. The backdrop and copy cursor only appear when a drop will actually be accepted (a consumer is mounted and not submitting).

Refs #520
This commit is contained in:
Mohamed Boudra
2026-06-29 16:55:45 +02:00
committed by GitHub
parent 6a23bbd121
commit 57800a0f17
14 changed files with 423 additions and 325 deletions
@@ -15,8 +15,6 @@ import {
} from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/composer/types";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
@@ -451,9 +449,6 @@ export interface AdaptiveModalSheetProps {
testID?: string;
/** Override the max width of the desktop card. */
desktopMaxWidth?: number;
/** When provided, wraps the card content in a FileDropZone. */
onFilesDropped?: (files: ImageAttachment[]) => void;
onGenericFilesDropped?: (items: import("@/hooks/use-file-drop-zone").DroppedItem[]) => void;
scrollable?: boolean;
presentation?: "push" | "replace";
}
@@ -467,8 +462,6 @@ export function AdaptiveModalSheet({
snapPoints,
testID,
desktopMaxWidth,
onFilesDropped,
onGenericFilesDropped,
scrollable = true,
presentation,
}: AdaptiveModalSheetProps) {
@@ -602,18 +595,7 @@ export function AdaptiveModalSheet({
style={ABSOLUTE_FILL_STYLE}
onPress={onClose}
/>
<View style={desktopCardStyle}>
{onFilesDropped ? (
<FileDropZone
onFilesDropped={onFilesDropped}
onGenericFilesDropped={onGenericFilesDropped}
>
{cardInner}
</FileDropZone>
) : (
cardInner
)}
</View>
<View style={desktopCardStyle}>{cardInner}</View>
</View>
);
@@ -1,106 +0,0 @@
import { View, Text } from "react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect, useMemo } from "react";
import { Upload } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useFileDropZone, type DroppedItem } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "@/composer/types";
import { isWeb } from "@/constants/platform";
interface FileDropZoneProps {
children: React.ReactNode;
onFilesDropped: (files: ImageAttachment[]) => void;
onGenericFilesDropped?: (items: DroppedItem[]) => void;
disabled?: boolean;
style?: StyleProp<ViewStyle>;
}
const IS_WEB = isWeb;
export function FileDropZone({
children,
onFilesDropped,
onGenericFilesDropped,
disabled = false,
style,
}: FileDropZoneProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({
onFilesDropped,
onGenericFilesDropped,
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",
}));
const overlayStyle = useMemo(
() => [styles.overlay, overlayAnimatedStyle],
[overlayAnimatedStyle],
);
const containerStyle = useMemo(() => [styles.container, style], [style]);
// 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={containerStyle}
>
{children}
{/* Drop overlay */}
<Animated.View style={overlayStyle}>
{/* Backdrop */}
<View style={styles.backdrop} />
{/* Content */}
<View style={styles.overlayContent}>
<Upload size={32} color={theme.colors.primary} />
<Text style={styles.overlayText}>{t("composer.attachments.dropFilesHere")}</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.surface0,
opacity: 0.7,
},
overlayContent: {
alignItems: "center",
gap: theme.spacing[2],
},
overlayText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
}));
@@ -0,0 +1,20 @@
import { createContext, useContext } from "react";
import type { SharedValue } from "react-native-reanimated";
import type { FileDropSink } from "./types";
export interface FileDropContextValue {
/** Drag-active flag, driven on the UI thread so toggling it triggers no React render. */
isDragging: SharedValue<boolean>;
/** Active sink can't accept right now (e.g. composer submitting): hide backdrop and reject drops. */
suppressed: SharedValue<boolean>;
/** Whether a consumer is currently registered — no consumer (e.g. archived agent), no backdrop. */
hasSink: SharedValue<boolean>;
/** Register the active sink. Pass a getter so the zone always reads the latest handlers. */
registerSink: (getSink: () => FileDropSink | null) => () => void;
}
export const FileDropContext = createContext<FileDropContextValue | null>(null);
export function useFileDropContext(): FileDropContextValue | null {
return useContext(FileDropContext);
}
@@ -0,0 +1,71 @@
import { View, Text, StyleSheet as RNStyleSheet } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming } from "react-native-reanimated";
import { Upload } from "lucide-react-native";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
import { useFileDropContext } from "./context";
const ThemedUpload = withUnistyles(Upload);
const primaryIconColorMapping = (theme: Theme) => ({ color: theme.colors.primary });
/**
* Drop overlay rendered by FileDropZone. Reads `isDragging` on the UI thread so the dim
* only ever repaints the backdrop — never the surrounding tree.
*/
export function FileDropBackdrop() {
const { t } = useTranslation();
const ctx = useFileDropContext();
const isDragging = ctx?.isDragging;
const suppressed = ctx?.suppressed;
const hasSink = ctx?.hasSink;
const animatedStyle = useAnimatedStyle(() => {
const active = isDragging?.value && hasSink?.value && !suppressed?.value;
return { opacity: withTiming(active ? 1 : 0, { duration: 150 }) };
});
const overlayStyle = useMemo(() => [positionStyles.overlay, animatedStyle], [animatedStyle]);
if (!ctx) return null;
// Animated.View keeps only plain-RN positioning; theme-dependent paint lives on the
// non-animated children (applying themed Unistyles styles to an Animated.View crashes
// on theme change — see docs/unistyles.md).
return (
<Animated.View style={overlayStyle} pointerEvents="none">
<View style={styles.backdrop} />
<View style={styles.content}>
<ThemedUpload size={32} uniProps={primaryIconColorMapping} />
<Text style={styles.text}>{t("composer.attachments.dropFilesHere")}</Text>
</View>
</Animated.View>
);
}
const positionStyles = RNStyleSheet.create({
overlay: {
...RNStyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
},
});
const styles = StyleSheet.create((theme) => ({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: theme.colors.surface0,
opacity: 0.7,
},
content: {
alignItems: "center",
gap: theme.spacing[2],
},
text: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
}));
@@ -0,0 +1,84 @@
import type { ReactNode, RefObject } from "react";
import { useCallback, useMemo, useRef } from "react";
import { View } from "react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useSharedValue } from "react-native-reanimated";
import { isWeb } from "@/constants/platform";
import { FileDropContext, type FileDropContextValue } from "./context";
import { FileDropBackdrop } from "./file-drop-backdrop";
import { useDropListeners } from "./use-drop-listeners";
import type { FileDropSink } from "./types";
interface FileDropZoneProps {
children: ReactNode;
/** When true, no drops are accepted and the backdrop stays hidden. */
disabled?: boolean;
/** Styles the drop area (defaults to filling its parent). The backdrop fills this area. */
style?: StyleProp<ViewStyle>;
}
/**
* Defines a drag-and-drop area and renders its dim backdrop. Files are consumed by any
* descendant calling `useFileDrop` — the drop area, the backdrop, and the consumer are
* decoupled, so a consumer's layout can never collapse the backdrop.
*/
export function FileDropZone({ children, disabled = false, style }: FileDropZoneProps) {
const isDragging = useSharedValue(false);
const suppressed = useSharedValue(false);
const hasSink = useSharedValue(false);
const activeGetSink = useRef<(() => FileDropSink | null) | null>(null);
const registerSink = useCallback(
(getSink: () => FileDropSink | null) => {
activeGetSink.current = getSink;
hasSink.value = true;
return () => {
if (activeGetSink.current === getSink) {
activeGetSink.current = null;
hasSink.value = false;
}
};
},
[hasSink],
);
const getSink = useCallback(() => activeGetSink.current?.() ?? null, []);
const ctx = useMemo<FileDropContextValue>(
() => ({ isDragging, suppressed, hasSink, registerSink }),
[isDragging, suppressed, hasSink, registerSink],
);
const containerRef = useDropListeners({ isDragging, suppressed, hasSink, getSink, disabled });
const targetStyle = useMemo(() => [styles.target, style], [style]);
// On native there is no web drag-and-drop, so skip the listeners and the backdrop — but still
// render the styled layout View (callers use FileDropZone as their container) and provide
// context so useFileDrop no-ops safely.
if (!isWeb) {
return (
<FileDropContext.Provider value={ctx}>
<View style={targetStyle}>{children}</View>
</FileDropContext.Provider>
);
}
return (
<FileDropContext.Provider value={ctx}>
<View ref={containerRef as unknown as RefObject<View>} style={targetStyle}>
{children}
<FileDropBackdrop />
</View>
</FileDropContext.Provider>
);
}
// No default flex: the caller's `style` owns sizing (full-area surfaces pass flex:1; a dialog
// passes a content-sized style). `position` anchors the absolutely-positioned backdrop.
const styles = StyleSheet.create({
target: {
position: "relative",
},
});
@@ -0,0 +1,21 @@
import type { ImageAttachment } from "@/composer/types";
export interface DroppedFileItem {
kind: "web-file";
file: File;
}
export interface DroppedPathItem {
kind: "desktop-path";
path: string;
}
export type DroppedItem = DroppedFileItem | DroppedPathItem;
/**
* What a consumer (e.g. a composer) registers to receive files dropped onto the
* surrounding FileDropZone. Raster images arrive already persisted via `onFiles`;
* everything else arrives raw via `onGenericFiles`.
*/
export interface FileDropSink {
onFiles: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
}
@@ -0,0 +1,281 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import type { SharedValue } from "react-native-reanimated";
import type { ImageAttachment } from "@/composer/types";
import { getDesktopHost } from "@/desktop/host";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
import {
getRasterImageMimeTypeFromPath,
isRasterImageFile,
isRasterImagePath,
} from "@/attachments/file-types";
import { isWeb } from "@/constants/platform";
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
type DesktopDragDropPayload =
| { type: "enter"; paths: string[] }
| { type: "over" }
| { type: "drop"; paths: string[] }
| { type: "leave" };
interface DesktopDragDropEvent {
payload: DesktopDragDropPayload;
}
async function filePathToImageAttachment(path: string): Promise<ImageAttachment> {
const mimeType = getRasterImageMimeTypeFromPath(path) ?? "image/jpeg";
return await persistAttachmentFromFileUri({ uri: path, mimeType });
}
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
return await persistAttachmentFromBlob({
blob: file,
mimeType: file.type || "image/jpeg",
fileName: file.name,
});
}
interface UseDropListenersOptions {
isDragging: SharedValue<boolean>;
/** Active sink can't accept right now: reject drops without showing acceptance. */
suppressed: SharedValue<boolean>;
/** Whether a consumer is mounted: with none, don't advertise or accept drops. */
hasSink: SharedValue<boolean>;
/** Stable getter for the currently registered sink. */
getSink: () => FileDropSink | null;
disabled: boolean;
}
/**
* Attaches web/desktop drag-and-drop listeners to the returned element ref. Drag state is
* written to a shared value (no React renders); dropped files are routed to the active sink.
*/
export function useDropListeners({
isDragging,
suppressed,
hasSink,
getSink,
disabled,
}: UseDropListenersOptions): RefObject<HTMLElement | null> {
const containerRef = useRef<HTMLElement | null>(null);
const dragCounter = useRef(0);
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
// Clear an in-progress drag when the zone becomes disabled.
useEffect(() => {
if (disabled) {
isDragging.value = false;
dragCounter.current = 0;
}
}, [disabled, isDragging]);
useEffect(() => {
if (!isWeb) return;
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
function runCleanup(unlisten?: () => void | Promise<void>) {
if (didCleanup) return;
const cleanupFn = unlisten ?? cleanup;
if (!cleanupFn) return;
didCleanup = true;
try {
void Promise.resolve(cleanupFn()).catch((error) => {
console.warn("[useDropListeners] Failed to remove desktop drag-drop listener:", error);
});
} catch (error) {
console.warn("[useDropListeners] Failed to remove desktop drag-drop listener:", error);
}
}
// Desktop drag-drop (Tauri-style) is window-scoped, not element-scoped: with multiple zones
// mounted, every zone would react to the same drop. Dormant today — current Electron does not
// expose onDragDropEvent, so the element-scoped HTML5 DOM path below is what actually runs.
async function setupDesktopDragDrop(): Promise<boolean> {
const desktopHost = getDesktopHost();
if (desktopHost === null) {
return false;
}
const desktopWindow = desktopHost.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.onDragDropEvent !== "function") {
return false;
}
try {
const unlisten = await desktopWindow.onDragDropEvent((event: DesktopDragDropEvent) => {
const payload = event.payload;
if (payload.type === "leave") {
isDragging.value = false;
return;
}
if (payload.type === "enter" || payload.type === "over") {
if (!disabledRef.current) {
isDragging.value = true;
}
return;
}
// Drop always ends the current drag operation.
isDragging.value = false;
if (disabledRef.current || suppressed.value) return;
const sink = getSink();
if (!sink) return;
const items: DroppedPathItem[] = payload.paths.map((path) => ({
kind: "desktop-path",
path,
}));
if (sink.onGenericFiles && items.length > 0) {
sink.onGenericFiles(items);
}
const imagePaths = payload.paths.filter(isRasterImagePath);
if (imagePaths.length === 0) {
return;
}
void Promise.all(imagePaths.map(filePathToImageAttachment))
.then((attachments) => {
if (attachments.length === 0) {
return;
}
// Use the sink captured at drop time, not a fresh getSink() — routing belongs to the
// composer the user dropped on (matches the web path below). No post-persist busy
// re-check: a mixed drop's own generic upload flips the busy flag, and re-checking
// would discard the image from the same drop.
sink.onFiles(attachments);
return;
})
.catch((error) => {
console.error("[useDropListeners] Failed to persist dropped files:", error);
});
});
if (disposed) {
runCleanup(unlisten);
return true;
}
cleanup = unlisten;
return true;
} catch (error) {
console.warn("[useDropListeners] Failed to listen for desktop drag-drop:", error);
return false;
}
}
function setupDomDragDrop() {
const element = containerRef.current;
if (!element) {
return;
}
function handleDragEnter(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabledRef.current) return;
dragCounter.current++;
if (e.dataTransfer?.types.includes("Files")) {
isDragging.value = true;
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (!e.dataTransfer) return;
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabledRef.current) return;
dragCounter.current--;
if (dragCounter.current === 0) {
isDragging.value = false;
}
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
isDragging.value = false;
dragCounter.current = 0;
if (disabledRef.current || suppressed.value) return;
const sink = getSink();
if (!sink) return;
const files = Array.from(e.dataTransfer?.files ?? []);
const genericItems: DroppedItem[] = files.map((file) => ({
kind: "web-file",
file,
}));
if (sink.onGenericFiles && genericItems.length > 0) {
sink.onGenericFiles(genericItems);
}
const imageFiles = files.filter(isRasterImageFile);
if (imageFiles.length === 0) return;
try {
const attachments = await Promise.all(imageFiles.map(fileToImageAttachment));
// No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag,
// and re-checking would discard the image from the same drop. The guard at drop start
// already rejects drops that begin while busy.
sink.onFiles(attachments);
} catch (error) {
console.error("[useDropListeners] Failed to process dropped files:", error);
}
}
element.addEventListener("dragenter", handleDragEnter);
element.addEventListener("dragover", handleDragOver);
element.addEventListener("dragleave", handleDragLeave);
element.addEventListener("drop", handleDrop);
cleanup = () => {
element.removeEventListener("dragenter", handleDragEnter);
element.removeEventListener("dragover", handleDragOver);
element.removeEventListener("dragleave", handleDragLeave);
element.removeEventListener("drop", handleDrop);
};
}
void (async () => {
const desktopListenersAttached = await setupDesktopDragDrop();
if (disposed || desktopListenersAttached) {
return;
}
setupDomDragDrop();
})();
return () => {
disposed = true;
runCleanup();
};
}, [isDragging, suppressed, hasSink, getSink]);
return containerRef;
}
@@ -0,0 +1,35 @@
import { useEffect, useRef } from "react";
import { useFileDropContext } from "./context";
import type { FileDropSink } from "./types";
interface UseFileDropOptions {
/** When true, the zone hides the backdrop and rejects drops atomically (e.g. while submitting). */
disabled?: boolean;
}
/**
* Receive files dropped onto the surrounding FileDropZone. The sink is read through
* a ref, so passing a fresh object every render neither re-registers nor re-renders.
* No-ops when rendered without a FileDropZone ancestor.
*/
export function useFileDrop(sink: FileDropSink, options?: UseFileDropOptions): void {
const ctx = useFileDropContext();
const sinkRef = useRef(sink);
sinkRef.current = sink;
const disabled = options?.disabled ?? false;
const registerSink = ctx?.registerSink;
useEffect(() => {
if (!registerSink) return;
return registerSink(() => sinkRef.current);
}, [registerSink]);
const suppressed = ctx?.suppressed;
useEffect(() => {
if (!suppressed) return;
suppressed.value = disabled;
return () => {
suppressed.value = false;
};
}, [suppressed, disabled]);
}
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { useToast } from "@/contexts/toast-context";
@@ -420,7 +421,7 @@ export function WorkspaceSetupDialog() {
testID="workspace-setup-dialog"
desktopMaxWidth={640}
>
<View style={styles.section}>
<FileDropZone style={styles.section}>
<Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
serverId={serverId}
@@ -440,7 +441,7 @@ export function WorkspaceSetupDialog() {
inputWrapperStyle={styles.composerInputWrapper}
footer={composerFooter}
/>
</View>
</FileDropZone>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</AdaptiveModalSheet>