diff --git a/packages/app/e2e/browser/assistant-selection-copy.spec.ts b/packages/app/e2e/browser/assistant-selection-copy.spec.ts index 2df4420ec..4dcbfd47b 100644 --- a/packages/app/e2e/browser/assistant-selection-copy.spec.ts +++ b/packages/app/e2e/browser/assistant-selection-copy.spec.ts @@ -199,12 +199,14 @@ test("copying an assistant selection preserves Markdown structure and links", as const clipboard = await readRichClipboard(page); expect(clipboard.plainText).toBe(EXPECTED_WHOLE_SELECTION_MARKDOWN); - expect(clipboard.html).toContain("
apply_patch");
- expect(clipboard.html).toContain('First
"); + + 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("" },
+ ])(
+ "copies a partial $tag range without expanding to its delimiters",
+ ({ tag, range, expected, forbiddenHtml }) => {
+ const message = mountFixture();
+ const element = fixtureElement(message, `[data-paseo-markdown-tag="${tag}"]`);
+ const content = createAssistantSelectionClipboardContent(
+ selectText(element, range[0], range[1]),
+ );
+ expect(content?.plainText).toBe(expected);
+ expect(content?.html).not.toContain(forbiddenHtml);
+ },
+ );
+
+ it.each([
+ { tag: "strong", expected: "**bold text**" },
+ { tag: "code", expected: "`inline code`" },
+ ])("retains $tag delimiters when its complete contents are selected", ({ tag, expected }) => {
+ const message = mountFixture();
+ const element = fixtureElement(message, `[data-paseo-markdown-tag="${tag}"]`);
+ const content = createAssistantSelectionClipboardContent(
+ selectText(element, 0, textNode(element).length),
+ );
+ expect(content?.plainText).toBe(expected);
+ expect(content?.html).toContain(`<${tag}>`);
+ });
+
+ it("keeps a complete inline node but drops formatting from a partial node at the other edge", () => {
+ const message = mountFixture();
+ const strong = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
+ const code = fixtureElement(message, '[data-paseo-markdown-tag="code"]');
+ expect(copiedMarkdown(selectRange(strong, 0, code, 6))).toBe("**bold text** and inline");
+ });
+
+ it("copies list-item text without inventing a bullet", () => {
+ const message = mountFixture();
+ const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
+ const content = createAssistantSelectionClipboardContent(
+ selectText(itemText, 0, textNode(itemText).length),
+ );
+ expect(content?.plainText).toBe("First bullet text");
+ expect(content?.html).toContain("First bullet text
");
+ expect(content?.html).not.toContain("- ");
+ });
+
+ it("retains a bullet when the range includes the marker and the complete item", () => {
+ const message = mountFixture();
+ const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
+ expect(copiedMarkdown(selectNodeContents(item))).toBe("- First bullet text");
+ });
+
+ it("preserves paragraph breaks when rich HTML flattens a loose list item", () => {
+ const message = mountFixture();
+ const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
+ item.replaceChildren();
+ item.insertAdjacentHTML(
+ "beforeend",
+ 'First paragraphSecond paragraph',
+ );
+
+ const content = createAssistantSelectionClipboardContent(selectNodeContents(item));
+ expect(content?.plainText).toBe("- First paragraph\n\n Second paragraph");
+ expect(content?.html).toMatch(/-\s*First paragraph\s*
Second paragraph\s*<\/div>/);
+ });
+
+ it("omits a partial leading bullet and retains the marker crossed before the trailing item", () => {
+ const message = mountFixture();
+ const selector = '[data-paseo-markdown-tag="li"] div span';
+ const first = fixtureElement(message, selector);
+ const second = fixtureElement(message, selector, 1);
+ expect(copiedMarkdown(selectRange(first, 6, second, textNode(second).length))).toBe(
+ "bullet text\n\n- Second bullet text",
+ );
+ });
+
+ it("copies part of a fenced code block without adding an inline or fenced delimiter", () => {
+ const message = mountFixture();
+ const blockCode = fixtureElement(
+ message,
+ '[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
+ );
+ const content = createAssistantSelectionClipboardContent(selectText(blockCode, 6, 12));
+ expect(content?.plainText).toBe("answer");
+ expect(content?.html).not.toContain("");
+ expect(content?.html).not.toContain("");
+ });
+
+ it("retains the fence and language when the complete code block is selected", () => {
+ const message = mountFixture();
+ const blockCode = fixtureElement(
+ message,
+ '[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
+ );
+ expect(copiedMarkdown(selectText(blockCode, 0, textNode(blockCode).length))).toBe(
+ "```ts\nconst answer = true;\n```",
+ );
+ });
+});
diff --git a/packages/app/src/assistant-selection-copy/content.web.ts b/packages/app/src/assistant-selection-copy/content.web.ts
index 9a49d2c2a..28d50e648 100644
--- a/packages/app/src/assistant-selection-copy/content.web.ts
+++ b/packages/app/src/assistant-selection-copy/content.web.ts
@@ -66,65 +66,219 @@ export function createAssistantSelectionClipboardContent(
}
const container = document.createElement("div");
- const selected = wrapSelectionWithAncestors(range, startMessage);
- normalizeOrderedListStarts(selected, range, startMessage);
+ const selected = cloneMarkdownSelection(range, startMessage);
container.append(selected);
restoreMarkdownElements(container);
const markdown = turndown.turndown(container.innerHTML).trim();
- return markdown ? createMarkdownClipboardContent(markdown) : null;
+ if (!markdown) {
+ return null;
+ }
+ const content = createMarkdownClipboardContent(markdown);
+ return { ...content, html: flattenClipboardListMarkup(content.html) };
}
-function wrapSelectionWithAncestors(range: Range, message: Element): Node {
- let selected: Node = range.cloneContents();
+function flattenClipboardListMarkup(html: string): string {
+ const container = document.createElement("div");
+ container.innerHTML = html;
+
+ const lists = Array.from(container.querySelectorAll("ul, ol")).toReversed();
+ for (const list of lists) {
+ const replacement = document.createDocumentFragment();
+ const items = Array.from(list.children).filter((child) => child.tagName === "LI");
+ const start = list.tagName === "OL" ? Number(list.getAttribute("start") ?? 1) : null;
+
+ items.forEach((item, index) => {
+ const line = document.createElement("div");
+ line.append(start === null ? "- " : `${start + index}. `);
+ let hasParagraph = false;
+ for (const child of Array.from(item.childNodes)) {
+ if (child instanceof HTMLParagraphElement) {
+ if (hasParagraph) {
+ line.append(document.createElement("br"), document.createElement("br"));
+ }
+ line.append(...child.childNodes);
+ hasParagraph = true;
+ } else {
+ line.append(child);
+ }
+ }
+ replacement.append(line);
+ });
+ list.replaceWith(replacement);
+ }
+
+ return container.innerHTML;
+}
+
+function cloneMarkdownSelection(range: Range, message: Element): Node {
+ const clonedMessage = message.cloneNode(true) as Element;
+ const clonedRange = cloneRangeInto(range, message, clonedMessage);
+ removeUnselectedSemantics(range, message, clonedMessage);
+
+ let selected: Node = clonedRange.cloneContents();
let ancestor =
range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
while (ancestor && ancestor !== message) {
- const wrapper = ancestor.cloneNode(false) as Element;
- wrapper.append(selected);
- selected = wrapper;
+ if (shouldPreserveSemanticElement(range, ancestor)) {
+ const wrapper = ancestor.cloneNode(false) as Element;
+ normalizeOrderedListStart(wrapper, ancestor, range);
+ wrapper.append(selected);
+ selected = wrapper;
+ }
ancestor = ancestor.parentElement;
}
return selected;
}
-function normalizeOrderedListStarts(selected: Node, range: Range, message: Element): void {
- const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="ol"]`;
- const originals = Array.from(message.querySelectorAll(selector)).filter((list) =>
- range.intersectsNode(list),
+function cloneRangeInto(range: Range, source: Element, clone: Element): Range {
+ const clonedRange = document.createRange();
+ clonedRange.setStart(
+ nodeAtPath(clone, nodePath(source, range.startContainer)),
+ range.startOffset,
);
- const copies = markedElements(selected, selector);
-
- for (let index = 0; index < Math.min(originals.length, copies.length); index += 1) {
- const original = originals[index];
- const firstSelectedItem = Array.from(original.children).find(
- (child) =>
- child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li" && range.intersectsNode(child),
- );
- if (!firstSelectedItem) {
- continue;
- }
- const omittedItems = Array.from(original.children)
- .slice(0, Array.from(original.children).indexOf(firstSelectedItem))
- .filter((child) => child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li").length;
- const originalStart = Number(original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ?? 1);
- copies[index].setAttribute(
- MARKDOWN_COPY_LIST_START_ATTRIBUTE,
- String(originalStart + omittedItems),
- );
- }
+ clonedRange.setEnd(nodeAtPath(clone, nodePath(source, range.endContainer)), range.endOffset);
+ return clonedRange;
}
-function markedElements(root: Node, selector: string): Element[] {
- const elements = root instanceof Element && root.matches(selector) ? [root] : [];
- if ("querySelectorAll" in root) {
- elements.push(...Array.from((root as ParentNode).querySelectorAll(selector)));
+function nodePath(root: Node, node: Node): number[] {
+ const path: number[] = [];
+ let current = node;
+ while (current !== root) {
+ const parent = current.parentNode;
+ if (!parent) {
+ throw new Error("Selection boundary is outside its assistant message");
+ }
+ path.unshift(Array.from(parent.childNodes).findIndex((child) => child === current));
+ current = parent;
}
- return elements;
+ return path;
+}
+
+function nodeAtPath(root: Node, path: number[]): Node {
+ let current = root;
+ for (const index of path) {
+ const child = current.childNodes[index];
+ if (!child) {
+ throw new Error("Cloned assistant message does not match the selection source");
+ }
+ current = child;
+ }
+ return current;
+}
+
+function removeUnselectedSemantics(range: Range, source: Element, clone: Element): void {
+ const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}], a`;
+ const originals = Array.from(source.querySelectorAll(selector));
+ const copies = Array.from(clone.querySelectorAll(selector));
+
+ originals.forEach((original, index) => {
+ const copy = copies[index];
+ if (!copy || shouldPreserveSemanticElement(range, original)) {
+ if (copy) {
+ normalizeOrderedListStart(copy, original, range);
+ }
+ return;
+ }
+ const tag = original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
+ if (tag && isBlockBoundary(tag)) {
+ copy.setAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE, "p");
+ return;
+ }
+ copy.removeAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
+ if (copy.tagName === "A") {
+ copy.setAttribute(MARKDOWN_COPY_UNWRAP_ATTRIBUTE, "true");
+ }
+ });
+}
+
+function shouldPreserveSemanticElement(range: Range, element: Element): boolean {
+ if (!range.intersectsNode(element)) {
+ return false;
+ }
+ const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
+ if (tag === "p" || isTableStructure(tag)) {
+ return true;
+ }
+ if (tag === "ol" || tag === "ul") {
+ const selectedItems = selectedListItems(element, range);
+ return selectedItems.some((item) => hasSelectedAllContents(range, item, true));
+ }
+ return hasSelectedAllContents(range, element, tag === "li");
+}
+
+function selectedListItems(list: Element, range: Range): Element[] {
+ return Array.from(list.children).filter(
+ (child) =>
+ child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li" &&
+ (child.contains(range.startContainer) ||
+ child.contains(range.endContainer) ||
+ hasSelectedAllContents(range, child, true)),
+ );
+}
+
+function normalizeOrderedListStart(copy: Element, original: Element, range: Range): void {
+ if (original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) !== "ol") {
+ return;
+ }
+ const firstSelectedItem = selectedListItems(original, range)[0];
+ if (!firstSelectedItem) {
+ return;
+ }
+ const items = Array.from(original.children).filter(
+ (child) => child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li",
+ );
+ const omittedItems = items.indexOf(firstSelectedItem);
+ const originalStart = Number(original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ?? 1);
+ copy.setAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE, String(originalStart + omittedItems));
+}
+
+function hasSelectedAllContents(range: Range, element: Element, includeIgnored = false): boolean {
+ const contents = document.createRange();
+ contents.selectNodeContents(element);
+
+ if (range.compareBoundaryPoints(Range.START_TO_START, contents) > 0) {
+ const before = contents.cloneRange();
+ before.setEnd(range.startContainer, range.startOffset);
+ if (hasMarkdownContent(before.cloneContents(), includeIgnored)) {
+ return false;
+ }
+ }
+ if (range.compareBoundaryPoints(Range.END_TO_END, contents) < 0) {
+ const after = contents.cloneRange();
+ after.setStart(range.endContainer, range.endOffset);
+ if (hasMarkdownContent(after.cloneContents(), includeIgnored)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean {
+ if (!includeIgnored) {
+ for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
+ ignored.remove();
+ }
+ }
+ if (fragment.textContent) {
+ return true;
+ }
+ const visibleVoidSelector = ["br", "hr"]
+ .map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
+ .join(",");
+ return Boolean(fragment.querySelector(visibleVoidSelector));
+}
+
+function isBlockBoundary(tag: string): boolean {
+ return tag === "li" || tag === "blockquote" || tag === "pre" || /^h[1-6]$/.test(tag);
+}
+
+function isTableStructure(tag: string | null): boolean {
+ return ["table", "thead", "tbody", "tr", "th", "td"].includes(tag ?? "");
}
function closestAssistantMessage(node: Node): Element | null {