From 7aae5a6634f0973f29eacf97ee1bb4f5febac13c Mon Sep 17 00:00:00 2001 From: ChangeHow <23733347+ChangeHow@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:37:49 +0800 Subject: [PATCH] fix: preserve Markdown blocks when copying messages (#2944) * fix: preserve Markdown blocks when copying messages * fix: preserve blank lines when copying code --------- Co-authored-by: Iuliia Ivashko --- .../ui/src/components/chat/ChatMessage.tsx | 37 +----- .../src/components/chat/markdown/decorate.ts | 71 +++++++++- packages/ui/src/index.css | 4 + packages/ui/src/lib/clipboard.test.ts | 51 ++++++++ .../ui/src/lib/messages/messageText.test.ts | 122 ++++++++++++++++++ packages/ui/src/lib/messages/messageText.ts | 33 ++++- 6 files changed, 277 insertions(+), 41 deletions(-) create mode 100644 packages/ui/src/lib/messages/messageText.test.ts diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 20a86cde..8144b0a9 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -21,7 +21,7 @@ import { deriveMessageRole } from './message/messageRole'; import { filterVisibleParts, normalizeParts } from './message/partUtils'; import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts'; import { isHiddenUserMessage } from './message/hiddenUserMessage'; -import { flattenAssistantTextParts } from '@/lib/messages/messageText'; +import { flattenAssistantTextParts, flattenUserTextParts } from '@/lib/messages/messageText'; import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError'; import { getProviderModelDisplayName } from '@/lib/modelDisplay'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; @@ -702,40 +702,7 @@ const ChatMessage: React.FC = ({ const messageTextContent = React.useMemo(() => { if (isUser) { - const shellOutputs = displayParts - .filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text') - .map((part) => { - const output = part.shellAction?.output; - return typeof output === 'string' ? output.trim() : ''; - }) - .filter((output) => output.length > 0); - - if (shellOutputs.length > 0) { - return shellOutputs.join('\n\n'); - } - - const shellCommands = displayParts - .filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text') - .map((part) => { - const command = part.shellAction?.command; - return typeof command === 'string' ? command.trim() : ''; - }) - .filter((command) => command.length > 0); - - if (shellCommands.length > 0) { - return shellCommands.join('\n'); - } - - const textParts = displayParts - .filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text') - .map((part) => { - const text = part.text || part.content || ''; - return text.trim(); - }) - .filter((text) => text.length > 0); - - const combined = textParts.join('\n'); - return combined.replace(/\n\s*\n+/g, '\n'); + return flattenUserTextParts(displayParts); } if (assistantErrorText && assistantErrorText.trim().length > 0) { diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts index 8981ebce..1ab6a6bd 100644 --- a/packages/ui/src/components/chat/markdown/decorate.ts +++ b/packages/ui/src/components/chat/markdown/decorate.ts @@ -156,9 +156,8 @@ const layoutCodeLines = (pre: HTMLPreElement): void => { row.setAttribute('data-md-code-line', ''); const number = document.createElement('span'); - number.setAttribute('data-md-code-line-number', ''); + number.setAttribute('data-md-code-line-number', String(index + 1)); number.setAttribute('aria-hidden', 'true'); - number.textContent = String(index + 1); const content = document.createElement('span'); content.setAttribute('data-md-code-line-content', ''); @@ -168,7 +167,6 @@ const layoutCodeLines = (pre: HTMLPreElement): void => { } else { content.textContent = sourceLine; } - row.append(number, content); fragment.appendChild(row); if (index < sourceLines.length - 1 || hasTrailingNewline) { @@ -543,6 +541,67 @@ const closeAllMenus = (container: HTMLElement): void => { } }; +const getContainingMarkdownCode = (node: Node): HTMLElement | null => { + const element = node.nodeType === 1 ? node as Element : node.parentElement; + return element?.closest('pre code[data-md-code-lines]') ?? null; +}; + +const getMarkdownCodeSelectionText = (range: Range): string | null => { + const code = getContainingMarkdownCode(range.startContainer); + if (!code || code !== getContainingMarkdownCode(range.endContainer)) return null; + // Line numbers are CSS-generated, so the DOM range is already the exact + // source selection, including boundaries between rows and empty lines. + return range.toString(); +}; + +type MarkdownCopyState = { + registrations: number; + handler: (event: ClipboardEvent) => void; + menuHandler: (event: Event) => void; +}; + +const markdownCopyStates = new WeakMap(); + +const registerMarkdownCodeCopy = (doc: Document): (() => void) => { + let state = markdownCopyStates.get(doc); + if (!state) { + const getSelectedText = (): string | null => { + const selection = doc.getSelection(); + if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null; + return getMarkdownCodeSelectionText(selection.getRangeAt(0)); + }; + const handler = (event: ClipboardEvent) => { + if (!event.clipboardData) return; + const text = getSelectedText(); + if (text === null) return; + event.preventDefault(); + event.stopPropagation(); + event.clipboardData.setData('text/plain', text); + }; + const menuHandler = (event: Event) => { + const text = getSelectedText(); + if (text === null) return; + event.preventDefault(); + void copyTextToClipboard(text); + }; + state = { registrations: 0, handler, menuHandler }; + markdownCopyStates.set(doc, state); + doc.addEventListener('copy', handler, true); + doc.defaultView?.addEventListener('openchamber:copy', menuHandler); + } + state.registrations += 1; + + return () => { + const current = markdownCopyStates.get(doc); + if (!current) return; + current.registrations -= 1; + if (current.registrations > 0) return; + doc.removeEventListener('copy', current.handler, true); + doc.defaultView?.removeEventListener('openchamber:copy', current.menuHandler); + markdownCopyStates.delete(doc); + }; +}; + /** * Attach a single delegated click listener for all in-markdown actions: code * copy, table copy/download menus, mermaid copy/download, loopback preview. @@ -552,6 +611,7 @@ export const attachMarkdownInteractions = ( container: HTMLElement, ctx: DecorateContext, ): (() => void) => { + const unregisterCodeCopy = registerMarkdownCodeCopy(container.ownerDocument); const handleClick = (event: MouseEvent) => { const target = event.target; if (!(target instanceof Element)) return; @@ -658,5 +718,8 @@ export const attachMarkdownInteractions = ( }; container.addEventListener('click', handleClick); - return () => container.removeEventListener('click', handleClick); + return () => { + unregisterCodeCopy(); + container.removeEventListener('click', handleClick); + }; }; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 16d067b2..265f77be 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1432,6 +1432,10 @@ html:not(.dark) .chat-scroll { white-space: nowrap; } +.markdown-content [data-md-code-line-number]::before { + content: attr(data-md-code-line-number); +} + .markdown-content [data-md-code-line-content] { min-width: 0; } diff --git a/packages/ui/src/lib/clipboard.test.ts b/packages/ui/src/lib/clipboard.test.ts index db92ba5c..de395381 100644 --- a/packages/ui/src/lib/clipboard.test.ts +++ b/packages/ui/src/lib/clipboard.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, test } from 'bun:test'; +import { marked } from 'marked'; + import { copyMarkdownToClipboard } from './clipboard'; +import { flattenAssistantTextParts } from './messages/messageText'; const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); const originalClipboardItem = Object.getOwnPropertyDescriptor(globalThis, 'ClipboardItem'); @@ -84,4 +87,52 @@ describe('copyMarkdownToClipboard', () => { expect(result).toEqual({ ok: true, method: 'clipboard' }); expect(fallbackText).toBe('# title'); }); + + test('assistant copy payload keeps Markdown block separation in every clipboard format', async () => { + let writtenItem: { data: Record } | undefined; + class FakeClipboardItem { + static supports(type: string): boolean { + return type === 'text/markdown'; + } + + readonly data: Record; + + constructor(data: Record) { + this.data = data; + } + } + + Object.defineProperty(globalThis, 'ClipboardItem', { configurable: true, value: FakeClipboardItem }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + clipboard: { + write: async (items: Array<{ data: Record }>) => { + writtenItem = items[0]; + }, + }, + }, + }); + + const parts = [ + { id: 'p0', sessionID: 's', messageID: 'm', type: 'text', text: '第一段' }, + { id: 'p1', sessionID: 's', messageID: 'm', type: 'text', text: '第二段' }, + { id: 'p2', sessionID: 's', messageID: 'm', type: 'text', text: '```js\nconsole.log(1)\n\n\nconsole.log(2)\n```' }, + { id: 'p3', sessionID: 's', messageID: 'm', type: 'text', text: '第三段' }, + ]; + + // Same path as ChatMessage.tsx handleCopyMessage: + const text = flattenAssistantTextParts(parts as Parameters[0]); + const html = marked.parse(text, { gfm: true, breaks: false }) as string; + const result = await copyMarkdownToClipboard(text, html); + + const expected = '第一段\n\n第二段\n\n```js\nconsole.log(1)\n\n\nconsole.log(2)\n```\n\n第三段'; + expect(result).toEqual({ ok: true, method: 'clipboard' }); + expect(await writtenItem?.data['text/plain']?.text()).toBe(expected); + expect(await writtenItem?.data['text/markdown']?.text()).toBe(expected); + const htmlText = await writtenItem?.data['text/html']?.text(); + expect(htmlText).toContain('

第一段

'); + expect(htmlText).toContain('

第二段

'); + expect(htmlText).not.toContain('

第一段\n第二段

'); + }); }); diff --git a/packages/ui/src/lib/messages/messageText.test.ts b/packages/ui/src/lib/messages/messageText.test.ts new file mode 100644 index 00000000..f62afad1 --- /dev/null +++ b/packages/ui/src/lib/messages/messageText.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from 'bun:test'; + +import type { Part } from '@opencode-ai/sdk/v2'; +import { flattenAssistantTextParts, flattenUserTextParts } from './messageText'; + +// Regression tests for https://github.com/openchamber/openchamber/issues/2867 +// +// `flattenAssistantTextParts` used to collapse every blank line into a single +// `\n`. Markdown block structure (paragraphs, lists, fenced code blocks) +// requires a blank line (`\n\n`); a single `\n` is a CommonMark soft break. +// `ChatMessage.tsx`'s `handleCopyMessage` feeds the flattened string into +// `copyMarkdownToClipboard`, which writes it to `text/plain`, `text/markdown` +// and its markdown-rendered HTML into `text/html`. + +const basePart = (overrides: Record): Part => + ({ + id: 'p1', + sessionID: 's', + messageID: 'm', + type: 'text', + text: '', + ...overrides, + }) as Part; + +const makeParts = (texts: string[]): Part[] => + texts.map((text, index) => basePart({ id: `p${index}`, text })); + +const makeUserParts = ( + entries: Array<{ text?: string; shellAction?: { output?: unknown; command?: unknown } }>, +): Part[] => + entries.map((entry, index) => + basePart({ id: `u${index}`, text: entry.text ?? '', shellAction: entry.shellAction }), + ); + +describe('flattenAssistantTextParts', () => { + const parts = makeParts([ + '第一段', + '第二段', + '```js\nconsole.log(1)\n```', + '第三段', + '- item 1\n- item 2', + ]); + + test('blank lines between paragraphs/code blocks/lists are preserved', () => { + expect(flattenAssistantTextParts(parts)).toBe( + '第一段\n\n第二段\n\n```js\nconsole.log(1)\n```\n\n第三段\n\n- item 1\n- item 2', + ); + }); + + test('a code fence is not glued to the following paragraph', () => { + const flattened = flattenAssistantTextParts(parts); + expect(flattened).not.toContain('```\n第三段'); + expect(flattened).toContain('```\n\n第三段'); + }); + + test('list items keep single newlines inside their part', () => { + expect(flattenAssistantTextParts(parts)).toContain('\n\n- item 1\n- item 2'); + }); + + test('internal blank-line runs are preserved', () => { + const text = 'a\n\n\n\nb\n \n \nd'; + expect(flattenAssistantTextParts(makeParts([text]))).toBe(text); + }); + + test('multiple blank lines inside a fenced code block are preserved', () => { + const fenced = '```js\na\n\n\nb\n```'; + expect(flattenAssistantTextParts(makeParts([fenced]))).toBe(fenced); + }); + + test('part boundaries produce block separators', () => { + expect(flattenAssistantTextParts(makeParts(['first', 'second']))).toBe('first\n\nsecond'); + }); + + test('empty and whitespace-only parts are dropped', () => { + expect(flattenAssistantTextParts([])).toBe(''); + expect(flattenAssistantTextParts(makeParts(['', ' ', '\n']))).toBe(''); + }); + + test('single part without blank lines is returned unchanged', () => { + const single = 'only line\nsecond line'; + expect(flattenAssistantTextParts(makeParts([single]))).toBe(single); + }); + + test('non-text parts are ignored', () => { + const partsWithTool: Part[] = [ + ...makeParts(['before']), + { id: 't1', sessionID: 's', messageID: 'm', type: 'tool', tool: 'bash' } as Part, + ...makeParts(['after']), + ]; + expect(flattenAssistantTextParts(partsWithTool)).toBe('before\n\nafter'); + }); +}); + +describe('flattenUserTextParts', () => { + test('plain text parts keep blank-line block separators', () => { + const parts = makeUserParts([{ text: '第一段\n\n\n第二段' }, { text: '下一段' }]); + expect(flattenUserTextParts(parts)).toBe('第一段\n\n\n第二段\n\n下一段'); + }); + + test('shell outputs win over other content and are joined with blank lines', () => { + const parts = makeUserParts([ + { text: 'note', shellAction: { command: 'ls -la' } }, + { text: '', shellAction: { output: ' file-a\nfile-b ' } }, + { text: '', shellAction: { output: 'done' } }, + ]); + expect(flattenUserTextParts(parts)).toBe('file-a\nfile-b\n\ndone'); + }); + + test('shell commands fall back to a single-newline command list', () => { + const parts = makeUserParts([ + { shellAction: { command: ' bun install ' } }, + { shellAction: { command: 'bun test' } }, + { text: 'ignored when commands exist' }, + ]); + expect(flattenUserTextParts(parts)).toBe('bun install\nbun test'); + }); + + test('returns empty string for parts without text', () => { + expect(flattenUserTextParts([])).toBe(''); + expect(flattenUserTextParts(makeUserParts([{ text: ' ' }]))).toBe(''); + }); +}); diff --git a/packages/ui/src/lib/messages/messageText.ts b/packages/ui/src/lib/messages/messageText.ts index 8bca994f..30579536 100644 --- a/packages/ui/src/lib/messages/messageText.ts +++ b/packages/ui/src/lib/messages/messageText.ts @@ -1,6 +1,7 @@ import type { Part } from '@opencode-ai/sdk/v2'; type TextLikePart = Part & { text?: string; content?: string }; +type UserTextPart = Part & { text?: string; content?: string; shellAction?: { output?: unknown; command?: unknown } }; export const flattenAssistantTextParts = (parts: Part[]): string => { const textParts = parts @@ -8,8 +9,36 @@ export const flattenAssistantTextParts = (parts: Part[]): string => { .map((part) => (part.text || part.content || '').trim()) .filter((text) => text.length > 0); - const combined = textParts.join('\n'); - return combined.replace(/\n\s*\n+/g, '\n'); + return textParts.join('\n\n'); +}; + +export const flattenUserTextParts = (parts: Part[]): string => { + const textParts = parts.filter((part): part is UserTextPart => part?.type === 'text'); + + const shellOutputs = textParts + .map((part) => { + const output = part.shellAction?.output; + return typeof output === 'string' ? output.trim() : ''; + }) + .filter((output) => output.length > 0); + if (shellOutputs.length > 0) { + return shellOutputs.join('\n\n'); + } + + const shellCommands = textParts + .map((part) => { + const command = part.shellAction?.command; + return typeof command === 'string' ? command.trim() : ''; + }) + .filter((command) => command.length > 0); + if (shellCommands.length > 0) { + return shellCommands.join('\n'); + } + + const plainTexts = textParts + .map((part) => (part.text || part.content || '').trim()) + .filter((text) => text.length > 0); + return plainTexts.join('\n\n'); }; export const suggestPlanTitleFromText = (text: string): string => {