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:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
2a1c10cb3e
commit
7aae5a6634
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user