diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx
index 979d0db29..e564f429d 100644
--- a/packages/app/src/components/adaptive-modal-sheet.tsx
+++ b/packages/app/src/components/adaptive-modal-sheet.tsx
@@ -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}
/>
-
- {onFilesDropped ? (
-
- {cardInner}
-
- ) : (
- cardInner
- )}
-
+ {cardInner}
);
diff --git a/packages/app/src/components/file-drop-zone.tsx b/packages/app/src/components/file-drop-zone.tsx
deleted file mode 100644
index 0a0ba4b3f..000000000
--- a/packages/app/src/components/file-drop-zone.tsx
+++ /dev/null
@@ -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;
-}
-
-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 (
- }
- style={containerStyle}
- >
- {children}
-
- {/* Drop overlay */}
-
- {/* Backdrop */}
-
- {/* Content */}
-
-
- {t("composer.attachments.dropFilesHere")}
-
-
-
- );
-}
-
-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,
- },
-}));
diff --git a/packages/app/src/components/file-drop/context.ts b/packages/app/src/components/file-drop/context.ts
new file mode 100644
index 000000000..32640f38a
--- /dev/null
+++ b/packages/app/src/components/file-drop/context.ts
@@ -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;
+ /** Active sink can't accept right now (e.g. composer submitting): hide backdrop and reject drops. */
+ suppressed: SharedValue;
+ /** Whether a consumer is currently registered — no consumer (e.g. archived agent), no backdrop. */
+ hasSink: SharedValue;
+ /** Register the active sink. Pass a getter so the zone always reads the latest handlers. */
+ registerSink: (getSink: () => FileDropSink | null) => () => void;
+}
+
+export const FileDropContext = createContext(null);
+
+export function useFileDropContext(): FileDropContextValue | null {
+ return useContext(FileDropContext);
+}
diff --git a/packages/app/src/components/file-drop/file-drop-backdrop.tsx b/packages/app/src/components/file-drop/file-drop-backdrop.tsx
new file mode 100644
index 000000000..b661dbe49
--- /dev/null
+++ b/packages/app/src/components/file-drop/file-drop-backdrop.tsx
@@ -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 (
+
+
+
+
+ {t("composer.attachments.dropFilesHere")}
+
+
+ );
+}
+
+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,
+ },
+}));
diff --git a/packages/app/src/components/file-drop/file-drop-zone.tsx b/packages/app/src/components/file-drop/file-drop-zone.tsx
new file mode 100644
index 000000000..6d12ec605
--- /dev/null
+++ b/packages/app/src/components/file-drop/file-drop-zone.tsx
@@ -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;
+}
+
+/**
+ * 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(
+ () => ({ 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 (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ } style={targetStyle}>
+ {children}
+
+
+
+ );
+}
+
+// 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",
+ },
+});
diff --git a/packages/app/src/components/file-drop/types.ts b/packages/app/src/components/file-drop/types.ts
new file mode 100644
index 000000000..11b44773d
--- /dev/null
+++ b/packages/app/src/components/file-drop/types.ts
@@ -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;
+}
diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/components/file-drop/use-drop-listeners.ts
similarity index 53%
rename from packages/app/src/hooks/use-file-drop-zone.ts
rename to packages/app/src/components/file-drop/use-drop-listeners.ts
index 710faf1f5..b8411b50c 100644
--- a/packages/app/src/hooks/use-file-drop-zone.ts
+++ b/packages/app/src/components/file-drop/use-drop-listeners.ts
@@ -1,4 +1,6 @@
-import { useState, useRef, useEffect } from "react";
+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";
@@ -8,45 +10,13 @@ import {
isRasterImagePath,
} from "@/attachments/file-types";
import { isWeb } from "@/constants/platform";
-
-export interface DroppedFileItem {
- kind: "web-file";
- file: File;
-}
-export interface DroppedPathItem {
- kind: "desktop-path";
- path: string;
-}
-export type DroppedItem = DroppedFileItem | DroppedPathItem;
-
-interface UseFileDropZoneOptions {
- onFilesDropped: (files: ImageAttachment[]) => void;
- onGenericFilesDropped?: (items: DroppedItem[]) => void;
- disabled?: boolean;
-}
-
-interface UseFileDropZoneReturn {
- isDragging: boolean;
- containerRef: React.RefObject;
-}
-
-const IS_WEB = isWeb;
+import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
type DesktopDragDropPayload =
- | {
- type: "enter";
- paths: string[];
- }
- | {
- type: "over";
- }
- | {
- type: "drop";
- paths: string[];
- }
- | {
- type: "leave";
- };
+ | { type: "enter"; paths: string[] }
+ | { type: "over" }
+ | { type: "drop"; paths: string[] }
+ | { type: "leave" };
interface DesktopDragDropEvent {
payload: DesktopDragDropPayload;
@@ -65,37 +35,43 @@ async function fileToImageAttachment(file: File): Promise {
});
}
-export function useFileDropZone({
- onFilesDropped,
- onGenericFilesDropped,
- disabled = false,
-}: UseFileDropZoneOptions): UseFileDropZoneReturn {
- const [isDragging, setIsDragging] = useState(false);
+interface UseDropListenersOptions {
+ isDragging: SharedValue;
+ /** Active sink can't accept right now: reject drops without showing acceptance. */
+ suppressed: SharedValue;
+ /** Whether a consumer is mounted: with none, don't advertise or accept drops. */
+ hasSink: SharedValue;
+ /** 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 {
const containerRef = useRef(null);
- const dragCounterRef = useRef(0);
- const onFilesDroppedRef = useRef(onFilesDropped);
- const onGenericFilesDroppedRef = useRef(onGenericFilesDropped);
+ const dragCounter = useRef(0);
+ const disabledRef = useRef(disabled);
+ disabledRef.current = disabled;
- // Keep callback refs up to date
- useEffect(() => {
- onFilesDroppedRef.current = onFilesDropped;
- }, [onFilesDropped]);
-
- useEffect(() => {
- onGenericFilesDroppedRef.current = onGenericFilesDropped;
- }, [onGenericFilesDropped]);
-
- // Reset drag state when disabled changes
+ // Clear an in-progress drag when the zone becomes disabled.
useEffect(() => {
if (disabled) {
- setIsDragging(false);
- dragCounterRef.current = 0;
+ isDragging.value = false;
+ dragCounter.current = 0;
}
- }, [disabled]);
+ }, [disabled, isDragging]);
- // Set up event listeners on web
useEffect(() => {
- if (!IS_WEB) return;
+ if (!isWeb) return;
let disposed = false;
let cleanup: (() => void) | undefined;
@@ -108,13 +84,16 @@ export function useFileDropZone({
didCleanup = true;
try {
void Promise.resolve(cleanupFn()).catch((error) => {
- console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
+ console.warn("[useDropListeners] Failed to remove desktop drag-drop listener:", error);
});
} catch (error) {
- console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", 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 {
const desktopHost = getDesktopHost();
if (desktopHost === null) {
@@ -130,29 +109,32 @@ export function useFileDropZone({
const unlisten = await desktopWindow.onDragDropEvent((event: DesktopDragDropEvent) => {
const payload = event.payload;
if (payload.type === "leave") {
- setIsDragging(false);
+ isDragging.value = false;
return;
}
if (payload.type === "enter" || payload.type === "over") {
- if (!disabled) {
- setIsDragging(true);
+ if (!disabledRef.current) {
+ isDragging.value = true;
}
return;
}
// Drop always ends the current drag operation.
- setIsDragging(false);
+ isDragging.value = false;
- if (disabled) return;
+ if (disabledRef.current || suppressed.value) return;
+
+ const sink = getSink();
+ if (!sink) return;
const items: DroppedPathItem[] = payload.paths.map((path) => ({
kind: "desktop-path",
path,
}));
- if (onGenericFilesDroppedRef.current && items.length > 0) {
- onGenericFilesDroppedRef.current(items);
+ if (sink.onGenericFiles && items.length > 0) {
+ sink.onGenericFiles(items);
}
const imagePaths = payload.paths.filter(isRasterImagePath);
@@ -165,11 +147,15 @@ export function useFileDropZone({
if (attachments.length === 0) {
return;
}
- onFilesDroppedRef.current(attachments);
+ // 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("[useFileDropZone] Failed to persist dropped files:", error);
+ console.error("[useDropListeners] Failed to persist dropped files:", error);
});
});
@@ -181,7 +167,7 @@ export function useFileDropZone({
cleanup = unlisten;
return true;
} catch (error) {
- console.warn("[useFileDropZone] Failed to listen for desktop drag-drop:", error);
+ console.warn("[useDropListeners] Failed to listen for desktop drag-drop:", error);
return false;
}
}
@@ -196,11 +182,11 @@ export function useFileDropZone({
e.preventDefault();
e.stopPropagation();
- if (disabled) return;
+ if (disabledRef.current) return;
- dragCounterRef.current++;
+ dragCounter.current++;
if (e.dataTransfer?.types.includes("Files")) {
- setIsDragging(true);
+ isDragging.value = true;
}
}
@@ -208,22 +194,22 @@ export function useFileDropZone({
e.preventDefault();
e.stopPropagation();
- if (disabled) return;
-
- if (e.dataTransfer) {
- e.dataTransfer.dropEffect = "copy";
- }
+ 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 (disabled) return;
+ if (disabledRef.current) return;
- dragCounterRef.current--;
- if (dragCounterRef.current === 0) {
- setIsDragging(false);
+ dragCounter.current--;
+ if (dragCounter.current === 0) {
+ isDragging.value = false;
}
}
@@ -231,10 +217,13 @@ export function useFileDropZone({
e.preventDefault();
e.stopPropagation();
- setIsDragging(false);
- dragCounterRef.current = 0;
+ isDragging.value = false;
+ dragCounter.current = 0;
- if (disabled) return;
+ 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) => ({
@@ -242,8 +231,8 @@ export function useFileDropZone({
file,
}));
- if (onGenericFilesDroppedRef.current && genericItems.length > 0) {
- onGenericFilesDroppedRef.current(genericItems);
+ if (sink.onGenericFiles && genericItems.length > 0) {
+ sink.onGenericFiles(genericItems);
}
const imageFiles = files.filter(isRasterImageFile);
@@ -252,9 +241,12 @@ export function useFileDropZone({
try {
const attachments = await Promise.all(imageFiles.map(fileToImageAttachment));
- onFilesDroppedRef.current(attachments);
+ // 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("[useFileDropZone] Failed to process dropped files:", error);
+ console.error("[useDropListeners] Failed to process dropped files:", error);
}
}
@@ -283,10 +275,7 @@ export function useFileDropZone({
disposed = true;
runCleanup();
};
- }, [disabled]);
+ }, [isDragging, suppressed, hasSink, getSink]);
- return {
- isDragging,
- containerRef,
- };
+ return containerRef;
}
diff --git a/packages/app/src/components/file-drop/use-file-drop.ts b/packages/app/src/components/file-drop/use-file-drop.ts
new file mode 100644
index 000000000..2f8009475
--- /dev/null
+++ b/packages/app/src/components/file-drop/use-file-drop.ts
@@ -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]);
+}
diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx
index 0a607612d..90eef6906 100644
--- a/packages/app/src/components/workspace-setup-dialog.tsx
+++ b/packages/app/src/components/workspace-setup-dialog.tsx
@@ -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}
>
-
+
-
+
{errorMessage ? {errorMessage} : null}
diff --git a/packages/app/src/composer/attachments/drop.ts b/packages/app/src/composer/attachments/drop.ts
index 8114f780b..b5a0f1b6a 100644
--- a/packages/app/src/composer/attachments/drop.ts
+++ b/packages/app/src/composer/attachments/drop.ts
@@ -4,7 +4,7 @@ import {
isRasterImagePath,
} from "@/attachments/file-types";
import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file";
-import type { DroppedItem } from "@/hooks/use-file-drop-zone";
+import type { DroppedItem } from "@/components/file-drop/types";
interface DroppedAttachmentsRuntime {
readDesktopFileBytes(path: string): Promise;
diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx
index e0067030a..14507a386 100644
--- a/packages/app/src/composer/draft/workspace-tab.tsx
+++ b/packages/app/src/composer/draft/workspace-tab.tsx
@@ -8,6 +8,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import invariant from "tiny-invariant";
import { Composer } from "@/composer";
+import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { ComposerImportPill } from "@/composer/draft/import-pill";
import { AgentStreamView } from "@/agent-stream/view";
@@ -644,7 +645,7 @@ export function WorkspaceDraftAgentTab({
);
return (
-
+
{isSubmitting && draftAgent ? (
@@ -702,7 +703,7 @@ export function WorkspaceDraftAgentTab({
isCompactLayout={isCompactComposerLayout}
/>
-
+
);
}
diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx
index 4065610fc..26fa355d9 100644
--- a/packages/app/src/composer/index.tsx
+++ b/packages/app/src/composer/index.tsx
@@ -43,8 +43,8 @@ import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
-import { FileDropZone } from "@/components/file-drop-zone";
-import type { DroppedItem } from "@/hooks/use-file-drop-zone";
+import { useFileDrop } from "@/components/file-drop/use-file-drop";
+import type { DroppedItem } from "@/components/file-drop/types";
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
import type { ImageAttachment, MessagePayload } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
@@ -1881,6 +1881,15 @@ export function Composer({
const messageInputContainerRef = useRef(null);
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
+
+ // Disable drops while submitting/uploading: the submit path clears and restores attachments,
+ // so a drop in that window would be lost or land on a locked draft. `disabled` hides the
+ // backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
+ useFileDrop(
+ { onFiles: addImages, onGenericFiles: handleGenericFilesDropped },
+ { disabled: isSubmitBusy },
+ );
+
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
const sendErrorNode = useMemo(
@@ -1894,97 +1903,90 @@ export function Composer({
return (
-
-
-
- {/* Input area */}
-
-
- {queueList}
- {sendErrorNode}
+
+
+ {/* Input area */}
+
+
+ {queueList}
+ {sendErrorNode}
-
-
+
+
- {/* MessageInput handles everything: text, dictation, attachments, all buttons */}
-
-
-
+ {/* MessageInput handles everything: text, dictation, attachments, all buttons */}
+
+
- {renderComposerFooter(footer, footerInlineContent)}
-
-
+
+ {renderComposerFooter(footer, footerInlineContent)}
+
);
}
@@ -1994,10 +1996,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexDirection: "column",
position: "relative",
},
- dropZone: {
- flex: 0,
- width: "100%",
- },
borderSeparator: {
height: theme.borderWidth[1],
backgroundColor: theme.colors.border,
diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx
index 960c5e6a1..6cb47587a 100644
--- a/packages/app/src/panels/agent-panel.tsx
+++ b/packages/app/src/panels/agent-panel.tsx
@@ -12,6 +12,7 @@ import { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
+import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer";
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
@@ -1177,7 +1178,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
return (
-
+
{contentContainer}
{composerSection}
@@ -1189,7 +1190,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
) : null}
-
+
{isArchivingCurrentAgent ? (
diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx
index 93defa90c..62d710828 100644
--- a/packages/app/src/screens/new-workspace-screen.tsx
+++ b/packages/app/src/screens/new-workspace-screen.tsx
@@ -11,6 +11,7 @@ import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native";
import { Composer } from "@/composer";
+import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { HostStatusDot } from "@/components/host-status-dot";
@@ -1967,7 +1968,7 @@ export function NewWorkspaceScreen({
const screenHeaderLeft = useMemo(() => , []);
return (
-
+
@@ -2003,7 +2004,7 @@ export function NewWorkspaceScreen({
{errorMessage ? {errorMessage} : null}
-
+
);
}