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