From 4cf91fa16d3fba697b49910a26fb58a9a5a2f3ec Mon Sep 17 00:00:00 2001
From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com>
Date: Fri, 6 Nov 2026 22:06:19 +0000
Subject: [PATCH] fix(app): preserve linked resources when pasting prompts
Rich clipboard links carried their destination only in HTML, while the composer let textarea default paste insert the plain label. Convert linked fragments to Markdown at ingestion and replace the selected draft range through the existing editor command.
---
.../app/e2e/browser/composer-focus.spec.ts | 21 +++++++-
packages/app/e2e/support/helpers/composer.ts | 21 ++++++++
packages/app/src/composer/input/input.tsx | 48 ++++++++++++++++---
.../src/composer/pasted-text.browser.test.ts | 38 +++++++++++++++
packages/app/src/composer/pasted-text.ts | 6 +++
packages/app/src/composer/pasted-text.web.ts | 16 +++++++
6 files changed, 142 insertions(+), 8 deletions(-)
create mode 100644 packages/app/src/composer/pasted-text.browser.test.ts
create mode 100644 packages/app/src/composer/pasted-text.ts
create mode 100644 packages/app/src/composer/pasted-text.web.ts
diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts
index 11fe0bf50f..8423afad95 100644
--- a/packages/app/e2e/browser/composer-focus.spec.ts
+++ b/packages/app/e2e/browser/composer-focus.spec.ts
@@ -1,5 +1,7 @@
import { expect, test, type Page } from "../support/fixtures";
import {
+ pasteRichTextIntoComposer,
+ selectComposerDraftRange,
blurComposerInput,
roundTripComposerTabBeforePaint,
composerLocator,
@@ -136,8 +138,23 @@ test("submitting a message leaves the composer ready for the next message", asyn
await submitMessage(page, "First message");
await expectComposerFocused(page);
- await typeIntoFocusedComposer(page, "Second message");
- await expectComposerDraft(page, "Second message");
+ await typeIntoFocusedComposer(page, "Investigate: replace this. Thanks");
+ await selectComposerDraftRange(page, 13, 25);
+ await pasteRichTextIntoComposer(
+ page,
+ 'this FR',
+ "this FR",
+ );
+ await expectComposerDraft(
+ page,
+ "Investigate: [this FR](https://example.com/issues/39150). Thanks",
+ );
+ await typeIntoFocusedComposer(page, " next");
+ await expectComposerDraft(
+ page,
+ "Investigate: [this FR](https://example.com/issues/39150) next. Thanks",
+ );
+ await expectComposerFocused(page);
} finally {
await agent.cleanup();
}
diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts
index bc0fef67f3..e5ef12c500 100644
--- a/packages/app/e2e/support/helpers/composer.ts
+++ b/packages/app/e2e/support/helpers/composer.ts
@@ -566,3 +566,24 @@ export async function roundTripComposerTabBeforePaint(
).toBe(true);
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
}
+
+/** Paste both clipboard representations through the browser's normal paste command. */
+export async function pasteRichTextIntoComposer(
+ page: Page,
+ html: string,
+ text: string,
+): Promise {
+ await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
+ await page.evaluate(
+ async ({ html: richHtml, text: plainText }) => {
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ "text/html": new Blob([richHtml], { type: "text/html" }),
+ "text/plain": new Blob([plainText], { type: "text/plain" }),
+ }),
+ ]);
+ },
+ { html, text },
+ );
+ await composerInput(page).press("ControlOrMeta+v");
+}
diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx
index 3c4541181f..4f4e97dece 100644
--- a/packages/app/src/composer/input/input.tsx
+++ b/packages/app/src/composer/input/input.tsx
@@ -88,6 +88,7 @@ import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeight } from "./height";
import { ComposerInputPresentation } from "./presentation";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
+import { readLinkedClipboardText } from "@/composer/pasted-text";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import {
EditingTextInput,
@@ -513,16 +514,17 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
return native instanceof HTMLElement ? native : null;
}
-interface PasteImagesEffectArgs {
+interface PasteEffectArgs {
textarea: HTMLElement | null;
isConnected: boolean;
disabled: boolean;
isDictating: boolean;
isRealtimeVoiceForCurrentAgent: boolean;
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
+ onPasteText: (text: string) => void;
}
-function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
+function usePasteEffect(args: PasteEffectArgs): void {
const {
textarea,
isConnected,
@@ -530,10 +532,11 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
isDictating,
isRealtimeVoiceForCurrentAgent,
onAddImages,
+ onPasteText,
} = args;
useEffect(() => {
- if (!isWeb || !onAddImages) return;
+ if (!isWeb) return;
if (!textarea) return;
@@ -542,7 +545,14 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
- if (imageFiles.length === 0) return;
+ if (imageFiles.length === 0) {
+ const text = readLinkedClipboardText(event.clipboardData);
+ if (text === null) return;
+ event.preventDefault();
+ onPasteText(text);
+ return;
+ }
+ if (!onAddImages) return;
event.preventDefault();
@@ -562,7 +572,15 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
disposed = true;
textarea.removeEventListener?.("paste", handlePaste);
};
- }, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]);
+ }, [
+ disabled,
+ textarea,
+ isConnected,
+ isDictating,
+ isRealtimeVoiceForCurrentAgent,
+ onAddImages,
+ onPasteText,
+ ]);
}
function useAutoFocusOnWebEffect(
@@ -1811,13 +1829,31 @@ export const MessageInput = forwardRef(
handleAlternateSendAction();
}, [alternateSendAction, handleAlternateSendAction]);
- usePasteImagesEffect({
+ const handlePasteText = useCallback(
+ (text: string) => {
+ const snapshot = getComposerInputSnapshot(
+ textInputRef.current,
+ valueRef.current,
+ selectionRef.current,
+ );
+ const nextText =
+ snapshot.text.slice(0, snapshot.selection.start) +
+ text +
+ snapshot.text.slice(snapshot.selection.end);
+ const cursor = snapshot.selection.start + text.length;
+ replaceText(nextText, { start: cursor, end: cursor });
+ },
+ [replaceText],
+ );
+
+ usePasteEffect({
textarea: webTextArea,
isConnected,
disabled,
isDictating,
isRealtimeVoiceForCurrentAgent,
onAddImages,
+ onPasteText: handlePasteText,
});
const handleSelectionChange = useCallback(
diff --git a/packages/app/src/composer/pasted-text.browser.test.ts b/packages/app/src/composer/pasted-text.browser.test.ts
new file mode 100644
index 0000000000..e9ed6580cf
--- /dev/null
+++ b/packages/app/src/composer/pasted-text.browser.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { readLinkedClipboardText } from "./pasted-text.web";
+
+function clipboard(html: string, text: string): DataTransfer {
+ const data = new DataTransfer();
+ data.setData("text/html", html);
+ data.setData("text/plain", text);
+ return data;
+}
+
+describe("pasted prompt links", () => {
+ it("preserves a labeled resource URL as Markdown", () => {
+ const data = clipboard(
+ 'Hey can you take a look at this FR and...',
+ "Hey can you take a look at this FR and...",
+ );
+ expect(readLinkedClipboardText(data)).toBe(
+ "Hey can you take a look at [this FR](https://example.com/issues/39150) and...",
+ );
+ });
+
+ it("leaves plain text and rich text without links to native paste", () => {
+ expect(readLinkedClipboardText(clipboard("", "[link](https://example.com)"))).toBeNull();
+ expect(readLinkedClipboardText(clipboard("hello", "hello"))).toBeNull();
+ expect(readLinkedClipboardText(null)).toBeNull();
+ });
+
+ it("keeps paragraphs and multiple links", () => {
+ expect(
+ readLinkedClipboardText(
+ clipboard(
+ 'See one
and two
',
+ "See one\n\nand two",
+ ),
+ ),
+ ).toBe("See [one](https://example.com/a)\n\nand [two](https://example.com/b)");
+ });
+});
diff --git a/packages/app/src/composer/pasted-text.ts b/packages/app/src/composer/pasted-text.ts
new file mode 100644
index 0000000000..f25c1c3780
--- /dev/null
+++ b/packages/app/src/composer/pasted-text.ts
@@ -0,0 +1,6 @@
+/** Native inputs retain their platform clipboard handling. */
+export function readLinkedClipboardText(
+ _clipboard: Pick | null,
+): string | null {
+ return null;
+}
diff --git a/packages/app/src/composer/pasted-text.web.ts b/packages/app/src/composer/pasted-text.web.ts
new file mode 100644
index 0000000000..fdd05f347e
--- /dev/null
+++ b/packages/app/src/composer/pasted-text.web.ts
@@ -0,0 +1,16 @@
+import TurndownService from "turndown";
+
+const markdown = new TurndownService({ codeBlockStyle: "fenced", bulletListMarker: "-" });
+
+/** Preserve destinations that the clipboard's plain-text representation cannot carry. */
+export function readLinkedClipboardText(
+ clipboard: Pick | null,
+): string | null {
+ const html = clipboard?.getData("text/html");
+ if (!html) return null;
+
+ // Parse in a detached document. Clipboard markup is never mounted in the app.
+ const document = new DOMParser().parseFromString(html, "text/html");
+ if (!document.querySelector("a[href]")) return null;
+ return markdown.turndown(document.body);
+}