fix(agents): attach pasted clipboard images on Linux (#2336)

WebKitGTK hands the composer an empty clipboardData when the clipboard
holds only an image, as after a GNOME screenshot, and refuses
navigator.clipboard.read(). Ctrl+V then attached nothing. A paste that
carries no data now reads the image through the GTK clipboard with the
new clipboard_image command.

Co-authored-by: NealimeKenna <[email protected]>
Цей коміт міститься в:
NealimeKennaтаNealimeKenna authored and GitHub committed 2026-11-09 19:59:30 +08:00
1 джерело ff2eb95b01
коміт e44a3af310
7 files changed
+79 -6

No files matched your search

+1
Переглянути файл
@@ -245,6 +245,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "clear_caches"
| "clipboard_file_list"
| "clipboard_has_files"
| "clipboard_image"
| "clipboard_set_file_list"
| "jdk_download_vendors"
| "jdk_download_releases"
+1
Переглянути файл
@@ -46,6 +46,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"clear_caches",
"clipboard_file_list",
"clipboard_has_files",
"clipboard_image",
"clipboard_set_file_list",
"jdk_download_vendors",
"jdk_download_releases",
+33
Переглянути файл
@@ -31,6 +31,39 @@ pub async fn clipboard_set_file_list(app: AppHandle, paths: Vec<PathBuf>) -> Ide
with_clipboard(app, move || Ok(ide_clipboard::set_files(&paths))).await
}
/// The clipboard's image as a PNG data URL, for a paste whose event carried no
/// files: WebKitGTK leaves `clipboardData` empty for a raw image (a screenshot).
/// None when the clipboard holds no image, and always on other platforms.
#[tauri::command]
#[specta::specta]
pub async fn clipboard_image(app: AppHandle) -> IdeResult<Option<String>> {
#[cfg(target_os = "linux")]
{
use base64::Engine;
let (tx, rx) = tokio::sync::oneshot::channel();
app.run_on_main_thread(move || {
let sender = std::cell::Cell::new(Some(tx));
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).request_image(move |_, pixbuf| {
let png = pixbuf.and_then(|image| image.save_to_bufferv("png", &[]).ok());
if let Some(sender) = sender.take() {
let _ = sender.send(png);
}
});
})
.map_err(|error| {
ide_core::IdeError::internal(format!("Cannot dispatch clipboard access to the main thread: {error}"))
})?;
let png = rx.await.ok().flatten();
Ok(png
.map(|bytes| format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes))))
}
#[cfg(not(target_os = "linux"))]
{
let _ = app;
Ok(None)
}
}
/// AppKit and WebKit share the general pasteboard, so a worker-side mutex cannot
/// protect its type cache. Await the main thread without blocking a command thread.
async fn with_clipboard<T: Send + 'static>(
+1
Переглянути файл
@@ -166,6 +166,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
fs::fs_read_text,
clipboard::clipboard_file_list,
clipboard::clipboard_has_files,
clipboard::clipboard_image,
clipboard::clipboard_set_file_list,
fonts::system_font_families,
doc::doc_open,
+6
Переглянути файл
@@ -227,6 +227,12 @@ export const commands = {
clipboardFileList: () => typedError<string[], IdeError>(__TAURI_INVOKE("clipboard_file_list")),
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
clipboardHasFiles: () => __TAURI_INVOKE<boolean>("clipboard_has_files"),
/**
* The clipboard's image as a PNG data URL, for a paste whose event carried no
* files: WebKitGTK leaves `clipboardData` empty for a raw image (a screenshot).
* None when the clipboard holds no image, and always on other platforms.
*/
clipboardImage: () => typedError<string | null, IdeError>(__TAURI_INVOKE("clipboard_image")),
/**
* Replaces the clipboard with `paths`, so Explorer can paste them. Best-effort:
* false when the clipboard stayed busy; the caller keeps working either way.
+17 -5
Переглянути файл
@@ -300,13 +300,15 @@ export function Composer(props: ComposerProps) {
[id],
);
const fitsLimit = (name: string, size: number) => {
if (size <= MAX_IMAGE_BYTES) return true;
notifications().notify({ level: "warning", title: "Image too large", body: tx`${name} is over 5 MB.` });
return false;
};
const readImageFiles = (picked: Iterable<File>) => {
for (const file of picked) {
if (!file.type.startsWith("image/")) continue;
if (file.size > MAX_IMAGE_BYTES) {
notifications().notify({ level: "warning", title: "Image too large", body: tx`${file.name} is over 5 MB.` });
continue;
}
if (!file.type.startsWith("image/") || !fitsLimit(file.name, file.size)) continue;
const reader = new FileReader();
reader.onload = () => {
const image = typeof reader.result === "string" ? imageFromDataUrl(reader.result, file.name || "image") : null;
@@ -316,6 +318,12 @@ export function Composer(props: ComposerProps) {
}
};
const pasteClipboardImage = async () => {
const url = await call(commands.clipboardImage());
const image = url ? imageFromDataUrl(url, "image") : null;
if (image && fitsLimit("image", (image.data.length * 3) / 4)) addImages([image]);
};
// Files dropped from the OS onto the box: images are attached, other files referenced.
useEffect(() => {
if (!isTauri()) return;
@@ -571,6 +579,10 @@ export function Composer(props: ComposerProps) {
if (pasted.length) {
e.preventDefault();
readImageFiles(pasted);
} else if (isTauri() && !e.clipboardData.types.length) {
pasteClipboardImage().catch((error: unknown) =>
notifications().notify({ level: "error", title: tx("Cannot insert image"), body: String(error) }),
);
}
}}
/>
+20 -1
Переглянути файл
@@ -7,6 +7,7 @@ const agentCommands = vi.fn();
const agentCreate = vi.fn();
const vcsBranches = vi.fn();
const vcsBranchInfo = vi.fn();
const clipboardImage = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
@@ -17,6 +18,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
...real.commands,
agentCommands: (provider: string, task: string | null) => agentCommands(provider, task),
agentCreate: (request: unknown) => agentCreate(request),
clipboardImage: () => clipboardImage(),
vcsBranchInfo: (root: string | null) => vcsBranchInfo(root),
vcsBranches: (root: string | null) => vcsBranches(root),
},
@@ -33,7 +35,7 @@ import { defaultTaskProject } from "./project";
import { HomeEditor } from "./HomeEditor";
import composerStyles from "./Composer.module.css";
import tagStyles from "@/ui/InlineTag.module.css";
import { HOME, updateComposer, useAgents } from "./store";
import { composerDraft, HOME, updateComposer, useAgents } from "./store";
import { emptyDraft } from "./composerModel";
const command = (name: string, description: string, source: "command" | "skill" = "command"): AgentCommand => ({
@@ -72,6 +74,7 @@ beforeEach(() => {
Promise.resolve({ status: "ok", data: { head: root === "/project-b" ? "b-main" : "a-main" } }),
);
agentCommands.mockReset();
clipboardImage.mockReset().mockResolvedValue({ status: "ok", data: "data:image/png;base64,iVBORw0KGgo=" });
agentCreate.mockReset().mockResolvedValue({ status: "error", error: { kind: "invalid", message: "stop here" } });
useAgents.setState({
providers: [claude],
@@ -227,6 +230,22 @@ describe("New Task composer commands", () => {
});
});
describe("New Task composer paste", () => {
it("attaches the clipboard's image when WebKitGTK pastes an empty clipboardData", async () => {
render(<HomeEditor />);
fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: [], files: [] } });
await waitFor(() =>
expect(composerDraft(HOME).images).toEqual([{ mimeType: "image/png", data: "iVBORw0KGgo=", name: "image" }]),
);
});
it("leaves a text paste to the text box", () => {
render(<HomeEditor />);
fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: ["text/plain"], files: [] } });
expect(clipboardImage).not.toHaveBeenCalled();
});
});
describe("workspace task project", () => {
const project = {
root: "/workspace",