From 2291f54dc59c259d180cafbf023f00afbeecea2f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:15:59 +0200 Subject: [PATCH] fix(app): paste copied text and its image together Pasting from Word put only an image of the text in the composer, because the paste handler cancelled the paste whenever the clipboard held an image. Let the text paste normally when the clipboard has text, and still attach the images, so content copied with both (Word, or the desktop browser's element screenshot) keeps both. Fixes #3851 Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com> --- .../e2e/browser/composer-attachments.spec.ts | 50 +++++++++++-------- packages/app/e2e/support/helpers/composer.ts | 26 +++++++++- packages/app/src/composer/input/input.tsx | 5 +- 3 files changed, 56 insertions(+), 25 deletions(-) diff --git a/packages/app/e2e/browser/composer-attachments.spec.ts b/packages/app/e2e/browser/composer-attachments.spec.ts index 7d24ebc0ce..8585304373 100644 --- a/packages/app/e2e/browser/composer-attachments.spec.ts +++ b/packages/app/e2e/browser/composer-attachments.spec.ts @@ -4,7 +4,6 @@ import { expectComposerVisible } from "../support/helpers/composer"; import { expectAgentIdle } from "../support/helpers/agent-stream"; import { openAttachmentMenu, - composerLocator, expectAttachmentSheetRowsOnTitleRail, openGithubPickerFromMenu, attachImageFromMenu, @@ -19,6 +18,7 @@ import { expectAttachButtonDisabled, fillComposerDraft, dropFileOnComposer, + pasteClipboardIntoComposer, sendDraftToQueue, expectQueuedMessageButton, startRunningMockAgent, @@ -54,35 +54,41 @@ const TEST_JSON = { }; test.describe("Composer attachments", () => { - test("pastes copied document text instead of its image representation", async ({ + test("pasting copied document text keeps the text and attaches its image", async ({ page, context, withWorkspace, }) => { - const workspace = await withWorkspace({ prefix: "paste-document-text-" }); + const workspace = await withWorkspace({ prefix: "paste-document-" }); await workspace.navigateTo(); await clickNewChat(page); await expectComposerVisible(page); - await context.grantPermissions(["clipboard-read", "clipboard-write"]); - const text = "Document text with 한글 and two lines.\nSecond line."; - await page.evaluate( - async ({ content, png }) => { - const response = await fetch(`data:image/png;base64,${png}`); - const image = await response.blob(); - await navigator.clipboard.write([ - new ClipboardItem({ - "text/plain": new Blob([content], { type: "text/plain" }), - "text/html": new Blob([`

${content}

`], { type: "text/html" }), - "image/png": image, - }), - ]); - }, - { content: text, png: MINIMAL_PNG.toString("base64") }, - ); - await composerLocator(page).click(); - await page.keyboard.press("ControlOrMeta+v"); + const text = "Copied paragraph with 한글.\nSecond line."; + + await pasteClipboardIntoComposer(page, context, { + text, + html: "

Copied paragraph with 한글.

Second line.

", + png: MINIMAL_PNG, + }); + await expectComposerDraft(page, text); - await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + }); + + test("pasting an image without text attaches it and leaves the draft empty", async ({ + page, + context, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "paste-image-" }); + await workspace.navigateTo(); + await clickNewChat(page); + await expectComposerVisible(page); + + await pasteClipboardIntoComposer(page, context, { png: MINIMAL_PNG }); + + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await expectComposerDraft(page, ""); }); test("selected file shows a loading attachment until upload is acknowledged", async ({ diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index 6e78e0c66b..215be456c0 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -1,4 +1,4 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type BrowserContext, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { createTempGitRepo } from "./workspace"; import { connectSeedClient, type SeedDaemonClient } from "./seed-client"; @@ -172,6 +172,30 @@ export async function dropFileOnComposer( await dataTransfer.dispose(); } +/** Puts the given formats on the system clipboard together, then pastes them into the composer with the keyboard shortcut. */ +export async function pasteClipboardIntoComposer( + page: Page, + context: BrowserContext, + clipboard: { text?: string; html?: string; png?: Buffer }, +): Promise { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await page.evaluate( + async ({ text, html, pngBase64 }) => { + const formats: Record = {}; + if (text !== undefined) formats["text/plain"] = new Blob([text], { type: "text/plain" }); + if (html !== undefined) formats["text/html"] = new Blob([html], { type: "text/html" }); + if (pngBase64 !== undefined) { + const bytes = Uint8Array.from(atob(pngBase64), (char) => char.charCodeAt(0)); + formats["image/png"] = new Blob([bytes], { type: "image/png" }); + } + await navigator.clipboard.write([new ClipboardItem(formats)]); + }, + { text: clipboard.text, html: clipboard.html, pngBase64: clipboard.png?.toString("base64") }, + ); + await composerInput(page).click(); + await page.keyboard.press("ControlOrMeta+v"); +} + /** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */ export async function removeAttachmentPill( page: Page, diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index af4c4c5b74..f268b4dbd7 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -471,12 +471,13 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void { let disposed = false; const handlePaste = (event: ClipboardEvent) => { if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return; - if (event.clipboardData?.getData("text/plain")) return; const imageFiles = collectImageFilesFromClipboardData(event.clipboardData); if (imageFiles.length === 0) return; - event.preventDefault(); + // Copied text often carries an image of itself, as from Word. Let the + // text paste normally and attach the images alongside it. + if (!event.clipboardData?.getData("text/plain")) event.preventDefault(); void filesToImageAttachments(imageFiles) .then((pastedAttachments) => {