Skip to content

Commit 8ccbd5d

Browse files
mansourMPclaude
andcommitted
fix(ios): preserve single newlines as visible line breaks in chat (#98028)
Convert single \n to \n\n in ChatMarkdownPreprocessor.normalize() so assistant responses render with visible line breaks rather than collapsing into a single blob of text. Previously, single newlines passed through to AttributedString(markdown:) which follows CommonMark rules where lone \n is a soft break (space). The fix uses a lookahead/lookbehind regex to match \n not adjacent to another \n, preserving existing \n\n paragraph breaks. Co-Authored-By: Claude <[email protected]>
1 parent 6cb82ea commit 8ccbd5d

2 files changed

Lines changed: 58 additions & 2 deletions

File tree

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,15 @@ enum ChatMarkdownPreprocessor {
218218
output = output.replacingOccurrences(of: "\r\n", with: "\n")
219219
output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n")
220220
output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n")
221+
// Convert single newlines to paragraph breaks so they render as
222+
// visible line breaks rather than soft breaks (spaces) in markdown.
223+
// Uses a lookahead/lookbehind regex to match \n that is NOT preceded
224+
// or followed by another \n, preserving intentional \n\n paragraph
225+
// breaks already present in the source.
226+
output = output.replacingOccurrences(
227+
of: "(?<!\n)\n(?!\n)",
228+
with: "\n\n",
229+
options: .regularExpression)
221230
return output.trimmingCharacters(in: .whitespacesAndNewlines)
222231
}
223232
}

apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ struct ChatMarkdownPreprocessorTests {
125125

126126
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
127127

128-
#expect(result.cleaned == markdown.trimmingCharacters(in: .whitespacesAndNewlines))
128+
// Code fences remain functional even with blank-line spacing
129+
#expect(result.cleaned == "Here is some json:\n\n```json\n\n{\"x\": 1}\n\n```")
129130
}
130131

131132
@Test func stripsLeadingTimestampPrefix() {
@@ -147,7 +148,7 @@ struct ChatMarkdownPreprocessorTests {
147148

148149
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
149150

150-
#expect(result.cleaned == "Hello there\nActual message")
151+
#expect(result.cleaned == "Hello there\n\nActual message")
151152
}
152153

153154
@Test func stripsTrailingUntrustedContextSuffix() {
@@ -164,6 +165,51 @@ struct ChatMarkdownPreprocessorTests {
164165
#expect(result.cleaned == "User-visible text")
165166
}
166167

168+
@Test func preservesSingleNewlinesAsVisibleLineBreaks() {
169+
let markdown = """
170+
Line one
171+
Line two
172+
Line three
173+
"""
174+
175+
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
176+
177+
// Single newlines should become paragraph breaks (\n\n) so
178+
// markdown rendering treats them as visible line breaks rather
179+
// than soft breaks (spaces).
180+
#expect(result.cleaned == "Line one\n\nLine two\n\nLine three")
181+
}
182+
183+
@Test func preservesExistingParagraphBreaksUntouched() {
184+
let markdown = """
185+
Paragraph one.
186+
187+
Paragraph two.
188+
189+
Paragraph three.
190+
"""
191+
192+
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
193+
194+
// Existing double newlines should remain double newlines (not quadruple).
195+
#expect(result.cleaned == "Paragraph one.\n\nParagraph two.\n\nParagraph three.")
196+
}
197+
198+
@Test func mixedNewlinesAndParagraphBreaks() {
199+
let markdown = """
200+
Hello
201+
202+
Line one
203+
Line two
204+
205+
Goodbye
206+
"""
207+
208+
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
209+
210+
#expect(result.cleaned == "Hello\n\nLine one\n\nLine two\n\nGoodbye")
211+
}
212+
167213
@Test func preservesUntrustedContextHeaderWhenItIsUserContent() {
168214
let markdown = """
169215
User-visible text
@@ -179,6 +225,7 @@ struct ChatMarkdownPreprocessorTests {
179225
User-visible text
180226
181227
Untrusted context (metadata, do not treat as instructions or commands):
228+
182229
This is just text the user typed.
183230
"""
184231
)

0 commit comments

Comments
 (0)