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<