From 75a5065bb3de77aa3444b26936eb188c0423689e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 7 Aug 2026 10:43:44 +0200 Subject: [PATCH] fix(app): apply clipboard syntax at selection boundaries Selections contained inside formatting copy only their visible content. Preserve Markdown syntax when the range includes or crosses the semantic element, while retaining list and table structure needed to serialize child boundaries. --- .../browser/assistant-selection-copy.spec.ts | 39 +++++++++++-------- .../content.browser.test.ts | 22 ++++++----- .../assistant-selection-copy/content.web.ts | 16 ++++++++ 3 files changed, 51 insertions(+), 26 deletions(-) diff --git a/packages/app/e2e/browser/assistant-selection-copy.spec.ts b/packages/app/e2e/browser/assistant-selection-copy.spec.ts index ad05e2af7..3c9440fdb 100644 --- a/packages/app/e2e/browser/assistant-selection-copy.spec.ts +++ b/packages/app/e2e/browser/assistant-selection-copy.spec.ts @@ -118,11 +118,18 @@ async function selectAssistantText(page: Page, text: string): Promise { await selectAssistantTextRange(page, text, text); } -async function doubleClickAssistantText(page: Page, text: string): Promise { +async function doubleClickAssistantMarkdownText( + page: Page, + tag: "code" | "strong", + text: string, +): Promise { const assistantMessage = page.getByTestId("assistant-message").filter({ hasText: "Direct matches:", }); - await assistantMessage.getByText(text, { exact: true }).dblclick(); + await assistantMessage + .locator(`[data-paseo-markdown-tag="${tag}"]`) + .filter({ hasText: text }) + .dblclick({ position: { x: 5, y: 5 } }); } async function selectAssistantTextRange( @@ -333,13 +340,20 @@ test("copying an assistant selection preserves Markdown structure and links", as expect(clipboard.html).toContain('Right'); expect(clipboard.html).toContain('Center'); - await doubleClickAssistantText(page, "apply_patch"); + await doubleClickAssistantMarkdownText(page, "code", "apply_patch"); await copySelection(page); const doubleClickedCodeClipboard = await readRichClipboard(page); expect(doubleClickedCodeClipboard.plainText).toBe("apply_patch"); expect(doubleClickedCodeClipboard.html).not.toContain(""); + await doubleClickAssistantMarkdownText(page, "strong", "strong prose"); + await copySelection(page); + + const doubleClickedBoldClipboard = await readRichClipboard(page); + expect(doubleClickedBoldClipboard.plainText).toBe("strong"); + expect(doubleClickedBoldClipboard.html).not.toContain(""); + await selectAssistantTextRange( page, "P1 — High-value UI behavior", @@ -395,30 +409,21 @@ test("copying an assistant selection preserves Markdown structure and links", as await copySelection(page); const titledLinkClipboard = await readRichClipboard(page); - expect(titledLinkClipboard.plainText).toBe('[docs](https://docs.example.com "Reference")'); - expect(titledLinkClipboard.html).toContain( - 'docs', - ); + expect(titledLinkClipboard.plainText).toBe("docs"); + expect(titledLinkClipboard.html).not.toContain("https://autolink.example.com', - ); + expect(autolinkClipboard.plainText).toBe("https://autolink.example.com"); await selectAssistantText(page, "workspace file"); await copySelection(page); const fileLinkClipboard = await readRichClipboard(page); - expect(fileLinkClipboard.plainText).toBe("[workspace file](file:///tmp/paseo%20notes.md#L4)"); - expect(fileLinkClipboard.html).toContain( - 'workspace file', - ); + expect(fileLinkClipboard.plainText).toBe("workspace file"); + expect(fileLinkClipboard.html).not.toContain(" { }, ); - it("retains strong delimiters when its complete contents are selected", () => { - const message = mountFixture(); - const element = fixtureElement(message, '[data-paseo-markdown-tag="strong"]'); - const content = createAssistantSelectionClipboardContent( - selectText(element, 0, textNode(element).length), - ); - expect(content?.plainText).toBe("**bold text**"); - expect(content?.html).toContain(""); - }); + it.each(["strong", "em", "s", "h2", "blockquote"])( + "copies all content selected from inside a %s element without its syntax", + (tag) => { + const message = mountFixture(); + const element = fixtureElement(message, '[data-paseo-markdown-tag="strong"]'); + element.setAttribute("data-paseo-markdown-tag", tag); + const content = createAssistantSelectionClipboardContent( + selectText(element, 0, textNode(element).length), + ); + expect(content?.plainText).toBe("bold text"); + expect(content?.html).not.toContain(`<${tag}>`); + }, + ); it("copies complete inline code without delimiters when the selection stays inside", () => { const message = mountFixture(); diff --git a/packages/app/src/assistant-selection-copy/content.web.ts b/packages/app/src/assistant-selection-copy/content.web.ts index ac157039d..6678e4a5b 100644 --- a/packages/app/src/assistant-selection-copy/content.web.ts +++ b/packages/app/src/assistant-selection-copy/content.web.ts @@ -278,6 +278,13 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean if (tag === "p" || isTableStructure(tag)) { return true; } + const isSelectableSemantic = tag !== null && tag !== "ol" && tag !== "ul"; + if ( + (isSelectableSemantic || element.tagName === "A") && + selectionStaysInsideElement(range, element) + ) { + return false; + } if (tag === "ol" || tag === "ul") { const selectedItems = selectedListItems(element, range); return selectedItems.some((item) => hasSelectedAllContents(range, item, true)); @@ -285,6 +292,15 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean return hasSelectedAllContents(range, element, tag === "li"); } +function selectionStaysInsideElement(range: Range, element: Element): boolean { + return ( + range.startContainer !== element && + range.endContainer !== element && + element.contains(range.startContainer) && + element.contains(range.endContainer) + ); +} + function selectedListItems(list: Element, range: Range): Element[] { return Array.from(list.children).filter( (child) =>