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,9 +73,13 @@ const ASSISTANT_MARKDOWN = [
].join("\n"); ].join("\n");
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace( const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
"<https://autolink.example.com>", `${CROSS_STRUCTURE_MARKDOWN}\n\n`,
"[https://autolink.example.com](https://autolink.example.com)", "",
) )
.replace(
"<https://autolink.example.com>",
"[https://autolink.example.com](https://autolink.example.com)",
)
.replace( .replace(
"3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine", "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", "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<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);
} }