diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 2306caa47..24cbf02f1 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -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 }; diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 6e821849d..979d0db29 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -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({ /> {onFilesDropped ? ( - {cardInner} + + {cardInner} + ) : ( cardInner )} diff --git a/packages/app/src/components/file-drop-zone.tsx b/packages/app/src/components/file-drop-zone.tsx index af4b272a0..0d6a0081d 100644 --- a/packages/app/src/components/file-drop-zone.tsx +++ b/packages/app/src/components/file-drop-zone.tsx @@ -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 */} - {t("composer.attachments.dropImagesHere")} + {t("composer.attachments.dropFilesHere")} diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 8c65af8f1..6befcdbfc 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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 ""; } diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index e4d3fba62..f6af2e78d 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -179,6 +179,7 @@ function createFakeSendClient( throw options.rejection; } }, + uploadFile: async () => ({ requestId: "test", file: null, error: null }), }; } diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index e99ffc5bc..816736ac0 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -49,6 +49,18 @@ export interface ComposerSendClient { attachments: ReturnType["attachments"]; }, ) => Promise; + 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[]> { + const result: Extract[] = []; + + 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(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 { + 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, ); diff --git a/packages/app/src/composer/attachments/submit.ts b/packages/app/src/composer/attachments/submit.ts index 4e443815b..ccfae9e60 100644 --- a/packages/app/src/composer/attachments/submit.ts +++ b/packages/app/src/composer/attachments/submit.ts @@ -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, }; } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 3cfd932bc..d748137f1 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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 ( + + ); + } if (composerWorkspaceAttachment.is(attachment)) { return composerWorkspaceAttachment.renderPill({ attachment, @@ -642,6 +659,45 @@ function GithubAttachmentPill({ ); } +interface FileAttachmentPillProps { + attachment: Extract; + 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 ( + + + + + + + {attachment.attachment.fileName} + + + + ); +} + 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(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) | 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: , + 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(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 }); diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts index 552b71cf4..82ef2c8fc 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -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 { export function useFileDropZone({ onFilesDropped, + onGenericFilesDropped, disabled = false, }: UseFileDropZoneOptions): UseFileDropZoneReturn { const [isDragging, setIsDragging] = useState(false); const containerRef = useRef(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; diff --git a/packages/app/src/hooks/use-file-picker.ts b/packages/app/src/hooks/use-file-picker.ts new file mode 100644 index 000000000..3139eb53f --- /dev/null +++ b/packages/app/src/hooks/use-file-picker.ts @@ -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 = { + ".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 { + 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 { + 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 { + 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 => { + 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 }; +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index d9eeea9fd..f51e861ee 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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: "أرشفة الوكيل الحالي", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 5725fa6ff..fd5507606 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index ceb2a6ec9..8a996ac8e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 30d784e57..40a3da6ff 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 3e19f1532..9d8c043da 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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: "Архивировать текущего агента", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index c36537f2c..9ba58672b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 62407f34b..1f887892c 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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(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; 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 ( - + {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} diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b9f9ed034..1773f39a3 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -4064,6 +4064,7 @@ export type DictationStreamFinishMessage = z.infer; export type CreateAgentRequestMessage = z.infer; export type AgentAttachment = z.infer; +export type UploadedFileAttachment = z.infer; export type FirstAgentContext = z.infer; export type ReviewAttachment = z.infer; export type ListProviderModelsRequestMessage = z.infer<