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
+6 -1
View File
@@ -1,4 +1,8 @@
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
import type {
AgentAttachment,
GitHubSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
@@ -61,6 +65,7 @@ export type PullRequestContextAttachment =
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "github_pr"; item: GitHubSearchItem };
@@ -453,6 +453,7 @@ export interface AdaptiveModalSheetProps {
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;
presentation?: "push" | "replace";
}
@@ -467,6 +468,7 @@ export function AdaptiveModalSheet({
testID,
desktopMaxWidth,
onFilesDropped,
onGenericFilesDropped,
scrollable = true,
presentation,
}: AdaptiveModalSheetProps) {
@@ -602,7 +604,12 @@ export function AdaptiveModalSheet({
/>
<View style={desktopCardStyle}>
{onFilesDropped ? (
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
<FileDropZone
onFilesDropped={onFilesDropped}
onGenericFilesDropped={onGenericFilesDropped}
>
{cardInner}
</FileDropZone>
) : (
cardInner
)}
+10 -3
View File
@@ -4,23 +4,30 @@ import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-na
import { useEffect, useMemo } from "react";
import { Upload } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
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;
}
const IS_WEB = isWeb;
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
export function FileDropZone({
children,
onFilesDropped,
onGenericFilesDropped,
disabled = false,
}: FileDropZoneProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({
onFilesDropped,
onGenericFilesDropped,
disabled,
});
@@ -60,7 +67,7 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil
{/* Content */}
<View style={styles.overlayContent}>
<Upload size={32} color={theme.colors.primary} />
<Text style={styles.overlayText}>{t("composer.attachments.dropImagesHere")}</Text>
<Text style={styles.overlayText}>{t("composer.attachments.dropFilesHere")}</Text>
</View>
</Animated.View>
</View>
+2
View File
@@ -443,6 +443,8 @@ function getUserMessageAttachmentLabel(
return `Issue #${attachment.number}`;
case "text":
return attachment.title ?? t("message.attachments.textAttachment");
case "uploaded_file":
return attachment.fileName;
default:
return "";
}
@@ -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 });
+36 -1
View File
@@ -4,8 +4,19 @@ import { getDesktopHost } from "@/desktop/host";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
import { isWeb } from "@/constants/platform";
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;
}
@@ -83,18 +94,24 @@ async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
export function useFileDropZone({
onFilesDropped,
onGenericFilesDropped,
disabled = false,
}: UseFileDropZoneOptions): UseFileDropZoneReturn {
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLElement | null>(null);
const dragCounterRef = useRef(0);
const onFilesDroppedRef = useRef(onFilesDropped);
const onGenericFilesDroppedRef = useRef(onGenericFilesDropped);
// Keep callback ref up to date
// Keep callback refs up to date
useEffect(() => {
onFilesDroppedRef.current = onFilesDropped;
}, [onFilesDropped]);
useEffect(() => {
onGenericFilesDroppedRef.current = onGenericFilesDropped;
}, [onGenericFilesDropped]);
// Reset drag state when disabled changes
useEffect(() => {
if (disabled) {
@@ -156,6 +173,15 @@ export function useFileDropZone({
if (disabled) return;
const items: DroppedPathItem[] = payload.paths.map((path) => ({
kind: "desktop-path",
path,
}));
if (onGenericFilesDroppedRef.current && items.length > 0) {
onGenericFilesDroppedRef.current(items);
}
const imagePaths = payload.paths.filter(isImagePath);
if (imagePaths.length === 0) {
return;
@@ -238,6 +264,15 @@ export function useFileDropZone({
if (disabled) return;
const files = Array.from(e.dataTransfer?.files ?? []);
const genericItems: DroppedItem[] = files.map((file) => ({
kind: "web-file",
file,
}));
if (onGenericFilesDroppedRef.current && genericItems.length > 0) {
onGenericFilesDroppedRef.current(genericItems);
}
const imageFiles = files.filter(isImageFile);
if (imageFiles.length === 0) return;
+200
View File
@@ -0,0 +1,200 @@
import { useCallback, useRef } from "react";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands";
import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url";
import { isWeb } from "@/constants/platform";
export interface PickedFile {
fileName: string;
mimeType: string;
bytes: Uint8Array;
}
const MIME_BY_EXTENSION: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".heic": "image/heic",
".heif": "image/heif",
".avif": "image/avif",
".tif": "image/tiff",
".tiff": "image/tiff",
".pdf": "application/pdf",
".txt": "text/plain",
".md": "text/markdown",
".json": "application/json",
".js": "text/javascript",
".ts": "text/typescript",
".tsx": "text/typescript-jsx",
".jsx": "text/javascript",
".html": "text/html",
".css": "text/css",
".xml": "text/xml",
".csv": "text/csv",
".zip": "application/zip",
".gz": "application/gzip",
".tar": "application/x-tar",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".wav": "audio/wav",
".ogg": "audio/ogg",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
};
function getFileExtension(path: string): string {
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
const extensionIndex = normalizedPath.lastIndexOf(".");
if (extensionIndex < 0) {
return "";
}
return normalizedPath.slice(extensionIndex).toLowerCase();
}
export function getMimeTypeFromPath(path: string): string {
const ext = getFileExtension(path);
return MIME_BY_EXTENSION[ext] ?? "application/octet-stream";
}
function base64ToUint8Array(base64: string): Uint8Array {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
export async function readDesktopFileBytes(path: string): Promise<Uint8Array> {
const { path: managedPath } = await copyDesktopAttachmentFile({
attachmentId: crypto.randomUUID(),
sourcePath: path,
extension: getFileExtension(path).slice(1) || null,
});
const base64 = await readDesktopFileBase64(managedPath);
return base64ToUint8Array(base64);
}
async function pickFilesWithDesktopDialog(): Promise<PickedFile[] | null> {
const dialog = getDesktopHost()?.dialog;
const dialogOpen = dialog?.open;
if (typeof dialogOpen !== "function") {
throw new Error("Desktop dialog API is not available.");
}
const selection = await dialogOpen({
directory: false,
multiple: true,
});
if (!selection) {
return null;
}
const paths = Array.isArray(selection) ? selection : [selection];
if (paths.length === 0) {
return null;
}
const result: PickedFile[] = [];
for (const filePath of paths) {
const fileName = filePath.split("/").pop() ?? filePath.split("\\").pop() ?? filePath;
const mimeType = getMimeTypeFromPath(filePath);
// Copy into managed storage so we can read it through the existing secure IPC.
const { path: managedPath } = await copyDesktopAttachmentFile({
attachmentId: crypto.randomUUID(),
sourcePath: filePath,
extension: getFileExtension(filePath).slice(1) || null,
});
const base64 = await readDesktopFileBase64(managedPath);
const bytes = base64ToUint8Array(base64);
result.push({ fileName, mimeType, bytes });
}
return result;
}
function pickFilesWithWebInput(): Promise<PickedFile[] | null> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", async () => {
const files = Array.from(input.files ?? []);
if (files.length === 0) {
resolve(null);
return;
}
const result: PickedFile[] = [];
for (const file of files) {
const bytes = new Uint8Array(await file.arrayBuffer());
result.push({
fileName: file.name,
mimeType: file.type || getMimeTypeFromPath(file.name),
bytes,
});
}
resolve(result);
});
input.addEventListener("cancel", () => {
resolve(null);
});
document.body.appendChild(input);
input.click();
// Clean up after a short delay to allow the change event to fire
setTimeout(() => {
input.remove();
}, 60_000);
});
}
export function useFilePicker() {
const isPickingRef = useRef(false);
const pickFiles = useCallback(async (): Promise<PickedFile[] | null> => {
if (isPickingRef.current) {
return null;
}
isPickingRef.current = true;
try {
if (isWeb && isElectronRuntime()) {
return await pickFilesWithDesktopDialog();
}
if (isWeb) {
return await pickFilesWithWebInput();
}
// Native not supported yet
return null;
} catch (error) {
console.error("[FilePicker] Failed to pick files:", error);
throw error;
} finally {
isPickingRef.current = false;
}
}, []);
return { pickFiles };
}
+5
View File
@@ -99,12 +99,15 @@ export const ar: TranslationResources = {
},
attachments: {
addImage: "أضف صورة",
addFile: "Upload file",
addIssueOrPr: "أضف مشكلة أو PR",
dropImagesHere: "إسقاط الصور هنا",
dropFilesHere: "Drop files here",
editQueuedMessage: "تحرير الرسالة في قائمة الانتظار",
sendQueuedMessageNow: "إرسال رسالة في قائمة الانتظار الآن",
openImage: "فتح مرفق الصورة",
removeImage: "إزالة مرفق الصورة",
removeFile: "Remove file attachment",
openGithub: "افتح{{kind}}#{{number}}",
removeGithub: "إزالة{{kind}}#{{number}}",
browserElement: "العنصر ·{{tag}}",
@@ -119,6 +122,8 @@ export const ar: TranslationResources = {
noHostSelected: "لم يتم تحديد مضيف",
initialPromptRequired: "مطلوب موجه الأولي",
alreadyLoading: "جارٍ التحميل بالفعل",
uploadFailed: "Failed to upload file",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
clientCommands: {
archiveAgent: "أرشفة الوكيل الحالي",
+5
View File
@@ -97,12 +97,15 @@ export const en = {
},
attachments: {
addImage: "Add image",
addFile: "Upload file",
addIssueOrPr: "Add issue or PR",
dropImagesHere: "Drop images here",
dropFilesHere: "Drop files here",
editQueuedMessage: "Edit queued message",
sendQueuedMessageNow: "Send queued message now",
openImage: "Open image attachment",
removeImage: "Remove image attachment",
removeFile: "Remove file attachment",
openGithub: "Open {{kind}} #{{number}}",
removeGithub: "Remove {{kind}} #{{number}}",
browserElement: "Element · {{tag}}",
@@ -117,6 +120,8 @@ export const en = {
noHostSelected: "No host selected",
initialPromptRequired: "Initial prompt is required",
alreadyLoading: "Already loading",
uploadFailed: "Failed to upload file",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
clientCommands: {
archiveAgent: "Archive the current agent",
+5
View File
@@ -99,12 +99,15 @@ export const es: TranslationResources = {
},
attachments: {
addImage: "Agregar imagen",
addFile: "Upload file",
addIssueOrPr: "Agregar problema oPR",
dropImagesHere: "Suelta imágenes aquí",
dropFilesHere: "Drop files here",
editQueuedMessage: "Editar mensaje en cola",
sendQueuedMessageNow: "Enviar mensaje en cola ahora",
openImage: "Abrir imagen adjunta",
removeImage: "Quitar imagen adjunta",
removeFile: "Remove file attachment",
openGithub: "Abrir{{kind}}#{{number}}",
removeGithub: "Quitar{{kind}}#{{number}}",
browserElement: "Elemento ·{{tag}}",
@@ -119,6 +122,8 @@ export const es: TranslationResources = {
noHostSelected: "Ningún anfitrión seleccionado",
initialPromptRequired: "Se requiere aviso inicial",
alreadyLoading: "Ya cargando",
uploadFailed: "Failed to upload file",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
clientCommands: {
archiveAgent: "Archivar el agente actual",
+5
View File
@@ -100,12 +100,15 @@ export const fr: TranslationResources = {
},
attachments: {
addImage: "Ajouter une image",
addFile: "Upload file",
addIssueOrPr: "Ajouter un problème ouPR",
dropImagesHere: "Déposez des images ici",
dropFilesHere: "Drop files here",
editQueuedMessage: "Modifier le message en file d'attente",
sendQueuedMessageNow: "Envoyer le message en file d'attente maintenant",
openImage: "Ouvrir la pièce jointe de l'image",
removeImage: "Supprimer l'image jointe",
removeFile: "Remove file attachment",
openGithub: "Ouvrir{{kind}}#{{number}}",
removeGithub: "Supprimer{{kind}}#{{number}}",
browserElement: "Élément ·{{tag}}",
@@ -120,6 +123,8 @@ export const fr: TranslationResources = {
noHostSelected: "Aucun hôte sélectionné",
initialPromptRequired: "Une invite initiale est requise",
alreadyLoading: "Déjà en cours de chargement",
uploadFailed: "Failed to upload file",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
clientCommands: {
archiveAgent: "Archiver l'agent actuel",
+5
View File
@@ -99,12 +99,15 @@ export const ru: TranslationResources = {
},
attachments: {
addImage: "Добавить изображение",
addFile: "Upload file",
addIssueOrPr: "Добавить проблему или PR",
dropImagesHere: "Скиньте изображения сюда",
dropFilesHere: "Drop files here",
editQueuedMessage: "Изменить сообщение в очереди",
sendQueuedMessageNow: "Отправить сообщение в очереди сейчас",
openImage: "Открыть прикрепленное изображение",
removeImage: "Удалить прикрепленное изображение",
removeFile: "Remove file attachment",
openGithub: "Открыть{{kind}}#{{number}}",
removeGithub: "Удалить{{kind}}#{{number}}",
browserElement: "Элемент ·{{tag}}",
@@ -119,6 +122,8 @@ export const ru: TranslationResources = {
noHostSelected: "Хост не выбран",
initialPromptRequired: "Требуется начальное приглашение",
alreadyLoading: "Уже загружается",
uploadFailed: "Failed to upload file",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
clientCommands: {
archiveAgent: "Архивировать текущего агента",
+5
View File
@@ -99,12 +99,15 @@ export const zhCN: TranslationResources = {
},
attachments: {
addImage: "添加图片",
addFile: "Upload file",
addIssueOrPr: "添加 issue 或 PR",
dropImagesHere: "将图片拖放到这里",
dropFilesHere: "Drop files here",
editQueuedMessage: "编辑排队消息",
sendQueuedMessageNow: "立即发送排队消息",
openImage: "打开图片附件",
removeImage: "移除图片附件",
removeFile: "Remove file attachment",
openGithub: "打开 {{kind}} #{{number}}",
removeGithub: "移除 {{kind}} #{{number}}",
browserElement: "元素 · {{tag}}",
@@ -119,6 +122,8 @@ export const zhCN: TranslationResources = {
noHostSelected: "未选择 Host",
initialPromptRequired: "初始 prompt 必填",
alreadyLoading: "正在加载",
uploadFailed: "Failed to upload file",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
clientCommands: {
archiveAgent: "归档当前 Agent",
+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}
+1
View File
@@ -4064,6 +4064,7 @@ export type DictationStreamFinishMessage = z.infer<typeof DictationStreamFinishM
export type DictationStreamCancelMessage = z.infer<typeof DictationStreamCancelMessageSchema>;
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>;
export type UploadedFileAttachment = z.infer<typeof UploadedFileAttachmentSchema>;
export type FirstAgentContext = z.infer<typeof FirstAgentContextSchema>;
export type ReviewAttachment = z.infer<typeof ReviewAttachmentSchema>;
export type ListProviderModelsRequestMessage = z.infer<