From 474a2671a2fb9e2c97b7211efaf5caeb7f437192 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:16:18 +0000 Subject: [PATCH] fix(app): keep pasted links undoable while drafting offline Use the browser edit command for converted paste so selection replacement participates in Undo and normal input publication. Keep complete Markdown clipboard text when it already carries the link destinations, and limit connection gating to image uploads. --- .../app/e2e/browser/composer-focus.spec.ts | 31 ++++++++++++ packages/app/e2e/support/helpers/composer.ts | 7 +++ packages/app/src/composer/input/input.tsx | 41 ++-------------- .../src/composer/pasted-text.browser.test.ts | 47 +++++++++++++++---- packages/app/src/composer/pasted-text.ts | 7 +-- packages/app/src/composer/pasted-text.web.ts | 26 +++++++--- 6 files changed, 105 insertions(+), 54 deletions(-) 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); }