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.
This commit is contained in:
Mohamed Boudra
2026-08-02 14:46:25 +02:00
committed by GitHub
parent 2b474f7df9
commit 0ad51e26a1
14 changed files with 189 additions and 13 deletions
+26
View File
@@ -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", () => {
+16 -1
View File
@@ -34,6 +34,11 @@ export interface AttachmentPersister {
mimeType: string;
fileName: string | null;
}) => Promise<AttachmentMetadata>;
persistFromDataUrl: (input: {
dataUrl: string;
mimeType: string;
fileName: string | null;
}) => Promise<AttachmentMetadata>;
deleteAttachments: (metadata: AttachmentMetadata[]) => Promise<void> | void;
}
@@ -80,7 +85,10 @@ export interface QueueWriter {
export async function pickAndPersistImages(input: {
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
persister: Pick<AttachmentPersister, "persistFromBlob" | "persistFromFileUri">;
persister: Pick<
AttachmentPersister,
"persistFromBlob" | "persistFromFileUri" | "persistFromDataUrl"
>;
}): Promise<AttachmentMetadata[]> {
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,
@@ -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();
});
});
@@ -0,0 +1,29 @@
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
interface ClipboardImage {
data: string;
}
export interface ClipboardImageReader {
hasImageAsync: () => Promise<boolean>;
getImageAsync: (options: { format: "png" }) => Promise<ClipboardImage | null>;
}
export async function readClipboardImage(
clipboard: ClipboardImageReader,
): Promise<PickedImageAttachmentInput | null> {
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",
};
}
+53 -11
View File
@@ -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<AttachmentMenuItem[]>(
() => [
const attachmentMenuItems = useMemo<AttachmentMenuItem[]>(() => {
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: <ThemedClipboardPaste size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
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 });
@@ -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;
+3
View File
@@ -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: {
+3
View File
@@ -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: {
+3
View File
@@ -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: {
+3
View File
@@ -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: {
+3
View File
@@ -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: {
+3
View File
@@ -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: {
+3
View File
@@ -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: {
+3
View File
@@ -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: {