From fa1f87cac1c102d5bf46f0a0bdc685a47a883e3c Mon Sep 17 00:00:00 2001 From: invarrow Date: Sun, 8 Feb 2026 16:22:08 +0530 Subject: [PATCH] fix(desktop): add native clipboard image paste and fix text paste - Add @tauri-apps/plugin-clipboard-manager for native clipboard access - Add readClipboardImage platform method to read images via Tauri - Add clipboard-manager:allow-read-image capability - Fix handlePaste to check for text before trying native clipboard image The native clipboard fallback was running before checking for plain text, causing stale image data to override text paste. Now the fallback only runs when there's no text on the clipboard. --- bun.lock | 3 +++ packages/app/src/components/prompt-input.tsx | 3 +++ .../components/prompt-input/attachments.ts | 11 +++++++++ packages/app/src/context/platform.tsx | 3 +++ packages/desktop/package.json | 1 + .../src-tauri/capabilities/default.json | 3 ++- packages/desktop/src/index.tsx | 24 +++++++++++++++++++ 7 files changed, 47 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index fcb2f8f0cf9c..8d2fe82d78f9 100644 --- a/bun.lock +++ b/bun.lock @@ -190,6 +190,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-clipboard-manager": "~2", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-http": "~2", @@ -1784,6 +1785,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], + "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], + "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.6", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA=="], "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.6.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="], diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 0b303cf55129..7f21a36dea37 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -35,6 +35,7 @@ import { Persist, persisted } from "@/utils/persist" import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" @@ -97,6 +98,7 @@ export const PromptInput: Component = (props) => { const command = useCommand() const permission = usePermission() const language = useLanguage() + const platform = usePlatform() let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement let scrollRef!: HTMLDivElement @@ -766,6 +768,7 @@ export const PromptInput: Component = (props) => { setCursorPosition(editorRef, promptLength(prompt.current())) }, addPart, + readClipboardImage: platform.readClipboardImage, }) const { abort, handleSubmit } = createPromptSubmit({ diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 48eda3742326..b384bf7d84e4 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -14,6 +14,7 @@ type PromptAttachmentsInput = { setDraggingType: (type: "image" | "@mention" | null) => void focusEditor: () => void addPart: (part: ContentPart) => void + readClipboardImage?: () => Promise } export function createPromptAttachments(input: PromptAttachmentsInput) { @@ -76,6 +77,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { } const plainText = clipboardData.getData("text/plain") ?? "" + + // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images + if (input.readClipboardImage && !plainText) { + const file = await input.readClipboardImage() + if (file) { + await addImageAttachment(file) + return + } + } + if (!plainText) return input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 127b9260b3b0..3fca502badb8 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -65,6 +65,9 @@ export type Platform = { /** Check if an editor app exists (desktop only) */ checkAppExists?(appName: string): Promise + + /** Read image from clipboard (desktop only) */ + readClipboardImage?(): Promise } export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 36bbe7bc2c12..8f09ed169ff5 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -18,6 +18,7 @@ "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-clipboard-manager": "~2", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-opener": "^2", diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 2d38d49a985e..4d0276c832ea 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -46,6 +46,7 @@ { "identifier": "http:default", "allow": [{ "url": "http://*" }, { "url": "https://*" }, { "url": "http://*:*/*" }] - } + }, + "clipboard-manager:allow-read-image" ] } diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index d3377e95aa3e..dd78224e345c 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -16,6 +16,7 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { Store } from "@tauri-apps/plugin-store" import { Splash } from "@opencode-ai/ui/logo" import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" +import { readImage } from "@tauri-apps/plugin-clipboard-manager" import { UPDATER_ENABLED } from "./updater" import { initI18n, t } from "./i18n" @@ -344,6 +345,29 @@ const createPlatform = (password: Accessor): Platform => ({ checkAppExists: async (appName: string) => { return commands.checkAppExists(appName) }, + + async readClipboardImage() { + const image = await readImage().catch(() => null) + if (!image) return null + const bytes = await image.rgba().catch(() => null) + if (!bytes || bytes.length === 0) return null + const size = await image.size().catch(() => null) + if (!size) return null + const canvas = document.createElement("canvas") + canvas.width = size.width + canvas.height = size.height + const ctx = canvas.getContext("2d") + if (!ctx) return null + const imageData = ctx.createImageData(size.width, size.height) + imageData.data.set(bytes) + ctx.putImageData(imageData, 0, 0) + return new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) return resolve(null) + resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })) + }, "image/png") + }) + }, }) let menuTrigger = null as null | ((id: string) => void)