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
@@ -11,6 +11,10 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
import {
markdownCopyCodeBlockDataSet,
markdownCopyDataSet,
} from "@/assistant-selection-copy/markup";
interface HighlightedCodeBlockProps {
code: string;
@@ -63,6 +67,10 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
[inheritedStyles, textStyle],
);
const renderedCode = useMemo(() => stripTerminalFenceNewline(code), [code]);
const copyDataSet = useMemo(
() => ({ ...CODE_SURFACE_DATASET, ...markdownCopyCodeBlockDataSet(language) }),
[language],
);
const keyedLines = useMemo<KeyedLine[] | null>(
() => highlightToKeyedLines(renderedCode, fenceLanguageToExtension(language)),
@@ -79,14 +87,18 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
return (
<View
style={containerStyle}
dataSet={CODE_SURFACE_DATASET}
dataSet={copyDataSet}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
{keyedLines ? (
<MarkdownTextSpan style={innerTextStyle}>{renderCodeSegments(keyedLines)}</MarkdownTextSpan>
<MarkdownTextSpan style={innerTextStyle} copyTag="code">
{renderCodeSegments(keyedLines)}
</MarkdownTextSpan>
) : (
<MarkdownTextSpan style={innerTextStyle}>{renderedCode}</MarkdownTextSpan>
<MarkdownTextSpan style={innerTextStyle} copyTag="code">
{renderedCode}
</MarkdownTextSpan>
)}
<CopyButton getCode={getCode} visible={controlsVisible} />
</View>
@@ -195,6 +207,7 @@ const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButt
accessibilityRole="button"
accessibilityLabel={copied ? t("message.actions.copied") : t("message.actions.copyCode")}
hitSlop={8}
dataSet={markdownCopyDataSet.ignore}
>
{({ hovered }) => {
const iconColor = hovered
@@ -7,10 +7,12 @@ import {
type TextStyle,
type ViewStyle,
} from "react-native";
import type { MarkdownCopyInlineTag } from "@/assistant-selection-copy/markup";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
monoSurface?: boolean;
copyTag?: MarkdownCopyInlineTag;
children: ReactNode;
onPress?: TextProps["onPress"];
accessibilityRole?: TextProps["accessibilityRole"];
@@ -13,10 +13,12 @@ import {
iosMarkdownTextIsSelectable,
useMarkdownTextSurface,
} from "@/components/markdown-text-selection";
import type { MarkdownCopyInlineTag } from "@/assistant-selection-copy/markup";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
monoSurface?: boolean;
copyTag?: MarkdownCopyInlineTag;
children: ReactNode;
// Links route through this span too (see assistant-file-links/link.tsx). A
// plain <Text> nested in the paragraph UITextView is dropped, so the link
@@ -8,10 +8,12 @@ import {
type ViewStyle,
} from "react-native";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { markdownCopyDataSet, type MarkdownCopyInlineTag } from "@/assistant-selection-copy/markup";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
monoSurface?: boolean;
copyTag?: MarkdownCopyInlineTag;
children: ReactNode;
// Web links use the <a>/Pressable path in link.tsx, not this span, so these
// are accepted for prop-shape parity with the native variants and forwarded
@@ -28,17 +30,23 @@ interface MarkdownTextSpanProps {
export function MarkdownTextSpan({
style,
monoSurface,
copyTag,
children,
onPress,
accessibilityRole,
}: MarkdownTextSpanProps) {
const dataSet = useMemo(() => {
if (copyTag && (monoSurface || copyTag === "code")) {
return { ...CODE_SURFACE_DATASET, ...markdownCopyDataSet[copyTag] };
}
if (copyTag) {
return markdownCopyDataSet[copyTag];
}
return monoSurface ? CODE_SURFACE_DATASET : undefined;
}, [copyTag, monoSurface]);
return (
<Text
dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined}
style={style}
onPress={onPress}
accessibilityRole={accessibilityRole}
>
<Text dataSet={dataSet} style={style} onPress={onPress} accessibilityRole={accessibilityRole}>
{children}
</Text>
);
@@ -57,5 +65,9 @@ const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
// elements via CSS user-select, so no UITextView equivalent is needed.
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
return <View style={style}>{children}</View>;
return (
<View style={style} dataSet={markdownCopyDataSet.p}>
{children}
</View>
);
}
+152 -24
View File
@@ -28,7 +28,8 @@ import {
cloneElement,
} from "react";
import type { ComponentType, ReactNode } from "react";
import { MarkdownIt, type ASTNode, type RenderRules } from "react-native-markdown-display";
import type MarkdownIt from "markdown-it";
import { type ASTNode, type RenderRules } from "react-native-markdown-display";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Circle,
@@ -71,6 +72,7 @@ import { markdownNodeContainsType } from "@/utils/markdown-ast";
import { useStableEvent } from "@/hooks/use-stable-event";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
@@ -103,6 +105,12 @@ import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
import { useRetainedPanelActive } from "@/components/retained-panel";
import {
markdownCopyDataSet,
markdownCopyOrderedListDataSet,
markdownCopyTableCellDataSet,
type MarkdownCopyInlineTag,
} from "@/assistant-selection-copy/markup";
export type { InlinePathTarget } from "@/assistant-file-links";
export type { AssistantForkTarget };
@@ -880,10 +888,7 @@ function AssistantMarkdownImage({
);
}
function getInlineCodeAutoLinkUrl(
markdownParser: ReturnType<typeof MarkdownIt>,
content: string,
): string | null {
function getInlineCodeAutoLinkUrl(markdownParser: MarkdownIt, content: string): string | null {
const trimmed = content.trim();
if (!trimmed) {
return null;
@@ -928,6 +933,7 @@ function getMarkdownLinkSource(node: AssistantMarkdownAstNode): AssistantFileLin
return {
href: typeof node.attributes?.href === "string" ? node.attributes.href : "",
text: getMarkdownNodeText(node),
title: typeof node.attributes?.title === "string" ? node.attributes.title : undefined,
markup: node.markup,
sourceInfo: node.sourceInfo,
sourceType: node.sourceType === "inline-code" ? "inline-code" : undefined,
@@ -1364,6 +1370,7 @@ interface MarkdownInheritedTextProps {
textStyle: TextStyle;
style?: StyleProp<TextStyle>;
monoSurface?: boolean;
copyTag?: MarkdownCopyInlineTag;
children: ReactNode;
}
@@ -1372,6 +1379,7 @@ function MarkdownInheritedText({
textStyle,
style: overrideStyle,
monoSurface,
copyTag,
children,
}: MarkdownInheritedTextProps) {
const style = useMemo(
@@ -1387,6 +1395,7 @@ function MarkdownInheritedText({
return (
<MarkdownTextSpan
monoSurface={monoSurface}
copyTag={copyTag}
style={style}
onPress={linkPress?.onPress}
accessibilityRole={linkPress?.accessibilityRole}
@@ -1410,13 +1419,27 @@ function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemCon
interface MarkdownListViewProps {
baseStyle: ViewStyle;
copyTag: "ol" | "ul";
orderedStart?: unknown;
spacing: { marginTop: number; marginBottom: number };
children: ReactNode;
}
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
function MarkdownListView({
baseStyle,
copyTag,
orderedStart,
spacing,
children,
}: MarkdownListViewProps) {
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
return <View style={style}>{children}</View>;
const copyDataSet =
copyTag === "ol" ? markdownCopyOrderedListDataSet(orderedStart) : markdownCopyDataSet.ul;
return (
<View style={style} dataSet={copyDataSet}>
{children}
</View>
);
}
export const AssistantMessage = memo(function AssistantMessage({
@@ -1428,18 +1451,7 @@ export const AssistantMessage = memo(function AssistantMessage({
client,
spacing = "default",
}: AssistantMessageProps) {
const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
parser.validateLink = (url: string) => {
if (url.trim().toLowerCase().startsWith("file://")) {
return true;
}
return defaultValidateLink(url);
};
return parser;
}, []);
const markdownParser = useMemo(createAssistantMarkdownParser, []);
const fileLinkActions = useAssistantFileLinkActions();
const handleMarkdownLinkPress = useStableEvent((url: string) => {
@@ -1451,6 +1463,103 @@ export const AssistantMessage = memo(function AssistantMessage({
const markdownRules = useMemo<RenderRules>(() => {
return {
heading1: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<View key={node.key} style={styles._VIEW_SAFE_heading1} dataSet={markdownCopyDataSet.h1}>
{children}
</View>
),
heading2: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<View key={node.key} style={styles._VIEW_SAFE_heading2} dataSet={markdownCopyDataSet.h2}>
{children}
</View>
),
heading3: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<View key={node.key} style={styles._VIEW_SAFE_heading3} dataSet={markdownCopyDataSet.h3}>
{children}
</View>
),
heading4: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<View key={node.key} style={styles._VIEW_SAFE_heading4} dataSet={markdownCopyDataSet.h4}>
{children}
</View>
),
heading5: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<View key={node.key} style={styles._VIEW_SAFE_heading5} dataSet={markdownCopyDataSet.h5}>
{children}
</View>
),
heading6: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<View key={node.key} style={styles._VIEW_SAFE_heading6} dataSet={markdownCopyDataSet.h6}>
{children}
</View>
),
blockquote: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<View
key={node.key}
style={styles._VIEW_SAFE_blockquote}
dataSet={markdownCopyDataSet.blockquote}
>
{children}
</View>
),
hr: (node: ASTNode, _children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
<View key={node.key} style={styles._VIEW_SAFE_hr} dataSet={markdownCopyDataSet.hr} />
),
table: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
<View key={node.key} style={styles._VIEW_SAFE_table} dataSet={markdownCopyDataSet.table}>
{children}
</View>
),
thead: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
<View key={node.key} style={styles._VIEW_SAFE_thead} dataSet={markdownCopyDataSet.thead}>
{children}
</View>
),
tbody: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
<View key={node.key} style={styles._VIEW_SAFE_tbody} dataSet={markdownCopyDataSet.tbody}>
{children}
</View>
),
tr: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
<View key={node.key} style={styles._VIEW_SAFE_tr} dataSet={markdownCopyDataSet.tr}>
{children}
</View>
),
text: (
node: ASTNode,
_children: ReactNode[],
@@ -1497,6 +1606,7 @@ export const AssistantMessage = memo(function AssistantMessage({
) => (
<MarkdownInheritedText
key={node.key}
copyTag="strong"
inheritedStyles={inheritedStyles}
textStyle={styles.strong}
>
@@ -1512,6 +1622,7 @@ export const AssistantMessage = memo(function AssistantMessage({
) => (
<MarkdownInheritedText
key={node.key}
copyTag="em"
inheritedStyles={inheritedStyles}
textStyle={styles.em}
>
@@ -1527,6 +1638,7 @@ export const AssistantMessage = memo(function AssistantMessage({
) => (
<MarkdownInheritedText
key={node.key}
copyTag="s"
inheritedStyles={inheritedStyles}
textStyle={styles.s}
>
@@ -1547,7 +1659,7 @@ export const AssistantMessage = memo(function AssistantMessage({
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<MarkdownTextSpan key={node.key} style={styles.hardbreak}>
<MarkdownTextSpan key={node.key} style={styles.hardbreak} copyTag="br">
{"\n"}
</MarkdownTextSpan>
),
@@ -1642,6 +1754,7 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<MarkdownInheritedText
key={node.key}
copyTag="code"
inheritedStyles={inheritedStyles}
textStyle={styles.code_inline}
monoSurface
@@ -1659,6 +1772,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<MarkdownListView
key={node.key}
baseStyle={styles.bullet_list}
copyTag="ul"
spacing={getMarkdownListSpacing(node, parent)}
>
{children}
@@ -1673,6 +1787,8 @@ export const AssistantMessage = memo(function AssistantMessage({
<MarkdownListView
key={node.key}
baseStyle={styles.ordered_list}
copyTag="ol"
orderedStart={node.attributes?.start}
spacing={getMarkdownListSpacing(node, parent)}
>
{children}
@@ -1689,8 +1805,10 @@ export const AssistantMessage = memo(function AssistantMessage({
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{marker}</Text>
<View key={node.key} style={styles.list_item} dataSet={markdownCopyDataSet.li}>
<Text style={iconStyle} dataSet={markdownCopyDataSet.ignore}>
{marker}
</Text>
<MarkdownListItemContent contentStyle={contentStyle}>
{children}
</MarkdownListItemContent>
@@ -1699,12 +1817,22 @@ export const AssistantMessage = memo(function AssistantMessage({
},
th: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
<MarkdownTableCellText key={node.key}>
<View style={styles._VIEW_SAFE_th}>{children}</View>
<View
style={styles._VIEW_SAFE_th}
dataSet={markdownCopyTableCellDataSet("th", node.attributes?.style)}
>
{children}
</View>
</MarkdownTableCellText>
),
td: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
<MarkdownTableCellText key={node.key}>
<View style={styles._VIEW_SAFE_td}>{children}</View>
<View
style={styles._VIEW_SAFE_td}
dataSet={markdownCopyTableCellDataSet("td", node.attributes?.style)}
>
{children}
</View>
</MarkdownTableCellText>
),
paragraph: (