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 <yulia.ivashko@gmail.com>
This commit is contained in:
ChangeHow
2026-08-27 19:37:49 +03:00
committed by GitHub
co-authored by Iuliia Ivashko
parent 2a1c10cb3e
commit 7aae5a6634
6 changed files with 277 additions and 41 deletions
@@ -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<ChatMessageProps> = ({
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) {
@@ -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<HTMLElement>('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<Document, MarkdownCopyState>();
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);
};
};
+4
View File
@@ -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;
}
+51
View File
@@ -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<string, Blob> } | undefined;
class FakeClipboardItem {
static supports(type: string): boolean {
return type === 'text/markdown';
}
readonly data: Record<string, Blob>;
constructor(data: Record<string, Blob>) {
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<string, Blob> }>) => {
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<typeof flattenAssistantTextParts>[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('<p>第一段</p>');
expect(htmlText).toContain('<p>第二段</p>');
expect(htmlText).not.toContain('<p>第一段\n第二段</p>');
});
});
@@ -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<string, unknown>): 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('');
});
});
+31 -2
View File
@@ -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 => {