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
+1 -1
View File
@@ -1 +1 @@
sha256-Mg6cn08RoEFmAYKDjowYd7DZSeeJezix0rzQeDadRAs=
sha256-WOVtBB7AQke7RM9FDJ1mUDU0sLMf03QOrvajrXSFwPs=
+35
View File
@@ -7728,6 +7728,12 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -12952,6 +12958,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/turndown": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -33434,6 +33447,12 @@
"domino": "^2.1.6"
}
},
"node_modules/turndown-plugin-gfm": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
"license": "MIT"
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
@@ -35388,6 +35407,8 @@
"react-native-webview": "^13.16.0",
"react-native-worklets": "~0.8.3",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"use-sync-external-store": "^1.6.0",
"zod": "^4.4.3",
"zustand": "^5.0.9"
@@ -35399,6 +35420,7 @@
"@types/markdown-it": "^14.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
"@types/turndown": "^5.0.6",
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
@@ -36237,6 +36259,19 @@
"dev": true,
"license": "MIT"
},
"packages/app/node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
},
"engines": {
"node": ">=18",
"npm": ">=9"
}
},
"packages/app/node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
@@ -0,0 +1,315 @@
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";',
"```",
"",
"````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",
);
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 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 },
);
}
async function copySelection(page: Page): Promise<void> {
await page.keyboard.press("ControlOrMeta+c");
}
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).toContain("<ul>");
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('<ol start="5">');
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](https://example.com/issues/1)**");
expect(partialClipboard.html).toContain(
'<li><strong><a href="https://example.com/issues/1">First</a></strong></li>',
);
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("7. Seventh item\n8. Eighth item");
expect(midListClipboard.html).toContain('<ol start="7">');
await selectAssistantTextRange(page, "Inner eight", "Inner nine");
await copySelection(page);
const nestedListClipboard = await readRichClipboard(page);
expect(nestedListClipboard.plainText).toBe("3. 8. Inner eight\n 9. Inner nine");
expect(nestedListClipboard.html).toContain('<ol start="8">');
await selectAssistantTextRange(page, "Seventh item", "Nested list:");
await copySelection(page);
const crossBlockClipboard = await readRichClipboard(page);
expect(crossBlockClipboard.plainText).toBe("7. Seventh item\n8. Eighth item\n\nNested list:");
expect(crossBlockClipboard.html).toContain('<ol start="7">');
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("&lt;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>");
} finally {
await agent.cleanup();
}
});
+3
View File
@@ -113,6 +113,8 @@
"react-native-webview": "^13.16.0",
"react-native-worklets": "~0.8.3",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"use-sync-external-store": "^1.6.0",
"zod": "^4.4.3",
"zustand": "^5.0.9"
@@ -124,6 +126,7 @@
"@types/markdown-it": "^14.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
"@types/turndown": "^5.0.6",
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
+3 -2
View File
@@ -84,6 +84,7 @@ import {
type BottomAnchorRouteRequest,
} from "./bottom-anchor-controller";
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
@@ -976,7 +977,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return (
<ToolCallSheetProvider>
<View style={stylesheet.container}>
<AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({
agentId,
@@ -1021,7 +1022,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
</Animated.View>
</View>
)}
</View>
</AssistantSelectionCopySurface>
</ToolCallSheetProvider>
);
},
+13 -1
View File
@@ -16,6 +16,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useStableEvent } from "@/hooks/use-stable-event";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
import { useAssistantFileLinkResolverContext } from "./provider";
import type { AssistantFileLinkSource } from "./resolver";
import { useFileLink } from "./use-file-link";
@@ -27,6 +28,11 @@ interface AssistantMarkdownLinkProps {
children: ReactNode;
}
const MARKDOWN_CODE_LINK_DATASET = {
...CODE_SURFACE_DATASET,
...markdownCopyDataSet.code,
} as const;
export function AssistantMarkdownLink({
source,
style,
@@ -62,6 +68,7 @@ export function AssistantMarkdownLink({
() => ({ onPress, accessibilityRole: "link" }),
[onPress],
);
const unwrapForMarkdownCopy = source.sourceType === "inline-code" || source.markup === "linkify";
if (isNative) {
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
@@ -100,7 +107,9 @@ export function AssistantMarkdownLink({
const anchor = (
<a
{...(unwrapForMarkdownCopy ? { "data-paseo-markdown-unwrap": "true" } : {})}
href={source.href}
title={source.title}
onClickCapture={handleAnchorClickCapture}
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
@@ -111,7 +120,10 @@ export function AssistantMarkdownLink({
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
<Text dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined} style={hoveredTextStyle}>
<Text
dataSet={monoSurface ? MARKDOWN_CODE_LINK_DATASET : undefined}
style={hoveredTextStyle}
>
{children}
</Text>
</Pressable>
@@ -9,6 +9,7 @@ import { i18n } from "@/i18n/i18next";
export interface AssistantFileLinkSource {
href: string;
text?: string;
title?: string;
markup?: string;
sourceInfo?: string;
sourceType?: "inline-code";
@@ -242,10 +242,10 @@ function canResolveAssistantFileLinkToFile(
}
function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSource {
const { href, text, markup, sourceInfo, sourceType } = source;
const { href, text, title, markup, sourceInfo, sourceType } = source;
return useMemo(
() => ({ href, text, markup, sourceInfo, sourceType }),
[href, text, markup, sourceInfo, sourceType],
() => ({ href, text, title, markup, sourceInfo, sourceType }),
[href, text, title, markup, sourceInfo, sourceType],
);
}
@@ -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>
);
}
@@ -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: (
+5
View File
@@ -0,0 +1,5 @@
declare module "turndown-plugin-gfm" {
import type TurndownService from "turndown";
export const gfm: TurndownService.Plugin;
}
@@ -67,6 +67,14 @@ describe("splitMarkdownBlocks", () => {
]);
});
it("keeps a loose nested list in its outer list block", () => {
expect(
splitMarkdownBlocks(
"Before\n\n3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n\nAfter",
),
).toEqual(["Before", "3. Outer three\n\n 7. Inner seven\n 8. Inner eight", "After"]);
});
it("treats triple newlines as a split point and filters empty blocks", () => {
expect(splitMarkdownBlocks("First paragraph\n\n\nSecond paragraph")).toEqual([
"First paragraph",
@@ -0,0 +1,15 @@
import MarkdownIt from "markdown-it";
export function createAssistantMarkdownParser(): MarkdownIt {
const parser = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
});
const defaultValidateLink = parser.validateLink.bind(parser);
parser.validateLink = (url: string) =>
url.trim().toLowerCase().startsWith("file://") || defaultValidateLink(url);
return parser;
}
@@ -74,6 +74,20 @@ describe("createMarkdownClipboardContent", () => {
expect(content.html).not.toContain('href="javascript:');
expect(content.html).toContain("&lt;script&gt;");
});
it("preserves assistant file links without allowing unsafe link schemes", () => {
const content = createMarkdownClipboardContent(
[
"[file](file:///tmp/paseo%20notes.md#L4)",
'[javascript](javascript:alert("x"))',
"[data](data:text/html,unsafe)",
"[vbscript](vbscript:msgbox(1))",
].join("\n\n"),
);
expect(content.html).toContain('<a href="file:///tmp/paseo%20notes.md#L4">file</a>');
expect(content.html).not.toMatch(/href="(?:javascript|data|vbscript):/);
});
});
describe("writeMarkdownToRichClipboard", () => {
+2 -6
View File
@@ -1,10 +1,6 @@
import MarkdownIt from "markdown-it";
import { createAssistantMarkdownParser } from "./assistant-markdown-parser";
const markdownRenderer = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
});
const markdownRenderer = createAssistantMarkdownParser();
type ClipboardMimeType = "text/plain" | "text/html";
+29 -25
View File
@@ -1,7 +1,6 @@
function getFenceDelimiter(line: string) {
const match = /^( {0,3})(`{3,}|~{3,})/.exec(line);
return match?.[2] ?? null;
}
import MarkdownIt from "markdown-it";
const markdownBlockParser = new MarkdownIt();
export function splitMarkdownBlocks(text: string): string[] {
if (text.length === 0) {
@@ -10,43 +9,32 @@ export function splitMarkdownBlocks(text: string): string[] {
const blocks: string[] = [];
let currentLines: string[] = [];
let activeFenceCharacter: "`" | "~" | null = null;
let activeFenceLength = 0;
let sawBlockSeparator = false;
const lines = text.split("\n");
const structuralBlankLines = getStructuralBlankLines(text, lines);
for (const line of text.split("\n")) {
for (const [index, line] of lines.entries()) {
const isBlankLine = line.trim().length === 0;
if (!activeFenceCharacter && isBlankLine) {
if (isBlankLine && structuralBlankLines.has(index)) {
currentLines.push(line);
continue;
}
if (isBlankLine) {
if (currentLines.length > 0) {
sawBlockSeparator = true;
}
continue;
}
if (!activeFenceCharacter && sawBlockSeparator) {
if (sawBlockSeparator) {
blocks.push(currentLines.join("\n"));
currentLines = [];
sawBlockSeparator = false;
}
currentLines.push(line);
const fenceDelimiter = getFenceDelimiter(line);
if (!fenceDelimiter) {
continue;
}
if (!activeFenceCharacter) {
activeFenceCharacter = fenceDelimiter[0] as "`" | "~";
activeFenceLength = fenceDelimiter.length;
continue;
}
if (fenceDelimiter[0] === activeFenceCharacter && fenceDelimiter.length >= activeFenceLength) {
activeFenceCharacter = null;
activeFenceLength = 0;
}
}
if (currentLines.length > 0) {
@@ -55,3 +43,19 @@ export function splitMarkdownBlocks(text: string): string[] {
return blocks.filter((block) => block.length > 0);
}
function getStructuralBlankLines(text: string, lines: string[]): Set<number> {
const blankLines = new Set<number>();
for (const token of markdownBlockParser.parse(text, {})) {
if (token.level !== 0 || !token.map) {
continue;
}
const [start, end] = token.map;
for (let index = start; index < end - 1; index += 1) {
if (lines[index]?.trim().length === 0) {
blankLines.add(index);
}
}
}
return blankLines;
}
@@ -614,6 +614,7 @@ export class MockLoadTestAgentSession implements AgentSession {
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private modeId: string | null;
private modelId: string | null;
private readonly assistantResponse: string | null;
private readonly rewindError: string | null;
private remainingPromptRejections: number;
@@ -622,6 +623,10 @@ export class MockLoadTestAgentSession implements AgentSession {
this.logger = options.logger;
this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID;
this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
this.assistantResponse =
typeof options.config.featureValues?.mockAssistantResponse === "string"
? options.config.featureValues.mockAssistantResponse
: null;
this.rewindError =
typeof options.config.featureValues?.mockRewindError === "string"
? options.config.featureValues.mockRewindError
@@ -687,6 +692,8 @@ export class MockLoadTestAgentSession implements AgentSession {
const scheduleTurn = () => {
if (shouldEmitTurnFailure(prompt)) {
this.scheduleFailedTurn(turn);
} else if (this.assistantResponse !== null) {
this.scheduleSettledAssistantTurn(turn, this.assistantResponse);
} else if (structuredBranchName) {
this.scheduleSettledAssistantTurn(turn, JSON.stringify(structuredBranchName));
} else if (settledAssistantImageMarkdown) {