Skip to content

Commit 58e6170

Browse files
authored
Merge f5b122f into 56c2d63
2 parents 56c2d63 + f5b122f commit 58e6170

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)