From 0ad51e26a14ed7507b79b329d633f434e8f1b3e6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 2 Aug 2026 14:46:25 +0200 Subject: [PATCH] fix(app): paste clipboard images on mobile (#2793) Native composers had no path from the device clipboard into image attachments. Route clipboard data URLs through the existing attachment persistence flow and expose the action only in the native attachment sheet. --- packages/app/src/composer/actions.test.ts | 26 ++++++++ packages/app/src/composer/actions.ts | 17 ++++- .../app/src/composer/clipboard-image.test.ts | 37 +++++++++++ packages/app/src/composer/clipboard-image.ts | 29 +++++++++ packages/app/src/composer/index.tsx | 64 +++++++++++++++---- .../app/src/hooks/image-attachment-picker.ts | 5 +- packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + 14 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 packages/app/src/composer/clipboard-image.test.ts create mode 100644 packages/app/src/composer/clipboard-image.ts diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index d1aff546a..743f5ebff 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -143,20 +143,27 @@ function browserElementWorkspaceAttachment(): Extract< function createFakePersister(): AttachmentPersister & { blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }>; + dataUrlCalls: Array<{ dataUrl: string; mimeType: string; fileName: string | null }>; fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }>; deletedBatches: AttachmentMetadata[][]; } { const blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }> = []; + const dataUrlCalls: Array<{ dataUrl: string; mimeType: string; fileName: string | null }> = []; const fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }> = []; const deletedBatches: AttachmentMetadata[][] = []; return { blobCalls, + dataUrlCalls, fileUriCalls, deletedBatches, persistFromBlob: async ({ blob, mimeType, fileName }) => { blobCalls.push({ blob, mimeType, fileName }); return { ...imageMetadata, id: `blob-${blobCalls.length}` }; }, + persistFromDataUrl: async ({ dataUrl, mimeType, fileName }) => { + dataUrlCalls.push({ dataUrl, mimeType, fileName }); + return { ...imageMetadata, id: `data-url-${dataUrlCalls.length}` }; + }, persistFromFileUri: async ({ uri, mimeType, fileName }) => { fileUriCalls.push({ uri, mimeType, fileName }); return { ...imageMetadata, id: `uri-${fileUriCalls.length}` }; @@ -388,6 +395,25 @@ describe("pickAndPersistImages", () => { ]); expect(result).toHaveLength(1); }); + + it("persists data_url sources via persistFromDataUrl", async () => { + const persister = createFakePersister(); + const dataUrl = "data:image/png;base64,AAEC"; + const result = await pickAndPersistImages({ + pickImages: async () => [ + { + source: { kind: "data_url", dataUrl }, + mimeType: "image/png", + fileName: "clipboard.png", + }, + ], + persister, + }); + expect(persister.dataUrlCalls).toEqual([ + { dataUrl, mimeType: "image/png", fileName: "clipboard.png" }, + ]); + expect(result).toHaveLength(1); + }); }); describe("dispatchComposerAgentMessage", () => { diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 7b454f130..55f0578cb 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -34,6 +34,11 @@ export interface AttachmentPersister { mimeType: string; fileName: string | null; }) => Promise; + persistFromDataUrl: (input: { + dataUrl: string; + mimeType: string; + fileName: string | null; + }) => Promise; deleteAttachments: (metadata: AttachmentMetadata[]) => Promise | void; } @@ -80,7 +85,10 @@ export interface QueueWriter { export async function pickAndPersistImages(input: { pickImages: () => Promise; - persister: Pick; + persister: Pick< + AttachmentPersister, + "persistFromBlob" | "persistFromFileUri" | "persistFromDataUrl" + >; }): Promise { const result = await input.pickImages(); if (!result?.length) return []; @@ -95,6 +103,13 @@ export async function pickAndPersistImages(input: { fileName, }); } + if (picked.source.kind === "data_url") { + return await input.persister.persistFromDataUrl({ + dataUrl: picked.source.dataUrl, + mimeType, + fileName, + }); + } return await input.persister.persistFromFileUri({ uri: picked.source.uri, mimeType, diff --git a/packages/app/src/composer/clipboard-image.test.ts b/packages/app/src/composer/clipboard-image.test.ts new file mode 100644 index 000000000..4a4befe61 --- /dev/null +++ b/packages/app/src/composer/clipboard-image.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { readClipboardImage, type ClipboardImageReader } from "./clipboard-image"; + +function createClipboard(input: { + hasImage: boolean; + imageData?: string | null; +}): ClipboardImageReader { + return { + hasImageAsync: async () => input.hasImage, + getImageAsync: async () => + input.imageData === undefined || input.imageData === null ? null : { data: input.imageData }, + }; +} + +describe("readClipboardImage", () => { + it("returns null when the clipboard does not contain an image", async () => { + await expect(readClipboardImage(createClipboard({ hasImage: false }))).resolves.toBeNull(); + }); + + it("returns the clipboard image as a persistable data URL", async () => { + const dataUrl = "data:image/png;base64,AAEC"; + + await expect( + readClipboardImage(createClipboard({ hasImage: true, imageData: dataUrl })), + ).resolves.toEqual({ + source: { kind: "data_url", dataUrl }, + mimeType: "image/png", + fileName: "clipboard.png", + }); + }); + + it("returns null when the clipboard image disappears before it is read", async () => { + await expect( + readClipboardImage(createClipboard({ hasImage: true, imageData: null })), + ).resolves.toBeNull(); + }); +}); diff --git a/packages/app/src/composer/clipboard-image.ts b/packages/app/src/composer/clipboard-image.ts new file mode 100644 index 000000000..1eb7c7045 --- /dev/null +++ b/packages/app/src/composer/clipboard-image.ts @@ -0,0 +1,29 @@ +import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker"; + +interface ClipboardImage { + data: string; +} + +export interface ClipboardImageReader { + hasImageAsync: () => Promise; + getImageAsync: (options: { format: "png" }) => Promise; +} + +export async function readClipboardImage( + clipboard: ClipboardImageReader, +): Promise { + if (!(await clipboard.hasImageAsync())) { + return null; + } + + const image = await clipboard.getImageAsync({ format: "png" }); + if (!image) { + return null; + } + + return { + source: { kind: "data_url", dataUrl: image.data }, + mimeType: "image/png", + fileName: "clipboard.png", + }; +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 0b9a94b75..9a535d9e2 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -30,8 +30,10 @@ import { FileText, GitPullRequest, Image as ImageIcon, + ClipboardPaste, Paperclip, } from "lucide-react-native"; +import * as Clipboard from "expo-clipboard"; import Animated from "react-native-reanimated"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { @@ -64,6 +66,7 @@ import { sendQueuedComposerMessageNow, toggleGithubAttachmentFromPicker, uploadFileAttachments, + type AttachmentPersister, type QueueWriter, type QueuedComposerMessage, } from "@/composer/actions"; @@ -82,6 +85,7 @@ import { import { deleteAttachments, persistAttachmentFromBlob, + persistAttachmentFromDataUrl, persistAttachmentFromFileUri, } from "@/attachments/service"; import { resolveAgentControlsMode } from "@/composer/agent-controls/mode"; @@ -119,6 +123,7 @@ import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { getForgePresentation } from "@/git/forge"; import { ForgeBrandIcon } from "@/git/forge-icon"; import { useComposerGithubAutoAttach } from "./github/auto-attach"; +import { readClipboardImage } from "./clipboard-image"; import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands"; import { appendWorkspaceFileAttachment, @@ -130,6 +135,15 @@ import { type WorkspaceFileDragPayload, } from "@/attachments/workspace-file-drag"; +const composerImageAttachmentPersister: Pick< + AttachmentPersister, + "persistFromBlob" | "persistFromDataUrl" | "persistFromFileUri" +> = { + persistFromBlob: persistAttachmentFromBlob, + persistFromDataUrl: persistAttachmentFromDataUrl, + persistFromFileUri: persistAttachmentFromFileUri, +}; + type QueuedMessage = QueuedComposerMessage; type AttachmentListUpdater = @@ -1408,17 +1422,32 @@ export function Composer({ const handlePickImage = useCallback(async () => { const newImages = await pickAndPersistImages({ pickImages, - persister: { - persistFromBlob: ({ blob, mimeType, fileName }) => - persistAttachmentFromBlob({ blob, mimeType, fileName }), - persistFromFileUri: ({ uri, mimeType, fileName }) => - persistAttachmentFromFileUri({ uri, mimeType, fileName }), - }, + persister: composerImageAttachmentPersister, }); if (newImages.length === 0) return; addImages(newImages); }, [addImages, pickImages]); + const handlePasteImage = useCallback(async () => { + try { + const newImages = await pickAndPersistImages({ + pickImages: async () => { + const image = await readClipboardImage(Clipboard); + return image ? [image] : null; + }, + persister: composerImageAttachmentPersister, + }); + if (newImages.length === 0) { + toastErrorRef.current(t("composer.errors.noClipboardImage")); + return; + } + addImages(newImages); + } catch (error) { + console.error("[Composer] Failed to paste clipboard image:", error); + toastErrorRef.current(t("composer.errors.pasteImageFailed")); + } + }, [addImages, t]); + const uploadPickedFiles = useCallback( async (files: PickedFile[]) => { if (files.length === 0) return; @@ -1823,8 +1852,8 @@ export function Composer({ [githubSearchItems, githubSearchQueryTrimmed], ); - const attachmentMenuItems = useMemo( - () => [ + const attachmentMenuItems = useMemo(() => { + const items: AttachmentMenuItem[] = [ { id: "image", label: t("composer.attachments.addImage"), @@ -1833,6 +1862,18 @@ export function Composer({ void handlePickImage(); }, }, + ]; + if (isNative) { + items.push({ + id: "paste-image", + label: t("composer.attachments.pasteImage"), + icon: , + onSelect: () => { + void handlePasteImage(); + }, + }); + } + items.push( { id: "github", label: t("composer.attachments.addIssueOrPr", { @@ -1851,9 +1892,9 @@ export function Composer({ void handlePickFile(); }, }, - ], - [handlePickImage, handlePickFile, t, forgePresentation], - ); + ); + return items; + }, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]); const handleToggleGithubItem = useCallback( (item: ForgeSearchItem) => { @@ -2248,6 +2289,7 @@ const ThemedCircleDot = withUnistyles(CircleDot); const ThemedAudioLines = withUnistyles(AudioLines); const ThemedPaperclip = withUnistyles(Paperclip); const ThemedImageIcon = withUnistyles(ImageIcon); +const ThemedClipboardPaste = withUnistyles(ClipboardPaste); const ThemedFileText = withUnistyles(FileText); const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); diff --git a/packages/app/src/hooks/image-attachment-picker.ts b/packages/app/src/hooks/image-attachment-picker.ts index c01a6ba8a..2b9df99c8 100644 --- a/packages/app/src/hooks/image-attachment-picker.ts +++ b/packages/app/src/hooks/image-attachment-picker.ts @@ -4,7 +4,10 @@ import { getFileNameFromPath } from "@/attachments/utils"; import { i18n } from "@/i18n/i18next"; import { isAbsolutePath } from "@/utils/path"; -export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob }; +export type PickedImageSource = + | { kind: "file_uri"; uri: string } + | { kind: "blob"; blob: Blob } + | { kind: "data_url"; dataUrl: string }; export interface PickedImageAttachmentInput { source: PickedImageSource; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index ee0ba9bb4..5a8b00b7f 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -112,6 +112,7 @@ export const ar: TranslationResources = { }, attachments: { addImage: "أضف صورة", + pasteImage: "لصق صورة", addFile: "Upload file", addIssueOrPr: "أضف مشكلة أو PR", addIssueOrPr_mr: "أضف مشكلة أو MR", @@ -137,6 +138,8 @@ export const ar: TranslationResources = { initialPromptRequired: "مطلوب موجه الأولي", alreadyLoading: "جارٍ التحميل بالفعل", uploadFailed: "Failed to upload file", + noClipboardImage: "لا توجد صورة في الحافظة", + pasteImageFailed: "تعذر لصق الصورة", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, clientCommands: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index f85e58179..f15b986c7 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -110,6 +110,7 @@ export const en = { }, attachments: { addImage: "Add image", + pasteImage: "Paste image", addFile: "Upload file", addIssueOrPr: "Add issue or PR", addIssueOrPr_mr: "Add issue or MR", @@ -135,6 +136,8 @@ export const en = { initialPromptRequired: "Initial prompt is required", alreadyLoading: "Already loading", uploadFailed: "Failed to upload file", + noClipboardImage: "No image in clipboard", + pasteImageFailed: "Failed to paste image", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, clientCommands: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index ace986d77..d55caf813 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -112,6 +112,7 @@ export const es: TranslationResources = { }, attachments: { addImage: "Agregar imagen", + pasteImage: "Pegar imagen", addFile: "Upload file", addIssueOrPr: "Agregar problema oPR", addIssueOrPr_mr: "Agregar problema o MR", @@ -137,6 +138,8 @@ export const es: TranslationResources = { initialPromptRequired: "Se requiere aviso inicial", alreadyLoading: "Ya cargando", uploadFailed: "Failed to upload file", + noClipboardImage: "No hay ninguna imagen en el portapapeles", + pasteImageFailed: "No se pudo pegar la imagen", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, clientCommands: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 2a1f6d60b..e10ac745f 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -114,6 +114,7 @@ export const fr: TranslationResources = { }, attachments: { addImage: "Ajouter une image", + pasteImage: "Coller une image", addFile: "Upload file", addIssueOrPr: "Ajouter un problème ouPR", addIssueOrPr_mr: "Ajouter un problème ou MR", @@ -139,6 +140,8 @@ export const fr: TranslationResources = { initialPromptRequired: "Une invite initiale est requise", alreadyLoading: "Déjà en cours de chargement", uploadFailed: "Failed to upload file", + noClipboardImage: "Aucune image dans le presse-papiers", + pasteImageFailed: "Impossible de coller l’image", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, clientCommands: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 47c879ce8..91ee63dbf 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -112,6 +112,7 @@ export const ja: TranslationResources = { }, attachments: { addImage: "画像を追加", + pasteImage: "画像を貼り付け", addFile: "ファイルをアップロード", addIssueOrPr: "イシューまたはPRを追加", addIssueOrPr_mr: "イシューまたはMRを追加", @@ -137,6 +138,8 @@ export const ja: TranslationResources = { initialPromptRequired: "初期プロンプトが必要です", alreadyLoading: "すでに読み込み中です", uploadFailed: "ファイルのアップロードに失敗しました", + noClipboardImage: "クリップボードに画像がありません", + pasteImageFailed: "画像を貼り付けられませんでした", fileTooLarge: "{{fileName}}が大きすぎます(最大{{size}})", }, clientCommands: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 1fb445f83..0aca91731 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -112,6 +112,7 @@ export const ptBR: TranslationResources = { }, attachments: { addImage: "Adicionar imagem", + pasteImage: "Colar imagem", addFile: "Enviar arquivo", addIssueOrPr: "Adicionar issue ou PR", addIssueOrPr_mr: "Adicionar issue ou MR", @@ -137,6 +138,8 @@ export const ptBR: TranslationResources = { initialPromptRequired: "O prompt inicial é obrigatório", alreadyLoading: "Já está carregando", uploadFailed: "Falha ao enviar arquivo", + noClipboardImage: "Não há imagem na área de transferência", + pasteImageFailed: "Falha ao colar a imagem", fileTooLarge: "{{fileName}} é grande demais (máximo {{size}})", }, clientCommands: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index de3135426..7615a707a 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -112,6 +112,7 @@ export const ru: TranslationResources = { }, attachments: { addImage: "Добавить изображение", + pasteImage: "Вставить изображение", addFile: "Upload file", addIssueOrPr: "Добавить проблему или PR", addIssueOrPr_mr: "Добавить проблему или MR", @@ -137,6 +138,8 @@ export const ru: TranslationResources = { initialPromptRequired: "Требуется начальное приглашение", alreadyLoading: "Уже загружается", uploadFailed: "Failed to upload file", + noClipboardImage: "В буфере обмена нет изображения", + pasteImageFailed: "Не удалось вставить изображение", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, clientCommands: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 3aaece91d..1e9684b74 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -112,6 +112,7 @@ export const zhCN: TranslationResources = { }, attachments: { addImage: "添加图片", + pasteImage: "粘贴图片", addFile: "Upload file", addIssueOrPr: "添加 issue 或 PR", addIssueOrPr_mr: "添加 issue 或 MR", @@ -137,6 +138,8 @@ export const zhCN: TranslationResources = { initialPromptRequired: "初始 prompt 必填", alreadyLoading: "正在加载", uploadFailed: "Failed to upload file", + noClipboardImage: "剪贴板中没有图片", + pasteImageFailed: "无法粘贴图片", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, clientCommands: {