From 57800a0f178797eaf8e2480ad8e501d20e8ce59e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 29 Jun 2026 16:55:45 +0200 Subject: [PATCH] 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 --- .../src/components/adaptive-modal-sheet.tsx | 20 +- .../app/src/components/file-drop-zone.tsx | 106 ---------- .../app/src/components/file-drop/context.ts | 20 ++ .../file-drop/file-drop-backdrop.tsx | 71 +++++++ .../components/file-drop/file-drop-zone.tsx | 84 ++++++++ .../app/src/components/file-drop/types.ts | 21 ++ .../file-drop/use-drop-listeners.ts} | 185 ++++++++---------- .../src/components/file-drop/use-file-drop.ts | 35 ++++ .../src/components/workspace-setup-dialog.tsx | 5 +- packages/app/src/composer/attachments/drop.ts | 2 +- .../app/src/composer/draft/workspace-tab.tsx | 5 +- packages/app/src/composer/index.tsx | 184 +++++++++-------- packages/app/src/panels/agent-panel.tsx | 5 +- .../app/src/screens/new-workspace-screen.tsx | 5 +- 14 files changed, 423 insertions(+), 325 deletions(-) delete mode 100644 packages/app/src/components/file-drop-zone.tsx create mode 100644 packages/app/src/components/file-drop/context.ts create mode 100644 packages/app/src/components/file-drop/file-drop-backdrop.tsx create mode 100644 packages/app/src/components/file-drop/file-drop-zone.tsx create mode 100644 packages/app/src/components/file-drop/types.ts rename packages/app/src/{hooks/use-file-drop-zone.ts => components/file-drop/use-drop-listeners.ts} (53%) create mode 100644 packages/app/src/components/file-drop/use-file-drop.ts 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} - + ); }