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:
Mohamed Boudra
2026-06-12 21:56:51 +08:00
committed by GitHub
parent 9c43e0222a
commit 736b2da1a2
18 changed files with 563 additions and 16 deletions
+81 -1
View File
@@ -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}