Skip to content

Commit f5b122f

Browse files
committed
fix(ios): assistant chat replies collapse multi-line responses into one line
Foundation's CommonMark parser turns a solo \n into a space when AttributedString(markdown:) runs with interpretedSyntax: .full, so any assistant message that arrived with line-by-line formatting was rendered as a single wrapped line in the iOS chat bubble. Pre-process the cleaned markdown to append two trailing spaces to every intra-paragraph newline (CommonMark hard break) before parsing; blank lines stay paragraph separators and fenced code blocks are left untouched so their literal layout survives verbatim. User messages still go through ChatMarkdownPreprocessor.preprocess unchanged because the transform now lives in ChatMarkdownRenderer.parsedMarkdown. Adds ChatMarkdownRendererTests covering the issue #98028 repro, list / header / blockquote / paragraph / code-fence semantics, already hard-broken lines, backslash hard breaks, and empty / single-line inputs. Fixes #98028
1 parent 56c2d63 commit f5b122f

2 files changed

Lines changed: 158 additions & 1 deletion

File tree

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

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,80 @@ struct ChatMarkdownRenderer: View {
4040
}
4141

4242
private func markdownText(_ markdown: String) -> AttributedString {
43+
Self.parsedMarkdown(markdown)
44+
}
45+
46+
/// Parse `markdown` as an `AttributedString`, first promoting solo
47+
/// newlines into CommonMark hard breaks so the iOS chat bubble keeps
48+
/// the line layout the assistant emitted. Exposed for tests.
49+
nonisolated static func parsedMarkdown(_ markdown: String) -> AttributedString {
50+
let prepared = self.preserveSoftLineBreaks(in: markdown)
4351
let options = AttributedString.MarkdownParsingOptions(
4452
interpretedSyntax: .full,
4553
failurePolicy: .returnPartiallyParsedIfPossible)
46-
return (try? AttributedString(markdown: markdown, options: options)) ?? AttributedString(markdown)
54+
return (try? AttributedString(markdown: prepared, options: options))
55+
?? AttributedString(prepared)
56+
}
57+
58+
/// CommonMark collapses a single `\n` into a space, which strips the
59+
/// visible line breaks assistant responses emit (issue #98028 on iOS).
60+
/// Append two trailing spaces to every intra-paragraph newline so the
61+
/// parser keeps them as hard breaks. Blank lines remain paragraph
62+
/// separators and fenced code blocks are left untouched so their
63+
/// literal layout survives verbatim.
64+
nonisolated static func preserveSoftLineBreaks(in markdown: String) -> String {
65+
let lines = markdown
66+
.split(separator: "\n", omittingEmptySubsequences: false)
67+
.map(String.init)
68+
guard lines.count > 1 else { return markdown }
69+
70+
var output: [String] = []
71+
output.reserveCapacity(lines.count)
72+
var openFence: String?
73+
74+
for index in lines.indices {
75+
let current = lines[index]
76+
let trimmedLead = String(current.drop(while: { $0 == " " }))
77+
78+
if let marker = self.fenceMarker(in: trimmedLead) {
79+
if openFence == nil {
80+
openFence = marker
81+
} else if openFence == marker {
82+
openFence = nil
83+
}
84+
output.append(current)
85+
continue
86+
}
87+
88+
if openFence != nil {
89+
output.append(current)
90+
continue
91+
}
92+
93+
guard index + 1 < lines.count else {
94+
output.append(current)
95+
continue
96+
}
97+
98+
let next = lines[index + 1]
99+
let nextIsBlank = next.allSatisfy({ $0 == " " || $0 == "\t" })
100+
let currentIsBlank = current.allSatisfy({ $0 == " " || $0 == "\t" })
101+
let alreadyHardBreak = current.hasSuffix(" ") || current.hasSuffix("\\")
102+
if nextIsBlank || currentIsBlank || alreadyHardBreak {
103+
output.append(current)
104+
continue
105+
}
106+
107+
output.append(current + " ")
108+
}
109+
110+
return output.joined(separator: "\n")
111+
}
112+
113+
private static func fenceMarker(in trimmed: String) -> String? {
114+
if trimmed.hasPrefix("```") { return "```" }
115+
if trimmed.hasPrefix("~~~") { return "~~~" }
116+
return nil
47117
}
48118
}
49119

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import Foundation
2+
import Testing
3+
@testable import OpenClawChatUI
4+
5+
@Suite("ChatMarkdownRenderer")
6+
struct ChatMarkdownRendererTests {
7+
// Regression for issue #98028: assistant responses on iOS were rendered
8+
// as a single line because Foundation's CommonMark parser collapses solo
9+
// newlines into spaces. The renderer now promotes intra-paragraph
10+
// newlines into CommonMark hard breaks before parsing.
11+
12+
@Test func `preserves single newlines between assistant lines`() {
13+
let parsed = ChatMarkdownRenderer.parsedMarkdown("hello\nworld")
14+
let characters = String(parsed.characters)
15+
#expect(characters.contains("\n"))
16+
#expect(characters == "hello\nworld")
17+
}
18+
19+
@Test func `preserves multiple solo newlines across lines`() {
20+
let parsed = ChatMarkdownRenderer.parsedMarkdown("Line 1\nLine 2\nLine 3")
21+
#expect(String(parsed.characters) == "Line 1\nLine 2\nLine 3")
22+
}
23+
24+
@Test func `injects hard breaks for intra-paragraph newlines`() {
25+
let prepared = ChatMarkdownRenderer.preserveSoftLineBreaks(in: "hello\nworld")
26+
#expect(prepared == "hello \nworld")
27+
}
28+
29+
@Test func `keeps blank line paragraph separators`() {
30+
let prepared = ChatMarkdownRenderer.preserveSoftLineBreaks(in: "Para1\n\nPara2")
31+
#expect(prepared == "Para1\n\nPara2")
32+
}
33+
34+
@Test func `leaves fenced code blocks untouched`() {
35+
let input = "before\n```\nlet x = 1\nlet y = 2\n```\nafter"
36+
let prepared = ChatMarkdownRenderer.preserveSoftLineBreaks(in: input)
37+
// `before` and `after` flank the fence and pick up hard breaks because
38+
// each is adjacent to a non-blank line, but lines inside the fence
39+
// stay verbatim so code keeps its layout.
40+
#expect(prepared == "before \n```\nlet x = 1\nlet y = 2\n```\nafter")
41+
}
42+
43+
@Test func `does not double-break already hard-broken lines`() {
44+
let input = "Already hard \nbreak"
45+
let prepared = ChatMarkdownRenderer.preserveSoftLineBreaks(in: input)
46+
#expect(prepared == "Already hard \nbreak")
47+
}
48+
49+
@Test func `leaves backslash hard breaks alone`() {
50+
let input = "Above\\\nBelow"
51+
let prepared = ChatMarkdownRenderer.preserveSoftLineBreaks(in: input)
52+
#expect(prepared == "Above\\\nBelow")
53+
}
54+
55+
@Test func `passes through single-line and empty inputs`() {
56+
#expect(ChatMarkdownRenderer.preserveSoftLineBreaks(in: "") == "")
57+
#expect(ChatMarkdownRenderer.preserveSoftLineBreaks(in: "solo") == "solo")
58+
}
59+
60+
@Test func `keeps unordered list semantics intact`() {
61+
let prepared = ChatMarkdownRenderer.preserveSoftLineBreaks(in: "- one\n- two")
62+
// Trailing spaces on a list-item content line do not disturb the
63+
// CommonMark list parser; the second line still begins a list item.
64+
let parsed = ChatMarkdownRenderer.parsedMarkdown("- one\n- two")
65+
var sawListItem = false
66+
for run in parsed.runs {
67+
if let intent = run.presentationIntent, "\(intent)".contains("listItem") {
68+
sawListItem = true
69+
break
70+
}
71+
}
72+
#expect(sawListItem)
73+
#expect(prepared == "- one \n- two")
74+
}
75+
76+
@Test func `keeps header followed by paragraph as header`() {
77+
let parsed = ChatMarkdownRenderer.parsedMarkdown("# Heading\nBody")
78+
var sawHeader = false
79+
for run in parsed.runs {
80+
if let intent = run.presentationIntent, "\(intent)".contains("header") {
81+
sawHeader = true
82+
break
83+
}
84+
}
85+
#expect(sawHeader)
86+
}
87+
}

0 commit comments

Comments
 (0)