Preserve assistant message formatting on copy
This commit is contained in:
Generated
+27
@@ -12358,6 +12358,24 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/markdown-it": {
|
||||||
|
"version": "14.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||||
|
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^5",
|
||||||
|
"@types/mdurl": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/mdast": {
|
"node_modules/@types/mdast": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||||
@@ -12367,6 +12385,13 @@
|
|||||||
"@types/unist": "*"
|
"@types/unist": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
@@ -36348,6 +36373,7 @@
|
|||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
|
"markdown-it": "^10.0.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -36375,6 +36401,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
|
"markdown-it": "^10.0.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -103,6 +104,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import Animated, {
|
|||||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
|
||||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||||
import type { AgentAttachment } from "@server/shared/messages";
|
import type { AgentAttachment } from "@server/shared/messages";
|
||||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||||
@@ -73,6 +72,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
|||||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||||
|
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||||
import {
|
import {
|
||||||
getAssistantImageLoadStateFromMetadata,
|
getAssistantImageLoadStateFromMetadata,
|
||||||
getAssistantImageMetadata,
|
getAssistantImageMetadata,
|
||||||
@@ -1117,7 +1117,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Clipboard.setStringAsync(content);
|
await writeMarkdownToRichClipboard(content);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
|
|
||||||
if (copyTimeoutRef.current) {
|
if (copyTimeoutRef.current) {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createMarkdownClipboardContent,
|
||||||
|
type RichClipboardWriter,
|
||||||
|
writeMarkdownToRichClipboard,
|
||||||
|
} from "./rich-clipboard";
|
||||||
|
|
||||||
|
const { setStringAsyncMock } = vi.hoisted(() => ({
|
||||||
|
setStringAsyncMock: vi.fn(async () => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("expo-clipboard", () => ({
|
||||||
|
setStringAsync: setStringAsyncMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setStringAsyncMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createMarkdownClipboardContent", () => {
|
||||||
|
it("renders markdown structures to clipboard html", () => {
|
||||||
|
const markdown = [
|
||||||
|
"# Heading",
|
||||||
|
"",
|
||||||
|
"| Name | Value |",
|
||||||
|
"| --- | --- |",
|
||||||
|
"| One | Two |",
|
||||||
|
"",
|
||||||
|
"- Parent",
|
||||||
|
" - Child",
|
||||||
|
"",
|
||||||
|
"```ts",
|
||||||
|
"const value = 1;",
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const content = createMarkdownClipboardContent(markdown);
|
||||||
|
|
||||||
|
expect(content.plainText).toBe(markdown);
|
||||||
|
expect(content.html).toContain("<h1>Heading</h1>");
|
||||||
|
expect(content.html).toContain("<table>");
|
||||||
|
expect(content.html).toContain("<ul>");
|
||||||
|
expect(content.html).toContain('class="language-ts"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes raw html instead of placing it on the rich clipboard", () => {
|
||||||
|
const content = createMarkdownClipboardContent(
|
||||||
|
'<script>alert("x")</script>\n\n[jump](javascript:alert("x"))',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(content.html).not.toContain("<script>");
|
||||||
|
expect(content.html).not.toContain('href="javascript:');
|
||||||
|
expect(content.html).toContain("<script>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("writeMarkdownToRichClipboard", () => {
|
||||||
|
it("writes plain text and html when a rich clipboard writer is available", async () => {
|
||||||
|
const markdown = "- item";
|
||||||
|
const writes: Array<Parameters<RichClipboardWriter["write"]>[0]> = [];
|
||||||
|
const richWriter: RichClipboardWriter = {
|
||||||
|
supportsHtml: () => true,
|
||||||
|
write: async (data) => {
|
||||||
|
writes.push(data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeMarkdownToRichClipboard(markdown, {
|
||||||
|
richWriter,
|
||||||
|
writePlainText: setStringAsyncMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
const written = writes[0];
|
||||||
|
if (!written) {
|
||||||
|
throw new Error("Expected rich clipboard data to be written");
|
||||||
|
}
|
||||||
|
await expect(written["text/plain"].text()).resolves.toBe(markdown);
|
||||||
|
await expect(written["text/html"].text()).resolves.toContain("<li>item</li>");
|
||||||
|
expect(setStringAsyncMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to plain text when rich clipboard writing fails", async () => {
|
||||||
|
const richWriter: RichClipboardWriter = {
|
||||||
|
supportsHtml: () => true,
|
||||||
|
write: async () => {
|
||||||
|
throw new Error("clipboard denied");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeMarkdownToRichClipboard("**bold**", {
|
||||||
|
richWriter,
|
||||||
|
writePlainText: setStringAsyncMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setStringAsyncMock).toHaveBeenCalledWith("**bold**");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
|
const markdownRenderer = new MarkdownIt({
|
||||||
|
html: false,
|
||||||
|
linkify: true,
|
||||||
|
typographer: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type ClipboardMimeType = "text/plain" | "text/html";
|
||||||
|
|
||||||
|
export interface MarkdownClipboardContent {
|
||||||
|
plainText: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichClipboardWriter {
|
||||||
|
supportsHtml: () => boolean;
|
||||||
|
write: (data: Record<ClipboardMimeType, Blob>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownClipboardEnvironment {
|
||||||
|
richWriter?: RichClipboardWriter | null;
|
||||||
|
writePlainText: (text: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMarkdownClipboardContent(markdown: string): MarkdownClipboardContent {
|
||||||
|
return {
|
||||||
|
plainText: markdown,
|
||||||
|
html: `<meta charset="utf-8">${markdownRenderer.render(markdown)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeMarkdownToRichClipboard(
|
||||||
|
markdown: string,
|
||||||
|
environment: MarkdownClipboardEnvironment = getDefaultMarkdownClipboardEnvironment(),
|
||||||
|
): Promise<void> {
|
||||||
|
if (environment.richWriter?.supportsHtml()) {
|
||||||
|
const content = createMarkdownClipboardContent(markdown);
|
||||||
|
try {
|
||||||
|
await environment.richWriter.write({
|
||||||
|
"text/plain": new Blob([content.plainText], { type: "text/plain" }),
|
||||||
|
"text/html": new Blob([content.html], { type: "text/html" }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall through to the plain-text path. Some webviews expose rich clipboard
|
||||||
|
// APIs but deny writes depending on focus, permissions, or browser policy.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await environment.writePlainText(markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultMarkdownClipboardEnvironment(): MarkdownClipboardEnvironment {
|
||||||
|
return {
|
||||||
|
richWriter: getWebRichClipboardWriter(),
|
||||||
|
writePlainText: (text) => Clipboard.setStringAsync(text),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebRichClipboardWriter(): RichClipboardWriter | null {
|
||||||
|
if (!isWeb) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof navigator === "undefined" || typeof navigator.clipboard?.write !== "function") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof ClipboardItem === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
supportsHtml: () =>
|
||||||
|
typeof ClipboardItem.supports !== "function" || ClipboardItem.supports("text/html"),
|
||||||
|
write: async (data) => {
|
||||||
|
await navigator.clipboard.write([new ClipboardItem(data)]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user