From 83f205bd3d275f869a6f79afb123ab67f2384971 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 24 May 2026 12:10:09 +0800 Subject: [PATCH] Preserve assistant message formatting on copy --- package-lock.json | 27 ++++++ packages/app/package.json | 2 + packages/app/src/components/message.tsx | 4 +- packages/app/src/utils/rich-clipboard.test.ts | 97 +++++++++++++++++++ packages/app/src/utils/rich-clipboard.ts | 81 ++++++++++++++++ 5 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/utils/rich-clipboard.test.ts create mode 100644 packages/app/src/utils/rich-clipboard.ts diff --git a/package-lock.json b/package-lock.json index 886e54871..07a05ec58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12358,6 +12358,24 @@ "@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": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -12367,6 +12385,13 @@ "@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": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -36348,6 +36373,7 @@ "expo-updates": "~29.0.12", "fast-deep-equal": "^3.1.3", "lucide-react-native": "^0.546.0", + "markdown-it": "^10.0.0", "mnemonic-id": "^3.2.7", "qrcode": "^1.5.4", "react": "19.1.0", @@ -36375,6 +36401,7 @@ "devDependencies": { "@playwright/test": "^1.56.1", "@testing-library/react": "^16.3.2", + "@types/markdown-it": "^14.1.2", "@types/qrcode": "^1.5.6", "@types/react": "~19.2.0", "@types/ws": "^8.18.1", diff --git a/packages/app/package.json b/packages/app/package.json index ca17374d7..3302620f4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -76,6 +76,7 @@ "expo-updates": "~29.0.12", "fast-deep-equal": "^3.1.3", "lucide-react-native": "^0.546.0", + "markdown-it": "^10.0.0", "mnemonic-id": "^3.2.7", "qrcode": "^1.5.4", "react": "19.1.0", @@ -103,6 +104,7 @@ "devDependencies": { "@playwright/test": "^1.56.1", "@testing-library/react": "^16.3.2", + "@types/markdown-it": "^14.1.2", "@types/qrcode": "^1.5.6", "@types/react": "~19.2.0", "@types/ws": "^8.18.1", diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 5cf4f99a0..3b96077ec 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -62,7 +62,6 @@ import Animated, { import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; import { createMarkdownStyles } from "@/styles/markdown-styles"; import { Fonts } from "@/constants/theme"; -import * as Clipboard from "expo-clipboard"; import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream"; import type { AgentAttachment } from "@server/shared/messages"; 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 { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; +import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { getAssistantImageLoadStateFromMetadata, getAssistantImageMetadata, @@ -1117,7 +1117,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({ return; } - await Clipboard.setStringAsync(content); + await writeMarkdownToRichClipboard(content); setCopied(true); if (copyTimeoutRef.current) { diff --git a/packages/app/src/utils/rich-clipboard.test.ts b/packages/app/src/utils/rich-clipboard.test.ts new file mode 100644 index 000000000..b204f3786 --- /dev/null +++ b/packages/app/src/utils/rich-clipboard.test.ts @@ -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("

Heading

"); + expect(content.html).toContain(""); + expect(content.html).toContain("