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:
@@ -179,6 +179,7 @@ function createFakeSendClient(
|
||||
throw options.rejection;
|
||||
}
|
||||
},
|
||||
uploadFile: async () => ({ requestId: "test", file: null, error: null }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user