feat(app): add drag-and-drop file upload and file picker to composer (#1474)
- Desktop web: drag-and-drop non-image files onto the chat pane to upload - Desktop web: file picker option in the composer attachment sheet - Uploaded files are sent to the LLM as filename references in a tmp directory - New useFilePicker hook for cross-platform file picking - Extended useFileDropZone to support generic file drops - Added UploadedFileAttachment type to protocol and composer attachment system - Fixed adaptive modal sheet closing on backdrop press in selection mode
This commit is contained in:
@@ -15,6 +15,10 @@ import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { Composer } from "@/composer";
|
||||
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { uploadFileAttachments } from "@/composer/actions";
|
||||
import { readDesktopFileBytes, getMimeTypeFromPath } from "@/hooks/use-file-picker";
|
||||
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
@@ -682,6 +686,7 @@ function ChatAgentContent({
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
const streamViewRef = useRef<AgentStreamViewHandle>(null);
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
const addFilesRef = useRef<((files: UserComposerAttachment[]) => void) | null>(null);
|
||||
const clearOnAgentBlurRef = useRef<() => void>(() => {});
|
||||
const wasPaneFocusedRef = useRef(isPaneFocused);
|
||||
const reconnectToastArmedRef = useRef(false);
|
||||
@@ -698,6 +703,46 @@ function ChatAgentContent({
|
||||
addImagesRef.current = addImages;
|
||||
}, []);
|
||||
|
||||
const handleAddFilesCallback = useCallback(
|
||||
(addFiles: (files: UserComposerAttachment[]) => void) => {
|
||||
addFilesRef.current = addFiles;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleGenericFilesDropped = useCallback(
|
||||
async (items: DroppedItem[]) => {
|
||||
if (!client || !isConnected) return;
|
||||
const nonImageItems = items.filter((item) => {
|
||||
if (item.kind === "web-file") return !item.file.type.startsWith("image/");
|
||||
return !isImagePath(item.path);
|
||||
});
|
||||
if (nonImageItems.length === 0) return;
|
||||
try {
|
||||
const files = await Promise.all(
|
||||
nonImageItems.map(async (item) => {
|
||||
if (item.kind === "web-file") {
|
||||
return {
|
||||
fileName: item.file.name,
|
||||
mimeType: item.file.type || getMimeTypeFromPath(item.file.name),
|
||||
bytes: new Uint8Array(await item.file.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
const fileName = item.path.split("/").pop() ?? item.path.split("\\").pop() ?? item.path;
|
||||
const bytes = await readDesktopFileBytes(item.path);
|
||||
return { fileName, mimeType: getMimeTypeFromPath(item.path), bytes };
|
||||
}),
|
||||
);
|
||||
const uploaded = await uploadFileAttachments({ client, files });
|
||||
addFilesRef.current?.(uploaded);
|
||||
} catch (error) {
|
||||
console.error("[AgentPanel] Failed to upload dropped files:", error);
|
||||
panelToast.api.error(error instanceof Error ? error.message : "Failed to upload file");
|
||||
}
|
||||
},
|
||||
[client, isConnected, panelToast.api],
|
||||
);
|
||||
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
|
||||
);
|
||||
@@ -1048,7 +1093,9 @@ function ChatAgentContent({
|
||||
streamViewRef={streamViewRef}
|
||||
animatedContentStyle={animatedContentStyle}
|
||||
handleFilesDropped={handleFilesDropped}
|
||||
handleGenericFilesDropped={handleGenericFilesDropped}
|
||||
handleAddImagesCallback={handleAddImagesCallback}
|
||||
handleAddFilesCallback={handleAddFilesCallback}
|
||||
handleComposerHeightChange={handleComposerHeightChange}
|
||||
handleMessageSent={handleMessageSent}
|
||||
showHistorySyncOverlay={showHistorySyncOverlay}
|
||||
@@ -1059,6 +1106,24 @@ function ChatAgentContent({
|
||||
);
|
||||
}
|
||||
|
||||
function isImagePath(path: string): boolean {
|
||||
const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
|
||||
const imageExts = new Set([
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
".avif",
|
||||
".tif",
|
||||
".tiff",
|
||||
]);
|
||||
return imageExts.has(ext);
|
||||
}
|
||||
function ChatAgentReadyContent({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -1072,7 +1137,9 @@ function ChatAgentReadyContent({
|
||||
streamViewRef,
|
||||
animatedContentStyle,
|
||||
handleFilesDropped,
|
||||
handleGenericFilesDropped,
|
||||
handleAddImagesCallback,
|
||||
handleAddFilesCallback,
|
||||
handleComposerHeightChange,
|
||||
handleMessageSent,
|
||||
showHistorySyncOverlay,
|
||||
@@ -1092,7 +1159,9 @@ function ChatAgentReadyContent({
|
||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||
animatedContentStyle: object[];
|
||||
handleFilesDropped: (files: ImageAttachment[]) => void;
|
||||
handleGenericFilesDropped: (items: DroppedItem[]) => void;
|
||||
handleAddImagesCallback: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
handleAddFilesCallback: (addFiles: (files: UserComposerAttachment[]) => void) => void;
|
||||
handleComposerHeightChange: (height: number) => void;
|
||||
handleMessageSent: () => void;
|
||||
showHistorySyncOverlay: boolean;
|
||||
@@ -1135,6 +1204,7 @@ function ChatAgentReadyContent({
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
onAddFiles={handleAddFilesCallback}
|
||||
onComposerHeightChange={handleComposerHeightChange}
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
@@ -1148,7 +1218,11 @@ function ChatAgentReadyContent({
|
||||
return (
|
||||
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
|
||||
<View style={styles.root}>
|
||||
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
|
||||
<FileDropZone
|
||||
onFilesDropped={handleFilesDropped}
|
||||
onGenericFilesDropped={handleGenericFilesDropped}
|
||||
disabled={isArchivingCurrentAgent}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{contentContainer}
|
||||
|
||||
@@ -1258,6 +1332,7 @@ function AgentComposerSection({
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onAddImages,
|
||||
onAddFiles,
|
||||
onComposerHeightChange,
|
||||
onMessageSent,
|
||||
}: {
|
||||
@@ -1272,6 +1347,7 @@ function AgentComposerSection({
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
onAddFiles: (addFiles: (files: UserComposerAttachment[]) => void) => void;
|
||||
onComposerHeightChange: (height: number) => void;
|
||||
onMessageSent: () => void;
|
||||
}) {
|
||||
@@ -1296,6 +1372,7 @@ function AgentComposerSection({
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
onAddImages={onAddImages}
|
||||
onAddFiles={onAddFiles}
|
||||
onComposerHeightChange={onComposerHeightChange}
|
||||
onMessageSent={onMessageSent}
|
||||
/>
|
||||
@@ -1312,6 +1389,7 @@ function ActiveAgentComposer({
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onAddImages,
|
||||
onAddFiles,
|
||||
onComposerHeightChange,
|
||||
onMessageSent,
|
||||
}: {
|
||||
@@ -1324,6 +1402,7 @@ function ActiveAgentComposer({
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
onAddFiles: (addFiles: (files: UserComposerAttachment[]) => void) => void;
|
||||
onComposerHeightChange: (height: number) => void;
|
||||
onMessageSent: () => void;
|
||||
}) {
|
||||
@@ -1465,6 +1544,7 @@ function ActiveAgentComposer({
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
onAddImages={onAddImages}
|
||||
onAddFiles={onAddFiles}
|
||||
onComposerHeightChange={onComposerHeightChange}
|
||||
onMessageSent={onMessageSent}
|
||||
onClientSlashCommand={handleClientSlashCommand}
|
||||
|
||||
Reference in New Issue
Block a user