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.
This commit is contained in:
Mohamed Boudra
2026-08-07 10:25:43 +02:00
parent 64058722e1
commit 84b735fbea
2 changed files with 67 additions and 17 deletions
@@ -2,7 +2,22 @@ import type { BrowserContext } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures"; import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; 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 = [ const ASSISTANT_MARKDOWN = [
CROSS_STRUCTURE_MARKDOWN,
"",
"Direct matches:", "Direct matches:",
"", "",
"Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.", "Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.",
@@ -58,6 +73,10 @@ const ASSISTANT_MARKDOWN = [
].join("\n"); ].join("\n");
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace( const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
`${CROSS_STRUCTURE_MARKDOWN}\n\n`,
"",
)
.replace(
"<https://autolink.example.com>", "<https://autolink.example.com>",
"[https://autolink.example.com](https://autolink.example.com)", "[https://autolink.example.com](https://autolink.example.com)",
) )
@@ -78,18 +97,8 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
await context.grantPermissions(["clipboard-read", "clipboard-write"]); await context.grantPermissions(["clipboard-read", "clipboard-write"]);
} }
async function selectAssistantMessage(page: Page): Promise<void> { async function selectStructuredClipboardFixture(page: Page): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({ await selectAssistantTextRange(page, "Direct matches:", "ready");
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 selectAssistantElement(page: Page, selector: string): Promise<void> { async function selectAssistantElement(page: Page, selector: string): Promise<void> {
@@ -109,6 +118,13 @@ 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> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.getByText(text, { exact: true }).dblclick();
}
async function selectAssistantTextRange( async function selectAssistantTextRange(
page: Page, page: Page,
startText: string, startText: string,
@@ -275,7 +291,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
.filter({ hasText: "apply_patch" }); .filter({ hasText: "apply_patch" });
await expect(inlineCode).toHaveAttribute("data-pmono", ""); await expect(inlineCode).toHaveAttribute("data-pmono", "");
await selectAssistantMessage(page); await selectStructuredClipboardFixture(page);
await copySelection(page); await copySelection(page);
const clipboard = await readRichClipboard(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('<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 copySelection(page);
const doubleClickedCodeClipboard = await readRichClipboard(page);
expect(doubleClickedCodeClipboard.plainText).toBe("apply_patch");
expect(doubleClickedCodeClipboard.html).not.toContain("<code>");
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(
/<h1>P1 — High-value UI behavior<\/h1>\s*<div>5\. Claude model and thinking preferences\s*<\/div><div>- Switch Claude models/,
);
await selectAssistantText(page, "First"); await selectAssistantText(page, "First");
await copySelection(page); await copySelection(page);
@@ -162,9 +162,12 @@ function flattenClipboardListMarkup(html: string): string {
items.forEach((item, index) => { items.forEach((item, index) => {
const line = document.createElement("div"); const line = document.createElement("div");
line.append(start === null ? "- " : `${start + index}. `); line.append(start === null ? "- " : `${start + index}. `);
const nestedLines: HTMLDivElement[] = [];
let hasParagraph = false; let hasParagraph = false;
for (const child of Array.from(item.childNodes)) { 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) { if (hasParagraph) {
line.append(document.createElement("br"), document.createElement("br")); line.append(document.createElement("br"), document.createElement("br"));
} }
@@ -174,7 +177,7 @@ function flattenClipboardListMarkup(html: string): string {
line.append(child); line.append(child);
} }
} }
replacement.append(line); replacement.append(line, ...nestedLines);
}); });
list.replaceWith(replacement); list.replaceWith(replacement);
} }