Preserve assistant selection formatting when copying (#2808)

* fix(app): preserve formatting when copying assistant selections

Browser selection serialized React Native Web layout nodes instead of the assistant message semantics. Recover semantic markup from renderer-owned metadata and reuse the rich clipboard generator used by Copy turn.

* fix(app): preserve assistant copy semantics

Carry source-only Markdown metadata through the selection copy boundary, unwrap generated code links, and cover ordered starts, hard breaks, and fence languages in Playwright. Refresh the Nix dependency hash for the added clipboard dependency.

* fix(app): preserve partial selection context

Wrap selected fragments with their shared semantic ancestor chain and enable GFM table and strikethrough conversion. Cover partial nested selections and GFM output through the browser clipboard.

* fix(app): preserve copied list and autolink text

Adjust partial ordered-list starts for omitted siblings, strip parser-generated anchors before Markdown reconstruction, and refresh the Nix dependency hash computed by CI.

* fix(app): normalize partial selection context

Normalize cloned ordered lists across block boundaries, degrade incomplete table fragments to selected text, and preserve table alignment provenance through Markdown reconstruction.

* fix(app): escape copied table delimiters

Rendered table cells no longer retain source escape characters. Escape literal pipes during DOM-to-Markdown conversion so copied tables preserve their column structure.

* fix(app): preserve authored link semantics

The rendered link boundary conflated parser-generated links with authored autolinks and discarded authored titles. Carry title semantics into the anchor and unwrap only links with explicit generated provenance.

* fix(app): preserve nested copy structure

Blank-line splitting separated loose nested lists before Markdown rendering, leaving no nesting for clipboard serialization to preserve. Derive protected separators from the Markdown block structure and keep copy metadata independent from monospace surface styling.

* fix(app): preserve selected file links

Assistant messages and rich clipboard HTML must apply the same link validation policy. A shared parser keeps file links semantic in both MIME representations while retaining markdown-it's unsafe-scheme rejection.

* fix(app): preserve partial table content

A selected body row wider than its retained header cannot be represented as GFM and is truncated when rich HTML is regenerated. Treat only those over-wide fragments, plus headerless fragments, as incomplete while preserving valid short rows.
This commit is contained in:
Mohamed Boudra
2026-08-03 19:02:31 +08:00
committed by GitHub
parent 04fe30a3e2
commit adf33930b3
24 changed files with 966 additions and 72 deletions
@@ -0,0 +1,212 @@
import TurndownService from "turndown";
import { gfm } from "turndown-plugin-gfm";
import {
createMarkdownClipboardContent,
type MarkdownClipboardContent,
} from "@/utils/rich-clipboard";
import {
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
MARKDOWN_COPY_TAG_ATTRIBUTE,
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
} from "./markup";
const ASSISTANT_MESSAGE_SELECTOR = '[data-testid="assistant-message"]';
const turndown = new TurndownService({
bulletListMarker: "-",
codeBlockStyle: "fenced",
emDelimiter: "_",
strongDelimiter: "**",
});
turndown.use(gfm);
turndown.addRule("escapedGfmTableCell", {
filter: ["th", "td"],
replacement: (content, node) => {
const index = node.parentNode ? Array.from(node.parentNode.childNodes).indexOf(node) : 0;
const prefix = index === 0 ? "| " : " ";
return `${prefix}${content.replace(/\|/g, "\\|")} |`;
},
});
turndown.addRule("gfmStrikethrough", {
filter: ["del", "s"],
replacement: (content) => `~~${content}~~`,
});
turndown.addRule("compactListItem", {
filter: "li",
replacement: (content, node, options) => {
const item = content
.replace(/^\n+/, "")
.replace(/\n+$/, "\n")
.replace(/\n(?=\S)/g, "\n ");
const parent = node.parentElement;
if (parent?.nodeName !== "OL") {
return `${options.bulletListMarker} ${item}\n`;
}
const start = Number(parent.getAttribute("start") ?? 1);
const index = Array.from(parent.children).indexOf(node);
return `${start + index}. ${item}\n`;
},
});
export function createAssistantSelectionClipboardContent(
selection: Selection | null,
): MarkdownClipboardContent | null {
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) {
return null;
}
const range = selection.getRangeAt(0);
const startMessage = closestAssistantMessage(range.startContainer);
const endMessage = closestAssistantMessage(range.endContainer);
if (!startMessage || startMessage !== endMessage) {
return null;
}
const container = document.createElement("div");
const selected = wrapSelectionWithAncestors(range, startMessage);
normalizeOrderedListStarts(selected, range, startMessage);
container.append(selected);
restoreMarkdownElements(container);
const markdown = turndown.turndown(container.innerHTML).trim();
return markdown ? createMarkdownClipboardContent(markdown) : null;
}
function wrapSelectionWithAncestors(range: Range, message: Element): Node {
let selected: Node = range.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;
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),
);
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),
);
}
}
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)));
}
return elements;
}
function closestAssistantMessage(node: Node): Element | null {
const element = node instanceof Element ? node : node.parentElement;
return element?.closest(ASSISTANT_MESSAGE_SELECTOR) ?? null;
}
function restoreMarkdownElements(container: HTMLElement): void {
for (const ignored of container.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
ignored.remove();
}
const marked = Array.from(container.querySelectorAll(`[${MARKDOWN_COPY_TAG_ATTRIBUTE}]`));
for (const element of marked.toReversed()) {
const tagName = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
if (!tagName) {
continue;
}
const semanticElement = document.createElement(tagName);
if (tagName !== "br") {
semanticElement.append(...element.childNodes);
}
if (tagName === "ol") {
const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE);
if (start) {
semanticElement.setAttribute("start", start);
}
}
if (tagName === "pre") {
const language = element.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE);
const code = semanticElement.querySelector(":scope > code");
if (language && code) {
code.className = `language-${language}`;
}
}
if (tagName === "th" || tagName === "td") {
const alignment = element.getAttribute(MARKDOWN_COPY_ALIGN_ATTRIBUTE);
if (alignment) {
semanticElement.setAttribute("align", alignment);
}
}
element.replaceWith(semanticElement);
}
unwrapIncompleteTables(container);
const generatedLinks = Array.from(
container.querySelectorAll(`[${MARKDOWN_COPY_UNWRAP_ATTRIBUTE}]`),
);
for (const element of generatedLinks.toReversed()) {
element.replaceWith(...element.childNodes);
}
const presentational = Array.from(container.querySelectorAll("div, span"));
for (const element of presentational.toReversed()) {
element.replaceWith(...element.childNodes);
}
}
function unwrapIncompleteTables(container: HTMLElement): void {
for (const table of container.querySelectorAll("table")) {
if (hasUsableTableColumnContract(table)) {
continue;
}
const cells = Array.from(table.querySelectorAll("th, td"));
const selectedContent = document.createDocumentFragment();
cells.forEach((cell, index) => {
if (index > 0) {
selectedContent.append("\n");
}
selectedContent.append(...cell.childNodes);
});
table.replaceWith(selectedContent);
}
}
function hasUsableTableColumnContract(table: Element): boolean {
const headerWidth = table.querySelectorAll("thead th").length;
return (
headerWidth > 0 &&
Array.from(table.querySelectorAll("tbody tr")).every(
(row) => row.children.length <= headerWidth,
)
);
}
@@ -0,0 +1,63 @@
export const MARKDOWN_COPY_TAG_ATTRIBUTE = "data-paseo-markdown-tag";
export const MARKDOWN_COPY_IGNORE_ATTRIBUTE = "data-paseo-markdown-ignore";
export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap";
export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start";
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align";
export const markdownCopyDataSet = {
blockquote: { paseoMarkdownTag: "blockquote" },
br: { paseoMarkdownTag: "br" },
code: { paseoMarkdownTag: "code" },
h1: { paseoMarkdownTag: "h1" },
h2: { paseoMarkdownTag: "h2" },
h3: { paseoMarkdownTag: "h3" },
h4: { paseoMarkdownTag: "h4" },
h5: { paseoMarkdownTag: "h5" },
h6: { paseoMarkdownTag: "h6" },
hr: { paseoMarkdownTag: "hr" },
ignore: { paseoMarkdownIgnore: "true" },
li: { paseoMarkdownTag: "li" },
ol: { paseoMarkdownTag: "ol" },
p: { paseoMarkdownTag: "p" },
pre: { paseoMarkdownTag: "pre" },
s: { paseoMarkdownTag: "s" },
strong: { paseoMarkdownTag: "strong" },
em: { paseoMarkdownTag: "em" },
table: { paseoMarkdownTag: "table" },
tbody: { paseoMarkdownTag: "tbody" },
td: { paseoMarkdownTag: "td" },
th: { paseoMarkdownTag: "th" },
thead: { paseoMarkdownTag: "thead" },
tr: { paseoMarkdownTag: "tr" },
ul: { paseoMarkdownTag: "ul" },
unwrap: { paseoMarkdownUnwrap: "true" },
} as const;
export type MarkdownCopyInlineTag = "br" | "code" | "em" | "s" | "strong";
export function markdownCopyOrderedListDataSet(start: unknown) {
return {
...markdownCopyDataSet.ol,
paseoMarkdownListStart: String(start ?? 1),
} as const;
}
export function markdownCopyCodeBlockDataSet(language: string | null | undefined) {
const fenceLanguage = language?.trim().split(/\s+/)[0];
return {
...markdownCopyDataSet.pre,
...(fenceLanguage ? { paseoMarkdownLanguage: fenceLanguage } : {}),
} as const;
}
export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) {
const alignment =
typeof style === "string"
? style.match(/(?:^|;)\s*text-align\s*:\s*(left|right|center)/i)?.[1]
: null;
return {
...markdownCopyDataSet[tag],
...(alignment ? { paseoMarkdownAlign: alignment.toLowerCase() } : {}),
} as const;
}
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
interface AssistantSelectionCopySurfaceProps {
children: ReactNode;
style?: StyleProp<ViewStyle>;
}
export function AssistantSelectionCopySurface({
children,
style,
}: AssistantSelectionCopySurfaceProps) {
return <View style={style}>{children}</View>;
}
@@ -0,0 +1,32 @@
import { useCallback, type ClipboardEvent, type CSSProperties, type ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { createAssistantSelectionClipboardContent } from "./content.web";
interface AssistantSelectionCopySurfaceProps {
children: ReactNode;
style?: StyleProp<ViewStyle>;
}
const DISPLAY_CONTENTS: CSSProperties = { display: "contents" };
export function AssistantSelectionCopySurface({
children,
style,
}: AssistantSelectionCopySurfaceProps) {
const handleCopy = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
const content = createAssistantSelectionClipboardContent(window.getSelection());
if (!content) {
return;
}
event.preventDefault();
event.clipboardData.setData("text/plain", content.plainText);
event.clipboardData.setData("text/html", content.html);
}, []);
return (
<div onCopy={handleCopy} style={DISPLAY_CONTENTS}>
<View style={style}>{children}</View>
</div>
);
}