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:
@@ -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;
|
||||
}
|
||||
+87
-98
@@ -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<HTMLElement | null>;
|
||||
}
|
||||
|
||||
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<ImageAttachment> {
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileDropZone({
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
disabled = false,
|
||||
}: UseFileDropZoneOptions): UseFileDropZoneReturn {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
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 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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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<Uint8Array>;
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<FileDropZone style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
{isSubmitting && draftAgent ? (
|
||||
<View style={styles.streamContainer}>
|
||||
@@ -702,7 +703,7 @@ export function WorkspaceDraftAgentTab({
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<View>(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 (
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
<FileDropZone
|
||||
onFilesDropped={addImages}
|
||||
onGenericFilesDropped={handleGenericFilesDropped}
|
||||
disabled={isSubmitBusy}
|
||||
style={styles.dropZone}
|
||||
>
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
</View>
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
</FileDropZone>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
</ComposerKeyboardScopeProvider>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
|
||||
<View style={styles.root}>
|
||||
<View style={styles.container}>
|
||||
<FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}>
|
||||
{contentContainer}
|
||||
|
||||
{composerSection}
|
||||
@@ -1189,7 +1190,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
) : null}
|
||||
|
||||
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
|
||||
@@ -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(() => <SidebarMenuToggle />, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FileDropZone style={styles.container}>
|
||||
<ScreenHeader left={screenHeaderLeft} borderless />
|
||||
<View style={contentStyle}>
|
||||
<TitlebarDragRegion />
|
||||
@@ -2003,7 +2004,7 @@ export function NewWorkspaceScreen({
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user