From 84b735fbeadd7e723108dd5c9b1d746174d80579 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 7 Aug 2026 10:25:27 +0200 Subject: [PATCH] fix(app): preserve nested list clipboard lines Flattened child list lines were reparented inside the parent item line, so rich paste targets split markers from their content. Keep every flattened line as a sibling and cover cross-structure selection plus inline-code double-click copying. --- .../browser/assistant-selection-copy.spec.ts | 77 +++++++++++++++---- .../assistant-selection-copy/content.web.ts | 7 +- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/packages/app/e2e/browser/assistant-selection-copy.spec.ts b/packages/app/e2e/browser/assistant-selection-copy.spec.ts index bcf90ae23..ad05e2af7 100644 --- a/packages/app/e2e/browser/assistant-selection-copy.spec.ts +++ b/packages/app/e2e/browser/assistant-selection-copy.spec.ts @@ -2,7 +2,22 @@ import type { BrowserContext } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +const CROSS_STRUCTURE_MARKDOWN = [ + "# P1 — High-value UI behavior", + "", + "5. Claude model and thinking preferences", + "", + " - Switch Claude models and confirm each model retains its own thinking choice.", + " - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.", + " - Confirm Opus 5 exposes the Fast toggle.", + " - Smoke with an older saved Fabel/model alias and with daemon/client version drift.", + "", + "6. Chat, composer, and timeline stability", +].join("\n"); + const ASSISTANT_MARKDOWN = [ + CROSS_STRUCTURE_MARKDOWN, + "", "Direct matches:", "", "Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.", @@ -58,9 +73,13 @@ const ASSISTANT_MARKDOWN = [ ].join("\n"); const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace( - "", - "[https://autolink.example.com](https://autolink.example.com)", + `${CROSS_STRUCTURE_MARKDOWN}\n\n`, + "", ) + .replace( + "", + "[https://autolink.example.com](https://autolink.example.com)", + ) .replace( "3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine", "3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine", @@ -78,18 +97,8 @@ async function allowRichClipboard(context: BrowserContext): Promise { await context.grantPermissions(["clipboard-read", "clipboard-write"]); } -async function selectAssistantMessage(page: Page): Promise { - const assistantMessage = page.getByTestId("assistant-message").filter({ - hasText: "Direct matches:", - }); - await expect(assistantMessage).toBeVisible(); - await assistantMessage.evaluate((element) => { - const selection = window.getSelection(); - const range = document.createRange(); - range.selectNodeContents(element); - selection?.removeAllRanges(); - selection?.addRange(range); - }); +async function selectStructuredClipboardFixture(page: Page): Promise { + await selectAssistantTextRange(page, "Direct matches:", "ready"); } async function selectAssistantElement(page: Page, selector: string): Promise { @@ -109,6 +118,13 @@ async function selectAssistantText(page: Page, text: string): Promise { await selectAssistantTextRange(page, text, text); } +async function doubleClickAssistantText(page: Page, text: string): Promise { + const assistantMessage = page.getByTestId("assistant-message").filter({ + hasText: "Direct matches:", + }); + await assistantMessage.getByText(text, { exact: true }).dblclick(); +} + async function selectAssistantTextRange( page: Page, startText: string, @@ -275,7 +291,7 @@ test("copying an assistant selection preserves Markdown structure and links", as .filter({ hasText: "apply_patch" }); await expect(inlineCode).toHaveAttribute("data-pmono", ""); - await selectAssistantMessage(page); + await selectStructuredClipboardFixture(page); await copySelection(page); const clipboard = await readRichClipboard(page); @@ -317,6 +333,37 @@ 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 copySelection(page); + + const doubleClickedCodeClipboard = await readRichClipboard(page); + expect(doubleClickedCodeClipboard.plainText).toBe("apply_patch"); + expect(doubleClickedCodeClipboard.html).not.toContain(""); + + await selectAssistantTextRange( + page, + "P1 — High-value UI behavior", + "Smoke with an older saved Fabel/model alias and with daemon/client version drift.", + ); + await copySelection(page); + + const headingAndListClipboard = await readRichClipboard(page); + expect(headingAndListClipboard.plainText).toBe( + [ + "P1 — High-value UI behavior", + "===========================", + "", + "5. Claude model and thinking preferences", + " - Switch Claude models and confirm each model retains its own thinking choice.", + " - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.", + " - Confirm Opus 5 exposes the Fast toggle.", + " - Smoke with an older saved Fabel/model alias and with daemon/client version drift.", + ].join("\n"), + ); + expect(headingAndListClipboard.html).toMatch( + /

P1 — High-value UI behavior<\/h1>\s*
5\. Claude model and thinking preferences\s*<\/div>
- Switch Claude models/, + ); + await selectAssistantText(page, "First"); await copySelection(page); diff --git a/packages/app/src/assistant-selection-copy/content.web.ts b/packages/app/src/assistant-selection-copy/content.web.ts index bde50cf6c..ac157039d 100644 --- a/packages/app/src/assistant-selection-copy/content.web.ts +++ b/packages/app/src/assistant-selection-copy/content.web.ts @@ -162,9 +162,12 @@ function flattenClipboardListMarkup(html: string): string { items.forEach((item, index) => { const line = document.createElement("div"); line.append(start === null ? "- " : `${start + index}. `); + const nestedLines: HTMLDivElement[] = []; let hasParagraph = false; for (const child of Array.from(item.childNodes)) { - if (child instanceof HTMLParagraphElement) { + if (child instanceof HTMLDivElement) { + nestedLines.push(child); + } else if (child instanceof HTMLParagraphElement) { if (hasParagraph) { line.append(document.createElement("br"), document.createElement("br")); } @@ -174,7 +177,7 @@ function flattenClipboardListMarkup(html: string): string { line.append(child); } } - replacement.append(line); + replacement.append(line, ...nestedLines); }); list.replaceWith(replacement); }