* fix(app): keep line breaks and indentation in copied code #2930 strips the pre/code tags from a partly selected code block, which is right for the Markdown syntax but leaves the code itself going through Turndown as prose. Turndown collapses whitespace, so two selected lines arrive as one, the indentation is gone, and Markdown-significant characters come back escaped. Divert a partial code selection before that: copy the selected text verbatim. Fully selected regions are untouched and still produce a fence, which is what hasSelectedAllContents was already deciding. The html half needs pre for anything multi-line -- HTML collapses newlines too, so a rich paste target would lose the lines the same way. A single line stays undecorated, matching the rule that a partial selection does not carry its container's formatting. Two trailing-newline fixes ride along, since both make a paste run code nobody asked to run. Newlines between rendered lines are their own text nodes, so a selection that overshoots the end of a line carries one; strip it. The block's Copy button was handed the raw fence body, which ends in a newline; hand it the rendered text instead. * fix(app): strip every trailing blank line from copied code The Copy button stripped one trailing newline, so a fence whose author left a blank line before the closing delimiter still put an executable newline on the clipboard. Pasting that into a terminal runs the last line, which is the thing the strip existed to prevent. Both copy paths now share one regex in markup.ts and remove every trailing break, plus any indentation that followed the last one. Reported by Greptile on #2935. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(app): infer code copy format from selection boundary --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
471 lines
17 KiB
TypeScript
471 lines
17 KiB
TypeScript
import type { BrowserContext } from "@playwright/test";
|
|
import { expect, test, type Page } from "../support/fixtures";
|
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
|
|
|
const ASSISTANT_MARKDOWN = [
|
|
"Direct matches:",
|
|
"",
|
|
"Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.",
|
|
"",
|
|
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
|
|
"- [Second issue](https://example.com/issues/2): repeated sandbox setup.",
|
|
"",
|
|
"5. Fifth item",
|
|
"6. Sixth item",
|
|
"7. Seventh item",
|
|
"8. Eighth item",
|
|
"",
|
|
"Nested list:",
|
|
"",
|
|
"3. Outer three",
|
|
"",
|
|
" 7. Inner seven",
|
|
" 8. Inner eight",
|
|
" 9. Inner nine",
|
|
"",
|
|
"A hard break ",
|
|
"stays hard.",
|
|
"",
|
|
"www.example.com stays plain Markdown text.",
|
|
"",
|
|
'[docs](https://docs.example.com "Reference") and <https://autolink.example.com>.',
|
|
"",
|
|
"[workspace file](file:///tmp/paseo%20notes.md#L4)",
|
|
"",
|
|
"`https://example.com/generated` stays code, not a generated link.",
|
|
"",
|
|
"```typescript",
|
|
'const answer = "yes";',
|
|
" return answer;",
|
|
"```",
|
|
"",
|
|
"After code.",
|
|
"",
|
|
"```bash",
|
|
"echo trailing",
|
|
"",
|
|
"```",
|
|
"",
|
|
"````text",
|
|
"before",
|
|
"```",
|
|
"after",
|
|
"````",
|
|
"",
|
|
"| Left | Right | Center |",
|
|
"| :-- | --: | :-: |",
|
|
"| Current \\| active | ~~obsolete~~ | ready |",
|
|
].join("\n");
|
|
|
|
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
|
|
"<https://autolink.example.com>",
|
|
"[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",
|
|
)
|
|
// Rendering drops the blank line before a closing fence, so copying cannot bring it
|
|
// back. The fence is here to cover a body that ends in more than one newline.
|
|
.replace("```bash\necho trailing\n\n```", "```bash\necho trailing\n```");
|
|
|
|
interface ClipboardContent {
|
|
html: string;
|
|
plainText: string;
|
|
}
|
|
|
|
async function allowRichClipboard(context: BrowserContext): Promise<void> {
|
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
}
|
|
|
|
async function selectAssistantMessage(page: Page): Promise<void> {
|
|
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 selectAssistantElement(page: Page, selector: string): Promise<void> {
|
|
const assistantMessage = page.getByTestId("assistant-message").filter({
|
|
hasText: "Direct matches:",
|
|
});
|
|
await assistantMessage.locator(selector).evaluate((element) => {
|
|
const selection = window.getSelection();
|
|
const range = document.createRange();
|
|
range.selectNodeContents(element);
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
});
|
|
}
|
|
|
|
async function selectAssistantText(page: Page, text: string): Promise<void> {
|
|
await selectAssistantTextRange(page, text, text);
|
|
}
|
|
|
|
async function selectAssistantTextRange(
|
|
page: Page,
|
|
startText: string,
|
|
endText: string,
|
|
): Promise<void> {
|
|
const assistantMessage = page.getByTestId("assistant-message").filter({
|
|
hasText: "Direct matches:",
|
|
});
|
|
await assistantMessage.evaluate(
|
|
(element, selectedRange) => {
|
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
let startNode: Node | null = null;
|
|
let startOffset = -1;
|
|
let endNode: Node | null = null;
|
|
let endOffset = -1;
|
|
let textNode = walker.nextNode();
|
|
while (textNode) {
|
|
if (!startNode) {
|
|
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
|
|
if (offset >= 0) {
|
|
startNode = textNode;
|
|
startOffset = offset;
|
|
}
|
|
}
|
|
if (startNode) {
|
|
const offset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
|
|
if (offset >= 0) {
|
|
endNode = textNode;
|
|
endOffset = offset + selectedRange.endText.length;
|
|
break;
|
|
}
|
|
}
|
|
textNode = walker.nextNode();
|
|
}
|
|
if (!startNode || !endNode) {
|
|
throw new Error(
|
|
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`,
|
|
);
|
|
}
|
|
const range = document.createRange();
|
|
range.setStart(startNode, startOffset);
|
|
range.setEnd(endNode, endOffset);
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
},
|
|
{ startText, endText },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Select from `startText` into the middle of the next rendered code line.
|
|
*
|
|
* Highlighted code splits a line across one text node per syntax token and puts the
|
|
* newline in a node of its own, so this is a partial selection that crosses a line
|
|
* break — the shape whose whitespace Turndown would collapse.
|
|
*/
|
|
async function selectAssistantAcrossCodeLines(
|
|
page: Page,
|
|
startText: string,
|
|
endText: string,
|
|
): Promise<void> {
|
|
const assistantMessage = page.getByTestId("assistant-message").filter({
|
|
hasText: "Direct matches:",
|
|
});
|
|
await assistantMessage.evaluate(
|
|
(element, selectedRange) => {
|
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
let startNode: Node | null = null;
|
|
let startOffset = -1;
|
|
let textNode = walker.nextNode();
|
|
while (textNode) {
|
|
if (!startNode) {
|
|
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
|
|
if (offset >= 0) {
|
|
startNode = textNode;
|
|
startOffset = offset;
|
|
}
|
|
textNode = walker.nextNode();
|
|
continue;
|
|
}
|
|
const endOffset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
|
|
if (endOffset >= 0) {
|
|
const range = document.createRange();
|
|
range.setStart(startNode, startOffset);
|
|
range.setEnd(textNode, endOffset + selectedRange.endText.length);
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
return;
|
|
}
|
|
textNode = walker.nextNode();
|
|
}
|
|
throw new Error(
|
|
`Could not find a code range: ${selectedRange.startText} — ${selectedRange.endText}`,
|
|
);
|
|
},
|
|
{ startText, endText },
|
|
);
|
|
}
|
|
|
|
async function copySelection(page: Page): Promise<void> {
|
|
await page.keyboard.press("ControlOrMeta+c");
|
|
}
|
|
|
|
/** The Copy button writes text/plain only, so the rich reader would throw on it. */
|
|
async function readPlainClipboard(page: Page): Promise<string> {
|
|
return page.evaluate(() => navigator.clipboard.readText());
|
|
}
|
|
|
|
async function readRichClipboard(page: Page): Promise<ClipboardContent> {
|
|
return page.evaluate(async () => {
|
|
const items = await navigator.clipboard.read();
|
|
const item = items[0];
|
|
if (!item) {
|
|
throw new Error("Expected clipboard content");
|
|
}
|
|
return {
|
|
html: await (await item.getType("text/html")).text(),
|
|
plainText: await (await item.getType("text/plain")).text(),
|
|
};
|
|
});
|
|
}
|
|
|
|
test("copying an assistant selection preserves Markdown structure and links", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "serif" }));
|
|
});
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "assistant-selection-copy-",
|
|
title: "Assistant selection copy",
|
|
initialPrompt: "Render the clipboard fixture.",
|
|
featureValues: { mockAssistantResponse: ASSISTANT_MARKDOWN },
|
|
});
|
|
|
|
try {
|
|
await allowRichClipboard(context);
|
|
await agent.client.waitForAgentUpsert(
|
|
agent.agentId,
|
|
(snapshot) => snapshot.status === "idle",
|
|
30_000,
|
|
);
|
|
await openAgentRoute(page, agent);
|
|
|
|
const assistantMessage = page.getByTestId("assistant-message").filter({
|
|
hasText: "Direct matches:",
|
|
});
|
|
for (const [tag, text] of [
|
|
["strong", "strong prose"],
|
|
["em", "emphasized prose"],
|
|
["s", "struck prose"],
|
|
]) {
|
|
const formattedProse = assistantMessage
|
|
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
|
.filter({ hasText: text });
|
|
await expect(formattedProse).toHaveCSS("font-family", "serif");
|
|
await expect(formattedProse).not.toHaveAttribute("data-pmono");
|
|
}
|
|
const inlineCode = assistantMessage
|
|
.locator('[data-paseo-markdown-tag="code"]')
|
|
.filter({ hasText: "apply_patch" });
|
|
await expect(inlineCode).toHaveAttribute("data-pmono", "");
|
|
|
|
await selectAssistantMessage(page);
|
|
await copySelection(page);
|
|
|
|
const clipboard = await readRichClipboard(page);
|
|
expect(clipboard.plainText).toBe(EXPECTED_WHOLE_SELECTION_MARKDOWN);
|
|
expect(clipboard.html).not.toContain("<ul>");
|
|
expect(clipboard.html).not.toContain("<li>");
|
|
expect(clipboard.html).toContain("<div>- ");
|
|
expect(clipboard.html).toContain(
|
|
'<strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
|
);
|
|
expect(clipboard.html).toContain("<code>apply_patch</code>");
|
|
expect(clipboard.html).toContain("<div>5. Fifth item</div>");
|
|
expect(clipboard.html).toContain("A hard break<br>");
|
|
expect(clipboard.html).toContain(
|
|
'<a href="http://www.example.com/">www.example.com</a> stays plain Markdown text.',
|
|
);
|
|
expect(clipboard.html).toContain(
|
|
'<a href="https://docs.example.com/" title="Reference">docs</a>',
|
|
);
|
|
expect(clipboard.html).toContain(
|
|
'<a href="https://autolink.example.com/">https://autolink.example.com</a>',
|
|
);
|
|
expect(clipboard.html).toContain(
|
|
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
|
|
);
|
|
expect(clipboard.html).toContain("<code>https://example.com/generated</code>");
|
|
expect(clipboard.html).not.toContain(
|
|
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>',
|
|
);
|
|
expect(clipboard.html).toContain('<code class="language-typescript">');
|
|
expect(clipboard.html).toContain(
|
|
'<pre><code class="language-text">before\n```\nafter\n</code></pre>',
|
|
);
|
|
expect(clipboard.html).toContain("<table>");
|
|
expect(clipboard.html).toContain('<td style="text-align:left">Current | active</td>');
|
|
expect(clipboard.html.match(/<td\b/g)).toHaveLength(3);
|
|
expect(clipboard.html).toContain("<s>obsolete</s>");
|
|
expect(clipboard.html).toContain('<th style="text-align:left">Left</th>');
|
|
expect(clipboard.html).toContain('<th style="text-align:right">Right</th>');
|
|
expect(clipboard.html).toContain('<th style="text-align:center">Center</th>');
|
|
|
|
await selectAssistantText(page, "First");
|
|
await copySelection(page);
|
|
|
|
const partialClipboard = await readRichClipboard(page);
|
|
expect(partialClipboard.plainText).toBe("First");
|
|
expect(partialClipboard.html).toContain("<p>First</p>");
|
|
|
|
await selectAssistantTextRange(page, "Direct matches:", "repeated sandbox setup.");
|
|
await copySelection(page);
|
|
|
|
const paragraphAndListClipboard = await readRichClipboard(page);
|
|
expect(paragraphAndListClipboard.plainText).toBe(
|
|
[
|
|
"Direct matches:",
|
|
"",
|
|
"Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.",
|
|
"",
|
|
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
|
|
"- [Second issue](https://example.com/issues/2): repeated sandbox setup.",
|
|
].join("\n"),
|
|
);
|
|
expect(paragraphAndListClipboard.html).not.toContain("<ul>");
|
|
expect(paragraphAndListClipboard.html).not.toContain("<li>");
|
|
expect(paragraphAndListClipboard.html).toContain(
|
|
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
|
);
|
|
|
|
await selectAssistantText(page, "docs");
|
|
await copySelection(page);
|
|
|
|
const titledLinkClipboard = await readRichClipboard(page);
|
|
expect(titledLinkClipboard.plainText).toBe('[docs](https://docs.example.com "Reference")');
|
|
expect(titledLinkClipboard.html).toContain(
|
|
'<a href="https://docs.example.com/" title="Reference">docs</a>',
|
|
);
|
|
|
|
await selectAssistantText(page, "https://autolink.example.com");
|
|
await copySelection(page);
|
|
|
|
const autolinkClipboard = await readRichClipboard(page);
|
|
expect(autolinkClipboard.plainText).toBe(
|
|
"[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 copySelection(page);
|
|
|
|
const fileLinkClipboard = await readRichClipboard(page);
|
|
expect(fileLinkClipboard.plainText).toBe("[workspace file](file:///tmp/paseo%20notes.md#L4)");
|
|
expect(fileLinkClipboard.html).toContain(
|
|
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
|
|
);
|
|
|
|
await selectAssistantTextRange(page, "Seventh item", "Eighth item");
|
|
await copySelection(page);
|
|
|
|
const midListClipboard = await readRichClipboard(page);
|
|
expect(midListClipboard.plainText).toBe("Seventh item\n\n8. Eighth item");
|
|
expect(midListClipboard.html).toContain("<div>8. Eighth item</div>");
|
|
|
|
await selectAssistantTextRange(page, "Inner eight", "Inner nine");
|
|
await copySelection(page);
|
|
|
|
const nestedListClipboard = await readRichClipboard(page);
|
|
expect(nestedListClipboard.plainText).toBe("Inner eight\n\n9. Inner nine");
|
|
expect(nestedListClipboard.html).toContain("<div>9. Inner nine</div>");
|
|
|
|
await selectAssistantTextRange(page, "Seventh item", "Nested list:");
|
|
await copySelection(page);
|
|
|
|
const crossBlockClipboard = await readRichClipboard(page);
|
|
expect(crossBlockClipboard.plainText).toBe("Seventh item\n\n8. Eighth item\n\nNested list:");
|
|
expect(crossBlockClipboard.html).toContain("<div>8. Eighth item</div>");
|
|
|
|
await selectAssistantText(page, "Current");
|
|
await copySelection(page);
|
|
|
|
const partialTableClipboard = await readRichClipboard(page);
|
|
expect(partialTableClipboard.plainText).toBe("Current");
|
|
expect(partialTableClipboard.html).toContain("<p>Current</p>");
|
|
expect(partialTableClipboard.html).not.toContain("<table");
|
|
|
|
await selectAssistantTextRange(page, "Right", "ready");
|
|
await copySelection(page);
|
|
|
|
const columnSliceClipboard = await readRichClipboard(page);
|
|
expect(columnSliceClipboard.plainText).toContain("ready");
|
|
expect(columnSliceClipboard.html).not.toContain("<table>");
|
|
expect(columnSliceClipboard.html).toContain("ready");
|
|
expect(columnSliceClipboard.html).toContain("<s>obsolete</s>");
|
|
|
|
// A partial selection loses its `pre` wrapper, so the code reaches Turndown as
|
|
// prose unless it is diverted first: the line break and the indentation go, and
|
|
// Markdown-significant characters get escaped.
|
|
await selectAssistantAcrossCodeLines(page, "const", "return");
|
|
await copySelection(page);
|
|
|
|
const codeLinesClipboard = await readRichClipboard(page);
|
|
expect(codeLinesClipboard.plainText).toBe('const answer = "yes";\n return');
|
|
expect(codeLinesClipboard.html).toContain('<pre><code class="language-typescript">');
|
|
|
|
// Selecting inside code expresses an intent to copy code, even when the selection
|
|
// contains every character in the block.
|
|
await selectAssistantElement(
|
|
page,
|
|
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
|
|
);
|
|
await copySelection(page);
|
|
|
|
const completeCodeClipboard = await readRichClipboard(page);
|
|
expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;');
|
|
|
|
// Crossing the block boundary expresses an intent to carry its Markdown structure.
|
|
await selectAssistantAcrossCodeLines(page, "const", "After code.");
|
|
await copySelection(page);
|
|
|
|
const crossedCodeClipboard = await readRichClipboard(page);
|
|
expect(crossedCodeClipboard.plainText).toBe(
|
|
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
|
|
);
|
|
|
|
// Selecting a whole line runs off its end into the newline node. A trailing
|
|
// newline auto-executes the line when it is pasted into a terminal.
|
|
await selectAssistantAcrossCodeLines(page, "const", " ");
|
|
await copySelection(page);
|
|
|
|
expect((await readRichClipboard(page)).plainText).toBe('const answer = "yes";');
|
|
|
|
// The block's own Copy button had the same hazard: a Markdown fence body ends in
|
|
// a newline, and the button copied it raw.
|
|
const typescriptFence = assistantMessage.locator('[data-paseo-markdown-language="typescript"]');
|
|
// The button is opacity 0 / pointerEvents none until the fence is hovered.
|
|
await typescriptFence.hover();
|
|
await typescriptFence.locator("[data-paseo-markdown-ignore]").click();
|
|
|
|
expect(await readPlainClipboard(page)).toBe('const answer = "yes";\n return answer;');
|
|
|
|
// A blank line before the closing fence leaves the body ending in two newlines,
|
|
// so stripping only the terminal one still hands the terminal an executable line.
|
|
const bashFence = assistantMessage.locator('[data-paseo-markdown-language="bash"]');
|
|
await bashFence.hover();
|
|
await bashFence.locator("[data-paseo-markdown-ignore]").click();
|
|
|
|
expect(await readPlainClipboard(page)).toBe("echo trailing");
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|