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";
|
} from "@gorhom/bottom-sheet";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
|
||||||
import type { ImageAttachment } from "@/composer/types";
|
|
||||||
import {
|
import {
|
||||||
IsolatedBottomSheetModal,
|
IsolatedBottomSheetModal,
|
||||||
useIsolatedBottomSheetVisibility,
|
useIsolatedBottomSheetVisibility,
|
||||||
@@ -451,9 +449,6 @@ export interface AdaptiveModalSheetProps {
|
|||||||
testID?: string;
|
testID?: string;
|
||||||
/** Override the max width of the desktop card. */
|
/** Override the max width of the desktop card. */
|
||||||
desktopMaxWidth?: number;
|
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;
|
scrollable?: boolean;
|
||||||
presentation?: "push" | "replace";
|
presentation?: "push" | "replace";
|
||||||
}
|
}
|
||||||
@@ -467,8 +462,6 @@ export function AdaptiveModalSheet({
|
|||||||
snapPoints,
|
snapPoints,
|
||||||
testID,
|
testID,
|
||||||
desktopMaxWidth,
|
desktopMaxWidth,
|
||||||
onFilesDropped,
|
|
||||||
onGenericFilesDropped,
|
|
||||||
scrollable = true,
|
scrollable = true,
|
||||||
presentation,
|
presentation,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
@@ -602,18 +595,7 @@ export function AdaptiveModalSheet({
|
|||||||
style={ABSOLUTE_FILL_STYLE}
|
style={ABSOLUTE_FILL_STYLE}
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
/>
|
/>
|
||||||
<View style={desktopCardStyle}>
|
<View style={desktopCardStyle}>{cardInner}</View>
|
||||||
{onFilesDropped ? (
|
|
||||||
<FileDropZone
|
|
||||||
onFilesDropped={onFilesDropped}
|
|
||||||
onGenericFilesDropped={onGenericFilesDropped}
|
|
||||||
>
|
|
||||||
{cardInner}
|
|
||||||
</FileDropZone>
|
|
||||||
) : (
|
|
||||||
cardInner
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</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 type { ImageAttachment } from "@/composer/types";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
|
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
|
||||||
@@ -8,45 +10,13 @@ import {
|
|||||||
isRasterImagePath,
|
isRasterImagePath,
|
||||||
} from "@/attachments/file-types";
|
} from "@/attachments/file-types";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
|
||||||
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;
|
|
||||||
|
|
||||||
type DesktopDragDropPayload =
|
type DesktopDragDropPayload =
|
||||||
| {
|
| { type: "enter"; paths: string[] }
|
||||||
type: "enter";
|
| { type: "over" }
|
||||||
paths: string[];
|
| { type: "drop"; paths: string[] }
|
||||||
}
|
| { type: "leave" };
|
||||||
| {
|
|
||||||
type: "over";
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "drop";
|
|
||||||
paths: string[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "leave";
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DesktopDragDropEvent {
|
interface DesktopDragDropEvent {
|
||||||
payload: DesktopDragDropPayload;
|
payload: DesktopDragDropPayload;
|
||||||
@@ -65,37 +35,43 @@ async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFileDropZone({
|
interface UseDropListenersOptions {
|
||||||
onFilesDropped,
|
isDragging: SharedValue<boolean>;
|
||||||
onGenericFilesDropped,
|
/** Active sink can't accept right now: reject drops without showing acceptance. */
|
||||||
disabled = false,
|
suppressed: SharedValue<boolean>;
|
||||||
}: UseFileDropZoneOptions): UseFileDropZoneReturn {
|
/** Whether a consumer is mounted: with none, don't advertise or accept drops. */
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
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 containerRef = useRef<HTMLElement | null>(null);
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
const onFilesDroppedRef = useRef(onFilesDropped);
|
const disabledRef = useRef(disabled);
|
||||||
const onGenericFilesDroppedRef = useRef(onGenericFilesDropped);
|
disabledRef.current = disabled;
|
||||||
|
|
||||||
// Keep callback refs up to date
|
// Clear an in-progress drag when the zone becomes disabled.
|
||||||
useEffect(() => {
|
|
||||||
onFilesDroppedRef.current = onFilesDropped;
|
|
||||||
}, [onFilesDropped]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onGenericFilesDroppedRef.current = onGenericFilesDropped;
|
|
||||||
}, [onGenericFilesDropped]);
|
|
||||||
|
|
||||||
// Reset drag state when disabled changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
setIsDragging(false);
|
isDragging.value = false;
|
||||||
dragCounterRef.current = 0;
|
dragCounter.current = 0;
|
||||||
}
|
}
|
||||||
}, [disabled]);
|
}, [disabled, isDragging]);
|
||||||
|
|
||||||
// Set up event listeners on web
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB) return;
|
if (!isWeb) return;
|
||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let cleanup: (() => void) | undefined;
|
let cleanup: (() => void) | undefined;
|
||||||
@@ -108,13 +84,16 @@ export function useFileDropZone({
|
|||||||
didCleanup = true;
|
didCleanup = true;
|
||||||
try {
|
try {
|
||||||
void Promise.resolve(cleanupFn()).catch((error) => {
|
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) {
|
} 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> {
|
async function setupDesktopDragDrop(): Promise<boolean> {
|
||||||
const desktopHost = getDesktopHost();
|
const desktopHost = getDesktopHost();
|
||||||
if (desktopHost === null) {
|
if (desktopHost === null) {
|
||||||
@@ -130,29 +109,32 @@ export function useFileDropZone({
|
|||||||
const unlisten = await desktopWindow.onDragDropEvent((event: DesktopDragDropEvent) => {
|
const unlisten = await desktopWindow.onDragDropEvent((event: DesktopDragDropEvent) => {
|
||||||
const payload = event.payload;
|
const payload = event.payload;
|
||||||
if (payload.type === "leave") {
|
if (payload.type === "leave") {
|
||||||
setIsDragging(false);
|
isDragging.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === "enter" || payload.type === "over") {
|
if (payload.type === "enter" || payload.type === "over") {
|
||||||
if (!disabled) {
|
if (!disabledRef.current) {
|
||||||
setIsDragging(true);
|
isDragging.value = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop always ends the current drag operation.
|
// 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) => ({
|
const items: DroppedPathItem[] = payload.paths.map((path) => ({
|
||||||
kind: "desktop-path",
|
kind: "desktop-path",
|
||||||
path,
|
path,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (onGenericFilesDroppedRef.current && items.length > 0) {
|
if (sink.onGenericFiles && items.length > 0) {
|
||||||
onGenericFilesDroppedRef.current(items);
|
sink.onGenericFiles(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
const imagePaths = payload.paths.filter(isRasterImagePath);
|
const imagePaths = payload.paths.filter(isRasterImagePath);
|
||||||
@@ -165,11 +147,15 @@ export function useFileDropZone({
|
|||||||
if (attachments.length === 0) {
|
if (attachments.length === 0) {
|
||||||
return;
|
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;
|
return;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.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;
|
cleanup = unlisten;
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,11 +182,11 @@ export function useFileDropZone({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (disabled) return;
|
if (disabledRef.current) return;
|
||||||
|
|
||||||
dragCounterRef.current++;
|
dragCounter.current++;
|
||||||
if (e.dataTransfer?.types.includes("Files")) {
|
if (e.dataTransfer?.types.includes("Files")) {
|
||||||
setIsDragging(true);
|
isDragging.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,22 +194,22 @@ export function useFileDropZone({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (disabled) return;
|
if (!e.dataTransfer) return;
|
||||||
|
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
|
||||||
if (e.dataTransfer) {
|
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
|
||||||
e.dataTransfer.dropEffect = "copy";
|
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
|
||||||
}
|
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragLeave(e: DragEvent) {
|
function handleDragLeave(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (disabled) return;
|
if (disabledRef.current) return;
|
||||||
|
|
||||||
dragCounterRef.current--;
|
dragCounter.current--;
|
||||||
if (dragCounterRef.current === 0) {
|
if (dragCounter.current === 0) {
|
||||||
setIsDragging(false);
|
isDragging.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,10 +217,13 @@ export function useFileDropZone({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
setIsDragging(false);
|
isDragging.value = false;
|
||||||
dragCounterRef.current = 0;
|
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 files = Array.from(e.dataTransfer?.files ?? []);
|
||||||
const genericItems: DroppedItem[] = files.map((file) => ({
|
const genericItems: DroppedItem[] = files.map((file) => ({
|
||||||
@@ -242,8 +231,8 @@ export function useFileDropZone({
|
|||||||
file,
|
file,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (onGenericFilesDroppedRef.current && genericItems.length > 0) {
|
if (sink.onGenericFiles && genericItems.length > 0) {
|
||||||
onGenericFilesDroppedRef.current(genericItems);
|
sink.onGenericFiles(genericItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageFiles = files.filter(isRasterImageFile);
|
const imageFiles = files.filter(isRasterImageFile);
|
||||||
@@ -252,9 +241,12 @@ export function useFileDropZone({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const attachments = await Promise.all(imageFiles.map(fileToImageAttachment));
|
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) {
|
} 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;
|
disposed = true;
|
||||||
runCleanup();
|
runCleanup();
|
||||||
};
|
};
|
||||||
}, [disabled]);
|
}, [isDragging, suppressed, hasSink, getSink]);
|
||||||
|
|
||||||
return {
|
return containerRef;
|
||||||
isDragging,
|
|
||||||
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 { useTranslation } from "react-i18next";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||||
import { Composer } from "@/composer";
|
import { Composer } from "@/composer";
|
||||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
@@ -420,7 +421,7 @@ export function WorkspaceSetupDialog() {
|
|||||||
testID="workspace-setup-dialog"
|
testID="workspace-setup-dialog"
|
||||||
desktopMaxWidth={640}
|
desktopMaxWidth={640}
|
||||||
>
|
>
|
||||||
<View style={styles.section}>
|
<FileDropZone style={styles.section}>
|
||||||
<Composer
|
<Composer
|
||||||
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
@@ -440,7 +441,7 @@ export function WorkspaceSetupDialog() {
|
|||||||
inputWrapperStyle={styles.composerInputWrapper}
|
inputWrapperStyle={styles.composerInputWrapper}
|
||||||
footer={composerFooter}
|
footer={composerFooter}
|
||||||
/>
|
/>
|
||||||
</View>
|
</FileDropZone>
|
||||||
|
|
||||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
isRasterImagePath,
|
isRasterImagePath,
|
||||||
} from "@/attachments/file-types";
|
} from "@/attachments/file-types";
|
||||||
import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file";
|
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 {
|
interface DroppedAttachmentsRuntime {
|
||||||
readDesktopFileBytes(path: string): Promise<Uint8Array>;
|
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 { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { Composer } from "@/composer";
|
import { Composer } from "@/composer";
|
||||||
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||||
import { ComposerImportPill } from "@/composer/draft/import-pill";
|
import { ComposerImportPill } from "@/composer/draft/import-pill";
|
||||||
import { AgentStreamView } from "@/agent-stream/view";
|
import { AgentStreamView } from "@/agent-stream/view";
|
||||||
@@ -644,7 +645,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<FileDropZone style={styles.container}>
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
{isSubmitting && draftAgent ? (
|
{isSubmitting && draftAgent ? (
|
||||||
<View style={styles.streamContainer}>
|
<View style={styles.streamContainer}>
|
||||||
@@ -702,7 +703,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
isCompactLayout={isCompactComposerLayout}
|
isCompactLayout={isCompactComposerLayout}
|
||||||
/>
|
/>
|
||||||
</ReanimatedAnimated.View>
|
</ReanimatedAnimated.View>
|
||||||
</View>
|
</FileDropZone>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ import { ContextWindowMeter } from "@/components/context-window-meter";
|
|||||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { useFileDrop } from "@/components/file-drop/use-file-drop";
|
||||||
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
|
import type { DroppedItem } from "@/components/file-drop/types";
|
||||||
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
|
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
|
||||||
import type { ImageAttachment, MessagePayload } from "./types";
|
import type { ImageAttachment, MessagePayload } from "./types";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
@@ -1881,6 +1881,15 @@ export function Composer({
|
|||||||
const messageInputContainerRef = useRef<View>(null);
|
const messageInputContainerRef = useRef<View>(null);
|
||||||
|
|
||||||
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
|
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 messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
|
||||||
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
|
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
|
||||||
const sendErrorNode = useMemo(
|
const sendErrorNode = useMemo(
|
||||||
@@ -1894,97 +1903,90 @@ export function Composer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||||
<FileDropZone
|
<Animated.View style={composerContainerStyle}>
|
||||||
onFilesDropped={addImages}
|
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||||
onGenericFilesDropped={handleGenericFilesDropped}
|
{/* Input area */}
|
||||||
disabled={isSubmitBusy}
|
<View style={inputAreaContainerStyle}>
|
||||||
style={styles.dropZone}
|
<View style={styles.inputAreaContent}>
|
||||||
>
|
{queueList}
|
||||||
<Animated.View style={composerContainerStyle}>
|
{sendErrorNode}
|
||||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
|
||||||
{/* Input area */}
|
|
||||||
<View style={inputAreaContainerStyle}>
|
|
||||||
<View style={styles.inputAreaContent}>
|
|
||||||
{queueList}
|
|
||||||
{sendErrorNode}
|
|
||||||
|
|
||||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||||
<AutocompletePopover
|
<AutocompletePopover
|
||||||
visible={autocompleteVisible}
|
visible={autocompleteVisible}
|
||||||
anchorRef={messageInputContainerRef}
|
anchorRef={messageInputContainerRef}
|
||||||
options={autocomplete.options}
|
options={autocomplete.options}
|
||||||
selectedIndex={autocomplete.selectedIndex}
|
selectedIndex={autocomplete.selectedIndex}
|
||||||
onSelect={autocomplete.onSelectOption}
|
onSelect={autocomplete.onSelectOption}
|
||||||
isLoading={autocomplete.isLoading}
|
isLoading={autocomplete.isLoading}
|
||||||
errorMessage={autocomplete.errorMessage}
|
errorMessage={autocomplete.errorMessage}
|
||||||
loadingText={autocomplete.loadingText}
|
loadingText={autocomplete.loadingText}
|
||||||
emptyText={autocomplete.emptyText}
|
emptyText={autocomplete.emptyText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||||
<StableMessageInput
|
<StableMessageInput
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
value={userInput}
|
value={userInput}
|
||||||
onChangeText={setUserInput}
|
onChangeText={setUserInput}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
hasExternalContent={hasExternalContent}
|
hasExternalContent={hasExternalContent}
|
||||||
allowEmptySubmit={allowEmptySubmit}
|
allowEmptySubmit={allowEmptySubmit}
|
||||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||||
submitButtonTestID={submitButtonTestID}
|
submitButtonTestID={submitButtonTestID}
|
||||||
submitIcon={submitIcon}
|
submitIcon={submitIcon}
|
||||||
isSubmitDisabled={isSubmitBusy}
|
isSubmitDisabled={isSubmitBusy}
|
||||||
isSubmitLoading={isSubmitBusy}
|
isSubmitLoading={isSubmitBusy}
|
||||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||||
attachments={selectedAttachments}
|
attachments={selectedAttachments}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
attachmentMenuItems={attachmentMenuItems}
|
attachmentMenuItems={attachmentMenuItems}
|
||||||
onAttachButtonRef={handleAttachButtonRef}
|
onAttachButtonRef={handleAttachButtonRef}
|
||||||
onAddImages={addImages}
|
onAddImages={addImages}
|
||||||
client={client}
|
client={client}
|
||||||
isReadyForDictation={isDictationReady}
|
isReadyForDictation={isDictationReady}
|
||||||
placeholder={messagePlaceholder}
|
placeholder={messagePlaceholder}
|
||||||
autoFocus={messageInputAutoFocus}
|
autoFocus={messageInputAutoFocus}
|
||||||
autoFocusKey={`${serverId}:${agentId}`}
|
autoFocusKey={`${serverId}:${agentId}`}
|
||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
beforeVoiceContent={beforeVoiceContent}
|
beforeVoiceContent={beforeVoiceContent}
|
||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
voiceServerId={serverId}
|
voiceServerId={serverId}
|
||||||
voiceAgentId={agentId}
|
voiceAgentId={agentId}
|
||||||
isAgentRunning={isAgentRunning}
|
isAgentRunning={isAgentRunning}
|
||||||
defaultSendBehavior={appSettings.sendBehavior}
|
defaultSendBehavior={appSettings.sendBehavior}
|
||||||
onQueue={handleQueue}
|
onQueue={handleQueue}
|
||||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||||
onKeyPress={handleCommandKeyPress}
|
onKeyPress={handleCommandKeyPress}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
onFocusChange={handleFocusChange}
|
onFocusChange={handleFocusChange}
|
||||||
onHeightChange={onComposerHeightChange}
|
onHeightChange={onComposerHeightChange}
|
||||||
inputWrapperStyle={inputWrapperStyle}
|
inputWrapperStyle={inputWrapperStyle}
|
||||||
attachmentSlot={attachmentTray}
|
attachmentSlot={attachmentTray}
|
||||||
/>
|
/>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={githubSearchOptions}
|
options={githubSearchOptions}
|
||||||
value=""
|
value=""
|
||||||
onSelect={noop}
|
onSelect={noop}
|
||||||
keepOpenOnSelect
|
keepOpenOnSelect
|
||||||
searchable
|
searchable
|
||||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||||
title={t("composer.github.title")}
|
title={t("composer.github.title")}
|
||||||
open={isGithubPickerOpen}
|
open={isGithubPickerOpen}
|
||||||
onOpenChange={handleGithubPickerOpenChange}
|
onOpenChange={handleGithubPickerOpenChange}
|
||||||
onSearchQueryChange={setGithubSearchQuery}
|
onSearchQueryChange={setGithubSearchQuery}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
anchorRef={attachButtonRef}
|
anchorRef={attachButtonRef}
|
||||||
emptyText={githubEmptyText}
|
emptyText={githubEmptyText}
|
||||||
renderOption={renderGithubPickerOption}
|
renderOption={renderGithubPickerOption}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{renderComposerFooter(footer, footerInlineContent)}
|
</View>
|
||||||
</Animated.View>
|
{renderComposerFooter(footer, footerInlineContent)}
|
||||||
</FileDropZone>
|
</Animated.View>
|
||||||
</ComposerKeyboardScopeProvider>
|
</ComposerKeyboardScopeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1994,10 +1996,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
dropZone: {
|
|
||||||
flex: 0,
|
|
||||||
width: "100%",
|
|
||||||
},
|
|
||||||
borderSeparator: {
|
borderSeparator: {
|
||||||
height: theme.borderWidth[1],
|
height: theme.borderWidth[1],
|
||||||
backgroundColor: theme.colors.border,
|
backgroundColor: theme.colors.border,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { shallow, useShallow } from "zustand/shallow";
|
|||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
|
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
|
||||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||||
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||||
import { Composer } from "@/composer";
|
import { Composer } from "@/composer";
|
||||||
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
|
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||||
@@ -1177,7 +1178,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
return (
|
return (
|
||||||
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
|
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
<View style={styles.container}>
|
<FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}>
|
||||||
{contentContainer}
|
{contentContainer}
|
||||||
|
|
||||||
{composerSection}
|
{composerSection}
|
||||||
@@ -1189,7 +1190,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
|
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
|
||||||
</View>
|
</FileDropZone>
|
||||||
|
|
||||||
{isArchivingCurrentAgent ? (
|
{isArchivingCurrentAgent ? (
|
||||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createNameId } from "mnemonic-id";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native";
|
import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native";
|
||||||
import { Composer } from "@/composer";
|
import { Composer } from "@/composer";
|
||||||
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||||
import { HostStatusDot } from "@/components/host-status-dot";
|
import { HostStatusDot } from "@/components/host-status-dot";
|
||||||
@@ -1967,7 +1968,7 @@ export function NewWorkspaceScreen({
|
|||||||
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
|
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<FileDropZone style={styles.container}>
|
||||||
<ScreenHeader left={screenHeaderLeft} borderless />
|
<ScreenHeader left={screenHeaderLeft} borderless />
|
||||||
<View style={contentStyle}>
|
<View style={contentStyle}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
@@ -2003,7 +2004,7 @@ export function NewWorkspaceScreen({
|
|||||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
</ReanimatedAnimated.View>
|
</ReanimatedAnimated.View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</FileDropZone>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user