diff --git a/apps/desktop/src/lib/chat-messages/parts.ts b/apps/desktop/src/lib/chat-messages/parts.ts index 07a06ad38c..997b61a797 100644 --- a/apps/desktop/src/lib/chat-messages/parts.ts +++ b/apps/desktop/src/lib/chat-messages/parts.ts @@ -34,8 +34,6 @@ export function renderMediaTags(text: string): string { (_match, lead: string, value: string, trailer: string) => `${lead}${mediaLink(value)}${trailer}` ) .replace(MEDIA_TAG_RE, (_match, value: string) => mediaLink(value)) - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') } export function assistantTextPart(text: string, timestamp?: number): ChatMessagePart { diff --git a/apps/desktop/src/lib/embedded-images.ts b/apps/desktop/src/lib/embedded-images.ts index 57578724df..7100b81d18 100644 --- a/apps/desktop/src/lib/embedded-images.ts +++ b/apps/desktop/src/lib/embedded-images.ts @@ -105,13 +105,6 @@ function embeddedImageRemovalRange(text: string, dataStart: number, dataEnd: num return { end, start } } -function normalizeCleanedText(text: string): string { - return text - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim() -} - export function extractEmbeddedImages(text: string): EmbeddedImageExtraction { if (!text || !text.includes(DATA_IMAGE_PREFIX)) { return { cleanedText: text, images: [] } @@ -150,7 +143,7 @@ export function extractEmbeddedImages(text: string): EmbeddedImageExtraction { pieces.push(text.slice(appendCursor)) - return { cleanedText: normalizeCleanedText(pieces.join('')), images } + return { cleanedText: pieces.join('').trim(), images } } export function embeddedImageUrls(text: string): string[] { diff --git a/apps/desktop/src/lib/generated-images.ts b/apps/desktop/src/lib/generated-images.ts index 69b315738e..47558a0c6c 100644 --- a/apps/desktop/src/lib/generated-images.ts +++ b/apps/desktop/src/lib/generated-images.ts @@ -82,17 +82,16 @@ export function stripGeneratedImageEchoes(text: string, sources: readonly string return text } - let next = text.replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, '').replace(/\[[^\]\n]*\]\(\s*#media:[^)\n]*\)/g, '') + // Join only the gap left by an image, never normalize unrelated Markdown or code. + let next = text + .replace(/([ \t]?)!\[[^\]\n]*\]\([^)\n]*\)([ \t]?)/g, (_match, before: string, after: string) => before || after) + .replace(/\[[^\]\n]*\]\(\s*#media:[^)\n]*\)/g, '') for (const source of unique([...sources])) { next = next.replace(new RegExp(String.raw`(^|[\s([{])?(?=$|[\s)\]},.!?])`, 'g'), '$1') } - return next - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .replace(/[ \t]{2,}/g, ' ') - .trim() + return next.trim() } /** Strip generated-image echoes from text parts, dropping any part left empty. diff --git a/apps/desktop/src/lib/markdown-preprocess.ts b/apps/desktop/src/lib/markdown-preprocess.ts index 7533a50050..154da79ba1 100644 --- a/apps/desktop/src/lib/markdown-preprocess.ts +++ b/apps/desktop/src/lib/markdown-preprocess.ts @@ -664,7 +664,6 @@ export function preprocessMarkdown(text: string): string { return leading + transformed + trailing }) .join('') - .replace(/[ \t]+\n/g, '\n') } /** diff --git a/apps/desktop/src/lib/markdown-whitespace.test.ts b/apps/desktop/src/lib/markdown-whitespace.test.ts new file mode 100644 index 0000000000..fdfa5a39ba --- /dev/null +++ b/apps/desktop/src/lib/markdown-whitespace.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import { assistantTextPart, renderMediaTags } from './chat-messages/parts' +import { extractEmbeddedImages } from './embedded-images' +import { stripGeneratedImageEchoes } from './generated-images' +import { preprocessMarkdown } from './markdown-preprocess' +import { stripPreviewTargets } from './preview-targets' + +describe('Markdown whitespace semantics', () => { + it('preserves hard and soft breaks and fenced whitespace through display preprocessing', () => { + for (const input of ['First line \nSecond line', 'Soft first\nSoft second', '```python\nvalue = 1 \nvalue = 2 \n```']) { + expect(preprocessMarkdown(input)).toBe(input) + } + }) + + it('preserves prose hard breaks when media and preview extraction runs', () => { + const prose = 'First line \nSecond line\n\n```python\nvalue = 1 \nvalue = 2 \n\n\nvalue = 3\n```' + const image = 'data:image/png;base64,' + 'A'.repeat(64) + expect(stripPreviewTargets(prose + '\n\n[Preview: x](#preview:test)')).toContain(prose) + expect(renderMediaTags(prose + '\n\nMEDIA: /tmp/image.png')).toContain(prose) + expect(assistantTextPart(prose)).toMatchObject({ type: 'text', text: prose }) + expect(extractEmbeddedImages(prose + '\n\n' + image).cleanedText).toContain(prose) + expect(stripGeneratedImageEchoes(prose + '\n\n![result](/tmp/image.png)', ['/tmp/image.png'])).toContain(prose) + }) +}) diff --git a/apps/desktop/src/lib/preview-targets.ts b/apps/desktop/src/lib/preview-targets.ts index bc7108abd4..5a850f5a53 100644 --- a/apps/desktop/src/lib/preview-targets.ts +++ b/apps/desktop/src/lib/preview-targets.ts @@ -1,11 +1,7 @@ const PREVIEW_MARKDOWN_RE = /\[Preview:[^\]]+\]\((?#preview[:/][^)]+)\)/gi export function stripPreviewTargets(text: string): string { - return text - .replace(PREVIEW_MARKDOWN_RE, '') - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim() + return text.replace(PREVIEW_MARKDOWN_RE, '').trim() } export function extractPreviewTargets(text: string): string[] { diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index b0fdc06b09..7ba3e1a708 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -41,6 +41,7 @@ The desktop app is organized as a chat-first window with a left sidebar for navi The center of the app. You get: - **Streaming responses** with live tool activity and structured tool-call summaries as the agent works. +- **Markdown line breaks** follow Markdown semantics: two trailing spaces create a hard line break; an ordinary newline stays a soft break. Media and preview extraction preserve these breaks and whitespace inside fenced code blocks. - **The same conversation history** as every other Hermes surface — sessions started here resume in the CLI/TUI and vice versa. - **Drag-and-drop files** anywhere in the chat area to attach them to your next message. - **A right-hand preview rail** — render web pages, files, and tool outputs side by side while you keep chatting.