diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx index 1d2590cbd..b969f436c 100644 --- a/src/features/agents/Composer.tsx +++ b/src/features/agents/Composer.tsx @@ -742,6 +742,8 @@ export function Composer(props: ComposerProps) { return true; } if (isTauri() && !data.types.length) { + // CodeMirror would otherwise paste empty text over the selection. + e.preventDefault(); pasteClipboardImage().catch((error: unknown) => notifications().notify({ level: "error", @@ -749,7 +751,7 @@ export function Composer(props: ComposerProps) { body: String(error), }), ); - return false; + return true; } const plain = data.getData("text/plain"); if (plain && pasteCopied(plain, view)) { diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx index b70583d88..eedb5503e 100644 --- a/src/features/agents/composerCommands.test.tsx +++ b/src/features/agents/composerCommands.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { sendKeyAction } from "./Composer"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { EditorView } from "@codemirror/view"; import type { AgentCommand, AgentProvider } from "@/bindings"; const agentCommands = vi.fn(); @@ -53,7 +54,7 @@ import type { ProjectInfo } from "@/bindings"; import { defaultTaskProject } from "./project"; import { HomeEditor } from "./HomeEditor"; import chipStyles from "./ReferenceChip.module.css"; -import { textOf, typeIn } from "./composerTesting"; +import { setCaret, textOf, typeIn } from "./composerTesting"; import { composerDraft, HOME, updateComposer, useAgents } from "./store"; import { emptyDraft } from "./composerModel"; @@ -280,19 +281,134 @@ describe("New Task composer paste", () => { it("attaches the clipboard's image when WebKitGTK pastes an empty clipboardData", async () => { render(); fireEvent.paste(screen.getByRole("textbox"), { - clipboardData: { types: [], files: [] }, + clipboardData: { types: [], files: [], getData: () => "" }, }); await waitFor(() => expect(composerDraft(HOME).images).toEqual([{ mimeType: "image/png", data: "iVBORw0KGgo=", name: "image" }]), ); + expect(textOf(screen.getByRole("textbox"))).toBe(""); + }); + + it.each([ + { name: "part of the prompt selected", text: "keep prompt", anchor: 0, head: 4 }, + { name: "the whole prompt selected", text: "keep prompt", anchor: 0, head: 11 }, + { name: "a backwards selection", text: "keep prompt", anchor: 4, head: 0 }, + { name: "a caret in the prompt", text: "keep prompt", anchor: 4, head: 4 }, + { name: "a multiline Unicode selection", text: "保留🙂\nprompt", anchor: 0, head: 5 }, + ])("keeps text and selection with $name when pasting a native image", async ({ text, anchor, head }) => { + render(); + const box = screen.getByRole("textbox"); + typeIn(box, text); + setCaret(box, anchor, head); + const allowed = fireEvent.paste(box, { + clipboardData: { types: [], files: [], getData: () => "" }, + }); + expect(textOf(box)).toBe(text); + expect(allowed).toBe(false); + await waitFor(() => expect(composerDraft(HOME).images).toHaveLength(1)); + expect(clipboardImage).toHaveBeenCalledTimes(1); + expect(composerDraft(HOME).text).toBe(text); + expect(EditorView.findFromDOM(box)!.state.selection.main).toMatchObject({ anchor, head }); + }); + + it.each([ + { name: "no image", result: { status: "ok", data: null } }, + { name: "an invalid image", result: { status: "ok", data: "not an image" } }, + { name: "an error", result: { status: "error", error: { kind: "internal", message: "clipboard busy" } } }, + ])("keeps selected text when the native clipboard returns $name", async ({ result }) => { + clipboardImage.mockResolvedValueOnce(result); + render(); + const box = screen.getByRole("textbox"); + typeIn(box, "keep prompt"); + setCaret(box, 0, 4); + await act(async () => { + fireEvent.paste(box, { clipboardData: { types: [], files: [], getData: () => "" } }); + }); + expect(clipboardImage).toHaveBeenCalledTimes(1); + expect(textOf(box)).toBe("keep prompt"); + expect(composerDraft(HOME)).toMatchObject({ text: "keep prompt", images: [] }); + expect(EditorView.findFromDOM(box)!.state.selection.main).toMatchObject({ anchor: 0, head: 4 }); + }); + + it("keeps selected text when the native clipboard request rejects", async () => { + clipboardImage.mockRejectedValueOnce(new Error("clipboard unavailable")); + render(); + const box = screen.getByRole("textbox"); + typeIn(box, "keep prompt"); + setCaret(box, 0, 4); + await act(async () => { + fireEvent.paste(box, { clipboardData: { types: [], files: [], getData: () => "" } }); + }); + expect(textOf(box)).toBe("keep prompt"); + expect(composerDraft(HOME)).toMatchObject({ text: "keep prompt", images: [] }); + }); + + it("keeps edits made while the native image is being read", async () => { + let finish!: (result: { status: "ok"; data: string }) => void; + clipboardImage.mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); + render(); + const box = screen.getByRole("textbox"); + typeIn(box, "keep prompt"); + setCaret(box, 0, 4); + fireEvent.paste(box, { clipboardData: { types: [], files: [], getData: () => "" } }); + expect(textOf(box)).toBe("keep prompt"); + typeIn(box, "edited while reading"); + await act(async () => finish({ status: "ok", data: "data:image/png;base64,iVBORw0KGgo=" })); + expect(textOf(box)).toBe("edited while reading"); + expect(composerDraft(HOME)).toMatchObject({ text: "edited while reading", images: [expect.anything()] }); + }); + + it("attaches an image file without replacing selected text or reading the native clipboard", async () => { + render(); + const box = screen.getByRole("textbox"); + typeIn(box, "keep prompt"); + setCaret(box, 0, 4); + fireEvent.paste(box, { + clipboardData: { + types: ["Files", "text/plain"], + files: [new File(["image"], "shot.png", { type: "image/png" })], + getData: () => "image caption", + }, + }); + await waitFor(() => expect(composerDraft(HOME).images).toHaveLength(1)); + expect(composerDraft(HOME).images[0]).toMatchObject({ name: "shot.png", mimeType: "image/png" }); + expect(textOf(box)).toBe("keep prompt"); + expect(clipboardImage).not.toHaveBeenCalled(); }); it("leaves a text paste to the text box", () => { render(); - fireEvent.paste(screen.getByRole("textbox"), { - clipboardData: { types: ["text/plain"], files: [] }, + const box = screen.getByRole("textbox"); + const text = "pasted text"; + fireEvent.paste(box, { + clipboardData: { + types: ["text/plain"], + files: [], + getData: (type: string) => (type === "text/plain" ? text : ""), + }, }); expect(clipboardImage).not.toHaveBeenCalled(); + expect(textOf(box)).toBe(text); + expect(composerDraft(HOME).text).toBe(text); + }); + + it.each([ + { name: "plain text", types: ["text/plain"], format: "text/plain", text: "new", native: true }, + { name: "rich text", types: ["text/html", "text/plain"], format: "text/plain", text: "新的🙂", native: true }, + { name: "a URI", types: ["text/uri-list"], format: "text/uri-list", text: "https://example.com", native: true }, + { name: "browser text", types: ["text/plain"], format: "text/plain", text: "new", native: false }, + ])("still replaces selected text when pasting $name", ({ types, format, text, native }) => { + vi.mocked(isTauri).mockReturnValue(native); + render(); + const box = screen.getByRole("textbox"); + typeIn(box, "keep old prompt"); + setCaret(box, 5, 8); + fireEvent.paste(box, { + clipboardData: { types, files: [], getData: (type: string) => (type === format ? text : "") }, + }); + expect(textOf(box)).toBe(`keep ${text} prompt`); + expect(composerDraft(HOME)).toMatchObject({ text: `keep ${text} prompt`, images: [] }); + expect(clipboardImage).not.toHaveBeenCalled(); }); });