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.
This commit is contained in:
Mohamed Boudra
2026-08-07 10:43:44 +02:00
parent 84b735fbea
commit 75a5065bb3
3 changed files with 51 additions and 26 deletions
@@ -118,11 +118,18 @@ async function selectAssistantText(page: Page, text: string): Promise<void> {
await selectAssistantTextRange(page, text, text); await selectAssistantTextRange(page, text, text);
} }
async function doubleClickAssistantText(page: Page, text: string): Promise<void> { async function doubleClickAssistantMarkdownText(
page: Page,
tag: "code" | "strong",
text: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({ const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:", 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( async function selectAssistantTextRange(
@@ -333,13 +340,20 @@ test("copying an assistant selection preserves Markdown structure and links", as
expect(clipboard.html).toContain('<th style="text-align:right">Right</th>'); expect(clipboard.html).toContain('<th style="text-align:right">Right</th>');
expect(clipboard.html).toContain('<th style="text-align:center">Center</th>'); expect(clipboard.html).toContain('<th style="text-align:center">Center</th>');
await doubleClickAssistantText(page, "apply_patch"); await doubleClickAssistantMarkdownText(page, "code", "apply_patch");
await copySelection(page); await copySelection(page);
const doubleClickedCodeClipboard = await readRichClipboard(page); const doubleClickedCodeClipboard = await readRichClipboard(page);
expect(doubleClickedCodeClipboard.plainText).toBe("apply_patch"); expect(doubleClickedCodeClipboard.plainText).toBe("apply_patch");
expect(doubleClickedCodeClipboard.html).not.toContain("<code>"); expect(doubleClickedCodeClipboard.html).not.toContain("<code>");
await doubleClickAssistantMarkdownText(page, "strong", "strong prose");
await copySelection(page);
const doubleClickedBoldClipboard = await readRichClipboard(page);
expect(doubleClickedBoldClipboard.plainText).toBe("strong");
expect(doubleClickedBoldClipboard.html).not.toContain("<strong>");
await selectAssistantTextRange( await selectAssistantTextRange(
page, page,
"P1 — High-value UI behavior", "P1 — High-value UI behavior",
@@ -395,30 +409,21 @@ test("copying an assistant selection preserves Markdown structure and links", as
await copySelection(page); await copySelection(page);
const titledLinkClipboard = await readRichClipboard(page); const titledLinkClipboard = await readRichClipboard(page);
expect(titledLinkClipboard.plainText).toBe('[docs](https://docs.example.com "Reference")'); expect(titledLinkClipboard.plainText).toBe("docs");
expect(titledLinkClipboard.html).toContain( expect(titledLinkClipboard.html).not.toContain("<a ");
'<a href="https://docs.example.com/" title="Reference">docs</a>',
);
await selectAssistantText(page, "https://autolink.example.com"); await selectAssistantText(page, "https://autolink.example.com");
await copySelection(page); await copySelection(page);
const autolinkClipboard = await readRichClipboard(page); const autolinkClipboard = await readRichClipboard(page);
expect(autolinkClipboard.plainText).toBe( expect(autolinkClipboard.plainText).toBe("https://autolink.example.com");
"[https://autolink.example.com](https://autolink.example.com)",
);
expect(autolinkClipboard.html).toContain(
'<a href="https://autolink.example.com/">https://autolink.example.com</a>',
);
await selectAssistantText(page, "workspace file"); await selectAssistantText(page, "workspace file");
await copySelection(page); await copySelection(page);
const fileLinkClipboard = await readRichClipboard(page); const fileLinkClipboard = await readRichClipboard(page);
expect(fileLinkClipboard.plainText).toBe("[workspace file](file:///tmp/paseo%20notes.md#L4)"); expect(fileLinkClipboard.plainText).toBe("workspace file");
expect(fileLinkClipboard.html).toContain( expect(fileLinkClipboard.html).not.toContain("<a ");
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
);
await selectAssistantTextRange(page, "Seventh item", "Eighth item"); await selectAssistantTextRange(page, "Seventh item", "Eighth item");
await copySelection(page); await copySelection(page);
@@ -155,15 +155,19 @@ describe("assistant selection copy ranges", () => {
}, },
); );
it("retains strong delimiters when its complete contents are selected", () => { it.each(["strong", "em", "s", "h2", "blockquote"])(
const message = mountFixture(); "copies all content selected from inside a %s element without its syntax",
const element = fixtureElement(message, '[data-paseo-markdown-tag="strong"]'); (tag) => {
const content = createAssistantSelectionClipboardContent( const message = mountFixture();
selectText(element, 0, textNode(element).length), const element = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
); element.setAttribute("data-paseo-markdown-tag", tag);
expect(content?.plainText).toBe("**bold text**"); const content = createAssistantSelectionClipboardContent(
expect(content?.html).toContain("<strong>"); 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", () => { it("copies complete inline code without delimiters when the selection stays inside", () => {
const message = mountFixture(); const message = mountFixture();
@@ -278,6 +278,13 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
if (tag === "p" || isTableStructure(tag)) { if (tag === "p" || isTableStructure(tag)) {
return true; 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") { if (tag === "ol" || tag === "ul") {
const selectedItems = selectedListItems(element, range); const selectedItems = selectedListItems(element, range);
return selectedItems.some((item) => hasSelectedAllContents(range, item, true)); 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"); 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[] { function selectedListItems(list: Element, range: Range): Element[] {
return Array.from(list.children).filter( return Array.from(list.children).filter(
(child) => (child) =>