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: {