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
@@ -179,6 +179,7 @@ function createFakeSendClient(
throw options.rejection;
}
},
uploadFile: async () => ({ requestId: "test", file: null, error: null }),
};
}
+41 -3
View File
@@ -49,6 +49,18 @@ export interface ComposerSendClient {
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
},
) => Promise<void>;
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
requestId: string;
file: {
type: "uploaded_file";
id: string;
fileName: string;
mimeType: string;
size: number;
path: string;
} | null;
error: string | null;
}>;
}
export interface ComposerCancelClient {
@@ -95,6 +107,23 @@ export async function pickAndPersistImages(input: {
);
}
export async function uploadFileAttachments(input: {
client: ComposerSendClient;
files: Array<{ fileName: string; mimeType: string; bytes: Uint8Array }>;
}): Promise<Extract<ComposerAttachment, { kind: "file" }>[]> {
const result: Extract<ComposerAttachment, { kind: "file" }>[] = [];
for (const file of input.files) {
const response = await input.client.uploadFile(file);
if (response.error || !response.file) {
throw new Error(response.error ?? "Upload failed.");
}
result.push({ kind: "file", attachment: response.file });
}
return result;
}
export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(input: {
attachments: T[];
index: number;
@@ -297,6 +326,9 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
input.setLightboxMetadata(input.attachment.metadata);
return;
}
if (input.attachment.kind === "file") {
return;
}
if (isWorkspaceAttachment(input.attachment)) {
input.openWorkspaceAttachment({ attachment: input.attachment });
return;
@@ -308,12 +340,18 @@ export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttac
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
}
function isGithubAttachment(
attachment: UserComposerAttachment,
): attachment is Extract<UserComposerAttachment, { kind: "github_issue" } | { kind: "github_pr" }> {
return attachment.kind === "github_issue" || attachment.kind === "github_pr";
}
export function toggleGithubAttachment(
current: UserComposerAttachment[],
item: GitHubSearchItem,
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
attachment.kind !== "image" &&
isGithubAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
@@ -335,7 +373,7 @@ export function toggleGithubAttachmentFromPicker({
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
const existingAttachment = current.find(
(attachment) =>
attachment.kind !== "image" &&
isGithubAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
@@ -358,7 +396,7 @@ export function isAttachmentSelectedForGithubItem(
): boolean {
return userAttachmentsOnly(current).some(
(attachment) =>
attachment.kind !== "image" &&
isGithubAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
@@ -12,7 +12,7 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
attachments: AgentAttachment[];
} {
const images: ImageAttachment[] = [];
const reviewAttachments: AgentAttachment[] = [];
const agentAttachments: AgentAttachment[] = [];
for (const attachment of attachments) {
if (attachment.kind === "image") {
@@ -20,22 +20,27 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
continue;
}
if (attachment.kind === "file") {
agentAttachments.push(attachment.attachment);
continue;
}
if (isWorkspaceAttachment(attachment)) {
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
if (workspaceAttachment) {
reviewAttachments.push(workspaceAttachment);
agentAttachments.push(workspaceAttachment);
}
continue;
}
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
if (reviewAttachment) {
reviewAttachments.push(reviewAttachment);
agentAttachments.push(reviewAttachment);
}
}
return {
images,
attachments: reviewAttachments,
attachments: agentAttachments,
};
}
+138 -2
View File
@@ -27,6 +27,7 @@ import {
Pencil,
AudioLines,
CircleDot,
FileText,
GitPullRequest,
Github,
Paperclip,
@@ -41,6 +42,7 @@ import {
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 { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
import type { ImageAttachment, MessagePayload } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
@@ -59,6 +61,7 @@ import {
removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow,
toggleGithubAttachmentFromPicker,
uploadFileAttachments,
type AgentStreamWriter,
type QueueWriter,
type QueuedComposerMessage,
@@ -114,6 +117,7 @@ type AttachmentListUpdater =
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
function noop() {}
const noopCallback = () => {};
function resolveComposerButtonIconSize(): number {
return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
@@ -259,6 +263,7 @@ interface RenderAttachmentTrayArgs {
labels: {
openImage: string;
removeImage: string;
removeFile: string;
openGithub: (kind: string, number: number) => string;
removeGithub: (kind: string, number: number) => string;
};
@@ -359,6 +364,18 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
/>
);
}
if (attachment.kind === "file") {
return (
<FileAttachmentPill
key={attachment.attachment.id}
attachment={attachment}
index={index}
disabled={disabled}
onRemove={onRemove}
removeLabel={labels.removeFile}
/>
);
}
if (composerWorkspaceAttachment.is(attachment)) {
return composerWorkspaceAttachment.renderPill({
attachment,
@@ -642,6 +659,45 @@ function GithubAttachmentPill({
);
}
interface FileAttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "file" }>;
index: number;
disabled: boolean;
onRemove: (index: number) => void;
removeLabel: string;
}
function FileAttachmentPill({
attachment,
index,
disabled,
onRemove,
removeLabel,
}: FileAttachmentPillProps) {
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
return (
<AttachmentPill
testID="composer-file-attachment-pill"
onOpen={noopCallback}
onRemove={handleRemove}
openAccessibilityLabel={attachment.attachment.fileName}
removeAccessibilityLabel={removeLabel}
disabled={disabled}
>
<View style={styles.filePillBody}>
<View style={styles.filePillIcon}>
<ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
</View>
<Text style={styles.filePillText} numberOfLines={1}>
{attachment.attachment.fileName}
</Text>
</View>
</AttachmentPill>
);
}
interface GithubPickerOptionProps {
label: string;
testID: string;
@@ -713,6 +769,8 @@ interface ComposerProps {
autoFocus?: boolean;
/** Callback to expose the addImages function to parent components */
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
/** Callback to expose the addFiles function to parent components */
onAddFiles?: (addFiles: (files: UserComposerAttachment[]) => void) => void;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
@@ -734,6 +792,8 @@ interface ComposerProps {
isCompactLayout?: boolean;
}
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const StableMessageInput = memo(MessageInput);
@@ -898,6 +958,7 @@ function ComposerVoiceModeButton({
);
}
// oxlint-disable-next-line complexity
export function Composer({
agentId,
serverId,
@@ -921,6 +982,7 @@ export function Composer({
clearDraft,
autoFocus = false,
onAddImages,
onAddFiles,
onFocusInput,
commandDraftConfig,
onMessageSent,
@@ -997,6 +1059,7 @@ export function Composer({
});
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
@@ -1073,6 +1136,7 @@ export function Composer({
}, [userInput.length]);
const { pickImages } = useImageAttachmentPicker();
const { pickFiles } = useFilePicker();
const agentIdRef = useRef(agentId);
const sendAgentMessageRef = useRef<
((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise<void>) | null
@@ -1094,6 +1158,20 @@ export function Composer({
onAddImages?.(addImages);
}, [addImages, onAddImages]);
// Expose addFiles function to parent for drag-and-drop support
const addFiles = useCallback(
(files: UserComposerAttachment[]) => {
setSelectedAttachments((prev) => [...prev, ...files]);
},
[setSelectedAttachments],
);
/* oxlint-disable react-hooks/exhaustive-deps */
useEffect(() => {
onAddFiles?.(addFiles);
}, [addFiles, onAddFiles]);
/* oxlint-enable react-hooks/exhaustive-deps */
const focusInput = useCallback(() => {
if (isNative) return;
focusWithRetries({
@@ -1289,6 +1367,36 @@ export function Composer({
addImages(newImages);
}, [addImages, pickImages]);
const handlePickFile = useCallback(async () => {
if (!client) {
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
return;
}
try {
const files = await pickFiles();
if (!files || files.length === 0) return;
const oversized = files.find((f) => f.bytes.byteLength > MAX_FILE_SIZE_BYTES);
if (oversized) {
toastErrorRef.current(
t("composer.errors.fileTooLarge", { size: "50MB", fileName: oversized.fileName }),
);
return;
}
setIsUploadingFile(true);
const uploaded = await uploadFileAttachments({ client, files });
addFiles(uploaded);
} catch (error) {
console.error("[Composer] Failed to upload file:", error);
toastErrorRef.current(
error instanceof Error ? error.message : t("composer.errors.uploadFailed"),
);
} finally {
setIsUploadingFile(false);
}
}, [client, pickFiles, addFiles, t]);
const handleRemoveAttachment = useCallback(
(index: number) => {
githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]);
@@ -1596,8 +1704,20 @@ export function Composer({
setIsGithubPickerOpen(true);
},
},
...(!isNative
? [
{
id: "file",
label: t("composer.attachments.addFile"),
icon: <ThemedFileText size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePickFile();
},
},
]
: []),
],
[handlePickImage, t],
[handlePickImage, handlePickFile, t],
);
const handleToggleGithubItem = useCallback(
@@ -1698,6 +1818,7 @@ export function Composer({
labels: {
openImage: t("composer.attachments.openImage"),
removeImage: t("composer.attachments.removeImage"),
removeFile: t("composer.attachments.removeFile"),
openGithub: (kind: string, number: number) =>
t("composer.attachments.openGithub", { kind, number }),
removeGithub: (kind: string, number: number) =>
@@ -1721,7 +1842,7 @@ export function Composer({
const messageInputContainerRef = useRef<View>(null);
const isSubmitBusy = isProcessing || isSubmitLoading;
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
const sendErrorNode = useMemo(
@@ -1961,6 +2082,20 @@ const styles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
justifyContent: "center",
},
filePillBody: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
filePillIcon: {
justifyContent: "center",
alignItems: "center",
},
filePillText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
flexShrink: 1,
},
githubPillText: {
minWidth: 0,
flexShrink: 1,
@@ -2030,6 +2165,7 @@ const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedAudioLines = withUnistyles(AudioLines);
const ThemedPaperclip = withUnistyles(Paperclip);
const ThemedFileText = withUnistyles(FileText);
const ThemedGithub = withUnistyles(Github);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });