diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index 8423afad95..86a9dd8a80 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -2,6 +2,8 @@ import { expect, test, type Page } from "../support/fixtures"; import { pasteRichTextIntoComposer, selectComposerDraftRange, + undoComposerEdit, + redoComposerEdit, blurComposerInput, roundTripComposerTabBeforePaint, composerLocator, @@ -30,6 +32,7 @@ import { openMobileAgentSidebarWithKeyboard, swipeOpenMobileExplorer, } from "../support/helpers/sidebar"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor"; import { expectTimelinePromptPositionPreserved, @@ -131,6 +134,7 @@ test("submitting a message leaves the composer ready for the next message", asyn title: "Composer focus", }); + const connection = await installDaemonWebSocketGate(page); try { await openAgentRoute(page, agent); await expectComposerVisible(page); @@ -149,11 +153,38 @@ test("submitting a message leaves the composer ready for the next message", asyn page, "Investigate: [this FR](https://example.com/issues/39150). Thanks", ); + await undoComposerEdit(page); + await expectComposerDraft(page, "Investigate: replace this. Thanks"); + await redoComposerEdit(page); + 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 connection.drop(); + await connection.waitForBlockedConnection(); + await pasteRichTextIntoComposer( + page, + 'offline link', + "offline link", + ); + await expectComposerDraft( + page, + "Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks", + ); + const copiedTable = + "| Resource | Status |\n| --- | --- |\n| [issue](https://example.com/1) | ~~closed~~ |"; + await composerLocator(page).fill(""); + await pasteRichTextIntoComposer( + page, + '
ResourceStatus
issueclosed
', + copiedTable, + ); + await expectComposerDraft(page, copiedTable); 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 e5ef12c500..c1989f5b4f 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -587,3 +587,10 @@ export async function pasteRichTextIntoComposer( ); await composerInput(page).press("ControlOrMeta+v"); } + +export async function undoComposerEdit(page: Page): Promise { + await composerInput(page).press("ControlOrMeta+z"); +} +export async function redoComposerEdit(page: Page): Promise { + await composerInput(page).press("ControlOrMeta+Shift+z"); +} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 4f4e97dece..fc82a73e6d 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -88,7 +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 { pasteLinkedClipboardText } from "@/composer/pasted-text"; import type { NativePastedFile } from "@/composer/native-pasted-image"; import { EditingTextInput, @@ -521,7 +521,6 @@ interface PasteEffectArgs { isDictating: boolean; isRealtimeVoiceForCurrentAgent: boolean; onAddImages: ((images: ImageAttachment[]) => void) | undefined; - onPasteText: (text: string) => void; } function usePasteEffect(args: PasteEffectArgs): void { @@ -532,7 +531,6 @@ function usePasteEffect(args: PasteEffectArgs): void { isDictating, isRealtimeVoiceForCurrentAgent, onAddImages, - onPasteText, } = args; useEffect(() => { @@ -542,17 +540,14 @@ function usePasteEffect(args: PasteEffectArgs): void { let disposed = false; const handlePaste = (event: ClipboardEvent) => { - if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return; + if (disabled || isDictating || isRealtimeVoiceForCurrentAgent) return; const imageFiles = collectImageFilesFromClipboardData(event.clipboardData); if (imageFiles.length === 0) { - const text = readLinkedClipboardText(event.clipboardData); - if (text === null) return; - event.preventDefault(); - onPasteText(text); + if (pasteLinkedClipboardText(textarea, event.clipboardData)) event.preventDefault(); return; } - if (!onAddImages) return; + if (!isConnected || !onAddImages) return; event.preventDefault(); @@ -572,15 +567,7 @@ function usePasteEffect(args: PasteEffectArgs): void { disposed = true; textarea.removeEventListener?.("paste", handlePaste); }; - }, [ - disabled, - textarea, - isConnected, - isDictating, - isRealtimeVoiceForCurrentAgent, - onAddImages, - onPasteText, - ]); + }, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]); } function useAutoFocusOnWebEffect( @@ -1829,23 +1816,6 @@ export const MessageInput = forwardRef( handleAlternateSendAction(); }, [alternateSendAction, handleAlternateSendAction]); - 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, @@ -1853,7 +1823,6 @@ export const MessageInput = forwardRef( 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 index e9ed6580cf..b307cec1ff 100644 --- a/packages/app/src/composer/pasted-text.browser.test.ts +++ b/packages/app/src/composer/pasted-text.browser.test.ts @@ -1,5 +1,17 @@ -import { describe, expect, it } from "vitest"; -import { readLinkedClipboardText } from "./pasted-text.web"; +import { afterEach, describe, expect, it } from "vitest"; +import { createMarkdownClipboardContent } from "@/utils/rich-clipboard"; +import { pasteLinkedClipboardText } from "./pasted-text.web"; + +const inputs: HTMLTextAreaElement[] = []; +function editor(): HTMLTextAreaElement { + const input = document.createElement("textarea"); + document.body.append(input); + inputs.push(input); + return input; +} +afterEach(() => { + for (const input of inputs.splice(0)) input.remove(); +}); function clipboard(html: string, text: string): DataTransfer { const data = new DataTransfer(); @@ -9,30 +21,49 @@ function clipboard(html: string, text: string): DataTransfer { } describe("pasted prompt links", () => { + it("keeps complete Markdown from Copy without reconverting its linked table or strikethrough", () => { + const source = + "| Resource | Status |\n| --- | --- |\n| [issue](https://example.com/1) | ~~closed~~ |"; + const content = createMarkdownClipboardContent(source); + const input = editor(); + expect(pasteLinkedClipboardText(input, clipboard(content.html, content.plainText))).toBe(false); + expect(input.value).toBe(""); + }); 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( + const input = editor(); + expect(pasteLinkedClipboardText(input, data)).toBe(true); + expect(input.value).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(); + const input = editor(); + expect(pasteLinkedClipboardText(input, clipboard("", "[link](https://example.com)"))).toBe( + false, + ); + expect(pasteLinkedClipboardText(input, clipboard("hello", "hello"))).toBe(false); + expect(pasteLinkedClipboardText(input, null)).toBe(false); + expect(input.value).toBe(""); }); it("keeps paragraphs and multiple links", () => { + const input = editor(); expect( - readLinkedClipboardText( + pasteLinkedClipboardText( + input, clipboard( '

See one

and two

', "See one\n\nand two", ), ), - ).toBe("See [one](https://example.com/a)\n\nand [two](https://example.com/b)"); + ).toBe(true); + expect(input.value).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 index f25c1c3780..99e7b2cfb5 100644 --- a/packages/app/src/composer/pasted-text.ts +++ b/packages/app/src/composer/pasted-text.ts @@ -1,6 +1,7 @@ /** Native inputs retain their platform clipboard handling. */ -export function readLinkedClipboardText( +export function pasteLinkedClipboardText( + _input: HTMLElement, _clipboard: Pick | null, -): string | null { - return null; +): boolean { + return false; } diff --git a/packages/app/src/composer/pasted-text.web.ts b/packages/app/src/composer/pasted-text.web.ts index fdd05f347e..8ca0a27633 100644 --- a/packages/app/src/composer/pasted-text.web.ts +++ b/packages/app/src/composer/pasted-text.web.ts @@ -2,15 +2,27 @@ 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( +/** Paste linked HTML as Markdown through the browser's undoable editing command. */ +export function pasteLinkedClipboardText( + input: HTMLElement, clipboard: Pick | null, -): string | null { +): boolean { const html = clipboard?.getData("text/html"); - if (!html) return null; + if (!html) return false; // 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); + const fragment = new DOMParser().parseFromString(html, "text/html"); + const destinations = Array.from( + fragment.querySelectorAll("a[href]"), + (link) => link.getAttribute("href")!, + ); + const plainText = clipboard!.getData("text/plain"); + // Copy actions already put complete Markdown on the clipboard. Keep that + // representation, including tables and formatting, whenever it carries the URLs. + if (destinations.every((url) => plainText.includes(url))) return false; + const text = markdown.turndown(fragment.body); + input.focus(); + // Direct value assignment drops the textarea's undo history. This also emits + // the normal input event so the editor publishes its new draft as for typing. + return input.ownerDocument.execCommand("insertText", false, text); }