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);
+}